forked from Support/sp-hydra-veil-gui
Big Bang Payments Refactor. Moved core logic out of the PyQt classes, instead it uses payment_actions, which coordinates with core background threaded tasks
This commit is contained in:
parent
5b97f6ad1b
commit
40ee53197e
9 changed files with 640 additions and 97 deletions
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
|
@ -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."""
|
"""Generate status text outside Qt so workers and pages report identical subscription state."""
|
||||||
subscription = getattr(profile, "subscription", None)
|
subscription = getattr(profile, "subscription", None)
|
||||||
profile_date = getattr(subscription, "expires_at", 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:
|
if profile_date is None:
|
||||||
return "Offline. No subscription found."
|
return "Offline. No subscription found."
|
||||||
|
|
||||||
|
|
|
||||||
95
gui/v2/actions/payment_actions.py
Normal file
95
gui/v2/actions/payment_actions.py
Normal file
|
|
@ -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,
|
||||||
|
|
@ -23,7 +23,7 @@ PAGE_REGISTRY = {
|
||||||
"policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"),
|
"policy_suggestion": ("gui.v2.ui.pages.policy_suggestion_page", "PolicySuggestionPage"),
|
||||||
"duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"),
|
"duration_selection": ("gui.v2.ui.pages.duration_selection_page", "DurationSelectionPage"),
|
||||||
"currency_selection": ("gui.v2.ui.pages.currency_selection_page", "CurrencySelectionPage"),
|
"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"),
|
"plan_picker": ("gui.v2.ui.pages.plan_picker_page", "PlanPickerPage"),
|
||||||
"ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"),
|
"ticket_crypto_picker": ("gui.v2.ui.pages.ticket_crypto_picker_page", "TicketCryptoPickerPage"),
|
||||||
"ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"),
|
"ticket_prep": ("gui.v2.ui.pages.ticket_prep_page", "TicketPrepPage"),
|
||||||
|
|
@ -62,10 +62,8 @@ PRELOAD_ORDER = [
|
||||||
"settings",
|
"settings",
|
||||||
"editor",
|
"editor",
|
||||||
"ticket_or_billing_choice",
|
"ticket_or_billing_choice",
|
||||||
"payment_details",
|
|
||||||
"plan_picker",
|
"plan_picker",
|
||||||
"ticket_crypto_picker",
|
"ticket_crypto_picker",
|
||||||
"duration_selection",
|
|
||||||
"currency_selection",
|
"currency_selection",
|
||||||
"policy_suggestion",
|
"policy_suggestion",
|
||||||
"systemwide_prompt",
|
"systemwide_prompt",
|
||||||
|
|
@ -85,3 +83,6 @@ PRELOAD_ORDER = [
|
||||||
"install_system_package",
|
"install_system_package",
|
||||||
"sync_screen",
|
"sync_screen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# "duration_selection",
|
||||||
|
# "payment_details",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
from gui.v2.ui.popups.error_popup import ErrorPopup
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from PyQt6.QtWidgets import QWidget, QLabel, QPushButton
|
from PyQt6.QtWidgets import QWidget, QLabel, QPushButton
|
||||||
|
|
@ -101,3 +103,17 @@ class Page(QWidget):
|
||||||
except TypeError:
|
except TypeError:
|
||||||
pass
|
pass
|
||||||
button.clicked.connect(handler)
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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.QtGui import QIcon
|
||||||
from PyQt6.QtCore import QSize
|
from PyQt6.QtCore import QSize
|
||||||
from PyQt6 import QtCore
|
|
||||||
|
|
||||||
from gui.v2.ui.pages.Page import Page
|
|
||||||
|
|
||||||
|
|
||||||
class CurrencySelectionPage(Page):
|
class CurrencySelectionPage(Page):
|
||||||
|
|
@ -13,53 +16,79 @@ class CurrencySelectionPage(Page):
|
||||||
super().__init__("Select Currency", page_stack, main_window, parent)
|
super().__init__("Select Currency", page_stack, main_window, parent)
|
||||||
self.update_status = main_window
|
self.update_status = main_window
|
||||||
self.selected_duration = None
|
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_reverse.setVisible(True)
|
||||||
self.button_next.setVisible(False)
|
self.button_next.setVisible(False)
|
||||||
|
|
||||||
def create_interface_elements(self):
|
|
||||||
|
def create_initial_elements(self):
|
||||||
self.title = QLabel("Payment Method", self)
|
self.title = QLabel("Payment Method", self)
|
||||||
self.title.setGeometry(QtCore.QRect(510, 30, 250, 40))
|
|
||||||
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
self.button_reverse.clicked.connect(self.reverse)
|
self.button_reverse.clicked.connect(self.reverse)
|
||||||
|
|
||||||
button_info = [
|
# container widget to hold the 'block' of buttons
|
||||||
("monero", (545, 75)),
|
self.button_container = QWidget(self)
|
||||||
("bitcoin", (545, 290)),
|
|
||||||
("lightnering", (545, 180)),
|
# Position of the entire group, not each individual button
|
||||||
("litecoin", (545, 395))
|
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.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 = 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.setIconSize(QSize(190, 120))
|
||||||
button.setCheckable(True)
|
button.setCheckable(True)
|
||||||
self.buttons.append(button)
|
button.setIcon(QIcon(os.path.join(self.btn_path, f"{currency_val}.png")))
|
||||||
self.buttonGroup.addButton(button, j)
|
|
||||||
button.setIcon(
|
|
||||||
QIcon(os.path.join(self.btn_path, f"{icon_name}.png")))
|
|
||||||
button.clicked.connect(self.on_currency_selected)
|
button.clicked.connect(self.on_currency_selected)
|
||||||
|
|
||||||
def on_currency_selected(self):
|
self.button_to_currency[button] = currency_val
|
||||||
self.custom_window.navigator.navigate("payment_details")
|
self.buttonGroup.addButton(button)
|
||||||
payment_details_page = self.custom_window.navigator.get_cached("payment_details")
|
self.layout.addWidget(button)
|
||||||
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...')
|
|
||||||
|
|
||||||
def get_selected_currency(self):
|
|
||||||
|
def on_currency_selected(self):
|
||||||
|
"""Sets the value selected to the singleton used across pages"""
|
||||||
|
|
||||||
|
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()
|
selected_button = self.buttonGroup.checkedButton()
|
||||||
if selected_button:
|
|
||||||
index = self.buttonGroup.id(selected_button)
|
# Retrieve the value from the map using the button object as the key
|
||||||
currencies = ["monero", "bitcoin", "lightning", "litecoin"]
|
raw_selection = self.button_to_currency.get(selected_button)
|
||||||
return currencies[index]
|
|
||||||
return None
|
# convert to enum:
|
||||||
|
return Currency(raw_selection)
|
||||||
|
|
||||||
|
|
||||||
def reverse(self):
|
def reverse(self):
|
||||||
self.custom_window.navigator.navigate("duration_selection")
|
self.custom_window.navigator.navigate("duration_selection")
|
||||||
|
|
|
||||||
|
|
@ -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.QtWidgets import QComboBox, QLabel, QPushButton
|
||||||
from PyQt6 import QtCore
|
from PyQt6 import QtCore
|
||||||
|
|
||||||
from gui.v2.ui.pages.Page import Page
|
|
||||||
|
|
||||||
|
|
||||||
class DurationSelectionPage(Page):
|
class DurationSelectionPage(Page):
|
||||||
def __init__(self, page_stack, main_window=None, parent=None):
|
def __init__(self, page_stack, main_window=None, parent=None):
|
||||||
super().__init__("Select Duration", page_stack, main_window, parent)
|
super().__init__("Select Duration", page_stack, main_window, parent)
|
||||||
self.update_status = main_window
|
self.update_status = main_window
|
||||||
self.create_interface_elements()
|
self.create_intitial_elements()
|
||||||
self.button_reverse.setVisible(True)
|
self.button_reverse.setVisible(True)
|
||||||
|
|
||||||
def create_interface_elements(self):
|
def create_intitial_elements(self):
|
||||||
self.title = QLabel("Select Duration", self)
|
self.title = QLabel("Select Duration", self)
|
||||||
self.title.setGeometry(QtCore.QRect(530, 30, 210, 40))
|
self.title.setGeometry(QtCore.QRect(530, 30, 210, 40))
|
||||||
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
self.title.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
|
||||||
|
|
@ -19,74 +30,76 @@ class DurationSelectionPage(Page):
|
||||||
|
|
||||||
self.combo_box = QComboBox(self)
|
self.combo_box = QComboBox(self)
|
||||||
self.combo_box.setGeometry(200, 200, 400, 60)
|
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.setEditable(False)
|
||||||
self.combo_box.setMaxVisibleItems(4)
|
self.combo_box.setMaxVisibleItems(4)
|
||||||
self.combo_box.setDuplicatesEnabled(True)
|
self.combo_box.setDuplicatesEnabled(True)
|
||||||
self.combo_box.setPlaceholderText("Select options")
|
self.combo_box.setPlaceholderText("Select options")
|
||||||
self.combo_box.setStyleSheet("""
|
self.combo_box.setStyleSheet(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;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
|
|
||||||
self.next_button = QPushButton("Next", self)
|
self.next_button = QPushButton("Next", self)
|
||||||
self.next_button.setGeometry(350, 300, 100, 40)
|
self.next_button.setGeometry(350, 300, 100, 40)
|
||||||
self.next_button.setStyleSheet("""
|
self.next_button.setStyleSheet(NEXT_BUTTON_STYLE)
|
||||||
QPushButton {
|
|
||||||
font-size: 18px;
|
|
||||||
padding: 10px;
|
|
||||||
border: 2px solid #ccc;
|
|
||||||
border-radius: 10px;
|
|
||||||
background-color: cyan;
|
|
||||||
color: black;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
""")
|
|
||||||
self.next_button.clicked.connect(self.show_next)
|
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):
|
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")
|
currency_page = self.custom_window.navigator.get_cached("currency_selection")
|
||||||
if currency_page is not None:
|
if currency_page is not None:
|
||||||
currency_page.selected_duration = self.combo_box.currentText()
|
self.custom_window.navigator.navigate("currency_selection")
|
||||||
|
|
||||||
def reverse(self):
|
def reverse(self):
|
||||||
self.custom_window.navigator.navigate("id")
|
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)"])
|
||||||
|
|
|
||||||
328
gui/v2/ui/pages/payments2.py
Normal file
328
gui/v2/ui/pages/payments2.py
Normal file
|
|
@ -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
|
||||||
56
gui/v2/ui/styles/payment_styles.py
Normal file
56
gui/v2/ui/styles/payment_styles.py
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
Loading…
Reference in a new issue