$invoice */ public function invoicePaid(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord { $subscription = $this->resolve($invoice['subscription'] ?? null); if ($subscription === null) { return null; } $reason = (string) ($invoice['billing_reason'] ?? ''); // The checkout's own invoice is not a renewal — it is the purchase, and // OpenSubscription has already entered it. Recording it again here // would double every customer's first payment in the register. if ($reason === 'subscription_create') { return null; } // Only a cycle renewal moves the term on. Stripe also sends paid // invoices for prorations and for charges raised by hand, and those are // money received without being the start of a new month — calling them // renewals would push the period forward on a top-up. $isRenewal = $reason === 'subscription_cycle'; $invoiceId = (string) ($invoice['id'] ?? ''); [$start, $end] = $this->period($invoice); // A contract that has ended stays ended (mutateInOrder refuses one), so // a final invoice arriving after the deletion cannot hand a departed // customer their service back. The payment is still entered in the // register below; it did happen. if ($isRenewal && $start !== null && $end !== null) { $this->mutateInOrder($subscription, $eventAt, self::RANK_PAID, fn (Subscription $fresh) => $end // Never backwards: a renewal delayed behind the next one would // otherwise shorten the term the customer has already paid for. ->greaterThanOrEqualTo($fresh->current_period_end) // Period boundaries are not part of the frozen snapshot: // what the customer is owed does not change, only which // month it is. ? [ 'current_period_start' => $start, 'current_period_end' => $end, 'status' => 'active', 'stripe_status' => 'active', ] : null); } // One invoice, one entry — enforced by the unique index rather than by // a check, because Stripe can deliver the same invoice twice at once // and both deliveries would pass a check. $event = $isRenewal ? SubscriptionRecord::EVENT_RENEWAL : SubscriptionRecord::EVENT_INVOICE_PAID; return $this->recordOnce( fn () => ($this->record)( event: $event, subscription: $subscription->refresh(), netCents: $isRenewal ? $subscription->price_cents : 0, stripe: [ 'invoice' => $invoiceId ?: null, 'subscription' => $subscription->stripe_subscription_id, ], // The exact kind, kept so a proration or a manual charge stays // distinguishable from an ordinary renewal. extra: ['billing_reason' => $reason ?: null], // Stripe's total is what was actually taken, tax and all. Ours // is what was agreed; the register states both. chargedGrossCents: isset($invoice['amount_paid']) ? (int) $invoice['amount_paid'] : null, eventKey: $invoiceId !== '' ? "{$event}:{$invoiceId}" : null, ) ); } /** * A renewal failed. Recorded, and nothing else: Stripe runs the dunning * schedule, and cutting a customer off on the first failed attempt would * punish an expired card as though it were a refusal to pay. * * @param array $invoice */ public function invoicePaymentFailed(array $invoice, ?Carbon $eventAt = null): ?SubscriptionRecord { $subscription = $this->resolve($invoice['subscription'] ?? null); if ($subscription === null) { return null; } $invoiceId = (string) ($invoice['id'] ?? ''); // Only if this is the newest word we have. A failed attempt delayed // behind the successful retry would otherwise flip a customer who has // since paid back to past_due. $this->mutateInOrder($subscription, $eventAt, self::RANK_FAILED, fn () => ['stripe_status' => 'past_due']); return $this->recordOnce( fn () => ($this->record)( event: SubscriptionRecord::EVENT_PAYMENT_FAILED, subscription: $subscription->refresh(), netCents: 0, stripe: [ 'invoice' => $invoiceId ?: null, 'subscription' => $subscription->stripe_subscription_id, ], extra: ['attempt' => $invoice['attempt_count'] ?? null], chargedGrossCents: 0, // Per attempt, not per invoice: Stripe retries a failing // payment on a schedule, and each attempt is its own event. eventKey: $invoiceId !== '' ? 'payment_failed:'.$invoiceId.':'.($invoice['attempt_count'] ?? 0) : null, ) ); } /** * Stripe's own status changed. Theirs is the authority on whether the money * is arriving; we copy it, and leave what the customer may USE to our own * status, which only a cancellation or an operator changes. * * @param array $object */ public function subscriptionUpdated(array $object, ?Carbon $eventAt = null): ?Subscription { $subscription = $this->resolve($object['id'] ?? null); if ($subscription === null) { return null; } [$start, $end] = $this->period($object); // Ended is ended, whatever order events arrive in — and an older // snapshot must never overwrite a newer one it was delivered behind. $this->mutateInOrder($subscription, $eventAt, self::RANK_UPDATED, fn () => array_filter([ 'stripe_status' => $object['status'] ?? null, 'current_period_start' => $start, 'current_period_end' => $end, ], fn ($value) => $value !== null)); return $subscription; } /** * The subscription has ended at Stripe. That is the end of the contract, * so it goes in the register — a cancellation nobody recorded is exactly * the kind of gap the register exists to prevent. * * @param array $object */ public function subscriptionDeleted(array $object, ?Carbon $eventAt = null): ?SubscriptionRecord { $subscription = $this->resolve($object['id'] ?? null); if ($subscription === null) { return null; } $endedAt = isset($object['ended_at']) ? Carbon::createFromTimestamp((int) $object['ended_at']) : now(); // The ending and its entry in the register commit together. Marking the // contract cancelled first and failing before the record would leave a // retry seeing "already cancelled" and returning — and the end of a // contract would never be entered at all. return $this->recordOnce(fn () => DB::transaction(function () use ($subscription, $object, $endedAt, $eventAt) { // Held while we decide, so a renewal arriving at the same moment // cannot reactivate the contract between this and the write. $subscription = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->firstOrFail(); // No ordering guard: an ending is final, so a late delivery of it // is still correct. Only the running picture can go stale. $subscription->update([ 'status' => 'cancelled', 'stripe_status' => $object['status'] ?? 'canceled', 'cancelled_at' => $endedAt, 'stripe_event_at' => $eventAt, ]); return ($this->record)( event: SubscriptionRecord::EVENT_CANCELLATION, subscription: $subscription->refresh(), netCents: 0, at: $endedAt, stripe: ['subscription' => $subscription->stripe_subscription_id], chargedGrossCents: 0, // A contract ends once. Two deliveries arriving together would // both pass a status check; only one can take this key. eventKey: 'cancellation:'.$subscription->id, ); })); } /** * Whether this event is the newest word we have about the contract. * * Stripe does not guarantee delivery order, and the state here is a * running picture rather than a log: applying a stale snapshot on top of a * fresher one is how a paid-up customer ends up looking overdue. The * register is unaffected — each entry is keyed by what it is about, so a * late arrival still lands once, in its proper place. */ /** * Decide and write in one step, with the contract's row held. * * The ordering guard is only worth as much as its atomicity: two * deliveries for the same contract can both read a stale copy, both pass * the check, and the loser then overwrites the winner — a renewal * reactivating a contract a deletion had just ended, or an old failure * burying a payment that went through. So the row is re-read under a lock * and re-judged inside the transaction that writes it. * * `$changes` returns the columns to set, or null to decline. * * @param callable(Subscription): ?array $changes */ private function mutateInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank, callable $changes): void { DB::transaction(function () use ($subscription, $eventAt, $rank, $changes) { $fresh = Subscription::query()->whereKey($subscription->getKey())->lockForUpdate()->first(); if ($fresh === null || $fresh->status === 'cancelled' || ! $this->appliesInOrder($fresh, $eventAt, $rank)) { return; } $values = $changes($fresh); if ($values === null) { return; } if ($eventAt !== null) { $values['stripe_event_at'] = $eventAt; $values['stripe_event_rank'] = $rank; } $fresh->update($values); }); $subscription->refresh(); } private function appliesInOrder(Subscription $subscription, ?Carbon $eventAt, int $rank): bool { if ($eventAt === null || $subscription->stripe_event_at === null) { return true; // nothing to compare against } if ($eventAt->greaterThan($subscription->stripe_event_at)) { return true; } if ($eventAt->lessThan($subscription->stripe_event_at)) { return false; } // Same second, which Stripe's one-second timestamps make common enough // to matter: a failed attempt and the retry that succeeded can share // one. Decide by what the event says about the money. return $rank >= (int) ($subscription->stripe_event_rank ?? 0); } /** * Write a register entry, unless one already exists for this invoice and * event. * * The uniqueness lives in the database, on (stripe_invoice_id, event). * Checking first and inserting afterwards leaves a window that Stripe * delivering the same invoice twice at once walks straight through — and a * register that counts a payment twice is worse than one that is late. * * @param callable(): SubscriptionRecord $write */ private function recordOnce(callable $write): ?SubscriptionRecord { try { return $write(); } catch (UniqueConstraintViolationException) { return null; // already recorded by a concurrent delivery } } /** * Hold an event whose contract does not exist yet, so it is not lost. * * @param array $event */ public function hold(array $event): void { $subscriptionId = $event['data']['object']['subscription'] ?? $event['data']['object']['id'] ?? null; if (! is_string($subscriptionId) || ! is_string($event['id'] ?? null)) { return; } // Only what we could not match. A handler returns null for several // reasons — already recorded, or deliberately skipped, like every // checkout's own invoice — and holding those would fill the table with // rows that replayHeldFor() can never revisit, because their contract // is right there. Nothing to wait for means nothing to hold. if (Subscription::query()->where('stripe_subscription_id', $subscriptionId)->exists()) { // Unless it appeared just now: the contract can be created between // the handler missing it and this check, and the replay that would // have collected it has then already been and gone. Apply it here // instead of dropping it. Safe to repeat — the handlers are // idempotent, so an event that was a no-op stays one. $this->dispatch($event); return; } StripePendingEvent::query()->updateOrCreate( ['stripe_event_id' => $event['id']], [ 'stripe_subscription_id' => $subscriptionId, 'type' => (string) ($event['type'] ?? ''), 'raised_at' => isset($event['created']) && is_numeric($event['created']) ? Carbon::createFromTimestamp((int) $event['created']) : null, 'payload' => $event, ], ); } /** * Replay everything held for a contract that has just appeared. * * In their original order, so the ordering guard sees them as Stripe raised * them rather than as they happened to be stored. */ public function replayHeldFor(Subscription $subscription): int { if ($subscription->stripe_subscription_id === null) { return 0; } $held = StripePendingEvent::query() ->where('stripe_subscription_id', $subscription->stripe_subscription_id) ->orderBy('raised_at') ->orderBy('id') ->get(); foreach ($held as $pending) { $this->dispatch($pending->payload ?? []); $pending->delete(); } return $held->count(); } /** * Route one event to its handler. Returns false for a type we do not * handle, so the caller can tell "not ours" from "nothing to do". * * @param array $event */ public function dispatch(array $event): mixed { $object = $event['data']['object'] ?? []; $raisedAt = isset($event['created']) && is_numeric($event['created']) ? Carbon::createFromTimestamp((int) $event['created']) : null; return match ($event['type'] ?? '') { 'invoice.paid' => $this->invoicePaid($object, $raisedAt), 'invoice.payment_failed' => $this->invoicePaymentFailed($object, $raisedAt), 'customer.subscription.updated' => $this->subscriptionUpdated($object, $raisedAt), 'customer.subscription.deleted' => $this->subscriptionDeleted($object, $raisedAt), default => false, }; } /** * Find the contract a Stripe event is about. * * An unknown id is logged and dropped rather than raised: Stripe delivers * events for objects created by hand in the dashboard, and by other * environments pointed at the same endpoint. Failing the webhook for those * would have Stripe retry something that can never succeed. */ private function resolve(mixed $stripeSubscriptionId): ?Subscription { $id = is_string($stripeSubscriptionId) ? $stripeSubscriptionId : null; if ($id === null) { return null; } return Subscription::query()->where('stripe_subscription_id', $id)->first(); } /** * @param array $object * @return array{0: ?Carbon, 1: ?Carbon} */ private function period(array $object): array { $start = $object['period_start'] ?? $object['current_period_start'] ?? null; $end = $object['period_end'] ?? $object['current_period_end'] ?? null; return [ is_numeric($start) ? Carbon::createFromTimestamp((int) $start) : null, is_numeric($end) ? Carbon::createFromTimestamp((int) $end) : null, ]; } }