'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 = []): 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: // the GROSS of the 49,00 € catalogue figure, because that is what the // Stripe Price charges. 'amount_cents' => 5880, 'currency' => 'EUR', 'status' => 'paid', 'stripe_event_id' => 'cs_withdraw', 'stripe_payment_intent_id' => 'pi_withdraw', ]); $contract = Subscription::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 a consumer withdrawing on day thirteen the whole of what they paid', function () { $stripe = withdrawalStripe(); [$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]); // The document the customer actually has: 58,80 € was charged, and the // document divides it — 49,00 € net and 9,80 € VAT. expect($invoice->gross_cents)->toBe(5880) ->and($invoice->net_cents)->toBe(4900) ->and($invoice->tax_cents)->toBe(980); // Day thirteen: deep inside the window, and thirteen days of cloud really // did run. None of it is charged for. Carbon::setTestNow('2026-03-13 09:00:00'); $right = WithdrawalRight::for($contract->refresh()); expect($right->open)->toBeTrue() ->and(WithdrawalRight::deliveredDays($contract))->toBe(12) ->and(WithdrawalRight::termDays($contract))->toBe(31); app(WithdrawContract::class)($contract); // Nothing is invoiced for the days that were delivered — there is the // original and its Storno, and no third document. expect(Invoice::query()->where('customer_id', $contract->customer_id)->count())->toBe(2); // And the money is the whole of what the cancelled document said. expect($stripe->refunds)->toHaveCount(1) ->and($stripe->refunds[0]['amount'])->toBe(5880) ->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw') ->and($contract->refresh()->withdrawal_refund_cents)->toBe(5880); }); it('refunds in full on day seven as well, whatever the express-start consent said', function () { $stripe = withdrawalStripe(); // FAGG §16 would let us keep the pro-rata share where the consumer asked for // the service to begin inside the window. The owner has decided not to, so // the consent decides nothing: the contract carries it and the refund is // still everything. [$contract, $invoice] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]); $contract->update(['immediate_start_consent_at' => now()]); Carbon::setTestNow('2026-03-08 09:00:00'); app(WithdrawContract::class)($contract->refresh()); 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('tells Stripe to stop charging immediately', function () { $stripe = withdrawalStripe(); [$contract] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]); // The call nothing in this application used to be able to make. Without it a // withdrawal ended the service, sent the money back — and left the card being // charged every month for a machine that had been switched off. $contract->update(['stripe_subscription_id' => 'sub_withdraw']); Carbon::setTestNow('2026-03-08 09:00:00'); app(WithdrawContract::class)($contract->refresh()); expect($stripe->cancellations)->toHaveCount(1) ->and($stripe->cancellations[0]['subscription'])->toBe('sub_withdraw') // IMMEDIATELY, which is the whole difference from a customer's own // cancellation: a withdrawal unwinds the contract and the entire amount // has just gone back, so there is no paid-up term left to use up. ->and($stripe->cancellations[0]['when'])->toBe(StripeClient::CANCEL_IMMEDIATELY) ->and($contract->refresh()->withdrawal_refund_error)->toBeNull(); }); it('refunds and cancels every charge inside the window, not only the opening one', function () { $stripe = withdrawalStripe(); [$contract, $opening] = withdrawableContract(['customer_type' => Customer::TYPE_CONSUMER]); $contract->update(['stripe_subscription_id' => 'sub_withdraw']); // Day three: the customer books a storage pack, Stripe prorates it and takes // 12,00 € there and then. Its document points at the CONTRACT and carries no // `order_id` — which is exactly why the old code, which looked the document up // by `invoices.order_id`, could not see it at all. It was neither cancelled // nor refunded, and the owner's rule is that a withdrawing consumer gets // everything back. Carbon::setTestNow('2026-03-04 09:00:00'); $module = app(IssueInvoice::class)->forStripeInvoice( subscription: $contract->refresh(), stripeInvoiceId: 'in_module', lines: [[ 'id' => 'il_module', 'amount' => 1200, 'quantity' => 1, 'price' => ['id' => 'price_storage', 'metadata' => ['addon' => 'storage']], ]], ); // A different charge is a different payment, and Stripe refunds a PAYMENT. $stripe->invoicePayments['in_module'] = 'pi_module'; expect($module->order_id)->toBeNull() ->and($module->subscription_id)->toBe($contract->id) ->and($module->gross_cents)->toBe(1200); Carbon::setTestNow('2026-03-08 09:00:00'); app(WithdrawContract::class)($contract->refresh()); // A Storno per document, because that is what a Storno is: it mirrors ONE // invoice and carries its number. Both originals keep theirs for ever. $stornos = Invoice::query()->whereNotNull('cancels_invoice_id')->orderBy('id')->get(); expect($stornos)->toHaveCount(2) ->and($stornos->pluck('cancels_invoice_id')->all())->toBe([$opening->id, $module->id]) ->and($stornos->pluck('gross_cents')->all())->toBe([-5880, -1200]); // And the money: one refund per payment, each against the payment that took // it. One refund of 70,80 € against the opening PaymentIntent is what Stripe // would have refused for exceeding the charge. expect($stripe->refunds)->toHaveCount(2) ->and($stripe->refunds[0]['payment'])->toBe('pi_withdraw') ->and($stripe->refunds[0]['amount'])->toBe(5880) ->and($stripe->refunds[1]['payment'])->toBe('pi_module') ->and($stripe->refunds[1]['amount'])->toBe(1200) // Distinct idempotency keys. One key for the whole withdrawal would have // Stripe replay the first refund's answer for the second, and the module // charge would silently never go back. ->and($stripe->refunds[0]['key'])->not->toBe($stripe->refunds[1]['key']); expect($contract->refresh()->withdrawal_refund_cents)->toBe(7080) ->and($contract->withdrawal_refund_error)->toBeNull(); // The register states what actually went back, so it agrees with the two // cancellation documents rather than with the opening one alone. $entry = SubscriptionRecord::query() ->where('subscription_id', $contract->id) ->where('event', SubscriptionRecord::EVENT_WITHDRAWAL) ->firstOrFail(); expect($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(7080); }); 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 the service // ran beside it, which nothing is charged for but which is still a fact // about this contract. expect($entry->gross_cents)->toBe(-5880) ->and($entry->snapshot['withdrawal']['delivered_days'])->toBe(7) ->and($entry->snapshot['withdrawal']['term_days'])->toBe(31) ->and($entry->snapshot['withdrawal']['refunded_gross_cents'])->toBe(5880); }); 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(5880); }); 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]); // On the contract tab: the settings page is four tabs now, and a withdrawal // belongs with the contract rather than with the company address. Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) ->assertSee(__('withdrawal.cta')); $contract->customer->update(['customer_type' => Customer::TYPE_BUSINESS]); Livewire::actingAs($user)->test(Settings::class, ['tab' => 'contract']) ->assertDontSee(__('withdrawal.cta')); });