diff --git a/app/Livewire/Admin/PlanVersions.php b/app/Livewire/Admin/PlanVersions.php index bdd2e40..f86d0f9 100644 --- a/app/Livewire/Admin/PlanVersions.php +++ b/app/Livewire/Admin/PlanVersions.php @@ -2,13 +2,12 @@ namespace App\Livewire\Admin; -use App\Support\LocalTime; use App\Models\PlanFamily; use App\Models\PlanVersion; use App\Models\Subscription; use App\Services\Billing\PlanCatalogue; +use App\Support\LocalTime; use App\Support\Money; -use Illuminate\Support\Carbon; use Illuminate\Validation\Rule; use Livewire\Attributes\Layout; use Livewire\Attributes\On; @@ -239,12 +238,72 @@ class PlanVersions extends Component $this->dispatch('notify', message: __('plans.closed', ['version' => $version->version])); } + /** + * Put a closed version back on sale — the way out of a closed window. + * + * The likely refusal here is that a newer version has taken over, and the + * catalogue says so in English, at the length a log entry deserves. Which + * of its two refusals it was is read off the version itself rather than out + * of the message text: a message is prose, and prose changes. + */ + public function reopen(string $versionUuid): void + { + $this->authorize('plans.manage'); + + $version = PlanVersion::query()->where('uuid', $versionUuid)->firstOrFail(); + abort_unless($version->plan_family_id === $this->family()->id, 404); + + try { + app(PlanCatalogue::class)->reopen($version); + } catch (RuntimeException) { + $this->addError('availableFrom', __( + $version->isPublished() ? 'plans.reopen_blocked' : 'plans.reopen_draft' + )); + + return; + } + + $this->dispatch('notify', message: __('plans.reopened', ['version' => $version->version])); + } + #[On('plan-draft-deleted')] public function refresh(): void { // Livewire re-renders; the listener exists so the modal can say so. } + /** + * The version that publishing at the chosen moment would take off sale. + * + * Announced on the form, because publishing now ends a running version's + * sale instead of being refused, and the owner should not have to work that + * out from the version list afterwards. Asked of the catalogue rather than + * worked out here, so the announcement and the act cannot name different + * versions. + * + * @return array{version: int, at: string}|null + */ + private function handover(PlanFamily $family): ?array + { + if ($this->publishing === null) { + return null; + } + + $version = $family->versions()->where('uuid', $this->publishing)->first(); + $from = LocalTime::fromField($this->availableFrom); + + if ($version === null || $from === null) { + return null; + } + + $predecessor = app(PlanCatalogue::class)->predecessorAt($version, $from); + + return $predecessor === null ? null : [ + 'version' => $predecessor->version, + 'at' => $from->local()->isoFormat('L LT'), + ]; + } + public function render() { $family = $this->family(); @@ -254,6 +313,7 @@ class PlanVersions extends Component 'family' => $family, 'versions' => $family->versions()->with('prices')->orderByDesc('version')->get(), 'now' => $now, + 'handover' => $this->handover($family), 'featureKeys' => array_keys((array) __('billing.feature')), 'performanceClasses' => (array) __('billing.perf'), 'currency' => Subscription::catalogueCurrency(), diff --git a/app/Services/Billing/PlanCatalogue.php b/app/Services/Billing/PlanCatalogue.php index 2ed730c..4392ace 100644 --- a/app/Services/Billing/PlanCatalogue.php +++ b/app/Services/Billing/PlanCatalogue.php @@ -3,6 +3,7 @@ namespace App\Services\Billing; use App\Models\PlanFamily; +use App\Models\PlanPrice; use App\Models\PlanVersion; use App\Models\Subscription; use Illuminate\Database\Eloquent\ModelNotFoundException; @@ -144,7 +145,7 @@ final class PlanCatalogue * shop, the checkout and the consistency command can never disagree about * which plans are real. * - * @return array|null + * @return array|null */ private function requiredPrices(PlanVersion $version, string $currency): ?array { @@ -328,6 +329,66 @@ final class PlanCatalogue }); } + /** + * Put a published version back on open-ended sale. + * + * The inverse of closing a window, and the reason it has to exist: closing + * was one-way. An owner who took a version off sale before its replacement + * was ready had no way back, and the plan stayed dark until someone edited + * the table by hand. + * + * Expressed through schedule() rather than as its own update, so it passes + * the same family lock and the same overlap check as every other window + * change. That check is the point, not an obstacle: a version whose + * successor is already selling must not come back and leave two on sale at + * once. + */ + public function reopen(PlanVersion $version): PlanVersion + { + // Re-read first: a stale object could carry a start date the owner has + // since moved, and reopening would then quietly restore the old one. + $version->refresh(); + + if (! $version->isPublished()) { + throw new RuntimeException( + 'That version was never published, so it was never on sale and there is nothing to reopen. '. + 'Publish it instead.' + ); + } + + return $this->schedule($version, $version->available_from, null); + } + + /** + * The other published version of this family that is on sale at `$at`. + * + * Public because the console announces a handover before the owner commits + * to it, and an announcement computed from its own idea of "currently + * running" would sooner or later name a different version than the one + * publish() actually closes. + * + * Null when two are running at once: that is the data fault schedule() + * exists to prevent, and picking one of them to close would decide by row + * order which of the owner's plans quietly stops selling. + */ + public function predecessorAt(PlanVersion $version, Carbon $at): ?PlanVersion + { + $running = PlanVersion::query() + ->where('plan_family_id', $version->plan_family_id) + ->whereKeyNot($version->getKey()) + ->whereNotNull('published_at') + // Running at `$at`, not merely clashing with it. A version that + // only starts later clashes too, but closing it at `$at` would end + // it before it began — that one still belongs to the overlap check. + ->where('available_from', '<=', $at) + ->where(fn ($q) => $q + ->whereNull('available_until') + ->orWhere('available_until', '>', $at)) + ->get(); + + return $running->count() === 1 ? $running->first() : null; + } + /** * Mark exactly one plan family as recommended, clearing every other one. * @@ -387,11 +448,21 @@ final class PlanCatalogue } } - // Publication and scheduling together, or not at all. Publishing first - // and then failing the overlap check would leave the version frozen but - // unscheduled — and publish() refuses it from then on, so nothing short - // of a manual repair could rescue it. + // Publication, the handover and the new window together, or not at all. + // Publishing first and then failing the overlap check would leave the + // version frozen but unscheduled — and publish() refuses it from then + // on, so nothing short of a manual repair could rescue it. The same + // goes the other way: a predecessor closed for a successor that never + // arrived is a plan taken off sale by an action that failed. return DB::transaction(function () use ($version, $from, $until) { + $from ??= now(); + + // The lock schedule() would take anyway, taken here instead: it has + // to be held from before the predecessor is chosen until after the + // successor's window is written, or a publish running alongside + // this one could hand the same version over twice. + PlanFamily::query()->whereKey($version->plan_family_id)->lockForUpdate()->firstOrFail(); + // Claim the publication conditionally, so of two simultaneous // attempts exactly one proceeds. Unconditional, the second would // sail past and overwrite the window the first had just set. @@ -404,7 +475,27 @@ final class PlanCatalogue throw new RuntimeException('That version is already published.'); } - return $this->schedule($version->refresh(), $from ?? now(), $until); + $predecessor = $this->predecessorAt($version, $from); + + if ($predecessor !== null) { + // A handover, not an overlap: the version that was running + // stops at the moment its successor starts, which is neither a + // gap nor two versions on sale. Refusing here — as this used to + // — forced the owner to close the running version first, and a + // replacement that then failed to publish left the plan off + // sale with no way back. That dead end is what this removes. + // + // Written by query for the same reason schedule() is: the + // version being handed over is published and immutable, and + // save() would offer every attribute on the object to that + // guard. + PlanVersion::query()->whereKey($predecessor->getKey())->update([ + 'available_until' => $from, + 'updated_at' => now(), + ]); + } + + return $this->schedule($version->refresh(), $from, $until); }); } } diff --git a/lang/de/plans.php b/lang/de/plans.php index 2b62743..91d2848 100644 --- a/lang/de/plans.php +++ b/lang/de/plans.php @@ -67,6 +67,11 @@ return [ 'available_until_hint' => 'Leer lassen für unbefristet.', 'close_now' => 'Verkauf beenden', 'closed' => 'Version :version wird nicht mehr verkauft.', + 'reopen' => 'Wieder verkaufen', + 'reopened' => 'Version :version ist wieder im Verkauf.', + 'reopen_blocked' => 'Diese Version kann nicht zurück in den Verkauf: Für dieses Paket läuft bereits eine neuere Version. Beenden oder verschieben Sie zuerst deren Verkaufszeitraum. Zwei Versionen gleichzeitig im Verkauf würden den Preis, den ein Kunde zahlt, von der Zeilenreihenfolge abhängig machen.', + 'reopen_draft' => 'Diese Version wurde nie veröffentlicht und war damit nie im Verkauf. Veröffentlichen Sie sie stattdessen.', + 'handover_note' => 'Version :version ist derzeit im Verkauf und endet mit dieser Veröffentlichung am :at. Keine Lücke: ab genau diesem Zeitpunkt verkauft die neue Version.', 'cancel' => 'Abbrechen', 'discard' => 'Verwerfen', diff --git a/lang/en/plans.php b/lang/en/plans.php index 15b6840..36fce03 100644 --- a/lang/en/plans.php +++ b/lang/en/plans.php @@ -67,6 +67,11 @@ return [ 'available_until_hint' => 'Leave empty for open-ended.', 'close_now' => 'Stop selling', 'closed' => 'Version :version is no longer sold.', + 'reopen' => 'Sell again', + 'reopened' => 'Version :version is on sale again.', + 'reopen_blocked' => 'This version cannot go back on sale: a newer version of this plan is already running. Stop or reschedule that one first. Two versions on sale at once would leave the price a customer pays decided by row order.', + 'reopen_draft' => 'This version was never published, so it was never on sale. Publish it instead.', + 'handover_note' => 'Version :version is on sale now and ends on :at when this one is published. No gap: from exactly that moment the new version sells.', 'cancel' => 'Cancel', 'discard' => 'Discard', diff --git a/resources/views/livewire/admin/plan-versions.blade.php b/resources/views/livewire/admin/plan-versions.blade.php index a56c192..5676b30 100644 --- a/resources/views/livewire/admin/plan-versions.blade.php +++ b/resources/views/livewire/admin/plan-versions.blade.php @@ -22,6 +22,16 @@
{{-- Versions --}}
+ @error('availableFrom') + @if ($publishing === null) + {{-- A refused row action has no form of its own to report + into: with the publish form closed, the message would + otherwise land in an error bag nothing renders, and the + button would look like it had simply done nothing. --}} +
{{ $message }}
+ @endif + @enderror + @forelse ($versions as $version) @php // The window being open is not the same as the plan being @@ -67,10 +77,21 @@ x-on:click="Livewire.dispatch('openModal', { component: 'admin.confirm-delete-plan-draft', arguments: { uuid: '{{ $version->uuid }}' } })"> {{ __('plans.discard') }} - @elseif ($windowOpen) - - {{ __('plans.close_now') }} - + @else + @if ($windowOpen) + + {{ __('plans.close_now') }} + + @endif + {{-- Every published version with an end date can + go back on open-ended sale, whether that end + is already past or still ahead. Closing used + to be one-way. --}} + @if ($version->available_until !== null) + + {{ __('plans.reopen') }} + + @endif @endif
@@ -79,6 +100,14 @@ @if ($publishing === $version->uuid)

{{ __('plans.publish_warning') }}

+ @if ($handover !== null) + {{-- Publishing here ends another version's sale, + so say which one and when, before the button + is pressed rather than in the list after. --}} +

+ {{ __('plans.handover_note', ['version' => $handover['version'], 'at' => $handover['at']]) }} +

+ @endif
diff --git a/tests/Feature/Admin/PlanAdminTest.php b/tests/Feature/Admin/PlanAdminTest.php index 0c84e6f..1fa26ff 100644 --- a/tests/Feature/Admin/PlanAdminTest.php +++ b/tests/Feature/Admin/PlanAdminTest.php @@ -1,16 +1,17 @@ assertHasErrors('diskGb'); }); -it('publishes a draft into a window, and refuses one that overlaps', function () { +it('publishes a draft straight over the running version, announcing the handover first', function () { $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); $page->set('monthlyPrice', '199,00')->call('draft'); $draft = teamFamily()->versions()->where('version', 2)->sole(); + $live = app(PlanCatalogue::class)->currentVersion('team'); + $at = now()->addHour(); + + // Publishing ends another version's sale, so the form has to say which one + // and when — before the button is pressed, not in the list afterwards. + $page->call('choose', $draft->uuid) + ->set('availableFrom', LocalTime::toField($at)) + ->assertSee(__('plans.handover_note', [ + 'version' => $live->version, + 'at' => $at->copy()->local()->isoFormat('L LT'), + ])); + + // No closing the running version first: that was the trap. A replacement + // that then failed to publish left the plan off sale with no way back. + $page->call('publish')->assertHasNoErrors(); + + expect($draft->fresh()->isPublished())->toBeTrue() + ->and($live->fresh()->available_until->eq($draft->fresh()->available_from))->toBeTrue() + ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(1) + ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHours(2))->version)->toBe(2); +}); + +it('puts a closed version back on sale from the version list', function () { + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $live = app(PlanCatalogue::class)->currentVersion('team'); + + $page->call('close', $live->uuid); + + expect(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse(); + + // Closing was one-way until now, so the way back has to be on the page. + $page->assertSee(__('plans.reopen')) + ->call('reopen', $live->uuid) + ->assertHasNoErrors(); + + expect(app(PlanCatalogue::class)->isSellable('team'))->toBeTrue() + ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe($live->version) + ->and($live->fresh()->available_until)->toBeNull(); +}); + +it('refuses to reopen a version whose successor is selling, in the console\'s own words', function () { + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->set('monthlyPrice', '199,00')->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + $live = app(PlanCatalogue::class)->currentVersion('team'); - // Straight into the running version's window: refused, and said so on the - // form rather than thrown. $page->call('choose', $draft->uuid) ->set('availableFrom', LocalTime::toField(now())) ->call('publish') - ->assertHasErrors('availableFrom'); - - expect($draft->fresh()->isPublished())->toBeFalse(); - - // Close the running one first, then the successor lands cleanly. - $live = app(PlanCatalogue::class)->currentVersion('team'); - $page->call('close', $live->uuid); - - $page->call('choose', $draft->uuid) - ->set('availableFrom', LocalTime::toField(now()->addMinute())) - ->call('publish') ->assertHasNoErrors(); - expect($draft->fresh()->isPublished())->toBeTrue() - ->and(app(PlanCatalogue::class)->currentVersion('team', now()->addHour())->version)->toBe(2); + $page->call('reopen', $live->uuid) + ->assertHasErrors('availableFrom') + ->assertSee(__('plans.reopen_blocked')) + // The catalogue explains itself in English, at the length a log entry + // deserves. That sentence must not reach a German console. + ->assertDontSee('overlaps'); + + expect($live->fresh()->available_until)->not->toBeNull() + ->and(app(PlanCatalogue::class)->currentVersion('team')->version)->toBe(2); +}); + +it('will not reopen a draft, and says why instead of doing nothing', function () { + $page = Livewire::actingAs(owner(), 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + $page->call('draft'); + + $draft = teamFamily()->versions()->where('version', 2)->sole(); + + // The list offers no such button for a draft, but a request can carry + // anything, and "was never on sale" is a different refusal from "something + // newer is selling". + $page->call('reopen', $draft->uuid) + ->assertHasErrors('availableFrom') + ->assertSee(__('plans.reopen_draft')); + + expect($draft->fresh()->isPublished())->toBeFalse() + ->and($draft->fresh()->available_until)->toBeNull(); +}); + +it('re-checks the capability on reopen rather than trusting the mount', function () { + $user = owner(); + $live = app(PlanCatalogue::class)->currentVersion('team'); + app(PlanCatalogue::class)->schedule($live, $live->available_from, now()); + + $page = Livewire::actingAs($user, 'operator')->test(PlanVersions::class, ['uuid' => teamFamily()->uuid]); + + // The page is open and legitimate, and then the account is demoted. + $user->syncRoles(['Support']); + app(PermissionRegistrar::class)->forgetCachedPermissions(); + + $page->call('reopen', $live->uuid)->assertForbidden(); + + expect($live->fresh()->available_until)->not->toBeNull() + ->and(app(PlanCatalogue::class)->isSellable('team'))->toBeFalse(); }); it('will not publish a window that ends before it starts', function () { diff --git a/tests/Feature/Billing/PlanCatalogueTest.php b/tests/Feature/Billing/PlanCatalogueTest.php index 7f08b59..73f763e 100644 --- a/tests/Feature/Billing/PlanCatalogueTest.php +++ b/tests/Feature/Billing/PlanCatalogueTest.php @@ -363,33 +363,154 @@ it('will not publish a version that is not priced for every term', function () { expect(fn () => app(PlanCatalogue::class)->publish($draft))->toThrow(RuntimeException::class, 'monthly'); }); -it('leaves a version unpublished when its window is refused', function () { +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' => 18900, 'currency' => $currency]); - $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 226800, 'currency' => $currency]); + $draft->prices()->create(['term' => 'monthly', 'amount_cents' => 19900, 'currency' => $currency]); + $draft->prices()->create(['term' => 'yearly', 'amount_cents' => 238800, 'currency' => $currency]); - // Straight into the running version's window. + // 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 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($draft->fresh()->available_from->toDateString())->toBe(now()->toDateString()) + ->and($v1->fresh()->available_until->toDateTimeString())->toBe($closesAt); - $current = $catalogue->currentVersion('team'); - $catalogue->schedule($current, $current->available_from, now()->addWeek()); - $catalogue->publish($draft, now()->addWeek()); + // 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); + 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 () {