Prepartion of univerisal cross-platform plumbing. And introduction of v2 billing/invoices
This commit is contained in:
parent
a34121d3c6
commit
77c7123d73
17 changed files with 796 additions and 14 deletions
|
|
@ -22,6 +22,8 @@ class Constants:
|
||||||
)
|
)
|
||||||
|
|
||||||
SP_API_BASE_URL: Final[str] = os.environ.get('SP_API_BASE_URL', 'https://api.hydraveil.net/api/v1')
|
SP_API_BASE_URL: Final[str] = os.environ.get('SP_API_BASE_URL', 'https://api.hydraveil.net/api/v1')
|
||||||
|
SP_BASE_URL: Final[str] = os.environ.get('SP_BASE_URL', 'https://api.hydraveil.net/api')
|
||||||
|
|
||||||
PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health')
|
PING_URL: Final[str] = os.environ.get('PING_URL', 'https://api.hydraveil.net/api/v1/health')
|
||||||
|
|
||||||
CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5'))
|
CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5'))
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,15 @@ from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from core.essentials.observers.ConnectionObserver import ConnectionObserver
|
from core.essentials.observers.ConnectionObserver import ConnectionObserver
|
||||||
|
# from core.observers.BaseObserver import BaseObserver
|
||||||
|
|
||||||
from core.observers.TicketObserver import TicketObserver
|
from core.observers.TicketObserver import TicketObserver
|
||||||
#
|
from core.utils.basic_operations.write_or_read_from_json import update_json
|
||||||
|
|
||||||
from core.models.invoice.TicketInvoice import TicketInvoice
|
from core.models.invoice.TicketInvoice import TicketInvoice
|
||||||
from core.services.prepare_tickets.get_pub_key import get_pub_key
|
from core.services.prepare_tickets.get_pub_key import get_pub_key
|
||||||
from core.observers.BaseObserver import BaseObserver
|
|
||||||
from core.services.payment_phase.save_and_send_intitial_billing import save_and_send_intitial_billing
|
from core.services.payment_phase.save_and_send_intitial_billing import save_and_send_intitial_billing
|
||||||
from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
# from core.services.networking.api_requests.ApiResponseModel import ApiResponse, ErrorType
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
|
@ -20,10 +22,12 @@ from core.services.networking.httpx import connect
|
||||||
|
|
||||||
from core.services.networking.make_url import make_url
|
from core.services.networking.make_url import make_url
|
||||||
from core.services.helpers.valid_profile_quantity import valid_profile_quantity
|
from core.services.helpers.valid_profile_quantity import valid_profile_quantity
|
||||||
from core.errors.exceptions import *
|
# from core.controllers.tickets.TicketSyncController import sync_ticket_prices
|
||||||
from core.controllers.tickets.TicketSyncController import sync_ticket_prices
|
|
||||||
|
|
||||||
from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
|
from core.services.payment_phase.ticket_config_tools import do_we_have_billing_id
|
||||||
|
from core.Constants import Constants
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Inputs: Which plan (key), which crypto, and how many profiles
|
Inputs: Which plan (key), which crypto, and how many profiles
|
||||||
|
|
@ -39,7 +43,7 @@ def initiate_payment(
|
||||||
ticket_observer: TicketObserver,
|
ticket_observer: TicketObserver,
|
||||||
connection_observer: ConnectionObserver,
|
connection_observer: ConnectionObserver,
|
||||||
bypass_existing: bool,
|
bypass_existing: bool,
|
||||||
) -> TicketInvoice:
|
) -> Result:
|
||||||
###############
|
###############
|
||||||
|
|
||||||
if bypass_existing == False:
|
if bypass_existing == False:
|
||||||
|
|
@ -82,7 +86,7 @@ def initiate_payment(
|
||||||
public_key_results = get_pub_key(which_key, connection_observer, "local")
|
public_key_results = get_pub_key(which_key, connection_observer, "local")
|
||||||
|
|
||||||
if not isinstance(public_key_results, dict):
|
if not isinstance(public_key_results, dict):
|
||||||
notification = f"Invalid Key chosen or the server is down for that key."
|
notification = "Invalid Key chosen or the server is down for that key."
|
||||||
logger.error(notification)
|
logger.error(notification)
|
||||||
ticket_observer.notify("failed_input", subject=notification)
|
ticket_observer.notify("failed_input", subject=notification)
|
||||||
# invoice_data_object.add_error_code("no_pub_key")
|
# invoice_data_object.add_error_code("no_pub_key")
|
||||||
|
|
@ -98,7 +102,7 @@ def initiate_payment(
|
||||||
notification = f"Connection Issues or Invalid Key chosen. {error_code}"
|
notification = f"Connection Issues or Invalid Key chosen. {error_code}"
|
||||||
logger.error(notification)
|
logger.error(notification)
|
||||||
ticket_observer.notify("failed_input", subject=notification)
|
ticket_observer.notify("failed_input", subject=notification)
|
||||||
return Result(valid=False, error_type=ResultError.CONNECTION, message="There were connection issues with getting the right key.")
|
return Result(valid=False, error_type=ResultError.CONNECTION, message=f"There were connection issues with getting the right key. {message}")
|
||||||
|
|
||||||
# In this case, the controller is going to send the whole JSON payload to the service,
|
# In this case, the controller is going to send the whole JSON payload to the service,
|
||||||
# instead of passing 4 values seperately.
|
# instead of passing 4 values seperately.
|
||||||
|
|
@ -130,14 +134,16 @@ def initiate_payment(
|
||||||
|
|
||||||
def check_if_paid(
|
def check_if_paid(
|
||||||
temp_billing_code: str,
|
temp_billing_code: str,
|
||||||
ticket_observer: TicketObserver,
|
|
||||||
connection_observer: ConnectionObserver,
|
connection_observer: ConnectionObserver,
|
||||||
|
ticket_observer: Optional[TicketObserver] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
"""Legacy version being kept in only temporarily."""
|
||||||
|
|
||||||
rejected_reasons = [None, "", False]
|
rejected_reasons = [None, "", False]
|
||||||
if temp_billing_code in rejected_reasons:
|
if temp_billing_code in rejected_reasons:
|
||||||
error_msg = "Invalid Temp Billing Code"
|
error_msg = "Invalid Temp Billing Code"
|
||||||
logger.error(f"{error_msg} inside the check_if_paid function", exc_info=True)
|
logger.error(f"{error_msg} inside the check_if_paid function", exc_info=True)
|
||||||
|
if ticket_observer:
|
||||||
ticket_observer.notify("failed_input", subject=error_msg)
|
ticket_observer.notify("failed_input", subject=error_msg)
|
||||||
return {"valid": False, "error_code": "rejected"}
|
return {"valid": False, "error_code": "rejected"}
|
||||||
else:
|
else:
|
||||||
|
|
@ -167,3 +173,62 @@ def check_if_paid(
|
||||||
logger.debug(f"[TICKET PayController] We have a valid reply from the API inside ticketpay controller of {reply_dict}")
|
logger.debug(f"[TICKET PayController] We have a valid reply from the API inside ticketpay controller of {reply_dict}")
|
||||||
return reply_dict
|
return reply_dict
|
||||||
|
|
||||||
|
|
||||||
|
def check_if_ticket_paid(
|
||||||
|
temp_billing_code: str,
|
||||||
|
connection_observer: ConnectionObserver,
|
||||||
|
) -> dict:
|
||||||
|
"""Coordinate a request with the ticket billing code, to check if an invoice is paid"""
|
||||||
|
|
||||||
|
billing_folder = Constants.HV_TICKETING_CONFIG_HOME
|
||||||
|
filepath = f"{billing_folder}/billing_choices.json"
|
||||||
|
|
||||||
|
rejected_reasons = [None, "", False]
|
||||||
|
if temp_billing_code in rejected_reasons:
|
||||||
|
error_msg = "Invalid Temp Billing Code"
|
||||||
|
logger.error(f"{error_msg} inside the check_if_paid function")
|
||||||
|
return Result(valid=False, error_type=ResultError.SUBSCRIPTION, message=error_msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
|
||||||
|
# prep the JSON payload:
|
||||||
|
payload = {"temp_billing_code": temp_billing_code}
|
||||||
|
|
||||||
|
# prep endpoint:
|
||||||
|
which_endpoint = "check_paid"
|
||||||
|
url = make_url(which_endpoint)
|
||||||
|
|
||||||
|
# literally send:
|
||||||
|
api_reply_object = connect.single_endpoint(
|
||||||
|
method="post",
|
||||||
|
url=url,
|
||||||
|
observer=connection_observer,
|
||||||
|
payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
if not api_reply_object.valid:
|
||||||
|
error_msg = f"Connection/API Error: {api_reply_object.message}"
|
||||||
|
logger.error(f"[TICKET PayController] 2nd Post Request inside ticketpay controller had a {error_msg}")
|
||||||
|
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||||
|
|
||||||
|
# return the payload with GUI/CLI to interpret results:
|
||||||
|
reply_dict = api_reply_object.data
|
||||||
|
logger.debug(f"[TICKET PayController] We have a valid reply from the API inside ticketpay controller of {reply_dict}")
|
||||||
|
print(f"reply_dict is {reply_dict}")
|
||||||
|
if isinstance(reply_dict, dict):
|
||||||
|
payment_status = reply_dict.get('payment_status', None)
|
||||||
|
message = reply_dict.get('message', None)
|
||||||
|
else:
|
||||||
|
payment_status = str(reply_dict)
|
||||||
|
|
||||||
|
if payment_status == "paid":
|
||||||
|
saved = update_json(filepath=filepath, key_to_add="paid", value_to_update="paid")
|
||||||
|
if saved:
|
||||||
|
return Result(valid=True, data=payment_status)
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.FILE_SYSTEM, message="You paid, but it was unable to save that to the config")
|
||||||
|
|
||||||
|
elif message == "bad_code":
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message="This is not a valid subscription code to check.")
|
||||||
|
|
||||||
|
return Result(valid=False, error_type=ResultError.UNPAID, message="Not yet paid")
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from core.services.prepare_tickets.ticket_tracker import get_all_unused_tickets
|
||||||
from core.models.session.SessionProfile import SessionProfile
|
from core.models.session.SessionProfile import SessionProfile
|
||||||
from core.models.system.SystemProfile import SystemProfile
|
from core.models.system.SystemProfile import SystemProfile
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
|
from core.models.enum_models import ProtocolChoice
|
||||||
|
|
||||||
# generic
|
# generic
|
||||||
import random
|
import random
|
||||||
|
|
@ -144,11 +145,16 @@ use_ticket function requires:
|
||||||
def use_ticket(
|
def use_ticket(
|
||||||
which_ticket: int,
|
which_ticket: int,
|
||||||
which_location: int, # Location.id
|
which_location: int, # Location.id
|
||||||
|
protocol_choice: ProtocolChoice,
|
||||||
ticket_observer: TicketObserver,
|
ticket_observer: TicketObserver,
|
||||||
connection_observer: ConnectionObserver,
|
connection_observer: ConnectionObserver,
|
||||||
profile: Optional[Union[SessionProfile, SystemProfile]] = None, # only for assassin or single profiles.
|
profile: Optional[Union[SessionProfile, SystemProfile]] = None, # only for assassin or single profiles.
|
||||||
) -> dict:
|
) -> dict:
|
||||||
|
|
||||||
|
if not is_valid_protocol(protocol_choice):
|
||||||
|
error_msg = f"Invalid Protocol Choice of {protocol_choice}."
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
which_ticket = str(which_ticket) # type: ignore
|
which_ticket = str(which_ticket) # type: ignore
|
||||||
|
|
||||||
# does the ticket's file exist:
|
# does the ticket's file exist:
|
||||||
|
|
@ -167,7 +173,7 @@ def use_ticket(
|
||||||
|
|
||||||
# the actual work here, everything else is just handling:
|
# the actual work here, everything else is just handling:
|
||||||
ticket_observer.notify("connecting", "Connecting..")
|
ticket_observer.notify("connecting", "Connecting..")
|
||||||
reply_object = use_ticket_orchestrator(which_ticket, which_location, connection_observer)
|
reply_object = use_ticket_orchestrator(which_ticket, which_location, protocol_choice, connection_observer)
|
||||||
|
|
||||||
return reply_object
|
return reply_object
|
||||||
|
|
||||||
|
|
|
||||||
28
core/infrastructure/action_registry.py
Normal file
28
core/infrastructure/action_registry.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
from core.controllers.SyncController import new_sync
|
||||||
|
|
||||||
|
|
||||||
|
def external_logic_handler(navigator, user_action):
|
||||||
|
if user_action == "go_to_settings":
|
||||||
|
# Use the signal to request navigation
|
||||||
|
navigator.request_navigation.emit("settings")
|
||||||
|
|
||||||
|
def test() -> Result:
|
||||||
|
print("test")
|
||||||
|
return Result(valid=True)
|
||||||
|
|
||||||
|
ENUM_MAP = {
|
||||||
|
ResultError.UNKNOWN: (test),
|
||||||
|
ResultError.NEED_SYNC: (new_sync),
|
||||||
|
ResultError.INVALID_API_REPLY: None,
|
||||||
|
ResultError.ENCRYPTION: None,
|
||||||
|
# ResultError.NO_TICKETS: (start_billing),
|
||||||
|
# ResultError.NO_PROFILE: (go_to_new_profile_page),
|
||||||
|
# ResultError.ALREADY_EXISTS: (get_invoice), # bypass_existing: True
|
||||||
|
# ResultError.BILLING_CODE_EXISTS: (get_invoice) # bypass_existing: True
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
AUTO_NAVIGATE_REGISTRY = {
|
||||||
|
ResultError.WRONG_LOCATION: "edit_profile"
|
||||||
|
}
|
||||||
17
core/infrastructure/error_popup.py
Normal file
17
core/infrastructure/error_popup.py
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
from typing import Protocol, Optional, Callable, Any, Type
|
||||||
|
|
||||||
|
class PopupProtocol(Protocol):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
choice: bool,
|
||||||
|
title: str,
|
||||||
|
message: str,
|
||||||
|
end_app: Optional[bool] = False,
|
||||||
|
on_answer: Optional[Callable] = None,
|
||||||
|
passed_result: Optional[Any] = None,
|
||||||
|
**kwargs
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
def show(self) -> None:
|
||||||
|
"""Framework-neutral method to display the popup"""
|
||||||
|
...
|
||||||
225
core/infrastructure/plumbing.py
Normal file
225
core/infrastructure/plumbing.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
from core.infrastructure.thread_dispatch import MainThreadDispatcher
|
||||||
|
from core.infrastructure.error_popup import PopupProtocol
|
||||||
|
from core.infrastructure.service_registry import ServiceLocator
|
||||||
|
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
from core.infrastructure.action_registry import ENUM_MAP
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
# from infrastructure.observer_service import notify
|
||||||
|
|
||||||
|
|
||||||
|
# generic:
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, Future
|
||||||
|
from typing import Callable, Any, Optional, Type
|
||||||
|
import inspect
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
MAX_ATTEMPTS = 3
|
||||||
|
executor = ThreadPoolExecutor(max_workers=1)
|
||||||
|
|
||||||
|
class ThreadPlumbing:
|
||||||
|
"""
|
||||||
|
Manage Execution of functions on threads, and orchestrate their retries,
|
||||||
|
then pass the data through to popup prompts for the user to decide how to solve any error, by enum type.
|
||||||
|
"""
|
||||||
|
def __init__(self, action: Callable, ui_dispatcher: MainThreadDispatcher, error_implementation: Type[PopupProtocol], auto_navigate_dict: dict, ui_caller: Optional[Callable] = None, *args, **kwargs):
|
||||||
|
self.action = action
|
||||||
|
self.ui_dispatcher = ui_dispatcher
|
||||||
|
self.ui_caller = ui_caller
|
||||||
|
self.error_implementation = error_implementation
|
||||||
|
self.auto_navigate_dict = auto_navigate_dict
|
||||||
|
self.navigator = ServiceLocator.get("navigator")
|
||||||
|
self.args = args
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.attempt_count = 0
|
||||||
|
|
||||||
|
def execute(self):
|
||||||
|
"""Entry point: kick off the first attempt"""
|
||||||
|
self.attempt_count += 1
|
||||||
|
self._spawn_action(action=self.action, first_time=True)
|
||||||
|
|
||||||
|
def _spawn_action(self, action: Callable, first_time: bool = None):
|
||||||
|
"""Spawn action on executor"""
|
||||||
|
if first_time:
|
||||||
|
future = executor.submit(action, *self.args, **self.kwargs) # original args/kwargs
|
||||||
|
else:
|
||||||
|
future = executor.submit(action) # this is a pre-loaded partial
|
||||||
|
future.add_done_callback(self._on_complete)
|
||||||
|
|
||||||
|
def _on_complete(self, future: Future):
|
||||||
|
"""Handle thread completion"""
|
||||||
|
try:
|
||||||
|
logger.info("The plumbing module started it's 'on complete' phase..")
|
||||||
|
result = future.result()
|
||||||
|
if not isinstance(result, Result):
|
||||||
|
result = convert_to_result(result)
|
||||||
|
if not result.valid:
|
||||||
|
result.function_choice = resolve_prescription(result, *self.args, **self.kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"There was an error: {str(e)}"
|
||||||
|
# notify(error_msg)
|
||||||
|
logger.error(error_msg)
|
||||||
|
result = Result(valid=False, error_type=ResultError.UNKNOWN, message=str(e))
|
||||||
|
|
||||||
|
# Back to main thread
|
||||||
|
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:
|
||||||
|
self.ui_caller(result)
|
||||||
|
return
|
||||||
|
|
||||||
|
# does this have an auto-navigate page?
|
||||||
|
if self.navigator and self.auto_navigate_dict:
|
||||||
|
self.check_auto_navigation(result)
|
||||||
|
|
||||||
|
# If the result sender explicitly asked us to navigate to a page with a ui callback, then bypass the popup:
|
||||||
|
if result.error_type == ResultError.NAVIGATE_TO:
|
||||||
|
if self.ui_caller:
|
||||||
|
self.ui_caller(result)
|
||||||
|
|
||||||
|
# notify(f"Error prompt {self.attempt_count} of {MAX_ATTEMPTS}")
|
||||||
|
|
||||||
|
# Show popup and wait for user choice
|
||||||
|
choice = bool(result.function_choice)
|
||||||
|
print(f"is there a choice? {choice}")
|
||||||
|
popup = self.error_implementation(
|
||||||
|
choice=bool(result.function_choice),
|
||||||
|
title=f"Error: {result.error_type.value}",
|
||||||
|
message=result.message,
|
||||||
|
on_answer=self._on_popup_answer,
|
||||||
|
passed_result=result,
|
||||||
|
end_app=False
|
||||||
|
)
|
||||||
|
# framework neutral show method
|
||||||
|
popup.show()
|
||||||
|
|
||||||
|
def _on_popup_answer(self, user_choice: bool, passed_result: Result):
|
||||||
|
"""User responded to popup"""
|
||||||
|
if user_choice and passed_result.function_choice:
|
||||||
|
self.attempt_count += 1
|
||||||
|
self._spawn_action(passed_result.function_choice)
|
||||||
|
else:
|
||||||
|
if self.ui_caller:
|
||||||
|
self.ui_caller(passed_result)
|
||||||
|
|
||||||
|
def check_auto_navigation(self, result: Result):
|
||||||
|
auto_navigate_page = self.auto_navigate_dict.get(result.error_type)
|
||||||
|
if auto_navigate_page is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.navigator.request_navigation.emit(auto_navigate_page)
|
||||||
|
|
||||||
|
def normalize_seq(s):
|
||||||
|
"""Ensures input is a tuple regardless of if it was a list or tuple."""
|
||||||
|
return tuple(s) if s is not None else ()
|
||||||
|
|
||||||
|
def resolve_prescription(result: Result, *args, **kwargs):
|
||||||
|
"""
|
||||||
|
Find the correct solution to the type of enum problem,
|
||||||
|
And then add the args from either the original caller's args,
|
||||||
|
or the defaults for that error type
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Priority 1: If the function explicitly provided a full partial, use it.
|
||||||
|
if result.function_choice:
|
||||||
|
return result.function_choice
|
||||||
|
|
||||||
|
# Priority 2: Look up the Enum map
|
||||||
|
if result.error_type not in ENUM_MAP:
|
||||||
|
return None # No solution
|
||||||
|
|
||||||
|
enum_value = ENUM_MAP[result.error_type]
|
||||||
|
|
||||||
|
# Handle the None case (no solution)
|
||||||
|
if enum_value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Unpack the enum value - could be just a function or (function, default_args)
|
||||||
|
if isinstance(enum_value, tuple):
|
||||||
|
func, default_args = enum_value
|
||||||
|
else:
|
||||||
|
func = enum_value
|
||||||
|
default_args = None
|
||||||
|
|
||||||
|
# What does this function require?
|
||||||
|
sig = inspect.signature(func)
|
||||||
|
function_requirements = sig.parameters
|
||||||
|
|
||||||
|
# requires nothing, we're done, return it.
|
||||||
|
if not function_requirements:
|
||||||
|
return func
|
||||||
|
|
||||||
|
# Priority 3: Use original args/kwargs
|
||||||
|
try:
|
||||||
|
bound = sig.bind(*args, **kwargs)
|
||||||
|
bound.apply_defaults()
|
||||||
|
return partial(func, **bound.arguments)
|
||||||
|
except TypeError:
|
||||||
|
pass # Not enough, move to priority 4
|
||||||
|
|
||||||
|
# Priority 4: Use original args/kwargs PLUS defaults
|
||||||
|
if default_args is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
if isinstance(default_args, dict):
|
||||||
|
# original kwargs take precedence over default_args
|
||||||
|
merged_kwargs = {**default_args, **kwargs}
|
||||||
|
bound = sig.bind(**merged_kwargs)
|
||||||
|
else:
|
||||||
|
# 1. Handle Type Mismatch: Normalize both to tuples
|
||||||
|
d_tuple = normalize_seq(default_args)
|
||||||
|
o_tuple = normalize_seq(args)
|
||||||
|
|
||||||
|
# 2. Positional Override: Replace defaults with original args by index
|
||||||
|
merged_args = list(d_tuple)
|
||||||
|
for i, val in enumerate(o_tuple):
|
||||||
|
if i < len(merged_args):
|
||||||
|
merged_args[i] = val # Override default at this position
|
||||||
|
else:
|
||||||
|
merged_args.append(val) # Append if original args exceed defaults
|
||||||
|
|
||||||
|
# 3. Collision Fix: Convert positional merge to a name-map first.
|
||||||
|
# This prevents "multiple values for argument" if the same arg
|
||||||
|
# is present in both merged_args and kwargs.
|
||||||
|
pos_bound = sig.bind(*merged_args)
|
||||||
|
|
||||||
|
# Now merge the positional map with kwargs (kwargs win)
|
||||||
|
final_kwargs = {**pos_bound.arguments, **kwargs}
|
||||||
|
bound = sig.bind(**final_kwargs)
|
||||||
|
|
||||||
|
bound.apply_defaults()
|
||||||
|
return partial(func, **bound.arguments)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return False # Still args or kwargs lacking
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_result(result: Any) -> Result:
|
||||||
|
"""Turn a non-Result object into a Result object"""
|
||||||
|
error_msg = "Internal error where the result was returned in the wrong format."
|
||||||
|
logger.error(error_msg)
|
||||||
|
|
||||||
|
if isinstance(result, bool):
|
||||||
|
return Result(valid=result, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
|
elif isinstance(result, dict):
|
||||||
|
valid = result.get('valid', False)
|
||||||
|
message = result.get('message', False)
|
||||||
|
if not message:
|
||||||
|
message = error_msg
|
||||||
|
return Result(valid=valid, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
return Result(valid=False, error_type=ResultError.INVALID_INPUT, message=error_msg)
|
||||||
|
|
||||||
|
|
||||||
|
# Optional Standalone Public API
|
||||||
|
def take_plumbing_action(action: Callable, ui_dispatcher: MainThreadDispatcher, error_implementation: Type[PopupProtocol], ui_caller: Callable, *args, **kwargs):
|
||||||
|
controller = ThreadPlumbing(action, ui_dispatcher, ui_caller, *args, **kwargs)
|
||||||
|
controller.execute()
|
||||||
10
core/infrastructure/service_registry.py
Normal file
10
core/infrastructure/service_registry.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
class ServiceLocator:
|
||||||
|
_services = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register(cls, name, service):
|
||||||
|
cls._services[name] = service
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls, name):
|
||||||
|
return cls._services.get(name)
|
||||||
18
core/infrastructure/thread_dispatch.py
Normal file
18
core/infrastructure/thread_dispatch.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
class MainThreadDispatcher(Protocol):
|
||||||
|
def run_on_main_thread(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# previous ABC implementation was cut due to PyQt conflict,
|
||||||
|
|
||||||
|
# from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
# class MainThreadDispatcher(ABC):
|
||||||
|
# @abstractmethod
|
||||||
|
# def run_on_main_thread(self, func, *args, **kwargs):
|
||||||
|
# """Schedules the given function to run on the main UI thread."""
|
||||||
|
# pass
|
||||||
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional, Any
|
from typing import Optional, Any
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
class ResultError(Enum):
|
class ResultError(Enum):
|
||||||
"""Classified error categories."""
|
"""Classified error categories."""
|
||||||
|
|
@ -34,6 +35,10 @@ class ResultError(Enum):
|
||||||
ENCRYPTION = "encryption"
|
ENCRYPTION = "encryption"
|
||||||
LEAK_ISSUE = "leak_issue"
|
LEAK_ISSUE = "leak_issue"
|
||||||
NEED_SYNC = "need_sync"
|
NEED_SYNC = "need_sync"
|
||||||
|
WRONG_LOCATION = "wrong_location"
|
||||||
|
SERVER_SIDE = "server_side"
|
||||||
|
UNPAID = "unpaid"
|
||||||
|
NAVIGATE_TO = "navigate_to"
|
||||||
UNKNOWN = "unknown"
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -44,6 +49,8 @@ class Result():
|
||||||
data: Optional[Any] = None
|
data: Optional[Any] = None
|
||||||
message: Optional[str] = None
|
message: Optional[str] = None
|
||||||
goal: Optional[str] = None
|
goal: Optional[str] = None
|
||||||
|
function_choice: Optional[partial] = None
|
||||||
|
navigate_to: Optional[str] = None
|
||||||
|
|
||||||
def user_message(self) -> str:
|
def user_message(self) -> str:
|
||||||
"""Human-readable error for the UI."""
|
"""Human-readable error for the UI."""
|
||||||
|
|
|
||||||
34
core/models/enum_models.py
Normal file
34
core/models/enum_models.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
class ProtocolChoice(Enum):
|
||||||
|
WIREGUARD = "wireguard"
|
||||||
|
HYSTERIA2 = "hysteria2"
|
||||||
|
VLESS = "vless"
|
||||||
|
SOCK5_PROXY = "proxy"
|
||||||
|
MASKED_WIREGUARD = "masked_wireguard"
|
||||||
|
|
||||||
|
class Currency(Enum):
|
||||||
|
MONERO = "monero"
|
||||||
|
BITCOIN = "bitcoin"
|
||||||
|
LITECOIN = "litecoin"
|
||||||
|
LIGHTNING = "lightning"
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentType(Enum):
|
||||||
|
CRYPTO = "crypto"
|
||||||
|
TICKET = "ticket"
|
||||||
|
FREE = "free"
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_protocol(potential_candidate: str) -> bool:
|
||||||
|
return potential_candidate in [choice.value for choice in ProtocolChoice]
|
||||||
|
|
||||||
|
|
||||||
|
def get_choice_list(model: Type[Enum]) -> list:
|
||||||
|
"""Gets a list of tuple/name for any Enum model"""
|
||||||
|
return [(e.value, e.name) for e in model]
|
||||||
|
|
||||||
|
|
||||||
|
def valid_enum(potential_candidate: str, enum_model: Type[Enum]) -> bool:
|
||||||
|
return potential_candidate in [choice.value for choice in enum_model]
|
||||||
47
core/models/pydantic_models/ServerReply.py
Normal file
47
core/models/pydantic_models/ServerReply.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from core.models.pydantic_models.type_checker import validate_types
|
||||||
|
from core.models.enum_models import ProtocolChoice, Currency
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from dataclasses_json import dataclass_json, config
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
class ServerError(str, Enum):
|
||||||
|
"""Why the Server's Operation was False."""
|
||||||
|
NONE = "none"
|
||||||
|
DATABASE = "database"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
MATH = "math"
|
||||||
|
QUANTITY = "quantity"
|
||||||
|
UNPAID = "unpaid"
|
||||||
|
INVALID = "invalid"
|
||||||
|
MISSING_DATA = "missing_data"
|
||||||
|
WRONG_LOCATION = "wrong_location"
|
||||||
|
NODE_DOWN = "node_down"
|
||||||
|
BTCPAY = "btcpay"
|
||||||
|
|
||||||
|
@dataclass_json
|
||||||
|
@validate_types
|
||||||
|
@dataclass
|
||||||
|
class ServerReply():
|
||||||
|
"""Server-Client Communication Format."""
|
||||||
|
valid: bool
|
||||||
|
signed_data: Optional[list] = None
|
||||||
|
error_type: Optional[ServerError] = ServerError.NONE
|
||||||
|
message: Optional[str] = None
|
||||||
|
model_version: int = 1
|
||||||
|
data: Optional[dict] = None
|
||||||
|
payment_status: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass_json
|
||||||
|
@validate_types
|
||||||
|
@dataclass
|
||||||
|
class SubscriptionInvoice:
|
||||||
|
billing_code: str
|
||||||
|
expires_at: datetime = field(metadata=config(decoder=datetime.fromisoformat))
|
||||||
|
selected_currency: Currency
|
||||||
|
due_amount: float
|
||||||
|
address: str
|
||||||
|
payment_type: str
|
||||||
50
core/models/pydantic_models/type_checker.py
Normal file
50
core/models/pydantic_models/type_checker.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from dataclasses_json import dataclass_json
|
||||||
|
from typing import Any, Type, TypeVar
|
||||||
|
from datetime import datetime, date, time
|
||||||
|
import json
|
||||||
|
|
||||||
|
def validate_field_type(field_name: str, value: Any, expected_type: Type):
|
||||||
|
"""Reusable validator to check if a value matches the expected type."""
|
||||||
|
if not isinstance(value, expected_type):
|
||||||
|
raise TypeError(
|
||||||
|
f"Field '{field_name}' expected type {expected_type.__name__}, "
|
||||||
|
f"but got {type(value).__name__}."
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate_types(cls):
|
||||||
|
"""Wrapper / Class decorator that injects type validation into __post_init__."""
|
||||||
|
original_post_init = getattr(cls, "__post_init__", None)
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
for field in fields(self):
|
||||||
|
value = getattr(self, field.name)
|
||||||
|
validate_field_type(field.name, value, field.type)
|
||||||
|
|
||||||
|
if original_post_init:
|
||||||
|
original_post_init(self)
|
||||||
|
|
||||||
|
cls.__post_init__ = __post_init__
|
||||||
|
return cls
|
||||||
|
|
||||||
|
|
||||||
|
# TypeVar so that the editor knows the function returns an instance of whatever model_type is passed in.
|
||||||
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
def validate_model(model_type: Type[T], data: dict|str) -> T | None:
|
||||||
|
"""Utility Conversion tool for the wrapper, to be called to convert."""
|
||||||
|
try:
|
||||||
|
if isinstance(data, dict):
|
||||||
|
final_result = model_type.from_dict(data)
|
||||||
|
elif isinstance(data, str):
|
||||||
|
final_result = model_type.from_json(data)
|
||||||
|
else:
|
||||||
|
print(f"Invalid data format of {type(data)}")
|
||||||
|
final_result = None
|
||||||
|
return final_result
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
# TypeError comes from the @validate_types decorator
|
||||||
|
# ValueError can come from dataclasses-json if the JSON is malformed
|
||||||
|
print(f"Validation Error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ def switch_endpoint_domain(domain: str) -> dict:
|
||||||
"operators": f"https://{domain}/api/v1/operators",
|
"operators": f"https://{domain}/api/v1/operators",
|
||||||
"client_version": f"https://{domain}/api/v1/platforms/linux-x86_64/appimage/client-versions",
|
"client_version": f"https://{domain}/api/v1/platforms/linux-x86_64/appimage/client-versions",
|
||||||
"subscriptions": f"https://{domain}/api/v1/subscription-plans",
|
"subscriptions": f"https://{domain}/api/v1/subscription-plans",
|
||||||
# "applications": f"https://{domain}/api/v1/platforms/linux-x86_64/applications",
|
"applications": f"https://{domain}/api/v1/platforms/linux-x86_64/applications",
|
||||||
"application_versions": f"https://{domain}/api/v1/platforms/linux-x86_64/application-versions",
|
"application_versions": f"https://{domain}/api/v1/platforms/linux-x86_64/application-versions",
|
||||||
"dependencies": f"https://{domain}/api/v1/dependencies"
|
"dependencies": f"https://{domain}/api/v1/dependencies"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
136
core/services/subscriptions/check_payment_loop.py
Normal file
136
core/services/subscriptions/check_payment_loop.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
from core.infrastructure.thread_dispatch import MainThreadDispatcher
|
||||||
|
from core.services.subscriptions.subscriptions_by_protocol import check_paid
|
||||||
|
|
||||||
|
from core.controllers.tickets.TicketPayController import check_if_ticket_paid
|
||||||
|
|
||||||
|
from core.observers.ConnectionObserver import ConnectionObserver
|
||||||
|
from core.models.enum_models import PaymentType
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
# from infrastructure.observer_service import notify
|
||||||
|
from core.errors.logger import logger
|
||||||
|
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
MAX_FAILED_CONNECTIONS = 8
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class CheckPaidTimer:
|
||||||
|
_instance = None # CLASS VARIABLE - survives across __init__ calls
|
||||||
|
|
||||||
|
def __new__(cls, view_callback: Callable, billing_code: str, ui_dispatcher: MainThreadDispatcher, payment_type: PaymentType, connection_observer: ConnectionObserver):
|
||||||
|
"""Called before __init__ — controls instance creation"""
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self, view_callback: Callable, billing_code: str, ui_dispatcher: MainThreadDispatcher, payment_type: PaymentType, connection_observer: ConnectionObserver):
|
||||||
|
# Guard: makes sure this entire class only runs once
|
||||||
|
if hasattr(self, '_initialized'):
|
||||||
|
return
|
||||||
|
self._initialized = True
|
||||||
|
|
||||||
|
# INSTANCE VARIABLES - created once, persist on the singleton
|
||||||
|
self.scheduled_event = None
|
||||||
|
self.is_running = False
|
||||||
|
self.view_callback = view_callback
|
||||||
|
self.ui_dispatcher = ui_dispatcher
|
||||||
|
self.billing_code = billing_code
|
||||||
|
self.connection_observer = connection_observer
|
||||||
|
self.payment_type = payment_type
|
||||||
|
self.failed_connections = 0
|
||||||
|
self.total_attempts = 0
|
||||||
|
|
||||||
|
logger.info("Check Paid Timer initialized. Starting polling..")
|
||||||
|
self.start_polling()
|
||||||
|
|
||||||
|
def start_polling(self):
|
||||||
|
logger.info("Starting polling")
|
||||||
|
"""If there is a billing code to check if it's paid, it begins the checks."""
|
||||||
|
if not self.billing_code:
|
||||||
|
logger.error("Billing code not found!")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not self.is_running:
|
||||||
|
self.is_running = True
|
||||||
|
# We create one long-lived thread that handles the 5s loop
|
||||||
|
self.poll_thread = threading.Thread(target=self._poll_loop, daemon=True)
|
||||||
|
self.poll_thread.start()
|
||||||
|
|
||||||
|
def stop_polling(self):
|
||||||
|
"""Stops checking if paid"""
|
||||||
|
self.is_running = False
|
||||||
|
logger.info("Polling stopped")
|
||||||
|
|
||||||
|
def _poll_loop(self):
|
||||||
|
logger.info("Starting loop")
|
||||||
|
while self.is_running:
|
||||||
|
# 1. Do the work
|
||||||
|
self._background_work()
|
||||||
|
# 2. Wait 5 seconds before the next tick
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
def _background_work(self):
|
||||||
|
"""Runs in background thread"""
|
||||||
|
|
||||||
|
# in the background thread:
|
||||||
|
print("checking if paid..")
|
||||||
|
if self.payment_type == PaymentType.CRYPTO:
|
||||||
|
result = check_paid(self.billing_code, self.connection_observer)
|
||||||
|
elif self.payment_type == PaymentType.TICKET:
|
||||||
|
result = check_if_ticket_paid(self.billing_code, self.connection_observer)
|
||||||
|
else:
|
||||||
|
result = Result(valid=False, error_type=ResultError.INVALID_INPUT, message="Invalid payment type chosen to check.")
|
||||||
|
print("checked")
|
||||||
|
|
||||||
|
# Bring result to main thread:
|
||||||
|
self.ui_dispatcher.run_on_main_thread(self._on_background_complete, result)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_background_complete(self, result):
|
||||||
|
"""Evaluates both payment and connection status separately, and updates the UI & timer."""
|
||||||
|
# paid:
|
||||||
|
if result.valid:
|
||||||
|
print("PAID! Stopping timer")
|
||||||
|
self.stop_polling()
|
||||||
|
message = "paid"
|
||||||
|
self.view_callback(paid=True, stopped=True, message=message)
|
||||||
|
|
||||||
|
# not yet paid:
|
||||||
|
elif result.error_type == ResultError.UNPAID:
|
||||||
|
print("Not yet paid, but continue to check..")
|
||||||
|
self.total_attempts = self.total_attempts + 1
|
||||||
|
message = f"Status: Not yet Paid ({self.total_attempts} Checks)"
|
||||||
|
self.view_callback(paid=False, stopped=False, message=message)
|
||||||
|
|
||||||
|
# invalid inputs, stop immediately,
|
||||||
|
elif result.error_type == ResultError.INVALID_INPUT:
|
||||||
|
print("immediately stop, the billing code or other input is invalid")
|
||||||
|
self.stop_polling()
|
||||||
|
self.view_callback(paid=False, stopped=True, message=result.message)
|
||||||
|
|
||||||
|
# connection error (timed out). Not related to payment status:
|
||||||
|
elif self.failed_connections > MAX_FAILED_CONNECTIONS:
|
||||||
|
self.stop_polling()
|
||||||
|
message = f"Your network connection timed out after {self.failed_connections} failed attempts"
|
||||||
|
self.view_callback(paid=False, stopped=True, message=message)
|
||||||
|
|
||||||
|
# evaluate timeouts:
|
||||||
|
elif result.error_type == ResultError.CONNECTION:
|
||||||
|
self.failed_connections = self.failed_connections + 1
|
||||||
|
message = f"The number of failed connections is {self.failed_connections}. This will time you out after {MAX_FAILED_CONNECTIONS} in total."
|
||||||
|
self.view_callback(paid=False, stopped=True, message=message)
|
||||||
|
|
||||||
|
else:
|
||||||
|
print("Other errors/unknown errors")
|
||||||
|
print(f"result.error_type is {result.error_type}")
|
||||||
|
print(f"result.message is {result.error_type}")
|
||||||
|
self.stop_polling()
|
||||||
|
self.view_callback(paid=False, stopped=True, message=result.message)
|
||||||
|
|
||||||
132
core/services/subscriptions/subscriptions_by_protocol.py
Normal file
132
core/services/subscriptions/subscriptions_by_protocol.py
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
|
||||||
|
from core.services.networking.httpx import connect
|
||||||
|
from core.models.enum_models import ProtocolChoice, Currency
|
||||||
|
from core.models.Result import Result, ResultError
|
||||||
|
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.Constants import Constants
|
||||||
|
|
||||||
|
|
||||||
|
def check_paid(billing_code: str, connection_observer: ConnectionObserver) -> Result:
|
||||||
|
"""Evaluates Payment Status from the Server"""
|
||||||
|
submit_data = {
|
||||||
|
"billing_code": billing_code
|
||||||
|
}
|
||||||
|
url = f'{Constants.SP_BASE_URL}/v2/check_paid'
|
||||||
|
print(f"url is {url}")
|
||||||
|
|
||||||
|
api_response = connect.single_endpoint(
|
||||||
|
method="post",
|
||||||
|
url=url,
|
||||||
|
observer=connection_observer,
|
||||||
|
payload=submit_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Layer 1: API failure:
|
||||||
|
if not api_response.valid:
|
||||||
|
error_msg = f"API Error: {api_response.error_type} {api_response.message}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||||
|
|
||||||
|
# Layer 2: Server's Reply:
|
||||||
|
raw_reply = api_response.data
|
||||||
|
print(f"validating the server reply of {raw_reply}")
|
||||||
|
server_reply = validate_model(ServerReply, raw_reply)
|
||||||
|
|
||||||
|
if not server_reply:
|
||||||
|
print("invalid structure for ServerReply")
|
||||||
|
return invalid_server_format()
|
||||||
|
|
||||||
|
# paid
|
||||||
|
if server_reply.payment_status:
|
||||||
|
return Result(valid=True, message="paid")
|
||||||
|
|
||||||
|
# NOT paid (but valid server reply),
|
||||||
|
if server_reply.valid and not server_reply.payment_status:
|
||||||
|
return Result(valid=False, error_type=ResultError.UNPAID, message="The API call went through, but this subscription is not yet paid.")
|
||||||
|
|
||||||
|
# invalid server reply,
|
||||||
|
return translate_server_failure(server_reply)
|
||||||
|
|
||||||
|
|
||||||
|
def create_invoice(
|
||||||
|
subscription_plan_id: int,
|
||||||
|
location_id: int,
|
||||||
|
protocol_choice: ProtocolChoice,
|
||||||
|
currency: Currency,
|
||||||
|
connection_observer: ConnectionObserver
|
||||||
|
) -> Result:
|
||||||
|
submit_data = {
|
||||||
|
"subscription_plan_id": subscription_plan_id,
|
||||||
|
"location_id": location_id,
|
||||||
|
"protocol_choice": protocol_choice.value,
|
||||||
|
"currency": currency.value,
|
||||||
|
"payment_type": "crypto"
|
||||||
|
}
|
||||||
|
|
||||||
|
url = f'{Constants.SP_BASE_URL}/v2/start_billing'
|
||||||
|
print(f"url is {url}")
|
||||||
|
|
||||||
|
api_response = connect.single_endpoint(
|
||||||
|
method="post",
|
||||||
|
url=url,
|
||||||
|
observer=connection_observer,
|
||||||
|
payload=submit_data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Layer 1: API failure:
|
||||||
|
if not api_response.valid:
|
||||||
|
error_msg = f"API Error: {api_response.error_type} {api_response.message}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
return Result(valid=False, error_type=ResultError.CONNECTION, message=error_msg)
|
||||||
|
|
||||||
|
# Layer 2: Server's Reply:
|
||||||
|
raw_reply = api_response.data
|
||||||
|
print("validating the server reply.")
|
||||||
|
server_reply = validate_model(ServerReply, raw_reply)
|
||||||
|
|
||||||
|
if not server_reply:
|
||||||
|
print("invalid structure for ServerReply")
|
||||||
|
return invalid_server_format()
|
||||||
|
|
||||||
|
if not server_reply.valid:
|
||||||
|
print("Server reply was correctly structured, but was a failure..")
|
||||||
|
return translate_server_failure(server_reply)
|
||||||
|
|
||||||
|
# Layer 3: Payload Data
|
||||||
|
payload_data = server_reply.data
|
||||||
|
print(f"validating the final invoice with this data: {payload_data}")
|
||||||
|
final_invoice = validate_model(SubscriptionInvoice, payload_data)
|
||||||
|
|
||||||
|
print(f"final_invoice is {final_invoice}")
|
||||||
|
print(f"final_invoice type is {type(final_invoice)}")
|
||||||
|
|
||||||
|
if not final_invoice:
|
||||||
|
return invalid_server_format()
|
||||||
|
|
||||||
|
print("returning True with the data")
|
||||||
|
return Result(valid=True, data=final_invoice)
|
||||||
|
|
||||||
|
|
||||||
|
# Translate Server-side to client-side errors in THIS CONTEXT,
|
||||||
|
TRANSLATION_DICT = {
|
||||||
|
ServerError.WRONG_LOCATION: ResultError.WRONG_LOCATION,
|
||||||
|
ServerError.NODE_DOWN: 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 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)
|
||||||
|
|
@ -12,6 +12,7 @@ from core.services.networking.api_requests.step5_solve_api_problems import solve
|
||||||
|
|
||||||
from core.services.networking.make_url import make_url
|
from core.services.networking.make_url import make_url
|
||||||
from core.services.payment_phase.ticket_config_tools import get_which_billing_key
|
from core.services.payment_phase.ticket_config_tools import get_which_billing_key
|
||||||
|
from core.models.enum_models import ProtocolChoice
|
||||||
|
|
||||||
# utils
|
# utils
|
||||||
from core.utils.get_raw_string import get_raw_string
|
from core.utils.get_raw_string import get_raw_string
|
||||||
|
|
@ -25,6 +26,7 @@ import traceback
|
||||||
def send_unblinded_ticket_to_server(
|
def send_unblinded_ticket_to_server(
|
||||||
which_ticket: int,
|
which_ticket: int,
|
||||||
which_location: int,
|
which_location: int,
|
||||||
|
protocol_choice: ProtocolChoice,
|
||||||
connection_observer: ConnectionObserver,
|
connection_observer: ConnectionObserver,
|
||||||
) -> dict|ApiResponse:
|
) -> dict|ApiResponse:
|
||||||
|
|
||||||
|
|
@ -46,6 +48,7 @@ def send_unblinded_ticket_to_server(
|
||||||
payload = {
|
payload = {
|
||||||
"which_key": which_key,
|
"which_key": which_key,
|
||||||
"which_location": which_location,
|
"which_location": which_location,
|
||||||
|
"protocol_choice": protocol_choice.value,
|
||||||
"ticket_data": ticket_data,
|
"ticket_data": ticket_data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,12 +10,14 @@ from core.services.prepare_tickets.ticket_tracker import update_ticket_tracker
|
||||||
from core.models.Result import Result, ResultError
|
from core.models.Result import Result, ResultError
|
||||||
from core.errors.logger import logger
|
from core.errors.logger import logger
|
||||||
from core.Constants import Constants
|
from core.Constants import Constants
|
||||||
|
from core.models.enum_models import ProtocolChoice
|
||||||
|
|
||||||
CUSTOMER_SERVICE_MSG = "Please speak with customer service."
|
CUSTOMER_SERVICE_MSG = "Please speak with customer service."
|
||||||
|
|
||||||
def use_ticket_orchestrator(
|
def use_ticket_orchestrator(
|
||||||
which_ticket: int,
|
which_ticket: int,
|
||||||
which_location: int, # Location.id
|
which_location: int, # Location.id
|
||||||
|
protocol_choice: ProtocolChoice,
|
||||||
connection_observer: ConnectionObserver,
|
connection_observer: ConnectionObserver,
|
||||||
) -> Result:
|
) -> Result:
|
||||||
"""
|
"""
|
||||||
|
|
@ -39,7 +41,7 @@ def use_ticket_orchestrator(
|
||||||
# SEND TO THE SERVER
|
# SEND TO THE SERVER
|
||||||
###################################
|
###################################
|
||||||
api_reply_object = send_unblinded_ticket_to_server(
|
api_reply_object = send_unblinded_ticket_to_server(
|
||||||
which_ticket, which_location, connection_observer
|
which_ticket, which_location, protocol_choice, connection_observer
|
||||||
)
|
)
|
||||||
|
|
||||||
# NETWORK ERROR.
|
# NETWORK ERROR.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue