feat(tickets_v2): self-service order cancellation#982
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
UpdateTicketsPreferences GraphQL mutations
optimized server order API
server on a 5 minute TTL so admin settings changes take effect
Frontend:
cancellation period) with a Settings tab
Co-Authored-By: Claude Fable 5 noreply@anthropic.com