|
| 1 | +# Plan 009: Payment schema is provably present before the payments code runs in production |
| 2 | + |
| 3 | +> **Executor instructions**: Follow this plan step by step. Run every |
| 4 | +> verification command and confirm the expected result before moving to the |
| 5 | +> next step. If anything in the "STOP conditions" section occurs, stop and |
| 6 | +> report — do not improvise. When done, update the status row for this plan |
| 7 | +> in `plans/README.md`. |
| 8 | +> |
| 9 | +> **Drift check (run first)**: |
| 10 | +> `git diff --stat e768e357..HEAD -- supabase/migrations app/api/payments app/[locale]/platform/revenue` |
| 11 | +> If any in-scope file changed since this plan was written, compare the |
| 12 | +> "Current state" excerpts against the live code before proceeding; on a |
| 13 | +> mismatch, treat it as a STOP condition. |
| 14 | +
|
| 15 | +## Status |
| 16 | + |
| 17 | +- **Priority**: P1 |
| 18 | +- **Effort**: S |
| 19 | +- **Risk**: LOW (adds a guard script + CI step + comment edits; touches no runtime code path) |
| 20 | +- **Depends on**: none |
| 21 | +- **Category**: migration |
| 22 | +- **Planned at**: commit `e768e357`, 2026-06-20 |
| 23 | + |
| 24 | +## Why this matters |
| 25 | + |
| 26 | +This branch ships payments code that **reads database objects which exist only |
| 27 | +in migrations the branch itself marks "LOCAL-ONLY … not pushed to cloud."** If |
| 28 | +the branch is deployed to production without those migrations being applied |
| 29 | +first, three things break silently or loudly: |
| 30 | + |
| 31 | +1. **Checkout fails** — `app/api/payments/checkout/route.ts` inserts |
| 32 | + `settlement_currency/base/mint/sol_usd`, columns added by |
| 33 | + `20260617130000_solana_settlement_lock.sql`. Missing columns → every Solana |
| 34 | + checkout INSERT errors → "Failed to create transaction". |
| 35 | +2. **Replay protection silently disappears** — the Solana double-spend guard |
| 36 | + relies on the partial-unique index `transactions_provider_charge_id_unique` |
| 37 | + from `20260615180000_consume_solana_signature.sql`. Without it, |
| 38 | + `app/api/payments/solana/verify/route.ts` can no longer reject a replayed |
| 39 | + on-chain signature (the 23505 branch never fires). |
| 40 | +3. **The platform revenue page 500s** — `app/[locale]/platform/revenue/page.tsx` |
| 41 | + calls `rpc('get_platform_revenue')`, defined only in |
| 42 | + `20260617120000_get_platform_revenue.sql`. |
| 43 | + |
| 44 | +The fix is **not** to push migrations from this plan (that mutates production — |
| 45 | +out of scope; it is an operator action documented in the checklist below). |
| 46 | +The fix an executor *can* deliver is a **preflight guard**: a script that asserts |
| 47 | +the required DB objects exist and fails the deploy/CI if they do not, so a |
| 48 | +half-applied schema can never reach users. Plus de-stale the two migration |
| 49 | +comments that tell a future operator "do not push." |
| 50 | + |
| 51 | +## Current state |
| 52 | + |
| 53 | +- The six payment migrations added by this branch (verified with |
| 54 | + `git diff --name-only --diff-filter=A $(git merge-base master HEAD)..HEAD -- supabase/migrations`): |
| 55 | + - `supabase/migrations/20260615150000_add_lemonsqueezy_solana_providers.sql` |
| 56 | + - `supabase/migrations/20260615160000_tenant_payment_wallets.sql` |
| 57 | + - `supabase/migrations/20260615170000_solana_native_subscriptions.sql` |
| 58 | + - `supabase/migrations/20260615180000_consume_solana_signature.sql` |
| 59 | + - `supabase/migrations/20260617120000_get_platform_revenue.sql` |
| 60 | + - `supabase/migrations/20260617130000_solana_settlement_lock.sql` |
| 61 | +- Two still carry an explicit "do not push" comment: |
| 62 | + - `20260617120000_get_platform_revenue.sql:26-27` — `-- LOCAL-ONLY: like the other 2026-06-15 payment migrations on this branch, this -- is not pushed to cloud until the operator says so.` |
| 63 | + - `20260617130000_solana_settlement_lock.sql:14` — `-- LOCAL-ONLY until the operator applies the #280/#334 migration set to cloud.` |
| 64 | +- The replay index (target of the guard), `20260615180000_consume_solana_signature.sql:21-23`: |
| 65 | + ```sql |
| 66 | + CREATE UNIQUE INDEX IF NOT EXISTS transactions_provider_charge_id_unique |
| 67 | + ON public.transactions (payment_provider, provider_charge_id) |
| 68 | + WHERE provider_charge_id IS NOT NULL AND status = 'successful'; |
| 69 | + ``` |
| 70 | +- The settlement columns, `20260617130000_solana_settlement_lock.sql:16-20`: |
| 71 | + `settlement_currency TEXT`, `settlement_base BIGINT`, `settlement_mint TEXT`, `settlement_sol_usd NUMERIC(20,8)`. |
| 72 | +- The repo already has a CI workflow at `.github/workflows/ci.yml` (added earlier |
| 73 | + on this branch) — the guard step is added there. |
| 74 | +- The repo's Node scripts convention: plain `.mjs`/`.ts` under `scripts/`, run |
| 75 | + with `node` or `tsx`. The Supabase service-role client is created in app code |
| 76 | + via `@supabase/supabase-js`'s `createClient(url, serviceKey)` — see |
| 77 | + `app/api/payments/checkout/route.ts` for the env var names |
| 78 | + (`NEXT_PUBLIC_SUPABASE_URL`, `SUPABASE_SERVICE_ROLE_KEY`). |
| 79 | + |
| 80 | +## Commands you will need |
| 81 | + |
| 82 | +| Purpose | Command | Expected on success | |
| 83 | +|-----------|---------------------------------|---------------------| |
| 84 | +| Typecheck | `npm run typecheck` | exit 0, no errors | |
| 85 | +| Unit tests| `npm run test:unit` | all pass | |
| 86 | +| Lint | `npm run lint` | no NEW errors (repo has a known lint baseline; see plans/005) | |
| 87 | +| Run guard | `node scripts/check-payment-schema.mjs` | prints a clear PASS/FAIL; exit 0 only when all objects present | |
| 88 | + |
| 89 | +## Scope |
| 90 | + |
| 91 | +**In scope** (the only files you should create/modify): |
| 92 | +- `scripts/check-payment-schema.mjs` (create) |
| 93 | +- `.github/workflows/ci.yml` (add one guarded job/step that runs the script) |
| 94 | +- `supabase/migrations/20260617120000_get_platform_revenue.sql` (comment edit only — lines 26-27) |
| 95 | +- `supabase/migrations/20260617130000_solana_settlement_lock.sql` (comment edit only — line 14) |
| 96 | + |
| 97 | +**Out of scope** (do NOT touch): |
| 98 | +- Any SQL statement body inside the migrations — change only the comment lines named above. The migrations are already applied locally; altering their DDL would create drift. |
| 99 | +- Any application route or page — this plan adds a guard, it does not change runtime behavior. |
| 100 | +- **Do NOT run `supabase db push` or any command that writes to a remote/cloud database.** That is the operator checklist below, not executor work. |
| 101 | + |
| 102 | +## Steps |
| 103 | + |
| 104 | +### Step 1: Write the preflight schema-check script |
| 105 | + |
| 106 | +Create `scripts/check-payment-schema.mjs`. It connects with the service-role key |
| 107 | +and asserts every object the shipped payments code depends on exists. It must: |
| 108 | + |
| 109 | +- Read `NEXT_PUBLIC_SUPABASE_URL` (or `SUPABASE_URL`) and `SUPABASE_SERVICE_ROLE_KEY` from env. |
| 110 | + If either is missing, print `SKIP: Supabase env not set — schema check skipped` and exit **0** (so CI without DB creds does not fail; the gate is best-effort). |
| 111 | +- Assert these objects, querying Postgres catalogs via the service-role client |
| 112 | + (use `supabase.rpc` is not available for catalogs — instead run lightweight |
| 113 | + probe queries that fail iff the object is absent): |
| 114 | + - **Columns** on `public.transactions`: `settlement_currency`, `settlement_base`, `settlement_mint`, `settlement_sol_usd`, `provider_charge_id`. |
| 115 | + Probe: `select settlement_currency, settlement_base, settlement_mint, settlement_sol_usd, provider_charge_id from transactions limit 0`. A PostgREST/Postgres error mentioning a missing column = FAIL. |
| 116 | + - **Table** `public.tenant_payment_wallets`: probe `select 1 from tenant_payment_wallets limit 0`. |
| 117 | + - **RPC** `get_platform_revenue`: probe `supabase.rpc('get_platform_revenue')`; a "function does not exist" error = FAIL (any other error, e.g. permission, still means it exists → treat as PASS for presence). |
| 118 | + - **Index** `transactions_provider_charge_id_unique`: probe with a raw catalog read if available; if you cannot read `pg_indexes` through the client, SKIP this single check and print a warning rather than failing (the column probe above already covers the dependent column). |
| 119 | +- Collect failures into a list. At the end: |
| 120 | + - If empty: print `PASS: payment schema present` and exit 0. |
| 121 | + - Else: print `FAIL: missing payment schema objects:` followed by the list and the remediation line `Run: supabase db push (see plans/009)`, then exit **1**. |
| 122 | +- Wrap each probe in try/catch so one missing object does not abort the others — report ALL missing objects in one run. |
| 123 | + |
| 124 | +Keep it dependency-free beyond `@supabase/supabase-js` (already a dependency). |
| 125 | + |
| 126 | +**Verify**: `node scripts/check-payment-schema.mjs` against your local DB → |
| 127 | +prints `PASS: payment schema present` and exits 0 (local migrations are applied, |
| 128 | +so every object exists). If env is unset locally, it prints `SKIP …` and exits 0. |
| 129 | + |
| 130 | +### Step 2: Wire the guard into CI as a non-fatal-when-skipped gate |
| 131 | + |
| 132 | +In `.github/workflows/ci.yml`, add a step (in the existing test/build job, after |
| 133 | +dependencies install) that runs `node scripts/check-payment-schema.mjs`. The step |
| 134 | +inherits `NEXT_PUBLIC_SUPABASE_URL` / `SUPABASE_SERVICE_ROLE_KEY` from CI secrets |
| 135 | +if present; when absent the script self-skips (exit 0), so this never breaks PRs |
| 136 | +that lack DB creds, but it **fails the pipeline when creds are present and the |
| 137 | +schema is incomplete** (the deploy-target case). |
| 138 | + |
| 139 | +**Verify**: `git diff .github/workflows/ci.yml` shows exactly one added step |
| 140 | +invoking the script; the YAML still parses (no tab characters; consistent |
| 141 | +indentation with the surrounding steps). |
| 142 | + |
| 143 | +### Step 3: De-stale the two "do not push" migration comments |
| 144 | + |
| 145 | +These comments will mislead the operator who applies the set. Replace the |
| 146 | +"LOCAL-ONLY … not pushed" wording with a neutral note that the migration is part |
| 147 | +of the #280/#334 payment set and must be applied before deploying the payments |
| 148 | +code (see plan 009). Edit **only** the comment lines: |
| 149 | +- `20260617120000_get_platform_revenue.sql:26-27` |
| 150 | +- `20260617130000_solana_settlement_lock.sql:14` |
| 151 | + |
| 152 | +Do not alter any SQL. |
| 153 | + |
| 154 | +**Verify**: `grep -rn "LOCAL-ONLY\|not pushed" supabase/migrations/` returns no |
| 155 | +matches. |
| 156 | + |
| 157 | +### Step 4: Full verification |
| 158 | + |
| 159 | +**Verify**: |
| 160 | +- `npm run typecheck` → exit 0 |
| 161 | +- `npm run test:unit` → all pass (you added no tests; nothing regresses) |
| 162 | +- `git status` → only the four in-scope files changed |
| 163 | + |
| 164 | +## Operator checklist (NOT executor work — for the human deploying this branch) |
| 165 | + |
| 166 | +Before deploying this branch to any environment, the operator runs, against that |
| 167 | +environment's database: |
| 168 | + |
| 169 | +1. `supabase migration list` — confirm the six `2026061515xxxx`–`2026061718xxxx` |
| 170 | + payment migrations show as **not yet applied** remotely. |
| 171 | +2. `supabase db push` — apply them (each uses `IF NOT EXISTS` / `create or |
| 172 | + replace`, so re-running is safe). |
| 173 | +3. `node scripts/check-payment-schema.mjs` against that DB → must print `PASS`. |
| 174 | + |
| 175 | +Only after PASS should the application image be promoted. |
| 176 | + |
| 177 | +## Test plan |
| 178 | + |
| 179 | +- No new unit tests required (the deliverable is an ops guard script). |
| 180 | +- Manual: run `node scripts/check-payment-schema.mjs` against the local DB (expect PASS) and, to confirm the failure path, temporarily point it at a DB without the columns (expect a FAIL list + exit 1) — do **not** commit any such config. |
| 181 | + |
| 182 | +## Done criteria |
| 183 | + |
| 184 | +ALL must hold: |
| 185 | + |
| 186 | +- [ ] `scripts/check-payment-schema.mjs` exists; running it locally prints `PASS` and exits 0 |
| 187 | +- [ ] The script exits 0 with a `SKIP` message when Supabase env vars are unset |
| 188 | +- [ ] `.github/workflows/ci.yml` runs the script in CI |
| 189 | +- [ ] `grep -rn "LOCAL-ONLY\|not pushed" supabase/migrations/` → no matches |
| 190 | +- [ ] `npm run typecheck` exits 0; `npm run test:unit` passes |
| 191 | +- [ ] `git status` shows only the four in-scope files modified |
| 192 | +- [ ] `plans/README.md` status row updated |
| 193 | + |
| 194 | +## STOP conditions |
| 195 | + |
| 196 | +Stop and report back (do not improvise) if: |
| 197 | + |
| 198 | +- The settlement columns / RPC / index do NOT actually exist in your local DB |
| 199 | + (the probe FAILs locally) — that means local migrations are not applied; report |
| 200 | + it rather than "fixing" by pushing anything. |
| 201 | +- The `@supabase/supabase-js` client cannot run the catalog/probe queries with the |
| 202 | + service-role key in your environment — report the limitation; do not switch to a |
| 203 | + direct `pg`/`psql` connection (not a repo dependency) without approval. |
| 204 | +- You discover a runtime code path already guards against the missing schema |
| 205 | + (e.g. a feature flag) — report it; the guard may be redundant. |
| 206 | + |
| 207 | +## Maintenance notes |
| 208 | + |
| 209 | +- When future payment migrations add columns/RPCs the code depends on, extend |
| 210 | + `scripts/check-payment-schema.mjs` with the new probes — it is the single |
| 211 | + deploy gate for payment schema presence. |
| 212 | +- A reviewer should confirm the CI step's skip-on-missing-creds behavior so the |
| 213 | + gate never blocks unrelated PRs, while still failing on an incomplete deploy DB. |
| 214 | +- Deferred: making each route degrade gracefully (e.g. checkout returning a clean |
| 215 | + 503 when settlement columns are absent) was intentionally left out — the guard |
| 216 | + prevents the broken-schema deploy in the first place, which is higher leverage. |
0 commit comments