Skip to content

fix: Prevent double charges and duplicate events in the checkout flow - #186

Draft
sveneberth wants to merge 4 commits into
viur-framework:mainfrom
sveneberth:fix/checkout-idempotency
Draft

fix: Prevent double charges and duplicate events in the checkout flow#186
sveneberth wants to merge 4 commits into
viur-framework:mainfrom
sveneberth:fix/checkout-idempotency

Conversation

@sveneberth

Copy link
Copy Markdown
Member

Problem

  1. Double charge: checkout_order guards only via can_order, which checks is_ordered — but that flag is set after the payment provider checkout() call:

    pp_res = self.get_payment_provider_by_name(...).checkout(order_skel)  # external charge
    order_skel = self.set_ordered(order_skel, pp_res)                     # flag set here

    If the process crashes in between, or a double click / replayed request races the first call, is_ordered is still falsy and a repeated checkout_order calls the payment provider again — a second charge.

  2. Duplicate events: set_ordered / set_paid / set_rts unconditionally wrote the state and fired their events. When the return handler and the (60s-delayed) payment webhook process the same payment, both saw is_paid=False (read-then-write, no precondition) and both fired ORDER_PAID — side effects like discount accounting (mark_discount_used on ORDER_ORDERED) or mails ran twice.

  3. Unreachable raise statements after return JsonResponse(...) in checkout_start and checkout_order; the latter referenced an undefined variable (error_) and would have been a NameError if ever reached.

Solution

  • New OrderSkel.checkout_order_started (DateBone): checkout_order claims the order via a toolkit.set_status precondition 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.
  • New _set_state_once helper: set_ordered / set_paid / set_rts perform their transition with a precondition and fire their events only on the actual transition. A repeated call changes and triggers nothing.
  • Removed the dead raise lines.
  • Docstrings added at all touched methods describing the idempotency guarantees.

Note

toolkit.set_status currently 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

`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>
Copilot AI review requested due to automatic review settings July 15, 2026 20:16
@sveneberth sveneberth added Priority: High After critical issues are fixed, these should be dealt with before any further issues. bug(fix) Something isn't working or address a specific issue or vulnerability component: skeleton component: module labels Jul 15, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_started to claim an order before calling the payment provider, rejecting concurrent/replayed checkout_order attempts.
  • Introduces _set_state_once so set_ordered / set_paid / set_rts only transition and fire their events once.
  • Removes unreachable raise statements 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.

Comment thread src/viur/shop/modules/order.py Outdated
Comment on lines 429 to 430
order_skel = self.set_ordered(order_skel, pp_res)
EVENT_SERVICE.call(Event.ORDER_CHANGED, order_skel=order_skel, deleted=False)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ff8ac20: removed the extra EVENT_SERVICE.call(Event.ORDER_CHANGED, ...); set_ordered() already fires it on the actual transition.

Comment thread src/viur/shop/modules/order.py Outdated
Comment on lines +418 to +420
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)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +421 to +428
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

sveneberth and others added 3 commits July 22, 2026 00:38
- 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>
@sveneberth
sveneberth marked this pull request as draft July 28, 2026 11:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug(fix) Something isn't working or address a specific issue or vulnerability component: module component: skeleton Priority: High After critical issues are fixed, these should be dealt with before any further issues.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants