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();