From 6b8412f0c394981c091fdba767fc2defe3627124 Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 20:12:26 +0200 Subject: [PATCH] Write an invoice for work that came off no price list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not everything we are paid for is a package. Somebody asks whether their data can be moved into Nextcloud; we look at it, we say what it costs, we do it. That is a real invoice with no order and no contract behind it, and the only way to produce one was to write it somewhere else — which puts it outside the series. A numbered series with a document missing from it is worth nothing at an audit. So it goes through the same door as every other invoice: IssueInvoice::forService() draws from the same series and therefore the same consecutive number, applies the same TaxTreatment (a verified business in another member state gets its reverse charge and the note that makes a zero-rated document lawful), freezes the same immutable snapshot and dispatches the same archive jobs. The page under Rechnungen writes what the operator typed and nothing else. Amounts are in euro because it is a form — 12,95 becomes 1295 by round(), not by a cast that would make it 1294 — and quantities go to a thousandth because hours are billed in quarters. The total beside the lines is the same InvoiceMath the document uses; a preview doing its own arithmetic would be a second answer, and the first one anybody notices differs is on a document that cannot be changed. There is no draft: a "save as draft" that draws a number is an issued invoice with a friendlier name. The website's buy buttons go to the sign-in now, not to /order. Booking belongs in the panel, where the account exists and its address has been confirmed — which matters because that address is where the finished cloud's credentials are sent. Sending an anonymous visitor at a page behind auth worked, but only via a middleware bounce off a page they never asked for. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Livewire/Admin/NewInvoice.php | 216 +++++++++++++++ app/Services/Billing/IssueInvoice.php | 52 ++++ lang/de/invoices_admin.php | 1 + lang/de/new_invoice.php | 41 +++ lang/en/invoices_admin.php | 1 + lang/en/new_invoice.php | 41 +++ resources/views/landing.blade.php | 28 +- .../views/livewire/admin/invoices.blade.php | 18 +- .../livewire/admin/new-invoice.blade.php | 139 ++++++++++ routes/admin.php | 3 + tests/Feature/Admin/ServiceInvoiceTest.php | 254 ++++++++++++++++++ tests/Feature/SelfServiceOrderTest.php | 13 +- 13 files changed, 790 insertions(+), 19 deletions(-) create mode 100644 app/Livewire/Admin/NewInvoice.php create mode 100644 lang/de/new_invoice.php create mode 100644 lang/en/new_invoice.php create mode 100644 resources/views/livewire/admin/new-invoice.blade.php create mode 100644 tests/Feature/Admin/ServiceInvoiceTest.php diff --git a/VERSION b/VERSION index 36165ba..5e1fed8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.30 +1.3.31 diff --git a/app/Livewire/Admin/NewInvoice.php b/app/Livewire/Admin/NewInvoice.php new file mode 100644 index 0000000..4dd86f5 --- /dev/null +++ b/app/Livewire/Admin/NewInvoice.php @@ -0,0 +1,216 @@ + + */ + public array $lines = []; + + public function mount(): void + { + $this->authorize('site.manage'); + + $this->addLine(); + } + + public function addLine(): void + { + $this->lines[] = [ + 'description' => '', + 'detail' => '', + 'quantity' => '1', + 'unit' => '', + 'price' => '', + ]; + } + + /** + * Remove a line, but never the last one. + * + * An invoice with no lines is not a draft state worth having — the page + * would show a total of nothing and a button that refuses. + */ + public function removeLine(int $index): void + { + if (count($this->lines) <= 1) { + return; + } + + unset($this->lines[$index]); + $this->lines = array_values($this->lines); + } + + /** + * Issue it. + * + * There is no draft and no edit afterwards, deliberately: an issued invoice + * cannot lawfully be changed, and a "save as draft" that draws a number + * would be the same thing with a friendlier name. The number is taken at + * this click and not before. + */ + public function issue(IssueInvoice $issuer): void + { + $this->authorize('site.manage'); + + $data = $this->validate([ + 'customerUuid' => 'required|string', + 'seriesId' => 'nullable|string', + 'lines' => 'required|array|min:1', + 'lines.*.description' => 'required|string|max:255', + 'lines.*.detail' => 'nullable|string|max:255', + // Down to a thousandth, because hours are billed in quarters and + // 0.25 has to survive the trip. + 'lines.*.quantity' => 'required|numeric|min:0.001|max:100000', + 'lines.*.unit' => 'nullable|string|max:32', + // Negative allowed: a credit line on an ordinary invoice is how a + // goodwill deduction is shown without a second document. + 'lines.*.price' => 'required|numeric|min:-1000000|max:1000000', + ]); + + $customer = Customer::query()->where('uuid', $data['customerUuid'])->first(); + + if ($customer === null) { + $this->addError('customerUuid', __('new_invoice.no_customer')); + + return; + } + + $series = $data['seriesId'] === '' + ? null + : InvoiceSeries::query()->whereKey($data['seriesId'])->first(); + + try { + $invoice = $issuer->forService( + customer: $customer, + lines: $this->linesForIssue(), + // The currency the catalogue is priced in, not a second + // setting: a service invoice in another currency than the + // packages would be a second answer to a question that already + // has one. + currency: Subscription::catalogueCurrency(), + series: $series, + ); + } catch (Throwable $e) { + // The company profile being incomplete is the usual one, and it is + // a refusal by design: an invoice without a registered name, an + // address or a VAT number is not a valid invoice, and issuing it + // would burn a number that can never be reused. + $this->addError('lines', $e->getMessage()); + + return; + } + + $this->redirectRoute('admin.invoices', navigate: true); + + session()->flash('status', __('new_invoice.issued', ['number' => $invoice->number])); + } + + /** + * The lines in the shape IssueInvoice wants them. + * + * round(), not a cast: (int) (12.95 * 100) is 1294 on a binary float, and + * an invoice a cent short of what was typed is the kind of error nobody + * finds until a customer does. + * + * @return array> + */ + private function linesForIssue(): array + { + return array_map(fn (array $line) => [ + 'description' => trim($line['description']), + 'details' => array_filter([trim((string) $line['detail'])]), + 'quantity_milli' => (int) round(((float) $line['quantity']) * 1000), + 'unit' => trim((string) $line['unit']), + 'unit_net_cents' => (int) round(((float) $line['price']) * 100), + ], $this->lines); + } + + public function render() + { + $this->authorize('site.manage'); + + $customer = $this->customerUuid === '' + ? null + : Customer::query()->where('uuid', $this->customerUuid)->first(); + + // The same treatment the document will carry, shown before it is + // issued: an operator writing an invoice for a business in another + // member state should see "reverse charge, 0 %" on the page, not + // discover it on the PDF. + $treatment = TaxTreatment::for($customer); + $rate = (int) round($treatment->rate * 10000); + + return view('livewire.admin.new-invoice', [ + 'customers' => Customer::query() + ->whereNull('closed_at') + ->orderBy('name') + ->get(['id', 'uuid', 'name', 'email']), + 'series' => InvoiceSeries::query()->where('kind', 'invoice')->orderBy('name')->get(), + 'customer' => $customer, + 'treatment' => $treatment, + 'totals' => InvoiceMath::totals($this->linesForIssue(), null, $rate), + 'currency' => Subscription::catalogueCurrency(), + // Said on the page rather than only thrown at the click: the + // company profile is somebody else's tab, and finding out at the + // last step costs the whole form. + 'missing' => CompanyProfile::missingForInvoicing(), + ]); + } +} diff --git a/app/Services/Billing/IssueInvoice.php b/app/Services/Billing/IssueInvoice.php index 575106d..f78e88c 100644 --- a/app/Services/Billing/IssueInvoice.php +++ b/app/Services/Billing/IssueInvoice.php @@ -154,6 +154,58 @@ final class IssueInvoice * @param array> $lines * @param array $attributes what this document is FOR */ + /** + * One invoice for work that was done, typed by a person. + * + * Not everything we are paid for is a package. Somebody asks whether their + * data can be moved into Nextcloud, we look at it, we say yes, we do it — + * and that is an invoice with no order and no contract behind it, because + * nobody bought it from a price list. + * + * It goes through the same door as every other invoice on purpose: the same + * series, so the number stays consecutive; the same tax treatment, so a + * business in another member state gets its reverse charge here too; the + * same immutable snapshot and the same archive jobs. A second way of making + * invoices would be a second numbering — and a series with a document + * missing from it is worth nothing at an audit. + * + * Lines arrive as a person typed them. Nothing here invents a price: the + * caller is the operator, and what they wrote is what the customer agreed + * to on the phone. + * + * @param array, quantity_milli: int, unit?: string, unit_net_cents: int}> $lines + */ + public function forService( + Customer $customer, + array $lines, + string $currency = 'EUR', + ?InvoiceSeries $series = null, + ): Invoice { + if ($lines === []) { + throw new RuntimeException('Refusing to issue an invoice with no lines on it.'); + } + + return $this->issue( + customer: $customer, + // Normalised here rather than trusted: the optional keys are + // optional to the caller, and InvoiceMath and the renderer both + // read them without asking whether they are there. + lines: array_map(fn (array $line) => [ + 'description' => (string) $line['description'], + 'details' => array_values(array_filter((array) ($line['details'] ?? []))), + 'quantity_milli' => (int) $line['quantity_milli'], + 'unit' => (string) ($line['unit'] ?? ''), + 'unit_net_cents' => (int) $line['unit_net_cents'], + ], array_values($lines)), + currency: strtoupper($currency), + series: $series, + // No order and no contract: this document points at neither, and + // saying so with a null is more honest than inventing an Order + // nobody placed just to have something to point at. + attributes: [], + ); + } + private function issue( Customer $customer, array $lines, diff --git a/lang/de/invoices_admin.php b/lang/de/invoices_admin.php index d997d0f..f889f96 100644 --- a/lang/de/invoices_admin.php +++ b/lang/de/invoices_admin.php @@ -15,4 +15,5 @@ return [ 'col_gross' => 'Brutto', 'col_actions' => 'Aktionen', 'download' => 'PDF', + 'new' => 'Rechnung schreiben', ]; diff --git a/lang/de/new_invoice.php b/lang/de/new_invoice.php new file mode 100644 index 0000000..b01f608 --- /dev/null +++ b/lang/de/new_invoice.php @@ -0,0 +1,41 @@ + 'Rechnung schreiben', + 'subtitle' => 'Für Leistungen, die nicht aus einem Paket kommen — Datenübernahme, Beratung, Sonderarbeiten. Sie bekommt die nächste Nummer aus derselben fortlaufenden Serie wie jede andere Rechnung.', + + 'who_title' => 'Empfänger', + 'customer' => 'Kunde', + 'pick_customer' => 'Kunden wählen …', + 'series' => 'Nummernkreis', + 'series_default' => 'Aktiver Rechnungskreis', + 'series_hint' => 'Die Nummer wird beim Ausstellen gezogen — nicht vorher, und nie zweimal.', + + 'lines_title' => 'Positionen', + 'add_line' => 'Position', + 'remove_line' => 'Position entfernen', + 'col_description' => 'Leistung', + 'col_detail' => 'Zusatz (optional)', + 'col_quantity' => 'Menge', + 'col_unit' => 'Einheit', + 'col_price' => 'Einzelpreis netto', + 'description_hint' => 'z. B. Datenübernahme aus bestehendem System', + 'detail_hint' => 'z. B. Migration von ownCloud 10, 42 GB, inkl. Prüfprotokoll', + 'unit_hint' => 'Std.', + + 'preview_title' => 'Ergibt', + 'net' => 'Netto', + 'tax' => 'Umsatzsteuer', + 'gross' => 'Gesamt', + 'final_note' => 'Beim Ausstellen wird die Nummer gezogen und das Dokument festgeschrieben. Eine ausgestellte Rechnung lässt sich nicht mehr ändern — ein Fehler wird mit einer Storno-Rechnung und einer neuen korrigiert.', + 'issue' => 'Rechnung ausstellen', + 'cancel' => 'Abbrechen', + + 'issued' => 'Rechnung :number wurde ausgestellt.', + 'no_customer' => 'Diesen Kunden gibt es nicht mehr.', + 'missing_profile' => 'Vor der ersten Rechnung fehlen noch Firmenangaben: :fields.', + 'to_finance' => 'Zu den Firmendaten', +]; diff --git a/lang/en/invoices_admin.php b/lang/en/invoices_admin.php index 9098cac..77a1f39 100644 --- a/lang/en/invoices_admin.php +++ b/lang/en/invoices_admin.php @@ -15,4 +15,5 @@ return [ 'col_gross' => 'Gross', 'col_actions' => 'Actions', 'download' => 'PDF', + 'new' => 'Write an invoice', ]; diff --git a/lang/en/new_invoice.php b/lang/en/new_invoice.php new file mode 100644 index 0000000..db0d48e --- /dev/null +++ b/lang/en/new_invoice.php @@ -0,0 +1,41 @@ + 'Write an invoice', + 'subtitle' => 'For work that does not come out of a package — data migration, advice, one-off jobs. It takes the next number from the same consecutive series as every other invoice.', + + 'who_title' => 'Recipient', + 'customer' => 'Customer', + 'pick_customer' => 'Choose a customer …', + 'series' => 'Number series', + 'series_default' => 'Active invoice series', + 'series_hint' => 'The number is drawn when it is issued — not before, and never twice.', + + 'lines_title' => 'Lines', + 'add_line' => 'Line', + 'remove_line' => 'Remove line', + 'col_description' => 'Work', + 'col_detail' => 'Detail (optional)', + 'col_quantity' => 'Quantity', + 'col_unit' => 'Unit', + 'col_price' => 'Unit price net', + 'description_hint' => 'e.g. migration from an existing system', + 'detail_hint' => 'e.g. from ownCloud 10, 42 GB, incl. verification log', + 'unit_hint' => 'hrs', + + 'preview_title' => 'Comes to', + 'net' => 'Net', + 'tax' => 'VAT', + 'gross' => 'Total', + 'final_note' => 'Issuing draws the number and freezes the document. An issued invoice cannot be changed — a mistake is corrected with a cancellation and a new one.', + 'issue' => 'Issue invoice', + 'cancel' => 'Cancel', + + 'issued' => 'Invoice :number has been issued.', + 'no_customer' => 'That customer no longer exists.', + 'missing_profile' => 'Some company details are still missing before the first invoice: :fields.', + 'to_finance' => 'To the company details', +]; diff --git a/resources/views/landing.blade.php b/resources/views/landing.blade.php index 3e0a0df..89cf2ff 100644 --- a/resources/views/landing.blade.php +++ b/resources/views/landing.blade.php @@ -41,13 +41,17 @@ Was wir tun, können Sie nachweisen: mit Protokoll, Prüfbericht und AV-Vertrag.

- {{-- Buying is the primary action now. Every path on this page used to - end in "anfragen" — a mailto: link and an afternoon of somebody's - time per customer, for a product whose whole point is that the - machine does the work. Asking is still here, one step quieter, - because a question is a question and not a sale. --}} + {{-- Straight to the sign-in, not to a booking page behind auth. The + difference matters: /order redirects an anonymous visitor to the + login anyway, but through a middleware redirect that leaves them on + a page they never asked for. Buying happens in the panel, where + everything already works — the website's job is to get them there. + + Every path on this page used to end in "anfragen" instead: a + mailto: link and an afternoon of somebody's time per customer, for + a product whose whole point is that the machine does the work. --}}
- Jetzt buchen + Jetzt buchen Preise ansehen

Fixer Preis pro Firma · keine Abrechnung pro Kopf

@@ -587,11 +591,11 @@ @endif - {{-- To the booking page, not to an inbox. Signing in comes - first (the route is behind auth), which is what makes - the credentials for the finished cloud go to an address - somebody has confirmed. --}} - + {{-- To the sign-in, not to an inbox. Booking happens in the + panel: the account has to exist and its address has to + be confirmed before money moves, because that address is + where the finished cloud's credentials are sent. --}} + {{ $plan['name'] }} buchen @@ -790,7 +794,7 @@

- + Jetzt buchen diff --git a/resources/views/livewire/admin/invoices.blade.php b/resources/views/livewire/admin/invoices.blade.php index e77256b..9103c20 100644 --- a/resources/views/livewire/admin/invoices.blade.php +++ b/resources/views/livewire/admin/invoices.blade.php @@ -1,9 +1,21 @@
-
-

{{ __('invoices_admin.title') }}

-

{{ __('invoices_admin.subtitle') }}

+
+
+

{{ __('invoices_admin.title') }}

+

{{ __('invoices_admin.subtitle') }}

+
+ {{-- The one thing that can be created here. The list itself stays + read-only: an issued invoice is corrected by a cancellation and a + new document, never by an edit button. --}} + + {{ __('invoices_admin.new') }} +
+ @if (session('status')) + {{ session('status') }} + @endif +
diff --git a/resources/views/livewire/admin/new-invoice.blade.php b/resources/views/livewire/admin/new-invoice.blade.php new file mode 100644 index 0000000..d485a54 --- /dev/null +++ b/resources/views/livewire/admin/new-invoice.blade.php @@ -0,0 +1,139 @@ +
+
+

{{ __('new_invoice.title') }}

+

{{ __('new_invoice.subtitle') }}

+
+ + {{-- Said here rather than only thrown at the click: the company profile is + another page, and finding out at the last step costs the whole form. --}} + @if ($missing !== []) + + {{ __('new_invoice.missing_profile', ['fields' => collect($missing)->map(fn ($f) => __('finance.field.'.$f))->join(', ')]) }} +
{{ __('new_invoice.to_finance') }} + + @endif + + @error('lines'){{ $message }}@enderror + +
+
+ {{-- Who and out of which series --}} +
+

{{ __('new_invoice.who_title') }}

+ +
+
+ + + @error('customerUuid')

{{ $message }}

@enderror +
+ +
+ + + {{-- The whole point of routing this through the same + door: one series, one consecutive numbering, no + document written outside it. --}} +

{{ __('new_invoice.series_hint') }}

+
+
+
+ + {{-- The lines. A form that IS the page (R20): this creates a record, + it does not edit one in a table row. --}} +
+
+

{{ __('new_invoice.lines_title') }}

+ + {{ __('new_invoice.add_line') }} + +
+ + @foreach ($lines as $i => $line) +
+
+ + + + + +
+
+ +
+
+ @endforeach +
+
+ + {{-- What the document will say, before it exists. Computed by the same + InvoiceMath the invoice itself uses — a preview doing its own + arithmetic would be a second answer, and the first one anybody + notices differs is on a document that cannot be changed. --}} +
+

{{ __('new_invoice.preview_title') }}

+ +
+
+
{{ __('new_invoice.net') }}
+
{{ Number::currency($totals['net'] / 100, in: $currency, locale: app()->getLocale()) }}
+
+
+
+ {{ __('new_invoice.tax') }} + {{ $treatment->percentLabel() }} % +
+
{{ Number::currency($totals['tax'] / 100, in: $currency, locale: app()->getLocale()) }}
+
+
+
{{ __('new_invoice.gross') }}
+
{{ Number::currency($totals['gross'] / 100, in: $currency, locale: app()->getLocale()) }}
+
+
+ + @if ($treatment->reverseCharge) + {{-- Shown before issuing, not discovered on the PDF. --}} + {{ __('invoice.reverse_charge') }} + @endif + +

{{ __('new_invoice.final_note') }}

+ + {{-- :disabled, not @disabled: the directive compiles to a raw + `if … echo … endif` and a Blade COMPONENT tag puts that inside + its attribute bag, where it is a PHP syntax error rather than an + attribute. On a plain element (the line's own remove button) it + is fine. --}} + + {{ __('new_invoice.issue') }} + + + + {{ __('new_invoice.cancel') }} + +
+
+
diff --git a/routes/admin.php b/routes/admin.php index e9164ea..186931c 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -42,6 +42,9 @@ Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/finance', Admin\Finance::class)->name('finance'); Route::get('/invoices', Admin\Invoices::class)->name('invoices'); +// Writing one by hand, for work that came off no price list. Same series, same +// numbering — see Admin\NewInvoice. +Route::get('/invoices/new', Admin\NewInvoice::class)->name('invoices.new'); // The PDF, rendered on demand from the frozen document. A plain route rather // than a Livewire action: a download is a download, and routing one through a diff --git a/tests/Feature/Admin/ServiceInvoiceTest.php b/tests/Feature/Admin/ServiceInvoiceTest.php new file mode 100644 index 0000000..d915627 --- /dev/null +++ b/tests/Feature/Admin/ServiceInvoiceTest.php @@ -0,0 +1,254 @@ + 'CluPilot GmbH', + 'address' => 'Musterstraße 1', + 'postcode' => '1010', + 'city' => 'Wien', + 'vat_id' => 'ATU12345678', + ]); + + InvoiceSeries::query()->firstOrCreate( + ['kind' => 'invoice'], + ['name' => 'Rechnungen', 'prefix' => 'RE', 'active' => true], + ); +}); + +it('issues a service invoice out of the same consecutive series as every other', function () { + // The whole point. A second way of making invoices would be a second + // numbering, and two numberings are two series with holes in them. + $customer = Customer::factory()->create(); + + $first = app(IssueInvoice::class)->forService($customer, [[ + 'description' => 'Datenübernahme aus bestehendem System', + 'quantity_milli' => 4000, + 'unit' => 'Std.', + 'unit_net_cents' => 12000, + ]]); + + $second = app(IssueInvoice::class)->forService($customer, [[ + 'description' => 'Beratung', + 'quantity_milli' => 1000, + 'unit_net_cents' => 9000, + ]]); + + expect($second->number_sequence)->toBe($first->number_sequence + 1) + ->and($second->invoice_series_id)->toBe($first->invoice_series_id); +}); + +it('bills quantity times price, with the VAT the customer would be charged anyway', function () { + $customer = Customer::factory()->create(); + + $invoice = app(IssueInvoice::class)->forService($customer, [[ + 'description' => 'Migration', + 'quantity_milli' => 4000, // four hours + 'unit' => 'Std.', + 'unit_net_cents' => 12000, // 120,00 € each + ]]); + + expect($invoice->net_cents)->toBe(48000) + ->and($invoice->tax_cents)->toBe(9600) // 20 % + ->and($invoice->gross_cents)->toBe(57600); +}); + +it('applies reverse charge to a verified EU business, like any other invoice', function () { + // Not a special case for service work — the treatment is the customer's, + // and this document goes through the same TaxTreatment as the rest. + // Verified means the VALUE was checked, not that a flag was set: editing + // the field must not inherit an old confirmation. + $customer = Customer::factory()->create([ + 'vat_id' => 'DE123456789', + 'vat_id_verified_at' => now(), + 'vat_id_verified_value' => 'DE123456789', + ]); + + $invoice = app(IssueInvoice::class)->forService($customer, [[ + 'description' => 'Migration', + 'quantity_milli' => 1000, + 'unit_net_cents' => 50000, + ]]); + + expect($invoice->tax_cents)->toBe(0) + ->and($invoice->gross_cents)->toBe(50000) + // 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. + ->and($invoice->snapshot['meta']['closing'])->toBe(__('invoice.reverse_charge')); +}); + +it('freezes what the document says, so it still says it when the settings change', function () { + $customer = Customer::factory()->create(['name' => 'Kanzlei Berger']); + + $invoice = app(IssueInvoice::class)->forService($customer, [[ + 'description' => 'Datenübernahme', + 'quantity_milli' => 1000, + 'unit_net_cents' => 30000, + ]]); + + CompanyProfile::put(['name' => 'Ein ganz anderer Name']); + $customer->update(['name' => 'Auch anders']); + + $snapshot = $invoice->fresh()->snapshot; + + expect($snapshot['issuer']['name'])->toBe('CluPilot GmbH') + ->and($snapshot['customer']['name'])->toBe('Kanzlei Berger') + ->and($snapshot['lines'][0]['description'])->toBe('Datenübernahme'); +}); + +it('refuses to draw a number for an invoice with nothing on it', function () { + // A number taken and thrown away is a gap in a series that must not have + // one. + $customer = Customer::factory()->create(); + + expect(fn () => app(IssueInvoice::class)->forService($customer, [])) + ->toThrow(RuntimeException::class); +}); + +// ---- The page an operator actually uses ---- + +it('writes the invoice the operator typed', function () { + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->set('customerUuid', $customer->uuid) + ->set('lines.0.description', 'Datenübernahme aus ownCloud') + ->set('lines.0.detail', '42 GB, inkl. Prüfprotokoll') + ->set('lines.0.quantity', '4') + ->set('lines.0.unit', 'Std.') + ->set('lines.0.price', '120') + ->call('issue') + ->assertRedirect(route('admin.invoices')); + + $invoice = Invoice::query()->latest('id')->first(); + + expect($invoice)->not->toBeNull() + ->and($invoice->customer_id)->toBe($customer->id) + ->and($invoice->net_cents)->toBe(48000) + ->and($invoice->snapshot['lines'][0]['description'])->toBe('Datenübernahme aus ownCloud') + ->and($invoice->snapshot['lines'][0]['details'][0])->toBe('42 GB, inkl. Prüfprotokoll'); +}); + +it('reads a price in euro the way a person types it', function () { + // The form is in euro and the document is in cents. (int) (12.95 * 100) is + // 1294 on a binary float, and an invoice a cent short of what was typed is + // the kind of thing nobody finds until a customer does. + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->set('customerUuid', $customer->uuid) + ->set('lines.0.description', 'Beratung') + ->set('lines.0.quantity', '1') + ->set('lines.0.price', '12.95') + ->call('issue'); + + expect(Invoice::query()->latest('id')->first()->net_cents)->toBe(1295); +}); + +it('bills a quarter of an hour without losing it', function () { + // Quantities are held to a thousandth because hours are billed in quarters. + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->set('customerUuid', $customer->uuid) + ->set('lines.0.description', 'Rückfrage') + ->set('lines.0.quantity', '0.25') + ->set('lines.0.price', '120') + ->call('issue'); + + expect(Invoice::query()->latest('id')->first()->net_cents)->toBe(3000); +}); + +it('adds up several lines the way the finished document will', function () { + $customer = Customer::factory()->create(); + + $page = Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->set('customerUuid', $customer->uuid) + ->set('lines.0.description', 'Migration') + ->set('lines.0.quantity', '4') + ->set('lines.0.price', '120') + ->call('addLine') + ->set('lines.1.description', 'Nacharbeit') + ->set('lines.1.quantity', '2') + ->set('lines.1.price', '90'); + + // The preview is the same arithmetic the invoice uses, not a second one. + $page->assertViewHas('totals', fn (array $t) => $t['net'] === 66000 && $t['gross'] === 79200); + + $page->call('issue'); + + expect(Invoice::query()->latest('id')->first()->gross_cents)->toBe(79200); +}); + +it('refuses a line with no work on it and no price', function () { + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->set('customerUuid', $customer->uuid) + ->call('issue') + ->assertHasErrors(['lines.0.description', 'lines.0.price']); + + expect(Invoice::query()->count())->toBe(0); +}); + +it('never removes the last line, so the form cannot end up with nothing to issue', function () { + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->call('removeLine', 0) + ->assertCount('lines', 1); +}); + +it('keeps the page to operators who may touch money', function () { + Livewire::actingAs(operator('Support'), 'operator') + ->test(NewInvoice::class) + ->assertForbidden(); +}); + +it('says what is missing before the form is filled in, not after', function () { + // The company profile is another page. Finding out at the last click costs + // the whole form. + CompanyProfile::put(['vat_id' => '']); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->assertViewHas('missing', fn (array $missing) => in_array('vat_id', $missing, true)) + ->assertSee(__('new_invoice.to_finance')); +}); + +it('arrives with the recipient already chosen when a link says who', function () { + // From a customer's own page, or from the support request that asked for + // the work: the operator should not have to find the name again in a + // dropdown they just came from. + $customer = Customer::factory()->create(); + + Livewire::withQueryParams(['customer' => $customer->uuid]) + ->actingAs(operator('Owner'), 'operator') + ->test(NewInvoice::class) + ->assertSet('customerUuid', $customer->uuid) + ->assertViewHas('customer', fn ($c) => $c?->id === $customer->id); +}); diff --git a/tests/Feature/SelfServiceOrderTest.php b/tests/Feature/SelfServiceOrderTest.php index ccf2d40..5285aa7 100644 --- a/tests/Feature/SelfServiceOrderTest.php +++ b/tests/Feature/SelfServiceOrderTest.php @@ -156,11 +156,18 @@ it('promises delivery on the booking page from the estate, like everywhere else' ->assertDontSee(__('delivery.immediate')); }); -it('points the website buttons at the booking page rather than an inbox', function () { +it('points the website buttons at the sign-in rather than an inbox', function () { + // Booking happens in the panel, where everything already works. The website + // does not need a second checkout — it needs to get somebody to the door. $content = $this->get('/')->assertOk()->getContent(); - expect($content)->toContain(route('order')) - // The old shape: every card ending in a jump to a mailto: block. + expect($content)->toContain(route('login')) + // Not straight at /order: that is behind auth, so an anonymous visitor + // would be bounced by a middleware redirect from a page they never + // asked for. + ->and($content)->not->toContain('href="'.route('order').'"') + // And not back to the old shape: every card ending in a jump to a + // mailto: block. ->and($content)->not->toContain('anfragen'); });