diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index f13cdd0..d43ef99 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -405,10 +405,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 037 | `classes.required_form_id` nullable FK -> forms(id) ON DELETE SET NULL. Optional per-class form gate (waiver-on-file): signup requires a completed form_submissions row; classes.manage override + on-behalf. App-enforced; no RLS change | | 038 | `space_visits` (presence/attendance) + `spaces.host_requires_card` bool default true. One open visit per member (partial unique); SELECT = any space member; service-client-only writes (self-resolved, immutable). No new permission code | | 039 | DATA backfill only: link `form_submissions.member_id` (NULL) to earliest matching member by `(space_id, lower email = lower submitter_email)`. Idempotent; not in schema.sql | +| 040 | Stripe dues P1: `payment_platform` += `stripe`; `member_billing` (member↔Stripe customer/sub/status; SELECT admin/board/treasurer, service-client-only writes); `stripe_webhook_events` (idempotency, PK=event id, service-only). Per-space own keys (vault + integrations.config) | --- -## Tables added by migrations 016-039 (quick map) +## Tables added by migrations 016-040 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -439,6 +440,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), occurred_at | Append-only door audit. SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) | | `door_card_slots` | space_id, connection_id -> door_connections, card_id -> member_cards, slot int, created_by | Per-connection integer-slot allocation map. UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id). SELECT = `door.manage`/`door.operate`; NO write policy (service-client executor only, in lockstep with the controller) | | `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members | +| `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action | +| `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index d63df4d..6b559e0 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -4,9 +4,9 @@ > **Database**: PostgreSQL via self-hosted Supabase > **Tables**: ~43 > **Source of Truth**: `scripts/schema.sql` (canonical, idempotent); numbered -> migrations `scripts/0NN_*.sql` (through **039**) upgrade existing +> migrations `scripts/0NN_*.sql` (through **040**) upgrade existing > deployments. The 13-table reference below is the original baseline; tables -> added by migrations 014-039 are summarized in the migrations section at the +> added by migrations 014-040 are summarized in the migrations section at the > end. `DB_SCHEMA_MAP.md` has the per-table quick map. --- @@ -146,7 +146,7 @@ CREATE TYPE area_lead_status AS ENUM ('active', 'vacant', 'handoff'); ### `payment_platform` ```sql -CREATE TYPE payment_platform AS ENUM ('paypal', 'zeffy', 'venmo', 'cash'); +CREATE TYPE payment_platform AS ENUM ('paypal', 'zeffy', 'venmo', 'cash', 'stripe'); ``` ### `payment_link_status` @@ -687,7 +687,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at); --- -## Migrations 014-039 (additions since the 13-table baseline) +## Migrations 014-040 (additions since the 13-table baseline) `scripts/schema.sql` is the canonical idempotent schema; each numbered migration is mirrored as a section in it. Tables/columns added: @@ -720,6 +720,7 @@ migration is mirrored as a section in it. Tables/columns added: | 037 | `classes.required_form_id` nullable FK -> `forms(id)` ON DELETE SET NULL. A class can optionally require a form (waiver/intake); signup is hard-gated on the member having a completed `form_submissions` row for it (waiver-on-file), with a `classes.manage` override + sign-up-on-behalf, mirroring the equipment required-cert gate. Gate enforced in the app (pure logic + service-client boolean check); no RLS change (additive nullable column on the already-policied `classes`) | | 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app | | 039 | DATA backfill only (no structural change): links existing `form_submissions.member_id` (where NULL) to the earliest-joined matching `space_members` by `(space_id, lower(email)=lower(submitter_email))`. Idempotent (NULL-only). Not mirrored in schema.sql (fresh DB has no submissions); ongoing linking is app-side (submitForm + linkSubmissionsByEmail) | +| 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. No new permission code | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/scripts/040_stripe_billing.sql b/scripts/040_stripe_billing.sql new file mode 100644 index 0000000..0d74d32 --- /dev/null +++ b/scripts/040_stripe_billing.sql @@ -0,0 +1,59 @@ +-- ============================================================================= +-- 040: Stripe recurring dues (Phase 1) — enum + member billing + webhook dedupe +-- ============================================================================= +-- Per-space OWN Stripe keys (NOT Connect). Secret key + webhook signing secret +-- live in the AES-256-GCM secrets vault; non-secret config (publishable key, +-- mode, tier->price map, secret refs) in integrations.config. This migration +-- is structure only; it never writes the literal 'stripe' enum value, so +-- ALTER TYPE ADD VALUE in the same script is safe. +-- +-- * payment_platform gains 'stripe' (ALTER TYPE ADD VALUE is the only safe +-- path; mirrored into the CREATE TYPE list in scripts/schema.sql). +-- * member_billing: one row per member with a Stripe customer/subscription. +-- Webhook (service client) is the only writer; SELECT = admin/board/ +-- treasurer (mirrors payments visibility). Member self-view of their own +-- billing is a validated service-client action, not an RLS path. +-- * stripe_webhook_events: idempotency ledger keyed by Stripe's stable +-- event id. Service-client only; no client policy. +-- +-- Idempotent: ADD VALUE IF NOT EXISTS, CREATE TABLE/INDEX IF NOT EXISTS, +-- DROP POLICY IF EXISTS then CREATE. +-- ============================================================================= + +ALTER TYPE public.payment_platform ADD VALUE IF NOT EXISTS 'stripe'; + +CREATE TABLE IF NOT EXISTS public.member_billing ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + stripe_customer_id text, + stripe_subscription_id text, + subscription_status text, + current_period_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_billing_member ON public.member_billing (space_id, member_id); +CREATE INDEX IF NOT EXISTS idx_member_billing_customer ON public.member_billing (stripe_customer_id); +CREATE INDEX IF NOT EXISTS idx_member_billing_space ON public.member_billing (space_id); + +ALTER TABLE public.member_billing ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS member_billing_select ON public.member_billing; +CREATE POLICY member_billing_select ON public.member_billing FOR SELECT + USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); +-- No INSERT/UPDATE/DELETE policy: the validated service-client webhook/actions +-- are the only writers. + +CREATE TABLE IF NOT EXISTS public.stripe_webhook_events ( + event_id text PRIMARY KEY, + space_id uuid REFERENCES public.spaces(id) ON DELETE CASCADE, + type text, + received_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.stripe_webhook_events ENABLE ROW LEVEL SECURITY; +-- No policy at all: service-client (webhook) only; opaque idempotency ledger. + +DROP TRIGGER IF EXISTS trg_member_billing_touch ON public.member_billing; +CREATE TRIGGER trg_member_billing_touch + BEFORE UPDATE ON public.member_billing + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/scripts/schema.sql b/scripts/schema.sql index 08efa76..c102937 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -32,7 +32,7 @@ DO $$ BEGIN CREATE TYPE public.task_status AS ENUM ('open','claimed','in_ DO $$ BEGIN CREATE TYPE public.task_type AS ENUM ('chore','task'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE public.recurrence_type AS ENUM ('daily','weekly','biweekly','monthly','none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE public.project_status AS ENUM ('backlog','in_progress','review','done','blocked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; -DO $$ BEGIN CREATE TYPE public.payment_platform AS ENUM ('paypal','zeffy','venmo','cash'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.payment_platform AS ENUM ('paypal','zeffy','venmo','cash','stripe'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE public.payment_link_status AS ENUM ('linked','unlinked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE public.kb_visibility AS ENUM ('all_members','board','admin_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; DO $$ BEGIN CREATE TYPE public.area_lead_status AS ENUM ('active','vacant','handoff'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; @@ -2539,3 +2539,51 @@ ALTER TABLE public.space_visits ENABLE ROW LEVEL SECURITY; DROP POLICY IF EXISTS space_visits_select ON public.space_visits; CREATE POLICY space_visits_select ON public.space_visits FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); + + +-- ============================================================================= +-- 25. Stripe recurring dues (Phase 1) +-- (equivalent to scripts/040_stripe_billing.sql; payment_platform 'stripe' +-- is added to the CREATE TYPE list near the top of this file). +-- +-- Per-space OWN Stripe keys (NOT Connect): secret key + webhook signing +-- secret in the AES-256-GCM secrets vault; publishable key, mode, +-- tier->price map, secret refs in integrations.config. member_billing = +-- one row per member with a Stripe customer/subscription; the webhook +-- (service client) is the only writer, SELECT = admin/board/treasurer +-- (mirrors payments). stripe_webhook_events = idempotency ledger keyed by +-- Stripe's stable event id; service-client only, no client policy. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.member_billing ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE, + member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE, + stripe_customer_id text, + stripe_subscription_id text, + subscription_status text, + current_period_end timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_member_billing_member ON public.member_billing (space_id, member_id); +CREATE INDEX IF NOT EXISTS idx_member_billing_customer ON public.member_billing (stripe_customer_id); +CREATE INDEX IF NOT EXISTS idx_member_billing_space ON public.member_billing (space_id); + +ALTER TABLE public.member_billing ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS member_billing_select ON public.member_billing; +CREATE POLICY member_billing_select ON public.member_billing FOR SELECT + USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); + +CREATE TABLE IF NOT EXISTS public.stripe_webhook_events ( + event_id text PRIMARY KEY, + space_id uuid REFERENCES public.spaces(id) ON DELETE CASCADE, + type text, + received_at timestamptz NOT NULL DEFAULT now() +); +ALTER TABLE public.stripe_webhook_events ENABLE ROW LEVEL SECURITY; + +DROP TRIGGER IF EXISTS trg_member_billing_touch ON public.member_billing; +CREATE TRIGGER trg_member_billing_touch + BEFORE UPDATE ON public.member_billing + FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); diff --git a/types/database.ts b/types/database.ts index 5ac5b79..55422e3 100644 --- a/types/database.ts +++ b/types/database.ts @@ -178,6 +178,18 @@ export type Database = { Update: { id?: string; space_id?: string; member_id?: string; checked_in_at?: string; checked_out_at?: string | null; is_host?: boolean; check_in_note?: string | null; check_out_note?: string | null; created_at?: string } Relationships: [] } + member_billing: { + Row: { id: string; space_id: string; member_id: string; stripe_customer_id: string | null; stripe_subscription_id: string | null; subscription_status: string | null; current_period_end: string | null; created_at: string; updated_at: string } + Insert: { id?: string; space_id: string; member_id: string; stripe_customer_id?: string | null; stripe_subscription_id?: string | null; subscription_status?: string | null; current_period_end?: string | null; created_at?: string; updated_at?: string } + Update: { id?: string; space_id?: string; member_id?: string; stripe_customer_id?: string | null; stripe_subscription_id?: string | null; subscription_status?: string | null; current_period_end?: string | null; created_at?: string; updated_at?: string } + Relationships: [] + } + stripe_webhook_events: { + Row: { event_id: string; space_id: string | null; type: string | null; received_at: string } + Insert: { event_id: string; space_id?: string | null; type?: string | null; received_at?: string } + Update: { event_id?: string; space_id?: string | null; type?: string | null; received_at?: string } + Relationships: [] + } activity_log: { Row: { action: string @@ -1684,7 +1696,7 @@ export type Database = { member_status: "current" | "late" | "inactive" | "unverified" member_tier: "plus" | "basic" | "associate" payment_link_status: "linked" | "unlinked" - payment_platform: "paypal" | "zeffy" | "venmo" | "cash" + payment_platform: "paypal" | "zeffy" | "venmo" | "cash" | "stripe" policy_status: "draft" | "active" | "deprecated" | "superseded" project_status: "backlog" | "in_progress" | "review" | "done" | "blocked" proposal_status: "draft" | "open" | "decided" | "withdrawn" | "expired" @@ -1875,7 +1887,7 @@ export const Constants = { member_status: ["current", "late", "inactive", "unverified"], member_tier: ["plus", "basic", "associate"], payment_link_status: ["linked", "unlinked"], - payment_platform: ["paypal", "zeffy", "venmo", "cash"], + payment_platform: ["paypal", "zeffy", "venmo", "cash", "stripe"], policy_status: ["draft", "active", "deprecated", "superseded"], project_status: ["backlog", "in_progress", "review", "done", "blocked"], proposal_status: ["draft", "open", "decided", "withdrawn", "expired"],