Skip to content

feat(tickets_v2): self-service order cancellation#982

Merged
japsu merged 10 commits into
mainfrom
feat/self-service-cancellation
Jun 13, 2026
Merged

feat(tickets_v2): self-service order cancellation#982
japsu merged 10 commits into
mainfrom
feat/self-service-cancellation

Conversation

@japsu

@japsu japsu commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Finnish consumer protection law requires webshops to offer a
self-service cancellation function as of June 2026.

Customers can now cancel paid orders themselves within a per-event
cancellation period (TicketsV2EventMeta.cancellation_period_days,
default 0 = disabled; capped at event start). Cancellation is
confirmed via a one-time link sent to the email address of the order,
after which an automated refund is initiated via the payment provider.
Paid orders that cannot be automatically refunded (eg. marked as paid
manually) direct the customer to contact ticket sales instead.

Backend:

  • Add cancellation_period_days to TicketsV2EventMeta
  • Add OrderCancellationToken (one-time code, no Person FK required)
  • Add Order.cancellation_deadline and can_be_cancelled_by_customer
  • Add RequestOrderCancellation, ConfirmOrderCancellation and
    UpdateTicketsPreferences GraphQL mutations
  • Add cancellation link and deadline to email receipts
  • Expose canRequestCancellation and cancellationDeadline in the
    optimized server order API
  • Expire the event caches of the receipt worker and the optimized
    server on a 5 minute TTL so admin settings changes take effect

Frontend:

  • New anonymous order cancellation and confirmation pages
  • Cancel button links on the order page and the profile order page
  • New tickets admin preferences page (contact email, T&C URLs,
    cancellation period) with a Settings tab
  • SubmitButton now shows a spinner and disables while submitting

Co-Authored-By: Claude Fable 5 noreply@anthropic.com

japsu and others added 10 commits June 12, 2026 23:38
Finnish consumer protection law requires webshops to offer a
self-service cancellation function as of June 2026.

Customers can now cancel paid orders themselves within a per-event
cancellation period (TicketsV2EventMeta.cancellation_period_days,
default 0 = disabled; capped at event start). Cancellation is
confirmed via a one-time link sent to the email address of the order,
after which an automated refund is initiated via the payment provider.
Paid orders that cannot be automatically refunded (eg. marked as paid
manually) direct the customer to contact ticket sales instead.

Backend:
- Add cancellation_period_days to TicketsV2EventMeta
- Add OrderCancellationToken (one-time code, no Person FK required)
- Add Order.cancellation_deadline and can_be_cancelled_by_customer
- Add RequestOrderCancellation, ConfirmOrderCancellation and
  UpdateTicketsPreferences GraphQL mutations
- Add cancellation link and deadline to email receipts
- Expose canRequestCancellation and cancellationDeadline in the
  optimized server order API
- Expire the event caches of the receipt worker and the optimized
  server on a 5 minute TTL so admin settings changes take effect

Frontend:
- New anonymous order cancellation and confirmation pages
- Cancel button links on the order page and the profile order page
- New tickets admin preferences page (contact email, T&C URLs,
  cancellation period) with a Settings tab
- SubmitButton now shows a spinner and disables while submitting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness and security:
- Refund the latest *provider* paid stamp instead of the latest paid stamp,
  which could be a provider NONE stamp (eg. order additionally marked as
  paid by an admin), burning the confirmation code without refunding
- Surface provider-rejected refunds to the customer (success=False ->
  "contact ticket sales to receive your refund") instead of claiming the
  refund is on its way; restore the confirmation code if cancellation
  failed without changing the order
- Render email datetimes in the event timezone (the |date template filter
  re-converts aware datetimes to the active timezone, which defeated
  astimezone())
- Rate limit cancellation request emails to one per order per minute
  (anonymous mutation; prevents email bombing)
- Expire cancellation confirmation links after 24 hours
- Require CBAC for TicketsV2EventMeta.contact_email in GraphQL (was
  newly readable by anonymous clients in Name <email> form)
- UpdateTicketsPreferences leaves omitted fields unchanged instead of
  silently wiping them

Performance:
- Compute paid_by_provider in claim_pending_receipts.sql instead of one
  PaymentStamp EXISTS query per receipt in the worker batch loop

Maintainability:
- Single source of truth for cancellation deadline/eligibility
  (optimized_server/utils/cancellation.py, Django-free) used by the
  Order model, the receipt worker and the optimized server
- OrderCancellationToken now reuses OneTimeCodeMixin instead of copying
  it; shared tickets_v2/utils/mail.py for sender addressing and the
  sv->en email template fallback
- Extract OrderCancellationView and RequestCancellationSection components
  to deduplicate the two cancel pages and the two order pages
- Consolidate the receipt worker event cache state into ClassVars

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… event cache

The TTL added for self-service cancellation makes the optimized server's
event cache refresh on a hot path under high concurrency. The pre-existing
shared-Future single-flight was not cancellation-safe: a single client
disconnect would cancel the shared refresh task (failing every other
in-flight request), and the per-awaiter `finally` could release the lock
mid-refresh and spawn a duplicate refresh on a borrowed connection.

Replace it with double-checked locking on an asyncio.Lock (one per worker
process). Each refresh uses the lock-holder's own live connection; a
cancelled waiter just drops out of the queue, and a cancelled holder
cleanly releases the lock for the next request to retry. Build the cache
dict and swap it in atomically to avoid torn reads mid-refresh.

Add two async regression tests (no DB / no pytest-asyncio) covering
single-flight under load and refresh survival across waiter cancellation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-lru

Adopt async-lru (alru_cache) for the optimized server's event cache instead
of the hand-rolled asyncio.Lock single-flight. The library gives us TTL expiry
plus single-flight within a worker process, and crucially is cancellation-safe:
a waiter giving up (client disconnect) does not abort the shared load nor
trigger a duplicate, which our earlier shared-Future approach got wrong.

The cached loader takes no arguments (alru_cache keys on args, and a per-request
pooled connection would be a different object every call), so it owns its own
pooled connection via get_connection_pool(). This also decouples the refresh
from any single request's connection lifecycle. Event.get() drops its now-unused
db parameter accordingly.

Port the two single-flight regression tests to drive the alru loader, including
the waiter-cancellation guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on link

Only a small fraction of visitors ever open the self-service cancellation page,
so Next.js prefetching it from every order view (public and profile) is wasteful.
Opt out with prefetch={false}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Once a cancellation link has been requested (?success=cancellationRequested),
re-requesting immediately is throttled by design. Disable the submit button in
that state, switch it to a muted secondary style, and relabel it to say the link
was sent so it is obvious why it is inactive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cancelling a paid order left the customer without a final notification:
migration 0007 had stopped creating receipts on CANCELLED (to avoid emailing
customers whose unpaid orders are auto-cancelled by cron), and provider refunds
only record CREATE_REFUND_SUCCESS synchronously — the REFUNDED stamp that
created a receipt arrives later via Paytrail's async callback (and never in dev).

Migration 0010 makes the receipt trigger fire as soon as the outcome is known:
- CREATE_REFUND_SUCCESS -> REFUNDED receipt (provider accepted the refund)
- CANCELLED -> CANCELLED receipt, but only if the order has a PAID stamp, so
  auto-cancelled unpaid orders stay silent (preserving 0007's intent)
- PAID / REFUNDED unchanged

The receipt id is the stamp correlation_id, which a provider refund's request,
success and callback stamps all share, so the eventual REFUNDED callback dedupes
against the receipt created at acceptance time (one email, not two). Failed
refunds never match, so a failed refund is never announced as done.

No mutation changes needed: admin and customer cancellation both route through
the same payment stamps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire OrderCancellationToken into the scheduled cleanup facility. Safe to delete:
a token is unusable past its 24h validity, nothing references it by FK, and the
cancellation request/outcome is recorded separately in the event log.

Filter on created_at rather than used_at (as the sibling token models do) so that
never-clicked valid tokens and revoked tokens — which keep accumulating as each
re-request revokes the previous token — are swept too, not only used ones. 30-day
retention sits well past the 24h validity and leaves a debugging window.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@japsu japsu merged commit 0919fe5 into main Jun 13, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant