4 Commits (3e7026407bb3ee2b2a18c8d37d48588b8ff7548e)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
3e7026407b |
Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two pages said opposite things about one account, straight after registering: the dashboard said "noch keine Instanz", and Paket & Addons said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The package page fell back to `$instance?->plan ?? 'start'` and rendered that fallback as a contract the customer had entered into, complete with a status of Aktiv, on the one page somebody opens to find out what they pay. The contract is resolved from the CUSTOMER now, not from the instance — a paid order parked for want of a machine has a subscription and no instance yet, and asking the instance would tell somebody who has paid that they have no package. An instance counts as well, whatever its subscription row says. With neither, the page says so and points at the booking page: an "Upgrade" from nothing is a purchase, and that has its own page. The cart stays outside that gate. A customer can have something in it before they have any contract at all, and hiding it there would hide the thing they came to pay for — which is what my first version did, and seven existing tests said so. And the booking page. Five cards in a four-column grid left the fifth alone on its own row, reading as a mistake; the consent box sat at the BOTTOM with every buy button disabled until it was ticked, so the page arrived looking broken before anybody had done anything wrong; and the price ran across the card in one block with nothing to compare. Now the consent comes first, because it is the condition for everything under it, and it colours itself green once given. Three columns, so five plans read as 3 + 2. Each card is the same four rows in the same order — price, delivery, what you get, the button — so the eye can run across a row and compare like with like. The button stays ENABLED with the reason it will not go through said under it: a disabled control explains nothing, and the server is the gate either way. 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> |
|
|
|
e0c109c449 |
Ask for the express request to begin, and send it to Stripe
tests / pest (push) Failing after 7m59s
Details
tests / assets (push) Successful in 18s
Details
tests / release (push) Has been skipped
Details
The withdrawal machinery has always read `immediate_start` off the checkout session's metadata — StripeWebhookController stamps `immediate_start_consent_at` from it, WithdrawalRight refunds pro rata when it is there and in full when it is not. The checkout never put it there. So every contract concluded through this application carried no consent, and a withdrawal on day thirteen of a running cloud took back the whole amount however many days the machine had run. That is the lawful outcome without the consent, and it was the only outcome available. Now the checkout asks. The box is required — validated `accepted`, not `present`, because an empty field is what an unticked checkbox sends and a consent that can be produced by leaving something blank is not a consent — and the metadata carries exactly the string '1' the webhook compares against. One box for the page rather than one per card: the sentence is the statutory wording and four copies of it is a wall nobody reads. The forms carry it as a hidden field bound to that box, and the buy buttons stay disabled until it is ticked. With JavaScript off the field arrives empty and the server refuses with the sentence explaining why — the safe direction to fail in. Ticking a box in a browser was never the record anyway: the record is the timestamp taken when the payment lands. Tested at both seams and along the whole chain: refused without it, refused for '', '0' and a word that is not yes, carried to Stripe as '1', and a webhook carrying it lands as a timestamp on the order while one without it lands as null. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f85f57152e |
Let the customer buy it themselves, and tab the integrations page
tests / pest (push) Failing after 21m20s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Every path on the site ended in "anfragen": a mailto: link and a promise to answer the same working day. That is an afternoon of somebody's time per customer, for a product whose whole point is that the machine does the work — and it scales exactly as far as one inbox does. Now: sign in, pick a package, pay, and provisioning starts by itself. The pieces were already there — Stripe holds a Price per plan version (the catalogue sync writes the ids), the webhook opens the contract, the pipeline builds the VM — and what was missing was the checkout session between them. CheckoutController opens one and gets out of the way; the purchase becomes real on the webhook, which stays the only place allowed to believe a payment happened. The success URL is a redirect target and therefore a URL anybody can type, so it says "we are building it", not "you have paid". Nothing in the request may decide the price. The plan key arrives from a form and everything else is re-derived from the catalogue, including the plan VERSION, so a version published while the customer types their card number cannot change what they were quoted. Two cases that are not errors: no capacity is a parked order (already built), and an existing customer is sent to the plan change rather than sold a second, empty cloud that bills twice. The site says what it can keep. "danach wissen Sie … wie Ihre Daten sicher umziehen" was a promise made before seeing the thing it is about: we do not know where a visitor's data is, what shape it is in, or whether it can be moved at all. Migration is named as its own question, looked at first and quoted afterwards. The integrations page gets the settings page's treatment for the same reason: it was one narrow column of six cards, so an operator connecting DNS scrolled past Stripe to reach it and the raw .env editor sat at the bottom of everything. Three tabs by what a section is FOR — services we buy from outside, our own machines, the file underneath — the same axis the page was rebuilt around when it stopped being split by storage mechanism. The tab is in the query string; unlocking survives a switch, because a confirmed password is a session fact and not a property of a tab. The .env tab is Owner-only, like the editor it holds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |