fix(payment): settle provider events that arrive before the order exists - #658
Merged
Conversation
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.
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.
What
In the Store API flow the provider confirms the payment in the browser before
POST /store/carts/{id}/completecreates the order, sopayment_intent.succeededusually 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 stayedpayment_status = pendingforever while every webhook answered 200, and a Stripe redelivery was rejected as a duplicate.Webhook ledger as a transactional inbox
IngestPaymentEventjournalizes the event unprocessed (processed_atnull) and hands it toApplyPaymentEvent, 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 theWebhookActionenum.referencecolumn onsh_payment_webhook_events, indexed withprocessed_at. The migration backfills existing rows and resetsprocessed_atfor events that predate any transaction of their reference, so the previously swallowed events become replayable.Catching up at completion
CompleteCartActionreplays the stored events for its payment reference throughSettlePayment, ordered by action precedence, then queuesSyncPendingPaymentJobwhen the payment is still pending. The job asks the provider throughretrievePayment()and applies the terminal state. Nothing calls the provider inline in the request.shopper:payments:reconcile --pullruns 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 throughmodel:prune.PaymentDrivergainssupportsRetrieval(), true for Stripe, false by default.shopper.payment.reconciliation(pull on completion, schedule, queue, backoff, retention).Domain rules
failedwebhook records the failed attempt and dispatchesPaymentFailed, it no longer cancels the order: the customer may still retry on the same intent.shopper.orders.reclaim_pending_after_hoursnow defaults to 24 hours so unpaid orders release their stock.Order::isAwaitingPayment()and theawaitingPayment()scope are shared with the reclaim command.refund.createdandrefund.updated(statussucceeded) with the amount of that refund and its id, instead of the cumulativecharge.refunded. A refund issued from the admin and its confirming webhook collapse onto one transaction.PaymentFailedis dispatched after commit like the other order events.Breaking changes
WebhookResult::$actionis typedWebhookActioninstead ofstring. Drivers must return the enum cases.PaymentDrivergainssupportsRetrieval(): bool. The baseDriverreturnsfalse.PaymentProcessingService::processWebhook()is removed. UseIngestPaymentEventfor a raw provider event orPaymentProcessingService::apply()when the order is already known.charge.refunded. Enablerefund.createdandrefund.updatedon the Stripe webhook endpoint.shopper/payment.phpconfig needs thereconciliationblock, otherwise the scheduled reconcile stays off.shopper.orders.reclaim_pending_after_hoursdefaults to 24 instead of null; set it to null to keep unpaid orders indefinitely.sh_payment_webhook_eventsgains areferencecolumn. Run the migrations.