'CluPilot Cloud e.U.', 'address' => 'Dreherstraße 66/1/8', 'postcode' => '1110', 'city' => 'Wien', 'vat_id' => 'ATU00000000', ]); }); function paidOrders(Customer $customer, int ...$amounts) { return collect($amounts)->map(fn (int $cents) => Order::factory()->create([ 'customer_id' => $customer->id, 'amount_cents' => $cents, 'currency' => 'EUR', 'status' => 'paid', ])); } it('issues one invoice for one purchase, with every item on its own line', function () { // A customer who buys a plan and two add-ons in one go has bought once. // Three invoices for one purchase is three times the paperwork for the // same money — and the add-ons were asked for as separate lines, not // separate documents. $customer = Customer::factory()->create(); $invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900, 500, 900)); expect($invoice->snapshot['lines'])->toHaveCount(3) ->and($invoice->net_cents)->toBe(3300) ->and($invoice->tax_cents)->toBe(660) ->and($invoice->gross_cents)->toBe(3960) ->and($invoice->number)->toStartWith('RE-'); }); it('refuses to issue before the company details are complete', function () { // An invoice without a registered name, an address or a VAT number is not // a valid invoice here — and issuing one consumes a number that can never // be handed out again. App\Support\Settings::forget('company.vat_id'); $customer = Customer::factory()->create(); expect(fn () => app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900))) ->toThrow(RuntimeException::class); expect(Invoice::query()->count())->toBe(0) // And the number was not taken either. ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(1); }); it('refuses to issue a document with nothing on it', function () { expect(fn () => app(IssueInvoice::class)->forOrders(Customer::factory()->create(), collect())) ->toThrow(RuntimeException::class); }); it('freezes what it says, so later settings cannot rewrite it', function () { // The whole reason no PDF is stored. Change the address in 2028 and an // invoice from 2026 still shows the address it was issued under. $customer = Customer::factory()->create(['name' => 'Muster GmbH']); $invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900)); CompanyProfile::put(['name' => 'CluPilot Cloud GmbH', 'address' => 'Anderswo 9']); $customer->update(['name' => 'Muster GmbH & Co KG']); $frozen = $invoice->refresh()->snapshot; expect($frozen['issuer']['name'])->toBe('CluPilot Cloud e.U.') ->and($frozen['issuer']['address'])->toBe('Dreherstraße 66/1/8') ->and($frozen['customer']['name'])->toBe('Muster GmbH'); // And the rendered document agrees with the snapshot, not with today. expect(app(InvoiceRenderer::class)->forInvoice($invoice))->toBeString()->not->toBeEmpty(); }); it('takes the number and writes the invoice together, or neither', function () { // A number taken and then lost to a failure is a gap in a series that must // not have one. $customer = Customer::factory()->create(); app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900)); app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 2900)); expect(Invoice::query()->pluck('number')->all())->toBe(['RE-2026-0001', 'RE-2026-0002']) ->and(InvoiceSeries::query()->where('kind', 'invoice')->value('next_number'))->toBe(3); })->skip(fn () => now()->format('Y') !== '2026', 'The expected numbers carry the current year.'); it('carries the customer’s billing address as it was written', function () { // One free-text field today. Guessing which line is the postcode would put // it where the street belongs, on a document nobody can correct afterwards. $customer = Customer::factory()->create([ 'billing_address' => "Beispielgasse 12/3\n1030 Wien\nÖsterreich", ]); $invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900)); expect($invoice->snapshot['customer']['address_lines']) ->toBe(['Beispielgasse 12/3', '1030 Wien', 'Österreich']); }); it('prints the reverse-charge note exactly when no VAT is charged', function () { // A zero-rated invoice without the note says nothing about why, which is // the one thing an auditor looks for. $customer = Customer::factory()->create([ 'vat_id' => 'DE123456789', 'vat_id_verified_at' => now(), 'vat_id_verified_value' => 'DE123456789', ]); $invoice = app(IssueInvoice::class)->forOrders($customer, paidOrders($customer, 1900)); expect($invoice->tax_cents)->toBe(0) ->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge')); });