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_models.Plan import Plan
from core.models.orm_calls.model_neutral_tools import get_all_for_model 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.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.models.orm_models.Location import Location
from core.errors.logger import logger from core.errors.logger import logger
from core.models.Result import Result, ResultError from core.models.Result import Result, ResultError
ALL_PROTOCOLS = [e.value for e in ProtocolChoice] ALL_PROTOCOLS = [e.value for e in ProtocolChoice]
ALL_CURRENCIES = [e.value for e in Currency]
""" """
Outputs: Outputs:
@ -57,16 +59,41 @@ def _get_supported_protocols_for_location(location: Location, as_strings: bool =
return SUPPORTED_PROTOCOLS 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.""" """Get a formatted list of all Plans for a given protocol."""
all_plans = get_all_for_model(model_type=Plan) all_plans = get_all_for_model(model_type=Plan)
if not all_plans: 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." 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: 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: if chosen_protocol not in each_plan.supported_protocols:
continue continue
each_json = { plan_format = str(f"{each_plan.months} months (€{each_plan.cost})")
'id': each_plan.id, PLANS_TO_RETURN[plan_format] = each_plan.id
'cost': each_plan.cost,
'months': each_plan.months
}
PLANS_TO_RETURN.append(each_json)
return Result(valid=True, data=PLANS_TO_RETURN) return Result(valid=True, data=PLANS_TO_RETURN)

View file

@ -37,7 +37,6 @@ import tempfile
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from enum import Enum from enum import Enum
from core.models.manage.session_management import init_session, get_session 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 exclude=lambda value: value is None
) )
) )
billing_pending: bool = False
def get_sanitized_billing_code(self): def get_sanitized_billing_code(self):
return '-'.join(['****'] * 3 + [self.billing_code[-4:]]) 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}") # print(f"wireguard_configuration value is {wireguard_configuration}")
if isinstance(wireguard_configuration, dict): 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) return handle_config_error(wireguard_configuration)
if wireguard_configuration is None: if wireguard_configuration is None:

View file

@ -37,6 +37,7 @@ def singleton(cls):
# for saving. # for saving.
EXCLUDED_FIELDS = {"parent_folder", "folder_path", "_data_file"} EXCLUDED_FIELDS = {"parent_folder", "folder_path", "_data_file"}
class StatePersistenceMixin(ValidatedDataclass): class StatePersistenceMixin(ValidatedDataclass):
def _serialize_value(self, value): def _serialize_value(self, value):
"""Helper method to ensure values are JSON-serializable""" """Helper method to ensure values are JSON-serializable"""
@ -89,6 +90,7 @@ class StatePersistenceMixin(ValidatedDataclass):
# Add it to the dictionary # Add it to the dictionary
data[f.name] = clean_value data[f.name] = clean_value
print(f"saving to {self._data_file}")
with open(self._data_file, "w") as f: with open(self._data_file, "w") as f:
json.dump(data, f, indent=4, sort_keys=True) json.dump(data, f, indent=4, sort_keys=True)
@ -122,6 +124,10 @@ class StatePersistenceMixin(ValidatedDataclass):
setattr(self, field.name, value) setattr(self, field.name, value)
def exists_already(self) -> bool:
return os.path.exists(self._data_file)
@singleton @singleton
@dataclass @dataclass
@ -134,7 +140,7 @@ class TicketPaymentData(StatePersistenceMixin):
_data_file: str = None _data_file: str = None
# actual payload data: # actual payload data:
billing_code: str = None # billing_code: str = None
currency: Currency = None currency: Currency = None
payment_type: PaymentType = PaymentType.CRYPTO payment_type: PaymentType = PaymentType.CRYPTO
@ -153,7 +159,7 @@ class TicketPaymentData(StatePersistenceMixin):
@singleton @singleton
@dataclass @dataclass
class PaymentData(StatePersistenceMixin): 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: # metadata on it's own instance:
parent_folder = None parent_folder = None
@ -162,11 +168,8 @@ class PaymentData(StatePersistenceMixin):
_data_file: str = None _data_file: str = None
# actual payload data: # actual payload data:
billing_code: str = None
currency: Currency = None currency: Currency = None
payment_type: PaymentType = PaymentType.CRYPTO payment_type: PaymentType = PaymentType.CRYPTO
# for traditional plans:
plan_id: int = None plan_id: int = None
location_id: int = None location_id: int = None
protocol_choice: ProtocolChoice = 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.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.models.Result import Result, ResultError
from core.errors.logger import logger from core.errors.logger import logger
from core.observers.ConnectionObserver import ConnectionObserver from core.observers.ConnectionObserver import ConnectionObserver
from core.models.pydantic_models.type_checker import validate_model from core.models.pydantic_models.type_checker import validate_model
from core.models.pydantic_models.ServerReply import ServerReply, ServerError, SubscriptionInvoice from core.models.pydantic_models.ServerReply import ServerReply, ServerError, SubscriptionInvoice
from core.models.Subscription import Subscription
from core.Constants import Constants from core.Constants import Constants
@ -32,11 +34,10 @@ def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Re
# Layer 2: Server's Reply: # Layer 2: Server's Reply:
raw_reply = api_response.data raw_reply = api_response.data
print(f"validating the server reply of {raw_reply}")
server_reply = validate_model(ServerReply, raw_reply) server_reply = validate_model(ServerReply, raw_reply)
if not server_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() return invalid_server_format()
# paid # paid
@ -51,68 +52,200 @@ def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Re
return translate_server_failure(server_reply) return translate_server_failure(server_reply)
def create_invoice( def create_invoice(profile_id: int, connection_observer: ConnectionObserver) -> Result:
subscription_plan_id: int, """Prepare the payment data payload and send it to the API. Returns an invoice nested inside Result's data"""
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"
}
# 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' url = f'{Constants.SP_BASE_URL}/v2/start_billing'
print(f"url is {url}")
api_response = connect.single_endpoint( api_response = connect.single_endpoint(
method="post", method="post",
url=url, url=url,
observer=connection_observer, 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: # Layer 1: API failure:
if not api_response.valid: 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) logger.error(error_msg)
return Result(valid=False, error_type=ResultError.CONNECTION, message=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 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) server_reply = validate_model(ServerReply, raw_reply)
if not server_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() return invalid_server_format()
if not server_reply.valid: 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) return translate_server_failure(server_reply)
#-----------------------------
# EVALUATE SERVER'S ACTUAL DATA
#-----------------------------
# Layer 3: Payload Data # Layer 3: Payload Data
payload_data = server_reply.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) 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: if not final_invoice:
logger.error(f"Failed to create an invoice object with this data: {payload_data}")
return invalid_server_format() return invalid_server_format()
print("returning True with the data")
return Result(valid=True, data=final_invoice) 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, # Translate Server-side to client-side errors in THIS CONTEXT,
TRANSLATION_DICT = { TRANSLATION_DICT = {
ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION, ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION,
@ -133,3 +266,21 @@ def translate_server_failure(server_reply: ServerReply) -> Result:
def invalid_server_format() -> Result: def invalid_server_format() -> Result:
error_msg = "The server gave an invalid response format." error_msg = "The server gave an invalid response format."
return Result(valid=False, error_type=ResultError.INVALID_API_REPLY, message=error_msg) 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"
# }