From 0560ae743dd519bf953069b5a4d3f4341a978372 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sun, 26 Jul 2026 13:36:28 +0200 Subject: [PATCH] feat(billing): Stripe owns the billing cycle, we own capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Product per plan family and a Price per priced row of a published version, plus the four subscribed webhook events that were arriving and being ignored. `stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored id is skipped, and each call carries an idempotency key derived from our own row, so a crash between Stripe creating a Price and us recording its id gives back the same object rather than a second one. That matters more here than usual — a Stripe Price cannot be edited or deleted, so a duplicate is permanent. Drafts are not synced at all: a version that has promised nothing has no business in a price list. --dry-run shows what would be created. The webhook now handles invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. It never re-derives an amount: the invoice is the authority for what was charged, and recomputing it from our catalogue would produce a second, disagreeing answer. A failed payment is recorded and nothing else — Stripe runs the dunning schedule, and cutting a customer off on the first failure would punish an expired card as though it were a refusal to pay. Only a cycle renewal moves the term on. Stripe also sends paid invoices for prorations and manual charges, and the checkout's own invoice — which is already in the register as the purchase, and would otherwise double every customer's first payment. Stripe does not guarantee delivery order, which is the source of most of the care here: - state changes are judged and written in one transaction with the row held, so two deliveries cannot both pass a check made on a stale copy; - an older event never overwrites a newer one, with a rank breaking ties between events sharing a second — their timestamps have one-second resolution, and a failed attempt and its successful retry routinely do; - a term is never shortened, and an ended contract is never revived; - an event for a contract that does not exist yet is HELD and replayed the moment it appears, rather than acknowledged and forgotten. A cancellation dropped that way would leave us serving someone who had already left. Held rows that never match are pruned after a week. Register entries are keyed by what they are about — the invoice, the attempt, the subscription — with a unique index doing the work, because a check two concurrent deliveries can both pass is not idempotency. PlanChange is now documented as the preview shown before someone confirms, not the invoice: Stripe's proration accounts for tax, existing credit and the exact second the change lands, and ours cannot. Not run against the live account — `stripe:sync-catalogue` creates objects that cannot be deleted, so that is the owner's call. The dry run lists 12. 457 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 --- app/Actions/ApplyStripeBillingEvent.php | 443 +++++++++++++++++ app/Actions/OpenSubscription.php | 3 + app/Actions/RecordCommercialEvent.php | 5 + app/Actions/StartCustomerProvisioning.php | 13 +- app/Console/Commands/SyncStripeCatalogue.php | 135 +++++ .../Controllers/StripeWebhookController.php | 31 +- app/Models/Order.php | 2 +- app/Models/StripePendingEvent.php | 24 + app/Models/Subscription.php | 1 + app/Models/SubscriptionRecord.php | 13 + app/Providers/AppServiceProvider.php | 3 + app/Services/Billing/PlanChange.php | 13 +- app/Services/Stripe/FakeStripeClient.php | 83 ++++ app/Services/Stripe/HttpStripeClient.php | 111 +++++ app/Services/Stripe/StripeClient.php | 42 ++ config/services.php | 5 + ...0001_create_subscription_records_table.php | 4 +- ...26_070000_link_subscriptions_to_stripe.php | 46 ++ ...1_one_register_entry_per_billing_event.php | 36 ++ ...070002_track_last_applied_stripe_event.php | 39 ++ ...003_create_stripe_pending_events_table.php | 42 ++ routes/console.php | 12 + tests/Feature/Billing/StripeBillingTest.php | 469 ++++++++++++++++++ .../Provisioning/StripeWebhookTest.php | 16 +- 24 files changed, 1582 insertions(+), 9 deletions(-) create mode 100644 app/Actions/ApplyStripeBillingEvent.php create mode 100644 app/Console/Commands/SyncStripeCatalogue.php create mode 100644 app/Models/StripePendingEvent.php create mode 100644 app/Services/Stripe/FakeStripeClient.php create mode 100644 app/Services/Stripe/HttpStripeClient.php create mode 100644 app/Services/Stripe/StripeClient.php create mode 100644 database/migrations/2026_07_26_070000_link_subscriptions_to_stripe.php create mode 100644 database/migrations/2026_07_26_070001_one_register_entry_per_billing_event.php create mode 100644 database/migrations/2026_07_26_070002_track_last_applied_stripe_event.php create mode 100644 database/migrations/2026_07_26_070003_create_stripe_pending_events_table.php create mode 100644 tests/Feature/Billing/StripeBillingTest.php diff --git a/app/Actions/ApplyStripeBillingEvent.php b/app/Actions/ApplyStripeBillingEvent.php new file mode 100644 index 0000000..d576f12 --- /dev/null +++ b/app/Actions/ApplyStripeBillingEvent.php @@ -0,0 +1,443 @@ + $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; + } + + 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, + ]; + } +} diff --git a/app/Actions/OpenSubscription.php b/app/Actions/OpenSubscription.php index ecf7806..496ac4a 100644 --- a/app/Actions/OpenSubscription.php +++ b/app/Actions/OpenSubscription.php @@ -57,6 +57,9 @@ class OpenSubscription [ 'customer_id' => $order->customer_id, 'order_id' => $order->id, + // Carried from the checkout: from here on, Stripe's events + // name this and nothing else. + 'stripe_subscription_id' => $order->stripe_subscription_id, 'started_at' => $start, 'current_period_start' => $start, 'current_period_end' => $term === Subscription::TERM_YEARLY diff --git a/app/Actions/RecordCommercialEvent.php b/app/Actions/RecordCommercialEvent.php index c18430d..2543cf7 100644 --- a/app/Actions/RecordCommercialEvent.php +++ b/app/Actions/RecordCommercialEvent.php @@ -36,6 +36,7 @@ class RecordCommercialEvent array $stripe = [], ?int $chargedGrossCents = null, ?Order $order = null, + ?string $eventKey = null, ): SubscriptionRecord { $at ??= now(); $customer = $subscription->customer; @@ -64,6 +65,10 @@ class RecordCommercialEvent return SubscriptionRecord::create([ 'event' => $event, + // What makes this event "the same one" if it arrives again. Unique + // where set, so a duplicate delivery collides in the database + // rather than in a check that two of them can both pass. + 'event_key' => $eventKey, 'customer_id' => $subscription->customer_id, 'subscription_id' => $subscription->id, diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index f5091d2..95af926 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -20,7 +20,10 @@ use Throwable; */ class StartCustomerProvisioning { - public function __construct(private OpenSubscription $openSubscription) {} + public function __construct( + private OpenSubscription $openSubscription, + private ApplyStripeBillingEvent $billing, + ) {} /** * @param array{id:string,email:string,name:?string,stripe_customer_id:?string,plan:string,datacenter:string,amount_cents:int,currency:string} $event @@ -51,6 +54,7 @@ class StartCustomerProvisioning 'currency' => $event['currency'], 'datacenter' => $event['datacenter'], 'stripe_event_id' => $event['id'], + 'stripe_subscription_id' => $event['stripe_subscription_id'] ?? null, 'status' => 'paid', ]); @@ -193,7 +197,12 @@ class StartCustomerProvisioning return; } - ($this->openSubscription)($order); + $subscription = ($this->openSubscription)($order); + + // Anything Stripe sent about this contract before it existed — + // a renewal, a failure, a cancellation — applied now, in the order + // they were raised. + $this->billing->replayHeldFor($subscription); } catch (Throwable $e) { Log::error('Failed to open a contract for a paid order.', [ 'order_id' => $order->id, 'plan' => $order->plan, 'error' => $e->getMessage(), diff --git a/app/Console/Commands/SyncStripeCatalogue.php b/app/Console/Commands/SyncStripeCatalogue.php new file mode 100644 index 0000000..16efa0e --- /dev/null +++ b/app/Console/Commands/SyncStripeCatalogue.php @@ -0,0 +1,135 @@ +option('dry-run'); + + if (! $dryRun && ! $stripe->isConfigured()) { + $this->error('Stripe is not configured (STRIPE_SECRET is empty). Nothing was created.'); + + return self::FAILURE; + } + + $created = 0; + + foreach (PlanFamily::query()->with('versions.prices')->orderBy('tier')->get() as $family) { + $published = $family->versions->filter(fn (PlanVersion $version) => $version->isPublished()); + + // A family whose versions are all drafts has promised nothing, so + // it has no business appearing in Stripe's price list yet. + if ($published->isEmpty()) { + continue; + } + + $productId = $family->stripe_product_id; + + if ($productId === null) { + $this->line(" product {$family->key} — {$family->name}"); + $created++; + + if (! $dryRun) { + $productId = $stripe->createProduct( + $family->name, + ['plan_family' => $family->key, 'plan_family_id' => (string) $family->id], + // Keyed on our row, so a crash between Stripe creating + // the product and us storing its id gives back the same + // product on the next run rather than a second one. + idempotencyKey: "clupilot-product-{$family->id}", + ); + $family->update(['stripe_product_id' => $productId]); + } + } + + foreach ($published as $version) { + foreach ($version->prices as $price) { + if ($price->stripe_price_id !== null) { + continue; + } + + $this->line(sprintf( + ' price %s v%d %s %d %s', + $family->key, $version->version, $price->term, $price->amount_cents, $price->currency, + )); + $created++; + + if ($dryRun || $productId === null) { + continue; + } + + $priceId = $stripe->createPrice( + productId: $productId, + amountCents: $price->amount_cents, + currency: $price->currency, + interval: $price->term === 'yearly' ? 'year' : 'month', + metadata: [ + 'plan_family' => $family->key, + 'plan_version' => (string) $version->version, + 'plan_version_id' => (string) $version->id, + 'plan_price_id' => (string) $price->id, + ], + idempotencyKey: "clupilot-price-{$price->id}", + ); + + // Written straight through the query builder: stripe_price_id + // is not part of what publication froze, and the model would + // otherwise have to be re-read first. + PlanPrice::query()->whereKey($price->id)->update(['stripe_price_id' => $priceId]); + } + } + } + + $this->newLine(); + + if ($created === 0) { + $this->info('Stripe is already in step with the catalogue.'); + + return self::SUCCESS; + } + + $this->info($dryRun + ? "{$created} object(s) would be created. Run without --dry-run to create them." + : "{$created} object(s) created in Stripe."); + + return self::SUCCESS; + } + + /** Versions whose prices are live in Stripe, for the status line. */ + public static function syncedVersions(): int + { + return PlanVersion::query() + ->whereNotNull('published_at') + ->whereHas('prices', fn ($q) => $q->whereNotNull('stripe_price_id')) + ->count(); + } +} diff --git a/app/Http/Controllers/StripeWebhookController.php b/app/Http/Controllers/StripeWebhookController.php index 604a983..fc7df9b 100644 --- a/app/Http/Controllers/StripeWebhookController.php +++ b/app/Http/Controllers/StripeWebhookController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers; +use App\Actions\ApplyStripeBillingEvent; use App\Actions\StartCustomerProvisioning; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,8 +14,11 @@ use Illuminate\Http\Request; */ class StripeWebhookController extends Controller { - public function __invoke(Request $request, StartCustomerProvisioning $action): JsonResponse - { + public function __invoke( + Request $request, + StartCustomerProvisioning $action, + ApplyStripeBillingEvent $billing, + ): JsonResponse { $payload = $request->getContent(); $secret = (string) config('services.stripe.webhook_secret'); @@ -30,6 +34,26 @@ class StripeWebhookController extends Controller $object = $event['data']['object'] ?? []; $type = $event['type'] ?? ''; + // The billing cycle is Stripe's: once a contract exists, they decide + // when it renews, when a payment failed, and when it has ended. Handled + // before the checkout branch because none of these are checkouts. + $applied = $billing->dispatch($event); + + if ($applied !== false) { + if ($applied === null) { + // Either already applied, or about a contract that does not + // exist yet — a checkout takes a moment to become one, and + // Stripe does not deliver in order. Holding it costs a row and + // saves a cancellation we would otherwise never hear about + // again; a replay is a no-op if it was simply a duplicate. + $billing->hold($event); + } + + // 2xx either way. Stripe retrying would not change the answer, and + // anything worth replaying is now held rather than dropped. + return response()->json(['handled' => $type, 'applied' => $applied !== null]); + } + // Paid triggers: a synchronous checkout (completed + paid) OR an async // method clearing later (async_payment_succeeded). Ignore everything else, // including the still-unpaid completed event for async methods. @@ -55,6 +79,9 @@ class StripeWebhookController extends Controller 'email' => $email, 'name' => $object['customer_details']['name'] ?? ($meta['name'] ?? null), 'stripe_customer_id' => $object['customer'] ?? null, + // The handle every later billing event arrives with. Without it an + // invoice.paid cannot be matched to the contract it renews. + 'stripe_subscription_id' => is_string($object['subscription'] ?? null) ? $object['subscription'] : null, 'plan' => $meta['plan'] ?? 'start', // What the customer was actually shown. Absent on a session created // before phase 5 put it there, and then the version on sale applies. diff --git a/app/Models/Order.php b/app/Models/Order.php index 3da17e1..e507d15 100644 --- a/app/Models/Order.php +++ b/app/Models/Order.php @@ -18,7 +18,7 @@ class Order extends Model implements ProvisioningSubject protected $fillable = [ 'customer_id', 'plan', 'plan_version_id', 'type', 'addon_key', 'amount_cents', 'currency', - 'datacenter', 'stripe_event_id', 'status', + 'datacenter', 'stripe_event_id', 'stripe_subscription_id', 'status', ]; protected function casts(): array diff --git a/app/Models/StripePendingEvent.php b/app/Models/StripePendingEvent.php new file mode 100644 index 0000000..e17371e --- /dev/null +++ b/app/Models/StripePendingEvent.php @@ -0,0 +1,24 @@ + 'array', + 'raised_at' => 'datetime', + ]; + } +} diff --git a/app/Models/Subscription.php b/app/Models/Subscription.php index 5e35244..82d927a 100644 --- a/app/Models/Subscription.php +++ b/app/Models/Subscription.php @@ -62,6 +62,7 @@ class Subscription extends Model 'current_period_end' => 'datetime', 'pending_effective_at' => 'datetime', 'cancelled_at' => 'datetime', + 'stripe_event_at' => 'datetime', ]; } diff --git a/app/Models/SubscriptionRecord.php b/app/Models/SubscriptionRecord.php index d2ddb0e..0095a62 100644 --- a/app/Models/SubscriptionRecord.php +++ b/app/Models/SubscriptionRecord.php @@ -27,6 +27,19 @@ class SubscriptionRecord extends Model public const EVENT_DOWNGRADE = 'downgrade'; + /** A term that renewed and was paid. Stripe's invoice says so. */ + public const EVENT_RENEWAL = 'renewal'; + + /** A renewal Stripe could not collect. Their dunning takes it from here. */ + public const EVENT_PAYMENT_FAILED = 'payment_failed'; + + /** + * A paid invoice that is not a cycle renewal — a proration, or a charge + * raised by hand. Money received, so it belongs in the register, but it + * does not start a new term and must not move the period. + */ + public const EVENT_INVOICE_PAID = 'invoice_paid'; + public const EVENT_ADDON_BOOKED = 'addon_booked'; public const EVENT_ADDON_CANCELLED = 'addon_cancelled'; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 74662a1..f73bc8b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -9,6 +9,8 @@ use App\Services\Monitoring\HttpMonitoringClient; use App\Services\Monitoring\MonitoringClient; use App\Services\Proxmox\HttpProxmoxClient; use App\Services\Proxmox\ProxmoxClient; +use App\Services\Stripe\HttpStripeClient; +use App\Services\Stripe\StripeClient; use App\Services\Ssh\PhpseclibRemoteShell; use App\Services\Ssh\RemoteShell; use App\Services\Traefik\SshTraefikWriter; @@ -43,6 +45,7 @@ class AppServiceProvider extends ServiceProvider $this->app->bind(HetznerDnsClient::class, HttpHetznerDnsClient::class); $this->app->bind(TraefikWriter::class, SshTraefikWriter::class); $this->app->bind(MonitoringClient::class, HttpMonitoringClient::class); + $this->app->bind(StripeClient::class, HttpStripeClient::class); } /** diff --git a/app/Services/Billing/PlanChange.php b/app/Services/Billing/PlanChange.php index 8b63485..7735fa0 100644 --- a/app/Services/Billing/PlanChange.php +++ b/app/Services/Billing/PlanChange.php @@ -7,7 +7,18 @@ use Illuminate\Support\Carbon; use RuntimeException; /** - * What a plan change costs, and whether it is allowed yet. + * The PREVIEW of a plan change: what it would cost, and whether it is allowed + * yet. + * + * A preview, not an invoice. Since Stripe took over the billing cycle, the + * amount a customer is actually charged for an upgrade comes back on Stripe's + * proration invoice, and that is the authority — it accounts for tax, for + * credit already on the account, and for the exact second the change lands, and + * ours cannot. What this is for is the sentence shown before someone confirms, + * so nobody is asked to agree to a number they have not seen. + * + * The two are expected to agree to the cent in the ordinary case. Where they do + * not, the invoice wins and the proof register records what was charged. * * Two rules, both the customer's rules: * diff --git a/app/Services/Stripe/FakeStripeClient.php b/app/Services/Stripe/FakeStripeClient.php new file mode 100644 index 0000000..664f2b8 --- /dev/null +++ b/app/Services/Stripe/FakeStripeClient.php @@ -0,0 +1,83 @@ + */ + public array $products = []; + + /** @var array */ + public array $prices = []; + + /** @var array */ + public array $archived = []; + + /** Idempotency key → the id first returned for it. @var array */ + public array $keys = []; + + public function isConfigured(): bool + { + return $this->configured; + } + + public function createProduct(string $name, array $metadata = [], ?string $idempotencyKey = null): string + { + // Replays the first answer for a repeated key, as Stripe does. + if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) { + return $this->keys[$idempotencyKey]; + } + + $id = 'prod_'.substr(sha1($name.count($this->products)), 0, 12); + $this->products[$id] = ['name' => $name, 'metadata' => $metadata]; + + if ($idempotencyKey !== null) { + $this->keys[$idempotencyKey] = $id; + } + + return $id; + } + + public function createPrice( + string $productId, + int $amountCents, + string $currency, + string $interval, + array $metadata = [], + ?string $idempotencyKey = null, + ): string { + if ($idempotencyKey !== null && isset($this->keys[$idempotencyKey])) { + return $this->keys[$idempotencyKey]; + } + + $id = 'price_'.substr(sha1($productId.$amountCents.$interval.count($this->prices)), 0, 12); + $this->prices[$id] = [ + 'product' => $productId, + 'amount' => $amountCents, + 'currency' => $currency, + 'interval' => $interval, + 'metadata' => $metadata, + ]; + + if ($idempotencyKey !== null) { + $this->keys[$idempotencyKey] = $id; + } + + return $id; + } + + public function archivePrice(string $priceId): void + { + $this->archived[] = $priceId; + } +} diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php new file mode 100644 index 0000000..24ef015 --- /dev/null +++ b/app/Services/Stripe/HttpStripeClient.php @@ -0,0 +1,111 @@ +request($idempotencyKey) + ->asForm() + ->post($this->url('products'), array_filter([ + 'name' => $name, + ...$this->flatten('metadata', $metadata), + ])) + ->throw() + ->json('id'); + } + + public function createPrice( + string $productId, + int $amountCents, + string $currency, + string $interval, + array $metadata = [], + ?string $idempotencyKey = null, + ): string { + return (string) $this->request($idempotencyKey) + ->asForm() + ->post($this->url('prices'), [ + 'product' => $productId, + 'unit_amount' => $amountCents, + 'currency' => strtolower($currency), + // Recurring, because a plan is a subscription. The interval + // belongs to the Price, which is why monthly and yearly cannot + // share one. + 'recurring[interval]' => $interval, + ...$this->flatten('metadata', $metadata), + ]) + ->throw() + ->json('id'); + } + + public function archivePrice(string $priceId): void + { + // Deactivated, never deleted: a Stripe Price is immutable and existing + // subscriptions keep billing against it. That is the same + // grandfathering our own catalogue does, enforced on their side too. + $this->request() + ->asForm() + ->post($this->url('prices/'.$priceId), ['active' => 'false']) + ->throw(); + } + + private function request(?string $idempotencyKey = null): PendingRequest + { + $secret = (string) config('services.stripe.secret'); + + if (blank($secret)) { + throw new RuntimeException('Stripe is not configured (STRIPE_SECRET is empty).'); + } + + $request = Http::withToken($secret)->acceptJson()->timeout(20); + + // Stripe replays the original response for a repeated key instead of + // creating a second object. That covers the gap this cannot close on + // its own: a crash between Stripe creating a Price and us storing its + // id. Their keys expire after 24 hours, so it protects a retry, not a + // sync re-run next week — for which the stored ids are the guard. + return $idempotencyKey !== null + ? $request->withHeaders(['Idempotency-Key' => $idempotencyKey]) + : $request; + } + + private function url(string $path): string + { + return rtrim((string) config('services.stripe.api_base'), '/').'/'.$path; + } + + /** + * Stripe's form API takes nested keys as `metadata[key]`. + * + * @param array $values + * @return array + */ + private function flatten(string $prefix, array $values): array + { + $flat = []; + + foreach ($values as $key => $value) { + if ($value !== null && $value !== '') { + $flat["{$prefix}[{$key}]"] = (string) $value; + } + } + + return $flat; + } +} diff --git a/app/Services/Stripe/StripeClient.php b/app/Services/Stripe/StripeClient.php new file mode 100644 index 0000000..37cf2f5 --- /dev/null +++ b/app/Services/Stripe/StripeClient.php @@ -0,0 +1,42 @@ + [ 'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'), + // Secret key for the REST API. Blank means "not connected": the + // catalogue sync says so and does nothing, rather than half-creating + // products against an account that is not there. + 'secret' => env('STRIPE_SECRET'), + 'api_base' => env('STRIPE_API_BASE', 'https://api.stripe.com/v1'), ], 'monitoring' => [ diff --git a/database/migrations/2026_07_26_060001_create_subscription_records_table.php b/database/migrations/2026_07_26_060001_create_subscription_records_table.php index 04650d2..3b7d690 100644 --- a/database/migrations/2026_07_26_060001_create_subscription_records_table.php +++ b/database/migrations/2026_07_26_060001_create_subscription_records_table.php @@ -26,8 +26,8 @@ return new class extends Migration $table->id(); $table->uuid()->unique(); - // purchase | upgrade | downgrade | addon_booked | addon_cancelled | - // cancellation + // purchase | renewal | payment_failed | upgrade | downgrade | + // addon_booked | addon_cancelled | cancellation $table->string('event'); // Nullable and nullOnDelete throughout: a customer exercising their diff --git a/database/migrations/2026_07_26_070000_link_subscriptions_to_stripe.php b/database/migrations/2026_07_26_070000_link_subscriptions_to_stripe.php new file mode 100644 index 0000000..ce9cd73 --- /dev/null +++ b/database/migrations/2026_07_26_070000_link_subscriptions_to_stripe.php @@ -0,0 +1,46 @@ +string('stripe_subscription_id')->nullable()->unique()->after('order_id'); + // Stripe's own view of the state (active, past_due, canceled …), + // kept beside ours: they are the authority on whether the money + // arrived, we are the authority on what the customer may use. + $table->string('stripe_status')->nullable()->after('status'); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->string('stripe_subscription_id')->nullable()->after('stripe_event_id'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropUnique(['stripe_subscription_id']); + $table->dropColumn(['stripe_subscription_id', 'stripe_status']); + }); + + Schema::table('orders', function (Blueprint $table) { + $table->dropColumn('stripe_subscription_id'); + }); + } +}; diff --git a/database/migrations/2026_07_26_070001_one_register_entry_per_billing_event.php b/database/migrations/2026_07_26_070001_one_register_entry_per_billing_event.php new file mode 100644 index 0000000..e17d5f7 --- /dev/null +++ b/database/migrations/2026_07_26_070001_one_register_entry_per_billing_event.php @@ -0,0 +1,36 @@ +string('event_key')->nullable()->unique()->after('event'); + }); + } + + public function down(): void + { + Schema::table('subscription_records', function (Blueprint $table) { + $table->dropUnique(['event_key']); + $table->dropColumn('event_key'); + }); + } +}; diff --git a/database/migrations/2026_07_26_070002_track_last_applied_stripe_event.php b/database/migrations/2026_07_26_070002_track_last_applied_stripe_event.php new file mode 100644 index 0000000..32086c3 --- /dev/null +++ b/database/migrations/2026_07_26_070002_track_last_applied_stripe_event.php @@ -0,0 +1,39 @@ +timestamp('stripe_event_at')->nullable()->after('stripe_status'); + // Stripe's timestamps have one-second resolution, and a failed + // attempt and its successful retry can share a second. The rank + // breaks that tie by what the event says about the money: a + // payment that succeeded outranks one that did not. + $table->unsignedTinyInteger('stripe_event_rank')->nullable()->after('stripe_event_at'); + }); + } + + public function down(): void + { + Schema::table('subscriptions', function (Blueprint $table) { + $table->dropColumn(['stripe_event_at', 'stripe_event_rank']); + }); + } +}; diff --git a/database/migrations/2026_07_26_070003_create_stripe_pending_events_table.php b/database/migrations/2026_07_26_070003_create_stripe_pending_events_table.php new file mode 100644 index 0000000..0ab70e1 --- /dev/null +++ b/database/migrations/2026_07_26_070003_create_stripe_pending_events_table.php @@ -0,0 +1,42 @@ +id(); + // Stripe's event id, so a retried delivery does not queue it twice. + $table->string('stripe_event_id')->unique(); + $table->string('stripe_subscription_id')->index(); + $table->string('type'); + $table->timestamp('raised_at')->nullable(); + $table->json('payload'); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stripe_pending_events'); + } +}; diff --git a/routes/console.php b/routes/console.php index 8119de8..f76c9d9 100644 --- a/routes/console.php +++ b/routes/console.php @@ -27,3 +27,15 @@ Schedule::job(new \App\Provisioning\Jobs\SyncVpnPeers) Schedule::job(new \App\Provisioning\Jobs\CollectInstanceTraffic) ->everyFifteenMinutes() ->name('traffic-collect'); + +// Discard Stripe events that were held for a contract which never appeared. +// Most of them never will: Stripe also delivers events for subscriptions +// belonging to other environments pointed at this endpoint, and for objects +// made by hand in the dashboard. A week is far longer than the moment a +// checkout takes to become a contract, and short enough that the table stays +// a holding area rather than a second copy of Stripe's event log. +Schedule::call(fn () => \App\Models\StripePendingEvent::query() + ->where('created_at', '<', now()->subWeek()) + ->delete()) + ->daily() + ->name('stripe-pending-prune'); diff --git a/tests/Feature/Billing/StripeBillingTest.php b/tests/Feature/Billing/StripeBillingTest.php new file mode 100644 index 0000000..ee9920e --- /dev/null +++ b/tests/Feature/Billing/StripeBillingTest.php @@ -0,0 +1,469 @@ +instance(StripeClient::class, $fake); + + return $fake; +} + +/** A contract Stripe will send events about. */ +function stripeContract(string $stripeId = 'sub_1'): Subscription +{ + return Subscription::factory()->plan('team')->create([ + 'stripe_subscription_id' => $stripeId, + 'current_period_start' => now()->subMonth(), + 'current_period_end' => now(), + ]); +} + +it('mirrors the catalogue into Stripe, once', function () { + $stripe = fakeStripe(); + + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + // A product per family, a price per priced row of a published version. + expect($stripe->products)->toHaveCount(4) + ->and($stripe->prices)->toHaveCount(8) + ->and(PlanFamily::query()->whereNull('stripe_product_id')->count())->toBe(0) + ->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0); + + // A Stripe Price cannot be edited, so a second run that minted duplicates + // would leave two live prices for one plan and no way to tell them apart. + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + expect($stripe->products)->toHaveCount(4) + ->and($stripe->prices)->toHaveCount(8); +}); + +it('gives each price its own recurring interval', function () { + $stripe = fakeStripe(); + $this->artisan('stripe:sync-catalogue'); + + $intervals = collect($stripe->prices)->groupBy('interval')->map->count(); + + // Monthly and yearly cannot share a Price, because the interval belongs to + // the Price itself. + expect($intervals['month'])->toBe(4) + ->and($intervals['year'])->toBe(4); + + $team = collect($stripe->prices)->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month'); + expect($team['amount'])->toBe(17900) + ->and($team['metadata']['plan_version'])->toBe('1'); +}); + +it('does not put an unpublished draft in the price list', function () { + $stripe = fakeStripe(); + $family = PlanFamily::query()->where('key', 'team')->sole(); + + app(App\Services\Billing\PlanCatalogue::class)->draft($family, [ + 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, + 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, + 'features' => [], + ], ['monthly' => 19900, 'yearly' => 238800]); + + $this->artisan('stripe:sync-catalogue'); + + // A draft has promised nothing; a Price for it would be a price list entry + // for something that may never exist. + expect($stripe->prices)->toHaveCount(8) + ->and(collect($stripe->prices)->pluck('amount'))->not->toContain(19900); +}); + +it('does not mint a second object when a run is interrupted before the id is stored', function () { + $stripe = fakeStripe(); + + $this->artisan('stripe:sync-catalogue'); + + // A crash between Stripe creating the objects and us recording their ids. + PlanFamily::query()->update(['stripe_product_id' => null]); + PlanPrice::query()->update(['stripe_price_id' => null]); + + $this->artisan('stripe:sync-catalogue')->assertSuccessful(); + + // Stripe replays the original answer for a repeated idempotency key, so the + // catalogue reconnects to what is already there instead of duplicating it — + // and a Price cannot be deleted afterwards to tidy up. + expect($stripe->products)->toHaveCount(4) + ->and($stripe->prices)->toHaveCount(8) + ->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0); +}); + +it('does not revive a contract Stripe already ended', function () { + $subscription = stripeContract(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_end', 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => now()->timestamp]], + ])->assertOk(); + + // Stripe does not guarantee delivery order: the final invoice can land + // after the deletion. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_late', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_late', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk(); + + $subscription->refresh(); + + // The payment is on record; the departed customer does not get their + // service back, and the cancellation is not recorded a second time. + expect($subscription->status)->toBe('cancelled') + ->and($subscription->cancelled_at)->not->toBeNull() + ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1) + ->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1); +}); + +it('does not put a plan that has never been published in Stripe at all', function () { + $stripe = fakeStripe(); + $family = PlanFamily::query()->create(['key' => 'agency', 'name' => 'Agentur', 'tier' => 5]); + app(App\Services\Billing\PlanCatalogue::class)->draft($family, [ + 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, + 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, + 'features' => [], + ], ['monthly' => 24900, 'yearly' => 298800]); + + $this->artisan('stripe:sync-catalogue'); + + // Not even the Product: it would be a price-list entry for something that + // has promised nothing to anyone. + expect($stripe->products)->toHaveCount(4) + ->and($family->fresh()->stripe_product_id)->toBeNull(); +}); + +it('does not record the checkout\'s own invoice as a renewal', function () { + stripeContract(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_first', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_first', 'subscription' => 'sub_1', 'amount_paid' => 21480, + // Stripe sends this for the invoice created during checkout. + 'billing_reason' => 'subscription_create', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk()->assertJson(['applied' => false]); + + // The purchase is already in the register; counting it again here would + // double every customer's first payment. + expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0); +}); + +it('does not move the term on for a proration or a manual charge', function () { + $subscription = stripeContract(); + $originalEnd = $subscription->current_period_end; + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_prorata', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_prorata', 'subscription' => 'sub_1', 'amount_paid' => 4200, + 'billing_reason' => 'subscription_update', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk(); + + // Money received, so it is on record — but a top-up does not start a new + // month, and pushing the period forward on one would give it away. + $record = SubscriptionRecord::query()->where('event', 'invoice_paid')->sole(); + + expect($record->gross_cents)->toBe(4200) + ->and($record->snapshot['billing_reason'])->toBe('subscription_update') + ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(0) + ->and($subscription->fresh()->current_period_end->timestamp)->toBe($originalEnd->timestamp); +}); + +it('refuses an older event delivered behind a newer one', function () { + $subscription = stripeContract(); + $secondTermEnd = now()->addMonths(2); + + // Renewal N+1 arrives first. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_n2', 'type' => 'invoice.paid', 'created' => now()->timestamp, + 'data' => ['object' => [ + 'id' => 'in_n2', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->addMonth()->timestamp, 'period_end' => $secondTermEnd->timestamp, + ]], + ])->assertOk(); + + // Renewal N turns up late, and a failed attempt from before that. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_n1', 'type' => 'invoice.paid', 'created' => now()->subHour()->timestamp, + 'data' => ['object' => [ + 'id' => 'in_n1', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_old_fail', 'type' => 'invoice.payment_failed', 'created' => now()->subHours(2)->timestamp, + 'data' => ['object' => ['id' => 'in_old', 'subscription' => 'sub_1', 'attempt_count' => 1]], + ])->assertOk(); + + $subscription->refresh(); + + // The term is not shortened, and a customer who has since paid is not + // flipped back to overdue. + expect($subscription->current_period_end->timestamp)->toBe($secondTermEnd->timestamp) + ->and($subscription->stripe_status)->toBe('active') + // Both payments are still on record: a late arrival is recorded, it + // just does not get to rewrite the running picture. + ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(2) + ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1); +}); + +it('lets a payment that went through outrank a failure from the same second', function () { + $subscription = stripeContract(); + $second = now(); + + // Stripe timestamps have one-second resolution, so a failed attempt and + // the retry that succeeded routinely share one. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_ok', 'type' => 'invoice.paid', 'created' => $second->timestamp, + 'data' => ['object' => [ + 'id' => 'in_ok', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => $second->timestamp, 'period_end' => $second->copy()->addMonth()->timestamp, + ]], + ])->assertOk(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_nok', 'type' => 'invoice.payment_failed', 'created' => $second->timestamp, + 'data' => ['object' => ['id' => 'in_ok', 'subscription' => 'sub_1', 'attempt_count' => 1]], + ])->assertOk(); + + // The customer has paid; a same-second failure must not say otherwise. + expect($subscription->fresh()->stripe_status)->toBe('active') + ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->count())->toBe(1); +}); + +it('holds a billing event that arrives before the contract exists, and applies it after', function () { + Queue::fake(); + + // The cancellation overtakes the checkout — Stripe does not deliver in + // order, and a checkout takes a moment to become a contract. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_early', 'type' => 'customer.subscription.deleted', 'created' => now()->timestamp, + 'data' => ['object' => ['id' => 'sub_early', 'status' => 'canceled', 'ended_at' => now()->timestamp]], + ])->assertOk()->assertJson(['applied' => false]); + + expect(App\Models\StripePendingEvent::query()->count())->toBe(1); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_checkout', 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_early', 'payment_status' => 'paid', 'subscription' => 'sub_early', + 'customer_details' => ['email' => 'schnell@example.com'], + 'amount_total' => 21480, 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], + ]], + ])->assertOk(); + + // Answering 2xx and forgetting it would have left us serving someone who + // had already left. + $subscription = Subscription::query()->sole(); + + expect($subscription->status)->toBe('cancelled') + ->and(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1) + // The holding area is a holding area, not a second event log. + ->and(App\Models\StripePendingEvent::query()->count())->toBe(0); +}); + +it('holds an event only once however often Stripe redelivers it', function () { + $event = [ + 'id' => 'evt_repeat', 'type' => 'invoice.paid', 'created' => now()->timestamp, + 'data' => ['object' => [ + 'id' => 'in_repeat', 'subscription' => 'sub_nothere', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + ]], + ]; + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + + expect(App\Models\StripePendingEvent::query()->count())->toBe(1); +}); + +it('creates nothing when Stripe is not connected', function () { + $stripe = fakeStripe(); + $stripe->configured = false; + + $this->artisan('stripe:sync-catalogue')->assertFailed(); + + expect($stripe->products)->toBeEmpty() + ->and(PlanFamily::query()->whereNotNull('stripe_product_id')->count())->toBe(0); +}); + +it('moves the period on when Stripe says a renewal was paid', function () { + $subscription = stripeContract(); + $end = now()->addMonth(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_paid', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_100', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->timestamp, 'period_end' => $end->timestamp, + ]], + ])->assertOk()->assertJson(['applied' => true]); + + $subscription->refresh(); + + expect($subscription->current_period_end->timestamp)->toBe($end->timestamp) + ->and($subscription->stripe_status)->toBe('active'); + + $record = SubscriptionRecord::query()->where('event', 'renewal')->sole(); + + // The invoice is the authority for the amount; the contract for the terms. + expect($record->gross_cents)->toBe(21480) + ->and($record->stripe_invoice_id)->toBe('in_100') + ->and($record->plan_key)->toBe('team'); +}); + +it('records one renewal however many times Stripe delivers the invoice', function () { + stripeContract(); + + $event = [ + 'id' => 'evt_dup', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_dup', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ]; + + $this->postJson(route('webhooks.stripe'), $event)->assertOk(); + $this->postJson(route('webhooks.stripe'), $event)->assertOk()->assertJson(['applied' => false]); + + expect(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1); +}); + +it('records a failed payment without cutting the customer off', function () { + $subscription = stripeContract(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_fail', 'type' => 'invoice.payment_failed', + 'data' => ['object' => ['id' => 'in_fail', 'subscription' => 'sub_1', 'attempt_count' => 1]], + ])->assertOk(); + + $subscription->refresh(); + + // Stripe runs the dunning schedule. Cutting someone off on the first + // failed attempt would punish an expired card as a refusal to pay. + expect($subscription->stripe_status)->toBe('past_due') + ->and($subscription->status)->toBe('active') + ->and(SubscriptionRecord::query()->where('event', 'payment_failed')->sole()->stripe_invoice_id) + ->toBe('in_fail'); +}); + +it('ends the contract, and records it, when Stripe ends the subscription', function () { + $subscription = stripeContract(); + $endedAt = now()->addDay(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_del', 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]], + ])->assertOk()->assertJson(['applied' => true]); + + $subscription->refresh(); + + expect($subscription->status)->toBe('cancelled') + ->and($subscription->cancelled_at->timestamp)->toBe($endedAt->timestamp); + + $record = SubscriptionRecord::query()->where('event', 'cancellation')->sole(); + expect($record->plan_key)->toBe('team'); + + // A retry must not record the ending twice. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_del', 'type' => 'customer.subscription.deleted', + 'data' => ['object' => ['id' => 'sub_1', 'status' => 'canceled', 'ended_at' => $endedAt->timestamp]], + ])->assertOk()->assertJson(['applied' => false]); + + expect(SubscriptionRecord::query()->where('event', 'cancellation')->count())->toBe(1); +}); + +it('copies Stripe\'s status without handing it the keys', function () { + $subscription = stripeContract(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_upd', 'type' => 'customer.subscription.updated', + 'data' => ['object' => [ + 'id' => 'sub_1', 'status' => 'past_due', + 'current_period_start' => now()->timestamp, + 'current_period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk(); + + $subscription->refresh(); + + // They are the authority on whether the money arrived; we stay the + // authority on what the customer may use. + expect($subscription->stripe_status)->toBe('past_due') + ->and($subscription->status)->toBe('active'); +}); + +it('carries the Stripe subscription from the checkout onto the contract', function () { + Queue::fake(); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_new', 'type' => 'checkout.session.completed', + 'data' => ['object' => [ + 'id' => 'cs_new', 'payment_status' => 'paid', 'subscription' => 'sub_new', + 'customer_details' => ['email' => 'neu@example.com'], + 'amount_total' => 21480, 'currency' => 'eur', + 'metadata' => ['plan' => 'team', 'datacenter' => 'fsn'], + ]], + ])->assertOk(); + + // Without this, no later billing event could be matched to the contract. + expect(Subscription::query()->sole()->stripe_subscription_id)->toBe('sub_new'); +}); + +it('never rewrites the snapshot when a renewal arrives', function () { + $subscription = stripeContract(); + + // The catalogue moves on between terms. + $catalogue = app(App\Services\Billing\PlanCatalogue::class); + $current = $catalogue->currentVersion('team'); + $catalogue->schedule($current, $current->available_from, now()); + + $dearer = App\Models\PlanVersion::query()->create([ + ...$current->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), + 'version' => 2, 'features' => $current->features, 'available_from' => now(), + ]); + $dearer->prices()->create(['term' => 'monthly', 'amount_cents' => 29900, 'currency' => 'EUR']); + $dearer->prices()->create(['term' => 'yearly', 'amount_cents' => 358800, 'currency' => 'EUR']); + $catalogue->publish($dearer, now()); + + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_ren', 'type' => 'invoice.paid', + 'data' => ['object' => [ + 'id' => 'in_ren', 'subscription' => 'sub_1', 'amount_paid' => 21480, + 'billing_reason' => 'subscription_cycle', + 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, + ]], + ])->assertOk(); + + // A renewal moves the month, not the terms. + expect($subscription->fresh()->price_cents)->toBe(17900) + ->and($subscription->fresh()->plan_version_id)->toBe($current->id); +}); diff --git a/tests/Feature/Provisioning/StripeWebhookTest.php b/tests/Feature/Provisioning/StripeWebhookTest.php index 6f16caf..6a2e603 100644 --- a/tests/Feature/Provisioning/StripeWebhookTest.php +++ b/tests/Feature/Provisioning/StripeWebhookTest.php @@ -48,13 +48,27 @@ it('is idempotent — a duplicate webhook starts only one run', function () { it('ignores unrelated event types', function () { Queue::fake(); - $this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'invoice.paid', 'data' => ['object' => []]]) + $this->postJson(route('webhooks.stripe'), ['id' => 'evt_x', 'type' => 'payout.paid', 'data' => ['object' => []]]) ->assertOk() ->assertJson(['ignored' => true]); Queue::assertNothingPushed(); }); +it('answers a billing event for a subscription it does not have, without retrying forever', function () { + Queue::fake(); + + // Stripe delivers events for objects created by hand in the dashboard, and + // for other environments pointed at the same endpoint. A non-2xx here would + // have them retry something that can never succeed. + $this->postJson(route('webhooks.stripe'), [ + 'id' => 'evt_orphan', 'type' => 'invoice.paid', + 'data' => ['object' => ['id' => 'in_1', 'subscription' => 'sub_unknown', 'amount_paid' => 21480]], + ])->assertOk()->assertJson(['handled' => 'invoice.paid', 'applied' => false]); + + Queue::assertNothingPushed(); +}); + it('ignores a paid checkout without a customer email', function () { Queue::fake();