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); });