release: commission price floor (EUR 9) + the self-serve billing fix - #226
Merged
Merged
Conversation
…t EUR 0 (#224) * feat(pricing): commission mode prices online-payments at EUR 9/mo, not EUR 0 The reduced floor the owner decided (workspace docs/plans/BACKLOG.md): `commission` prices the `online-payments` module at EUR 9/mo instead of EUR 0/mo. The default rate stays 150 bps. Offering `flat` and `commission` as a free peer choice collects min(flat, commission) from every tenant, which is structurally worse than charging everyone the EUR 19 flat module. The floor keeps the choice and caps the downside at EUR 9/tenant/mo instead of EUR 0. The crossover follows the price: it is driven by the DELTA between the two modes (EUR 19 - EUR 9 = EUR 10), not the full flat price, so it moves from ~EUR 1,267 to ~EUR 667/mo of online turnover. `crossoverCentsPerMonth` now DEFAULTS to COMMISSION_MODE_SAVING_CENTS, so no UI caller passes a basis at all and none of the four can pass the wrong one. * test(pricing): pin the crossover basis at every call site The arithmetic tests cannot see the mistake this slice exists to prevent: a caller that passes ONLINE_PAYMENTS_PRICE_CENTS type-checks, renders, and prints EUR 1,267 where EUR 667 is true. Mutating one caller that way left the whole suite green — the unit suite is scoped to pure modules and nothing renders these components. So the invariant is asserted where it lives, in the call sites: every production caller passes the rate ALONE and lets the default basis apply. Carries its own positive control (the scan must find all four call sites), so a rename or a moved directory turns it red instead of vacuously green. * fix(pricing): correct the registry-PR crossover direction and stop the price prose drifting Findings from an independent review pass, actioned: BLOCKER — lib/registry-commission-pr.ts said "below that figure `flat` would have cost this tenant less; above it, `commission` does". That is inverted: below the crossover the turnover is small, so commission (the EUR 9 floor plus a small cut) is the CHEAPER mode. It also contradicted the sibling sentence this branch wrote in messages/*.json. Wrong on `develop` too, and a founder reads it in the registry PR immediately before merging a live rate. The two PR-body builders hardcoded "EUR 9 / EUR 19 / EUR 10" as prose. This same branch deleted a duplicated catalog lookup out of registry-commission-pr.ts to stop price drift, then reintroduced it one layer out as strings — a body that would keep quoting the old floor after it moved, while every UI surface updated. Both now read the constants. app/api/signup/route.ts carried a comment that this change made false ("that total EXCLUDES the online-payments module" — it now includes it at the floor). i18n: flatSummary/modeFlat still hardcoded "EUR 19/mo" beside a crossover string that interpolates the same price, so the next price change would have made one paragraph contradict itself. Both now take {price}. One vocabulary across all three namespaces: {price} = the full list price, {floor} = the commission floor, {saving} = the difference. * test(pricing): make the registry-PR body a testable surface The inverted crossover sentence fixed in the previous commit had survived since the feature shipped, and a mutation run showed why: re-inverting it left the whole suite green. `commissionChangePrBody` lived inside the module that calls GitHub, so nothing could reach it without a token and the network — it was decided by reading, and reading is what missed it. Split the pure body out into `lib/registry-commission-pr-body.ts`, the same split `provisioning.ts` already made for `provisioning-pr-body.ts` / `provisioning-pr-blocks.ts`, and test it: the direction of the sentence, the EUR 666.67 figure and the fact that EUR 1,266.67 does not appear, that every price in the prose is derived from the constants, that 0 bps prints no figure at all, and that the enforcement-only warning survives. Added to the coverage include list for the reason that list is explicit: leaving a pure module off moves covered code out of the floor's scope and reads as a passing gate. Mutation: re-inverting the sentence now fails 1 test; pointing the body's crossover back at the full list price fails 3. * chore(test): use toHaveLength for the call-site count (Sonar S5906) The only open Sonar issue on the PR. A generic length assertion reports worse on failure, and this is the positive control that stops the whole call-site scan from passing vacuously — so it is the one assertion whose failure message matters most.
…quoted (#225) `decideSelfServe` re-quoted the MODULES and skipped the payments mode, while `sanitizeSignupConfiguration` recorded the mode-adjusted total. So a buyer who chose `commission` on the public configurator was SHOWN one number and BILLED a higher one — and it is the billed number that becomes the Mollie subscription amount and the figure in the welcome email. The founder's own new-lead notice printed both, side by side, and disagreed with itself. The mode-adjusted quote is the CORRECT number: it is what the buyer accepted. The re-quote stays a re-quote (reading the stored `quotedCents` would let a crafted POST influence a charge) — it just reproduces the WHOLE quote now, mode included, through the same `paymentsModeQuote` the sanitizer uses. The test asserts the two PATHS agree rather than asserting a number, across both modes, with and without the module, and on top of a bundle. Two independent computations of one price IS the defect; a value test would let them drift apart again. It carries a control: if the two modes ever cost the same, every agreement assertion would pass while measuring nothing. Gap was EUR 19, became EUR 10 when the commission floor landed (#224), and is now zero. Nobody was ever charged wrongly: measured on prod before writing this, SignupRequest 1 row / 0 on commission, TenantBilling 2 rows / 0 on commission / 0 self-serve. The funnel is live; the defect was latent.
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Release — the commission price floor + the self-serve billing fix
Two commits. No migrations in this release (verified:
git diff origin/main..origin/develop -- prisma/migrations/is empty).commissionpricesonline-paymentsat EUR 9/mo, not EUR 0. Rate unchanged at 150 bps. Crossover ~EUR 666.67/mo.commissionbuyer is billed what they were quotedWhy these ship together, and not separately
#224 halves the self-serve quote-vs-bill gap (EUR 19 -> EUR 10) but does not remove it. Releasing the floor
alone would put a smaller-but-still-wrong number in front of the first self-serve commission buyer. #225 is
the fix. The gap is latent today — measured on prod:
SignupRequest1 / commission 0,TenantBilling2 /commission 0 / self-serve 0 — but the funnel is live, so it is real.
What #224 corrected that nobody could see
The registry PR body a founder reads immediately before merging a live rate stated the crossover
backwards. It was wrong on
mainfor the whole life of the feature, and no test could reach it because theprose lived inside the module that calls GitHub. It is now a pure module with a test.
Still inert
Every tenant is
flat/ 0 bps; 0 emits no fee parameter. No tenant has astripe_account.Rollout
No migration step.
compose pull sofra && up -d sofraon the box, then verify/api/healthreports thismerge commit and
POST /api/webhooks/stripestill answers 400 (the rail is armed).