Merge upstream v1.6.8 into the aio fork

Brings in ticket waves (per-wave price/currency/stock/fiat), the paginated
ticket endpoint, the organiser ticket-image template, and the SatsPay
on-chain surface. Refs #33.

Resolutions that were not mechanical, and why:

- set_ticket_paid debits the wave named on the ticket, keeping upstream's
  `> 0` guards; ours decremented unconditionally and could go negative.
  The purchase and free-ticket paths now stamp ticket_wave_id /
  ticket_wave_title, without which every sale would debit the primary wave.

- Pricing moved onto the selected wave (basket_totals takes it as a required
  argument, the promo-validate endpoint resolves the same wave through the
  shared _resolve_ticket_wave). event.price_per_ticket is a roll-up of the
  PRIMARY wave since sync_event_ticket_waves, so pricing off the event
  quoted and charged the first wave's price to buyers who picked a later
  one. Regression test added.

- Kept npub support in two places upstream removed it: the purchase
  endpoint's normalize_public_key path and the notification dispatcher.
  Upstream's replacement rejects with "Only NIP-05 Nostr identifiers are
  supported", which is false for this fork. The purchase-side rejection had
  merged in outside any conflict marker.

- _ticket_image_url existed on both sides as two unrelated features. Ours
  (always-attached rendered QR card) is now _ticket_card_url; upstream's
  (organiser template, opt-in per wave) keeps the name. Both are wired into
  the mail, and the /qr/{ticket_id} endpoint — also duplicated on both
  sides, on the same route — is merged into one handler rather than
  registered twice, where the second copy would have been unreachable.

- models._parse_date now accepts a full ISO datetime. Upstream's date-only
  strptime raised ValueError on any event whose closing_date carries a time,
  which create_event produces by defaulting it from event_end_date — it
  would have 500'd the purchase path, the public event gate and the promo
  preview. Reproduced before fixing.

- Dropped upstream's inline make_qr_png (we import a superset from .qr) and
  its duplicate paymentMethodOptions in display.js, which re-derived payment
  options from per-method booleans and offered an on-chain option the
  backend rejects; the submit gate now matches the template's condition.

- Restored imports the merge silently dropped with upstream's npub removal
  (normalize_public_key, normalize_private_key, DEFAULT_NOSTR_RELAYS).

Event create/update stays ours: upstream's combined endpoint would have
replaced the approval workflow and the explicit field allowlist that keeps
`status` out of the request body.

mypy error set is unchanged from HEAD; ruff, black and the 97 tests pass.
This commit is contained in:
Padreug 2026-09-28 22:09:18 +02:00
commit ae5affa44f
14 changed files with 2228 additions and 275 deletions

View file

@ -11,7 +11,7 @@ from __future__ import annotations
from collections import Counter
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket
from .models import BasketDiscount, BasketTotals, Event, PromoCode, Ticket, TicketWave
SAT_UNITS = ("sat", "sats")
@ -66,17 +66,26 @@ def basket_totals(
codes: list[str],
quantity: int,
usage: dict[str, int],
wave: TicketWave,
) -> BasketTotals:
"""Price `quantity` tickets with the first applicable code in `codes`.
"""Price `quantity` tickets from `wave` with the first applicable code.
A code is applicable when it exists, is active, has enough uses left
for the whole quantity, and actually saves something. Anything else is
simply absent from `discounts_applied` (upstream v2 semantics — the
purchase endpoint is where hard errors are raised). Only one code is
applied; v2's `combinable` stacking is out of scope here.
`wave` is the pricing authority, not `event`. Since v1.6.8 a ticket's
price and currency belong to the wave it is bought from, and the
event-level fields are a derived roll-up of the PRIMARY wave
(`sync_event_ticket_waves`) — pricing off `event` would quote and charge
the first wave's price to a buyer who picked a later one. It is a
required argument rather than an optional override precisely because
that failure is silent: both call sites have to name the wave.
"""
currency = event.currency or "sat"
subtotal = round_amount(event.price_per_ticket * quantity, currency)
currency = wave.currency or "sat"
subtotal = round_amount(wave.price_per_ticket * quantity, currency)
totals = BasketTotals(
subtotal=subtotal, discount=0, total=subtotal, currency=currency
)