232 lines
9.5 KiB
PHP
232 lines
9.5 KiB
PHP
<?php
|
|
|
|
use App\Actions\BookAddon;
|
|
use App\Actions\GrantAddon;
|
|
use App\Actions\OpenSubscription;
|
|
use App\Livewire\Billing;
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\Operator;
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionAddon;
|
|
use App\Models\User;
|
|
use App\Services\Billing\AddonCatalogue;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Buying the same module twice.
|
|
*
|
|
* The unique index on `subscription_addons` guards a single ORDER, which is the
|
|
* wrong unit: two orders for the same module on one contract both went through,
|
|
* so a stale tab or a double click charged the customer twice for one thing.
|
|
*
|
|
* The other half of that is just as real in the opposite direction. Storage is
|
|
* deliberately sold in packs and two bookings are the point of it — refusing
|
|
* the second would be a lost sale, not a saved customer. So which modules may be
|
|
* held once and which may be stacked is declared per module in
|
|
* config/provisioning.php, and these prove it is obeyed everywhere a module can
|
|
* be booked: the action, the shop, and the operator giving one away.
|
|
*/
|
|
|
|
/** A contract to book modules onto. */
|
|
function entitlementContract(string $plan = 'team'): Subscription
|
|
{
|
|
$order = Order::factory()->create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']);
|
|
|
|
return app(OpenSubscription::class)($order);
|
|
}
|
|
|
|
/** A portal customer on a package, with the machine their contract pays for. */
|
|
function entitlementShopper(string $plan = 'team'): array
|
|
{
|
|
$customer = Customer::factory()->create();
|
|
$user = User::factory()->create(['email' => $customer->email]);
|
|
$order = Order::factory()->withSubscription()->for($customer)->create(['plan' => $plan]);
|
|
$instance = Instance::factory()->for($customer)->create([
|
|
'order_id' => $order->id,
|
|
'plan' => $plan,
|
|
'status' => 'active',
|
|
]);
|
|
|
|
$order->subscription->update(['instance_id' => $instance->id]);
|
|
|
|
return [$customer, $user, $order->subscription->fresh()];
|
|
}
|
|
|
|
it('declares for every module it sells whether it may be held once or stacked', function () {
|
|
$catalogue = app(AddonCatalogue::class);
|
|
$declared = array_merge(
|
|
collect((array) config('provisioning.addons'))->map(fn ($a) => $a['sold_as'] ?? null)->all(),
|
|
[AddonCatalogue::STORAGE => config('provisioning.storage_addon.sold_as')],
|
|
);
|
|
|
|
// Undeclared is not a state this catalogue may ship in. It costs money in
|
|
// both directions — a double charge or a lost sale — and the fallback in
|
|
// soldAs() is a safety net, not somewhere to leave the decision.
|
|
foreach ($declared as $key => $soldAs) {
|
|
expect($soldAs)->toBeIn([AddonCatalogue::ENTITLEMENT, AddonCatalogue::QUANTITY], "module {$key}");
|
|
}
|
|
|
|
// And the one that is genuinely a pack is the one declared as a pack.
|
|
expect($catalogue->isEntitlement(AddonCatalogue::STORAGE))->toBeFalse()
|
|
->and($catalogue->isEntitlement('priority_support'))->toBeTrue()
|
|
->and($catalogue->isEntitlement('extra_backups'))->toBeTrue()
|
|
->and($catalogue->isEntitlement('collabora_pro'))->toBeTrue()
|
|
->and($catalogue->isEntitlement('custom_domain'))->toBeTrue();
|
|
});
|
|
|
|
it('refuses to book an entitlement a second time on the same contract', function () {
|
|
$subscription = entitlementContract();
|
|
|
|
app(BookAddon::class)($subscription, 'priority_support');
|
|
|
|
// A second, different order — which is exactly what a stale tab produces,
|
|
// and what the per-order unique index never saw.
|
|
$second = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'priority_support', 'status' => 'paid']);
|
|
|
|
expect(fn () => app(BookAddon::class)($subscription, 'priority_support', 1, $second))
|
|
->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
|
|
'module' => __('billing.addon.priority_support.name'),
|
|
]));
|
|
|
|
expect($subscription->addons()->count())->toBe(1);
|
|
});
|
|
|
|
it('still books the same order twice as one module, because that is a retry', function () {
|
|
$subscription = entitlementContract();
|
|
$order = Order::factory()->for($subscription->customer)->create(['type' => 'addon', 'addon_key' => 'collabora_pro', 'status' => 'paid']);
|
|
|
|
$first = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
|
|
$again = app(BookAddon::class)($subscription, 'collabora_pro', 1, $order);
|
|
|
|
// A webhook delivered twice is one purchase arriving again, not a second
|
|
// one: it gets its booking back rather than an error.
|
|
expect($again->id)->toBe($first->id)
|
|
->and($subscription->addons()->count())->toBe(1);
|
|
});
|
|
|
|
it('lets an entitlement be booked again once the customer has cancelled it', function () {
|
|
$subscription = entitlementContract();
|
|
|
|
$addon = app(BookAddon::class)($subscription, 'extra_backups');
|
|
app(BookAddon::class)->cancel($addon);
|
|
|
|
// The rule is about what a contract HAS, not about what it has ever had —
|
|
// otherwise cancelling a module would be irreversible.
|
|
app(BookAddon::class)($subscription, 'extra_backups');
|
|
|
|
expect($subscription->addons()->active()->count())->toBe(1)
|
|
->and(SubscriptionAddon::query()->count())->toBe(2);
|
|
});
|
|
|
|
it('stacks a module that is sold in packs, and totals it as the sum of its bookings', function () {
|
|
$subscription = entitlementContract();
|
|
$pack = (int) app(AddonCatalogue::class)->priceCents(AddonCatalogue::STORAGE);
|
|
|
|
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
|
|
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2);
|
|
|
|
$row = app(AddonCatalogue::class)->forSubscription($subscription->fresh())[AddonCatalogue::STORAGE];
|
|
|
|
expect($subscription->addons()->active()->count())->toBe(2)
|
|
->and($row['quantity'])->toBe(3)
|
|
->and($row['monthly_cents'])->toBe($pack * 3)
|
|
->and($row['entitlement'])->toBeFalse();
|
|
});
|
|
|
|
it('refuses a second purchase of an entitlement through the shop, in words', function () {
|
|
[$customer, $user, $subscription] = entitlementShopper();
|
|
|
|
app(BookAddon::class)($subscription, 'collabora_pro');
|
|
|
|
Livewire::actingAs($user)->test(Billing::class)
|
|
->call('purchase', 'addon', 'collabora_pro')
|
|
->assertDispatched('notify', message: __('billing.addon_already_booked', [
|
|
'module' => __('billing.addon.collabora_pro.name'),
|
|
]));
|
|
|
|
// No order, so nothing to charge for: the refusal is the point, the
|
|
// sentence is only how the customer finds out.
|
|
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse();
|
|
});
|
|
|
|
it('refuses a second purchase while the first is still sitting in the cart', function () {
|
|
[$customer, $user] = entitlementShopper();
|
|
|
|
$component = Livewire::actingAs($user)->test(Billing::class);
|
|
$component->call('purchase', 'addon', 'priority_support');
|
|
$component->call('purchase', 'addon', 'priority_support')
|
|
->assertDispatched('notify', message: __('billing.addon_in_cart', [
|
|
'module' => __('billing.addon.priority_support.name'),
|
|
]));
|
|
|
|
// The double click, which is where the second charge came from.
|
|
expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->count())->toBe(1);
|
|
});
|
|
|
|
it('stops offering an entitlement the customer already has, and keeps offering packs', function () {
|
|
[, $user, $subscription] = entitlementShopper();
|
|
|
|
app(BookAddon::class)($subscription, 'collabora_pro');
|
|
|
|
Livewire::actingAs($user)->test(Billing::class)
|
|
->assertSee(__('billing.addon_booked'))
|
|
// Storage is a pack and has its own card, which never stops offering.
|
|
->assertSee(__('billing.storage_cta', ['gb' => config('provisioning.storage_addon.gb')]));
|
|
|
|
$rows = Livewire::actingAs($user)->test(Billing::class)->viewData('addons');
|
|
|
|
expect($rows['collabora_pro']['booked'])->toBeTrue()
|
|
->and($rows['collabora_pro']['entitlement'])->toBeTrue();
|
|
});
|
|
|
|
it('marks a module waiting in the cart so the card stops offering it', function () {
|
|
[, $user] = entitlementShopper();
|
|
|
|
Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', 'extra_backups');
|
|
|
|
$component = Livewire::actingAs($user)->test(Billing::class);
|
|
|
|
expect($component->viewData('addons')['extra_backups']['in_cart'])->toBeTrue();
|
|
|
|
$component->assertSee(__('billing.addon_in_cart_badge'));
|
|
});
|
|
|
|
it('refuses to grant an entitlement the customer is already paying for', function () {
|
|
$subscription = entitlementContract();
|
|
$owner = Operator::factory()->create();
|
|
|
|
app(BookAddon::class)($subscription, 'priority_support');
|
|
|
|
expect(fn () => app(GrantAddon::class)(
|
|
subscription: $subscription,
|
|
grantedBy: $owner,
|
|
addonKey: 'priority_support',
|
|
priceCents: 0,
|
|
))->toThrow(RuntimeException::class, __('billing.addon_already_booked', [
|
|
'module' => __('billing.addon.priority_support.name'),
|
|
]));
|
|
|
|
// And the synthetic order the grant would have hung off it is gone with it:
|
|
// an order left behind is a charge for a module nobody got.
|
|
expect($subscription->addons()->count())->toBe(1)
|
|
->and(Order::query()->where('type', 'addon')->count())->toBe(0);
|
|
});
|
|
|
|
it('still lets an operator grant a second pack of storage', function () {
|
|
$subscription = entitlementContract();
|
|
$owner = Operator::factory()->create();
|
|
|
|
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE);
|
|
|
|
app(GrantAddon::class)(
|
|
subscription: $subscription,
|
|
grantedBy: $owner,
|
|
addonKey: AddonCatalogue::STORAGE,
|
|
priceCents: 0,
|
|
);
|
|
|
|
expect($subscription->addons()->active()->count())->toBe(2);
|
|
});
|