14 Commits (feature/host-bootstrap)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
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> |
|
|
|
8440266ed3 |
Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was no such method on the client at all. So a customer who cancelled and a consumer who withdrew both stayed subscribed, the card went on being charged every month, and every one of those payments arrived as invoice.paid, drew a real number out of the gapless Austrian series and mailed the PDF, for a machine that had been switched off. Indefinitely. StripeClient::cancelSubscription() closes the first half, with the timing passed explicitly at the call site — CANCEL_AT_PERIOD_END for a cancellation, because the customer keeps the term they paid for, and CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and the whole amount has just gone back. The immediate form states prorate and invoice_now as false rather than inheriting them: a credit note of Stripe's beside our own Storno would be the same money twice. The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every invoice on a cancelled contract would lose the legitimate final one — the cycle for a term the customer really did use, unpaid, dunned for three weeks and settled after the contract ended. So the question is not when the payment landed but what it is for: an invoice whose billed period STARTED before the contract ended is documented, one whose period begins at or after that moment is not. Neither is a contract marked cancelled with no date, nor an invoice with no period, because a number handed out in error can never be withdrawn while a missing document can still be issued. The register entry is written either way — the money moved, and that is where an operator finds what has to go back. A cancellation also took its date from the order date plus MONTHS, whatever the term said, so a yearly customer who cancelled in March lost the nine months they had already paid for. It reads current_period_end now, which Stripe keeps current for both terms; the month-walk survives only for a machine with no contract behind it, where nothing records a term at all. Stripe is asked first and our rows are written only if it agreed: a cancellation we could not make effective is the defect above wearing a "gekündigt" label on the settings page, and a cancellation has no deadline, so being asked to try again costs nobody anything. A withdrawal cancelled the OPENING invoice only, found by invoices.order_id — which a renewal and a module document leave null on purpose. A storage pack booked on day three and withdrawn from on day ten was therefore neither cancelled nor refunded. Every charge inside the window is covered now, one Storno per document and one refund per payment, each against the payment that actually took it: a single refund of the total against the opening PaymentIntent is what Stripe would have refused for exceeding it, and one idempotency key for the whole withdrawal would have had Stripe replay the first refund's answer for the second. StartCustomerProvisioning::resume() opened a missing contract and returned, and the only production call to IssueInvoice::forOrders() is after the order commits — so a worker killed in between cost that customer their invoice for good, silently, with nothing anywhere sweeping for it. resume() finishes that work too, guarded by the invoice already filed against the order and by invoices.sent_at, so however often Stripe redelivers it comes out as one invoice, one number, one mail. The order confirmation stays on the first pass alone: it has nothing to stamp. subscriptions.cancel_requested_at is new because the billing half of the application had no way to tell a cancelled contract from an untouched one. Deliberately not a status: until the term runs out this is a paying customer, which is the distinction EndInstanceService is built around, and `cancelled_at` beside it still means the day it genuinely ended. clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock no longer points at a note in routes/console.php that never existed. Reverse charge rested on a one-off VIES answer, a withdrawn registration keeps earning rate 0 for ever, and the unpaid VAT on those invoices is the seller's — so the cadence is the width of that window, and the first of the month is the rhythm the return is filed on. Two tests that proved nothing are now about behaviour. SettingsTest asserted service_ends_at was not null, a column the code had just written and which the broken monthly arithmetic satisfied exactly as well as the right answer; it states the yearly date, the contract's own record and the order to Stripe. EndInstanceServiceTest travelled to whatever value the implementation had computed, so it recomputed the code instead of checking it, and never looked at billing at all; the boundary is a literal date now and the money stopping is asserted beside the address coming down. 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> |
|
|
|
79564947d4 |
Actually issue the invoice when the money arrives
tests / pest (push) Failing after 7m24s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Everything was built — numbering, the frozen snapshot, the PDF, the mail, the archive and its retention — and none of it was connected to anything. IssueInvoice was called from nowhere but its own tests. So on a real server no invoice had ever been created, the handover directory was empty, and rsync was correctly copying nothing. Reported as "nothing gets copied", which it was. One mail, not two. The invoice is issued the moment the payment lands, so an order confirmation and an invoice mail would otherwise go out in the same second for the same event — a defect rather than a preference. The invoice mail carries the document and says everything the confirmation said, so it replaces it whenever there is an invoice; the confirmation stays for when there cannot be one. Issuing is allowed to fail without taking the purchase with it. IssueInvoice refuses while the company details are incomplete, and that refusal is correct — an invoice without a registered name or a VAT number is not a valid invoice, and issuing one consumes a number that can never be handed out again. The order still stands, the machine is still built, and the invoice can be issued later. Nothing here may throw at the webhook either: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning and build a second machine over a missing VAT number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b1ab0f7f31 |
Put every mail in one design, and confirm an order when the money arrives
tests / pest (push) Failing after 7m48s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Four templates, one layout. Two of them — the maintenance announcement and its cancellation — still wore Laravel's markdown component, which brings its own logo, its own button and its own footer: the product was sending mail that looked like two different companies, one of which was the framework. New: an order confirmation, sent when the payment lands rather than when provisioning finishes. Those are minutes apart at best and can be much longer, and somebody who has just been charged and heard nothing assumes the worst about both the charge and the product. It deliberately promises no time for the cloud itself — provisioning takes what it takes, CloudReady announces the end, and a promised minute that slips is worse than no promise. Queued after the transaction, never inside it, and never allowed to fail the webhook: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning over a mail server having a bad minute. The finished-cloud mail now names what was actually built — address, plan, storage, location — because somebody who ordered a fortnight ago is checking it against what they bought, and that is a comparison rather than a sentence. Still no credential in it. Two tests earn their place. One holds every template on the shared layout, so a new one written in a hurry cannot copy-paste its way back to the framework's. The other proves the finished-cloud mail carries no password — asserted against the data the mail is given and the HTML it renders, not against the template's source, because the first version of that test tripped over the word in a comment and would have passed a template that leaked the value through a variable. It tested the prose, not the mail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
20329b176b |
Refuse a portal login for an address that already belongs to an operator
Customer::ensureUser() enforced uniqueness only within users, so a customer provisioned (or registering publicly) with an operator's email got a second, colliding identity for the same address — the exact thing R21 exists to prevent. Checks Operator by address now, not by session (the deleted assertNotAdmin() checked who was signed in, which could never be the right question). Public registration gets the same guard via a plain unique() validation rule, reusing Laravel's stock "already taken" message rather than a dedicated one — naming the collision specifically would tell a public visitor an address belongs to staff, a worse leak than the generic refusal every other collision here already gives them. Impersonation answers 409 instead of crashing into it. The Stripe webhook keeps recording the paid order (same principle as openContract()'s own comment: the order is proof money changed hands) and withholds only the colliding login, logged for an operator to resolve by hand. Verified the reverse direction Codex flagged as already covered: two Admin\Settings actions and the clupilot:create-operator command all already refuse an operator email that collides with a customer's, and that check is sound today because every users row is created in lockstep with a customers row of the same email (CreateNewUser and ensureUser are the only two creation sites, both confirmed by grep) and nothing in this app can change a user's email afterwards (Fortify's updateProfileInformation feature is disabled, and no other code calls that action). |
|
|
|
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> |
|
|
|
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>
|
|
|
|
52b41bb0d5 |
fix(billing): a paid order opens a contract, and provisioning obeys it
The pipeline re-resolved config('provisioning.plans') by order.plan, so the
subscription snapshot protected a customer's price but not their machine:
shrinking a plan resized an existing customer's VM on its next run. Nothing
created a subscription either, so closing this meant opening the contract at
purchase and pointing provisioning at it.
- OpenSubscription freezes the catalogue onto a subscription when a checkout
is paid; StartCustomerProvisioning calls it inside the order transaction.
- CustomerStep::plan() reads the frozen snapshot. ValidateOrder and
ReserveResources fail closed with no_subscription rather than falling back
to the catalogue, which is the bug itself.
- template_vmid joins the snapshot so a re-clone cannot pick up a blueprint
published after the sale. Deliberately outside FROZEN: it is how we build
the machine, not a term the customer is owed, and a dead template must be
replaceable without cancelling a contract.
- TrafficMeter reads the allowance off the contract too — cutting a plan's
traffic was otherwise enough to start throttling someone who bought more.
- The migration backfills contracts for orders that already bought something,
reconstructed from what was actually delivered where an instance exists,
and adopts an existing order-less contract instead of opening a second.
Orders paid in a currency the catalogue cannot price get none, matching the
checkout path.
price_cents stays the catalogue's NET price, which is what PlanChange
prorates against — not Order::amount_cents, which holds Stripe's GROSS total.
Reconciling the two belongs to the proof register and Stripe (phases 4/5).
Also pins STRIPE_WEBHOOK_SECRET blank in phpunit.xml: the operator's real
secret was reaching the suite from .env and rejecting every unsigned test
payload, which is why 7 webhook tests failed before any of this.
Verified in the browser: with team traffic cut from 3000 to 500 GB in the
catalogue, the customer's portal still shows 3 TB.
373 tests green. Codex review clean after three rounds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
df10448e5d |
feat(portal): settings page — company profile, branding (logo+colors), cancel package, close account
- customers gain profile + branding + closed_at; instances gain cancellation fields; branding resolver (NULL -> CluPilot defaults) snapshotted into the provisioning run context - cancel package: term-end, irreversible, typed-confirm modal (R5) - close account: guarded (no active package), typed-confirm modal (R5) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
879697c6ea |
feat(admin): impersonate customer portal — session login + return banner
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
e2b8f25e78 |
fix(engine-b): address Codex round 5 (capacity release, activate-last, guest-ip poll, email unique)
- Failed run releases the instance (status=failed) so its quota stops counting against host capacity. - CompleteProvisioning activates instance+order only AFTER onboarding tasks + credential delivery succeed. - ConfigureNetwork polls until the guest has an address (no wrong Traefik route). - customers.email unique + race-safe customer resolution in the intake action. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
|
|
|
65ba3d6588 |
feat(engine-b): Stripe webhook -> idempotent customer provisioning intake
Signed webhook (HMAC verify when secret set), CSRF-exempt route. Paid checkout creates customer+order+run (stripe_event_id unique => duplicate webhooks start one run) and dispatches. 5 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |