$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. Whether it also owes a DOCUMENT is a // separate question with a separate answer — see owesADocument(). 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(), // The whole term, modules included. A cycle invoice bills the // package item AND one item per booked module, so what Stripe // took is the gross of all of them — held against `price_cents` // alone, every renewal on a contract with a single module was // filed as charged at the wrong amount. Nothing is agreed for a // proration or a hand-raised charge: what those are worth is // whatever Stripe worked out, and it arrives as the charged // figure below. netCents: $isRenewal ? $subscription->termNetCents() : 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 ($this->owesADocument($subscription->refresh(), $invoice, $start)) { $this->invoicePayment($subscription, $invoice, $start, $end); } return $record; } /** * Whether this charge is one the customer is still owed a document for. * * Nothing in this application could cancel a Stripe subscription, so a * customer who cancelled and a customer who withdrew both stayed subscribed — * and every month Stripe took another payment, `invoice.paid` arrived here, * and a real invoice was drawn out of the gapless Austrian series and mailed, * for a service that no longer existed. Cancelling the subscription is the * first half of the fix; this is the second, because a subscription somebody * failed to cancel, or one cancelled by hand in Stripe's dashboard, must not * be able to mint numbers here either. * * ## The rule, and why it is not simply "refuse a cancelled contract" * * A perfectly legitimate FINAL invoice can arrive after the contract has * ended, and refusing every one of them would lose a document the customer is * legally owed. The commonest case: the cycle invoice for the term they were * in went unpaid, Stripe's dunning ran for three weeks, the contract ended in * the middle of it, and then the customer paid. That money is for a term they * really did have a running cloud in. * * So the question is not WHEN the payment landed — Stripe's dunning makes that * arbitrary — but WHAT it is for: * * the period this invoice bills STARTED before the contract ended * → a term the customer was actually in. Document it. * the period begins at or after the moment the contract ended * → a term that does not exist. No document, no number, and an error in * the log, because a charge for a service that has ended means Stripe * was never stopped and somebody has to send that money back. * * The boundary case — a period starting exactly at `cancelled_at` — is the * first month the customer does NOT have, so it is refused. * * A contract marked cancelled with no date on it, or an invoice carrying no * period, cannot be judged by that rule at all, and both are refused. The * asymmetry is deliberate and it is the same one IssueInvoice is built on: a * missing document is recoverable — an operator issues it from the console — * while a number handed out in error can never be withdrawn, only corrected by * a cancellation document plus a new one. * * The register entry is written either way, above. The money moved; refusing * to record that would hide it, and the register is where an operator finds * the payments that need sending back. * * @param array $invoice */ private function owesADocument(Subscription $subscription, array $invoice, ?Carbon $periodStart): bool { if (! $subscription->hasEnded()) { return true; } $endedAt = $subscription->cancelled_at; if ($endedAt !== null && $periodStart !== null && $periodStart->lessThan($endedAt)) { return true; } Log::error('Stripe charged for a contract that has ended, so no invoice was issued and no number was used. This money has to go back.', [ 'subscription' => $subscription->uuid, 'stripe_subscription' => $subscription->stripe_subscription_id, 'stripe_invoice' => $invoice['id'] ?? null, 'billing_reason' => $invoice['billing_reason'] ?? null, 'amount_paid' => $invoice['amount_paid'] ?? null, 'contract_ended_at' => $endedAt?->toIso8601String(), 'billed_period_start' => $periodStart?->toIso8601String(), ]); return false; } /** * The document this payment owes the customer, and the one mail that carries * it. * * **Every paid invoice, not only a renewal.** The rule is one document per * charge, so a proration, a module booked in the middle of a term and a * charge raised by hand in Stripe's dashboard each get one — built from * Stripe's own lines, which is what actually took the money. The single * exception is the checkout's own invoice, which returns before this line: * that purchase already has a document, and a second would put two invoices * on one sale. * * 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. * * @param array $invoice */ private function invoicePayment(Subscription $subscription, array $invoice, ?Carbon $start, ?Carbon $end): void { try { ($this->issueStripeInvoice)($subscription->refresh(), $invoice, $start, $end); } catch (Throwable $e) { Log::error('Failed to invoice a Stripe payment that went through.', [ 'subscription' => $subscription->uuid, 'stripe_invoice' => $invoice['id'] ?? 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, ]; } }