231 lines
9.6 KiB
PHP
231 lines
9.6 KiB
PHP
<?php
|
|
|
|
use App\Models\Subscription;
|
|
use App\Services\Billing\PlanChange;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
it('freezes what the customer bought against later price rises', function () {
|
|
$subscription = Subscription::factory()->plan('team')->create();
|
|
$bookedPrice = $subscription->price_cents;
|
|
|
|
config()->set('provisioning.plans.team.price_cents', $bookedPrice * 2);
|
|
|
|
// 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
|
|
|
|
config()->set('provisioning.plans.team.price_cents', 17900); // today's team costs more
|
|
|
|
$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.
|
|
$subscription->forceFill(['price_cents' => 9900])->saveQuietly();
|
|
config()->set('provisioning.plans.team.price_cents', 17900);
|
|
|
|
$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');
|
|
});
|