Commit Graph

4 Commits (feat/plan-reopen)

Author SHA1 Message Date
nexxo c71b2c362b Skip the invoice for a full gift, keep it out of revenue, hide its price
IssueInvoice now rejects free-grant orders before it will consume an invoice
number for them — a full gift produces no invoice at all, while a discounted
grant still charges something and is invoiced exactly like an ordinary sale.
Keyed on the linked subscription/add-on's own provenance, not on the amount
being zero, so a genuinely fully-discounted Stripe checkout is unaffected.

Revenue excludes granted contracts from MRR/ARR/ARPU and the contracts count
— a gift at 0 must not drag the average down — and shows their number as its
own KPI instead of folding it in, since the owner measures himself on it.

The portal (Cloud, Billing) shows a granted package or module without a
price at all: not "free", not struck through, just the service, so a later
conversion to paid does not read as a negotiation.
2026-07-29 12:41:32 +02:00
nexxo b3081a544b Let the export have as many destinations as somebody wants
tests / pest (push) Failing after 7m35s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
One path and one exported_at could not say "on the office NAS, not yet on the
off-site box" — which is the only interesting question once there is more than
one destination, and the reason for a second is precisely that any single one
can fail. A row per destination now, and a row per (invoice, destination) pair.

Two kinds, and the difference is where the protocol lives. 'local' is a
directory this machine can already write to: a NAS mounted over NFS 4.1, a bind
mount, a disk. The application knows nothing about the protocol, the target
changes without code changing with it, and the whole thing stays testable
against a temporary directory. 'sftp' is for something reachable with
credentials — a Hetzner Storage Box being the obvious one — and the password
goes into the secret vault with only its key on the row, because a credential
in a settings table is a credential in every database dump. An empty password
field on an existing target means unchanged, not cleared: the field cannot show
what is stored, so blank-means-delete would wipe it on every unrelated edit.

One job per pair rather than one per invoice. A job writing to both would retry
the one that worked every time the other did not, and report one outcome for
two different things.

The ordering fix the tests found: Laravel's local adapter creates its root when
it is CONSTRUCTED, so the "never create the root" guard was checking a
directory the disk had just made for it. It runs before the disk is built now.
That guard is the difference between an archive and a hole in the ground — mkdir
on an absent mountpoint succeeds, writes onto the container's own disk and
reports success, and an archive that silently is not the archive is worse than
none.

The single path from the previous commit is migrated into a destination called
"Archiv" rather than dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:43:15 +02:00
nexxo ab4b0311d4 Copy every invoice to the archive when it is issued, and notice when that fails
tests / pest (push) Failing after 11m24s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
The application knows nothing about NFS and should not. It writes to a
directory; whether that is a local disk, a bind mount or a NAS over NFS 4.1
belongs to the mount. No protocol library, the target changes without code
changing with it, and the whole thing is testable against a temp directory.

The finding that shaped it: mkdir -p on a path whose mount is NOT there
succeeds. It creates the empty directory beneath the mountpoint, writes the
invoice onto the container's own disk and reports success — so an unmounted NAS
would quietly collect invoices locally while every check said it worked. An
archive that silently is not the archive is worse than none. The root is
therefore never created, only used: a missing root means a missing mount, and
that is a failure. Only the year subdirectory is created, inside a root already
proven to exist.

Written to a .part and renamed. A rename within one filesystem is atomic, so
whatever watches that folder never sees a truncated PDF and takes it for a
finished invoice. The size is read back afterwards rather than the write being
trusted: over a network mount a short write can report success, and an archive
of truncated files looks exactly like one that worked.

Queued, not done during the request. A network mount can block for its whole
timeout, and an invoice must not fail to be issued because a NAS is rebooting.
Dispatched after the commit, or a worker could look for an invoice that has not
landed yet.

And copy-on-issue is not enough on its own: an invoice issued while the office
connection was down never arrives, the queue eventually gives up, and nobody
opens an archive to check whether last Tuesday is in it. An hourly sweep
re-queues whatever is still missing, the failure is recorded on the invoice
rather than only in a log, and the Finance page says how many are outstanding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:32:08 +02:00
nexxo cd54212b34 Issue an invoice from what somebody bought, and freeze it there
tests / pest (push) Failing after 7m32s Details
tests / assets (push) Successful in 25s Details
tests / release (push) Has been skipped Details
One invoice per purchase rather than per order: a customer who buys a plan and
two add-ons in one go has bought once, and three invoices for one purchase is
three times the paperwork for the same money. Each order becomes a line, which
is also how the add-ons get listed individually.

Everything the document says is copied into the snapshot at the moment the
number is assigned — issuer, recipient, lines, rate, the reason for the rate.
A test changes the company name and the customer name afterwards and asserts
the invoice still says what it said. That is the whole reason no PDF is stored.

The number and the invoice commit in one transaction. A number taken and then
lost to a failure is a gap in a series that must not have one, and the refusal
test asserts that a rejected invoice leaves the counter where it was.

It refuses outright until the company details are complete. An invoice without
a registered name, an address or a VAT number is not a valid invoice here, and
issuing one consumes a number that can never be handed out again.

VAT comes from TaxTreatment, which already existed and already handles a
verified EU VAT ID correctly — including the reverse-charge note, without which
a zero-rated invoice says nothing about why no VAT was charged.

The billing address is carried as the lines it was written as. It is one
free-text field today, and guessing which line is the postcode would put it
where the street belongs, on a document nobody can correct afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 02:09:05 +02:00