release: the payments commission mechanism (S1-S4, fee-refund rail, earnings ledger) - #223
Merged
Merged
Conversation
) * 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.
|
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 payments commission mechanism (S1–S4 + both blockers' code)
Seven commits from
develop. Every tenant is onflat/ 0 bps, and 0 emits no fee parameter at all, so thisrelease is inert on the money path. It ships the switch, not a rate.
flat/commissionfoundation,lib/payments-pricing.ts, registry emission/admin/billing/[id]panel and the/admin/tenantsbadgeFOUR migrations, founder-run BEFORE the app rollout
They never run on container start.
20260904090000_payments_pricing_mode20260904120000_stripe_fee_refund20260904130000_signup_payments_mode20260905000000_stripe_application_feeVerified absent from
mainindividually, with a positive control on20260830120000_partner_brandto prove thecheck can find a migration that is on main. All additive.
Rollout (sofra is not auto-deployed)
Verify with
curl -s https://sofrapiwas.com/api/healthand checkversionis 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 puttingeach
whsec_on the box. Until then both webhook branches answer 503 — correct while no tenant is on a rate.Audit that endpoint by asserting
applicationis null: the API does not echoconnectback.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.