diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 4e87ccf..8743116 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -952,3 +952,11 @@ Surfaced as the "Who's here" panel on the dashboard (present list + self check-i - `discloseAffiliations(input)` — `members.ts`; the member records their own conflict-of-interest affiliations. - `updateSpaceVisibility(input)` — `settings.ts`; admin toggles space/member-directory visibility. - `listDoorCards(input)` — `door.ts`; `door.operate`, service client; active cards for a connection's grant/revoke UI, returning only the masked `last4` (raw UID never returned). + +### Stripe recurring dues: `stripe.ts` + `lib/stripe/*` + webhook (migration 040) +Per-space OWN Stripe account (NOT Connect). Pure mapping in `lib/stripe-logic.ts` (pinned `STRIPE_API_VERSION`, `duesMemberStatus`, `graceExceeded`, `priceIdForTier`, `isStripeConfigured`) is unit-tested. Server helpers in `lib/stripe/config.ts` (vault store/read of the secret key + webhook signing secret — door secret pattern; `getStripeConfig`) and `lib/stripe/client.ts` (`getStripe`: a per-request client with the space's decrypted key + pinned apiVersion — never global, never client). +- `getStripeSettings()` / `saveStripeSettings(input)` — Admin only. Settings view returns status flags only, **never secret values**; save is write-only for secrets (blank = keep), config (mode, publishable key, tier→price map, grace days) in `integrations.config`, secrets encrypted in the vault. +- `startDuesCheckout()` — any member; resolves the caller + their tier server-side, ensures a Stripe Customer (reuse/create + `member_billing` upsert), returns a hosted Checkout Session URL in `subscription` mode with `client_reference_id` + metadata on the session AND `subscription_data.metadata`. +- `startBillingPortal()` — any member with a customer; returns a Stripe Billing Portal URL (self-serve card/plan/cancel; the space must activate the Portal in its Stripe Dashboard once). +- `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view. +- Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; no auth middleware in this app): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b48ef2f..b4b2193 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -622,6 +622,31 @@ self-mark host. Surfaced as the dashboard "Who's here" panel, the --- +### Stripe recurring dues (migration 040; product spine Phase 1) + +Per-space OWN Stripe account (NOT Connect): the secret key + webhook +signing secret live encrypted in the AES-256-GCM secrets vault (the door +secret pattern), referenced by id from `integrations.config` which also +holds the mode, publishable key, tier→Price map and grace days. Pure +mapping logic in `lib/stripe-logic.ts` (pinned API version, +`duesMemberStatus`, `graceExceeded`) is unit-tested; `lib/stripe/client.ts` +builds a per-request `Stripe` client (never global, never client-exposed); +`lib/stripe/config.ts` holds the vault/config helpers (plain server module, +not `'use server'`, since they take the admin client). Members pay via a +hosted Checkout Session (`subscription` mode) and self-serve via the hosted +Billing Portal — card data never touches the app (SAQ A). The per-space +webhook `POST /api/stripe/webhook/[space]` verifies the signature with that +space's signing secret, is idempotent on Stripe's event id +(`stripe_webhook_events`), and maps the subscription lifecycle onto +`member_billing` + a `stripe` `payments` row + `space_members.status` +(grace → `late`, **never** auto-inactive, never auto-approves `unverified`). +`member_billing` SELECT = admin/board/treasurer with **no client write +policy** (webhook/service-client only); the member self-view is a validated +action. No new permission code. Phases 2-3 (transactional notifications, +broader self-serve) build on this. + +--- + ## 13. Known Limitations 1. **Test suite** - Extensive Vitest unit coverage of pure logic (`__tests__/`, run with `pnpm test`) plus Playwright smoke e2e (`e2e/`). Gap: server-action orchestration is not unit-tested (no Supabase mock harness); e2e is mostly page-render checks, not full write/RLS flows. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 9e5c14e..173a8c3 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,6 +4,29 @@ Append-only. Newest entries on top. Keep each entry to one screen. --- +## 2026-05-17 (pass 42) — Stripe dues Phase 1 COMPLETE (P1f UI + P1g docs); LOCAL, one reviewed deploy pending + +Branch `main`. P1a/P1b deployed (inert foundation). P1c–P1g built locally — Phase 1 is now a complete, reviewable unit. Owner asked to review the money/webhook code before its deploy; build is clean, suite 479. + +### Done this pass (gated, committed, NOT deployed) +- **`1f61029` P1f**: `StripeBillingPanel` (admin, on /settings after SettingsClient) — mode, publishable key, write-only secret/webhook secret, per-tier price ids, grace days, shows the per-space webhook URL + events to subscribe + Portal-activation reminder. `DuesCard` on /me — status via getMyBilling, "Pay dues" (startDuesCheckout redirect) / "Manage billing" (startBillingPortal). +- **P1g**: API_REFERENCE + ARCHITECTURE Stripe sections + this entry. + +### Full Phase-1 commit set (LOCAL, awaiting one reviewed deploy) +`5ca7ee6` P1c (dep+client+vault config) · `f293e41` P1d (checkout/portal/getMyBilling/listMemberBilling) · `fa76176` P1e (per-space signed webhook + dues→status) · `1f61029` P1f (UI) · docs. (P1a `521df69` + P1b `3d7cf87` already deployed, inert.) + +### Reviewer notes (money-critical surface) +- Secrets: secret key + webhook signing secret only in the AES-256-GCM vault; `getStripeSettings` returns booleans, never values; keys never `NEXT_PUBLIC_`/client. +- Webhook: signature-verified with the space's own secret; idempotent on `event.id`; per-space path (no Connect `event.account`); 400 bad-sig/config, 500 handler (Stripe retries; idempotent). No auth middleware exists so the path is reachable for Stripe by design. +- Status automation only moves `current`↔`late` (filtered `IN ('current','late')`) — never auto-inactive, never auto-approves `unverified`; `last_paid_at` on paid. +- Multi-tenant: every query space-scoped; member resolved by metadata then customer id, re-checked in-space. +- Inert until an admin actually configures Stripe in Settings (no `integrations` stripe row exists in prod). + +### Open +- LOCAL & undeployed: pass-42 (P1f + docs) on top of pass-41 (P1c–P1e). **One reviewed deploy** ships all of Phase 1 (no migration in this batch — 040 already live). ASK before deploy. After deploy: a space admin configures Stripe (test mode) → member "Pay dues" → webhook flips status. Then Phase 2 = transactional notifications (renewal/failed-payment/booking emails) — the next product-spine thread. + +--- + ## 2026-05-17 (pass 41) — Stripe dues P1c–P1e (config + actions + webhook) built; LOCAL, REVIEW before deploy Branch `main`. P1a/P1b deployed (run 25987779054, inert foundation live). Built the money-critical core; per the owner's choice this code should be reviewed before its deploy. 4 commits this pass, suite 479, build clean each.