242 lines
10 KiB
PHP
242 lines
10 KiB
PHP
<?php
|
|
|
|
use App\Actions\BookAddon;
|
|
use App\Actions\GrantAddon;
|
|
use App\Livewire\Billing;
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\Order;
|
|
use App\Models\Subscription;
|
|
use App\Models\SubscriptionAddon;
|
|
use App\Models\User;
|
|
use App\Services\Billing\CustomDomainAccess;
|
|
use App\Services\Billing\DowngradeCheck;
|
|
use App\Services\Billing\PlanCatalogue;
|
|
use App\Services\Billing\PlanChange;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* Who may have their own domain.
|
|
*
|
|
* The domain page shipped with no access control at all: the `custom_domain`
|
|
* plan feature was checked nowhere, and every customer on every package could
|
|
* point a domain at their instance. These tests are the owner's rule, stated as
|
|
* behaviour — impossible on the entry package, a module on Team, part of the
|
|
* package on Business and Enterprise — and, as much as anything, they are about
|
|
* what happens on the way DOWN, which is where a right silently outliving the
|
|
* package that granted it does its damage.
|
|
*/
|
|
function domainCustomer(string $plan, array $instanceAttributes = []): array
|
|
{
|
|
$customer = Customer::factory()->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,
|
|
]);
|
|
|
|
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']);
|
|
});
|