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(), ]); } /** * The plan half of the mirror — modules live in the same lists. * * Two Prices per priced row, not one: the domestic gross everybody who is charged * VAT pays, and the bare net a verified business in another member state owes. * `$treatment` picks one of the two where a test is about a figure rather than * about how many objects there are. */ function planPrices(FakeStripeClient $stripe, ?string $treatment = null): Collection { return collect($stripe->prices) ->filter(fn ($price) => isset($price['metadata']['plan_family'])) ->filter(fn ($price) => $treatment === null || $price['metadata']['tax_treatment'] === $treatment); } function planProducts(FakeStripeClient $stripe): Collection { return collect($stripe->products)->filter(fn ($product) => isset($product['metadata']['plan_family'])); } /** The module half: a Price per module, term and treatment, so a booking has one to bill on. */ function modulePrices(FakeStripeClient $stripe): Collection { return collect($stripe->prices)->filter(fn ($price) => isset($price['metadata']['addon'])); } it('mirrors the catalogue into Stripe, once', function () { $stripe = fakeStripe(); $this->artisan('stripe:sync-catalogue')->assertSuccessful(); // A product per family, and TWO prices per priced row of a published // version: the domestic gross and the bare net a reverse-charge business is // charged. Both are minted up front, because the Price has to exist before // the checkout that needs it. expect(planProducts($stripe))->toHaveCount(4) ->and(planPrices($stripe))->toHaveCount(16) ->and(planPrices($stripe, 'domestic'))->toHaveCount(8) ->and(planPrices($stripe, 'reverse_charge'))->toHaveCount(8) ->and(PlanFamily::query()->whereNull('stripe_product_id')->count())->toBe(0) // The catalogue row still points at the DOMESTIC Price, which is the // ordinary sale; the net one is only ever read out of the register. ->and(PlanPrice::query()->whereNull('stripe_price_id')->count())->toBe(0); // And every module we sell, on both terms: a Stripe Price carries its own // interval, so a module on a yearly contract needs a yearly one. Without // these a booked module could never become an item on the subscription, // which is why it was charged in the month it was booked and never again. // Times two again, for the same reason as the packages. $modules = array_merge(array_keys((array) config('provisioning.addons')), ['storage']); $storageYear = StripeAddonPrice::query()->where('addon_key', 'storage')->where('interval', 'year'); // A yearly module Price is twelve months of the NET figure, grossed once — // which is what a yearly customer is actually charged for it. The // reverse-charge Price beside it charges that net figure and nothing on top. expect(modulePrices($stripe))->toHaveCount(count($modules) * 4) ->and((clone $storageYear)->where('reverse_charge', false)->value('amount_cents')) ->toBe(TaxTreatment::advertisedCents(12 * (int) config('provisioning.storage_addon.price_cents'))) ->and((clone $storageYear)->where('reverse_charge', true)->value('amount_cents')) ->toBe(12 * (int) config('provisioning.storage_addon.price_cents')) ->and((clone $storageYear)->where('reverse_charge', false)->value('net_cents')) ->toBe(12 * (int) config('provisioning.storage_addon.price_cents')); // 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(planPrices($stripe))->toHaveCount(16) ->and(modulePrices($stripe))->toHaveCount(count($modules) * 4) ->and($stripe->products)->toHaveCount(4 + count($modules)); }); it('gives each price its own recurring interval', function () { $stripe = fakeStripe(); $this->artisan('stripe:sync-catalogue'); $intervals = planPrices($stripe, 'domestic')->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); // The DOMESTIC GROSS, which is the figure on the website and the one taken // from the card of everybody who is charged VAT. The catalogue row stays net. $team = planPrices($stripe, 'domestic') ->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month'); expect($team['amount'])->toBe(21480) ->and($team['amount'])->toBe(TaxTreatment::advertisedCents(17900)) ->and($team['metadata']['plan_version'])->toBe('1'); // And the bare net beside it, on the same Product, for the business that owes // no VAT at all. One Price for everybody was the overcharge: 214,80 € taken // for a 179,00 € package from exactly the customers who read their invoices. $reverse = planPrices($stripe, 'reverse_charge') ->first(fn ($p) => $p['metadata']['plan_family'] === 'team' && $p['interval'] === 'month'); expect($reverse['amount'])->toBe(17900) ->and($reverse['product'])->toBe($team['product']); }); it('does not put an unpublished draft in the price list', function () { $stripe = fakeStripe(); $family = PlanFamily::query()->where('key', 'team')->sole(); app(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(planPrices($stripe))->toHaveCount(16) ->and(planPrices($stripe)->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(planProducts($stripe))->toHaveCount(4) ->and(planPrices($stripe))->toHaveCount(16) ->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(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(planProducts($stripe))->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(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(StripePendingEvent::query()->count())->toBe(0); }); it('holds nothing for a contract that is right there', function () { stripeContract(); // Deliberately skipped: the checkout's own invoice. $this->postJson(route('webhooks.stripe'), [ 'id' => 'evt_c', 'type' => 'invoice.paid', 'created' => now()->timestamp, 'data' => ['object' => [ 'id' => 'in_c', 'subscription' => 'sub_1', 'amount_paid' => 21480, 'billing_reason' => 'subscription_create', ]], ])->assertOk(); // Already recorded: a redelivered renewal. $renewal = [ 'id' => 'evt_r', 'type' => 'invoice.paid', 'created' => now()->timestamp, 'data' => ['object' => [ 'id' => 'in_r', 'subscription' => 'sub_1', 'amount_paid' => 21480, 'billing_reason' => 'subscription_cycle', 'period_start' => now()->timestamp, 'period_end' => now()->addMonth()->timestamp, ]], ]; $this->postJson(route('webhooks.stripe'), $renewal)->assertOk(); $this->postJson(route('webhooks.stripe'), $renewal)->assertOk(); // The holding area waits for contracts that do not exist yet. Ordinary // traffic must not silt it up with rows nothing will ever come back for. expect(StripePendingEvent::query()->count())->toBe(0) ->and(SubscriptionRecord::query()->where('event', 'renewal')->count())->toBe(1); }); 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(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(PlanCatalogue::class); $current = $catalogue->currentVersion('team'); $catalogue->schedule($current, $current->available_from, now()); $dearer = 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); });