# core from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data from core.services.payment_phase.payment_state import PaymentData from core.models.pydantic_models.ServerReply import SubscriptionInvoice from core.models.invoice.TicketInvoice import TicketInvoice 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, stop_payment_timer, ) from gui.v2.ui.pages.Page import Page, DataError from gui.v2.ui.popups.qrcode_dialog import QRCodeDialog from gui.v2.ui.styles.css.payment_styles import PAYMENT_STYLE_SHEET # Bring background threads to the main thread UI: from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool mainthread = ThreadSafetyTool() # generic from typing import Optional import os from PyQt6 import QtCore, QtWidgets from PyQt6.QtWidgets import ( QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QWidget, QApplication, QFrame, QSpacerItem, QSizePolicy, QStackedWidget, QMainWindow, ) from PyQt6.QtCore import Qt from PyQt6.QtGui import QIcon from enum import Enum # For formatting errors graphically: class DataError(Enum): FORMAT: "format" BLANK: "blank" class PaymentDetailsPage(Page): """ Display Payment Details, fetch an invoice, and launch a repeat timer to check payment status. From Page: Inherits display_error, show_a_popup From Navigator: gets the functions navigate, get_cached, page_stack object """ def __init__( self, page_stack: QStackedWidget, main_window: Optional[QMainWindow] = None, parent: Optional[QWidget] = None, ): super().__init__("Payment Details", page_stack, main_window, parent) self.update_status = main_window self.fields = {} self.ticketing = paying_for_tickets() self.polling = False self.setStyleSheet(PAYMENT_STYLE_SHEET) self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) self.create_initial_elements() self.button_reverse.setVisible(True) # self.begin_invoice() def showEvent(self, event): """Refresh data. This runs every time the widget is displayed""" # Call the parent class showEvent first super().showEvent(event) self.ticketing = paying_for_tickets() self.begin_invoice() def create_initial_elements(self): """Setup Payment UI Buttons as part of a dict by field key.""" main_layout = QVBoxLayout(self) # put the entire group (card + buttons) in the middle: main_layout.setAlignment(Qt.AlignmentFlag.AlignCenter) main_layout.setSpacing(15) main_layout.setContentsMargins(10, 10, 10, 10) # --- THE INFO CARD --- card = QFrame() card.setObjectName("InfoCard") card_layout = QVBoxLayout(card) card_layout.setSpacing(8) card_layout.setContentsMargins(12, 12, 12, 12) def add_info_row(label_text, field_key=None, show_copy=False): row_layout = QHBoxLayout() row_layout.setContentsMargins(0, 0, 0, 0) lbl = QLabel(label_text) lbl.setStyleSheet("font-size: 12px;") lbl.setAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter) row_layout.addWidget(lbl) if field_key is not None: edit = QLineEdit() edit.setReadOnly(True) edit.setAlignment(Qt.AlignmentFlag.AlignCenter) self.fields[field_key] = edit row_layout.addWidget(edit) if show_copy: btn = QPushButton() btn.setFixedSize(28, 28) btn.setIcon(QIcon(os.path.join(self.btn_path, "paste_button.png"))) btn.clicked.connect( lambda checked, key=field_key: self.copy_text(key) ) row_layout.addWidget(btn) card_layout.addLayout(row_layout) add_info_row("Billing ID:", field_key="billing_code", show_copy=True) add_info_row("Subscription End-date", 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_sep = QLabel("Wallet Address") addr_sep.setStyleSheet( "font-size: 11px; font-weight: bold; color: #89b4fa; margin-top: 2px;" ) card_layout.addWidget(addr_sep) add_info_row("", field_key="address", show_copy=True) main_layout.addWidget(card) action_bar = QHBoxLayout() action_bar.setSpacing(10) action_bar.setAlignment(Qt.AlignmentFlag.AlignCenter) self.qr_code_button = QPushButton(" Scan QR Code") self.qr_code_button.setIcon(QIcon(os.path.join(self.btn_path, "qr-code.png"))) self.qr_code_button.setMinimumHeight(40) self.qr_code_button.clicked.connect(self.show_qr_code) self.qr_code_button.setDisabled(True) self.stop_button = QPushButton(" Stop Checking") self.stop_button.setObjectName("StopButton") self.stop_button.setMinimumHeight(40) self.stop_button.clicked.connect(self.stop_or_start_check) self.stop_button.setDisabled(True) self.switch_to_background_button = QPushButton(" Check in Background") self.switch_to_background_button.setMinimumHeight(40) self.switch_to_background_button.clicked.connect(self.background_fetch_mode) self.switch_to_background_button.setDisabled(True) action_bar.addWidget(self.qr_code_button) action_bar.addWidget(self.stop_button) action_bar.addWidget(self.switch_to_background_button) main_layout.addLayout(action_bar) self.button_reverse.clicked.connect(self.reverse) 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) print(f"profile_id is {self.profile_id}") if not self.ticketing and 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: error_msg = gui_starts_invoice( invoice_ui_caller=self.receive_invoice, timer_resume_caller=self.timer_updates_gui, profile_id=self.profile_id, ) if error_msg: self.show_a_popup(error_msg) else: self.polling = True # And make sure it's allowed to be stopped: self.stop_button.setDisabled(False) self.switch_to_background_button.setDisabled(False) def stop_or_start_check(self): """Toggle On/Off Polling""" # If it's on: if self.polling: stopped, message = stop_payment_timer() if stopped: self.update_status.update_status("Stopped checking!") self.stop_button.setDisabled(True) self.switch_to_background_button.setDisabled(True) # regardless of if it worked or not: if message: self.show_a_popup(str(message)) # Turn it back on if off: else: self.begin_invoice() def background_fetch_mode(self): """Continue checking if paid, but navigate back to the menu page""" if self.polling: self.custom_window.navigator.navigate("menu") else: message = "It was not polling to begin with." self.show_a_popup(message) def copy_text(self, key: str): """Copy/Paste fields and update the bottom status""" 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) # 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!")) 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=paying_for_tickets(), 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 into the labels""" print(f"filling in {invoice}") # 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) if self.currency: self.fields["currency"].setText(self.currency.value) # Now that we have the data populated, show the QR code: self.qr_code_button.setDisabled(False) # And make sure it's allowed to be stopped: self.stop_button.setDisabled(False) self.switch_to_background_button.setDisabled(False) @mainthread def timer_updates_gui(self, paid: bool, stopped: bool, message: str, billing_code: str): """Callback for the background thread timer to repeatedly update the UI""" # CONTINUED UPDATES if message and isinstance(message, str): self.update_status.update_status(message) # PAID if paid: self.update_status.update_status("Payment received.") if self.ticketing: self.ticket_payment_confirmed() else: self.regular_payment_confirmed(billing_code=billing_code) # NOT PAID BUT ENDED elif not paid and stopped: self._display_it_stopped(message) 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): """Navigate away from the page, depending on which payment flow.""" if self.ticketing: self.ticketing = False self.custom_window.navigator.navigate("ticket_crypto_picker") else: self.custom_window.navigator.navigate("menu") def _get_currency(self): """Get the currency from prior cached Payment Data""" 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, ) # get the singleton (this was created on a previous page) data_result = get_current_billing_data() if not data_result.valid: message = data_result.message if not message: message = "Error fetching data" self.show_a_popup(str(message)) # get the singleton (this was created on a previous page) billing_singleton = data_result.data if not billing_singleton: message = "Error, no billing data" self.show_a_popup(str(message)) currency = billing_singleton.currency if not currency: self.show_a_popup('Missing Currency') if not isinstance(currency, Currency): self.display_error( whats_wrong=DataError.FORMAT, whats_it_called="currency", the_thing=currency, ) return currency def regular_payment_confirmed(self, billing_code: str): """Conclude a regular payment by putting the billing code on the final confirmation window.""" payment_page = self.custom_window.navigator.get_cached("payment_confirmed") if payment_page is not None: for line in self.text_fields: line.setText('') payment_page.set_billing_code(billing_code) else: print("PaymentConfirmed page not found") def ticket_payment_confirmed(self): """Conclude a ticket payment by forwarding to the prep page.""" # self.ticketing = False self.update_status.update_status('Payment received.') self.custom_window.navigator.navigate("ticket_prep") prep_page = self.custom_window.navigator.get_cached("ticket_prep") if prep_page is not None: prep_page.start_prep()