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')); +});