lint.yml / Merge pull request 'fix(tickets): amount_tickets is the remaining count, stop subtracting sold' (#59) from fix/amount-tickets-remaining into main (push) Failing after 0s
`set_ticket_paid` decrements `amount_tickets` on every sale and
increments `sold`, so the two describe the same tickets. Subtracting
one from the other in `api_ticket_create` removed each sale twice:
remaining = amount_tickets - sold
That under-reported availability to buyers, and because
`remaining + sold` is the original capacity, `sold >= amount_tickets`
first becomes true at the halfway point — so every event locked itself
as sold out once half its seats had gone, with the rest still unsold.
Measured on aio-demo before the fix: 16 of 24 live events affected,
3 of them already refusing sales while stock remained. "Tech Meetup"
had 9 tickets left and offered -2.
The arithmetic was ours, introduced with the multi-quantity purchase
feature; upstream has no equivalent because it sells one ticket per
request and reads `amount_tickets` as remaining everywhere. So this
deletes the divergence and restores upstream's own guard plus the one
line `quantity` needs, which should also make the v1.6.8 rebase (#33)
a little easier rather than harder.
Tests walk a 50-seat event to capacity one sale at a time and pin the
boundary: sold=49 passes even unfixed, sold=50 is where it used to
lock. Six of the ten fail without the change.
Scoped deliberately to the double-subtraction. The rest of #34 — making
capacity a required field, dropping the `0 = unlimited` reading, and the
organizer form that prefills remaining as though it were capacity —
waits for #33, since ticket waves change the shape of that fix.
Refs #34
`publish_nostr_event` returned as soon as the EVENT was on the send
queue, and the publisher logged "Published" on the next line. Queueing
is not delivery: nostrclient drops an EVENT outright when no relay is
connected, answering `OK false "error: no relays connected"`. We threw
that reply away.
On cfaun this cost a completed repair. The #55 sweep republished a
14-day-stale calendar event 21 seconds before nostrclient had finished
connecting to its relay, got `OK false`, logged `Published`, reported
`1/1 recovered` and cleared `nostr_publish_pending` — leaving the count
stale, the row unflagged and the log asserting success. It took a
manual re-arm of the flag to finish the job.
So the flag's contract was never true: it claimed to clear only on a
confirmed success but cleared on a confirmed enqueue.
`publish_nostr_event` now registers a future per event id, awaits the
`OK`, and returns whether it was accepted. `publish_event_to_nostr`
returns None when unconfirmed, which keeps the row flagged so the sweep
retries rather than recording a delivery that never happened.
Correlation lives in `get_event`, the one place relay messages cross
from the websocket thread into the event loop — no cross-thread future
juggling. OK frames are consumed there rather than forwarded; the sync
loop never handled them. A disconnect settles every in-flight publish
immediately instead of making callers wait out the timeout.
On latency: the timeout is not the common cost. A disconnected relay is
rejected by nostrclient's router in milliseconds (230ms measured on
aio-demo), so the 12s budget only applies when relays are connected but
silent, which nostrclient itself bounds at 10s. `set_ticket_paid` runs
on the invoice-listener task, so that narrow case does stall the loop;
if it ever matters, the remedy is to stop awaiting on the sale path
while leaving the flag set — the sweep already guarantees eventual
delivery — not to go back to reporting unverified success.
Closes#56
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
`_resolve_frontend_root` honours `CreateTicket.frontend_url` when its
origin is one of LNBITS_CORS_ALLOWED_ORIGINS, the LNbits base URL or
LNBITS_CUSTOM_FRONTEND_URL (400 otherwise — a silent fallback would send
the buyer to the wrong app), and falls back to request.base_url as before.
Under that root the fiat path now parameterises the hosted checkout via
`extra["checkout"]` (lnbits StripeCheckoutOptions): success_url
`/events/{id}?checkout=success&tickets=<ids>`, cancel_url
`/events/{id}?checkout=cancelled`, customer_email, an event-named line
item and event_id/quantity/ticket_ids metadata. Ticket ids are minted
before the invoice so the success URL can carry them (the payment_hash
only exists afterwards). ticket_base_url on the rows uses the same root,
so the emailed link lands in the webapp when the webapp was the client.
Purchases are gated on `effective_payment_methods(event)` (checked after
the free-ticket short-circuit, which charges nothing on any rail).
New anonymous `GET /events/api/v1/qr/{ticket_id}` returns a PNG of
`ticket://<id>` — port of upstream v1.6.8's endpoint without ticket-image
compositing — built at error-correction H with the instance QR logo
(`lnbits_qr_logo`) pasted in the centre, matching the client-side QRs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
`CreateTicket` no longer rejects `user_id` together with `name`/`email`
(the exclusion was a fork-only dispatch convenience from dfabcb8; nothing
needed it). `crud.create_ticket` stops blanking name/email when a user_id
is present, so logged-in webapp buyers can have their ticket emailed —
until now `_send_ticket_notification` short-circuited on the empty
address for every app purchase.
New optional `frontend_url` (absolute http(s) root, no query/fragment/..,
trailing slash stripped) lets a buyer-side client name the app the buyer
should be returned to and linked into from the ticket email; the origin
allow-list lives in views_api.
Also folds in the pending black reflow of migrations_fork.py.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
NIP-52 calendar events (31922/31923) are replaceable and republished
whenever inventory changes (a ticket sells). build_nip52_event stamped
created_at=int(time.time()); relays only push a replacement to OPEN
subscriptions when created_at is strictly newer, so two republishes in
the same wall-clock second tie and the second is silently dropped for
live subscribers — clients' "tickets remaining" badge stalls until a
reload. Same root cause as the webapp fix (aiolabs/webapp#122).
- Add monotonic_created_at() in nostr_timestamp.py = max(now, last+1),
mirroring the webapp helper + docs/nostr-patterns/replaceable-events.md.
- Anchor it on the already-persisted Event.nostr_event_created_at
(set after each publish in nostr_hooks.py). The kind-5 delete event is
not replaceable, so it keeps plain int(time.time()).
- Unit tests mirror the webapp's timestamp suite.
Concurrent same-second sales reading the same stored anchor can still
collide; full hardening (row-level lock) is noted as follow-up in #26.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>