plan('team')->create(); $bookedPrice = $subscription->price_cents; // The owner doubles what Team costs, which means publishing a new version: // the price of a published one is fixed, because someone may be standing at // the checkout looking at it. $catalogue = app(App\Services\Billing\PlanCatalogue::class); $current = $catalogue->currentVersion('team'); $currency = Subscription::catalogueCurrency(); $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' => $bookedPrice * 2, 'currency' => $currency]); $dearer->prices()->create(['term' => 'yearly', 'amount_cents' => $bookedPrice * 24, 'currency' => $currency]); $catalogue->publish($dearer, now()); // The existing customer keeps their terms — that is the entire point. expect($subscription->fresh()->price_cents)->toBe($bookedPrice) ->and(Subscription::snapshotFrom('team')['price_cents'])->toBe($bookedPrice * 2); }); it('refuses to rewrite a snapshot after the fact', function () { $subscription = Subscription::factory()->plan('team')->create(); expect(fn () => $subscription->update(['price_cents' => 1])) ->toThrow(RuntimeException::class, 'immutable'); expect($subscription->fresh()->price_cents)->toBe(Subscription::snapshotFrom('team')['price_cents']); // The rejected value is still on the in-memory model, so it has to be // discarded before anything else can be saved — otherwise the next write // carries the forbidden change along and is refused too. $subscription->refresh(); // Operational fields still move. $subscription->update(['pending_plan' => 'start']); expect($subscription->fresh()->pending_plan)->toBe('start'); }); it('charges only the remaining days when upgrading mid-month', function () { $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); $change = PlanChange::evaluate($subscription, 'team', Carbon::parse('2026-08-16')); $startPrice = Subscription::snapshotFrom('start')['price_cents']; $teamPrice = Subscription::snapshotFrom('team')['price_cents']; $expected = (int) round($teamPrice * 16 / 31) - (int) round($startPrice * 16 / 31); expect($change->isUpgrade)->toBeTrue() ->and($change->allowedNow)->toBeTrue() ->and($change->chargeCents)->toBe($expected) ->and($change->chargeCents)->toBeLessThan($teamPrice) ->and($change->chargeCents)->toBeGreaterThan(0); }); it('makes an upgrade on the last day cost almost nothing', function () { $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); $change = PlanChange::evaluate($subscription, 'business', Carbon::parse('2026-08-31')); // One day of the difference, not a month of it. expect($change->remainingDays)->toBe(1) ->and($change->chargeCents)->toBeLessThan(2000); }); it('holds a downgrade until the term the customer paid for is over', function () { $start = Carbon::parse('2026-01-01'); $yearly = Subscription::factory()->plan('business', Subscription::TERM_YEARLY)->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addYear(), ]); $change = PlanChange::evaluate($yearly, 'start', Carbon::parse('2026-03-15')); // Someone on a yearly contract bought a year, not three months. expect($change->isUpgrade)->toBeFalse() ->and($change->allowedNow)->toBeFalse() ->and($change->chargeCents)->toBe(0) ->and($change->effectiveAt->toDateString())->toBe('2027-01-01'); }); it('lets a monthly customer move down at the end of the month', function () { $start = Carbon::parse('2026-08-01'); $monthly = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); expect(PlanChange::evaluate($monthly, 'team', Carbon::parse('2026-08-10'))->effectiveAt->toDateString()) ->toBe('2026-09-01'); }); it('credits only the unused part of the difference for a goodwill downgrade', function () { $start = Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // 11 days left of 31: they used the expensive plan for the other 20. $credit = PlanChange::goodwillCredit($subscription, 'team', Carbon::parse('2026-08-21')); $difference = Subscription::snapshotFrom('business')['price_cents'] - Subscription::snapshotFrom('team')['price_cents']; expect($credit)->toBe((int) round($difference * 11 / 31)) ->and($credit)->toBeLessThan($difference); }); it('will not call an upgrade a credit', function () { $subscription = Subscription::factory()->plan('start')->create(); expect(fn () => PlanChange::goodwillCredit($subscription, 'business')) ->toThrow(RuntimeException::class); }); it('prices a yearly term as twelve months of the same plan', function () { $monthly = Subscription::snapshotFrom('team'); $yearly = Subscription::snapshotFrom('team', Subscription::TERM_YEARLY); // Any discount belongs in the catalogue, not hidden in the conversion. expect($yearly['price_cents'])->toBe($monthly['price_cents'] * 12); }); it('reads the direction from the plan rank, not from a grandfathered price', function () { $start = Illuminate\Support\Carbon::parse('2026-08-01'); // A business customer from back when business cost less than today's team. $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); $subscription->forceFill(['price_cents' => 9900])->saveQuietly(); // grandfathered // The premise, asserted rather than written into config('provisioning.plans.*') // — a key that has not existed since the catalogue moved into the database, so // that line set nothing and this test never built the scenario it is named for. // Today's team has to cost MORE than this customer's grandfathered business // price, or a price-led reading of the direction would agree with the rank one // and the test would prove nothing. expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900); $change = PlanChange::evaluate($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15')); // Moving to team takes resources away — that is a downgrade whatever the // prices say, and it waits for the end of the term. expect($change->isUpgrade)->toBeFalse() ->and($change->allowedNow)->toBeFalse() ->and($change->chargeCents)->toBe(0); }); it('lets a scheduled downgrade actually happen once the term is over', function () { $start = Illuminate\Support\Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Mid-term: not yet. expect(PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse('2026-08-15'))->allowedNow) ->toBeFalse(); // On the boundary and after it: the job carrying it out has to be able to. foreach (['2026-09-01', '2026-09-03'] as $when) { $change = PlanChange::evaluate($subscription, 'team', Illuminate\Support\Carbon::parse($when)); expect($change->allowedNow)->toBeTrue() ->and($change->effectiveAt->toDateString())->toBe($when); } }); it('never invoices a customer for downgrading', function () { $start = Illuminate\Support\Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('business')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Grandfathered below today's smaller plan — asserted, for the reason given in // the direction test above: the config write this replaces was a no-op. $subscription->forceFill(['price_cents' => 9900])->saveQuietly(); expect(Subscription::snapshotFrom('team')['price_cents'])->toBeGreaterThan(9900); $credit = PlanChange::goodwillCredit($subscription->fresh(), 'team', Illuminate\Support\Carbon::parse('2026-08-15')); expect($credit)->toBe(0); }); it('does not give away an upgrade in the last hours of a term', function () { $start = Illuminate\Support\Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Six hours left: still service, so still a charge. $change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-08-31 18:00')); expect($change->remainingDays)->toBe(1) ->and($change->chargeCents)->toBeGreaterThan(0); }); it('sends an upgrade requested after the term into the next one', function () { $start = Illuminate\Support\Carbon::parse('2026-08-01'); $subscription = Subscription::factory()->plan('start')->create([ 'current_period_start' => $start, 'current_period_end' => $start->copy()->addMonth(), ]); // Nothing left to prorate — charging zero and switching immediately would // hand out the bigger plan for free. $change = PlanChange::evaluate($subscription, 'business', Illuminate\Support\Carbon::parse('2026-09-05')); expect($change->allowedNow)->toBeFalse() ->and($change->chargeCents)->toBe(0) ->and($change->effectiveAt->toDateString())->toBe('2026-09-01'); }); it('has nothing to change once a subscription is cancelled', function () { $subscription = Subscription::factory()->plan('start')->create([ 'status' => 'cancelled', 'cancelled_at' => now(), ]); // A caller trusting allowedNow would otherwise provision and bill an // upgrade for a customer who has already left. $change = PlanChange::evaluate($subscription, 'business'); expect($change->allowedNow)->toBeFalse() ->and($change->chargeCents)->toBe(0) ->and($change->effectiveAt)->toBeNull(); }); it('refuses a term it does not know', function () { expect(fn () => Subscription::snapshotFrom('team', 'yearlyy')) ->toThrow(RuntimeException::class, 'Unknown term'); });