Big Bang Refactor of Payments. Introduced a new paymentdata singleton that stores data across pages and on disk. Also introduced a suite of functions for handling payments

This commit is contained in:
SimplifiedPrivacy 2026-10-07 13:29:19 -04:00
parent 57774ae697
commit 52b3e1ba94
6 changed files with 228 additions and 52 deletions

View file

@ -1,13 +1,15 @@
from core.services.payment_phase.payment_state import PaymentData
from core.models.BaseProfile import BaseProfile as Profile
from core.models.orm_models.Plan import Plan
from core.models.orm_calls.model_neutral_tools import get_all_for_model
from core.models.orm_calls.location_calls import get_profile_location_data
from core.models.enum_models import ProtocolChoice
from core.models.enum_models import ProtocolChoice, Currency
from core.models.orm_models.Location import Location
from core.errors.logger import logger
from core.models.Result import Result, ResultError
ALL_PROTOCOLS = [e.value for e in ProtocolChoice]
ALL_CURRENCIES = [e.value for e in Currency]
"""
Outputs:
@ -57,16 +59,41 @@ def _get_supported_protocols_for_location(location: Location, as_strings: bool =
return SUPPORTED_PROTOCOLS
def get_all_plans(chosen_protocol: ProtocolChoice) -> Result:
def get_plans_for_profile(profile_id: int) -> Result:
"""
Get a formatted list of all Plans for a given profile.
And save the protocol in the PaymentData singleton.
"""
# Get profile:
profile = Profile.find_by_id(profile_id)
if not profile:
error_msg = f"Invalid profile id of {profile_id}"
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
# Get Protocol for that profile:
chosen_protocol = ProtocolChoice(profile.connection.code)
# create or get the singleton:
billing_data = PaymentData(instance_id=profile_id)
# set it's value across modules:
billing_data.protocol_choice = chosen_protocol
# Return plans for that protocol:
return get_all_plans_by_protocol(chosen_protocol)
def get_all_plans_by_protocol(chosen_protocol: ProtocolChoice) -> Result:
"""Get a formatted list of all Plans for a given protocol."""
all_plans = get_all_for_model(model_type=Plan)
if not all_plans:
error_msg = "There are no plans in your local SQL database. Please sync or make sure there's no SQL errors."
return Result(valid=False, error_type=ResultError.SYNC, message=error_msg)
return Result(valid=False, error_type=ResultError.MISSING_DATA, message=error_msg)
PLANS_TO_RETURN = []
PLANS_TO_RETURN = {}
for each_plan in all_plans:
@ -74,12 +101,7 @@ def get_all_plans(chosen_protocol: ProtocolChoice) -> Result:
if chosen_protocol not in each_plan.supported_protocols:
continue
each_json = {
'id': each_plan.id,
'cost': each_plan.cost,
'months': each_plan.months
}
PLANS_TO_RETURN.append(each_json)
plan_format = str(f"{each_plan.months} months (€{each_plan.cost})")
PLANS_TO_RETURN[plan_format] = each_plan.id
return Result(valid=True, data=PLANS_TO_RETURN)

View file

@ -37,7 +37,6 @@ import tempfile
from sqlalchemy.orm import Session
from enum import Enum
from core.models.manage.session_management import init_session, get_session

View file

@ -20,6 +20,7 @@ class Subscription:
exclude=lambda value: value is None
)
)
billing_pending: bool = False
def get_sanitized_billing_code(self):
return '-'.join(['****'] * 3 + [self.billing_code[-4:]])

View file

@ -65,7 +65,7 @@ def register_wireguard_session(
# print(f"wireguard_configuration value is {wireguard_configuration}")
if isinstance(wireguard_configuration, dict):
logger.error("Server isn't returning a WG config, it's an error.")
logger.error("Server isn't returning a WG config, it's trying to tell you a ServerReply message...")
return handle_config_error(wireguard_configuration)
if wireguard_configuration is None:

View file

@ -37,6 +37,7 @@ def singleton(cls):
# for saving.
EXCLUDED_FIELDS = {"parent_folder", "folder_path", "_data_file"}
class StatePersistenceMixin(ValidatedDataclass):
def _serialize_value(self, value):
"""Helper method to ensure values are JSON-serializable"""
@ -89,6 +90,7 @@ class StatePersistenceMixin(ValidatedDataclass):
# Add it to the dictionary
data[f.name] = clean_value
print(f"saving to {self._data_file}")
with open(self._data_file, "w") as f:
json.dump(data, f, indent=4, sort_keys=True)
@ -122,6 +124,10 @@ class StatePersistenceMixin(ValidatedDataclass):
setattr(self, field.name, value)
def exists_already(self) -> bool:
return os.path.exists(self._data_file)
@singleton
@dataclass
@ -134,7 +140,7 @@ class TicketPaymentData(StatePersistenceMixin):
_data_file: str = None
# actual payload data:
billing_code: str = None
# billing_code: str = None
currency: Currency = None
payment_type: PaymentType = PaymentType.CRYPTO
@ -153,7 +159,7 @@ class TicketPaymentData(StatePersistenceMixin):
@singleton
@dataclass
class PaymentData(StatePersistenceMixin):
PAYLOAD_FIELDS = {"billing_code", "currency", "plan_id", "location_id", "protocol_choice", "payment_type"}
PAYLOAD_FIELDS = {"currency", "plan_id", "location_id", "protocol_choice", "payment_type"}
# metadata on it's own instance:
parent_folder = None
@ -162,11 +168,8 @@ class PaymentData(StatePersistenceMixin):
_data_file: str = None
# actual payload data:
billing_code: str = None
currency: Currency = None
payment_type: PaymentType = PaymentType.CRYPTO
# for traditional plans:
plan_id: int = None
location_id: int = None
protocol_choice: ProtocolChoice = None

View file

@ -1,11 +1,13 @@
from core.models.BaseProfile import BaseProfile as Profile
from core.services.payment_phase.payment_state import PaymentData
from core.services.networking.httpx import connect
from core.models.enum_models import ProtocolChoice, Currency
from core.models.enum_models import ProtocolChoice
from core.models.Result import Result, ResultError
from core.errors.logger import logger
from core.observers.ConnectionObserver import ConnectionObserver
from core.models.pydantic_models.type_checker import validate_model
from core.models.pydantic_models.ServerReply import ServerReply, ServerError, SubscriptionInvoice
from core.models.Subscription import Subscription
from core.Constants import Constants
@ -32,11 +34,10 @@ def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Re
# Layer 2: Server's Reply:
raw_reply = api_response.data
print(f"validating the server reply of {raw_reply}")
server_reply = validate_model(ServerReply, raw_reply)
if not server_reply:
print("invalid structure for ServerReply")
logger.error(f"Invalid structure for ServerReply, which had a raw reply of {raw_reply}")
return invalid_server_format()
# paid
@ -51,68 +52,200 @@ def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Re
return translate_server_failure(server_reply)
def create_invoice(
subscription_plan_id: int,
location_id: int,
protocol_choice: ProtocolChoice,
currency: Currency,
connection_observer: ConnectionObserver
) -> Result:
submit_data = {
"subscription_plan_id": subscription_plan_id,
"location_id": location_id,
"protocol_choice": protocol_choice.value,
"currency": currency.value,
"payment_type": "crypto"
}
def create_invoice(profile_id: int, connection_observer: ConnectionObserver) -> Result:
"""Prepare the payment data payload and send it to the API. Returns an invoice nested inside Result's data"""
# Get profile object:
profile = Profile.find_by_id(profile_id)
if not profile:
error_msg = f"Invalid profile id of {profile_id}"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
#-----------------------------
# MAKE PAYLOAD. PIECE BY PIECE
#-----------------------------
# get the payment data singleton:
billing_data = PaymentData(instance_id=profile_id)
# make sure the protocol is saved in the singleton already,
if not billing_data.protocol_choice:
# If not, then get the Protocol for that profile:
billing_data.protocol_choice = ProtocolChoice(profile.connection.code)
# location
billing_data.location_id = profile.location.id
# plan id
if not billing_data.plan_id:
error_msg = "Missing a plan id to make an invoice"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
# currency
if not billing_data.currency:
error_msg = "Missing a currency to make an invoice"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
# saves it to disk (organized by profile id) & returns a json:
payload = billing_data.prep_payload()
if not payload:
error_msg = "Unable to save the payload, or missing fields. Check the error log."
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.FILE_SYSTEM)
#-----------------------------
# SEND TO API
#-----------------------------
url = f'{Constants.SP_BASE_URL}/v2/start_billing'
print(f"url is {url}")
api_response = connect.single_endpoint(
method="post",
url=url,
observer=connection_observer,
payload=submit_data,
payload=payload,
)
print(f"api_response is {api_response.valid}")
print(f"api_response is {api_response.error_type}")
#-----------------------------
# EVALUATE CONNECTION FAILURE
#-----------------------------
# Layer 1: API failure:
if not api_response.valid:
error_msg = f"API Error: {api_response.error_type} {api_response.message}"
error_msg = f"Connection/500s Error prevented the server from replying: {api_response.error_type} {api_response.message}"
logger.error(error_msg)
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
# Layer 2: Server's Reply:
#-----------------------------
# EVALUATE SERVER-SIDE ISSUES (assuming it replied)
#-----------------------------
# Layer 2: Validity of Server's Reply:
raw_reply = api_response.data
print(f"validating the server reply of {raw_reply}.")
logger.info("Validating the Validity of the Server's Reply..")
server_reply = validate_model(ServerReply, raw_reply)
if not server_reply:
print("invalid structure for ServerReply")
logger.error(f"Invalid Structure for ServerReply! The raw data is {raw_reply}")
return invalid_server_format()
if not server_reply.valid:
print("Server reply was correctly structured, but was a failure..")
logger.error(f"Server reply was correctly structured, but the actual invoice failed to be created. The raw data is {raw_reply}")
# translate by enum error code:
return translate_server_failure(server_reply)
#-----------------------------
# EVALUATE SERVER'S ACTUAL DATA
#-----------------------------
# Layer 3: Payload Data
payload_data = server_reply.data
print(f"validating the final invoice with this data: {payload_data}")
logger.info("Validating the final invoice")
final_invoice = validate_model(SubscriptionInvoice, payload_data)
print(f"final_invoice is {final_invoice}")
print(f"final_invoice type is {type(final_invoice)}")
if not final_invoice:
logger.error(f"Failed to create an invoice object with this data: {payload_data}")
return invalid_server_format()
print("returning True with the data")
return Result(valid=True, data=final_invoice)
def get_profile_subscription(profile_id: int) -> Subscription:
"""Lookup the full profile object & get it's subscription"""
print(f"starting lookup of profile id {profile_id}")
profile = Profile.find_by_id(profile_id)
if not profile:
error_msg = f"Invalid profile id of {profile_id}"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
print(f"returning profile {profile}")
return profile.subscription
def save_billing_code(profile_id: int, billing_code: str, expires_at: str) -> Result:
"""Checks if the billing code is already saved, and if not, saves it."""
logger.info("Saving billing code..")
PRE_REQS = [profile_id, billing_code, expires_at]
if None in PRE_REQS:
error_msg = "Missing critical required data to save!"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.MISSING_DATA)
# Get profile object:
profile = Profile.find_by_id(profile_id)
if not profile:
error_msg = f"Invalid profile id of {profile_id}"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
try:
profile.subscription = Subscription(
billing_code=billing_code,
expires_at=expires_at,
billing_pending=True
)
profile.save()
except Exception as e:
error_msg = f"Legacy system wouldn't create or save Subscription object with {str(billing_code)} and {str(expires_at)} or type {type(expires_at)}, with error: {str(e)}"
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
return Result(valid=True)
# # get the payment data singleton:
# billing_data = PaymentData(instance_id=profile_id)
# # new
# if billing_data.billing_code is None:
# billing_data.billing_code = billing_code
# billing_data.save()
# logger.info("saved billing_code")
# return Result(valid=True)
# # Already exists and is different:
# if regular_billing.billing_code != billing_code:
# error_msg = "Critical Error! Billing codes do NOT match"
# logger.error(error_msg)
# return Result(valid=False, error_type=ResultError.UNKNOWN, message=error_msg)
# # already saved:
# return Result(valid=True, message="Already saved.")
def return_pre_existing_payment_data(profile_id: int, subscription: SubscriptionInvoice) -> Result:
"""Return the pre-existing payment details (or lack of) for a profile"""
logger.info("Starting a fetch of pre-existing payment data")
# singleton:
regular_billing = PaymentData(instance_id=profile_id)
if not regular_billing.exists_already():
error_msg = f"You have a billing id of {subscription.billing_code}, but no related payment data."
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.MISSING_DATA)
# dump data:
logger.info("Payment data already exists for this profile.")
payload = regular_billing.prep_payload
# add on billing code & expires
if payload and isinstance(payload, dict):
payload["billing_code"] = subscription.billing_code
payload["expires_at"] = subscription.expires_at
# form an invoice with Profile data & PaymentData singleton for the gui
subscription = SubscriptionInvoice(**payload)
return Result(valid=True, data=subscription)
else:
error_msg = "Invalid format for the local PaymentData. It expected a dict and the payload produced to not be blank."
logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT)
# Translate Server-side to client-side errors in THIS CONTEXT,
TRANSLATION_DICT = {
ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION,
@ -133,3 +266,21 @@ def translate_server_failure(server_reply: ServerReply) -> Result:
def invalid_server_format() -> Result:
error_msg = "The server gave an invalid response format."
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message=error_msg)
# def create_invoice(
# subscription_plan_id: int,
# location_id: int,
# protocol_choice: ProtocolChoice,
# currency: Currency,
# connection_observer: ConnectionObserver
# ) -> Result:
# submit_data = {
# "subscription_plan_id": subscription_plan_id,
# "location_id": location_id,
# "protocol_choice": protocol_choice.value,
# "currency": currency.value,
# "payment_type": "crypto"
# }