From 52b3e1ba9453e723f1f87cf6dd87a017f6e59937 Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Wed, 7 Oct 2026 13:29:19 -0400 Subject: [PATCH] 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 --- .../controllers/profile_state/data_filters.py | 46 +++- core/models/BaseProfile.py | 1 - core/models/Subscription.py | 1 + .../keys_and_verifications/wireguard_keys.py | 2 +- core/services/payment_phase/payment_state.py | 13 +- .../subscriptions_by_protocol.py | 217 +++++++++++++++--- 6 files changed, 228 insertions(+), 52 deletions(-) diff --git a/core/controllers/profile_state/data_filters.py b/core/controllers/profile_state/data_filters.py index 8047f54..67ef36d 100644 --- a/core/controllers/profile_state/data_filters.py +++ b/core/controllers/profile_state/data_filters.py @@ -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) diff --git a/core/models/BaseProfile.py b/core/models/BaseProfile.py index cba024e..19e71f4 100644 --- a/core/models/BaseProfile.py +++ b/core/models/BaseProfile.py @@ -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 diff --git a/core/models/Subscription.py b/core/models/Subscription.py index 5211690..94cde6f 100644 --- a/core/models/Subscription.py +++ b/core/models/Subscription.py @@ -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:]]) diff --git a/core/services/keys_and_verifications/wireguard_keys.py b/core/services/keys_and_verifications/wireguard_keys.py index f5e29c6..cba8687 100644 --- a/core/services/keys_and_verifications/wireguard_keys.py +++ b/core/services/keys_and_verifications/wireguard_keys.py @@ -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: diff --git a/core/services/payment_phase/payment_state.py b/core/services/payment_phase/payment_state.py index dcd3a93..49bd495 100644 --- a/core/services/payment_phase/payment_state.py +++ b/core/services/payment_phase/payment_state.py @@ -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 diff --git a/core/services/subscriptions/subscriptions_by_protocol.py b/core/services/subscriptions/subscriptions_by_protocol.py index 85d7283..52eb75e 100644 --- a/core/services/subscriptions/subscriptions_by_protocol.py +++ b/core/services/subscriptions/subscriptions_by_protocol.py @@ -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" +# } +