['tier' => 1, 'traffic_gb' => 1000, 'quota_gb' => 100, 'disk_gb' => 120, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 5, 'performance' => 'standard', 'price_cents' => 4900, 'template_vmid' => 9000], 'team' => ['tier' => 2, 'traffic_gb' => 3000, 'quota_gb' => 500, 'disk_gb' => 540, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'performance' => 'enhanced', 'price_cents' => 17900, 'template_vmid' => 9000], 'business' => ['tier' => 3, 'traffic_gb' => 8000, 'quota_gb' => 1000, 'disk_gb' => 1050, 'ram_mb' => 16384, 'cores' => 8, 'seats' => 100, 'performance' => 'high', 'price_cents' => 39900, 'template_vmid' => 9000], 'enterprise' => ['tier' => 4, 'traffic_gb' => 20000, 'quota_gb' => 2000, 'disk_gb' => 2100, 'ram_mb' => 32768, 'cores' => 16, 'seats' => 500, 'performance' => 'dedicated', 'price_cents' => 79900, 'template_vmid' => 9000], ]; it('sells exactly what the config catalogue sold', function () { $sellable = app(PlanCatalogue::class)->sellable(); expect(array_keys($sellable))->toBe(array_keys(CATALOGUE_BEFORE_THE_MOVE)); foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) { foreach ($expected as $field => $value) { expect($sellable[$key][$field])->toBe($value, "{$key}.{$field}"); } } }); it('freezes the same snapshot the config catalogue would have frozen', function () { foreach (CATALOGUE_BEFORE_THE_MOVE as $key => $expected) { $snapshot = Subscription::snapshotFrom($key); expect($snapshot['price_cents'])->toBe($expected['price_cents']) ->and($snapshot['ram_mb'])->toBe($expected['ram_mb']) ->and($snapshot['cores'])->toBe($expected['cores']) ->and($snapshot['disk_gb'])->toBe($expected['disk_gb']) ->and($snapshot['quota_gb'])->toBe($expected['quota_gb']) ->and($snapshot['traffic_gb'])->toBe($expected['traffic_gb']) ->and($snapshot['seats'])->toBe($expected['seats']) ->and($snapshot['tier'])->toBe($expected['tier']) ->and($snapshot['template_vmid'])->toBe($expected['template_vmid']) ->and($snapshot['plan_version_id'])->not->toBeNull(); // Yearly is twelve months of the same price until the owner says // otherwise — and it comes from its own row, not from arithmetic. expect(Subscription::snapshotFrom($key, 'yearly')['price_cents']) ->toBe($expected['price_cents'] * 12); } }); it('hides a plan the shop cannot price for every term it sells', function () { $catalogue = app(PlanCatalogue::class); // Forced past the guard, the way a bad repair script would. PlanPrice::query()->whereKey($catalogue->currentVersion('team')->priceFor('yearly')->id)->delete(); // The shop and the checkout have to agree, or a customer picks a plan, // pays, and lands on a contract that cannot be opened. expect($catalogue->isSellable('team'))->toBeFalse() ->and(array_keys($catalogue->sellable()))->not->toContain('team') ->and(array_keys($catalogue->sellable()))->toContain('start'); }); it('records which version a contract was sold under', function () { $snapshot = Subscription::snapshotFrom('team'); $version = app(PlanCatalogue::class)->version($snapshot['plan_version_id']); expect($version->family->key)->toBe('team') ->and($version->version)->toBe(1); }); it('contracts a checkout to the version it was shown, not the one that replaced it', function () { $catalogue = app(PlanCatalogue::class); $shown = $catalogue->currentVersion('team'); $currency = Subscription::catalogueCurrency(); // The customer is looking at v1 when the owner's scheduled swap lands. $catalogue->schedule($shown, $shown->available_from, now()); $successor = PlanVersion::query()->create([ ...$shown->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 2, 'features' => $shown->features, 'available_from' => now(), ]); $successor->prices()->create(['term' => 'monthly', 'amount_cents' => 24900, 'currency' => $currency]); $successor->prices()->create(['term' => 'yearly', 'amount_cents' => 298800, 'currency' => $currency]); $catalogue->publish($successor, now()); // Their payment arrives after the swap. They pay what they were quoted. $snapshot = Subscription::snapshotFrom('team', 'monthly', $shown->id); expect($snapshot['price_cents'])->toBe(17900) ->and($snapshot['plan_version_id'])->toBe($shown->id); // Someone arriving now gets the successor. expect(Subscription::snapshotFrom('team')['price_cents'])->toBe(24900); }); it('fixes the price of a published version, so a checkout cannot be repriced mid-flight', function () { $price = app(PlanCatalogue::class)->currentVersion('team')->priceFor('monthly'); expect(fn () => $price->update(['amount_cents' => 24900])) ->toThrow(RuntimeException::class, 'fixed'); expect($price->fresh()->amount_cents)->toBe(17900); // Stripe's id is filled in afterwards, so that stays writable. Re-read // first: the refused edit is still sitting on the rejected object, and the // guard would rightly stop it riding along on the next save. $price->refresh()->update(['stripe_price_id' => 'price_123']); expect($price->fresh()->stripe_price_id)->toBe('price_123') ->and($price->fresh()->amount_cents)->toBe(17900); }); it('refuses to delete a published version or its price, but lets a draft go', function () { $published = app(PlanCatalogue::class)->currentVersion('team'); expect(fn () => $published->delete())->toThrow(RuntimeException::class, 'cannot be deleted') ->and(fn () => $published->priceFor('monthly')->delete())->toThrow(RuntimeException::class, 'cannot be deleted'); // Contracts point at the version to record what was sold; deleting it would // null that away and leave a customer contracted to nothing. $subscription = Subscription::factory()->plan('team')->create(); expect($subscription->plan_version_id)->toBe($published->id); $draft = PlanVersion::query()->create([ ...$published->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 9, 'features' => [], 'available_from' => now()->addYear(), ]); // Nothing was ever promised on a draft, so it can simply go. expect($draft->delete())->toBeTrue(); }); it('will not let a plan family be renamed or deleted out from under its customers', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); // Orders, instances and every snapshot store this string. expect(fn () => $family->update(['key' => 'teams']))->toThrow(RuntimeException::class, 'permanent'); // Deleting would cascade past the version guard and null the provenance // off every contract on it. expect(fn () => $family->fresh()->delete())->toThrow(RuntimeException::class, 'cannot be deleted'); expect(PlanFamily::query()->where('key', 'team')->exists())->toBeTrue(); // The display name is what is meant to change. $family->fresh()->update(['name' => 'Team Pro']); expect(PlanFamily::query()->where('key', 'team')->sole()->name)->toBe('Team Pro'); }); it('lets a family that never sold anything be removed with its drafts', function () { $family = PlanFamily::query()->create(['key' => 'trial', 'name' => 'Trial', 'tier' => 0]); $draft = $family->versions()->create([ 'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024, 'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [], 'available_from' => now(), ]); expect($family->delete())->toBeTrue() ->and(PlanVersion::query()->whereKey($draft->id)->exists())->toBeFalse(); }); it('still honours a quoted version after the plan is withdrawn', function () { $catalogue = app(PlanCatalogue::class); $quoted = $catalogue->currentVersion('team'); // The owner pulls Team while a customer is at the checkout. PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); expect($catalogue->isSellable('team'))->toBeFalse() // They saw it and paid for it, so it is still deliverable to them. ->and($catalogue->isDeliverable('team', $quoted->id))->toBeTrue() ->and(Subscription::snapshotFrom('team', 'monthly', $quoted->id)['price_cents'])->toBe(17900); }); it('will not contract a customer to a version of a different plan', function () { $startVersion = app(PlanCatalogue::class)->currentVersion('start'); expect(fn () => Subscription::snapshotFrom('team', 'monthly', $startVersion->id)) ->toThrow(RuntimeException::class, 'belongs to'); }); it('refuses to sell a plan the owner has switched off', function () { PlanFamily::query()->where('key', 'team')->update(['sales_enabled' => false]); expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse() ->and(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('team'); // Fails closed rather than falling back to anything. expect(fn () => Subscription::snapshotFrom('team'))->toThrow(RuntimeException::class); }); it('does not sell a version before its window opens or after it closes', function () { $catalogue = app(PlanCatalogue::class); $version = $catalogue->currentVersion('team'); $catalogue->schedule($version, now()->addWeek(), now()->addMonth()); expect($catalogue->isSellable('team'))->toBeFalse() ->and($catalogue->isSellable('team', now()->addWeek()))->toBeTrue() // Half-open: the closing moment is the first at which it is gone. ->and($catalogue->isSellable('team', now()->addMonth()))->toBeFalse() ->and($catalogue->isSellable('team', now()->addMonth()->subSecond()))->toBeTrue(); }); it('crashes instead of choosing between two versions on sale at once', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $first = $family->versions()->sole(); // Forced past the guard, the way a bad migration or a direct edit would. PlanVersion::query()->create([ ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 2, 'features' => $first->features, 'available_from' => now()->subDay(), 'available_until' => null, 'published_at' => now()->subDay(), ]); expect(fn () => app(PlanCatalogue::class)->currentVersion('team')) ->toThrow(MultipleRecordsFoundException::class); }); it('refuses a window that overlaps another version of the same plan', function () { $catalogue = app(PlanCatalogue::class); $family = PlanFamily::query()->where('key', 'team')->sole(); $first = $catalogue->currentVersion('team'); // Close the running version, then publish a successor after it. $catalogue->schedule($first, $first->available_from, now()->addMonth()); $second = PlanVersion::query()->create([ ...$first->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']), 'version' => 2, 'features' => $first->features, 'available_from' => now()->addMonth(), ]); $second->prices()->create([ 'term' => 'monthly', 'amount_cents' => 19900, 'currency' => Subscription::catalogueCurrency(), ]); $second->prices()->create([ 'term' => 'yearly', 'amount_cents' => 238800, 'currency' => Subscription::catalogueCurrency(), ]); $catalogue->publish($second, now()->addMonth()); // Backdating the successor into its predecessor's window is refused. expect(fn () => $catalogue->schedule($second, now()->addWeek())) ->toThrow(RuntimeException::class); // Starting exactly where the predecessor stops is not an overlap. $catalogue->schedule($second, now()->addMonth()); expect($catalogue->currentVersion('team', now()->addMonth())->version)->toBe(2); }); it('locks a version once it is published, but still lets its window move', function () { $version = app(PlanCatalogue::class)->currentVersion('team'); expect(fn () => $version->update(['ram_mb' => 4096]))->toThrow(RuntimeException::class); // The refused edit is not smuggled through by the next write either. app(PlanCatalogue::class)->schedule($version, $version->available_from, now()->addYear()); expect($version->fresh()->available_until)->not->toBeNull() ->and($version->fresh()->ram_mb)->toBe(8192); }); it('lets an unpublished draft be edited freely', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $draft = PlanVersion::query()->create([ 'plan_family_id' => $family->id, 'version' => 2, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => ['monitoring'], 'available_from' => now()->addMonth(), ]); $draft->update(['ram_mb' => 12288]); expect($draft->fresh()->ram_mb)->toBe(12288); }); it('will not publish a version that is not priced for every term', function () { $family = PlanFamily::query()->where('key', 'team')->sole(); $draft = PlanVersion::query()->create([ 'plan_family_id' => $family->id, 'version' => 3, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => [], 'available_from' => now(), ]); expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class); // Yearly alone is not enough: someone would pick monthly and hit a // checkout that cannot price them. $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 214800, 'currency' => Subscription::catalogueCurrency()]); expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly'); }); it('leaves a version unpublished when its window is refused', function () { $catalogue = app(PlanCatalogue::class); $family = PlanFamily::query()->where('key', 'team')->sole(); $currency = Subscription::catalogueCurrency(); $draft = PlanVersion::query()->create([ 'plan_family_id' => $family->id, 'version' => 4, 'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192, 'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000, 'features' => [], 'available_from' => now(), ]); $draft->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]); $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]); // Straight into the running version's window. expect(fn () => $catalogue->publish($draft, now()))->toThrow(RuntimeException::class, 'overlaps'); // Still a draft, so the mistake is simply correctable — publishing first // and failing after would have frozen it beyond repair. expect($draft->fresh()->isPublished())->toBeFalse() ->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString()); $current = $catalogue->currentVersion('team'); $catalogue->schedule($current, $current->available_from, now()->addWeek()); $catalogue->publish($draft, now()->addWeek()); expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4); }); it('reports a consistent catalogue, and an inconsistent one', function () { $this->artisan('plans:check')->assertSuccessful(); PlanVersion::query()->whereKey(app(PlanCatalogue::class)->currentVersion('team')->id) ->update(['available_until' => now()->subDay()]); $this->artisan('plans:check')->assertFailed(); });