Skip to content

fix(payment): settle provider events that arrive before the order exists - #658

Merged
mckenziearts merged 2 commits into
3.xfrom
fix/payment-completion-race
Sep 3, 2026
Merged

fix(payment): settle provider events that arrive before the order exists#658
mckenziearts merged 2 commits into
3.xfrom
fix/payment-completion-race

Conversation

@mckenziearts

Copy link
Copy Markdown
Collaborator

What

In the Store API flow the provider confirms the payment in the browser before POST /store/carts/{id}/complete creates the order, so payment_intent.succeeded usually lands first. The webhook controller journalized the event as processed before applying it, processWebhook() returned when no order matched the reference, and completion never looked at the ledger. The order stayed payment_status = pending forever while every webhook answered 200, and a Stripe redelivery was rejected as a duplicate.

Webhook ledger as a transactional inbox

  • IngestPaymentEvent journalizes the event unprocessed (processed_at null) and hands it to ApplyPaymentEvent, which resolves the order, then claims the row with a conditional update inside the same transaction that applies it. A rollback hands the event back to the ledger, a redelivery collapses on the unique (driver, event_id) key, and a listener failing after commit no longer releases anything.
  • PaymentProcessingService::apply() is the single write path: transactional, order row locked, deduplicated by payment reference or refund id. Webhook actions move from strings to the WebhookAction enum.
  • New reference column on sh_payment_webhook_events, indexed with processed_at. The migration backfills existing rows and resets processed_at for events that predate any transaction of their reference, so the previously swallowed events become replayable.

Catching up at completion

  • CompleteCartAction replays the stored events for its payment reference through SettlePayment, ordered by action precedence, then queues SyncPendingPaymentJob when the payment is still pending. The job asks the provider through retrievePayment() and applies the terminal state. Nothing calls the provider inline in the request.
  • shopper:payments:reconcile --pull runs every fifteen minutes: it replays orphaned events whose order now exists and queues a provider check for every payment still awaiting confirmation. The ledger is pruned daily through model:prune.
  • PaymentDriver gains supportsRetrieval(), true for Stripe, false by default.
  • Configurable through shopper.payment.reconciliation (pull on completion, schedule, queue, backoff, retention).

Domain rules

  • A failed webhook records the failed attempt and dispatches PaymentFailed, it no longer cancels the order: the customer may still retry on the same intent. shopper.orders.reclaim_pending_after_hours now defaults to 24 hours so unpaid orders release their stock. Order::isAwaitingPayment() and the awaitingPayment() scope are shared with the reclaim command.
  • Stripe refunds are ingested from refund.created and refund.updated (status succeeded) with the amount of that refund and its id, instead of the cumulative charge.refunded. A refund issued from the admin and its confirming webhook collapse onto one transaction.
  • Every transaction written from a provider event carries no actor; outgoing operations record the authenticated user. PaymentFailed is dispatched after commit like the other order events.

Breaking changes

  • WebhookResult::$action is typed WebhookAction instead of string. Drivers must return the enum cases.
  • PaymentDriver gains supportsRetrieval(): bool. The base Driver returns false.
  • PaymentProcessingService::processWebhook() is removed. Use IngestPaymentEvent for a raw provider event or PaymentProcessingService::apply() when the order is already known.
  • The Stripe driver no longer handles charge.refunded. Enable refund.created and refund.updated on the Stripe webhook endpoint.
  • A published shopper/payment.php config needs the reconciliation block, otherwise the scheduled reconcile stays off.
  • shopper.orders.reclaim_pending_after_hours defaults to 24 instead of null; set it to null to keep unpaid orders indefinitely.
  • sh_payment_webhook_events gains a reference column. Run the migrations.

In the Store API flow the provider confirms the payment in the browser
before POST /store/carts/{id}/complete creates the order. The webhook was
swallowed when no order matched the reference, journalized as processed
before it was applied, and never replayed at completion, leaving the order
pending forever.

The webhook ledger becomes a transactional inbox: an event is journalized
unprocessed and claimed with a conditional update inside the same
transaction that applies it. Cart completion replays the stored events for
its payment reference and queues a provider check for a payment still
pending. A scheduled shopper:payments:reconcile command replays what is
still orphaned and queues a check for every pending payment. Webhook
actions are a backed enum, a failed attempt no longer cancels the order,
and Stripe refunds are journalized per refund id.
@mckenziearts mckenziearts added this to the v3 milestone Sep 2, 2026
@mckenziearts mckenziearts added bug Something isn't working php Pull requests that update php code labels Sep 2, 2026
@mckenziearts mckenziearts self-assigned this Sep 2, 2026
@mckenziearts mckenziearts moved this to In Progress in Roadmap Sep 2, 2026
@mckenziearts
mckenziearts merged commit 5a5a4da into 3.x Sep 3, 2026
67 of 68 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in Roadmap Sep 3, 2026
@mckenziearts
mckenziearts deleted the fix/payment-completion-race branch September 3, 2026 18:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working php Pull requests that update php code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant