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
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:

View file

@ -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

View file

@ -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"

View file

@ -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]

View file

@ -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"""

View file

@ -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

View file

@ -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)