Support for multiple invoices being managed through a global payment cycle

This commit is contained in:
SimplifiedPrivacy 2026-10-10 15:25:36 -04:00
parent 4aa6e2fb8b
commit f9698ced68
8 changed files with 245 additions and 29 deletions

View file

@ -118,20 +118,35 @@ def do_we_use_a_random_ticket(ticket_observer: TicketObserver) -> tuple:
return which_ticket, error_msg 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 keeping track of ALL tickets exist:
does_the_file_exist, path_of_file = does_ticket_tracker_exist() does_the_file_exist, path_of_file = does_ticket_tracker_exist()
if does_the_file_exist == False: if does_the_file_exist == False:
error_msg = f"The ticket tracker organizer file does not exist. Check the folder {path_of_file}" 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 Result(valid=False, error_type=ResultError.MISSING_FILE, message=error_msg)
return {"valid": False, "message": error_msg}
# Use the Model: # Use the Model:
unused_tickets = get_all_unused_tickets() unused_tickets = get_all_unused_tickets()
print(f"unused_tickets is {unused_tickets}")
return 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: use_ticket function requires:

View file

@ -63,12 +63,16 @@ class ThreadPlumbing:
result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e)) result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e))
# Back to main thread # Back to main thread
print(f"result in plumbing is {result}")
self.ui_dispatcher.run_on_main_thread(self._on_result_ready, result) self.ui_dispatcher.run_on_main_thread(self._on_result_ready, result)
def _on_result_ready(self, result: Result): def _on_result_ready(self, result: Result):
"""Decide: retry, show popup, or final callback""" """Decide: retry, show popup, or final callback"""
if result.valid or self.attempt_count >= MAX_ATTEMPTS: if result.valid or self.attempt_count >= MAX_ATTEMPTS:
if self.ui_caller: 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) self.ui_caller(result)
return return

View file

@ -19,6 +19,7 @@ class ResultError(Enum):
DATABASE = "database" DATABASE = "database"
PERMISSION = "permission" PERMISSION = "permission"
SUBSCRIPTION = "subscription" SUBSCRIPTION = "subscription"
EXPIRED = "expired" # meant for invoice
TICKET = "ticket" TICKET = "ticket"
ALREADY_EXISTS = "already_exists" ALREADY_EXISTS = "already_exists"
BILLING_CODE_EXISTS = "billing_code_exists" BILLING_CODE_EXISTS = "billing_code_exists"

View file

@ -21,6 +21,11 @@ class PaymentType(Enum):
FREE = "free" FREE = "free"
class PaymentCategory(Enum):
INDIVIDUAL = "individual"
TICKET = "ticket"
def is_valid_protocol(potential_candidate: str) -> bool: def is_valid_protocol(potential_candidate: str) -> bool:
return potential_candidate in [choice.value for choice in ProtocolChoice] return potential_candidate in [choice.value for choice in ProtocolChoice]

View file

@ -18,7 +18,7 @@ TRANSLATION_DICT = {
ServerError.EXPIRED: ResultError.SUBSCRIPTION 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") print("Starting format_server_response conversion")
"""Turn an API response into a Result Object""" """Turn an API response into a Result Object"""

View file

@ -1,6 +1,6 @@
from core.models.manage.validate_dataclass import ValidatedDataclass from core.models.manage.validate_dataclass import ValidatedDataclass
from core.Constants import Constants 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 from core.errors.logger import logger
@ -9,7 +9,7 @@ import json
import os import os
from functools import wraps from functools import wraps
from enum import Enum from enum import Enum
from typing import Any
def singleton(cls): def singleton(cls):
instances = {} instances = {}
@ -184,3 +184,26 @@ class PaymentData(StatePersistenceMixin):
self.load() 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

View file

@ -1,16 +1,117 @@
from core.models.BaseProfile import BaseProfile as Profile 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.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.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.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, SubscriptionInvoice
from core.models.Subscription import Subscription from core.models.Subscription import Subscription
from core.Constants import Constants from core.Constants import Constants
from datetime import datetime 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: def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Result:
"""Evaluates Payment Status from the Server""" """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) 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""" """Prepare the payment data payload and send it to the API. Returns an invoice nested inside Result's data"""
# Get profile object: # Get profile object:
@ -62,6 +223,13 @@ def create_invoice(profile_id: int, connection_observer: ConnectionObserver) ->
logger.error(error_msg) logger.error(error_msg)
return Result(valid=False, message=error_msg, error_type=ResultError.INVALID_INPUT) 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 # 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) 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, # def translate_server_failure(server_reply: ServerReply) -> Result:
TRANSLATION_DICT = { # """Translate the server's failure to the UI"""
ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION, # logger.error(f"Server-side failure occured. The server claims the reason is of type {server_reply.error_type} with {server_reply.message}")
ServerError.NODE_ERROR: ResultError.WRONG_LOCATION, # client_error = TRANSLATION_DICT[server_reply.error_type]
ServerError.BTCPAY: ResultError.SERVER_SIDE, # return Result(valid=False, error_type=client_error, message=server_reply.message)
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 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)