v1.6.8 sells tickets on-chain through SatsPay charges — a per-purchase
watchonly address, a hosted charge page, and a webhook back into the
extension. We are not adopting that: on-chain goes through the fork's
native LndRestWallet support once core can mint purpose-bound receiving
addresses (aiolabs/lnbits#53). This is the parity note on #41, applied
as its own commit so the merge it follows stays a faithful diff of what
upstream shipped.
Removed:
- services: the five SatsPay/watchonly HTTP clients (and with them the
only uses of httpx and typing.Any in that module)
- views_api: _get_watchonly_status, GET /events/onchain/status, the
SatsPay charge branch of api_ticket_create, POST
/tickets/{id}/satspay-webhook, PUT /tickets/{hash}/onchain-confirm
- models: EventExtra.onchain_{enabled,wallet_id,zeroconf,fasttrack},
TicketExtra.satspay_charge_id, TicketPaymentRequest.satspay_charge_url
- frontend: the organiser's "Onchain payments" panel and its watchonly
wallet picker, the per-ticket confirm-onchain button, the SatsPay
charge-page redirect, and the wallet-status fetch behind them
`onchain` is no longer an accepted payment_method — there is no rail to
fulfil it until #41 lands, so accepting it could only fail later.
Kept as vocabulary for #41, in upstream's field names so it needs no
migration: TicketExtra.onchain / onchain_address and
TicketPaymentRequest.onchain_amount_sat.
35 routes register with no duplicates; ruff, black, prettier clean; 97
tests pass; mypy error set still identical to HEAD's baseline.
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.
Inventory reaches clients only through the republished calendar event,
and until now a publish that failed or was skipped left no durable
trace — only a log line, if that. Twice the drift was caught by a human
reading a wrong number on a public page (#35 on aio-demo, #51 on cfaun,
where an event's relay copy sat 14 days behind the DB).
Adds `events.nostr_publish_pending`, set before every attempt and
cleared only on a confirmed success. Ordering it that way is what makes
"the attempt was never made" — no signer resolved, no NostrClient, the
process died mid-flight — as discoverable as "the attempt raised". Both
shapes have now been observed in production; only the second one was
ever visible.
`set_ticket_paid` raises the flag inside its own update so the counters
and "the relay doesn't know about them yet" commit atomically, and the
sale path pays no extra write.
`publish_or_delete_nostr_event` now returns a bool so callers can
branch. The flag, not the return value, is the durable record — the
existing call sites stay correct ignoring it.
Publish failures move from WARNING to ERROR: the published ticket count
has stopped tracking reality, which is not routine journal noise.
Refs #35
Promo handling was inherited from upstream unchanged and had four gaps
the webapp was about to put in front of buyers:
- `active` was decorative: purchase never read it, so a deactivated code
kept discounting. Now rejected with "Promo code is not active."
- No redemption cap (#32). `PromoCode.max_uses` (None/0 = unlimited) with
`used_count` DERIVED from paid tickets carrying the code in
`extra.applied_promo_code` — each ticket of a multi-ticket purchase
consumes one use (upstream v2 counts one per basket; documented).
Paid-only counting so an abandoned Stripe session can't lock out the
last uses for the 24 h unpaid-row lifetime; bounded overshoot under
concurrency accepted.
- Every code was readable by anyone: `PublicEvent.extra` was the full
`EventExtra` and `/events/public` returned the untrimmed `Event`
(wallet id included). `EventExtraBase` / `PublicEventExtra` project
them out; `/public` now goes through `PublicEvent`. Organizer and admin
listings keep the full model, now hydrated with `used_count`.
- No preview: `POST /events/api/v1/promo/validate/{event_id}` (same URL as
upstream v2; `quantity` replaces v2's `items` since this fork has no
ticket types) returns v2-shaped `BasketTotals` + `currency`. Advisory:
bad codes are simply absent from `discounts_applied`; purchase still
hard-fails them with distinct details.
All pricing (validate, invoice, Stripe amount) goes through one pure
`basket_totals` with a single rounding rule (whole sats / 2 dp fiat), so
the preview equals the charge. Stripe metadata carries `promo_code`; the
organizer stats rows carry `applied_promo_code`.
`api_event_update` keeps stored codes when the request omits
`extra.promo_codes` (explicit `[]` still clears): now that public
records don't carry them, a client round-tripping one would otherwise
wipe the organizer's codes on every edit.
Closes#32
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ByAwHU4pRnyE58YocQvAas
The v1.6.1-aio.9 mail still scored 8.4/10 on mail-tester: the remaining
deduction was HTML_IMAGE_ONLY (1.8) — an HTML part whose only content of
note is a remote <img>. Remote images are also blocked by default in most
clients until the reader opts in, and a bare QR saved from that mail says
nothing about what it opens.
- New `qr.py` module (QR + logo helpers moved out of views_api) with
`render_ticket_card`: site title, event name, when/where, the branded
QR, name on ticket, ticket id and the door instruction, laid out with
the bundled DejaVu Sans; `format_event_when` gives "Fri 19 Feb 2027,
16:00 - 20:00"; filenames are `ticket-<event-slug>-<id8>.png`.
- `GET /events/api/v1/ticket-card/{ticket_id}` serves the same PNG
(anonymous, like the QR endpoint); the email's "Ticket image" link
now points there.
- The ticket email becomes multipart/mixed: text + HTML alternatives
(URLs as links, no <img>) plus the card as a PNG attachment, which
clients show inline at the end of the message and which works offline
at the door.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
A tester's ticket email landed in spam. A mail-tester run against demo
scored 8.3/10 with SPF, DKIM and DMARC all passing through the VPS relay,
so the deductions were all in the message: MISSING_DATE (1.4),
HTML_IMAGE_ONLY_04 (0.3), MISSING_MID (0.1) — and Gmail/Outlook weigh a
missing Date/Message-ID as "machine-generated" far more than that.
- `build_ticket_email` sets Date, a Message-ID under the sender domain,
and a From display name from `lnbits_site_title`.
- The body now carries the event name, dates, location, name on ticket,
ticket id and the door instruction, so the HTML part is no longer a
QR with a handful of words.
Upstream candidate: lnbits core `send_email` has the same omissions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Port of upstream v1.6.8's delivery layer, wave-free: `_deliver_ticket_
notifications` sends text + HTML (the HTML embeds the ticket QR PNG from
this extension on the LNbits host — built from lnbits_baseurl on purpose,
since ticket_base_url may point at a separate web app) and returns a
`TicketResendResult` with per-channel attempted/sent/error. The SMTP
session runs via asyncio.to_thread so a slow relay cannot stall the event
loop while a batch of tickets settles. Resend keeps bypassing the
per-event email opt-in (organizer asked explicitly) and is email-only.
Our nsec-DM Nostr path is kept (upstream went NIP-05-only).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
Inventory sync over Nostr, mirroring how nostrmarket republishes
kind 30018 product events when stock changes. Connected webapp /
other-client subscriptions pick up the new state via their existing
relay subscription — no REST polling needed.
build_nip52_event grows four AIO custom tags on every published
kind 31922/31923 event:
- tickets_available — current remaining (omitted when amount_tickets
is 0, the schema's "unlimited" sentinel, so clients can tell the
difference between unlimited and sold-out)
- tickets_sold — running count, always emitted (clients derive
original_capacity = available + sold for progress bars)
- tickets_price — price_per_ticket (0 means free)
- tickets_currency — the currency string
Tags are AIO additions outside the NIP-52 spec; spec-compliant
clients MUST ignore unknown tags so this stays backwards-compatible.
set_ticket_paid calls publish_or_delete_nostr_event after the
counter update so the new state lands on relays. The whole sequence
(counter update + republish) is wrapped in a per-event-id asyncio
lock to address the existing # todo: lock and to ensure two paid
invoices for the same event can't reorder the published state.
Failures inside the Nostr publish are logged + swallowed by the
existing wrapper, so a relay outage can never break the payment
flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>