Prepartion of univerisal cross-platform plumbing. And introduction of v2 billing/invoices

This commit is contained in:
SimplifiedPrivacy 2026-09-28 17:30:08 -04:00
parent a34121d3c6
commit 77c7123d73
17 changed files with 796 additions and 14 deletions

View file

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

View file

@ -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,14 +134,16 @@ 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)
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")

View file

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

View 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"
}

View 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"""
...

View 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()

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

View 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

View file

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

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

View 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

View 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

View file

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

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

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

View file

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

View file

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