create(); $user = User::factory()->create(['email' => $customer->email, 'email_verified_at' => now()]); $customer->update(['user_id' => $user->id]); $instance = Instance::factory()->create([ 'customer_id' => $customer->id, 'plan' => $plan, 'status' => 'active', // Per package: subdomains are unique, and one test walks two packages. 'subdomain' => 'berger-'.$plan, ...$instanceAttributes, ]); $subscription = Subscription::factory()->plan($plan)->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); // One test below calls Billing::purchase(), which since Task 5 refuses // outright without a Stripe key for the active mode. Without this, that // test would see the same "no order" result for the wrong reason — the // Stripe guard, not the plan-restriction it names — and prove nothing. withStripeSecret(); return [$customer, $user, $instance, $subscription]; } /** An instance whose owner has proved the domain is theirs. */ function verifiedDomain(string $domain = 'cloud.berger.at'): array { return ['custom_domain' => $domain, 'domain_token' => 'tok123', 'domain_verified_at' => now()]; } it('does not sell an own domain to the entry package, at any of the three doors', function () { [$customer, $user, , $subscription] = domainCustomer('start'); // The action, which is the only door that actually books anything. expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON)) ->toThrow(RuntimeException::class, __('billing.custom_domain.not_in_plan', ['plan' => 'Start'])); // The page, which must not offer what the action would refuse. $page = Livewire::actingAs($user)->test(Billing::class); expect($page->viewData('addons'))->not->toHaveKey(CustomDomainAccess::ADDON); $page->assertDontSee("purchase('addon', 'custom_domain')", false); // And the component method behind the button, because a hidden button is // markup: a stale tab can still call it. Livewire::actingAs($user)->test(Billing::class)->call('purchase', 'addon', CustomDomainAccess::ADDON); expect(Order::query()->where('customer_id', $customer->id)->where('addon_key', CustomDomainAccess::ADDON)->exists()) ->toBeFalse(); }); it('leaves no paid order behind when a grant of the module is refused', function () { // The console gives a module away through the same booking action, on a // synthetic order it writes first. A refusal after that would leave a paid // purchase of nothing on the customer's account — and on their invoice. [$customer, , , $subscription] = domainCustomer('start'); expect(fn () => app(GrantAddon::class)($subscription, admin(), CustomDomainAccess::ADDON, 0)) ->toThrow(RuntimeException::class); expect(Order::query()->where('customer_id', $customer->id)->where('type', 'addon')->exists())->toBeFalse(); }); it('gives Team the domain only once the module is booked', function () { [, , , $subscription] = domainCustomer('team'); $access = app(CustomDomainAccess::class); // Bookable, and not yet booked — which is not the same as allowed. expect($access->bookable($subscription))->toBeTrue() ->and($access->allows($subscription))->toBeFalse() ->and($access->refusal($subscription))->not->toBeNull(); app(BookAddon::class)($subscription, CustomDomainAccess::ADDON); expect($access->allows($subscription))->toBeTrue() ->and($access->refusal($subscription))->toBeNull(); }); it('gives Business and Enterprise the domain with the package, and does not sell it to them twice', function () { $access = app(CustomDomainAccess::class); foreach (['business', 'enterprise'] as $plan) { [, $user, , $subscription] = domainCustomer($plan); expect($access->allows($subscription))->toBeTrue() ->and($access->hasBooked($subscription))->toBeFalse() ->and($access->bookable($subscription))->toBeFalse(); expect(fn () => app(BookAddon::class)($subscription, CustomDomainAccess::ADDON)) ->toThrow(RuntimeException::class, __('billing.custom_domain.already_included')); // The card stays — it says what they have — but it is not a sale. $page = Livewire::actingAs($user)->test(Billing::class); expect($page->viewData('addons')[CustomDomainAccess::ADDON]['included'])->toBeTrue(); $page->assertSee(__('billing.addon_included')) ->assertDontSee("purchase('addon', 'custom_domain')", false); } }); it('switches the domain off when the package it moved to cannot have one', function () { // Team → Start. The domain and the module both came with the package the // customer has just left. [$customer, , $instance] = domainCustomer('team', verifiedDomain()); $start = Subscription::factory()->plan('start')->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); // The module rides along with the change: nothing cancels a booking when a // package changes, so settling has to be the thing that notices. SubscriptionAddon::create([ 'subscription_id' => $start->id, 'addon_key' => CustomDomainAccess::ADDON, 'price_cents' => 900, 'currency' => 'EUR', 'quantity' => 1, 'booked_at' => now(), ]); expect(PlanChange::settleCustomDomain($start))->toBeTrue(); $instance->refresh(); expect($instance->domainIsVerified())->toBeFalse() // Back to the address the platform issued it, which is the whole of the // fallback: nothing here reimplements how an instance is served. ->and($instance->address('clupilot.com'))->toBe('berger-team.clupilot.com') ->and($instance->custom_domain)->toBeNull() // And it stops being charged for. A feature withdrawn while the module // is still on the bill is the complaint we would deserve. ->and($start->addons()->active()->count())->toBe(0); }); it('asks a Business customer before the move, and abides by their answer', function () { [$customer, $user, $instance] = domainCustomer('business', verifiedDomain()); $team = app(PlanCatalogue::class)->sellable()['team']; $check = DowngradeCheck::for($customer, $instance, $team, 'team'); // Possible, and still worth saying out loud beforehand. expect($check->allowed)->toBeTrue() ->and($check->consequences)->toHaveCount(1) ->and($check->consequences[0]['key'])->toBe('custom_domain_addon') ->and($check->consequences[0]['replace']['domain'])->toBe('cloud.berger.at'); // And it is on the card, above the button that would do it — not in a // message afterwards. Livewire::actingAs($user)->test(Billing::class) ->assertSee(__('billing.downgrade_consequence_title')) ->assertSee('cloud.berger.at'); // Declining: the Team contract carries no module, and the domain goes. $teamContract = Subscription::factory()->plan('team')->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); expect(PlanChange::settleCustomDomain($teamContract))->toBeTrue() ->and($instance->fresh()->domainIsVerified())->toBeFalse(); }); it('leaves everything alone when the customer keeps the domain by booking the module', function () { [$customer, , $instance] = domainCustomer('business', verifiedDomain()); // The module is booked onto the contract they are moving TO — on Business // it would be refused as something they already have. $teamContract = Subscription::factory()->plan('team')->create([ 'customer_id' => $customer->id, 'instance_id' => $instance->id, ]); app(BookAddon::class)($teamContract, CustomDomainAccess::ADDON); expect(PlanChange::settleCustomDomain($teamContract))->toBeFalse() ->and($instance->fresh()->domainIsVerified())->toBeTrue() ->and($instance->fresh()->address('clupilot.com'))->toBe('cloud.berger.at'); // And with the module booked there is nothing left to warn about. $team = app(PlanCatalogue::class)->sellable()['team']; expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]); }); it('says nothing about a domain the customer has not got', function () { // The warning is about a concrete address being switched off. Without one // there is nothing to decide, and a paragraph of consequences on every // downgrade card teaches people to skip them. [$customer, , $instance] = domainCustomer('business'); $team = app(PlanCatalogue::class)->sellable()['team']; expect(DowngradeCheck::for($customer, $instance, $team, 'team')->consequences)->toBe([]); }); it('keeps the rule in one place, where it can only be changed once', function () { // The gap this closed was the same rule living in no place at all. The // opposite failure — a copy in the shop, a copy on the price sheet, a copy // in the downgrade warning — is the one that ships a customer three // different answers, so the config key that says where a domain is // impossible is read by exactly one class. $readers = []; $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(app_path())); foreach ($files as $file) { if ($file->isFile() && $file->getExtension() === 'php' && str_contains((string) file_get_contents($file->getPathname()), 'unavailable_on')) { $readers[] = str_replace(base_path().'/', '', $file->getPathname()); } } expect($readers)->toBe(['app/Services/Billing/CustomDomainAccess.php']); });