Contract: Stripe Webhook Events (inbound)
Endpoint (existing, unchanged): POST /webhook → admin/routes/web.php →
App\Domains\Subscription\Controllers\PaymentWebhookController (extends Cashier’s
WebhookController, which verifies the Stripe signature and dispatches to handle{PascalCase(event type)} by convention). This feature adds three new handler methods to that same controller; no
new route is created.
Event: invoice.payment_failed → handleInvoicePaymentFailed(array $payload)
Preconditions checked before acting:
payload.data.object.billing_reason === 'subscription_cycle'(ignore proration/one-off invoices)- The Stripe customer on the invoice resolves to a known
UserviaUserPaymentSetting - A
UserPaymentrow exists forpayload.data.object.subscription
Fields consumed from payload.data.object (the Invoice):
| Field | Used for |
|---|---|
billing_reason |
Filter to renewal-cycle failures only |
customer |
Resolve the User |
subscription |
Locate the UserPayment row |
hosted_invoice_url |
Stored on UserPayment, embedded in dunning email links |
attempt_count |
Distinguish “entering a new grace cycle” vs. “another automatic retry failed within an existing grace cycle” |
lines.data[].period.start (fallback: period_start, then the event’s created) |
Deterministic anchor for grace_period_started_at |
Side effects:
- On entering grace (status was not already
PAST_DUE): setstatus = PAST_DUE, set
hosted_invoice_url, setgrace_period_started_at, reset all 4grace_*_sent_atguard columns
to null, then sendFirstFailureMailimmediately (idempotent viagrace_mail_1_sent_at). - If already in grace (a later automatic retry within the same cycle also failed): only update
the storedhosted_invoice_url/attempt-count bookkeeping — do not resend mail 1 or reset the
anchor. - Response: always
200 OK(Cashier’ssuccessMethod()) so Stripe does not retry a webhook we’ve
already processed; genuine processing failures are logged, not surfaced as webhook errors, except
where explicitly noted below.
Event: invoice.payment_succeeded (and alias invoice.paid) → handleInvoicePaymentSucceeded(array $payload)
Preconditions checked before acting:
payload.data.object.subscriptionis present (ignore non-subscription invoices)- The matching
UserPayment.status === PAST_DUE(ignore success events unrelated to an open grace
period, e.g. a brand-new subscription’s very first payment)
Fields consumed:
| Field | Used for |
|---|---|
subscription |
Locate the UserPayment row and the live Stripe subscription object |
Stripe subscription’s default_payment_method |
Sync the card used to pay off the grace period back into UserPaymentSetting/UserPayment |
Side effects:
- Re-sync the subscription via the existing
newOrUpdateUserSubscription()helper →statusbecomes
SETTLEDagain. - Send
RepaymentSuccessMailexactly once (guarded bygrace_success_mail_sent_at, claimed via an
atomic conditional update to tolerateinvoice.payment_succeededandinvoice.paidboth firing
for the same payment). - If the Stripe subscription or its plan mapping cannot be resolved, respond with an error status so
Stripe retries the webhook rather than silently leaving the subscriber stuck inPAST_DUE.
Event: customer.subscription.deleted → handleCustomerSubscriptionDeleted(array $payload) (existing handler, extended)
New side effect added by this feature: mark the corresponding UserPayment.status = DELETED
(via the shared grace-state helper) before running the existing userDowngradePlan() cleanup,
so paid-feature access is revoked immediately rather than after cleanup completes.
No change to preconditions or to the existing downgrade-cleanup behavior itself.
Event: customer.subscription.updated → handleCustomerSubscriptionUpdated(array $payload) (existing handler, extended)
New precondition added by this feature: if the subscription’s UserPayment is already
PAST_DUE, or the update’s status === 'past_due' on a subscription_cycle invoice, hold
(record the subscription history sync only; skip the normal plan-downgrade path) so the grace
period set up by invoice.payment_failed is not immediately undone by this event, which Stripe
fires around the same time.