Commit Graph

4 Commits (v1.3.49)

Author SHA1 Message Date
nexxo 35b312d5ef Invoice every renewal, and tell Stripe when the package changes
Two things Stripe was doing on its own, without us.

**A renewal produced no invoice and no mail.** InvoiceMail went out from
exactly one place — the first purchase — so a customer paying every month
for a year received one invoice, for month one. Stripe already says when
the money lands: invoice.paid with billing_reason subscription_cycle. What
was missing was the document.

A renewal has no Order behind it, and one was not invented for the
occasion: an Order is something a customer bought, and writing a purchase
nobody made would corrupt the record of what they have actually ordered.
IssueInvoice::forBilledPeriod() issues from the CONTRACT instead, at its
frozen net price, for the term Stripe billed. Idempotent against the Stripe
invoice id, which is unique in the database rather than checked in PHP —
and because the number is drawn inside the same transaction as the row, a
redelivery takes its number back with it and the series keeps its sequence.
Nothing in the paperwork can fail the webhook: issuing and mailing are both
caught and logged, exactly as confirmByMail() does.

Only a cycle renewal gets a document. The checkout's own invoice already
has one; an upgrade's proration was worked out against Stripe's boundaries
and would not match a document written from our snapshot; a charge raised
by hand is for something this catalogue cannot describe. All three stay in
the register, and are logged once as money no document of ours covers.

**After a plan change, Stripe went on billing the old price.** There was no
way to move a subscription onto another price at all, so a customer who
upgraded paid for the smaller package every month afterwards. The client
can now swap one, the item id it needs is learnt from Stripe's own events
(and asked for once, for contracts older than the column), and the
behaviour is chosen per direction: an upgrade settles immediately, so the
cycle invoice stays exactly the contract price the renewal document states;
a downgrade lands at the period boundary and settles nothing.

A granted contract has no Stripe subscription and is left untouched. A
change that reaches the machine and not Stripe is never rolled back — it is
parked on the contract with the error and the behaviour it needed, logged
as an error, and retried hourly by clupilot:sync-stripe-subscriptions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:35:15 +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 735daf46a4 Lay the foundation for self-issued invoices: series, numbers, frozen documents
tests / pest (push) Failing after 7m49s Details
tests / assets (push) Successful in 21s Details
tests / release (push) Has been skipped Details
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>
2026-07-29 00:57:06 +02:00