99 lines
3.6 KiB
PHP
99 lines
3.6 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Billing;
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\InstanceMetric;
|
|
use App\Models\Order;
|
|
use App\Models\Seat;
|
|
use App\Models\User;
|
|
use App\Services\Billing\DowngradeCheck;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Moving down to a smaller plan.
|
|
*
|
|
* A downgrade is not the mirror of an upgrade: going up always fits, going
|
|
* down can ask the instance to hold more than the target allows. What matters
|
|
* as much as refusing it is SAYING WHY, in the customer's own numbers — a
|
|
* disabled button that does not name the obstacle is the thing people ring
|
|
* about.
|
|
*/
|
|
|
|
function downgradeCustomer(int $seats = 1, ?int $usedBytes = null): array
|
|
{
|
|
$user = User::factory()->create();
|
|
$customer = Customer::factory()->create(['user_id' => $user->id]);
|
|
$instance = Instance::factory()->create(['customer_id' => $customer->id, 'status' => 'active', 'quota_gb' => 500]);
|
|
|
|
foreach (range(1, $seats) as $n) {
|
|
Seat::create([
|
|
'customer_id' => $customer->id,
|
|
'email' => "seat{$n}@example.test",
|
|
'role' => $n === 1 ? 'owner' : 'member',
|
|
'status' => 'active',
|
|
]);
|
|
}
|
|
|
|
if ($usedBytes !== null) {
|
|
InstanceMetric::create([
|
|
'instance_id' => $instance->id,
|
|
'day' => now()->toDateString(),
|
|
'disk_used_bytes' => $usedBytes,
|
|
'disk_total_bytes' => 500 * 1024 ** 3,
|
|
]);
|
|
}
|
|
|
|
return [$user, $customer, $instance];
|
|
}
|
|
|
|
it('allows a downgrade when everything fits', function () {
|
|
[, $customer, $instance] = downgradeCustomer(seats: 3, usedBytes: 40 * 1024 ** 3);
|
|
|
|
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
|
|
|
expect($check->allowed)->toBeTrue()->and($check->blockers)->toBe([]);
|
|
});
|
|
|
|
it('names the seats that are in the way, with both numbers', function () {
|
|
[, $customer, $instance] = downgradeCustomer(seats: 31);
|
|
|
|
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
|
|
|
expect($check->allowed)->toBeFalse()
|
|
->and($check->blockers[0]['key'])->toBe('seats')
|
|
->and($check->blockers[0]['current'])->toBe('31')
|
|
->and($check->blockers[0]['limit'])->toBe('10');
|
|
});
|
|
|
|
it('measures storage rather than trusting the allowance', function () {
|
|
// 480 GB really in use. The contract says 500 GB, but what decides whether
|
|
// 200 GB is enough is what is actually stored.
|
|
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: 480 * 1024 ** 3);
|
|
|
|
$check = DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200]);
|
|
|
|
expect($check->allowed)->toBeFalse()
|
|
->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']);
|
|
});
|
|
|
|
it('does not refuse a customer who has simply never been measured', function () {
|
|
// A new instance has no reading. Refusing on the contractual allowance
|
|
// would block every downgrade for anyone who once bought a large plan.
|
|
[, $customer, $instance] = downgradeCustomer(seats: 2, usedBytes: null);
|
|
|
|
expect(DowngradeCheck::for($customer, $instance, ['seats' => 10, 'quota_gb' => 200])->allowed)->toBeTrue();
|
|
});
|
|
|
|
it('refuses a blocked downgrade even when the button is never rendered', function () {
|
|
// The action is callable from a stale tab or a second window. A limit
|
|
// enforced only in markup is not enforced.
|
|
[$user, $customer, $instance] = downgradeCustomer(seats: 31);
|
|
|
|
Livewire::actingAs($user)
|
|
->test(Billing::class)
|
|
->call('purchase', 'downgrade', 'start');
|
|
|
|
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'downgrade')->count())->toBe(0);
|
|
});
|