From 77c7123d73710d37ff74e9e857a169a5bf486cbf Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Mon, 28 Sep 2026 17:30:08 -0400 Subject: [PATCH] Prepartion of univerisal cross-platform plumbing. And introduction of v2 billing/invoices --- core/Constants.py | 2 + .../tickets/TicketPayController.py | 87 ++++++- .../tickets/UseTicketController.py | 8 +- core/infrastructure/action_registry.py | 28 +++ core/infrastructure/error_popup.py | 17 ++ core/infrastructure/plumbing.py | 225 ++++++++++++++++++ core/infrastructure/service_registry.py | 10 + core/infrastructure/thread_dispatch.py | 18 ++ core/models/Result.py | 7 + core/models/enum_models.py | 34 +++ core/models/pydantic_models/ServerReply.py | 47 ++++ core/models/pydantic_models/type_checker.py | 50 ++++ core/services/networking/httpx/endpoints.py | 2 +- .../subscriptions/check_payment_loop.py | 136 +++++++++++ .../subscriptions_by_protocol.py | 132 ++++++++++ core/services/using_tickets/send_unblinded.py | 3 + .../using_tickets/use_ticket_orchestrator.py | 4 +- 17 files changed, 796 insertions(+), 14 deletions(-) create mode 100644 core/infrastructure/action_registry.py create mode 100644 core/infrastructure/error_popup.py create mode 100644 core/infrastructure/plumbing.py create mode 100644 core/infrastructure/service_registry.py create mode 100644 core/infrastructure/thread_dispatch.py create mode 100644 core/models/enum_models.py create mode 100644 core/models/pydantic_models/ServerReply.py create mode 100644 core/models/pydantic_models/type_checker.py create mode 100644 core/services/subscriptions/check_payment_loop.py create mode 100644 core/services/subscriptions/subscriptions_by_protocol.py diff --git a/core/Constants.py b/core/Constants.py index bc64add..bc7ea6e 100644 --- a/core/Constants.py +++ b/core/Constants.py @@ -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_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') CONNECTION_RETRY_INTERVAL: Final[int] = int(os.environ.get('CONNECTION_RETRY_INTERVAL', '5')) diff --git a/core/controllers/tickets/TicketPayController.py b/core/controllers/tickets/TicketPayController.py index dc4aa1c..4aa1bb0 100644 --- a/core/controllers/tickets/TicketPayController.py +++ b/core/controllers/tickets/TicketPayController.py @@ -3,13 +3,15 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from core.essentials.observers.ConnectionObserver import ConnectionObserver - from core.observers.TicketObserver import TicketObserver -# + # from core.observers.BaseObserver import BaseObserver + +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.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.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.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.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.Constants import Constants + +from typing import Optional """ Inputs: Which plan (key), which crypto, and how many profiles @@ -39,7 +43,7 @@ def initiate_payment( ticket_observer: TicketObserver, connection_observer: ConnectionObserver, bypass_existing: bool, -) -> TicketInvoice: +) -> Result: ############### if bypass_existing == False: @@ -82,7 +86,7 @@ def initiate_payment( public_key_results = get_pub_key(which_key, connection_observer, "local") 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) ticket_observer.notify("failed_input", subject=notification) # 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}" logger.error(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, # instead of passing 4 values seperately. @@ -130,15 +134,17 @@ def initiate_payment( def check_if_paid( temp_billing_code: str, - ticket_observer: TicketObserver, connection_observer: ConnectionObserver, + ticket_observer: Optional[TicketObserver] = None, ) -> dict: + """Legacy version being kept in only temporarily.""" 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", exc_info=True) - ticket_observer.notify("failed_input", subject=error_msg) + if ticket_observer: + ticket_observer.notify("failed_input", subject=error_msg) return {"valid": False, "error_code": "rejected"} 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}") 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") diff --git a/core/controllers/tickets/UseTicketController.py b/core/controllers/tickets/UseTicketController.py index 9d4f3da..309afa9 100644 --- a/core/controllers/tickets/UseTicketController.py +++ b/core/controllers/tickets/UseTicketController.py @@ -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.system.SystemProfile import SystemProfile from core.errors.logger import logger +from core.models.enum_models import ProtocolChoice # generic import random @@ -144,11 +145,16 @@ use_ticket function requires: def use_ticket( which_ticket: int, which_location: int, # Location.id + protocol_choice: ProtocolChoice, ticket_observer: TicketObserver, connection_observer: ConnectionObserver, profile: Optional[Union[SessionProfile, SystemProfile]] = None, # only for assassin or single profiles. ) -> 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 # does the ticket's file exist: @@ -167,7 +173,7 @@ def use_ticket( # the actual work here, everything else is just handling: 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 diff --git a/core/infrastructure/action_registry.py b/core/infrastructure/action_registry.py new file mode 100644 index 0000000..a527e9d --- /dev/null +++ b/core/infrastructure/action_registry.py @@ -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" +} diff --git a/core/infrastructure/error_popup.py b/core/infrastructure/error_popup.py new file mode 100644 index 0000000..1fa0cfd --- /dev/null +++ b/core/infrastructure/error_popup.py @@ -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""" + ... diff --git a/core/infrastructure/plumbing.py b/core/infrastructure/plumbing.py new file mode 100644 index 0000000..d88df08 --- /dev/null +++ b/core/infrastructure/plumbing.py @@ -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() diff --git a/core/infrastructure/service_registry.py b/core/infrastructure/service_registry.py new file mode 100644 index 0000000..dce6e5b --- /dev/null +++ b/core/infrastructure/service_registry.py @@ -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) diff --git a/core/infrastructure/thread_dispatch.py b/core/infrastructure/thread_dispatch.py new file mode 100644 index 0000000..a4d1af4 --- /dev/null +++ b/core/infrastructure/thread_dispatch.py @@ -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 + diff --git a/core/models/Result.py b/core/models/Result.py index 3ca17fc..aa7bb96 100644 --- a/core/models/Result.py +++ b/core/models/Result.py @@ -3,6 +3,7 @@ from enum import Enum from dataclasses import dataclass from typing import Optional, Any +from functools import partial class ResultError(Enum): """Classified error categories.""" @@ -34,6 +35,10 @@ class ResultError(Enum): ENCRYPTION = "encryption" LEAK_ISSUE = "leak_issue" NEED_SYNC = "need_sync" + WRONG_LOCATION = "wrong_location" + SERVER_SIDE = "server_side" + UNPAID = "unpaid" + NAVIGATE_TO = "navigate_to" UNKNOWN = "unknown" @dataclass @@ -44,6 +49,8 @@ class Result(): data: Optional[Any] = None message: Optional[str] = None goal: Optional[str] = None + function_choice: Optional[partial] = None + navigate_to: Optional[str] = None def user_message(self) -> str: """Human-readable error for the UI.""" diff --git a/core/models/enum_models.py b/core/models/enum_models.py new file mode 100644 index 0000000..31c7e4d --- /dev/null +++ b/core/models/enum_models.py @@ -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] diff --git a/core/models/pydantic_models/ServerReply.py b/core/models/pydantic_models/ServerReply.py new file mode 100644 index 0000000..ab72326 --- /dev/null +++ b/core/models/pydantic_models/ServerReply.py @@ -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 diff --git a/core/models/pydantic_models/type_checker.py b/core/models/pydantic_models/type_checker.py new file mode 100644 index 0000000..e449983 --- /dev/null +++ b/core/models/pydantic_models/type_checker.py @@ -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 + diff --git a/core/services/networking/httpx/endpoints.py b/core/services/networking/httpx/endpoints.py index 4712ef9..baa57aa 100644 --- a/core/services/networking/httpx/endpoints.py +++ b/core/services/networking/httpx/endpoints.py @@ -8,7 +8,7 @@ def switch_endpoint_domain(domain: str) -> dict: "operators": f"https://{domain}/api/v1/operators", "client_version": f"https://{domain}/api/v1/platforms/linux-x86_64/appimage/client-versions", "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", "dependencies": f"https://{domain}/api/v1/dependencies" } diff --git a/core/services/subscriptions/check_payment_loop.py b/core/services/subscriptions/check_payment_loop.py new file mode 100644 index 0000000..74574c4 --- /dev/null +++ b/core/services/subscriptions/check_payment_loop.py @@ -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) + diff --git a/core/services/subscriptions/subscriptions_by_protocol.py b/core/services/subscriptions/subscriptions_by_protocol.py new file mode 100644 index 0000000..6417cc4 --- /dev/null +++ b/core/services/subscriptions/subscriptions_by_protocol.py @@ -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) diff --git a/core/services/using_tickets/send_unblinded.py b/core/services/using_tickets/send_unblinded.py index a3d2534..aac481e 100644 --- a/core/services/using_tickets/send_unblinded.py +++ b/core/services/using_tickets/send_unblinded.py @@ -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.payment_phase.ticket_config_tools import get_which_billing_key +from core.models.enum_models import ProtocolChoice # utils from core.utils.get_raw_string import get_raw_string @@ -25,6 +26,7 @@ import traceback def send_unblinded_ticket_to_server( which_ticket: int, which_location: int, + protocol_choice: ProtocolChoice, connection_observer: ConnectionObserver, ) -> dict|ApiResponse: @@ -46,6 +48,7 @@ def send_unblinded_ticket_to_server( payload = { "which_key": which_key, "which_location": which_location, + "protocol_choice": protocol_choice.value, "ticket_data": ticket_data, } diff --git a/core/services/using_tickets/use_ticket_orchestrator.py b/core/services/using_tickets/use_ticket_orchestrator.py index e9de5a4..4f56921 100644 --- a/core/services/using_tickets/use_ticket_orchestrator.py +++ b/core/services/using_tickets/use_ticket_orchestrator.py @@ -10,12 +10,14 @@ from core.services.prepare_tickets.ticket_tracker import update_ticket_tracker from core.models.Result import Result, ResultError from core.errors.logger import logger from core.Constants import Constants +from core.models.enum_models import ProtocolChoice CUSTOMER_SERVICE_MSG = "Please speak with customer service." def use_ticket_orchestrator( which_ticket: int, which_location: int, # Location.id + protocol_choice: ProtocolChoice, connection_observer: ConnectionObserver, ) -> Result: """ @@ -39,7 +41,7 @@ def use_ticket_orchestrator( # SEND TO THE 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.