Skip to content

fix(webhook): settle the right order when several share a quote (#69) - #81

Merged
jules-paystack merged 9 commits into
masterfrom
fix/webhook-multi-order-quote
Sep 29, 2026
Merged

jules-paystack merged 9 commits into
masterfrom
fix/webhook-multi-order-quote

Conversation

@jules-paystack

Copy link
Copy Markdown
Collaborator

Fixes #69.

Problem

In inline (popup) mode, closing the popup runs /paystack/payment/recreate, which cancels the order and restores the same quote. A retry therefore leaves several orders on one quote_id. The webhook's fallback lookup required exactly one (getTotalCount() == 1), so whenever the webhook was the path that confirmed payment (customer closed the tab, inline verify failed) the paid order stayed pending.

Changes

  • Model/WebhookOrderResolver (new) finds the order, first match wins: increment id → an order the reference is already bound to → metadata.orderId + quoteId (any state) → the quote: its lone order as before, otherwise the single payable Paystack order. Never guesses among several. Metadata is read from the verify response and strictly parsed; repository errors propagate (503) instead of falling through.
  • Inline JS captures the placed order's entity id (getPlaceOrderDeferredObject() override) and sends it as metadata.orderId.
  • TransactionValidator::isPayable() / isClosedForPayment() / chargeIsReal() — one definition of payability shared by settlement and the resolver (Recreate keeps its own list on purpose).
  • New order_closed reason: a charge for a canceled/closed/complete or fully-paid order is acknowledged with 200 once a history comment is saved ("received after this order was closed … refund or reconcile if not already reflected"), logged at critical on first record — instead of 503 for Paystack's ~72h retry budget.
  • Ack only once recorded: for any decided rejection of a real (live, successful) charge, the webhook now returns 503 until the order-history comment is saved (historyRecorded on register()'s result).

Behaviour changes to be aware of

  • Real-money permanent rejections retry (503) if the history write fails — previously acknowledged with 200. A persistently failing order save now means retries until Paystack gives up.
  • Inline REST verify and the redirect callback report order_closed (terminal, same customer message as order_not_payable) for these orders.

Known limitations (in CHANGELOG)

  • Transactions without metadata.orderId (pre-release, or checkouts that replace Magento's payment renderer such as Hyvä) still resolve by quote; a late bank-transfer/USSD settlement for a cancelled attempt can bind to the live retry order.
  • A quote with several orders, none choosable, still ends in order-not-found (now logged at error with the candidates).

Verification

  • PHPUnit: 409 tests green; 35/35 source mutations killed.
  • dev-repro, real Paystack test-mode charges + signed webhook replay: cancel-and-retry with orderId → retry order settles, cancelled order untouched; legacy (no orderId) → settles; redelivery → idempotent 200, one invoice; late charge naming the cancelled order → 200 rejected with the history comment. Control with Webhook.php reverted to master reproduces Webhook handler does not advance order when multiple transactions exist for the same quoteId #69 (503 "order not found", order pending).
  • Real browser checkout (Luma): popup options carried metadata.orderId equal to the placed order's entity id.

🤖 Generated with Claude Code

jules-paystack and others added 9 commits September 29, 2026 15:24
Core calls afterPlaceOrder() with no arguments, so the renderer never knew
which order a popup transaction was for. Capture the entity id the
payment-information response resolves with, and send it so the webhook can
settle the right order when several share a quote (issue #69).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Payable)

register()'s order-state guard moves onto the new predicate (state new or
pending_payment, and a positive base amount due) so the upcoming webhook
order resolver can apply exactly the same rule. Recreate keeps its own
state list on purpose: it gates an anonymous cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rded

A charge whose order can never take it (canceled/closed/complete, or nothing
left due) used to return ORDER_NOT_PAYABLE, which the webhook retries for
Paystack's whole ~72h budget and which risks endpoint back-off. It now
returns the new ORDER_CLOSED, a permanent reason, but only once the
rejection is durably on the order's history; if that write fails it stays
ORDER_NOT_PAYABLE so the retry keeps trying to record it. Held or
payment-review orders keep retrying as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inline retries cancel the previous order and reuse its quote, so the
webhook's quoteId lookup found several orders, required exactly one, and
left the paid order pending. Order lookup moves into WebhookOrderResolver:
increment id, then an order the reference is already bound to, then the
popup's metadata.orderId (matched with its quoteId), then the quote - a
lone order as before, otherwise the single payable Paystack order, and
never a guess among several. Metadata is read from the verify response and
strictly parsed; repository errors propagate (503) instead of falling
through to a weaker step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecorded

Diff review of #69: the record-before-ack rule lived inside the shared
settlement service and only covered closed orders. register() now reports
historyRecorded on every result and returns ORDER_CLOSED deterministically;
the webhook acknowledges any permanent reason only when the rejection is on
the order's history or no real money moved, and retries (503) otherwise.

Also: closed-order history now says the payment was received but not
applied (refund or reconcile) and logs at critical; the resolver skips a
non-Paystack order bound to the reference and logs unresolvable quotes at
error with the candidate orders; stale retry-policy docblock corrected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the cases a mutation pass showed missing: an orphaned bound
transaction falls through to the quote lookup, chargeIsReal's full table,
and historyRecorded=false on the bound-elsewhere and registration-failed
paths when the history save fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security re-review of the ack-once-recorded change: an order paid before
reference binding existed has no transaction row, so a replay of its own
successful charge reaches ORDER_CLOSED. The history now asks the merchant
to refund only if the charge is not already reflected on the order.
Critical is logged when a closed-order rejection is first recorded or when
any history write fails, not on every replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One history sprintf instead of two near-identical blocks, the closed-order
decision derived once and passed to writeHistory(), guard clauses instead
of nested ifs, a findOrders() helper for the resolver's three order
lookups, and the retry-policy rationale kept on the reason constants with
pointers elsewhere. No behaviour change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG Unreleased entry, Reference Manual order-lookup and retry
semantics, a User Guide troubleshooting entry for the closed-order history
comment, and CLAUDE.md's webhook flow and key classes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jules-paystack
jules-paystack merged commit 4a3515b into master Sep 29, 2026
5 checks passed
@jules-paystack
jules-paystack deleted the fix/webhook-multi-order-quote branch September 29, 2026 14:02
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.

Webhook handler does not advance order when multiple transactions exist for the same quoteId

1 participant