CluPilotCloud/tests/Feature/Billing/WithdrawalTest.php

464 lines
17 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
use App\Actions\WithdrawContract;
use App\Livewire\Admin\RecordWithdrawal;
use App\Livewire\Settings;
use App\Models\Customer;
use App\Models\DnsRecord;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionRecord;
use App\Models\User;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\WithdrawalRight;
use App\Services\Stripe\FakeStripeClient;
use App\Services\Stripe\StripeClient;
use App\Services\Traefik\FakeTraefikWriter;
use App\Services\Traefik\TraefikWriter;
use App\Support\CompanyProfile;
use Illuminate\Support\Carbon;
use Livewire\Livewire;
/**
* The fourteen-day right of withdrawal — who has it, what it costs, and what it
* leaves behind.
*
* The concrete example these tests are built on, once, so the arithmetic can be
* checked by hand rather than recomputed from the implementation:
*
* contract concluded 1 March 2026, 09:00
* term 1 March → 1 April = 31 days
* price 49,00 € net, 20 % VAT → 58,80 € gross, invoiced and paid
* withdrawal 8 March 2026, 09:00 — 7 days of service delivered
*
* owed net = round(4900 × 7 / 31) = 1106 (11,06 €)
* owed gross = 1106 + round(1106 × 0,20) = 1327 (13,27 €)
* refund = 5880 1327 = 4553 (45,53 €)
*/
beforeEach(function () {
Carbon::setTestNow('2026-03-01 09:00:00');
// A withdrawal takes an address down, and taking one down is SSH and DNS.
// Without the fakes bound the teardown dials a random public IP on port 22
// and sits there — no test in this suite may reach the network.
fakeServices();
CompanyProfile::put([
'name' => 'CluPilot Cloud e.U.',
'address' => 'Dreherstraße 66/1/8',
'postcode' => '1110',
'city' => 'Wien',
'vat_id' => 'ATU00000000',
]);
});
afterEach(function () {
Carbon::setTestNow();
});
/**
* A paid contract, invoiced, with a running machine behind it.
*
* @return array{0: Subscription, 1: Invoice, 2: Instance}
*/
function withdrawableContract(array $customerState = [], bool $consented = true): array
{
$customer = Customer::factory()->create($customerState + [
'name' => 'Frau Berger',
'email' => 'berger@example.test',
]);
$order = Order::factory()->create([
'customer_id' => $customer->id,
'plan' => 'team',
// What the payment provider took, and what the document is written from.
'amount_cents' => 4900,
'currency' => 'EUR',
'status' => 'paid',
'stripe_event_id' => 'cs_withdraw',
'stripe_payment_intent_id' => 'pi_withdraw',
]);
$factory = Subscription::factory();
if ($consented) {
$factory = $factory->startedImmediately();
}
$contract = $factory->create([
'customer_id' => $customer->id,
'order_id' => $order->id,
'price_cents' => 4900,
'currency' => 'EUR',
'started_at' => now(),
'current_period_start' => now(),
'current_period_end' => now()->addMonth(),
'withdrawal_ends_at' => now()->addDays(WithdrawalRight::WINDOW_DAYS),
]);
$host = Host::factory()->create();
$instance = Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $order->id,
'host_id' => $host->id,
'plan' => 'team',
'status' => 'active',
'route_written' => true,
]);
$contract->update(['instance_id' => $instance->id]);
// The router and the record that would leak if a withdrawal left them
// standing — the whole point of EndInstanceService.
app(TraefikWriter::class)->write(
(string) ($host->wg_ip ?? $host->public_ip),
(string) $instance->subdomain,
[$instance->subdomain.'.clupilot.cloud'],
'http://10.0.0.2:80',
);
DnsRecord::query()->create([
'instance_id' => $instance->id,
'provider' => 'hetzner',
'record_id' => 'rec_withdraw',
'fqdn' => $instance->subdomain.'.clupilot.cloud',
'type' => 'A',
'value' => '203.0.113.9',
]);
$invoice = app(IssueInvoice::class)->forOrders($customer, collect([$order]));
return [$contract->refresh(), $invoice, $instance->refresh()];
}
/** A Stripe that answers, so the refund has somewhere to go. */
function withdrawalStripe(): FakeStripeClient
{
$fake = new FakeStripeClient;
app()->instance(StripeClient::class, $fake);
return $fake;
}
it('lets a consumer withdraw inside fourteen days', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
->and($contract->withdrawal_refund_error)->toBeNull()
->and($stripe->refunds)->toHaveCount(1);
});
it('refuses a business customer, at the action and at the portal alike', function () {
withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);
Carbon::setTestNow('2026-03-08 09:00:00');
// The door the console and any future caller comes through.
expect(fn () => app(WithdrawContract::class)($contract))
->toThrow(RuntimeException::class, __('withdrawal.refusal_business'));
// And the door a browser comes through. A Livewire action is reachable by
// anybody who can POST to /livewire/update, so hiding the card proves
// nothing — this calls the method directly, exactly as that request would.
$user = User::factory()->create(['email' => $contract->customer->email]);
$contract->customer->update(['user_id' => $user->id]);
Livewire::actingAs($user)
->test(Settings::class)
->call('withdraw')
->assertDispatched('notify', message: __('withdrawal.refusal_business'));
expect($contract->refresh()->withdrawn_at)->toBeNull();
});
it('treats a customer whose type was never recorded as a consumer', function () {
$stripe = withdrawalStripe();
// No `customer_type` at all: the state every customer created from a Stripe
// event arrives in. NULL is not "business" and must never be read as one.
[$contract] = withdrawableContract();
expect($contract->customer->customer_type)->toBeNull()
->and($contract->customer->hasRecordedType())->toBeFalse();
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
->and($stripe->refunds)->toHaveCount(1);
});
it('refuses a withdrawal once the fourteen days are up', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
// One second past the deadline. The window closed at 15 March 09:00.
Carbon::setTestNow('2026-03-15 09:00:01');
expect(WithdrawalRight::for($contract->refresh())->open)->toBeFalse();
expect(fn () => app(WithdrawContract::class)($contract))
->toThrow(RuntimeException::class, __('withdrawal.refusal_expired'));
expect($contract->refresh()->withdrawn_at)->toBeNull()
->and($stripe->refunds)->toBeEmpty();
});
it('refunds the amount paid less the pro-rata value of the service delivered', function () {
$stripe = withdrawalStripe();
[$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
// The document the customer actually has: 49,00 € net, 20 % VAT.
expect($invoice->net_cents)->toBe(4900)
->and($invoice->tax_cents)->toBe(980)
->and($invoice->gross_cents)->toBe(5880);
Carbon::setTestNow('2026-03-08 09:00:00');
expect(WithdrawalRight::deliveredDays($contract->refresh()))->toBe(7)
->and(WithdrawalRight::termDays($contract))->toBe(31)
->and(WithdrawalRight::owedNetCents($contract))->toBe(1106);
app(WithdrawContract::class)($contract);
// The corrected document states exactly what is kept…
$remainder = Invoice::query()
->where('customer_id', $contract->customer_id)
->whereNull('cancels_invoice_id')
->where('id', '>', $invoice->id)
->firstOrFail();
expect($remainder->net_cents)->toBe(1106)
->and($remainder->tax_cents)->toBe(221)
->and($remainder->gross_cents)->toBe(1327);
// …and the money is the difference between the two documents, to the cent.
expect($stripe->refunds)->toHaveCount(1)
->and($stripe->refunds[0]['amount'])->toBe(4553)
->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw')
->and($contract->refresh()->withdrawal_refund_cents)->toBe(4553);
expect(5880 - 1327)->toBe(4553);
});
it('refunds everything where no express request to start at once was recorded', function () {
$stripe = withdrawalStripe();
// FAGG §16 lets us keep the pro-rata share only where the consumer asked
// for the service to begin inside the window AND was told what that would
// cost. Without that on record they owe nothing.
[$contract, $invoice] = withdrawableContract(
['customer_type' => Customer::TYPE_CONSUMER],
consented: false,
);
Carbon::setTestNow('2026-03-08 09:00:00');
expect(WithdrawalRight::owedNetCents($contract->refresh()))->toBe(0);
app(WithdrawContract::class)($contract);
expect($stripe->refunds[0]['amount'])->toBe($invoice->gross_cents)
->and($stripe->refunds[0]['amount'])->toBe(5880);
});
it('ends the service through the existing machinery, and the instance stops being served', function () {
withdrawalStripe();
[$contract, , $instance] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
/** @var FakeTraefikWriter $traefik */
$traefik = app(TraefikWriter::class);
expect($traefik->routes)->toHaveKey($instance->subdomain);
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
$instance->refresh();
// Marked ended by EndInstanceService, through `cancellation_scheduled` and
// a service end of NOW — a withdrawal leaves no paid-up term to run out.
expect($instance->status)->toBe('ended')
->and($instance->route_written)->toBeFalse()
->and($instance->service_ends_at?->toDateTimeString())->toBe('2026-03-08 09:00:00');
// The address really is gone: no router, no DNS record.
expect($traefik->routes)->not->toHaveKey($instance->subdomain)
->and(DnsRecord::query()->where('instance_id', $instance->id)->exists())->toBeFalse();
expect($contract->refresh()->status)->toBe('cancelled');
});
it('cancels the invoice with its own document and never reuses a number', function () {
withdrawalStripe();
[$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
$originalNumber = $invoice->number;
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
// The original is untouched. Not edited, not deleted, same number.
$invoice->refresh();
expect($invoice->exists)->toBeTrue()
->and($invoice->number)->toBe($originalNumber)
->and($invoice->gross_cents)->toBe(5880);
// A cancellation in its own series, pointing at what it takes back and
// stating the same figures with the opposite sign.
$storno = Invoice::query()->where('cancels_invoice_id', $invoice->id)->firstOrFail();
$series = InvoiceSeries::query()->where('kind', 'cancellation')->firstOrFail();
expect($storno->invoice_series_id)->toBe($series->id)
->and($storno->number)->toStartWith($series->prefix)
->and($storno->net_cents)->toBe(-4900)
->and($storno->tax_cents)->toBe(-980)
->and($storno->gross_cents)->toBe(-5880)
->and($storno->snapshot['meta']['title'])->toBe(__('invoice.cancellation_title'));
// Every number issued is distinct, and each series only ever went forwards.
$numbers = Invoice::query()->pluck('number');
expect($numbers->unique()->count())->toBe($numbers->count());
// A second Storno of the same invoice would take the same money back twice
// on paper, so it is refused outright.
expect(fn () => app(IssueInvoice::class)->cancelling($invoice))
->toThrow(RuntimeException::class);
});
it('records the withdrawal in the proof register', function () {
withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
$entry = SubscriptionRecord::query()
->where('subscription_id', $contract->id)
->where('event', SubscriptionRecord::EVENT_WITHDRAWAL)
->firstOrFail();
// Revenue leaving, with what actually went back and the days it was worked
// out from beside it.
expect($entry->gross_cents)->toBe(-4553)
->and($entry->snapshot['withdrawal']['delivered_days'])->toBe(7)
->and($entry->snapshot['withdrawal']['term_days'])->toBe(31)
->and($entry->snapshot['withdrawal']['owed_net_cents'])->toBe(1106)
->and($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(4553);
});
it('withdraws once however many times it is asked', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
Carbon::setTestNow('2026-03-08 09:00:00');
app(WithdrawContract::class)($contract);
expect(fn () => app(WithdrawContract::class)($contract->refresh()))
->toThrow(RuntimeException::class, __('withdrawal.refusal_already'));
// One refund, one cancellation document. A second of either would be money
// and a number we can never get back.
expect($stripe->refunds)->toHaveCount(1)
->and(Invoice::query()->whereNotNull('cancels_invoice_id')->count())->toBe(1);
});
it('lets a consumer withdraw from the portal', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
$user = User::factory()->create(['email' => $contract->customer->email]);
$contract->customer->update(['user_id' => $user->id]);
Carbon::setTestNow('2026-03-08 09:00:00');
Livewire::actingAs($user)
->test(Settings::class)
->call('withdraw')
->assertDispatched('notify', message: __('withdrawal.done'));
expect($contract->refresh()->withdrawn_at)->not->toBeNull()
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_PORTAL)
->and($stripe->refunds)->toHaveCount(1);
});
it('lets an operator record a withdrawal that came in by telephone', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
$support = operator('Support');
Carbon::setTestNow('2026-03-08 09:00:00');
Livewire::actingAs($support, 'operator')
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
->call('record')
->assertDispatched('notify');
$contract->refresh();
// The same action, the same arithmetic — with the channel and the name of
// whoever took the declaration on it.
expect($contract->withdrawn_at)->not->toBeNull()
->and($contract->withdrawal_channel)->toBe(WithdrawContract::CHANNEL_OPERATOR)
->and($contract->withdrawal_recorded_by)->toBe($support->id)
->and($stripe->refunds[0]['amount'])->toBe(4553);
});
it('will not let an operator record a withdrawal a business customer does not have', function () {
$stripe = withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_BUSINESS]);
Carbon::setTestNow('2026-03-08 09:00:00');
Livewire::actingAs(operator('Support'), 'operator')
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
->call('record')
->assertSet('refusal', __('withdrawal.refusal_business'));
expect($contract->refresh()->withdrawn_at)->toBeNull()
->and($stripe->refunds)->toBeEmpty();
});
it('keeps the console withdrawal behind a permission', function () {
withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
// A modal is reachable without the route middleware of the page that opens
// it, so the check lives on the component itself.
Livewire::actingAs(operator('Read-only'), 'operator')
->test(RecordWithdrawal::class, ['uuid' => $contract->customer->uuid])
->assertForbidden();
});
it('shows the card to a consumer and never to a business', function () {
withdrawalStripe();
[$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]);
$user = User::factory()->create(['email' => $contract->customer->email]);
$contract->customer->update(['user_id' => $user->id]);
Livewire::actingAs($user)->test(Settings::class)->assertSee(__('withdrawal.cta'));
$contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]);
Livewire::actingAs($user)->test(Settings::class)->assertDontSee(__('withdrawal.cta'));
});