Write an invoice for work that came off no price list
tests / pest (push) Failing after 8m11s Details
tests / assets (push) Successful in 20s Details
tests / release (push) Has been skipped Details

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 <noreply@anthropic.com>
feature/betriebsmodus v1.3.31
nexxo 2026-07-29 20:12:26 +02:00
parent f85f57152e
commit 6b8412f0c3
13 changed files with 790 additions and 19 deletions

View File

@ -1 +1 @@
1.3.30
1.3.31

View File

@ -0,0 +1,216 @@
<?php
namespace App\Livewire\Admin;
use App\Models\Customer;
use App\Models\InvoiceSeries;
use App\Models\Subscription;
use App\Services\Billing\InvoiceMath;
use App\Services\Billing\IssueInvoice;
use App\Services\Billing\TaxTreatment;
use App\Support\CompanyProfile;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Url;
use Livewire\Component;
use Throwable;
/**
* An invoice for work that was done, typed by the person who did it.
*
* Not everything we are paid for comes off a price list. Somebody asks whether
* their data can be moved into Nextcloud; we look at it, we say what it will
* cost, we do it. That is a real invoice with no order and no contract behind
* it, and until now the only way to produce one was to write it somewhere else
* which puts it outside the series, and 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: the same series and
* therefore the same consecutive number, the same tax treatment (a business in
* another member state gets its reverse charge here too), the same immutable
* snapshot, the same archive jobs.
*
* This page only writes what the operator typed. It invents no price and
* applies no rule of its own what is on the lines is what was agreed on the
* phone. Everything it shows before the button is a preview of what the
* document will say, computed by InvoiceMath, so nobody has to issue one to
* find out what it adds up to.
*/
#[Layout('layouts.admin')]
class NewInvoice extends Component
{
/**
* Who it is for. The uuid, because that is what a URL and a select carry.
*
* In the query string so a link can arrive with the recipient already
* chosen from a customer's own page, or from the support request that
* asked for the work and so a reload does not lose them.
*/
#[Url(as: 'customer')]
public string $customerUuid = '';
/** Which series draws the number. Empty means the active invoice series. */
public string $seriesId = '';
/**
* The lines, as a person types them.
*
* Amounts are held as strings in euro rather than integers in cents: this
* is a form, and asking somebody to type 12000 for a hundred and twenty
* euro is how an invoice ends up a hundred times too large. Converted once,
* on the way out.
*
* @var array<int, array{description: string, detail: string, quantity: string, unit: string, price: string}>
*/
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<int, array<string, mixed>>
*/
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(),
]);
}
}

View File

@ -154,6 +154,58 @@ final class IssueInvoice
* @param array<int, array<string, mixed>> $lines
* @param array<string, mixed> $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<int, array{description: string, details?: array<int, string>, 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,

View File

@ -15,4 +15,5 @@ return [
'col_gross' => 'Brutto',
'col_actions' => 'Aktionen',
'download' => 'PDF',
'new' => 'Rechnung schreiben',
];

41
lang/de/new_invoice.php Normal file
View File

@ -0,0 +1,41 @@
<?php
// Eine Rechnung für Arbeit, die auf keiner Preisliste steht — eine Migration,
// eine Beratung, ein Sonderfall. Geht durch dieselbe Tür wie jede andere
// Rechnung: dieselbe Serie, dieselbe fortlaufende Nummer.
return [
'title' => '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',
];

View File

@ -15,4 +15,5 @@ return [
'col_gross' => 'Gross',
'col_actions' => 'Actions',
'download' => 'PDF',
'new' => 'Write an invoice',
];

41
lang/en/new_invoice.php Normal file
View File

@ -0,0 +1,41 @@
<?php
// An invoice for work that is on no price list — a migration, an hour of
// advice, a one-off. Through the same door as every other invoice: the same
// series, the same consecutive number.
return [
'title' => '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',
];

View File

@ -41,13 +41,17 @@
Was wir tun, können Sie nachweisen: mit Protokoll, Prüfbericht und AV-Vertrag.
</p>
{{-- 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. --}}
<div class="mt-9 flex flex-wrap justify-center gap-3">
<x-ui.button href="{{ route('order') }}" variant="ink" size="lg">Jetzt buchen</x-ui.button>
<x-ui.button href="{{ route('login') }}" variant="ink" size="lg">Jetzt buchen</x-ui.button>
<x-ui.button href="#pricing" variant="secondary" size="lg">Preise ansehen</x-ui.button>
</div>
<p class="mt-4 text-sm text-muted">Fixer Preis pro Firma · keine Abrechnung pro Kopf</p>
@ -587,11 +591,11 @@
@endif
</ul>
{{-- 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. --}}
<x-ui.button href="{{ route('order') }}" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
{{-- 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. --}}
<x-ui.button href="{{ route('login') }}" :variant="$plan['recommended'] ? 'ink' : 'secondary'" class="mt-7 w-full">
{{ $plan['name'] }} buchen
</x-ui.button>
</div>
@ -790,7 +794,7 @@
</p>
<div class="mt-9 flex flex-wrap justify-center gap-3">
<a href="{{ route('order') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
<a href="{{ route('login') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
Jetzt buchen
</a>
<a href="mailto:office&#64;clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">

View File

@ -1,8 +1,20 @@
<div class="space-y-5">
<div class="animate-rise">
<div class="flex flex-wrap items-start gap-4 animate-rise">
<div>
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('invoices_admin.title') }}</h1>
<p class="mt-1 text-sm text-muted">{{ __('invoices_admin.subtitle') }}</p>
</div>
{{-- 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. --}}
<x-ui.button :href="route('admin.invoices.new')" variant="primary" class="ml-auto" wire:navigate>
<x-ui.icon name="plus" class="size-4" />{{ __('invoices_admin.new') }}
</x-ui.button>
</div>
@if (session('status'))
<x-ui.alert variant="success">{{ session('status') }}</x-ui.alert>
@endif
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-end gap-3 border-b border-line p-4">

View File

@ -0,0 +1,139 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('new_invoice.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm text-muted">{{ __('new_invoice.subtitle') }}</p>
</div>
{{-- 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 !== [])
<x-ui.alert variant="warning">
{{ __('new_invoice.missing_profile', ['fields' => collect($missing)->map(fn ($f) => __('finance.field.'.$f))->join(', ')]) }}
<a href="{{ route('admin.finance') }}" class="font-semibold underline">{{ __('new_invoice.to_finance') }}</a>
</x-ui.alert>
@endif
@error('lines')<x-ui.alert variant="error">{{ $message }}</x-ui.alert>@enderror
<div class="grid gap-5 lg:grid-cols-[1fr_320px] lg:items-start">
<div class="space-y-5">
{{-- Who and out of which series --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise">
<h2 class="font-semibold text-ink">{{ __('new_invoice.who_title') }}</h2>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="text-sm font-medium text-body" for="customer">{{ __('new_invoice.customer') }}</label>
<select id="customer" wire:model.live="customerUuid"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('new_invoice.pick_customer') }}</option>
@foreach ($customers as $c)
<option value="{{ $c->uuid }}">{{ $c->name }} · {{ $c->email }}</option>
@endforeach
</select>
@error('customerUuid')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div>
<label class="text-sm font-medium text-body" for="series">{{ __('new_invoice.series') }}</label>
<select id="series" wire:model="seriesId"
class="mt-1.5 w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-body">
<option value="">{{ __('new_invoice.series_default') }}</option>
@foreach ($series as $s)
<option value="{{ $s->id }}">{{ $s->name }}</option>
@endforeach
</select>
{{-- The whole point of routing this through the same
door: one series, one consecutive numbering, no
document written outside it. --}}
<p class="mt-1 text-xs text-muted">{{ __('new_invoice.series_hint') }}</p>
</div>
</div>
</div>
{{-- The lines. A form that IS the page (R20): this creates a record,
it does not edit one in a table row. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:60ms]">
<div class="flex flex-wrap items-center gap-3">
<h2 class="font-semibold text-ink">{{ __('new_invoice.lines_title') }}</h2>
<x-ui.button wire:click="addLine" variant="secondary" size="sm" class="ml-auto">
<x-ui.icon name="plus" class="size-4" />{{ __('new_invoice.add_line') }}
</x-ui.button>
</div>
@foreach ($lines as $i => $line)
<div wire:key="line-{{ $i }}" class="rounded-md border border-line bg-surface-2 p-4">
<div class="grid gap-3 sm:grid-cols-[1fr_88px_88px_120px_auto] sm:items-end">
<x-ui.input :name="'lines.'.$i.'.description'" wire:model.blur="lines.{{ $i }}.description"
:label="__('new_invoice.col_description')" :placeholder="__('new_invoice.description_hint')" />
<x-ui.input :name="'lines.'.$i.'.quantity'" wire:model.live.debounce.400ms="lines.{{ $i }}.quantity"
:label="__('new_invoice.col_quantity')" inputmode="decimal" />
<x-ui.input :name="'lines.'.$i.'.unit'" wire:model.blur="lines.{{ $i }}.unit"
:label="__('new_invoice.col_unit')" :placeholder="__('new_invoice.unit_hint')" />
<x-ui.input :name="'lines.'.$i.'.price'" wire:model.live.debounce.400ms="lines.{{ $i }}.price"
:label="__('new_invoice.col_price')" inputmode="decimal" />
<button type="button" wire:click="removeLine({{ $i }})"
@disabled(count($lines) <= 1)
class="mb-1.5 rounded-md p-2 text-muted transition-colors hover:text-danger disabled:opacity-40"
aria-label="{{ __('new_invoice.remove_line') }}">
<x-ui.icon name="trash-2" class="size-4" />
</button>
</div>
<div class="mt-3">
<x-ui.input :name="'lines.'.$i.'.detail'" wire:model.blur="lines.{{ $i }}.detail"
:label="__('new_invoice.col_detail')" :placeholder="__('new_invoice.detail_hint')" />
</div>
</div>
@endforeach
</div>
</div>
{{-- 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. --}}
<div class="space-y-4 rounded-lg border border-line bg-surface p-6 shadow-xs animate-rise [animation-delay:120ms] lg:sticky lg:top-6">
<h2 class="font-semibold text-ink">{{ __('new_invoice.preview_title') }}</h2>
<dl class="space-y-2 text-sm">
<div class="flex justify-between gap-3">
<dt class="text-muted">{{ __('new_invoice.net') }}</dt>
<dd class="font-mono tabular-nums text-ink">{{ Number::currency($totals['net'] / 100, in: $currency, locale: app()->getLocale()) }}</dd>
</div>
<div class="flex justify-between gap-3">
<dt class="text-muted">
{{ __('new_invoice.tax') }}
<span class="font-mono text-xs">{{ $treatment->percentLabel() }} %</span>
</dt>
<dd class="font-mono tabular-nums text-ink">{{ Number::currency($totals['tax'] / 100, in: $currency, locale: app()->getLocale()) }}</dd>
</div>
<div class="flex justify-between gap-3 border-t border-line pt-2">
<dt class="font-medium text-ink">{{ __('new_invoice.gross') }}</dt>
<dd class="font-mono font-semibold tabular-nums text-ink">{{ Number::currency($totals['gross'] / 100, in: $currency, locale: app()->getLocale()) }}</dd>
</div>
</dl>
@if ($treatment->reverseCharge)
{{-- Shown before issuing, not discovered on the PDF. --}}
<x-ui.alert variant="info">{{ __('invoice.reverse_charge') }}</x-ui.alert>
@endif
<p class="text-xs leading-relaxed text-muted">{{ __('new_invoice.final_note') }}</p>
{{-- :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. --}}
<x-ui.button wire:click="issue" variant="primary" class="w-full"
wire:loading.attr="disabled" wire:target="issue"
:disabled="$missing !== [] || $customer === null">
{{ __('new_invoice.issue') }}
</x-ui.button>
<a href="{{ route('admin.invoices') }}" class="block text-center text-xs font-semibold text-muted hover:text-ink">
{{ __('new_invoice.cancel') }}
</a>
</div>
</div>
</div>

View File

@ -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

View File

@ -0,0 +1,254 @@
<?php
use App\Livewire\Admin\NewInvoice;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\InvoiceSeries;
use App\Services\Billing\IssueInvoice;
use App\Support\CompanyProfile;
use Livewire\Livewire;
/**
* An invoice for work that is on no price list.
*
* 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 writing it somewhere else would put it outside the
* series. A numbered series with a document missing from it is worth nothing at
* an audit, which is why this goes through the same door as every other
* invoice.
*/
beforeEach(function () {
// An invoice without a registered name, address and VAT number is not a
// valid invoice, and IssueInvoice refuses to draw a number for one.
CompanyProfile::put([
'name' => '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);
});

View File

@ -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</x-ui.button>');
});