From 40ee53197e50e807c19a395f57a8fde47c670be4 Mon Sep 17 00:00:00 2001 From: SimplifiedPrivacy Date: Wed, 7 Oct 2026 13:59:24 -0400 Subject: [PATCH] Big Bang Payments Refactor. Moved core logic out of the PyQt classes, instead it uses payment_actions, which coordinates with core background threaded tasks --- .../images/{lightnering.png => lightning.png} | Bin gui/v2/actions/menu_profiles.py | 5 + gui/v2/actions/payment_actions.py | 95 +++++ gui/v2/infrastructure/page_registry.py | 7 +- gui/v2/ui/pages/Page.py | 16 + gui/v2/ui/pages/currency_selection_page.py | 97 ++++-- gui/v2/ui/pages/duration_selection_page.py | 133 +++---- gui/v2/ui/pages/payments2.py | 328 ++++++++++++++++++ gui/v2/ui/styles/payment_styles.py | 56 +++ 9 files changed, 640 insertions(+), 97 deletions(-) rename gui/resources/images/{lightnering.png => lightning.png} (100%) create mode 100644 gui/v2/actions/payment_actions.py create mode 100644 gui/v2/ui/pages/payments2.py create mode 100644 gui/v2/ui/styles/payment_styles.py diff --git a/gui/resources/images/lightnering.png b/gui/resources/images/lightning.png similarity index 100% rename from gui/resources/images/lightnering.png rename to gui/resources/images/lightning.png diff --git a/gui/v2/actions/menu_profiles.py b/gui/v2/actions/menu_profiles.py index 297287e..e2d5e3c 100755 --- a/gui/v2/actions/menu_profiles.py +++ b/gui/v2/actions/menu_profiles.py @@ -318,6 +318,11 @@ def profile_status_message(profile: BaseProfile, is_enabled: bool, idle: bool = """Generate status text outside Qt so workers and pages report identical subscription state.""" subscription = getattr(profile, "subscription", None) profile_date = getattr(subscription, "expires_at", None) + billing_pending = getattr(subscription, "billing_pending", None) + + if billing_pending: + return "Click Enable to Resume Billing." + if profile_date is None: return "Offline. No subscription found." diff --git a/gui/v2/actions/payment_actions.py b/gui/v2/actions/payment_actions.py new file mode 100644 index 0000000..a36ec83 --- /dev/null +++ b/gui/v2/actions/payment_actions.py @@ -0,0 +1,95 @@ +from core.services.payment_phase.payment_state import PaymentData +from core.services.subscriptions.check_payment_loop import CheckPaidTimer +from core.services.subscriptions.subscriptions_by_protocol import create_invoice, save_billing_code, return_pre_existing_payment_data, get_profile_subscription +from core.models.enum_models import PaymentType +from core.models.pydantic_models.ServerReply import SubscriptionInvoice +from core.models.Result import Result +from core.models.Subscription import Subscription +from core.errors.logger import logger + + +# gui +from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool +from gui.v2.infrastructure.take_plumbing_action import take_action +from gui.v2.infrastructure.setup_observers import connection_observer + +from typing import Callable, Optional + +def gui_starts_invoice(profile_id: int, ui_caller: Callable) -> Result: + """Create a new invoice if we don't have one yet, or re-use the saved data.""" + + subscription = get_profile_subscription(profile_id) + print(f"in gui_starts_invoice, subscription is {subscription}") + + if not subscription: + logger.info(f"Starting a New Invoice for Profile {profile_id}..") + take_action( + action=create_invoice, + ui_caller=ui_caller, + profile_id=profile_id, + connection_observer=connection_observer + ) + return Result(valid=True) + else: + return return_pre_existing_payment_data(profile_id=profile_id, subscription=subscription) + + +def save_and_begin_timer( + ticketing: bool, + gui_callback: Callable, + billing_code: str, + profile_id: Optional[int] = None, + expires_at: Optional[str] = None, +) -> bool: + """ + First saves the billing code & profile expiration, if it's an individual (non-ticket) crypto-based one. + Then it Starts a loop of checking if the invoice is paid and saves it. + """ + + if ticketing: + payment_type = PaymentType.TICKETS + logger.error("Not yet setup for ticketing") + return False + + # NON-ticketing: + elif profile_id and expires_at: + payment_type = PaymentType.CRYPTO + saved = save_billing_code(profile_id=profile_id, billing_code=billing_code, expires_at=expires_at) + if not saved.valid: + return False + else: + logger.error("You need a profile id and expires_at for individual profiles") + return False + + # Setup PyQt6 GUI as mainthread to bring back to: + pyqt_threader = ThreadSafetyTool() + + # setup the timer with the callback: + # it doesn't return result here. It calls the gui_callback function. + check_paid_timer = CheckPaidTimer( + view_callback=gui_callback, + billing_code=billing_code, + ui_dispatcher=pyqt_threader, + payment_type=payment_type, + connection_observer=connection_observer + ) + + return True # this is just that it started + + + + +# def create_an_invoice(ui_caller: Callable, profile_id: int, connection_observer: ConnectionObserver) -> SubscriptionInvoice|str: +# """Create an Invoice for regular single profile billing""" + +# take_action( +# action=create_invoice, +# ui_caller=ui_caller, +# profile_id=profile_id, +# connection_observer=connection_observer +# ) + +# subscription_plan_id=subscription_plan_id, +# location_id=location_id, +# protocol_choice=protocol_choice, +# currency=currency_choice, diff --git a/gui/v2/infrastructure/page_registry.py b/gui/v2/infrastructure/page_registry.py index 3fa915f..374b352 100755 --- a/gui/v2/infrastructure/page_registry.py +++ b/gui/v2/infrastructure/page_registry.py @@ -23,7 +23,7 @@ PAGE_REGISTRY = { "policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"), "duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"), "currency_selection": ("gui.v2.ui.pages.currency_selection_page", "CurrencySelectionPage"), - "payment_details": ("gui.v2.ui.pages.payment_details_page", "PaymentDetailsPage"), + "payment_details": ("gui.v2.ui.pages.payments2", "PaymentDetailsPage"), "plan_picker": ("gui.v2.ui.pages.plan_picker_page", "PlanPickerPage"), "ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"), "ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"), @@ -62,10 +62,8 @@ PRELOAD_ORDER = [ "settings", "editor", "ticket_or_billing_choice", - "payment_details", "plan_picker", "ticket_crypto_picker", - "duration_selection", "currency_selection", "policy_suggestion", "systemwide_prompt", @@ -85,3 +83,6 @@ PRELOAD_ORDER = [ "install_system_package", "sync_screen", ] + +# "duration_selection", +# "payment_details", diff --git a/gui/v2/ui/pages/Page.py b/gui/v2/ui/pages/Page.py index a94b119..acb30e1 100755 --- a/gui/v2/ui/pages/Page.py +++ b/gui/v2/ui/pages/Page.py @@ -1,3 +1,5 @@ +from gui.v2.ui.popups.error_popup import ErrorPopup + import os from PyQt6.QtWidgets import QWidget, QLabel, QPushButton @@ -101,3 +103,17 @@ class Page(QWidget): except TypeError: pass button.clicked.connect(handler) + + + def show_a_popup(self, message: str, title: str = "Error"): + """Display a popup with an error""" + error_popup = ErrorPopup( + choice=False, + title=title, + message=message, + end_app = False, + on_answer = None, + passed_result = None, + ) + + error_popup.show() diff --git a/gui/v2/ui/pages/currency_selection_page.py b/gui/v2/ui/pages/currency_selection_page.py index e53f80f..e3ead10 100755 --- a/gui/v2/ui/pages/currency_selection_page.py +++ b/gui/v2/ui/pages/currency_selection_page.py @@ -1,11 +1,14 @@ -import os +from core.controllers.profile_state.data_filters import ALL_CURRENCIES +from core.models.enum_models import Currency +from core.services.payment_phase.payment_state import PaymentData +from gui.v2.ui.pages.Page import Page -from PyQt6.QtWidgets import QButtonGroup, QLabel, QPushButton +import os +from PyQt6 import QtCore +from PyQt6.QtWidgets import QLabel, QPushButton, QButtonGroup +from PyQt6.QtWidgets import QVBoxLayout, QWidget, QSpacerItem, QSizePolicy from PyQt6.QtGui import QIcon from PyQt6.QtCore import QSize -from PyQt6 import QtCore - -from gui.v2.ui.pages.Page import Page class CurrencySelectionPage(Page): @@ -13,53 +16,79 @@ class CurrencySelectionPage(Page): super().__init__("Select Currency", page_stack, main_window, parent) self.update_status = main_window self.selected_duration = None - self.create_interface_elements() + + # Map to link the button object to its currency string value + self.button_to_currency = {} + + self.create_initial_elements() self.button_reverse.setVisible(True) self.button_next.setVisible(False) - def create_interface_elements(self): + + def create_initial_elements(self): self.title = QLabel("Payment Method", self) - self.title.setGeometry(QtCore.QRect(510, 30, 250, 40)) self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.button_reverse.clicked.connect(self.reverse) - button_info = [ - ("monero", (545, 75)), - ("bitcoin", (545, 290)), - ("lightnering", (545, 180)), - ("litecoin", (545, 395)) - ] + # container widget to hold the 'block' of buttons + self.button_container = QWidget(self) + + # Position of the entire group, not each individual button + self.button_container.setGeometry(545, 75, 185, 400) + + # Vertical Layout + self.layout = QVBoxLayout(self.button_container) + + # margins avoid cutting off image: + self.layout.setContentsMargins(0, 0, 0, 0) + + # Consistent gap between buttons + self.layout.setSpacing(20) + self.layout.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) self.buttonGroup = QButtonGroup(self) - self.buttons = [] - for j, (icon_name, position) in enumerate(button_info): + # loop through enums and get the relevant image, + for currency_val in ALL_CURRENCIES: button = QPushButton(self) - button.setGeometry(position[0], position[1], 185, 75) + + # size required for the image + button.setFixedSize(185, 75) + button.setIconSize(QSize(190, 120)) button.setCheckable(True) - self.buttons.append(button) - self.buttonGroup.addButton(button, j) - button.setIcon( - QIcon(os.path.join(self.btn_path, f"{icon_name}.png"))) + button.setIcon(QIcon(os.path.join(self.btn_path, f"{currency_val}.png"))) button.clicked.connect(self.on_currency_selected) + + self.button_to_currency[button] = currency_val + self.buttonGroup.addButton(button) + self.layout.addWidget(button) + def on_currency_selected(self): - self.custom_window.navigator.navigate("payment_details") - payment_details_page = self.custom_window.navigator.get_cached("payment_details") - if payment_details_page is not None: - payment_details_page.selected_duration = self.selected_duration - payment_details_page.selected_currency = self.get_selected_currency() - payment_details_page.check_invoice() - self.update_status.update_status('Loading payment details...') + """Sets the value selected to the singleton used across pages""" - def get_selected_currency(self): + profile_id = int(self.update_status.current_profile_id) + + # get the singleton (this was created on a previous page) + self.billing_data = PaymentData(instance_id=profile_id) + + # set the value as an Enum to be used in a different page, + self.billing_data.currency = self._get_selected_currency() + + self.update_status.update_status('Loading payment details...') + self.custom_window.navigator.navigate("payment_details") + + + def _get_selected_currency(self): selected_button = self.buttonGroup.checkedButton() - if selected_button: - index = self.buttonGroup.id(selected_button) - currencies = ["monero", "bitcoin", "lightning", "litecoin"] - return currencies[index] - return None + + # Retrieve the value from the map using the button object as the key + raw_selection = self.button_to_currency.get(selected_button) + + # convert to enum: + return Currency(raw_selection) + def reverse(self): self.custom_window.navigator.navigate("duration_selection") diff --git a/gui/v2/ui/pages/duration_selection_page.py b/gui/v2/ui/pages/duration_selection_page.py index c355c4e..9e6aadf 100755 --- a/gui/v2/ui/pages/duration_selection_page.py +++ b/gui/v2/ui/pages/duration_selection_page.py @@ -1,17 +1,28 @@ +# Core +from core.services.payment_phase.payment_state import PaymentData +from gui.v2.infrastructure.take_plumbing_action import take_action +from core.controllers.profile_state.data_filters import get_plans_for_profile +from core.models.Result import Result #, ResultError + +# GUI +from gui.v2.ui.styles.payment_styles import DURATION_SELECTION_STYLE, NEXT_BUTTON_STYLE +from gui.v2.ui.pages.Page import Page + +from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool +mainthread = ThreadSafetyTool() + from PyQt6.QtWidgets import QComboBox, QLabel, QPushButton from PyQt6 import QtCore -from gui.v2.ui.pages.Page import Page - class DurationSelectionPage(Page): def __init__(self, page_stack, main_window=None, parent=None): super().__init__("Select Duration", page_stack, main_window, parent) self.update_status = main_window - self.create_interface_elements() + self.create_intitial_elements() self.button_reverse.setVisible(True) - def create_interface_elements(self): + def create_intitial_elements(self): self.title = QLabel("Select Duration", self) self.title.setGeometry(QtCore.QRect(530, 30, 210, 40)) self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) @@ -19,74 +30,76 @@ class DurationSelectionPage(Page): self.combo_box = QComboBox(self) self.combo_box.setGeometry(200, 200, 400, 60) - self.combo_box.addItems( - ["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"]) self.combo_box.setEditable(False) self.combo_box.setMaxVisibleItems(4) self.combo_box.setDuplicatesEnabled(True) self.combo_box.setPlaceholderText("Select options") - self.combo_box.setStyleSheet(""" - QComboBox { - font-size: 16px; - padding: 10px; - background-color: #000000; - color: #00ffff; - border: 2px solid #00ffff; - border-radius: 8px; - } - QComboBox::drop-down { - width: 30px; - background-color: #1a1a1a; - border-left: 1px solid #00ffff; - } - QComboBox::down-arrow { - width: 12px; - height: 12px; - color: #00ffff; - } - QComboBox QAbstractItemView { - background-color: #0a0a0a; - border: 1px solid #00ffff; - border-radius: 4px; - selection-background-color: #003333; - selection-color: #00ffff; - } - QComboBox QAbstractItemView::item { - padding: 8px; - border: none; - color: #00ffff; - background-color: #0a0a0a; - } - QComboBox QAbstractItemView::item:hover { - background-color: #1a1a1a; - color: #00ffff; - } - QComboBox QAbstractItemView::item:selected { - background-color: #003333; - color: #00ffff; - } - """) + self.combo_box.setStyleSheet(DURATION_SELECTION_STYLE) self.next_button = QPushButton("Next", self) self.next_button.setGeometry(350, 300, 100, 40) - self.next_button.setStyleSheet(""" - QPushButton { - font-size: 18px; - padding: 10px; - border: 2px solid #ccc; - border-radius: 10px; - background-color: cyan; - color: black; - font-weight: bold; - } - """) + self.next_button.setStyleSheet(NEXT_BUTTON_STYLE) self.next_button.clicked.connect(self.show_next) + # Background thread launch: + self.get_data() + + + def get_data(self): + """Launches background thread to get data""" + + # get profile id: + profile_id = int(self.update_status.current_profile_id) + + # setup singleton + self.billing_data = PaymentData(instance_id=profile_id) + + # Thread SQL in background: + take_action( + action=get_plans_for_profile, + ui_caller=self.populate_with_data, + profile_id=profile_id, + ) + + @mainthread + def populate_with_data(self, result: Result): + if not result.valid: + self.show_a_popup(result.message) + return + + self.choice_to_id = {} + self.dict_of_choices = result.data + + if not self.dict_of_choices: + self.show_a_popup("There is no plan data. Go sync or remove the database and try again.") + return + + if not isinstance(self.dict_of_choices, dict): + self.show_a_popup("Plan data is in an invalid format.") + return + + for plan_string, plan_id in self.dict_of_choices.items(): + self.combo_box.addItem(str(plan_string)) + def show_next(self): - self.custom_window.navigator.navigate("currency_selection") + # get current selection: + current_choice = self.combo_box.currentText() + + # which plan id is that: + plan_id = self.dict_of_choices[current_choice] + + # save to singleton across pages: + self.billing_data.plan_id = plan_id + + # go to next page: currency_page = self.custom_window.navigator.get_cached("currency_selection") if currency_page is not None: - currency_page.selected_duration = self.combo_box.currentText() + self.custom_window.navigator.navigate("currency_selection") def reverse(self): self.custom_window.navigator.navigate("id") + + +# currency_page.selected_duration +# self.combo_box.addItems( +# ["1 month (€1.50)", "3 months (€4.30)", "6 months (€8)", "12 months (€16)"]) diff --git a/gui/v2/ui/pages/payments2.py b/gui/v2/ui/pages/payments2.py new file mode 100644 index 0000000..2bb9fa3 --- /dev/null +++ b/gui/v2/ui/pages/payments2.py @@ -0,0 +1,328 @@ +# core +from core.services.payment_phase.payment_state import PaymentData +from core.models.pydantic_models.ServerReply import SubscriptionInvoice +from core.models.enum_models import Currency +from core.models.Result import Result + +# GUI +from gui.v2.actions.payment_actions import save_and_begin_timer, gui_starts_invoice +from gui.v2.ui.pages.Page import Page +from gui.v2.ui.popups.qrcode_dialog import QRCodeDialog + +# Bring background threads to the main thread UI: +from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool +mainthread = ThreadSafetyTool() + + +import os +from typing import Any +from PyQt6 import QtCore, QtWidgets +from PyQt6.QtWidgets import ( + QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QWidget, QApplication +) +from PyQt6.QtCore import Qt +from PyQt6.QtGui import QIcon +from enum import Enum + +class DataError(Enum): + FORMAT: "format" + BLANK: "blank" + + +class PaymentDetailsPage(Page): + def __init__(self, page_stack, main_window=None, parent=None): + super().__init__("Payment Details", page_stack, main_window, parent) + self.update_status = main_window + + self.fields = {} + self.ticketing = False + self.create_initial_elements() + self.button_reverse.setVisible(True) + self.begin_invoice() + + + def begin_invoice(self): + """"Fetches an Invoice from GUI's actions modules""" + self.update_status.update_status('Getting Invoice...') + self.profile_id = int(self.update_status.current_profile_id) + + if not self.profile_id: + self._display_error(whats_wrong=DataError.BLANK, whats_it_called="Profile ID", the_thing=profile_id) + return + + # this is not used here, but this is the right place to put it. Since we should have it by now: + self.currency = self._get_currency() + if not self.currency: + return # error is already displayed in subfunction. + + # launches background thread tasks: + gui_starts_invoice(profile_id=self.profile_id, ui_caller=self.receive_invoice) + + + def create_initial_elements(self): + container = QWidget(self) + container.setFixedSize(600, 500) + main_layout = QVBoxLayout(container) + main_layout.setSpacing(15) + main_layout.setContentsMargins(30, 30, 30, 30) + + self.title = QLabel("Payment Details") + self.title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.title.setStyleSheet("font-size: 20px; font-weight: bold; margin-bottom: 10px;") + main_layout.addWidget(self.title) + + # takes field_key (string) instead of field_index (int) + def add_info_row(label_text, field_key=None, show_copy=False): + row_layout = QHBoxLayout() + + lbl = QLabel(label_text) + lbl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + row_layout.addWidget(lbl) + + if field_key is not None: + edit = QLineEdit() + edit.setReadOnly(True) + edit.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Store by name: e.g., self.fields["amount"] = edit_object + self.fields[field_key] = edit + + row_layout.addWidget(edit) + + if show_copy: + btn = QPushButton() + btn.setFixedSize(40, 40) + btn.setIcon(QIcon(os.path.join(self.btn_path, "paste_button.png"))) + # Pass the string key to the copy function + btn.clicked.connect(lambda checked, key=field_key: self.copy_text(key)) + row_layout.addWidget(btn) + + main_layout.addLayout(row_layout) + + # Build the Form using descriptive keys + add_info_row("Your new billing ID:", field_key="billing_code", show_copy=True) + add_info_row("Duration:", field_key="duration", show_copy=False) + add_info_row("Currency:", field_key="currency", show_copy=False) + add_info_row("Amount:", field_key="amount", show_copy=True) + + addr_prompt = QLabel("Hit the icon to copy paste the address:") + addr_prompt.setStyleSheet("font-size: 14px;") + main_layout.addWidget(addr_prompt) + add_info_row("", field_key="address", show_copy=True) + + qr_prompt = QLabel("Hit this icon to Scan the QR code:") + qr_prompt.setAlignment(Qt.AlignmentFlag.AlignCenter) + main_layout.addWidget(qr_prompt) + + self.qr_code_button = QPushButton() + self.qr_code_button.setFixedSize(60, 60) + self.qr_code_button.setIcon(QIcon(os.path.join(self.btn_path, "qr-code.png"))) + self.qr_code_button.clicked.connect(self.show_qr_code) + self.qr_code_button.setDisabled(True) + + qr_container = QHBoxLayout() + qr_container.addWidget(self.qr_code_button, alignment=Qt.AlignmentFlag.AlignCenter) + main_layout.addLayout(qr_container) + + self.setLayout(main_layout) + self.button_reverse.clicked.connect(self.reverse) + + + def _display_error(self, whats_wrong: DataError, whats_it_called: str, the_thing: Any): + # Diagnosis + if whats_wrong == DataError.FORMAT: + error_msg = f"The {whats_it_called} is in the wrong format" + status_update = f'Error: Bad {whats_it_called} Format' + elif whats_wrong == DataError.BLANK: + error_msg = f"The {whats_it_called} is blank! But you need a {whats_it_called} to continue!" + status_update = f'Error: Blank {whats_it_called}' + else: + error_msg = f"The {whats_it_called} has unknown errors!" + status_update = f'Unknown Error with {whats_it_called}' + + # Display + if the_thing and isinstance(the_thing, str): + self.show_a_popup(f"{error_msg}: {the_thing}") + elif the_thing and isinstance(the_thing, dict): + message = the_thing.get('message', "!") + self.show_a_popup(f"{error_msg} {message}") + else: + self.show_a_popup(error_msg) + self.update_status.update_status(status_update) + + + def copy_text(self, key: str): + """Copy/Paste feature""" + try: + # Use the key to determine if we use elided text or the full raw address + text = self.full_address if key == "address" else self.fields[key].text() + QApplication.clipboard().setText(text) + + # Descriptive messages based on the key + msg_map = { + "billing_code": "Billing code copied!", + "address": "Address copied!", + "amount": "Pay amount copied!" + } + self.update_status.update_status(msg_map.get(key, "Copied!")) + # QTimer.singleShot(2000, lambda: self.update_status.update_status("Ready")) + except Exception as e: + self.show_a_popup(str(e)) + self.update_status.update_status('No content available for copying') + + + @mainthread + def receive_invoice(self, invoice_result: Result): + """Callback for the background API thread to update the UI""" + + if not invoice_result: + self._display_error(whats_wrong=DataError.BLANK, whats_it_called="Backend Invoice Result", the_thing=invoice_result) + return + + if not isinstance(invoice_result, Result): + self._display_error(whats_wrong=DataError.FORMAT, whats_it_called="Backend Invoice Result", the_thing=invoice_result) + return + + if not invoice_result.valid: + self.update_status.update_status(str(invoice_result.error_type.value)) + self.show_a_popup(str(invoice_result.message)) + return + + # pull the actual invoice out of the result wrapper, + invoice = invoice_result.data + + if not invoice: + self._display_error(whats_wrong=DataError.BLANK, whats_it_called="invoice", the_thing=invoice) + return + + if not isinstance(invoice, SubscriptionInvoice): + self._display_error(whats_wrong=DataError.FORMAT, whats_it_called="invoice", the_thing=invoice) + return + + self.fill_in_invoice_data(invoice) + self.update_status.update_status('Invoice generated. Awaiting payment...') + + # kick off the timer and save the billing id, + saved_and_started = save_and_begin_timer( + ticketing=self.ticketing, + gui_callback=self.timer_updates_gui, + billing_code=invoice.billing_code, + profile_id=int(self.update_status.current_profile_id), + expires_at=invoice.expires_at + ) + if not saved_and_started: + self.show_a_popup("The save and start checking if paid did not even begin. Please see the error logs/") + self.update_status.update_status("Could not begin") + + + def fill_in_invoice_data(self, invoice: SubscriptionInvoice): + """Extract and populate invoice data in the labels""" + + # Extract from the already validated object: + billing_code = str(invoice.billing_code) + duration = str(invoice.expires_at) + amount_text = str(invoice.due_amount) + self.full_address = str(invoice.address) + + if billing_code: + self.fields["billing_code"].setText(billing_code) + + if duration: + self.fields["duration"].setText(duration) + self.fields["duration"].setStyleSheet('font-size: 13px;') + + if amount_text: + self.fields["amount"].setText(amount_text) + + # Dynamic font sizing for amount + font_size = 14 + if len(amount_text) > 9: font_size = 15 + if len(amount_text) > 13: font_size = 10 + self.fields["amount"].setStyleSheet(f"font-size: {font_size}px; font-weight: bold;") + + if self.full_address: + metrics = self.fields["address"].fontMetrics() + elided_text = metrics.elidedText( + self.full_address, + Qt.TextElideMode.ElideMiddle, + self.fields["address"].width() + ) + self.fields["address"].setText(elided_text) + + # Now that we have the data populated, show the QR code: + self.qr_code_button.setDisabled(False) + + + @mainthread + def timer_updates_gui(self, paid: bool, stopped: bool, message: str): + """Callback for the background thread timer to repeatedly update the UI""" + + if message and isinstance(message, str): + self.update_status.update_status(message) + + if paid: + self._finish_after_paid() + + if stopped and not paid: + self._display_it_stopped(message) + + + def _finish_after_paid(self): + """Last step. Notify the user and potentially navigate away.""" + self.update_status.update_status('Payment received.') + + # TICKETS + if self.ticketing: + self.custom_window.navigator.navigate("ticket_prep") + prep_page = self.custom_window.navigator.get_cached("ticket_prep") + if prep_page: + prep_page.start_prep() + + + def _display_it_stopped(self, message: str): + """Notify the user if it's NOT paid, but stopped""" + if not message: + error_msg = "It stopped checking if it's paid. Could be a connection timeout or other issue" + else: + error_msg = f"It stopped checking if it's paid. {message}" + self.update_status.update_status("Timer Stopped") + self.show_a_popup(error_msg) + + + def show_qr_code(self): + """Launch an external QR scanning popup screen""" + if self.full_address and self.currency: + qr_dialog = QRCodeDialog(self.full_address, self.fields["amount"].text(), self.currency.value, self) + qr_dialog.exec() + else: + self.update_status.update_status('No address available for QR code') + + + def reverse(self): + if self.ticketing: + self.ticketing = False + self.custom_window.navigator.navigate("ticket_crypto_picker") + else: + self.custom_window.navigator.navigate("currency_selection") + + def _get_currency(self): + """Get the currency from prior cached PaymentData""" + + if not self.profile_id or not isinstance(self.profile_id, int): + self._display_error(whats_wrong=DataError.FORMAT, whats_it_called="profile_id", the_thing=self.profile_id) + + billing_data = PaymentData(instance_id=self.profile_id) + + if not billing_data: + self._display_error(whats_wrong=DataError.BLANK, whats_it_called="Profile's Billing Data", the_thing=billing_data) + + currency = billing_data.currency + + if not currency: + self._display_error(whats_wrong=DataError.BLANK, whats_it_called="currency", the_thing=currency) + + if not isinstance(currency, Currency): + self._display_error(whats_wrong=DataError.FORMAT, whats_it_called="currency", the_thing=currency) + + return currency diff --git a/gui/v2/ui/styles/payment_styles.py b/gui/v2/ui/styles/payment_styles.py new file mode 100644 index 0000000..d39cb59 --- /dev/null +++ b/gui/v2/ui/styles/payment_styles.py @@ -0,0 +1,56 @@ + +DURATION_SELECTION_STYLE = """ + QComboBox { + font-size: 16px; + padding: 10px; + background-color: #000000; + color: #00ffff; + border: 2px solid #00ffff; + border-radius: 8px; + } + QComboBox::drop-down { + width: 30px; + background-color: #1a1a1a; + border-left: 1px solid #00ffff; + } + QComboBox::down-arrow { + width: 12px; + height: 12px; + color: #00ffff; + } + QComboBox QAbstractItemView { + background-color: #0a0a0a; + border: 1px solid #00ffff; + border-radius: 4px; + selection-background-color: #003333; + selection-color: #00ffff; + } + QComboBox QAbstractItemView::item { + padding: 8px; + border: none; + color: #00ffff; + background-color: #0a0a0a; + } + QComboBox QAbstractItemView::item:hover { + background-color: #1a1a1a; + color: #00ffff; + } + QComboBox QAbstractItemView::item:selected { + background-color: #003333; + color: #00ffff; + } +""" + + + +NEXT_BUTTON_STYLE = """ + QPushButton { + font-size: 18px; + padding: 10px; + border: 2px solid #ccc; + border-radius: 10px; + background-color: cyan; + color: black; + font-weight: bold; + } +""" \ No newline at end of file