Contract: Stripe Webhook Events (inbound)

Endpoint (existing, unchanged): POST /webhookadmin/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_failedhandleInvoicePaymentFailed(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 User via UserPaymentSetting
  • A UserPayment row exists for payload.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): set status = PAST_DUE, set
    hosted_invoice_url, set grace_period_started_at, reset all 4 grace_*_sent_at guard columns
    to null, then send FirstFailureMail immediately (idempotent via grace_mail_1_sent_at).
  • If already in grace (a later automatic retry within the same cycle also failed): only update
    the stored hosted_invoice_url/attempt-count bookkeeping — do not resend mail 1 or reset the
    anchor.
  • Response: always 200 OK (Cashier’s successMethod()) 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.subscription is 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 → status becomes
    SETTLED again.
  • Send RepaymentSuccessMail exactly once (guarded by grace_success_mail_sent_at, claimed via an
    atomic conditional update to tolerate invoice.payment_succeeded and invoice.paid both 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 in PAST_DUE.

Event: customer.subscription.deletedhandleCustomerSubscriptionDeleted(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.updatedhandleCustomerSubscriptionUpdated(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.