# Outreach, Seminars and the Ledger

The repository's architectural decision, rationale and consequences.

# Outreach, Seminars and the Ledger

**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](0004-payments-providers.md), [ADR-0009](0009-row-level-security.md), [ADR-0018](0018-location-is-city-and-window.md), [ADR-0019](0019-instructor-and-academy-are-organizations.md), [ADR-0021](0021-jobs-on-the-cron-tick-and-leased-queue.md), [ADR-0022](0022-adapters-with-fakes-and-money-in-config.md), [ADR-0024](0024-academy-side-and-public-records.md)

**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 `SystemReason`s) · `[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 [#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 `SystemReason`s:

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

