Multiple invoices resumed, each one stops the timer on the other.

This commit is contained in:
SimplifiedPrivacy 2026-10-10 15:24:36 -04:00
parent daa3815c38
commit e071b0f502
11 changed files with 500 additions and 168 deletions

View file

@ -1,7 +1,8 @@
# core
from core.services.subscriptions.check_payment_loop import CheckPaidTimer
from core.services.subscriptions.subscriptions_by_protocol import create_invoice, save_billing_code, get_profile_subscription
from core.models.enum_models import PaymentType
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
from core.services.payment_phase.check_payment_loop import CheckPaidTimer
from core.services.subscriptions.subscriptions_by_protocol import create_individual_invoice, save_billing_code, get_profile_subscription, start_billing_state, wipe_billing_cycle
from core.models.enum_models import PaymentType, PaymentCategory
from core.models.Result import Result, ResultError
from core.errors.logger import logger
@ -13,24 +14,105 @@ from gui.v2.infrastructure.setup_observers import connection_observer
# generic
from typing import Callable, Optional
def gui_starts_invoice(profile_id: int, invoice_ui_caller: Callable, timer_resume_caller: Callable) -> str | None:
"""Create a new invoice if we don't have one yet, or re-use the saved data. Returns any errors as strings for UI to display."""
subscription = get_profile_subscription(profile_id)
def new_individual_billing(profile_id: int):
"""Wipes pre-existing global payment cycle. And Calls upon core to setup a new individual profile cycle & data state."""
logger.info("starting a new individual profile state. And wiping the old one")
return start_billing_state(
category=PaymentCategory.INDIVIDUAL,
profile_id=profile_id,
wipe_existing=True
)
def gui_starts_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: Optional[int] = None) -> str | None:
logger.info(f"gui_starts_invoice is being called with profile id {profile_id}")
# Is there currently a process?
payment_cycle = OverallPaymentCycle()
# start a new individual one if not existing:
if not payment_cycle:
logger.info("There's no current payment cycle")
if profile_id:
return fetch_individual_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller, profile_id=profile_id)
else:
return "Need a profile id for an individual profile."
billing_data = payment_cycle.instance
category = payment_cycle.category
if not billing_data:
msg = "There is a payment cycle, but no current billing data in it. So we're starting a new cycle.."
logger.info(msg)
result = new_individual_billing(profile_id=profile_id)
if not result.valid:
return result
billing_data = result.data
# already conflicting one:
elif profile_id and profile_id != payment_cycle.profile_id:
logger.info("The new profile id request is different than the pre-existing running one. So we're wiping it and stopping the timer.")
worked, message = stop_payment_timer()
if not worked:
return message
# start over:
payment_cycle.wipe()
new_data = new_individual_billing(profile_id)
if not new_data.valid:
return new_data # result object
category = PaymentCategory.INDIVIDUAL
# billing_data = new_cycle.data
#----------------------------
# INVOICE DATA
#----------------------------
if category == PaymentCategory.INDIVIDUAL:
if profile_id:
return fetch_individual_invoice(invoice_ui_caller=invoice_ui_caller, timer_resume_caller=timer_resume_caller, profile_id=profile_id)
else:
return "Need a profile id for an individual profile."
elif category == PaymentCategory.TICKET:
print("ready to start for tickets")
def fetch_individual_invoice(invoice_ui_caller: Callable, timer_resume_caller: Callable, profile_id: int) -> str | None:
"""
Purpose:
Create a new invoice if we don't have one yet, or re-uses the saved data.
And starts polling if it's not already.
Context:
For individual profiles.
Returns:
Returns any errors as strings for UI to display.
"""
# Fetch:
take_action(
action=create_invoice,
action=create_individual_invoice,
ui_caller=invoice_ui_caller,
profile_id=profile_id,
connection_observer=connection_observer
)
#----------------------------
# FILTER
# FILTER ALREADY POLLING
#----------------------------
payment_cycle = OverallPaymentCycle()
if payment_cycle.already_polling:
logger.info("This cycle is already polling, so we don't need to start another poll")
return None
#----------------------------
# FILTER LACK OF DATA
#----------------------------
subscription = get_profile_subscription(profile_id)
if not subscription:
logger.info(f"Started a New Invoice for Profile {profile_id}..")
return None
@ -50,6 +132,9 @@ def gui_starts_invoice(profile_id: int, invoice_ui_caller: Callable, timer_resum
# Setup PyQt6 GUI as mainthread to bring back to:
pyqt_threader = ThreadSafetyTool()
# update the global singleton:
payment_cycle.already_polling = True
# setup the timer with the callback:
# it doesn't return result here. It calls the view_callback function.
check_paid_timer = CheckPaidTimer(
@ -112,6 +197,10 @@ def save_and_begin_timer(
def stop_payment_timer() -> tuple:
"""Gets the single instance of the payment checker timer, and stops it."""
try:
# Wipe the cycle, so this is not the active poller (blocking others)
wipe_billing_cycle()
# stop the timer:
check_paid_timer = CheckPaidTimer.get_instance()
if check_paid_timer:
check_paid_timer.stop_polling()

View file

@ -0,0 +1,34 @@
# core
from core.services.payment_phase.payment_state import OverallPaymentCycle, PaymentData, TicketPaymentData
from core.models.enum_models import PaymentType
from core.controllers.tickets.UseTicketController import get_unused_tickets
from core.models.Result import Result, ResultError
# from core.controllers.ProfileController import ProfileController
# 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, ticket_observer
def get_ticket_data() -> Result:
"""This is running in the background ALREADY. Get a list of unused tickets from core"""
ticket_results = get_unused_tickets()
valid = ticket_results.get('valid')
if not valid:
message = ticket_results.get('message')
if not message:
message = "Error gathering ticket data"
Result(valid=False, error_type=ResultError.MISSING_FILE, message=message)
raw_tickets = ticket_results.get('data')
print(f"raw_tickets is {raw_tickets}")
if not raw_tickets:
Result(valid=True, data=raw_tickets, message="No tickets available.")
formatted_data = [(f"Ticket #{t}", t) for t in raw_tickets]
print(f"formatted_data is {formatted_data}")
return Result(valid=True, data=formatted_data)

View file

@ -85,25 +85,25 @@ class Navigator(QObject):
def _preload_one(self, name):
if name in self._instances:
logger.debug(f"[preload] {name:30s} SKIP (already cached)")
# logger.debug(f"[preload] {name:30s} SKIP (already cached)")
return False
if name not in PAGE_REGISTRY:
logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
# logger.debug(f"[preload] {name:30s} SKIP (not in registry)")
return False
module_path, class_name = PAGE_REGISTRY[name]
logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
# logger.debug(f"[preload] {name:30s} LOAD {module_path}.{class_name}")
t0 = time.perf_counter()
try:
page = self._instantiate(name)
self.register_instance(name, page)
dt_ms = (time.perf_counter() - t0) * 1000.0
logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
# logger.debug(f"[preload] {name:30s} OK ({dt_ms:6.1f} ms, "
# f"cached={len(self._instances)}, stack_size={self.page_stack.count()})")
return True
except Exception as e:
dt_ms = (time.perf_counter() - t0) * 1000.0
logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
f"{type(e).__name__}: {e}")
# logger.debug(f"[preload] {name:30s} FAIL ({dt_ms:6.1f} ms) "
# f"{type(e).__name__}: {e}")
traceback.print_exc()
from core.errors.logger import logger as core_logger
core_logger.warning(f"Background preload failed for '{name}': "
@ -126,22 +126,22 @@ class Navigator(QObject):
self._preload_inflight = 0
self._preload_summary_emitted = False
self._preload_t_start = time.perf_counter()
logger.debug(f"[preload] ============================================================")
logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
if fast_mode:
logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
logger.debug(f"[preload] ============================================================")
# logger.debug(f"[preload] ============================================================")
# logger.debug(f"[preload] starting background preload: {self._preload_total} pages queued")
# logger.debug(f"[preload] order : {', '.join(self._preload_queue)}")
# logger.debug(f"[preload] skipped : {', '.join(sorted(PRELOAD_SKIP))} (unsafe init)")
# if fast_mode:
# logger.debug(f"[preload] fastmode: ON -> skipping regular-flow pages: "
# f"{', '.join(skipped_regular) if skipped_regular else '(none)'}")
# logger.debug(f"[preload] gap : 250 ms between loads, 700 ms initial delay")
# logger.debug(f"[preload] ============================================================")
self._preload_timer.start(700)
def _preload_next(self):
while self._preload_queue and self._preload_queue[0] in self._instances:
skipped = self._preload_queue.pop(0)
self._preload_skipped_runtime += 1
logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
# logger.debug(f"[preload] {skipped:30s} SKIP (user navigated here first)")
if not self._preload_queue:
self._maybe_emit_preload_summary()
return
@ -197,7 +197,7 @@ class Navigator(QObject):
def _on_preload_failed(self, name, error):
self._preload_inflight -= 1
logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
# logger.debug(f"[preload] {name:30s} FAIL (async prepare) {error}")
if name not in self._instances:
ok = self._preload_one(name)
if ok:

View file

@ -61,10 +61,8 @@ REGULAR_FLOW_ONLY = {
PRELOAD_ORDER = [
"settings",
"editor",
"ticket_or_billing_choice",
"plan_picker",
"ticket_crypto_picker",
"currency_selection",
"policy_suggestion",
"systemwide_prompt",
"fast_mode_prompt",
@ -86,3 +84,5 @@ PRELOAD_ORDER = [
# "duration_selection",
# "payment_details",
# "ticket_or_billing_choice",
# "currency_selection",

View file

@ -1,6 +1,10 @@
# core
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets, get_current_billing_data
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
# gui
from gui.v2.ui.pages.Page import Page
import os
@ -71,10 +75,27 @@ class CurrencySelectionPage(Page):
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)
print("doing the new method")
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))
# prior direct method:
# 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()
billing_singleton.currency = self._get_selected_currency()
self.update_status.update_status('Loading payment details...')
self.custom_window.navigator.navigate("payment_details")
@ -91,4 +112,7 @@ class CurrencySelectionPage(Page):
def reverse(self):
if paying_for_tickets():
self.custom_window.navigator.navigate("plan_picker")
else:
self.custom_window.navigator.navigate("duration_selection")

View file

@ -5,6 +5,7 @@ from core.controllers.profile_state.data_filters import get_plans_for_profile
from core.models.Result import Result #, ResultError
# GUI
from gui.v2.actions.payment_actions import new_individual_billing
from gui.v2.ui.styles.payment_styles import DURATION_SELECTION_STYLE, NEXT_BUTTON_STYLE
from gui.v2.ui.pages.Page import Page
@ -51,8 +52,18 @@ class DurationSelectionPage(Page):
# get profile id:
profile_id = int(self.update_status.current_profile_id)
# start entire payment cycle process:
result = new_individual_billing(profile_id=profile_id)
if not result.valid:
self.show_a_popup(str(result.message))
return
# setup singleton
self.billing_data = PaymentData(instance_id=profile_id)
self.billing_data = result.data
# PaymentData(instance_id=profile_id)
if self.billing_data:
print(f"we have billing data: {self.billing_data}")
# Thread SQL in background:
take_action(
@ -89,11 +100,13 @@ class DurationSelectionPage(Page):
plan_id = self.dict_of_choices[current_choice]
# save to singleton across pages:
print(f"saving plan_id to the billing data")
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:
print("going to the next page")
# currency_page = self.custom_window.navigator.get_cached("currency_selection")
# if currency_page is not None:
self.custom_window.navigator.navigate("currency_selection")
def reverse(self):

View file

@ -1,17 +1,23 @@
# core
from core.services.subscriptions.subscriptions_by_protocol import paying_for_tickets
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, stop_payment_timer
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
@ -19,12 +25,23 @@ 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
QVBoxLayout,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QWidget,
QApplication,
QFrame,
QSpacerItem,
QSizePolicy,
QStackedWidget,
QMainWindow,
)
from PyQt6.QtCore import Qt
from PyQt6.QtGui import QIcon
class PaymentDetailsPage(Page):
"""
Display Payment Details, fetch an invoice, and launch a repeat timer to check payment status.
@ -33,12 +50,17 @@ class PaymentDetailsPage(Page):
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):
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 = False
self.ticketing = paying_for_tickets()
self.polling = False
self.setStyleSheet(PAYMENT_STYLE_SHEET)
@ -47,6 +69,13 @@ class PaymentDetailsPage(Page):
self.create_initial_elements()
self.button_reverse.setVisible(True)
# self.begin_invoice()
def showEvent(self, event):
# Call the parent class showEvent first
super().showEvent(event)
# runs every time the widget is displayed
self.begin_invoice()
@ -87,7 +116,9 @@ class PaymentDetailsPage(Page):
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))
btn.clicked.connect(
lambda checked, key=field_key: self.copy_text(key)
)
row_layout.addWidget(btn)
card_layout.addLayout(row_layout)
@ -98,7 +129,9 @@ class PaymentDetailsPage(Page):
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;")
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)
@ -134,11 +167,17 @@ class PaymentDetailsPage(Page):
def begin_invoice(self):
""" "Fetches an Invoice from GUI's actions modules"""
self.update_status.update_status('Getting Invoice...')
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.profile_id:
self.display_error(whats_wrong=DataError.BLANK, whats_it_called="Profile ID", the_thing=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:
@ -147,7 +186,11 @@ class PaymentDetailsPage(Page):
return # error is already displayed in subfunction.
# launches background thread tasks:
error_msg = gui_starts_invoice(profile_id=self.profile_id, invoice_ui_caller=self.receive_invoice, timer_resume_caller=self.timer_updates_gui)
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)
@ -157,7 +200,6 @@ class PaymentDetailsPage(Page):
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:
@ -177,7 +219,6 @@ class PaymentDetailsPage(Page):
else:
self.begin_invoice()
def background_fetch_mode(self):
"""Continue checking if paid, but navigate back to the menu page"""
if self.polling:
@ -186,7 +227,6 @@ class PaymentDetailsPage(Page):
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:
@ -198,24 +238,31 @@ class PaymentDetailsPage(Page):
msg_map = {
"billing_code": "Billing code copied!",
"address": "Address copied!",
"amount": "Pay amount 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')
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)
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)
self.display_error(
whats_wrong=DataError.FORMAT,
whats_it_called="Backend Invoice Result",
the_thing=invoice_result,
)
return
if not invoice_result.valid:
@ -227,29 +274,38 @@ class PaymentDetailsPage(Page):
invoice = invoice_result.data
if not invoice:
self.display_error(whats_wrong=DataError.BLANK, whats_it_called="invoice", the_thing=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)
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...')
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,
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
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.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"""
@ -264,23 +320,27 @@ class PaymentDetailsPage(Page):
if duration:
self.fields["duration"].setText(duration)
self.fields["duration"].setStyleSheet('font-size: 13px;')
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 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"].width(),
)
self.fields["address"].setText(elided_text)
@ -294,7 +354,6 @@ class PaymentDetailsPage(Page):
self.stop_button.setDisabled(False)
self.switch_to_background_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"""
@ -308,10 +367,9 @@ class PaymentDetailsPage(Page):
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.')
self.update_status.update_status("Payment received.")
# TICKETS
if self.ticketing:
@ -320,7 +378,6 @@ class PaymentDetailsPage(Page):
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:
@ -330,15 +387,18 @@ class PaymentDetailsPage(Page):
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 = 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')
self.update_status.update_status("No address available for QR code")
def reverse(self):
"""Navigate away from the page, depending on which payment flow."""
@ -348,24 +408,39 @@ class PaymentDetailsPage(Page):
else:
self.custom_window.navigator.navigate("menu")
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)
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)
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)
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)
self.display_error(
whats_wrong=DataError.FORMAT,
whats_it_called="currency",
the_thing=currency,
)
return currency

View file

@ -1,16 +1,29 @@
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
from PyQt6 import QtCore
# core
from core.services.subscriptions.subscriptions_by_protocol import start_billing_state
from core.models.enum_models import PaymentCategory
# gui
# from gui.v2.actions.payment_actions import start_billing_state
from gui.v2.ui.pages.Page import Page
from gui.v2.ui.styles.styles import TERMINAL_LIST_QSS
from gui.v2.workers.ticketing_worker_thread import TicketingWorkerThread
# generic
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem
from PyQt6 import QtCore
class PlanPickerPage(Page):
def __init__(self, page_stack, main_window=None, parent=None):
super().__init__("PickPlan", page_stack, main_window, parent)
self.update_status = main_window
# this page only, immediate pricing display:
self.pricing_data = None
# cross page cache overall data:
self.billing_data = None
self.worker = None
self.title.setText("Pick a Plan")
@ -39,6 +52,19 @@ class PlanPickerPage(Page):
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
def start_sync(self):
# STATE
result = start_billing_state(category=PaymentCategory.TICKET)
if not result.valid:
error_msg = result.message
if error_msg:
self.show_a_popup(str(error_msg))
else:
self.show_a_popup("Error with Starting Billing")
# cache data to save the choice to it:
self.billing_data = result.data
# Begin Tickets:
self.plan_list.clear()
self.status_label.setText("Syncing prices...")
self.update_status.update_status("Syncing ticket prices...")
@ -88,10 +114,23 @@ class PlanPickerPage(Page):
plan_key = item.data(QtCore.Qt.ItemDataRole.UserRole)
if not plan_key:
return
self.custom_window.navigator.navigate("ticket_crypto_picker")
crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
if crypto_page is not None:
crypto_page.set_selected_plan(plan_key)
print(f"plan_key is {plan_key}")
# in temp cache save choice:
self.billing_data.which_key = plan_key
# go to next page:
currency_page = self.custom_window.navigator.get_cached("currency_selection")
if currency_page is not None:
self.custom_window.navigator.navigate("currency_selection")
# self.custom_window.navigator.navigate("ticket_crypto_picker")
# crypto_page = self.custom_window.navigator.get_cached("ticket_crypto_picker")
# if crypto_page is not None:
# crypto_page.set_selected_plan(plan_key)
def on_error(self, msg):
self.status_label.setText(f"Error: {msg}")

View file

@ -1,13 +1,25 @@
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton
from PyQt6 import QtCore
from core.controllers.ProfileController import ProfileController
from core.controllers.tickets.UseTicketController import get_unused_tickets
# core
# from core.controllers.ProfileController import ProfileController
# from core.controllers.tickets.UseTicketController import get_unused_tickets
from core.models.Result import Result, ResultError
# gui
from gui.v2.ui.styles.css.ticket_billing_styles import TICKET_LIST_STYLE_ONE, TICKET_BUTTON_A, USE_BILLING_BUTTON_STYLE
from gui.v2.actions.locations import location_candidates
from gui.v2.actions.menu_tasks import enable_profile
from gui.v2.infrastructure.setup_observers import ticket_observer
from gui.v2.ui.pages.Page import Page
from gui.v2.infrastructure.take_plumbing_action import take_action
from gui.v2.actions.ticket_actions import get_ticket_data
# Bring background threads to the main thread UI:
from gui.v2.infrastructure.ThreadSafetyTool import ThreadSafetyTool
mainthread = ThreadSafetyTool()
from PyQt6.QtWidgets import QLabel, QListWidget, QListWidgetItem, QPushButton
from PyQt6 import QtCore
class TicketOrBillingChoicePage(Page):
@ -32,87 +44,103 @@ class TicketOrBillingChoicePage(Page):
self.ticket_list = QListWidget(self)
self.ticket_list.setGeometry(60, 140, 380, 320)
self.ticket_list.setStyleSheet("""
QListWidget {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 8px;
font-size: 14px;
}
QListWidget::item { padding: 10px; }
QListWidget::item:hover { background-color: #003333; }
QListWidget::item:selected { background-color: #006666; color: white; }
""")
self.ticket_list.setStyleSheet(TICKET_LIST_STYLE_ONE)
self.use_ticket_button = QPushButton("Use Selected Ticket", self)
self.use_ticket_button.setGeometry(60, 470, 380, 40)
self.use_ticket_button.setStyleSheet("""
QPushButton {
background-color: #00aaff; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
""")
self.use_ticket_button.setStyleSheet(TICKET_BUTTON_A)
self.use_ticket_button.clicked.connect(self.on_use_ticket)
self.use_billing_button = QPushButton("Use a Billing Code Instead", self)
self.use_billing_button.setGeometry(470, 200, 280, 60)
self.use_billing_button.setStyleSheet("""
QPushButton {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 10px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background-color: #003333; }
""")
self.use_billing_button.setStyleSheet(USE_BILLING_BUTTON_STYLE)
self.use_billing_button.clicked.connect(self.on_use_billing)
self.status_label = QLabel("", self)
self.status_label.setGeometry(20, 520, 760, 20)
self.status_label.setStyleSheet("color: #cccccc; font-size: 12px;")
self.status_label.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
self.get_tickets()
def populate(self):
def get_tickets(self):
"""Gets tickets in the background"""
take_action(
action=get_ticket_data,
ui_caller=self.populate
)
@mainthread
def populate(self, result: Result):
"""Callback to Populate tickets."""
self.ticket_list.clear()
try:
unused = get_unused_tickets(ticket_observer)
except Exception as e:
self.status_label.setText(f"Error reading tickets: {e}")
message = result.message
if not result.valid:
self.status_label.setText("Error reading tickets")
if message:
self.show_a_popup(message) # this is an error
return
if unused.get('valid'):
for t in unused.get('data', []):
item = QListWidgetItem(f"Ticket #{t}")
item.setData(QtCore.Qt.ItemDataRole.UserRole, t)
data = result.data
if not data:
if message:
self.status_label.setText(message) # this is valid state of none
return
# POPULATE
for display_text, ticket_id in result.data:
item = QListWidgetItem(display_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, ticket_id)
self.ticket_list.addItem(item)
else:
self.status_label.setText(unused.get('message', 'No tickets available.'))
def on_use_ticket(self):
# 1. Get the visual item the user clicked on
item = self.ticket_list.currentItem()
if not item:
self.status_label.setText("Pick a ticket from the list first.")
return
# 2. This returns the actual ID (e.g., 1), NOT the string "Ticket #1"
which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole)
profile_id = self.update_status.current_profile_id
try:
profile = ProfileController.get(int(profile_id))
except Exception:
profile = None
candidates = location_candidates(profile)
if not candidates:
self.status_label.setText("Could not determine profile location.")
return
profile_data = {
'id': int(profile_id),
'use_ticket': which_ticket,
'ticket_location': candidates[0]
}
menu_page = self.custom_window.navigator.get_cached("menu")
if menu_page:
self.update_status.update_status(f"Using ticket #{which_ticket}...")
self.profile_enable_worker = enable_profile(
profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
)
self.custom_window.navigator.navigate("menu")
print(f"which_ticket is {which_ticket}")
profile_id = int(self.update_status.current_profile_id)
print(f"profile_id is {profile_id}")
# def on_use_ticket(self):
# item = self.ticket_list.currentItem()
# if not item:
# self.status_label.setText("Pick a ticket from the list first.")
# return
# which_ticket = item.data(QtCore.Qt.ItemDataRole.UserRole)
# profile_id = self.update_status.current_profile_id
# try:
# profile = ProfileController.get(int(profile_id))
# except Exception:
# profile = None
# candidates = location_candidates(profile)
# if not candidates:
# self.status_label.setText("Could not determine profile location.")
# return
# profile_data = {
# 'id': int(profile_id),
# 'use_ticket': which_ticket,
# 'ticket_location': candidates[0]
# }
# menu_page = self.custom_window.navigator.get_cached("menu")
# if menu_page:
# self.update_status.update_status(f"Using ticket #{which_ticket}...")
# self.profile_enable_worker = enable_profile(
# profile_data, menu_page.handle_task_result, allow_uninstalled_tor=True,
# )
# self.custom_window.navigator.navigate("menu")
def on_use_billing(self):
self.custom_window.navigator.navigate("id")

View file

@ -100,7 +100,7 @@ class TicketPrepPage(Page):
def on_error(self, msg):
self.terminal.append(f"EXCEPTION: {msg}")
self.status_label.setText(f"An unkown error occured")
self.status_label.setText(f"An unknown error occured")
self.continue_button.setEnabled(True)
def on_continue(self):

View file

@ -0,0 +1,30 @@
TICKET_LIST_STYLE_ONE = """
QListWidget {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 8px;
font-size: 14px;
}
QListWidget::item { padding: 10px; }
QListWidget::item:hover { background-color: #003333; }
QListWidget::item:selected { background-color: #006666; color: white; }
"""
TICKET_BUTTON_A = """
QPushButton {
background-color: #00aaff; color: white; border: none;
border-radius: 5px; font-size: 14px; font-weight: bold;
}
QPushButton:disabled { background-color: #555555; }
"""
USE_BILLING_BUTTON_STYLE = """
QPushButton {
background-color: #000000; color: #00ffff;
border: 2px solid #00ffff; border-radius: 10px;
font-size: 14px; font-weight: bold;
}
QPushButton:hover { background-color: #003333; }
"""