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
|
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:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue