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