From f9698ced68727454ca354fa675a8ebda76859799 Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Sat, 10 Oct 2026 15:25:36 -0400 Subject: [PATCH] Support for multiple invoices being managed through a global payment cycle --- .../tickets/UseTicketController.py | 23 +- core/infrastructure/plumbing.py | 4 + core/models/Result.py | 1 + core/models/enum_models.py | 5 + core/models/manage/server_reply_tools.py | 2 +- .../check_payment_loop.py | 0 core/services/payment_phase/payment_state.py | 27 ++- .../subscriptions_by_protocol.py | 212 ++++++++++++++++-- 8 files changed, 245 insertions(+), 29 deletions(-) rename core/services/{subscriptions => payment_phase}/check_payment_loop.py (100%) diff --git a/core/controllers/tickets/UseTicketController.py b/core/controllers/tickets/UseTicketController.py index 309afa9..03470fb 100644 --- a/core/controllers/tickets/UseTicketController.py +++ b/core/controllers/tickets/UseTicketController.py @@ -118,20 +118,35 @@ def do_we_use_a_random_ticket(ticket_observer: TicketObserver) -> tuple: return which_ticket, error_msg -def get_unused_tickets(ticket_observer: TicketObserver) -> dict: +def get_unused_tickets(format_for_gui: Optional[bool] = False): + """Get a list of all unused ticket numbers""" + # does the file keeping track of ALL tickets exist: does_the_file_exist, path_of_file = does_ticket_tracker_exist() if does_the_file_exist == False: error_msg = f"The ticket tracker organizer file does not exist. Check the folder {path_of_file}" - # ticket_observer.notify("failed_input", subject=error_msg) - return {"valid": False, "message": error_msg} + return Result(valid=False, error_type=ResultError.MISSING_FILE, message=error_msg) # Use the Model: unused_tickets = get_all_unused_tickets() - + print(f"unused_tickets is {unused_tickets}") return unused_tickets +# if not format_for_gui: +# return Result(valid=True, data=unused_tickets) + +# # format for gui: +# formatted_data = format_for_gui(unused_tickets) +# return Result(valid=True, data=formatted_data) + + +# def format_for_gui(raw_tickets) -> list: +# return [(f"Ticket #{t}", t) for t in raw_tickets] + + + + """ use_ticket function requires: diff --git a/core/infrastructure/plumbing.py b/core/infrastructure/plumbing.py index d88df08..1162752 100644 --- a/core/infrastructure/plumbing.py +++ b/core/infrastructure/plumbing.py @@ -63,12 +63,16 @@ class ThreadPlumbing: result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e)) # Back to main thread + print(f"result in plumbing is {result}") self.ui_dispatcher.run_on_main_thread(self._on_result_ready, result) def _on_result_ready(self, result: Result): """Decide: retry, show popup, or final callback""" if result.valid or self.attempt_count >= MAX_ATTEMPTS: if self.ui_caller: + print("calling back with result") + if not result: + result = Result(valid=False, error_type=ResultError.UNKNOWN, message="Plumbing is missing a result.") self.ui_caller(result) return diff --git a/core/models/Result.py b/core/models/Result.py index aa7bb96..7cdf256 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -19,6 +19,7 @@ class ResultError(Enum): DATABASE = "database" PERMISSION = "permission" SUBSCRIPTION = "subscription" + EXPIRED = "expired" # meant for invoice TICKET = "ticket" ALREADY_EXISTS = "already_exists" BILLING_CODE_EXISTS = "billing_code_exists" diff --git a/core/models/enum_models.py b/core/models/enum_models.py index adc30a1..b1d0749 100644 --- a/core/models/enum_models.py +++ b/core/models/enum_models.py @@ -21,6 +21,11 @@ class PaymentType(Enum): FREE = "free" +class PaymentCategory(Enum): + INDIVIDUAL = "individual" + TICKET = "ticket" + + def is_valid_protocol(potential_candidate: str) -> bool: return potential_candidate in [choice.value for choice in ProtocolChoice] diff --git a/core/models/manage/server_reply_tools.py b/core/models/manage/server_reply_tools.py index 6c0e7a0..f683788 100644 --- a/core/models/manage/server_reply_tools.py +++ b/core/models/manage/server_reply_tools.py @@ -18,7 +18,7 @@ TRANSLATION_DICT = { ServerError.EXPIRED: ResultError.SUBSCRIPTION } -def format_server_response(api_response: ApiResponse, desired_type: str) -> Result: +def format_server_response(api_response: ApiResponse|dict, desired_type: str) -> Result: print("Starting format_server_response conversion") """Turn an API response into a Result Object""" diff --git a/core/services/subscriptions/check_payment_loop.py b/core/services/payment_phase/check_payment_loop.py similarity index 100% rename from core/services/subscriptions/check_payment_loop.py rename to core/services/payment_phase/check_payment_loop.py diff --git a/core/services/payment_phase/payment_state.py b/core/services/payment_phase/payment_state.py index 49bd495..c9694e8 100644 --- a/core/services/payment_phase/payment_state.py +++ b/core/services/payment_phase/payment_state.py @@ -1,6 +1,6 @@ from core.models.manage.validate_dataclass import ValidatedDataclass from core.Constants import Constants -from core.models.enum_models import Currency, ProtocolChoice, PaymentType +from core.models.enum_models import Currency, ProtocolChoice, PaymentType, PaymentCategory from core.errors.logger import logger @@ -9,7 +9,7 @@ import json import os from functools import wraps from enum import Enum - +from typing import Any def singleton(cls): instances = {} @@ -184,3 +184,26 @@ class PaymentData(StatePersistenceMixin): self.load() + +@singleton +@dataclass +class OverallPaymentCycle(StatePersistenceMixin): + """For ALL payment states, this just keeps track of which one.""" + PAYLOAD_FIELDS = {} + + # metadata on it's own GLOBAL instance: + parent_folder = None + folder_path: str = None + _data_file: str = None + + category: PaymentCategory = PaymentCategory.INDIVIDUAL + already_polling: bool = False + profile_id: int = None + instance: Any = None + + def wipe(self): + """Wipe all data and start over.""" + self.category = PaymentCategory.INDIVIDUAL + self.already_polling = False + self.profile_id = None + self.instance = None \ No newline at end of file diff --git a/core/services/subscriptions/subscriptions_by_protocol.py b/core/services/subscriptions/subscriptions_by_protocol.py index 18083d7..dd83784 100644 --- a/core/services/subscriptions/subscriptions_by_protocol.py +++ b/core/services/subscriptions/subscriptions_by_protocol.py @@ -1,16 +1,117 @@ from core.models.BaseProfile import BaseProfile as Profile -from core.services.payment_phase.payment_state import PaymentData +from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData from core.services.networking.httpx import connect -from core.models.enum_models import ProtocolChoice +from core.models.enum_models import ProtocolChoice, PaymentCategory from core.models.Result import Result, ResultError +from core.models.manage.server_reply_tools import translate_server_failure, invalid_server_format 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.pydantic_models.ServerReply import ServerReply, SubscriptionInvoice from core.models.Subscription import Subscription from core.Constants import Constants from datetime import datetime +from typing import Optional + +HOW_MANY_PROFILES_DEFAULT = 6 + + +def paying_for_tickets() -> bool: + """Returns boolean for if the current billing is tickets""" + + payment_cycle = OverallPaymentCycle() + + if not payment_cycle: + return False + + category = payment_cycle.category + + if category == PaymentCategory.TICKET: + return True + else: + return False + + +def get_current_billing_data() -> Result: + """ + Goal: + Get whatever current billing data is relevant by the overall cycle, for either billing category + + Note: + The reason it's broken into a cycle and the actual data, is to keep track of if it's individual or tickets. + And to make sure that only one set of data is polling at once. + """ + # Is there currently a process? + payment_cycle = OverallPaymentCycle() + + if not payment_cycle: + error_msg = "There is no current billing cycle." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + + billing_data = payment_cycle.instance + + if not billing_data: + error_msg = "There is no current billing data." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + + return Result(valid=True, data=billing_data) + + +def wipe_billing_cycle(): + """Wipes the entire overall cycle""" + logger.info("Wiping the overall cycle, so this cycle no longer blocks other new ones") + payment_cycle = OverallPaymentCycle() + if payment_cycle: + payment_cycle.wipe() + + +def start_billing_state(category: PaymentCategory, profile_id: Optional[int], wipe_existing: Optional[bool] = False) -> Result: + """Start a new billing process. Wipe the existing one. And never save these 'overall' states to disk.""" + + if not category: + error_msg = "Must pick a payment category to start a billing cycle." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + + if category == PaymentCategory.INDIVIDUAL and not profile_id: + error_msg = "Starting an Individual Profile Billing requires a profile ID." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + + # Is there currently a process? + payment_cycle = OverallPaymentCycle() + + # WIPE IF REQUESTED: + if wipe_existing and payment_cycle: + payment_cycle.wipe() + + if payment_cycle and payment_cycle.already_polling: + error_msg = "Please stop the payment checking already going on before starting another." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + + if payment_cycle: + logger.info("Wiping the current payment state already saved.") + + payment_cycle.category = category + + if category == PaymentCategory.INDIVIDUAL: + payment_cycle.profile_id = profile_id + billing_data = PaymentData(instance_id=profile_id) + payment_cycle.instance = billing_data + return Result(valid=True, data=billing_data) + + elif category == PaymentCategory.TICKET: + billing_data = TicketPaymentData( + which_key=None, + how_many_profiles=HOW_MANY_PROFILES_DEFAULT, + payment_type=None + ) + payment_cycle.instance = billing_data + return Result(valid=True, data=billing_data) + + else: + error_msg = "This is a currently unsupported payment category." + return Result(valid=False, error_type=ResultError.INVALID, message=error_msg) + def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Result: """Evaluates Payment Status from the Server""" @@ -52,7 +153,67 @@ def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Re return translate_server_failure(server_reply) -def create_invoice(profile_id: int, connection_observer: ConnectionObserver) -> Result: +def return_pre_existing_invoice(billing_code: str, connection_observer: ConnectionObserver) -> Result: + """This billing_code already got an invoice, but needs to fetch that data from the server again.""" + + payload = { + "billing_code": billing_code + } + + #----------------------------- + # SEND TO API + #----------------------------- + url = f'{Constants.SP_BASE_URL}/v2/get_invoice' + + api_response = connect.single_endpoint( + method="post", + url=url, + observer=connection_observer, + payload=payload, + ) + + #----------------------------- + # EVALUATE CONNECTION FAILURE + #----------------------------- + # Layer 1: API failure: + if not api_response.valid: + 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) + + #----------------------------- + # EVALUATE SERVER-SIDE ISSUES (assuming it replied) + #----------------------------- + # Layer 2: Validity of Server's Reply: + raw_reply = api_response.data + logger.info("Validating the Validity of the Server's Reply..") + server_reply = validate_model(ServerReply, raw_reply) + + if not server_reply: + logger.error(f"Invalid Structure for ServerReply! The raw data is {raw_reply}") + return invalid_server_format() + + if not server_reply.valid: + 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 + logger.info("Validating the final invoice") + final_invoice = validate_model(SubscriptionInvoice, payload_data) + + if not final_invoice: + logger.error(f"Failed to create an invoice object with this data: {payload_data}") + return invalid_server_format() + + return Result(valid=True, data=final_invoice) + + +def create_individual_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: @@ -62,6 +223,13 @@ def create_invoice(profile_id: int, connection_observer: ConnectionObserver) -> logger.error(error_msg) return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT) + # already has billing id: + if profile.subscription: + billing_code = profile.subscription.billing_code + if billing_code: + logger.info("We already have a subscription id for this profile.") + return return_pre_existing_invoice(billing_code=billing_code, connection_observer=connection_observer) + #----------------------------- # MAKE PAYLOAD. PIECE BY PIECE #----------------------------- @@ -262,27 +430,27 @@ def return_pre_existing_payment_data(profile_id: int, subscription: Subscription 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, +# ServerError.NODE_ERROR: ResultError.WRONG_LOCATION, +# ServerError.BTCPAY: ResultError.SERVER_SIDE, +# ServerError.INVALID: ResultError.INVALID_INPUT, +# ServerError.MISSING_DATA: ResultError.INVALID_INPUT, +# ServerError.UNPAID: ResultError.UNPAID, +# ServerError.EXPIRED: ResultError.EXPIRED +# } -# Translate Server-side to client-side errors in THIS CONTEXT, -TRANSLATION_DICT = { - ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION, - ServerError.NODE_ERROR: ResultError.WRONG_LOCATION, - ServerError.BTCPAY: ResultError.SERVER_SIDE, - ServerError.INVALID: ResultError.INVALID_INPUT, - ServerError.MISSING_DATA: ResultError.INVALID_INPUT, - ServerError.UNPAID: ResultError.UNPAID -} - -def translate_server_failure(server_reply: ServerReply) -> Result: - """Translate the server's failure to the UI""" - logger.error(f"Server-side failure occured. The server claims the reason is of type {server_reply.error_type} with {server_reply.message}") - client_error = TRANSLATION_DICT[server_reply.error_type] - return Result(valid=False, error_type=client_error, message=server_reply.message) +# def translate_server_failure(server_reply: ServerReply) -> Result: +# """Translate the server's failure to the UI""" +# logger.error(f"Server-side failure occured. The server claims the reason is of type {server_reply.error_type} with {server_reply.message}") +# client_error = TRANSLATION_DICT[server_reply.error_type] +# return Result(valid=False, error_type=client_error, message=server_reply.message) -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 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)