524 lines
25 KiB
PHP
524 lines
25 KiB
PHP
<?php
|
|
|
|
use App\Models\PlanFamily;
|
|
use App\Models\PlanPrice;
|
|
use App\Models\PlanVersion;
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use Illuminate\Database\MultipleRecordsFoundException;
|
|
|
|
/**
|
|
* The catalogue moved out of config and into three tables. This is the shadow
|
|
* comparison the move was supposed to survive: the values the seeded catalogue
|
|
* serves are pinned literally, so a migration that quietly changes what we sell
|
|
* fails here rather than in a customer's invoice.
|
|
*/
|
|
|
|
/** What config/provisioning.php sold, before the catalogue became a table. */
|
|
const CATALOGUE_BEFORE_THE_MOVE = [
|
|
'start' => ['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('publishes a version once, even if two attempts arrive together', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
$currency = Subscription::catalogueCurrency();
|
|
|
|
$live = $catalogue->currentVersion('team');
|
|
$catalogue->schedule($live, $live->available_from, now());
|
|
|
|
$draft = $catalogue->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' => 18900, 'yearly' => 226800]);
|
|
|
|
$catalogue->publish($draft, now(), now()->addYear());
|
|
|
|
// A second attempt on the same version must not sail past and overwrite the
|
|
// window the first one just set.
|
|
$stale = PlanVersion::query()->findOrFail($draft->id);
|
|
$stale->published_at = null; // as a racing request would still see it
|
|
|
|
expect(fn () => $catalogue->publish($stale, now(), null))
|
|
->toThrow(RuntimeException::class, 'already published');
|
|
|
|
expect($draft->fresh()->available_until)->not->toBeNull();
|
|
});
|
|
|
|
it('numbers drafts in sequence without colliding', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
$capabilities = [
|
|
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
|
|
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced', 'template_vmid' => 9000,
|
|
'features' => [],
|
|
];
|
|
|
|
$first = $catalogue->draft($family, $capabilities, ['monthly' => 18900, 'yearly' => 226800]);
|
|
$second = $catalogue->draft($family, $capabilities, ['monthly' => 19900, 'yearly' => 238800]);
|
|
|
|
expect($first->version)->toBe(2)
|
|
->and($second->version)->toBe(3)
|
|
->and($second->priceFor('monthly')->amount_cents)->toBe(19900);
|
|
});
|
|
|
|
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 sell a version that provisioning could not build', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
$draft = $catalogue->draft($family, [
|
|
'quota_gb' => 600, 'traffic_gb' => 3000, 'seats' => 30, 'ram_mb' => 8192,
|
|
'cores' => 4, 'disk_gb' => 640, 'performance' => 'enhanced',
|
|
'template_vmid' => null, // no blueprint
|
|
'features' => [],
|
|
], ['monthly' => 18900, 'yearly' => 226800]);
|
|
|
|
// Otherwise it goes on sale, a customer pays, and the pipeline stops at
|
|
// CloneVirtualMachine with template_missing.
|
|
expect(fn () => $catalogue->publish($draft, now()))
|
|
->toThrow(RuntimeException::class, 'VM template');
|
|
});
|
|
|
|
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 the version unpublished and its predecessor untouched when the window is still refused', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
$currency = Subscription::catalogueCurrency();
|
|
|
|
// v1 runs until next month, when the already-published v2 takes over.
|
|
$v1 = $catalogue->currentVersion('team');
|
|
$catalogue->schedule($v1, $v1->available_from, now()->addMonth());
|
|
$closesAt = $v1->fresh()->available_until->toDateTimeString();
|
|
|
|
$v2 = PlanVersion::query()->create([
|
|
...$v1->only(['plan_family_id', 'quota_gb', 'traffic_gb', 'seats', 'ram_mb', 'cores', 'disk_gb', 'performance', 'template_vmid']),
|
|
'version' => 2, 'features' => $v1->features, 'available_from' => now()->addMonth(),
|
|
]);
|
|
$v2->prices()->create(['term' => 'monthly', 'amount_cents' => 18900, 'currency' => $currency]);
|
|
$v2->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]);
|
|
$catalogue->publish($v2, now()->addMonth());
|
|
|
|
$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' => 19900, 'currency' => $currency]);
|
|
$draft->prices()->create(['term' => 'yearly', 'amount_cents' => 238800, 'currency' => $currency]);
|
|
|
|
// Open-ended from now: v1 would be handed over, but v2 is still in the way,
|
|
// and a handover is not licence to run over a scheduled successor.
|
|
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. And v1 keeps the
|
|
// window it had: a predecessor closed for a successor that never arrived
|
|
// would be a plan taken off sale by an action that failed.
|
|
expect($draft->fresh()->isPublished())->toBeFalse()
|
|
->and($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString())
|
|
->and($v1->fresh()->available_until->toDateTimeString())->toBe($closesAt);
|
|
|
|
// A window that fits between the two is accepted, and takes v1 over then.
|
|
$catalogue->publish($draft, now()->addWeek(), now()->addMonth());
|
|
|
|
expect($catalogue->currentVersion('team', now()->addWeek())->version)->toBe(4)
|
|
->and($v1->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue();
|
|
});
|
|
|
|
it('hands the running version over to its replacement instead of refusing to publish it', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
$running = $catalogue->currentVersion('team');
|
|
$at = now()->addWeek();
|
|
|
|
$successor = $catalogue->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' => 18900, 'yearly' => 226800]);
|
|
|
|
// No closing the old one first: that used to be mandatory, and a
|
|
// replacement that then failed to publish left the plan off sale for good.
|
|
$catalogue->publish($successor, $at);
|
|
|
|
// The predecessor stops exactly where the successor starts.
|
|
expect($running->fresh()->available_until->eq($successor->fresh()->available_from))->toBeTrue();
|
|
|
|
// currentVersion() uses sole(), so each of these also proves that exactly
|
|
// one version is on sale at that moment — and there is no gap at the seam.
|
|
expect($catalogue->currentVersion('team', $at->copy()->subSecond())->version)->toBe($running->version)
|
|
->and($catalogue->currentVersion('team', $at)->version)->toBe($successor->version)
|
|
->and($catalogue->isSellable('team', $at->copy()->subSecond()))->toBeTrue()
|
|
->and($catalogue->isSellable('team', $at))->toBeTrue()
|
|
->and($catalogue->sellable($at)['team']['price_cents'])->toBe(18900);
|
|
});
|
|
|
|
it('leaves a version that had already ended out of the handover', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
// v1 stops in a minute, and nothing replaces it for a week.
|
|
$ended = $catalogue->currentVersion('team');
|
|
$catalogue->schedule($ended, $ended->available_from, now()->addMinute());
|
|
$endedAt = $ended->fresh()->available_until->toDateTimeString();
|
|
|
|
$successor = $catalogue->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' => 18900, 'yearly' => 226800]);
|
|
|
|
$catalogue->publish($successor, now()->addWeek());
|
|
|
|
// The handover closes what is running, not what is over. Moving this one's
|
|
// end date forward would rewrite when it actually stopped selling.
|
|
expect($ended->fresh()->available_until->toDateTimeString())->toBe($endedAt);
|
|
});
|
|
|
|
it('puts a closed version back on sale', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$version = $catalogue->currentVersion('team');
|
|
|
|
$catalogue->schedule($version, $version->available_from, now());
|
|
expect($catalogue->isSellable('team'))->toBeFalse();
|
|
|
|
$catalogue->reopen($version);
|
|
|
|
// Open-ended again, not merely extended: the plan sells now and keeps
|
|
// selling until someone decides otherwise.
|
|
expect($catalogue->isSellable('team'))->toBeTrue()
|
|
->and($catalogue->isSellable('team', now()->addYear()))->toBeTrue()
|
|
->and($catalogue->sellable()['team']['price_cents'])->toBe(17900)
|
|
->and($catalogue->currentVersion('team')->version)->toBe($version->version);
|
|
});
|
|
|
|
it('refuses to reopen a version whose successor is already selling', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
$first = $catalogue->currentVersion('team');
|
|
|
|
$second = $catalogue->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' => 18900, 'yearly' => 226800]);
|
|
$catalogue->publish($second, now());
|
|
|
|
// Reopening would put both on sale, and every read of the family would
|
|
// throw rather than pick one.
|
|
expect(fn () => $catalogue->reopen($first))->toThrow(RuntimeException::class, 'overlaps');
|
|
|
|
expect($catalogue->currentVersion('team')->version)->toBe($second->version);
|
|
});
|
|
|
|
it('refuses to reopen a version that was never published', function () {
|
|
$catalogue = app(PlanCatalogue::class);
|
|
$family = PlanFamily::query()->where('key', 'team')->sole();
|
|
|
|
$draft = $catalogue->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' => 18900, 'yearly' => 226800]);
|
|
|
|
// Nothing was ever on sale, so there is nothing to put back.
|
|
expect(fn () => $catalogue->reopen($draft))->toThrow(RuntimeException::class, 'never published');
|
|
|
|
expect($draft->fresh()->isPublished())->toBeFalse();
|
|
});
|
|
|
|
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();
|
|
});
|