Status: accepted · Date: 2026-11-13 · Implements: US-18…US-20, US-26…US-30, US-37, F4, F6, RN01–RN08, RNF05, RNF08, C4, C6, C7 · Related: ADR-0004, ADR-0009, ADR-0018, ADR-0019, ADR-0021, ADR-0022, ADR-0024
Enforcement: [test] ledger.rules.test.ts (C7: 10 scenarios balancing to zero, RN04–RN07), plan.rules.test.ts (RN01–RN02), outreach.procedures.test.ts (F4, RN08, RN02, RLS), seminar.procedures.test.ts (C6, US-26…US-30, RN04, RN07, RLS), prospect.procedures.test.ts (C4 extended to messaging, rate limits) · [lint] scripts/check-system-call-sites.mjs (three new SystemReasons) · [test] packages/db/src/rls-catalog.test.ts (grants: money and history tables insert-only).
The Instructor proposes and confirms a Seminar with an Academy (F4), sells tickets on a public page, checks students in offline and gets paid (F6). The two sides are different Organizations under row-level security, the buyer has no account, and money must be exact and auditable.
Decision
Three features. outreach (Threads, messages, Proposal Versions, deposits), seminar (the page, tickets, waitlist, check-in, results, report) and ledger (the books, payouts, the payments adapter). plan holds the Free/Pro limits. Every row belongs to the Instructor's Organization.
Warm contact (RN08, C4). A Thread is created only for a registered Academy (its academy_profile exists only with contact consent, read through prospect.registered-academies) or the Contact of an Introduction; a CHECK on outreach_thread requires one of the two. A Public Academy Record is accepted by the contract and refused by the service (FORBIDDEN, recipient: public_record), so the refusal is tested, not just absent. No outreach table has an email column.
Messages (US-18, US-20). outreach.writer.ts is the ADR-0022 adapter (AI draft + translation; vendor [TBD]). The fake drafts DE/EN/PT from the prototype's template and the structured terms, so switching language keeps the terms; translation is read-time in the reader's language, with the original one tap away. Proposal Versions are insert-only; recorded is an off-app agreement.
Pipeline. The Stop's stage moves forward only: send → contacted, accept or record → confirmed, deposit paid → deposit, check-in closed → done.
Money (RN03–RN07, RNF08, C7). ledger.rules.ts (a leaf) computes every amount in integer cents: buyer fee 4 % rounded half up, split ⌊revenue × 70 %⌋ to the Instructor, guarantee top-up paid by the Academy first from its deposit, unused deposit back to the Academy, 7-day cancellation, D+2 payout. Every movement is a group of ledger_entry rows with one transaction_key summing to zero over the accounts buyers, escrow, platform, instructor, academy and bank (outside the platform); the key's unique index makes a retried write a no-op. Ledger Entries, Tickets, cancellations, check-ins, Stop Results, messages and Proposal Versions get no UPDATE grant.
Payments. ledger.stripe.ts is the Stripe Connect adapter, separate charges and transfers: the platform charges buyers and deposits and later transfers each share to a connected account. Only provider ids are stored. PAYMENTS_PROVIDER selects it: off by default (every money call refuses with PAYMENTS_DISABLED, see the note below), fake for dev and tests, stripe not built yet. fakePayments: charges succeed, a deposit waits for payDeposit, an account id containing unverified stays pending_verification. Adapter calls never run inside a database transaction; a checkout that loses the race for the last spot refunds its charge at once.
Crossing tenants. Three new SystemReasons:
| Reason | Who crosses | What |
|---|---|---|
outreach.academy-thread | an Academy | Threads addressed to it: read, reply, accept, pay the deposit (Ledger rows and the Stop's stage) |
seminar.public | the public, no session | a published page by slug; checkout, waitlist, ticket status, cancel, rename, release by private token |
seminar.academy | an Academy | Seminars at its mats; approve a page |
Public endpoints. seminars.* and publicRecords.* need no session and are rate-limited per client IP with the template's DB-backed limiter (consumePublicRateLimit in mcp.guard.ts, mcp.rate-limit reason). The page shows only its own Seminar (City, district, date), never other Stops (ADR-0018), and stays public when the Instructor turns invisible (ADR-0024).
Offline check-in (C6). seminar.roster is what the device caches; seminar.checkIn takes batches of up to 200 entries by QR token or ticket id; check_in is unique per ticket and the insert ignores conflicts, so replays count as duplicates and the answer carries the live count.
Jobs (ADR-0021). waitlist (offers older than 12 h expire, the next in line is offered), payouts (due Payouts transfer when the payee's account is verified, else held), planReminders (one plan_reminder per subscription billing within 2 days).
Plan (RN01–RN02). Pro is the template's billing Entitlement pro (ADR-0004); BILLING_PROVIDERS stays empty by default, so everyone is Free. plan.rules.ts holds the limits; assertPlanAllows enforces 5 outbound messages per calendar month (UTC) and 1 active Tour (route.accept). A limit is FORBIDDEN with { reason: "plan_limit", limit }. Restore purchase stays the RevenueCat path of billing.
Consequences
- Translation runs on every Thread read with the fake; a real vendor needs caching (the
translated_*columns, written on insert) and theproposal.sentjob of ADR-0021. - Note (AI writer, ADR-0022 amendment):
OUTREACH_AI_PROVIDER=aidrafts and translates with the assistant's provider;Draft.generatedByandtranslation.generatedBysay whether the text is the AI's, the fake's, or the fake's after a failure (fallback). An Academy reply is now translated into the Instructor's language (the language of their last message in the Thread, English before any) when it arrives and stored with its original intranslated_language/translated_body; a fallback is not stored, so a later read tries again. Outbound messages and other reading languages are still translated on read. The German fake now addresses the Academy with "Sie". - The plan reminder is recorded, not delivered: push/email delivery of
plan_reminderis a follow-up. App-store trials send their own reminders. - An Academy whose top-up exceeds its deposit and its share is asked to pay the rest after the close (see the top-up note below); an unpaid top-up is not chased automatically.
- The tour report uses the Tour's estimated travel cost for the real net too ("Travel €440 deducted"); real travel spend is not recorded.
- Note (payments off by default, 2026-11-13):
PAYMENTS_PROVIDERisoff(default, production),fake(dev, tests, demos) orstripe(not built: behaves asoffand logs once).offselectsdisabledPayments, whose every call throwsPaymentsDisabled(ledger.errors.ts);paid()lets it through instead of turning it intoPaymentFailed, and every feature maps it to the new client codePAYMENTS_DISABLED(409, localized inpackages/errors). Checkout and closing check-in refuse up front; a deposit request, its payment and the payout account refuse at the adapter, before any row is written; the payouts tick returns{ paid: 0, held: 0 }without touching Payouts. Two rules relax while payments are off:seminar.publishaccepts aconfirmedStop with agreed terms (no deposit can be paid), andseminars.joinWaitlistis an interest list open whatever the spots (it never charged); its entries staywaitinguntil spots are offered after payments open. Clients learn the switch fromappConfig.get(payments.enabled) and, for the SSR seminar page,SeminarPage.ticketsOpen. Tests:ledger.stripe.test.ts(selection, default off) andledger.payments-off.test.ts(refusals, nothing written toticket,ledger_entry,deposit_request,payout_account). - Note (Stripe Connect transport, 2026-11-13):
PAYMENTS_PROVIDER=stripenow selectsstripePayments(ledger.stripe-connect.ts) whenSTRIPE_SECRET_KEYandBETTER_AUTH_URLare set; without them it staysoffand logs once. The default staysoff. REST over fetch like the billing feature (its form encoding, pinned API version, bounded JSON and Web Crypto signature check are reused), no SDK. Effect at the edge (ADR-0005): one request per call with a 10 s timeout; only GETs retry (idempotentRead); every write (Checkout Session, Refund, Account, Transfer) is sent once withIdempotency-Key: bjj-travel-payments:<our key>. Money in: a hosted Checkout Session in EUR on the platform account (charge,requestPayment), so card data never reaches us (RNF08); the stored payment id is the session id (cs_…), a refund resolves its PaymentIntent first. Money out:transferwithtransfer_group=seminar-<id>(also set on ticket and shortfall charges). Payout account: an Express account (DE,transferscapability) plus an account link;ledger.connectAccountreturns an optionalonboardingUrl(newonboardingLinkadapter method), which web and mobile open;verified= payouts enabled and charges enabled or transfers active. Because a Stripe charge completes on Stripe's page, two flows became asynchronous:seminars.checkoutreturns{ checkoutUrl }(CheckoutResult) and the ticket is issued by the webhook under the tokens already in the return URL, written to the session metadata (confirmTicketSale, idempotent per payment; a spot lost meanwhile is refunded);outreach.payDepositreturns the Thread withcheckoutUrluntil the webhook marks the deposit paid (confirmDepositPayment). The webhookPOST /api/payments/stripe(ledger.http.ts, system reasonpayments.webhook) verifiesStripe-SignatureagainstSTRIPE_WEBHOOK_SECRETand, for a separate Connect endpoint,STRIPE_CONNECT_WEBHOOK_SECRET(new optional env); it handlescheckout.session.completed/.async_payment_succeeded,payment_intent.succeeded(session looked up),charge.refunded(a deposit refunded in the Dashboard becomesrefundedwith the in-app refund's transaction key) andaccount.updated. No event table: every transition is guarded by its state or unique key, and deliveries older than 300 s are refused. (The guarantee shortfall gap noted here is closed by the top-up note below.) Tests:ledger.stripe-connect.test.ts(request shapes, no retry on writes, timeout, C7 through the transport),ledger.http.test.ts(signatures valid/invalid/replayed, mode),ledger.stripe-webhook.test.ts(disposable cluster: deposit and ticket via the webhook, replays). - Note (guarantee top-up request, 2026-11-13, RN03): the top-up follows the deposit's model (request → the Academy pays → confirmation).
splitnow pays the top-up from the deposit, then from the Academy's share (academyShareAppliedCents), and leaves the rest astopupDueCents; the close books only money the platform holds, so the Academy's account never goes negative. WhentopupDueCents > 0,closeCheckInasks the adapter for a payment (requestPayment, keytopup:<seminar>, metadatakind=topup) before its transaction and writes onetopup_request(new RLS table, Instructor's Organization, insert + status update only) with the formula's amounts; the Instructor's Payout iswaiting_topup(new status, skipped by the tick). The Academy sees it in its web inbox (seminar.academySeminars→topup, formula "guarantee − instructor share = top-up", covered part, amount) and pays withseminar.payTopup(system reasonseminar.academy):fakeconfirms at once,stripereturns the hostedcheckoutUrland the webhook (kind=topup→confirmTopupPayment) confirms,offrefuses withPAYMENTS_DISABLED. Only the confirmation writestopupPostings(guarantee_topup,bank→instructor, keytopup:<seminar>) and releases the Payout toscheduled; both are guarded by the request's state, so replays are no-ops. Instructor web/payoutsand mobile payouts show "waiting for academy top-up (€X)". C7 keeps its 10 scenarios and now also checks the books at close (balanced, nothing booked for the unpaid top-up) andtopupDue. A Seminar whose Academy has no Organization (academy_organization_idnull) still gets the request, but no inbox shows it: its Payout waits until it is settled by hand. Tests:ledger.rules.test.ts,seminar.procedures.test.ts(fake, off, RLS),ledger.stripe-webhook.test.ts(Stripe session, webhook, replay).