An invoice is a tax document, so two things drive the shape of this.
It is frozen at issue. Everything the finished document says — issuer address,
VAT number, bank details, customer address, every line, the rate, the totals —
is copied into the invoice row when the number is assigned. The PDF is rendered
from that and never stored, which is what was asked for; rendering it from
today's settings instead would have been simpler and wrong, because an address
changed in 2028 would rewrite an invoice from 2026. The width of this table IS
the feature.
Numbers are gapless and ascending, which is a legal requirement rather than a
preference, and that is why none of it is derived from the invoices table.
"Highest number plus one" hands the same number to two sales that land together
and hands a used number back out after a row is removed — a number already
printed on a document somebody holds. The counter is a column, read and advanced
under a row lock inside the caller's transaction, and taking one outside a
transaction throws rather than quietly working.
Four Rechnungskreise seeded — Rechnung, Gutschrift, Storno, Anzahlung — each
with its own prefix and its own counter, because a credit note is not an
invoice with a minus sign in front of it. Seeded rather than left to the
operator: an installation with no series cannot issue anything, and discovering
that at the first sale is the worst possible moment.
TCPDF added, for the renderer that comes next.
There is deliberately no test for the outside-a-transaction guard, and a note
where it would have been: RefreshDatabase opens a transaction around every
test, so the guard cannot fire and a green test for it would be green for a
reason that has nothing to do with the guard.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reworked after a design consultation with Codex, which pushed back on my first
proposal in three useful ways.
Ownership and rights:
- vpn.manage split into vpn.view.all and vpn.manage.all. Seeing an access is
not managing it, and neither is holding its private key.
- Record-level rules live in VpnPeerPolicy, not in permissions: an access
belongs to a person, and ownership is what grants sight of it. Every operator
reaches the page, but sees only their own unless they may see all.
- Issuing an access is not self-service — it reaches the management network, so
it needs vpn.manage.all even for oneself.
- New Developer role: sees everything, manages nothing. Writing code does not
imply authority over other people's access.
- kind (staff|host|system) replaces a null user_id that had to mean two
different things at once.
Config storage, opt-in per access:
- Downloading is owner-only — explicitly NOT for view.all or manage.all. An
admin who needs access revokes this one and issues their own, which keeps the
record of who holds what honest.
- The password is asked on EVERY retrieval, rate-limited. Laravel's
password.confirm keeps a 15-minute session stamp, which would authorise
unlimited later downloads from an unattended browser.
- Stored under VPN_CONFIG_KEY, not APP_KEY: a leaked application key must not
also hand over the management network. Purged on revocation and when a staff
member is revoked — console taken away, tunnel left open was the worst case.
- The plaintext never enters a Livewire snapshot (the page polls every five
seconds); the component carries an opaque handle instead.
Also: copy button works without HTTPS again (navigator.clipboard only exists in
a secure context, so over http it silently did nothing), plus config download
as a file and a QR code for the mobile apps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 5 operator roles (Owner/Admin/Support/Billing/Read-only) seeded via migration
with a capability catalogue; app checks capabilities via Gate, never role names
- every mutating admin action authorizes server-side (hosts/datacenters/customers/
impersonate/provisioning); is_admin reads migrated to console.view / isOperator()
- admin /settings: own account + Owner-only staff invite/role/revoke with
last-owner, self-role and customer-collision guards (transactional)
- sidebar 'zum Kundenportal' link replaced with Settings
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>