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 buchenPreise 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 @@
+
+
+ {{-- 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. --}}
+
+
+ @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');
});