Support for multiple invoices being managed through a global payment cycle
This commit is contained in:
parent
4aa6e2fb8b
commit
f9698ced68
8 changed files with 245 additions and 29 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue