464 lines
17 KiB
PHP
464 lines
17 KiB
PHP
<?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'));
|
||
});
|