57 Commits (feature/host-bootstrap)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
048e5ba81f |
Subtract only what was counted, not everything a singleton adopted
A figure may only subtract what it counted as an intent in the first place. The family loop counts a Product before knowing whether it will be adopted or minted, so an adoption there could honestly be subtracted back out — but syncModules() has never counted a module's own Product as an intent at all; it only counts a module's Prices. Reading AdoptStripeProduct::adoptions as one run-wide delta could not tell the two apart, so a module Product adopted from an interrupted run silently inflated "adopted" and shrank "created" by exactly one, for a Product the command never claimed to have made in the first place. The fix counts the family side locally, at the one call site that already counts the intent, and leaves a module's Product out of both figures entirely — adopted or minted, it was never counted, so neither number may move for it. AdoptStripeProduct::$adoptions is gone with it: nothing reads a singleton-wide total that cannot be attributed to one side or the other, and keeping it around unread would be exactly the kind of state this codebase does not leave lying about. The duplicate-product report had the same shape of bug one line down: it read straight off the singleton's list with no before/after snapshot, so a second handle() call in one process would reprint a duplicate an earlier run already named. Sliced to what this run itself added, the same way the counters beside it already were. Also: the unread `$charged` line in the adoption test that TDD had already exercised as dead weight, and its now-unused PlanPrices import. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
df7da334ee |
Say what was adopted, not that it was created
The count is taken before ensure() runs, so the sweep reported objects created in Stripe when it had made none — and after the next interrupted run, that line is the first thing a human reads. Telling the two apart is the whole point of the recognition step. Comparing the price id before and after the call does not distinguish them either: there is no id before, in either case. AdoptStripePrice counts its own adoptions instead, which is why it and its product sibling are now singletons — resolved per call, a counter on the instance would never pass one. Duplicate products are named in the report as well as the log. We deliberately do not deactivate them, so only a person can resolve one, and a person reads this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d6ec09a9b4 |
Recognise the product Stripe already has
The gap beside the guard. A run that creates a product and dies before storing its id leaves an orphan exactly as a price does, and past the key's twenty-four hours the next run makes a second one — after which activePricesFor() is asked about the wrong product, price recognition goes blind for the whole family, and every orphaned price on the first product is unreachable. Two things are unlike the price side, deliberately. No money gate: a product carries no amount, so its metadata is the whole of the proof. And a duplicate is reported, never deactivated — archiving a price is provably harmless because Stripe keeps billing subscriptions already on it, but deactivating a product makes its prices unsellable and contracts can be running on those. Silent about strangers, too. This asks for every active product on the account, so an operator selling something else through it is an ordinary state, not a finding worth a line on every run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4b99c6d5c6 |
Make the fake behave like Stripe where it matters
Three ways it did not. It replaced metadata where Stripe merges — and merging is the property AdoptStripePrice's "write only when it differs" comparison is built around. It handed metadata back uncast where the wire returns strings, so a test could pass on an int that production's strict === rejects forever. And a tie on `created` fell to insertion order, so two runs could adopt different prices. Paging now throws instead of stopping when Stripe says there is another page and the last item carries no id. invoiceLines() stops there and it costs a short document; here it costs an unseen orphan and a second live price for one figure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
40532c6e02 |
Put the money gate in the class that promises it
AdoptStripePrice says in its own docblock that no adoption can move money, and could compare three properties. The four that also decide what a Price charges were filtered in HttpStripeClient — where FakeStripeClient cannot express them, so no test of the class could reach the promise it makes, and a third client implementation would drop the check in silence. The client reports them now and the caller decides. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the gate refuses every legitimate price and recognition becomes a no-op nothing alerts on. And the unexplained-orphan warning is a state, not an event. It fired on every sweep and every customer booking for as long as the orphan existed; once a day per price is enough to act on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
865fd16f58 |
Put the price recognition back, all 3526 lines of it
|
|
|
|
b32c6fc33f |
Sell the year as well as the month, and say what it saves
Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.
**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.
`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.
**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.
The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.
Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
3cf16ecd63 |
Name every property that decides what a price charges
The money gate compared amount, currency and interval and left the rest to a filter that excluded two fields. A recurring, licensed, monthly price with transform_quantity divide_by 10, at our exact figure, on our product, carrying our addon key passed all five conditions. Modules bill BY quantity — a pack is one item at quantity n — so a customer holding three would have been charged one. A tiered price was excluded only by accident: Stripe reports unit_amount null for it and we read that as 0, which fails the amount match unless the caller's own figure is 0, and PlanPrices::ensure() has no zero guard. activePricesFor() now also skips a non-empty transform_quantity and any billing_scheme other than per_unit. Absent keys stay Stripe's defaults, which is the direction that matters: read the other way round the filter would refuse every legitimate price and turn recognition into a permanent no-op. The contract docblock names the four properties instead of claiming to cover all of them, and says the list is not everything Stripe can put on a price — only what is known to change what one charges. And a comment that says a gap is covered is worse than the gap. The sync said a crash between Stripe creating a product and us storing its id gives back the same product next run: true for twenty-four hours, false afterwards, and the exact claim this branch was written to refute. There is no recognition step for products at all, so an interrupted run leaves an orphan and the next run past the expiry mints a second one — worse than a duplicate price, because activePricesFor() then looks at the wrong product and recognition goes blind for the whole family. Written down as a known gap at both createProduct call sites, with the trade this branch chose: folding name and metadata into the key means a renamed family now mints a second product where it used to answer 400, and a blockade reaches a paying customer while a duplicate product does not. The sweep no longer reports "created" for what it adopted — the count is taken before ensure() runs, and telling those two apart is the whole point. The test named after 2026-07-29 could not reproduce it: the fake's key ledger was empty, so createPrice() could not have thrown and the test could not tell "adoption prevented the 400" from "no 400 was possible". Seeded now with the key today's call sends and a fingerprint over the metadata the pre-9da1358 call sent. Remove the adopt() call from AddonPrices::ensure() and the test dies with Stripe's own sentence instead of an assertion. Also: the plan document still instructed migrate:fresh --env=testing. There is no .env.testing here, so it targets the live development database; struck through with the reason, not deleted, and a Korrekturen section records the five defects the plan's own reviews found in it. Four comments corrected — "below" that meant above, a counter claimed unable to tie that ties with a planted price, the unique index's second meaning for remember()'s catch, and that MariaDB DDL is not transactional so the dedupe commits before the index. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dba5df5d1e |
One row per Stripe price on the module side too
The plan register has had this since 2026_07_30_110000. The module register never did, so two rows could claim one Price and archiving the one would have withdrawn the Price the other was still selling — Block D of the real-run handoff. It is also the net under the adoption step: at a VAT rate of nought both treatments charge the same amount, and the check that refuses an already-claimed Price is then the only thing keeping the two apart. Duplicates are deleted, not archived. An archived row goes on claiming the id and a unique index knows nothing about archived_at; the row rebuilds itself on the next ensure(), and subscription_addons holds the Stripe id as text rather than a foreign key, so no booking loses its price. AddonPrices::remember()'s catch comment described the guard as the only thing standing between two rows and one Price; now that the index exists, rewritten to describe it as the net under that guard instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
dfbd3b5c1f |
Restore the key's own reason and pin the plan-side identifying key
Two things the plan's own text asked back. First, the idempotency-key comment lost its substance when Task 5 rewrote it: the charged amount and the treatment are in the key because at a rate of nought the two Prices are the same amount and one key would have Stripe hand back a single object for both — a maintainer who simplified that key would only rediscover why from a unique-constraint violation. Restored alongside the newer sentence about what actually stops a second Price (adoption, not this key), so the module and plan sides read alike again. Spelled out "twenty-four hours" in both places in the same comment, matching the sibling. Second, nothing pinned identifying: ['plan_price_id'] — the one decision that makes the plan side different from the module side, because a family's Product carries a Price for every version, term and treatment, so plan_family alone cannot say which row a Price belongs to. Added a test that plants a Price proving only the family, confirmed it fails when identifying is loosened to ['plan_family'], and reverted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
355f9cce01 |
Ask before minting a package price
Same defect, same fix, one difference: a family Product carries every version and every term, so the amount alone does not say which row a Price belongs to. plan_price_id in the metadata does, and it is what has to agree before a Price is taken over. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
40f7818757 |
Fix round 1: give the frozen-booking test real teeth
Two Important findings from review, both against the plan's own text. The frozen-booking test could not fail for a defect this diff introduces. Digging into why turned up two problems, not one. The two assertions the review named were never exercised: sync-catalogue never writes subscription_addons.stripe_price_id, and the orphan planted for the new figure sits at a different amount than the booking's own price, so archiveSuperseded's net_cents scoping was never going to reach it either. Underneath that, the test's own orphan was inert regardless of any of that: with no StripeAddonPrice row seeded first, AddonPrices::product() minted its own Stripe Product instead of reusing 'prod_support', so the competing orphan planted under that literal id sat on a product nothing ever asked about. Fixed both — seeded the product the way the first two tests in this file already do, added the three assertions the review specified, and a comment on what the two original assertions do and do not prove. Verified the fix actually bites, since a test whose teeth are asserted but never demonstrated is what produced this finding in the first place. With just the amount check in AdoptStripePrice disabled, the booking still survived: the `claimed` callback this diff wires into AddonPrices::ensure() caught it as a second, independent guard. Only disabling both together reproduced the failure — the honest demonstration, not the single-line sabotage first suggested, which this diff's own defense-in-depth absorbs. The remember() comment credited a unique index stripe_addon_prices does not have — that index sits on stripe_plan_prices, the plan side. Reworded to name what actually keeps two rows off one Price id today (the `claimed` callback), what 2026_07_31_210000_one_row_per_stripe_price adds as a net under it, and why two rows sharing one Price is dangerous: archiving one — the ordinary response to a superseded figure — would withdraw the very Price the other row is still selling. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
97b655938e |
Ask before minting a module price
The sweep that died on 2026-07-29 left price_1TygdEC7u8NpJ8pOt3nsoyYw at Stripe and no row here. This is the step that finds it: same money, same interval, our metadata on it, unclaimed — taken over and brought up to date instead of duplicated. The comment on the key was the root of it. It read "keyed on what the Price IS", and a key is not that: identity is product, amount, currency and interval, and recognising those is AdoptStripePrice's job. Stripe forgets a key after a day; it never was the guard against a duplicate. A booking stays where it was sold. Adoption only ever matches an identical amount, so a frozen booking cannot be moved by it — there is a test that says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
06c214e597 |
Make the money gate compare the whole recurrence
activePricesFor() reported interval alone, never interval_count or usage_type. A Price we could not have created ourselves — billed every three months, or on metered usage — could carry our own metadata (Stripe's dashboard "duplicate price" copies it) and pass AdoptStripePrice's amount/currency/interval check on 'month' alone. Adopting it would move money onto a different recurrence, which is exactly what nothing here may do. Filtered at the boundary instead of in the predicate: HttpStripeClient now skips anything besides Stripe's own defaults (interval_count 1, usage_type licensed), so activePricesFor() can promise callers it returns only prices we could have minted. FakeStripeClient needed no filter — it only ever holds prices its own createPrice()/plantPrice() made, all standard — but says so, so the omission doesn't read as forgotten. Two more silent-failure paths, closed with the same three lines each: - The metadata-differs check was a bare !==, which is key-order sensitive and blind to Stripe's own merge. A Price carrying an extra key of its own, or metadata handed back in a different order, could never compare equal and would be rewritten every sweep forever. Compared through array_intersect_key() plus ksort() on both sides instead, so only what WE would send is what gets compared. - Metadata is normalised to strings once, up front, so an un-cast value from a caller can't make confirms() fail forever and silently turn adoption off for that Price. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4de44e7ab0 |
Recognise the price Stripe already has
An abandoned run leaves an orphan: Stripe made the Price, our insert never happened, and the table that decides everything afterwards does not know it exists. The key covers a day; after that the next run makes a second live Price for the same money. What may be taken over is narrow. Same amount, currency and interval — a Price at another figure would move money, and nothing here may: a running contract keeps the Price it was sold on, and a booking stays frozen at what it cost that day. Plus proof in the metadata, because an unexplained active Price at the right money is what somebody clicking through Stripe's dashboard leaves behind, and adopting that is worse than minting a second one. Several orphans: the oldest is adopted — likeliest to be the one a lost row was billing on — and the rest are archived, which stops them being sold and moves nobody. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9da1358802 |
Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT line, the note on the invoice — and was still charged 214,80 € for a 179,00 € package, because one Stripe Price carried the domestic gross for everybody. There is no VAT line on their document to reclaim, so it was a flat 20 % surcharge on exactly the customers who read their invoices, and the document then stated the whole 214,80 € as net at 0 %, so they self-accounted their own VAT on a base a fifth too large. A Stripe Price can ask who is buying after all: there are two per sellable thing now, the domestic gross and the bare net, both live on one Product, and the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B without VAT — a domestic business still pays the gross, reclaims it as input tax, and the price on the website is still the price charged for them. - stripe:sync-catalogue mints and archives both halves of every pair, per plan and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices says which is which, and joining it on subscriptions.stripe_price_id says which one a running contract is billed on. A rate change moves the gross Price and leaves the net one alone, because the net owes nothing to the rate. - A status that changes after the sale converges: a verification (or a lapse) makes stripe:reprice-subscriptions move the contract onto the other Price with PRORATE_NONE, because the term is already paid for. The module items follow the same way. - Downstream follows: the invoice total equals what was taken, the proof register expects the figure this customer is actually charged (so a correct reverse-charge sale is no longer flagged as a mismatch, and an overcharged one is), the setup fee obeys the same rule, and the booking page quotes what it will charge. - StripeClient gained activatePrice(). Archived Prices were brought back in our own table and left inactive at Stripe, so a rate that moved and moved back pointed the catalogue at a Price no checkout could be opened for. An unverified number is still charged the gross — an unchecked string must never be a discount — and where the net Price is missing the checkout refuses rather than reaching for the domestic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8ca0d4d257 |
Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Five places where the product told somebody a figure that nothing behind it produced. **The invoice page showed five invoices nobody was ever issued.** A mock that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix belonging to no configured series, all "paid", a spend curve from 179 to 198 € and a next charge of 198 € on 01.08.2026 — behind ['auth','verified','customer.active'], so every signed-in customer saw it. Their real invoices existed in `invoices`, were rendered as PDFs and mailed to them, and appeared nowhere in the portal at all. The page now lists their own documents, newest first, with the numbers, dates and totals off the frozen snapshot, a Storno marked as one, and a download. The PDF route resolves the document against the signed-in customer in the query — a URL anybody can type is not made private by not printing it. No next charge and no chart: nothing here computes what Stripe will take next cycle, and a figure nobody has worked out is worse than a blank space. **The setup fee was quoted everywhere and charged nowhere.** On the price sheet, on the booking page, set by the operator in the console — and CompanyProfile::setupFeeCents() had no reader outside the two sentences that quoted it. It is charged now, as a second non-recurring line on the same checkout session, which Stripe puts on the initial invoice only. `orders.setup_fee_cents` records how much of the charge it was, so the whole total still goes back on a withdrawal while the register holds the PACKAGE's charge against the contract price and the invoice prints the fee as a line of its own. The booking page was also quoting the fee NET under the same sentence the website quotes GROSS — 99,00 against 118,80 — and now quotes what will be taken. **An upgrade click cancelled a booked downgrade and delivered nothing.** Nothing in this application marks a cart order paid, so the customer traded a downgrade they had genuinely booked for a cart line that would never be fulfilled. The booking now stands until the upgrade actually lands on the contract, where ApplyPlanChange clears it. The fulfilment seam is reachable for every type that can be paid — a module and a storage pack book through BookAddon, which was the missing path from a paid order to a SubscriptionAddon and left AddonPrices, SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller behind a payment. A paid traffic pack logs an error rather than pretending: the metered allowance is a standing count of packs while a top-up is sold for one month, and which of the two is meant is not a guess to make here. That no path to `paid` exists yet is now stated in both places rather than in one. **The proof register called correct charges wrong.** expected_gross_cents used the CUSTOMER's rate, which is zero under reverse charge, against the domestic gross Stripe actually took — so matches_catalogue was false for every EU business sale charged exactly the advertised amount, and for every renewal on a contract with a module, which was held against `price_cents` alone. It compares against TaxTreatment::chargedCents() and against the whole term now. Where no money moved, charged and the flag are null instead of the expected figure agreeing with itself, and a plan change records the terms with the pro-rata figures in the snapshot rather than money that arrives again on Stripe's proration invoice. **The seller's own VAT id was on the invoice, unlabelled**, between the register number and the court. Labelled — and the footer's composition moved out of the TCPDF method, because inside one the only way to read what it says is to render a PDF and un-subset its fonts. One test enshrined a figure the code does not produce: a reverse-charge purchase recorded with no charged amount, asserting 17900/0/17900, where OpenSubscription always passes the charged figure and a real one records 21480/0/21480. Another asserted that an upgrade click cancels a booked downgrade. Full suite: 1676 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f02e86769b |
Charge the price the website shows, and hand a withdrawal back in full
tests / pest (push) Failing after 9m21s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Stripe was charging the catalogue's NET figure while the document added the domestic rate on top: a customer quoted 214,80 paid 179,00 and was then invoiced for VAT nobody had collected. Under para 11(12) UStG that VAT is owed to the tax office whether or not it ever arrived, so every document issued created a liability against revenue that did not contain it. The Stripe Price now carries the GROSS figure. price_cents stays net - it is frozen onto every contract and PlanChange prorates against it, so redefining it would corrupt every pro-rata sum ever computed. Only the amount at the till moved, and it is formed by the one call TaxTreatment already answers for the price sheet and for the invoice, so displayed, charged and invoiced cannot drift apart. Stripe's automatic_tax is deliberately not used. TaxTreatment is the single tax authority here, and a second rate computed by Stripe would take 19 % from a German consumer while our document said 20 %. A Stripe Price cannot be edited, so a changed figure means a new Price and the old one archived - which stops it being SOLD and leaves every subscription already on it billing the old amount for ever. stripe:reprice-subscriptions is the second half, and a command of its own because it touches live money. A withdrawing consumer is refunded in full, as the owner decided. That is more generous than FAGG para 16 requires, and it makes the express-request consent the statute hinges on irrelevant - so the gate is gone rather than left looking load-bearing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6d7c2bdf35 |
Ask whether they are a consumer, and let one change their mind
tests / pest (push) Failing after 8m35s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Three things the product owed its customers and did not have. **Who is on the other side.** There was no consumer/business flag anywhere, and `vat_id` was standing in for one — which it cannot: a business without a VAT number is an ordinary small business, and a consumer with one does not exist. It is asked at sign-up now, correctable in the portal, and NULL where nobody has been asked. Unknown is read as CONSUMER everywhere it decides a right, because that mistake costs us a refund while the other one takes a statutory right away from somebody who has it. Reverse charge asks the recorded type instead of the number: an explicit consumer is charged the domestic rate whatever `vat_id` says — previously they were not, and anyone could zero their own VAT by getting a number verified. An unrecorded type still falls back to the verified number, so no contract that is already running changes rate. **The fourteen-day right of withdrawal** (FAGG, §312g BGB), for consumers only, at every door: the window is stamped on the contract when it is concluded, the customer exercises it from the portal, an operator records one that arrived by telephone or post, and both go through one action that refuses a business customer on the server rather than by hiding a card. The money follows the paperwork rather than being computed beside it. The invoice is cancelled by a Storno with its own gapless number — nothing is ever edited or deleted — a new invoice states the pro-rata value of the service actually delivered (FAGG §16, by days over the term paid for), and the refund is exactly the difference between the two documents. Where the consumer never expressly asked for the service to begin at once, they owe nothing and the whole amount goes back. `StripeClient::refund()` is new, keyed so a retry cannot send the money twice. The service ends through EndInstanceService and the `cancellation_scheduled` machinery that was already there. **The cancel button for modules.** BookAddon::cancelAtPeriodEnd() had no caller in the interface at all, so a customer could book a recurring charge in two clicks and had no way to stop it. It is on the module card now, with the date it runs until and a way back while the cancellation is still pending — and putting a module back settles nothing, because the term it was cancelled for was paid for in advance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8f1630ba91 |
Bill a booked module every month, and put it on the invoice
A module was never an item on the Stripe subscription, so it was charged in the month it was booked and never again. And the renewal document was written from our own contract snapshot, so it would not have said so even if the money had been taken. Two halves of one fault, closed together. Modules are items now. stripe:sync-catalogue mirrors a Product and a Price per module on both terms — a Stripe Price carries its own interval, and a booking frozen at an older figure needs its own Price, so they are keyed by module, amount, currency and interval in stripe_addon_prices. Booking adds an item with always_invoice: prorated by days AND charged there and then, which is the invoice the owner wants for a module booked mid-term. A second storage pack is a quantity on one item, not a second item. Cancelling takes the item off with no proration — no credit, and the next cycle is simply smaller — while the module itself is kept until the period end on subscription_addons.cancels_at and ended by clupilot:end-cancelled-addons. Documents are built from Stripe's own invoice lines. Stripe is what charged the customer; a document assembled from our figures would state a sum that is not the one taken. So every paid Stripe invoice gets one document with one number, idempotent on stripe_invoice_id — a cycle carrying package and every module together, a mid-period booking carrying just its prorated line, an upgrade carrying its proration. Only the wording is ours: each line is named from the catalogue rather than printed as a Price id, and a line nobody can name stays on the document under Stripe's own description and is logged. The checkout's own invoice is still the one exception; that purchase already has a document. Stripe being away never undoes a booking that has already reached the machine. The failure is parked on the contract in stripe_addon_sync and swept by the same clupilot:sync-stripe-subscriptions that retries a plan change, which finds its work from the bookings themselves rather than from the marker. A granted contract has no Stripe subscription and is not touched at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
5b63fdb86c |
Deliver the storage a customer actually buys
A booked storage pack was a row in `subscription_addons` and nothing else: priced, frozen, charged every month, and delivered nowhere. Nextcloud's quota came from `instances.quota_gb`, which is the package alone; the disk was sized for the package alone; and on the one occasion a disk did grow — a plan upgrade — the guest never saw it, because nothing stretched the partition or the filesystem over the new space. One authority for the allowance. StorageAllowance adds the package and the booked packs, and everything that needs the figure asks it: the quota step, the downgrade check, the portal, the console, the repair command. It is DERIVED rather than stored — a column would have to be rewritten by booking, cancelling, granting, a grant expiring and a plan change, and the day one of those paths forgot it would be silently wrong in whichever direction costs somebody money. `quota_applied_gb` keeps its own meaning: what the guest was last actually told, which is how a machine with the allowance enforced is told from one where the figure has only ever been a row in our database. Buying it delivers it. BookAddon asks ApplyStorageAllowance for a run on booking AND on cancellation; the new `storage` pipeline grows the virtual disk to the allowance plus the package's own overhead (read off the package — 20/40/50/100 GB on the four catalogue packages — never a ratio invented here), then GrowGuestFilesystem makes the guest see it, then the quota is applied. No cold boot is involved: the data disk is scsi0 and Proxmox's resize on a running guest is a qemu block_resize, so the capacity reaches the guest while it runs. The new step rescans, growparts and grows the filesystem with the tool its type actually needs — ext2/3/4, xfs, btrfs — and fails loudly on one it does not know rather than handing it to resize2fs and hoping. Idempotent end to end: NOCHANGE from growpart is not a failure, and every tool here exits 0 when there is nothing left to grow. The same step now runs in the plan-change pipeline, where an upgrade used to stop one step short of the customer. A downgrade blocked by data gets a way out. The block stays — it is correct — but the check now measures against the target package PLUS the packs the customer already owns, and reports the numbers behind the refusal: what is stored, what would be allowed, how much has to go, and how many packs would cover it instead. The portal offers both routes: book exactly those packs (confirmed in a modal, R23), or delete data and have the fill level measured on demand rather than waiting for the nightly sampler. That reading is taken by DiskUsageProbe, which CollectInstanceTraffic now uses too, so there is one notion of "how full is it" and not two. A plan change keeps booked packs. They were paid for separately and have nothing to do with which package the customer is on; the new package's disk and quota are sized with them included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8f2252e81b |
Book a downgrade for a date that cannot move, and sell a module once
Two ways of charging a customer for something they did not get. A downgrade was due when `subscriptions.current_period_end` said so, and Stripe pushes that column forward on every renewal. So a downgrade booked in March was deferred to the end of April by April's renewal, and to the end of May by May's — each time by a whole term, and each time the customer was billed again for the package they had asked to leave. The due date was being re-derived from a moving target on every scheduler tick. It is decided once now, at the moment the customer decides, and stamped onto the contract: `pending_plan` and `pending_effective_at`, the two columns that have been in the schema since the first migration and were written by nothing at all. The shop stamps them, PlanChange reads them instead of the period end when a move is already booked, and clupilot:apply-due-plan-changes finds contracts by the stamped date rather than orders by a date it recomputes. The order stays what it always was — the customer's own record of the request, what the cart shows them and what they remove to change their mind — so it is still consumed with the change, and removing it unbooks the change. What it no longer does is decide when. A second downgrade replaces the first rather than queueing behind it; an upgrade clears one, or it would come due months later and undo the bigger package; a contract cancelled before the date takes the booking with it; and a customer with no contract can no longer place a downgrade at all, which was accepted before and could never be carried out. A booked change is now visible where the contract is read: on the plan card in the portal, because the cart entry disappears when it is paid for and the booking does not, and on the customer row in the console, because that is where an operator answers "what is this customer on". The second defect is the same money in one step: BookAddon guarded duplicates per ORDER — the unique index on (order_id, addon_key) — so two orders for the same module on one contract both went through. A stale tab or a double click bought priority support twice. But storage is deliberately sold in packs, and AddonCatalogue sums the quantities for exactly that reason, so "refuse the second" is only right for some of them. Which is which is declared beside each price in config/provisioning.php (`sold_as`), because it is a commercial decision about each module and not something to infer from a key: storage is a quantity; off-site backups are on or off, support is prioritised or it is not, Collabora Pro is one licence, and a machine answers to one own domain. An undeclared module counts as an entitlement — the cheaper of the two mistakes — and a test refuses to let one ship undeclared. Enforced in the action, where the portal, the console's grant screen and a webhook all pass, with the sentence the customer is shown rather than a developer's. A retried webhook for the SAME order still gets its one booking back; that is one purchase arriving twice, not two purchases. The portal stops offering what would be refused — booked, or already waiting in the cart — and goes on offering the pack. What is missing is on the Stripe side and is not invented here: nothing moves the subscription's item onto the new Price, so the renewal after a plan change still bills the old plan at Stripe. StripeClient only creates and archives catalogue objects, and swapping a price also needs the subscription ITEM id, which nothing stores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
712803edd6 |
Serve the custom domain, not just announce it
A verified custom domain was reported as the customer's address by
Instance::address(), by the portal and by the credentials mail while
nothing on the platform routed it: the Traefik router's rule was
hard-coded to {subdomain}.{zone}. The address a customer was handed
answered nothing, and a withdrawn domain stayed in Nextcloud's
trusted_domains forever, because the only thing that ever wrote either
was the initial provisioning run.
- TraefikWriter::write() takes a LIST of hostnames under one stable
router name — the platform address always, the verified domain as
well when there is one. One file per instance, so a withdrawal is a
rewrite rather than a second thing somebody has to remember.
- ConfigureDnsAndTls records the custom domain's certificate instead of
waiting on it: it depends on an A record in the customer's own zone,
which may never appear, and must never fail a run. The platform
address keeps its 840s deadline. instances.domain_cert_ok tells
"proven" apart from "answering", and the portal now says which.
- ConfigureNextcloud deletes trusted_domains 2 when there is no verified
domain, so a withdrawn one stops being trusted.
- New `address` pipeline (those two steps) plus ReapplyInstanceAddress,
which starts one against the order and refuses to start a second while
any run is in flight. The route is rewritten when the hostname list
differs from what the router carries — not on route_written, which
would short-circuit exactly the case a re-apply exists for.
- Triggered where the address changes: verification flipping either way
in clupilot:verify-domains, the customer's own domain page, and
CustomDomainAccess::deactivate() on a package downgrade.
- A maintenance run no longer condemns its subject: an address run that
failed used to mark the order failed and release the live instance
with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
b3651a14be |
Decide who may have their own domain, and decide it in one place
tests / pest (push) Failing after 9m46s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The domain page shipped with no access control at all: the `custom_domain`
plan feature was checked nowhere in the codebase, and every customer on
every package could point a domain at their instance. This is the owner's
rule, built as one authority that everything else asks.
Start impossible — not bookable, not upgradable, not offered
Team optional, via the `custom_domain` module
Business included, because the plan version carries the feature
Enterprise included, likewise
App\Services\Billing\CustomDomainAccess answers all of it: may this
contract use one, may it book the module, why not in words a customer can
be shown, what a downgrade would do to a domain they have, and — after a
change lands — making the state match. Read against the FROZEN plan
version, never today's catalogue, so a feature added to a package later
cannot reach into a contract signed before it.
Which packages cannot have one at all is an explicit list in
config/provisioning.php, beside the module's price. The catalogue cannot
answer it: plan versions model what a package HAS, and nothing models what
a package may BUY. Both available proxies are dishonest — "the lowest tier
on sale" hands the right to a grandfathered Start customer the day the
owner stops selling Start or adds something cheaper below it, and reading
it off `branding` would sell a domain to anyone allowed to upload a logo.
The config comment says so.
Enforced where it can be enforced today:
- BookAddon refuses the module, with the sentence the customer is shown.
GrantAddon now writes its synthetic order inside the transaction, so a
refusal cannot leave a paid order for a module nobody got.
- The portal drops the card where it cannot be booked and shows it as
included where the package carries it — no price, no button.
- Billing::purchase() re-checks, because a hidden button is markup.
- DowngradeCheck reports the consequence beside the blockers, so the
customer reads it above the button rather than after it: losing the
domain (Team -> Start), or the choice (Business -> Team, where keeping
it means booking the module).
- PlanChange::settleCustomDomain() applies it once a change lands: the
module stops being charged for and the instance goes back to its
platform address. It clears the whole domain, not only the verified
flag — clupilot:verify-domains re-reads every row that still has a
domain and a token, and would switch it back on the next night.
- The price sheet's own-domain row now reads dash / optional + price /
included, from the same authority; the three cell states already
existed and the view is unchanged.
STILL TO APPLY, ONCE THIS AND THE CUSTOM-DOMAIN BRANCH MEET — one line at
the top of App\Livewire\CustomDomain::mount(), which belongs to the other
session and is deliberately untouched here:
abort_unless(app(\App\Services\Billing\CustomDomainAccess::class)->allowsCustomer($this->customer()), 403);
routes/web.php is left alone for the same reason. The mount() guard covers
the route and the component both; a middleware would only repeat it.
Follow-ups, named rather than built:
- Nothing applies a plan change anywhere yet, so settleCustomDomain()
has no caller. Whoever builds that must call it after the new contract
opens, and must decide whether a booked module is carried onto the new
contract — enforce() cancels the booking on the contract it is given.
- Deactivation sets the state; it does not re-run provisioning. Traefik
and Nextcloud's trusted_domains still name the old address until
ConfigureDnsAndTls / ConfigureNextcloud run again.
- App\Support\Navigation still shows the Domain tab to every customer;
it can ask allowsCustomer() the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
d4e8822dbb |
Paketversionen: Verkauf wieder aufnehmen und saubere Übergabe
tests / pest (push) Failing after 7m58s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Eine geschlossene Version konnte nie wieder in den Verkauf. Schlimmer: um ein Paket zu ändern, musste die laufende Version zuerst beendet werden — und wenn die Nachfolgerin dann nicht sofort veröffentlicht wurde, war das Paket dauerhaft dunkel. - PlanCatalogue::reopen() setzt available_until zurück auf null, über schedule(), damit Family-Lock und Überlappungsprüfung dieselben bleiben. Ein nie veröffentlichter Entwurf wird abgelehnt. - PlanCatalogue::publish() übergibt eine laufende Version an ihre Nachfolgerin (available_until = deren Start) statt die Überlappung abzulehnen — unter demselben Lock, in derselben Transaktion. Nur die unmittelbare Vorgängerin, und nur wenn es genau eine gibt. - Konsole: Knopf „Wieder verkaufen", übersetzte Ablehnung statt der englischen Ausnahme, und die Übergabe wird im Veröffentlichen-Formular vorher angekündigt (Version und Zeitpunkt, ->local()). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8c105c17d6 |
Move each plan's marketing copy from the controller into the console
tests / pest (push) Failing after 8m10s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Audience line and note were LandingController::COPY, a hardcoded array keyed on exactly the four plan keys the seed migration created — a plan made under any other key rendered with no copy at all. They move onto the family for the same reason the recommendation mark does: they are a stance about the product line, not a capability that changes with a price, so they must not be frozen per version. Backfilled in the migration from the controller's own strings, so the public page prints exactly what it printed a moment ago. A family with no copy yet renders without the audience line rather than an empty gap. Feature labels (managed_updates, daily_backups, ...) stay in the controller: they name catalogue features, not individual plans, so there is no per-plan or per-version place to hang an editable customer-facing label without re-introducing a shared feature table. That table existed once and was deliberately removed when the plan catalogue closed its pricing split-brain; bringing it back for wording alone is a bigger, separate decision. |
|
|
|
4ef5b9519c |
Let the owner mark one plan as recommended, from the console
Recommended lives on the plan family, not a version: it is a stance about
the product line ("which plan we point a visitor towards"), not a
capability that changes with a price, and freezing it per version would
make the mark silently vanish every time a new version publishes.
Setting a plan recommended clears whichever other plan carried the mark,
inside one transaction locking the whole table — two operators
recommending two different plans at once must not both win. The public
price sheet already had the highlighted-card treatment for this (it was
driven by a hardcoded "team" constant); it now reads the family instead.
|
|
|
|
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. |
|
|
|
132be7fdd5 |
Leave the archive readable by whoever collects from it
tests / pest (push) Failing after 7m32s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Reported as "the folder arrives empty". It was not empty. The folder was 0700 and owned by the account that wrote it, so the account that COLLECTS — an rsync over ssh, a backup agent, anything that is not the writer — could not enter it, and an unreadable directory looks exactly like an empty one. The invoice had been sitting in it the whole time. The cause is that the default follows the process umask, so under a restrictive one 0755 quietly becomes 0700. The permissions are declared on the disk now rather than left to inherit, and a test writes under umask 0077 and asserts the mode — the ordinary umask hides this completely, which is why it was found on a server and not here. Two mistakes of mine on the way to it, both worth recording. I read the folder as the wrong user, got no entries back, and concluded the file had been deleted — it was a permission failure reading as an unprivileged account, not a missing file. And the first fix was a chmod on a variable that does not exist in that method, silenced by an @ so it failed without a word. Flysystem takes its permissions from configuration; a chmod afterwards was the wrong layer even before it was the wrong variable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d8ff717cb5 |
Let a destination say how it is laid out and how long anything stays
tests / pest (push) Failing after 7m29s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Two destinations doing two jobs. A handover directory a NAS collects from wants a folder per day and wants emptying — thirty days is enough to notice a NAS that stopped collecting, and keeping more only hides it. An archive on the NAS itself wants a folder per year and wants nothing deleted, ever. So both are per destination rather than global. Deleting is safe here in a way it usually is not, and that is worth saying plainly: the invoice is a frozen document in the database and its PDF is rendered from that on demand. Nothing is moved anywhere — it is copied, and anything removed from a directory can be produced again and re-exported. The directory is a handover point, not the record. keep_days is null by default and stays null unless somebody sets it: a retention rule nobody asked for is a deletion nobody expected. The prune only touches folders whose NAME is one of the two shapes this application writes, and leaves everything else alone — a prune that deletes what it does not recognise is how an archive loses something nobody was watching. It also skips a destination whose path is missing rather than acting on whatever is underneath an absent mountpoint. The shape is matched with a pattern before parsing rather than by handing nonsense to Carbon: under strict mode createFromFormat throws instead of returning false, so one folder somebody made by hand would have ended the whole prune instead of being skipped. The test with a foreign folder in it found that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0528b56a32 |
Name no vendor in the export settings
tests / pest (push) Failing after 7m36s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
A destination is a kind of connection, not a product. The wording said "Hetzner Storage Box" and the host field suggested one particular provider's hostname format, which reads as a requirement rather than an example — and the whole point of the directory kind is that whatever is behind it is nobody's business here. Both kinds stay. A directory is how an NFS 4.1 mount is used: the mount is made on the server, and the application writes into the path it produces. SFTP is for a destination that is not mounted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f0ec2fe03f |
Keep the SFTP credential on the row it belongs to, encrypted
tests / pest (push) Failing after 7m22s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
The first version put it in the credential vault. The vault refuses it, and correctly: it keeps a registry of fixed, documented credentials so the Secrets page can describe each one, and a per-destination password is not that. Forcing a dynamic key into that registry would have made the registry meaningless. Encrypted onto the row with the same cipher instead — the same key, so it is no more readable in a database dump than anything the vault holds, and it lives with the record it describes. A rotated SECRETS_KEY now says so by name rather than surfacing as an authentication failure against a host that is perfectly fine. Also answers the question that prompted this: the host, port and user fields are there, and appear once SFTP is chosen. A mounted directory has no host on purpose — the mount is made on the server with mount(8), and the application only names the directory it writes into. A test asserts both halves, because "the fields are there, you just cannot see them yet" is not something anybody should have to take on trust. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
3fb7e5f08c |
Decide the VAT rate in one place, before the two disagree
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 26s
Details
tests / release (push) Has been skipped
Details
The Finance tab wrote a rate into settings while TaxTreatment kept reading one from config — two sources for one number, added by me two commits ago. It had not bitten yet only because both happened to say 20. TaxTreatment now takes the domestic rate from CompanyProfile, which falls back to the .env-derived config until an operator saves one. Same shape as ProvisioningSettings, which already solves this for the deployment values, and the same reason: an operator can change a settings page and cannot change a config value, so a page that appears to set the rate and does not is worse than no page at all. Reverse charge and the seller country stay where they are. That logic already existed, already handles a verified EU VAT ID correctly, and nothing here needed to know about it — which is exactly why it should not have been duplicated. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4646880b90 |
Print every line at full price and take the discount off once, in euros
tests / pest (push) Failing after 7m40s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
Second correction to the same block, and this one came from the right place: a line reading 202,50 beside "Rabatt 10 %" does not say whether the discount is in that number or still to come. It was unreadable, and unreadable on the one figure a customer checks. Lines are at full price now. Discounts are still entered per line — that is where they belong and where somebody decides them — but they are summed and shown once, in euros, under a subtotal that the "Gesamt netto" column adds up to. Subtotal, one subtraction, VAT, total: a sum anybody can follow without being told which figures are already adjusted. The gross line-total column is gone and a gross UNIT price takes its place. Full-price gross totals would sum to 315,60 against a total of 288,60 — the same contradiction in a different column. Nobody adds up unit prices, and a consumer reading this wants to know what one of the things costs. lineTotal() and adjustmentLabel() went with it. Nothing calls them any more, and a helper kept for a layout that no longer exists is the next person's wrong turn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d21e4f0eb2 |
Put a discount on the line it belongs to, so the columns add up
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
With one discount applied under the table, the gross column summed to 315,60 against a total of 284,04. Nothing was miscalculated — the discount simply came after — but a reader who adds up a column found a document that does not agree with itself, and no way to tell which figure was the real one. On an invoice that is not a presentation detail. A discount or surcharge now sits on its line, percentage or fixed amount, and is inside that line's total. The label under the line is built from the same value that did the arithmetic, so the words cannot end up describing a different number than the one printed beside them. The invoice-wide adjustment stays in the arithmetic for anything that genuinely applies to the whole document, but nothing uses it by default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
835f3eeb64 |
Give the specimen invoice a placeholder in every field, not only the required ones
tests / pest (push) Failing after 6m58s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
The footer is four columns of three lines, as designed — but the specimen only filled the fields an invoice legally needs, so two of the four columns came out empty and it read as a missing footer rather than as missing data. Reported that way, which is the correct reading of what was on the page. A specimen exists to show the layout. Every field now carries an obvious placeholder, so the shape is visible before any real detail has been entered, and what is still a placeholder is obvious at a glance. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c1896e70fc |
Render an invoice, with the arithmetic held to integer cents
tests / pest (push) Failing after 7m9s
Details
tests / assets (push) Successful in 24s
Details
tests / release (push) Has been skipped
Details
TCPDF 6, deliberately, after installing 7 and finding out what it is. Version 7 is not a newer TCPDF; it is a compatibility shim over tc-lib-pdf whose own mapping table lists 115 of 293 methods as stubs, and whose font package ships no font files at all — the first render died on a missing helvetica.json. The 6.x line carries a hundred and sixty-five fonts, a working writeHTML and real header and footer hooks, and is what everybody means by TCPDF. The body is a Blade template handed to writeHTML rather than a hundred Cell() calls with millimetre coordinates, because this table has to be adjusted the first time somebody sees it on paper, and nobody adjusts the second kind. InvoiceMath is integer cents end to end, and rounds once. A percentage applied line by line and rounded each time drifts from the same percentage applied to the sum — three lines at 3,33 € less 10 % is 99 cents that way and 100 cents the honest way, and the customer's calculator is what finds it. Gross is always derived from net and never the reverse: taking VAT back out of a gross figure returns a different number often enough to matter. A discount and a surcharge are one operation with opposite signs, so nothing has to remember which of two flags means which. Quantities are thousandths, because hours, gigabytes and part-months are all real, and are printed with the trailing zeroes stripped — "12,000 Std." reads as a defect. Every line shows quantity, unit price net, total net AND total gross: this goes to businesses and to consumers, and each reads a different column first. Verifying it took two goes and both failures were mine. `strings` over a PDF set in a Unicode font finds nothing, because the text is stored as subset glyph ids — the check has to render in a core font to be a check at all. And two of the strings I then declared missing were simply expectations I had got wrong. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
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> |
|
|
|
85fcc154b8 |
Measure availability, and let a customer move down again
Availability is now counted from our own monitoring answers. Uptime Kuma knows the figure, but the REST bridge in front of it only answers "healthy, yes or no", and waiting for the bridge to grow an endpoint would have left the panel without the number indefinitely. The sync already asks on a schedule; counting the answers gives a percentage that is ours and explainable. An unanswered check is not a failed one — the counter only moves when a real answer arrived, so our monitoring going down does not become the customer's outage on the one figure they are asked to trust. An instance nobody has ever checked shows no availability rather than 100 %. The downgrade path existed nowhere. Going down is not the mirror of going up: it can ask an instance to hold more than the target plan allows. Smaller plans are now listed even when they cannot be taken, with the obstacle in the customer's own numbers — "you have 31 users, this package allows 10" — because a greyed-out button that does not name what is in the way is the thing people ring about. Storage is checked against the last real reading rather than the contractual allowance, or anyone who once bought a large plan could never leave it. The limit is re-checked in the action: a rule enforced only in markup is not enforced. And the smaller corrections from this round: - The cloud tab carried a hardcoded "B" as the instance's initial, and the PHP and MariaDB versions, which tell a tenant nothing they can act on. - "EU — Serverstandort im Angebot festgehalten" under a label already reading "Serverstandort" was two sentences saying nothing. It is the country. - Support promised a reply "binnen 4 Std." with nothing behind it. What is true everywhere else is an answer on the same working day. - The actions column appeared even when no row in the table had an action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
0560ae743d |
feat(billing): Stripe owns the billing cycle, we own capability
A Product per plan family and a Price per priced row of a published version, plus the four subscribed webhook events that were arriving and being ignored. `stripe:sync-catalogue` mirrors the catalogue. Idempotent twice over: a stored id is skipped, and each call carries an idempotency key derived from our own row, so a crash between Stripe creating a Price and us recording its id gives back the same object rather than a second one. That matters more here than usual — a Stripe Price cannot be edited or deleted, so a duplicate is permanent. Drafts are not synced at all: a version that has promised nothing has no business in a price list. --dry-run shows what would be created. The webhook now handles invoice.paid, invoice.payment_failed, customer.subscription.updated and customer.subscription.deleted. It never re-derives an amount: the invoice is the authority for what was charged, and recomputing it from our catalogue would produce a second, disagreeing answer. A failed payment is recorded and nothing else — Stripe runs the dunning schedule, and cutting a customer off on the first failure would punish an expired card as though it were a refusal to pay. Only a cycle renewal moves the term on. Stripe also sends paid invoices for prorations and manual charges, and the checkout's own invoice — which is already in the register as the purchase, and would otherwise double every customer's first payment. Stripe does not guarantee delivery order, which is the source of most of the care here: - state changes are judged and written in one transaction with the row held, so two deliveries cannot both pass a check made on a stale copy; - an older event never overwrites a newer one, with a rank breaking ties between events sharing a second — their timestamps have one-second resolution, and a failed attempt and its successful retry routinely do; - a term is never shortened, and an ended contract is never revived; - an event for a contract that does not exist yet is HELD and replayed the moment it appears, rather than acknowledged and forgotten. A cancellation dropped that way would leave us serving someone who had already left. Held rows that never match are pruned after a week. Register entries are keyed by what they are about — the invoice, the attempt, the subscription — with a unique index doing the work, because a check two concurrent deliveries can both pass is not idempotency. PlanChange is now documented as the preview shown before someone confirms, not the invoice: Stripe's proration accounts for tax, existing credit and the exact second the change lands, and ours cannot. Not run against the live account — `stripe:sync-catalogue` creates objects that cannot be deleted, so that is the owner's call. The dry run lists 12. 457 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
7582df3de6 |
feat(billing): a proof register, and modules frozen at their booked price
Two things the contract could not answer on its own: what happened, and what the customer's whole bill is. `subscription_records` is append-only, one row per commercial event. Flat columns for everything searched or relied on as evidence — event, customer, subscription, plan family and version, term, net/tax/gross, currency, tax rate, reverse charge, Stripe ids — PLUS a versioned JSON copy of the whole snapshot. Not JSON alone: you cannot query it, and "the amount is in there somewhere" is poor evidence. Copied, not joined, so a row still answers after the customer, the plan or the version have gone. The flat columns describe the TRANSACTION. Gross is what was actually taken; net and tax are that gross split by the rate that applied on the day. A discount lowers the taxable amount rather than creating negative VAT, and a free checkout is recorded as free instead of as paid in full. What was agreed sits beside it in the snapshot, so a charge that differs from the catalogue is preserved as a question rather than reconciled away. The register refuses to be rewritten, in bulk as well as one row at a time — model events do not fire for `query()->update()`, which is exactly the shape a careless data fix takes. `subscription_addons` carries the owner's rule: a customer's total is their subscription plus their modules, and all of it is frozen at what was agreed. Price protection covering only the plan would let a module quietly double for someone who booked it two years ago. A module they have NOT booked is a sale still to be made, at today's price — so the catalogue is consulted only for what is not in this table. Several bookings of one module are summed rather than reduced to one arbitrary row, or the page and the bill would disagree. Concurrency: one order books one module, enforced by a unique index rather than a lookup two retries can both pass; cancellation is an atomic claim; and each booking commits with its own register entry, so a failure cannot leave a sale without evidence and a retry that finds the booking cannot skip the event. Verified in the browser: with the module raised from 29,00 € to 59,00 € in the catalogue, the customer who booked it still sees 29,00 € and a total of 208,00 €, while a new customer is quoted 59,00 €. 436 tests green. Codex review clean after seven rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f6ddf45dc0 |
feat(admin): a console for creating, pricing and scheduling plans
The owner can now do from the console what only a config edit could do before: create a plan line, draft a version, price it, publish it into a window, and pull a plan out of the shop. Two pages and one modal, because the catalogue has exactly two levels. The plan list is a name, a rank and a kill switch. Everything that can be got wrong — capabilities, prices, windows — lives on the versions, where the page is built around the one rule that matters: a draft is freely editable, a published version is not touchable at all. So drafting and publishing are separate acts, and publishing says plainly that it is final. The console refuses everything the catalogue refuses, on the form rather than as a stack trace: an overlapping window, a window that ends before it starts, a version with no price for a term we sell on, and — new here — a version with no VM template, which would be bought and then fail provisioning every time. Numbers are bounded on both sides, because a mistyped price otherwise overflows the column and answers with a 500 instead of saying which field was wrong. Performance class and features are picked from the keys we have labels for; free text would be frozen at publication and shown to customers as a raw translation key forever. Concurrency, since two admins share one catalogue: draft numbers are allocated under a lock on the family, publication is an atomic conditional claim, and discarding a draft is one statement conditional on it still being a draft — otherwise a draft published in the meantime could be deleted out from under the customers now contracted to it. `plans.manage` (Owner and Admin) guards the pages themselves, not only the buttons, and the modal authorises itself — prices, drafts and unreleased plans are not something to leave readable to anyone who types the URL. Also fixes the shared checkbox, which ticked in the browser's own blue on every page that used one: a native checkbox ignores text colour and needs accent-color. Verified in the browser: withdrawing a plan in the console removes it from the customer's billing page immediately, and restoring it brings it back. The public landing page carries no catalogue-driven plan list, so there is nothing there to hide. 421 tests green. Codex review clean after six rounds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
6387c747d0 |
feat(billing): the plan catalogue becomes three tables, and config stops selling
Plans lived in config/provisioning.php, where the owner cannot reach them and
where a plan has no history. They now live in plan_families / plan_versions /
plan_prices — a name that never moves, what the plan WAS at a point in time,
and a price per version and term with its own Stripe Price id.
- Availability is computed on every read (available_from <= now < until,
half-open, UTC) plus a per-family sales kill switch. Nothing schedules a plan
into or out of sale: a job that fails to run is a plan that silently
misbehaves.
- Overlaps crash rather than resolve. currentVersion() uses sole(), and
scheduling takes a lock on the family and rejects an overlapping window —
two versions on sale at once would decide a customer's price by row order.
- A version is frozen from publication, not from first sale, and so is its
price: a checkout is not instant, and an amount edited between the session
opening and the webhook landing would contract someone at a price they were
never quoted. Repricing publishes a new version, as Stripe requires anyway.
- Neither a published version, its price, nor a family with customers can be
deleted, and a family key cannot be renamed. All of those would null the
provenance off existing contracts.
- Subscriptions and orders record plan_version_id. A historical reference
resolved by plan NAME hands back today's terms, which is the split-brain one
level up. A checkout that carried its version is honoured even after the
window closes — they paid for what they were shown.
Switched atomically and failing closed: config('provisioning.plans') and
plan_features are gone, and nothing falls back to them. A fallback would
resurrect a plan the owner had just switched off. The seed lives in the
migration, and PlanCatalogueTest pins what the config catalogue sold as the
shadow comparison. `php artisan plans:check` reports overlaps, gaps and
missing prices before a customer finds them.
Contract-backed displays, which were reading the live catalogue:
seat limits, the cloud card, the plan card, and admin MRR — the last three now
divide a yearly contract down via Subscription::monthlyPriceCents(), since all
three label the figure per month.
Two recovery gaps closed along the way: the order now commits before the
contract is opened (so a contract failure can never erase the record of a
payment), a Stripe retry repairs a missing contract and restarts a run stranded
with no_subscription, and TickProvisioning sweeps runs left pending by a crash
rather than only running/waiting ones.
402 tests green. Codex review clean after thirteen rounds. Verified in the
browser: portal, billing and console render unchanged off the new catalogue.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
6119044669 |
fix(billing): no plan changes after cancellation, no unknown terms
tests / pest (push) Successful in 6m46s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
A cancelled subscription still reported an upgrade as allowed and priced it, so a caller trusting that would have provisioned and billed a customer who had already left. And any term string other than the two we support was silently priced monthly while keeping the unknown value — a subscription whose price and billing period disagree. Both are refused now. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |