6 Commits (6b8412f0c394981c091fdba767fc2defe3627124)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
6b8412f0c3 |
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> |
|
|
|
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> |
|
|
|
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. |
|
|
|
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> |
|
|
|
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> |
|
|
|
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> |