feat(billing): P1a Stripe schema — payment_platform 'stripe' + member_billing + webhook ledger
Migration 040 (mirrored in schema.sql Section 25 + the CREATE TYPE list): ALTER TYPE payment_platform ADD VALUE 'stripe' (only safe path; not used as a literal in the same migration); member_billing (member<-> Stripe customer/subscription/status/period; RLS SELECT = admin/board/ treasurer, NO client write policy — webhook/service-client only); stripe_webhook_events idempotency ledger (PK = Stripe event id, service- only, no policy). Per-space OWN keys: secrets->vault, config-> integrations.config. types + DATABASE_SCHEMA + DB_SCHEMA_MAP.
This commit is contained in:
+4
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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();
|
||||
+49
-1
@@ -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();
|
||||
|
||||
+14
-2
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user