8 Commits (f294db5259fa82bfc3520c3ac6febd3bcff87c73)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
3b5dee8f0d |
Ein Ausweg wird nur angeboten, wenn er auch buchbar ist
DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon. Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt, landet jede Ueberschreitung ueber 60 GB dort. Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)" an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt, und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt. AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt. DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute Knopf mit Preisschild. packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum: die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal, die beide keine Verkaufsfrage stellen. Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch, wo es gar keine gibt. Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen, wofuer sie geschrieben wurden. Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese Weiterleitung. Voller Testlauf: 2405 bestanden. Pint sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
afa6183903 |
Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise
100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird. BookAddon haelt den Ausschluss durch AddonCatalogue::availabilityRefusal() (gleiches Muster wie CustomDomainAccess), damit greift er auch beim Verschenken durch den Betreiber. Billing::purchase() und storageLimitNote fragen dieselbe Regel VOR der Zahlung, sonst haette ein Enterprise-Kunde einen zahlbaren Auftrag anlegen koennen, den BookAddon erst danach abgelehnt haette. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
1f32a2d5b3 |
Hoechstens drei Bloecke, geprueft in der Buchung
Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke. StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest; angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt fuer DowngradeTest vorgezeichnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2ab6a92fd8 |
Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration
Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden, der bereits Daten darin liegen hat. 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> |
|
|
|
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> |
|
|
|
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. |
|
|
|
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> |