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_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'))
|
||||
|
|
|
|||
|
|
@ -3,13 +3,15 @@ from typing import TYPE_CHECKING
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from core.essentials.observers.ConnectionObserver import ConnectionObserver
|
||||
# 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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
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 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."""
|
||||
|
|
|
|||
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",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
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.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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue