docs: P1g Stripe dues — API_REFERENCE + ARCHITECTURE + HANDOFF pass-42 (Phase 1 complete)
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user