From cd54212b34458b7ae681af0cb550c1cd6f42e47c Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 02:09:05 +0200 Subject: [PATCH] Issue an invoice from what somebody bought, and freeze it there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One invoice per purchase rather than per order: a customer who buys a plan and two add-ons in one go has bought once, and three invoices for one purchase is three times the paperwork for the same money. Each order becomes a line, which is also how the add-ons get listed individually. Everything the document says is copied into the snapshot at the moment the number is assigned — issuer, recipient, lines, rate, the reason for the rate. A test changes the company name and the customer name afterwards and asserts the invoice still says what it said. That is the whole reason no PDF is stored. The number and the invoice commit in one transaction. A number taken and then lost to a failure is a gap in a series that must not have one, and the refusal test asserts that a rejected invoice leaves the counter where it was. It refuses outright until the company details are complete. 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. VAT comes from TaxTreatment, which already existed and already handles a verified EU VAT ID correctly — including the reverse-charge note, without which a zero-rated invoice says nothing about why no VAT was charged. The billing address is carried as the lines it was written as. It is one free-text field today, and guessing which line is the postcode would put it where the street belongs, on a document nobody can correct afterwards. Co-Authored-By: Claude Opus 5 --- app/Services/Billing/IssueInvoice.php | 157 +++++++++++++++++++++ lang/de/invoice.php | 6 + lang/en/invoice.php | 6 + resources/views/pdf/invoice.blade.php | 6 +- tests/Feature/Billing/IssueInvoiceTest.php | 127 +++++++++++++++++ 5 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 app/Services/Billing/IssueInvoice.php create mode 100644 tests/Feature/Billing/IssueInvoiceTest.php diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php new file mode 100644 index 0000000..b1afe13 --- /dev/null +++ b/app/Services/Billing/IssueInvoice.php @@ -0,0 +1,157 @@ + $orders + */ + public function forOrders(Customer $customer, Collection $orders, ?InvoiceSeries $series = null): Invoice + { + if ($orders->isEmpty()) { + throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); + } + + $missing = CompanyProfile::missingForInvoicing(); + + if ($missing !== []) { + // Refusing is the correct outcome. 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 reused. + throw new RuntimeException( + 'Refusing to issue an invoice before the company details are complete: '.implode(', ', $missing) + ); + } + + $series ??= InvoiceSeries::query()->where('kind', 'invoice')->where('active', true)->firstOrFail(); + + $treatment = TaxTreatment::for($customer); + $rate = (int) round($treatment->rate * 10000); + + $lines = $orders->map(fn (Order $order) => [ + 'description' => $order->label(), + 'details' => array_values(array_filter([ + $order->isRecurring() ? __('invoice.line_recurring') : __('invoice.line_once'), + ])), + 'quantity_milli' => 1000, + 'unit' => '', + 'unit_net_cents' => (int) $order->amount_cents, + ])->all(); + + $totals = InvoiceMath::totals($lines, null, $rate) + ['rate_basis_points' => $rate]; + + $snapshot = [ + 'issuer' => CompanyProfile::all(), + 'customer' => $this->customerBlock($customer), + 'lines' => $lines, + 'totals' => $totals, + 'meta' => $this->meta($customer, $treatment), + ]; + + return DB::transaction(function () use ($series, $customer, $orders, $snapshot, $totals) { + [$number, $sequence, $year] = $this->numbers->next($series); + + // The number is only true once it is on the document, so both are + // written inside the same transaction the counter was advanced in. + $snapshot['meta']['number'] = $number; + + return Invoice::create([ + 'invoice_series_id' => $series->id, + 'customer_id' => $customer->id, + 'order_id' => $orders->first()->id, + 'number' => $number, + 'number_year' => $year, + 'number_sequence' => $sequence, + 'issued_on' => now()->toDateString(), + 'due_on' => now()->addDays((int) CompanyProfile::get('payment_days', 14))->toDateString(), + 'snapshot' => $snapshot, + 'net_cents' => $totals['net'], + 'tax_cents' => $totals['tax'], + 'gross_cents' => $totals['gross'], + 'currency' => strtoupper((string) ($orders->first()->currency ?: 'EUR')), + ]); + }); + } + + /** + * The recipient, as the document will show them. + * + * The billing address is one free-text field today, so it is carried as + * lines rather than pulled apart into street and city here — guessing which + * line is which would put a postcode where a street belongs on a document + * nobody can correct afterwards. + * + * @return array + */ + private function customerBlock(Customer $customer): array + { + $address = trim((string) $customer->billing_address); + + return [ + 'name' => (string) $customer->name, + 'address_lines' => $address === '' + ? [] + : array_values(array_filter(array_map('trim', preg_split('/\R/', $address) ?: []))), + 'vat_id' => (string) $customer->vat_id, + 'email' => (string) $customer->email, + ]; + } + + /** @return array */ + private function meta(Customer $customer, TaxTreatment $treatment): array + { + $days = (int) CompanyProfile::get('payment_days', 14); + + return [ + 'title' => __('invoice.title'), + 'number' => '', // filled inside the transaction, once it is real + 'customer_number' => 'KN-'.str_pad((string) $customer->id, 4, '0', STR_PAD_LEFT), + 'issued_on' => now()->local()->format('d.m.Y'), + 'due_on' => now()->local()->addDays($days)->format('d.m.Y'), + 'currency' => 'EUR', + 'salutation' => __('invoice.salutation'), + 'intro' => __('invoice.intro'), + 'adjustment_label' => __('invoice.adjustment'), + 'payment_terms' => trim((string) CompanyProfile::get('payment_terms')) !== '' + ? (string) CompanyProfile::get('payment_terms') + : __('invoice.payment_default', ['days' => $days]), + // The note that makes a zero-rated invoice lawful. Without it the + // document says nothing about why no VAT was charged, which is the + // one thing an auditor looks for. + 'closing' => $treatment->reverseCharge ? __('invoice.reverse_charge') : null, + ]; + } +} diff --git a/lang/de/invoice.php b/lang/de/invoice.php index 315afba..a66585d 100644 --- a/lang/de/invoice.php +++ b/lang/de/invoice.php @@ -25,5 +25,11 @@ return [ 'tax' => 'Umsatzsteuer :rate %', 'gross' => 'Gesamtsumme', + 'line_recurring' => 'monatlich', + 'line_once' => 'einmalig', + 'salutation' => 'Sehr geehrte Damen und Herren,', + 'intro' => 'wir erlauben uns, für die im Folgenden aufgeführten Leistungen in Rechnung zu stellen.', + 'payment_default' => 'Zahlbar ohne Abzug innerhalb von :days Tagen auf das unten angeführte Konto.', + 'reverse_charge' => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge). Die Umsatzsteuer ist vom Leistungsempfänger zu entrichten.', 'payment_title' => 'Zahlungsbedingungen', ]; diff --git a/lang/en/invoice.php b/lang/en/invoice.php index 0b0bfb5..b442d8d 100644 --- a/lang/en/invoice.php +++ b/lang/en/invoice.php @@ -25,5 +25,11 @@ return [ 'tax' => 'VAT :rate %', 'gross' => 'Total', + 'line_recurring' => 'monthly', + 'line_once' => 'one-off', + 'salutation' => 'Dear Sir or Madam,', + 'intro' => 'we are pleased to invoice the services listed below.', + 'payment_default' => 'Payable in full within :days days to the account shown below.', + 'reverse_charge' => 'Reverse charge: VAT is to be accounted for by the recipient of the service.', 'payment_title' => 'Payment terms', ]; diff --git a/resources/views/pdf/invoice.blade.php b/resources/views/pdf/invoice.blade.php index 5f7f3c4..c2abb82 100644 --- a/resources/views/pdf/invoice.blade.php +++ b/resources/views/pdf/invoice.blade.php @@ -35,7 +35,11 @@ {{-- No leading whitespace inside these cells. TCPDF prints it: every indented line came out indented, and only the last one — which had none — sat flush against the margin. --}} - {{ $customer['name'] ?? '' }}
{{ $customer['address'] ?? '' }}
{{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}
{{ $customer['country'] ?? '' }}@if (! empty($customer['vat_id']))
{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }}@endif +{{-- Address lines where the record keeps one free-text field, structured + fields where it keeps them apart. Guessing which free-text line is the + postcode would put it where the street belongs, on a document nobody can + correct afterwards. --}} + {{ $customer['name'] ?? '' }}@if (! empty($customer['address_lines']))@foreach ($customer['address_lines'] as $addressLine)
{{ $addressLine }}@endforeach@else
{{ $customer['address'] ?? '' }}
{{ trim(($customer['postcode'] ?? '').' '.($customer['city'] ?? '')) }}
{{ $customer['country'] ?? '' }}@endif@if (! empty($customer['vat_id']))
{{ __('invoice.customer_vat') }}: {{ $customer['vat_id'] }}@endif diff --git a/tests/Feature/Billing/IssueInvoiceTest.php b/tests/Feature/Billing/IssueInvoiceTest.php new file mode 100644 index 0000000..d510978 --- /dev/null +++ b/tests/Feature/Billing/IssueInvoiceTest.php @@ -0,0 +1,127 @@ + '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')); +});