fix: Prevent double charges and duplicate events in the checkout flow - #186
fix: Prevent double charges and duplicate events in the checkout flow#186sveneberth wants to merge 4 commits into
Conversation
`checkout_order` could charge a customer twice: `can_order` only checks `is_ordered`, which is set *after* the payment provider `checkout()` call. A crash in between (or a double click / replayed request racing the first call) left `is_ordered` unset, so a repeated `checkout_order` passed the check and called the payment provider again. The state setters `set_ordered`/`set_paid`/`set_rts` were also not idempotent: a repeated call (e.g. return handler and payment webhook processing the same payment concurrently) wrote the state again and re-fired the `ORDER_ORDERED`/`ORDER_PAID`/`ORDER_RTS` events, running side effects like discount accounting or mails twice. Changes: - new `checkout_order_started` DateBone: `checkout_order` claims the order via a `set_status` precondition before calling the payment provider; concurrent/repeated calls get a 409. The claim is released on provider errors and expires after 15 minutes (crash recovery) - `set_ordered`/`set_paid`/`set_rts` use a precondition to perform the transition exactly once and fire their events only on the actual transition - remove unreachable `raise` statements after `return` in `checkout_start`/`checkout_order` (the latter referenced an undefined variable) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens the checkout flow against double charges and duplicate side effects by introducing an explicit “checkout claim” and making order state transitions idempotent.
Changes:
- Adds
checkout_order_startedto claim an order before calling the payment provider, rejecting concurrent/replayedcheckout_orderattempts. - Introduces
_set_state_oncesoset_ordered/set_paid/set_rtsonly transition and fire their events once. - Removes unreachable
raisestatements after JSON error responses.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/viur/shop/skeletons/order.py | Adds checkout_order_started timestamp used to claim an order during final checkout. |
| src/viur/shop/modules/order.py | Implements claim logic + idempotent state transitions; removes dead raises in checkout endpoints. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| order_skel = self.set_ordered(order_skel, pp_res) | ||
| EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False) |
There was a problem hiding this comment.
Fixed in ff8ac20: removed the extra EVENT_SERVICE.call(Event.ORDER_CHANGED, ...); set_ordered() already fires it on the actual transition.
| order_skel = HOOK_SERVICE.dispatch(Hook.ORDER_ASSIGN_UID, self._default_assign_uid)(order_skel) | ||
| # TODO: charge order if it should directly be charged | ||
| pp_res = self.get_payment_provider_by_name(order_skel["payment_provider"]).checkout(order_skel) |
There was a problem hiding this comment.
Fixed in ff8ac20: UID assignment is now guarded with if not order_skel["order_uid"]: so a retry after an overtaken claim no longer reassigns it.
| except Exception: | ||
| # Release the claim, the user may retry immediately | ||
| toolkit.set_status( | ||
| key=order_skel["key"], | ||
| skel=order_skel, | ||
| values={"checkout_order_started": None}, | ||
| ) | ||
| raise |
There was a problem hiding this comment.
Fixed in ff8ac20: the release now uses a precondition comparing checkout_order_started against the timestamp this request itself claimed, so it only clears its own claim and no longer clobbers a newer one from a concurrent retry.
- Remove the duplicate `EVENT_SERVICE.call(Event.ORDER_CHANGED, ...)` in `checkout_order`; `set_ordered()` already fires it on the actual transition, so the normal checkout path fired it twice. - Only assign `order_uid` once (`if not order_skel["order_uid"]`). A retry after an overtaken claim re-ran UID assignment, changing the invoice/reference id payment providers use for webhook/return reconciliation and defeating provider-side idempotency. - Release the checkout claim (`checkout_order_started`) with a precondition comparing against the timestamp this request itself claimed. An unconditional release could clobber a newer claim created by a concurrent retry (last-write-wins), reopening the double-charge window the claim mechanism is meant to close. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…heckout-idempotency
Problem
Double charge:
checkout_orderguards only viacan_order, which checksis_ordered— but that flag is set after the payment providercheckout()call:If the process crashes in between, or a double click / replayed request races the first call,
is_orderedis still falsy and a repeatedcheckout_ordercalls the payment provider again — a second charge.Duplicate events:
set_ordered/set_paid/set_rtsunconditionally wrote the state and fired their events. When the return handler and the (60s-delayed) payment webhook process the same payment, both sawis_paid=False(read-then-write, no precondition) and both firedORDER_PAID— side effects like discount accounting (mark_discount_usedonORDER_ORDERED) or mails ran twice.Unreachable
raisestatements afterreturn JsonResponse(...)incheckout_startandcheckout_order; the latter referenced an undefined variable (error_) and would have been aNameErrorif ever reached.Solution
OrderSkel.checkout_order_started(DateBone):checkout_orderclaims the order via atoolkit.set_statusprecondition before calling the payment provider. A concurrent or repeated call is rejected with HTTP 409. The claim is released when the provider call raises (immediate retry possible) and expires after 15 minutes (CHECKOUT_ORDER_CLAIM_TIMEOUT) to recover from crashes after the provider call._set_state_oncehelper:set_ordered/set_paid/set_rtsperform their transition with a precondition and fire their events only on the actual transition. A repeated call changes and triggers nothing.raiselines.Note
toolkit.set_statuscurrently never opens a transaction due to a bug (if db.IsInTransaction:— missing call parentheses, see viur-framework/viur-toolkit#65). The precondition pattern in this PR already narrows the race window considerably without it and becomes fully transactional once that fix is released.🤖 Generated with Claude Code