Skip to content
Docs

Command palette

Jump to a page or view, or find a todo by name.

Changelog

Outreach, Seminars and the Ledger

The repository's architectural decision, rationale and consequences.

Markdown

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:

ReasonWho crossesWhat
outreach.academy-threadan AcademyThreads addressed to it: read, reply, accept, pay the deposit (Ledger rows and the Stop's stage)
seminar.publicthe public, no sessiona published page by slug; checkout, waitlist, ticket status, cancel, rename, release by private token
seminar.academyan AcademySeminars 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 the proposal.sent job of ADR-0021.
  • Note (AI writer, ADR-0022 amendment): OUTREACH_AI_PROVIDER=ai drafts and translates with the assistant's provider; Draft.generatedBy and translation.generatedBy say 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 in translated_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_reminder is 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_PROVIDER is off (default, production), fake (dev, tests, demos) or stripe (not built: behaves as off and logs once). off selects disabledPayments, whose every call throws PaymentsDisabled (ledger.errors.ts); paid() lets it through instead of turning it into PaymentFailed, and every feature maps it to the new client code PAYMENTS_DISABLED (409, localized in packages/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.publish accepts a confirmed Stop with agreed terms (no deposit can be paid), and seminars.joinWaitlist is an interest list open whatever the spots (it never charged); its entries stay waiting until spots are offered after payments open. Clients learn the switch from appConfig.get (payments.enabled) and, for the SSR seminar page, SeminarPage.ticketsOpen. Tests: ledger.stripe.test.ts (selection, default off) and ledger.payments-off.test.ts (refusals, nothing written to ticket, ledger_entry, deposit_request, payout_account).
  • Note (Stripe Connect transport, 2026-11-13): PAYMENTS_PROVIDER=stripe now selects stripePayments (ledger.stripe-connect.ts) when STRIPE_SECRET_KEY and BETTER_AUTH_URL are set; without them it stays off and logs once. The default stays off. 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 with Idempotency-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: transfer with transfer_group=seminar-<id> (also set on ticket and shortfall charges). Payout account: an Express account (DE, transfers capability) plus an account link; ledger.connectAccount returns an optional onboardingUrl (new onboardingLink adapter 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.checkout returns { 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.payDeposit returns the Thread with checkoutUrl until the webhook marks the deposit paid (confirmDepositPayment). The webhook POST /api/payments/stripe (ledger.http.ts, system reason payments.webhook) verifies Stripe-Signature against STRIPE_WEBHOOK_SECRET and, for a separate Connect endpoint, STRIPE_CONNECT_WEBHOOK_SECRET (new optional env); it handles checkout.session.completed/.async_payment_succeeded, payment_intent.succeeded (session looked up), charge.refunded (a deposit refunded in the Dashboard becomes refunded with the in-app refund's transaction key) and account.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). split now pays the top-up from the deposit, then from the Academy's share (academyShareAppliedCents), and leaves the rest as topupDueCents; the close books only money the platform holds, so the Academy's account never goes negative. When topupDueCents > 0, closeCheckIn asks the adapter for a payment (requestPayment, key topup:<seminar>, metadata kind=topup) before its transaction and writes one topup_request (new RLS table, Instructor's Organization, insert + status update only) with the formula's amounts; the Instructor's Payout is waiting_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 with seminar.payTopup (system reason seminar.academy): fake confirms at once, stripe returns the hosted checkoutUrl and the webhook (kind=topup → confirmTopupPayment) confirms, off refuses with PAYMENTS_DISABLED. Only the confirmation writes topupPostings (guarantee_topup, bank → instructor, key topup:<seminar>) and releases the Payout to scheduled; both are guarded by the request's state, so replays are no-ops. Instructor web /payouts and 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) and topupDue. A Seminar whose Academy has no Organization (academy_organization_id null) 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).
Edit on GitHubDocumentation is in English.
Cookie settings
We use essential cookies to make our app work. With your consent, we also use optional cookies to improve your experience.

Read our Cookie Policy and Privacy Policy.