CluPilotCloud/tests/Feature/Billing/AddonEntitlementTest.php

236 lines
9.7 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]);
// The tests that buy through the shop call Billing::purchase(), which
// since Task 5 refuses outright without a Stripe key for the active mode.
withStripeSecret();
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);
});