Skip to content

release: the payments commission mechanism (S1-S4, fee-refund rail, earnings ledger) - #223

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

mahmutkaya merged 7 commits into
mainfrom
develop

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

Release — the payments commission mechanism (S1–S4 + both blockers' code)

Seven commits from develop. Every tenant is on flat / 0 bps, and 0 emits no fee parameter at all, so this
release is inert on the money path. It ships the switch, not a rate.

PR What
#216 S1 — flat/commission foundation, lib/payments-pricing.ts, registry emission
#217 the fee-refund rail: a connected account's refund returns the application fee, proportionally
#218 S2a — the surgical registry editor + audited server action
#219 S2b — the owner's /admin/billing/[id] panel and the /admin/tenants badge
#220 S3 — the pricing mode as a choice in the public signup configurator
#221 S4 — the same control for a partner's own clients, ownership-scoped
#222 records what the commission earned, per tenant

FOUR migrations, founder-run BEFORE the app rollout

They never run on container start.

  1. 20260904090000_payments_pricing_mode
  2. 20260904120000_stripe_fee_refund
  3. 20260904130000_signup_payments_mode
  4. 20260905000000_stripe_application_fee

Verified absent from main individually, with a positive control on 20260830120000_partner_brand to prove the
check can find a migration that is on main. All additive.

Rollout (sofra is not auto-deployed)

docker pull ghcr.io/piwas-21/sofra:migrate
docker run --rm ... ghcr.io/piwas-21/sofra:migrate     # = prisma migrate deploy
docker compose -f docker-compose.prod.yml pull sofra
docker compose -f docker-compose.prod.yml up -d sofra

Verify with curl -s https://sofrapiwas.com/api/health and check version is this merge commit.

What is still owner-side, and is NOT required for this release

Registering the second, account-scoped (non-Connect) Stripe endpoint for application_fee.created, and putting
each whsec_ on the box. Until then both webhook branches answer 503 — correct while no tenant is on a rate.
Audit that endpoint by asserting application is null: the API does not echo connect back.

Not in this release, deliberately

The commission price (reduced-floor recommendation in BACKLOG). It is the first change that moves a
customer-facing quoted price, so it gets its own PR and an owner yes.

mahmutkaya and others added 7 commits September 4, 2026 21:32
)

* feat(payments): foundation for a flat/commission pricing mode (S1)

Lets a tenant be billed a flat online-payments module price OR a
per-transaction commission instead of only the flat fee, without changing
anything for the tenants already on it. Adds the pure pricing arithmetic
(lib/payments-pricing.ts: mode type, default/max commission rates, the
quote adjustment, and the crossover turnover the plan requires every
switching surface to show), the TenantBilling.paymentsMode/
paymentsCommissionBps columns (both default to what every tenant already
is), and has the registry generator emit payments_commission_bps only when
online-payments actually survives the module/account pairing split —
never against a deferred module, or provision-tenant.sh would refuse the
whole entry. No UI yet (S2-S4 land separately): this is the foundation the
plan calls S1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(provisioning): lift the PR-body suffixes out of nested ternaries

SonarCloud typescript:S3358 on the summary line. The conditional commission
suffix sat inside a template literal that was itself inside a conditional, which
is the shape the rule rejects and is genuinely hard to read: the
"(not written)" caveat belongs to the commission but appeared to sit beside the
deferred-module list.

Both suffixes are now plain statements above the return, and the reason the
caveat exists is written down — a rate is shown when the module is deferred
because the second registry PR will carry it, but must not read as live.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…efunds a charge (#217)

* feat(payments): return the application fee when a connected account refunds a charge

ADR-011 amendment consequence 1 ("fee follows the refund") — Stripe never
auto-refunds an application fee on a Connect direct charge, so a restaurant
refunding a meal in its own dashboard silently ate Sofra's commission. Only
the platform can return the fee, via one platform-level `connect: true`
webhook routed on `event.account` (Stripe refuses per-connected-account
registration).

- lib/stripe.ts — minimal fetch-based client, no SDK dep (mirrors lib/mollie.ts)
- lib/stripe-signature.ts — Stripe-Signature verification, pure/clock-free,
  timing-safe compare
- lib/stripe-fee-refund.ts — feeRefundAmount (half-away-from-zero proration,
  rounds against Sofra on a tie) + the charge/fee refund orchestration
- app/api/webhooks/stripe/route.ts — the endpoint; disputes deliberately left
  unhandled (fee-reversal behavior on dispute is unverified)
- StripeFeeRefund model + handwritten migration, unique on the Stripe refund
  id as the idempotency anchor; also seeds the fields a future per-tenant
  commission-revenue readout will need (ADR-011's second recorded gap)

18 new unit tests (feeRefundAmount's 9 hand-derived vectors incl. the
half-away-from-zero rounding discriminator, plus the signature verifier).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(payments): prove the fee-refund ceiling clamp, which nothing exercised

Mutation found it: deleting the Math.min(...) that caps the refund at what
remains of the fee left all nine existing vectors green. The clamp is
unreachable through Stripe — a charge cannot report more refunded than its own
amount — so it is defensive code, and defensive code no test exercises is a
guard nobody can show works.

The new case feeds it the impossible input directly. Without the clamp we would
ask Stripe to refund 75 against a fee of 60: a request Stripe rejects, which
would turn a silent upstream anomaly into a webhook that fails and retries
forever. The mutant now dies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(payments): amend an existing tenant's commission rate (S2a)

The mechanism half of the owner's /admin switch (SOFRA-PAYMENTS-PRICING-MODE-PLAN
S2): a pure, surgical line-editor for tenants/registry.yml's
payments_commission_bps (a YAML round-trip would delete the file's hand-written
comments), a billing-vs-enforcement predicate mirroring payments-pending.ts's
fail-quiet direction, and the admin action that opens the amendment PR before
writing the Prisma intent — never the other way round, so a failure after the
PR is an orphan PR, not an orphan charge. No UI; that is a separate slice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(payments): pin prefix-safety with a pair the ordering cannot rescue

The demo/demo2 case passed for the wrong reason. findBlock uses findIndex, which
returns the FIRST match, and `demo` precedes `demo2` in the fixture — so a header
regex that lost its trailing ':' still found the right block by luck. Mutation
proved it: dropping the ':' left all 14 tests green.

zeta2 is placed deliberately BEFORE zeta, so the same bug lands on the wrong
tenant instead. That mutant now dies. The property this protects is the one worth
having: editing the wrong tenant's money configuration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor(payments): clear the seven Sonar findings on the S2a slice

All seven were in the new code and all were fair:

- S3776 x2, cognitive complexity 17 > 15. Both fixed by lifting branches into
  named functions rather than raising a threshold: the registry editor's three
  write paths become rewriteExisting/insertAfterStripeAccount, and the server
  action's two error funnels become proposeCommissionChange/recordPaymentsModeIntent,
  so the action reads as the sequence it is — propose, then record.
- S6594 x3, String.match -> RegExp.exec.
- S7780 x2, String.raw for patterns carrying backslash escapes.

Both extractions pushed their file past the 200-line limit, so the error classes
moved to registry-commission-errors.ts (re-exported, no importer changed) and the
action helpers to actions/payments-mode-change.ts — the same split this repo has
taken before rather than compressing the comments that explain why each refusal
exists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…2b) (#219)

* feat(payments): the owner's admin UI for the payments pricing mode (S2b)

Fixes the bug that would have made this silently wrong: tenantSchema never
listed payments_commission_bps, so zod stripped it on every read and every
commission tenant would have rendered as permanently "pending" — the same
trap stripe_account fell into.

/admin/billing/[id] gets a panel showing the EFFECTIVE mode (derived from the
registry, not the stored intent), a pending explainer when the two disagree,
the crossover turnover as a plain sentence, and a form to change the mode +
rate. Commission is disabled with a stated reason when the tenant's registry
entry lacks online-payments + stripe_account, since provision-tenant.sh
refuses a non-zero rate without that pair, before the database.
/admin/tenants gets a read-only effective-mode badge per tenant.

Six-locale parity for every new string. New pure helpers (commission
eligibility, bps percent formatting, paymentsMode narrowing) are unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(a11y): the pending notice is an <output>, not a <p role="status">

SonarCloud S6819. <output> carries the same implicit ARIA role while being the
element browsers and assistive tech already understand, so the announcement is
better supported rather than merely differently spelled.

Added `block` with it: <output> is inline by default and this notice is
paragraph-shaped, so without it the spacing silently changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…figurator (S3) (#220)

* feat(payments): the payments pricing mode as a choice in the public signup configurator (S3)

The buyer can now pick flat vs commission for online-payments at /signup,
with the crossover sentence in their own language and an honest note that
the module is deferred to a second registry PR until they complete Stripe's
own onboarding — the choice is recorded as a preference, not switched on.

SignupRequest carries the mode AND the rate shown (not just the mode): the
default rate can change later, and a historical signup whose meaning
silently shifted with it would be a record of nothing. The server re-quotes
through paymentsModeQuote and drops an unrecognised mode (or one posted
without online-payments in the selection) to flat, mirroring the module's
existing DROP/RE-QUOTE rules. /admin/signups shows what was picked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(signup): the founder's new-lead mail shows the payments mode

It already listed the quote, and under `commission` that number EXCLUDES the
online-payments module — so a founder reading the mail alone would see a cheaper
plan with no visible reason for it. The mode belongs next to the figure it
explains.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(a11y): the mode radios name the option, and describe the hint

SonarCloud S6853 on both radios. The rule could not see through the t() calls,
but the underlying shape was genuinely wrong: a WRAPPING <label> folds its whole
subtree into the accessible NAME, so each option was announced as its label plus
its entire hint sentence.

Now htmlFor names the option and the hint sits outside the label with
aria-describedby — the name is the choice, the description is the explanation,
which is what each actually is. Fixed rather than silenced.

Also lifts the mail's Payments row into paymentsRow(): adding it had pushed the
POST handler's cognitive complexity from 15 to 16 (S3776).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat(payments): record what the commission earned, per tenant

Closes the second commission blocker: StripeFeeRefund records fees RETURNED
and nothing recorded fees EARNED. Adds StripeApplicationFee (one row per
Stripe ApplicationFee, UNIQUE applicationFeeId, upserted), the
application_fee.created webhook branch, and a per-tenant readout on
/admin/billing/[id] beside the rate control.

MEASURED against the Stripe API and it corrects the plan: application_fee.created
is a PLATFORM event (account: null) and NEVER reaches the connect:true endpoint.
Platform scope 5 fee events / 0 charge.refunded; connected-account scope 0 / 2 --
the charge.refunded row is the control that makes the zeros trustworthy. Stripe
accepts a connect:true endpoint listing that event with HTTP 200 and then never
fires it, so a wrong config reads as "no commission earned yet". Hence a SECOND,
account-scoped endpoint at the same URL and a second secret
(STRIPE_ACCOUNT_WEBHOOK_SECRET); the route tries every configured secret and the
logs name the scope that verified, or the scopes tried when none did.

The migration also adds a nullable StripeFeeRefund.feeRefundedAt so both halves
periodise on Stripe's clock. Nets are NOT clamped: staging already holds two
refund rows whose fees predate this table, so day one is legitimately negative
and the panel reports the count instead of tidying it away. Totals are per
currency -- fees are CHF, eur() is the wrong instrument, so money() was added.

* refactor(payments): one error taxonomy for both webhook branches

Sonar: cognitive complexity 17 > 15 in the route handler. The two branches had
mirrored try/catch blocks saying the same thing twice — 404 acks, anything else
returns 5xx so Stripe retries. Shared as `acknowledge()` so they cannot drift,
which is also what the design asked for: do not invent a second error taxonomy.
Both log messages are unchanged, and each still carries the scope that verified
the delivery.
@sonarqubecloud

sonarqubecloud Bot commented Sep 5, 2026

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit e3ba130 into main Sep 5, 2026
27 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