Skip to content

release: commission price floor (EUR 9) + the self-serve billing fix - #226

Merged
mahmutkaya merged 2 commits into
mainfrom
develop
Sep 5, 2026
Merged

mahmutkaya merged 2 commits into
mainfrom
develop

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

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).

PR What
#224 commission prices online-payments at EUR 9/mo, not EUR 0. Rate unchanged at 150 bps. Crossover ~EUR 666.67/mo.
#225 a self-serve commission buyer is billed what they were quoted

Why 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: SignupRequest 1 / commission 0, TenantBilling 2 /
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 main for the whole life of the feature, and no test could reach it because the
prose 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 a stripe_account.

Rollout

No migration step. compose pull sofra && up -d sofra on the box, then verify /api/health reports this
merge commit and POST /api/webhooks/stripe still answers 400 (the rail is armed).

…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.
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit ec05455 into main Sep 5, 2026
23 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant