$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; $record = $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, ) ); if ($isRenewal && $start !== null && $end !== null) { $this->invoiceRenewal($subscription, $invoiceId, $start, $end); } elseif ($record !== null) { // Money that gets no document from us, said out loud once — when it // is first recorded, not on every redelivery. // // Which paid invoices those are, and why none of them is invoiced // from here: // // - `subscription_create` is the checkout's own invoice. The // purchase already has a document, issued the moment the money // landed; a second one would put two invoices on one sale. (It // never reaches this line — the handler returns above.) // - `subscription_update` is an upgrade's proration. Stripe worked // the amount out against ITS period boundaries and its own view // of what is already on the account, and a document written from // our contract snapshot would state a sum that is not the one // charged. The move itself is in the register as an `upgrade`, // carrying the pro-rata the customer was quoted. // - a charge raised by hand in Stripe's dashboard is for something // this catalogue knows nothing about. We cannot describe what was // sold, and an invoice line that is a guess is worse than none. // // All three are in the register with the amount, which is what the // console's payments panel reads. This is the trail for whoever has // to raise the paper. Log::warning('A payment was received that no document of ours covers.', [ 'subscription' => $subscription->uuid, 'stripe_invoice' => $invoiceId ?: null, 'billing_reason' => $reason ?: null, ]); } return $record; } /** * The renewal's document and the one mail that carries it. * * Outside recordOnce() on purpose. The register entry and the invoice are * two different obligations, and tying the second to the first succeeding * would mean that a delivery which recorded the payment but could not issue * the document — an incomplete company profile, a mail server that was down * — could never be finished: every retry would find the register entry * already there, return, and the customer would never get an invoice. The * issuing is idempotent on its own, keyed on the Stripe invoice id. * * Caught here as well as inside the action. The action guards what it does; * this guards the rest, because the rule is that no paperwork of ours may * ever cost Stripe a 2xx — a retried webhook is a second attempt at * everything, and one of those things moves the customer's term. */ private function invoiceRenewal(Subscription $subscription, string $invoiceId, Carbon $start, Carbon $end): void { try { ($this->issueRenewalInvoice)($subscription->refresh(), $invoiceId, $start, $end); } catch (Throwable $e) { Log::error('Failed to invoice a renewal that was paid.', [ 'subscription' => $subscription->uuid, 'stripe_invoice' => $invoiceId ?: null, 'error' => $e->getMessage(), ]); } } /** * 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); $this->rememberItem($subscription, $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; } /** * Keep the id of the item this subscription bills through. * * Stripe names it only on the subscription object, and moving a contract * onto another price is impossible without it — the price hangs off the * item, not off the subscription. Learnt here because this event arrives on * its own for every status change and every renewal, so the ordinary running * of a contract fills it in without anyone having to ask Stripe. * * Written outside the ordering guard on purpose: this is not part of the * running picture. An item id does not go stale, does not change when the * price on it does, and a stale event carries exactly the same one. * * @param array $object */ private function rememberItem(Subscription $subscription, array $object): void { if ($subscription->stripe_item_id !== null) { return; } $itemId = $object['items']['data'][0]['id'] ?? null; // One item, because one contract bills one package. A subscription with // several would need a rule for which of them the package is, and there // is nothing here to build one from — better to have none than to swap // the price on whichever happened to be first. if (is_string($itemId) && $itemId !== '' && count($object['items']['data'] ?? []) === 1) { $subscription->update(['stripe_item_id' => $itemId]); } } /** * 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. // // A booked plan change goes with the contract it was booked on. It // could never be carried out — clupilot:apply-due-plan-changes only // touches active contracts — but leaving the stamp behind would have // the portal and the console go on announcing that a package which // no longer exists is about to shrink. $subscription->update([ 'status' => 'cancelled', 'stripe_status' => $object['status'] ?? 'canceled', 'cancelled_at' => $endedAt, 'stripe_event_at' => $eventAt, 'pending_plan' => null, 'pending_effective_at' => null, ]); 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, ]; } }