hydraveil-gui/gui/v2/ui/pages/payments2.py

328 lines
13 KiB
Python

# 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