Skip to content

Commit 86f50e0

Browse files
guillermoscriptDPS0340claude
committed
fix(db): make the split snapshot database-owned, not caller-supplied (#512)
Hardens the trigger backstop from #525. Two changes, both found by reviewing that PR against a local database. 1. The snapshot column is client-writable. `transactions` grants ALL to `authenticated` and its RLS policies (20260313025318) restrict which ROWS a user may write, never which columns — so a student can POST their own transaction with `school_percentage_snapshot: 100`, or UPDATE it in later, and inflate what the platform believes it owes their school. A backstop whose rule is "an explicit value always wins" preserves exactly the write we most need to override. Verified against the local DB: with #525 as submitted, both the INSERT and the UPDATE tamper survive at 100 where the real split is 70. The column is now computed by the database on INSERT (no WHEN guard, so a supplied value cannot survive) and frozen on UPDATE (the previous value is restored verbatim). 2. The UPDATE branch was a lazy, non-deterministic backfill. Once the trigger is installed every new row is snapshotted at INSERT, so the UPDATE branch can only ever fire on legacy NULL rows — stamping TODAY's split on them at whatever arbitrary moment something incidental (a refund, an archive) first touches the row. That is the retroactive repricing #496 removed, applied to an unpredictable subset of history. It now fills a legacy NULL in exactly one case: a provider is activating the row (`payment_provider` changing), the one moment at which today's split is the honest answer. This needs two triggers rather than one, because OLD cannot be referenced in a WHEN clause on a combined INSERT OR UPDATE trigger. Also corrects a claim repeated in the migration, the checkout route and #525's description: `revenue_splits` is NOT super-admin-only under RLS. Its SELECT policy is `tenant_id = get_tenant_id() OR is_super_admin()` — every tenant member, students included, can read their school's split. SECURITY DEFINER is kept for the opposite reason: so this trigger does not silently depend on a permissive policy whose own name contradicts it, and stamp the 80 fallback onto every checkout the day someone tightens it. Verification (local DB, not just static): the verify snippet now covers seven cases including both tamper paths and the no-lazy-backfill rule, and all seven pass; the rollback script drops cleanly and the migration re-applies. The snippet is relabelled LOCAL ONLY — it needs ALTER TABLE ... DISABLE TRIGGER, which locks the live payments table. The unit tests are relabelled as static drift guards, since a green run says nothing about whether the trigger works. Co-Authored-By: Jiho Lee <optional.int@kakao.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013NffLeUiW78cokcUXrt68n
1 parent da0707f commit 86f50e0

6 files changed

Lines changed: 272 additions & 104 deletions

File tree

app/api/payments/checkout/route.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -207,9 +207,18 @@ export async function POST(req: NextRequest) {
207207

208208
// Snapshot the tenant's CURRENT revenue split onto this transaction (#496)
209209
// so a later plan change doesn't retroactively reprice it in the payouts
210-
// computation — revenue_splits is super-admin-only under RLS, so this
211-
// needs the admin client even though the rest of this route uses the
212-
// user-scoped one.
210+
// computation. The admin client is belt-and-braces, not a requirement: this
211+
// comment used to say revenue_splits is "super-admin-only under RLS", which
212+
// is wrong — the SELECT policy is `tenant_id = get_tenant_id() OR
213+
// is_super_admin()`, so the user-scoped client could read it too (#512).
214+
//
215+
// Since #512 the DATABASE owns this column: the BEFORE INSERT trigger in
216+
// 20260725110000_transaction_split_snapshot_backstop.sql recomputes it from
217+
// the same table and ignores whatever we send, because the column is
218+
// writable by any authenticated client (transactions RLS restricts rows, not
219+
// columns) and a student-supplied value must not survive. This write is kept
220+
// deliberately: it computes the identical number, and it keeps the rollback
221+
// migration a safe lever — drop the trigger and this path still snapshots.
213222
const adminClientForSplit = createAdminClient()
214223
const { data: revenueSplit } = await adminClientForSplit
215224
.from('revenue_splits')

lib/payments/payouts-owed.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,19 +16,31 @@
1616
* whole sum. A plan change (which rewrites `revenue_splits`) therefore only
1717
* affects transactions created after the change.
1818
*
19-
* That snapshot is also enforced in the database (issue #512): a
20-
* BEFORE INSERT OR UPDATE trigger on `transactions` fills it from
21-
* `revenue_splits` whenever a caller omits it, so a transaction-insert path
22-
* that forgets — or a webhook that sets `payment_provider` on an existing row
23-
* post-insert — cannot silently fall back to the current-split behaviour.
24-
* The trigger never overwrites a non-NULL snapshot, so the app-layer write
25-
* stays authoritative and history is never re-stamped.
19+
* Since issue #512 that snapshot is OWNED BY THE DATABASE, not by the caller.
20+
* `20260725110000_transaction_split_snapshot_backstop.sql` computes it from
21+
* `revenue_splits` on INSERT and freezes it on UPDATE, so:
22+
*
23+
* - a transaction-insert path that forgets it (four of the five do) can't
24+
* silently fall back to the current-split behaviour;
25+
* - a webhook that sets `payment_provider` on an existing row post-insert
26+
* (`lib/payments/webhook-dispatch.ts`) can't either;
27+
* - and no client can supply the number. `transactions` grants ALL to
28+
* `authenticated` and its RLS policies restrict which ROWS a user may
29+
* write, never which columns — so without the trigger a student could set
30+
* `school_percentage_snapshot: 100` on their own transaction and inflate
31+
* `grossOwed` below.
32+
*
33+
* The value never changes once written, so history is never re-stamped —
34+
* re-stamping would be the #496 bug, not a fix for it.
2635
*
2736
* Transactions predating the snapshot column (`schoolPercentageSnapshot:
2837
* null`) still fall back to the tenant's current `schoolPercentage` — the
2938
* same behavior this module had before #496, so old data isn't retroactively
3039
* wrong either way. They were deliberately not backfilled: stamping today's
31-
* split onto history would manufacture the very repricing #496 removed.
40+
* split onto history would manufacture the very repricing #496 removed. Such
41+
* a row is snapshotted in exactly one situation — a provider activating it
42+
* (its `payment_provider` changing), the one moment at which today's split is
43+
* the honest answer for it.
3244
*
3345
* Balances are grouped PER CURRENCY (issue #497) — a tenant with both USD and
3446
* EUR sales owes two separate numbers, never one meaningless summed total.

supabase/migrations/20260725110000_transaction_split_snapshot_backstop.sql

Lines changed: 125 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,50 +7,103 @@
77
-- back to the tenant's CURRENT split, which is the #496 bug returning by a side
88
-- door. The number is just quietly wrong.
99
--
10-
-- WHY INSERT *OR UPDATE*, not INSERT alone.
10+
-- This makes `transactions.school_percentage_snapshot` a DATABASE-OWNED column:
11+
-- the value is computed here, from revenue_splits, and is immutable once set.
12+
-- No caller writes it and no caller can tamper with it.
1113
--
12-
-- The obvious backstop is BEFORE INSERT, on the reasoning that the unsnapshotted
13-
-- insert sites are harmless because they leave payment_provider NULL (they write
14-
-- payment_method instead) or use Stripe Connect, and getPayoutsOwed() filters on
15-
-- platform-settled payment_provider values. That reasoning holds for the insert
16-
-- and fails for the row's lifetime: payment_provider is also written by UPDATE,
17-
-- after the row exists —
14+
-- WHY THE COLUMN HAS TO BE DATABASE-OWNED, NOT MERELY DEFAULTED.
15+
--
16+
-- The obvious backstop is "fill it in when the caller omits it, and let an
17+
-- explicit value win." That cannot be the rule here, because the explicit value
18+
-- can come from a caller we do not trust. `transactions` grants ALL to
19+
-- `authenticated` (20260126190500_lms_complete.sql), and the RLS policies in
20+
-- 20260313025318_rls_transactions.sql restrict only WHICH ROWS a user may write,
21+
-- never which columns:
22+
--
23+
-- CREATE POLICY "Users can create own transactions"
24+
-- ON public.transactions FOR INSERT TO authenticated
25+
-- WITH CHECK (auth.uid() = user_id AND tenant_id = get_tenant_id());
26+
--
27+
-- So a student can POST /rest/v1/transactions with school_percentage_snapshot:
28+
-- 100 — or UPDATE their own row to set it later — and that number flows straight
29+
-- into computeOwedBalances()'s grossOwed, inflating what the platform believes it
30+
-- owes their school. A backstop that defers to the explicit value would preserve
31+
-- exactly the write we most need to override. Hence: computed on INSERT, frozen
32+
-- on UPDATE.
33+
--
34+
-- (The column-privilege alternative — REVOKE INSERT, UPDATE (school_percentage_
35+
-- snapshot) — is a no-op while the table-level grant is held. Closing it that way
36+
-- means revoking table-level INSERT/UPDATE from `authenticated` and re-granting an
37+
-- explicit column list, which silently breaks every column omitted from that list.
38+
-- Owning the value in a trigger is the narrower change.)
39+
--
40+
-- WHY UPDATE, not INSERT alone.
41+
--
42+
-- payment_provider — the field getPayoutsOwed() filters on — is also written by
43+
-- UPDATE, after the row exists:
1844
--
1945
-- lib/payments/webhook-dispatch.ts:205 .update({ status, payment_provider })
2046
-- app/api/stripe/webhook/route.ts:406 same shape, Stripe subscriptions
2147
--
2248
-- dispatchBillingEvent is the shared activation path for PayPal, the unified
23-
-- provider webhook (Lemon Squeezy et al), and Stripe. PayPal and Lemon Squeezy
24-
-- are precisely the settlesToPlatformAccount providers the payout computation
25-
-- reads. So a row can be inserted with payment_provider NULL, sail past a
26-
-- BEFORE INSERT trigger with nothing to do, and only then be turned into a
27-
-- platform-settled transaction by a webhook — arriving in the payout math with
28-
-- a NULL snapshot. Covering UPDATE closes that path.
49+
-- provider webhook (Lemon Squeezy et al), and Stripe. PayPal and Lemon Squeezy are
50+
-- precisely the settlesToPlatformAccount providers the payout computation reads.
51+
-- So a row inserted before this migration, carrying a NULL snapshot, can be turned
52+
-- into a platform-settled transaction by a webhook and arrive in the payout math
53+
-- with nothing snapshotted. Covering UPDATE closes that path.
2954
--
3055
-- WHAT IT DELIBERATELY DOES NOT DO.
3156
--
32-
-- 1. It never overwrites an existing snapshot. On UPDATE it acts only while the
33-
-- value is still NULL, so a historical transaction is never re-stamped with a
34-
-- newer split — re-stamping would BE the #496 bug, not a fix for it. The
35-
-- app-layer write therefore stays authoritative; this only fills gaps.
57+
-- 1. It never re-stamps a snapshot that already exists. Re-stamping a historical
58+
-- transaction with a newer split would BE the #496 bug, not a fix for it. On
59+
-- UPDATE the previous value is restored verbatim, so the row is frozen from the
60+
-- moment it is first written — against tampering and against drift alike.
3661
--
37-
-- 2. It does not backfill existing NULLs. Rows predating 20260724140000 are
38-
-- documented (in that migration and in lib/payments/payouts-owed.ts) to fall
39-
-- back to the tenant's current split. Backfilling would stamp TODAY's split
40-
-- onto history and manufacture exactly the retroactive repricing #496
41-
-- removed — and it would look authoritative while doing it, which is worse
42-
-- than a documented fallback.
62+
-- 2. It does not backfill existing NULLs, and it does not fill them on unrelated
63+
-- updates. Rows predating 20260724140000 are documented (in that migration and
64+
-- in lib/payments/payouts-owed.ts) to fall back to the tenant's current split.
65+
-- Stamping today's split onto a year-old sale is the same retroactive repricing
66+
-- #496 removed — it does not stop being that because it happens one row at a
67+
-- time, when something incidental (a refund, an archive) touches the row. A
68+
-- legacy NULL is therefore filled in exactly ONE case: the row is being
69+
-- activated by a provider right now (payment_provider is changing), which is
70+
-- the only moment at which today's split is the honest answer.
4371
--
4472
-- A CHECK constraint was the alternative offered in the issue. It cannot read
4573
-- revenue_splits, and a check like `payment_provider IS NULL OR snapshot IS NOT
46-
-- NULL` would hard-fail the webhook UPDATE above for any legacy NULL-snapshot
47-
-- row a provider later activates — converting a quiet mispricing into a failed
74+
-- NULL` would hard-fail the webhook UPDATE above for any legacy NULL-snapshot row
75+
-- a provider later activates — converting a quiet mispricing into a failed
4876
-- activation and a lost sale. Filling the value beats rejecting the row.
77+
--
78+
-- The pre-existing after_transaction_insert / after_transaction_update triggers
79+
-- are both AFTER, so these BEFORE triggers compose with them without ordering
80+
-- concerns.
4981

50-
-- Mirrors DEFAULT_SCHOOL_PERCENTAGE in lib/payments/payouts-owed.ts, the 20%
51-
-- platform default in app/api/stripe/create-payment-intent/route.ts, and the
52-
-- row seeded by 20260216212440_create_revenue_infrastructure.sql. Used only
53-
-- when a tenant has no revenue_splits row at all.
82+
-- The fallback mirrors DEFAULT_SCHOOL_PERCENTAGE in lib/payments/payouts-owed.ts,
83+
-- the revenue_splits.school_percentage column default, the 20% platform default in
84+
-- app/api/stripe/create-payment-intent/route.ts, and the row seeded by
85+
-- 20260216212440_create_revenue_infrastructure.sql. It is used only when a tenant
86+
-- has no revenue_splits row at all. (tests/unit/transaction-split-snapshot-
87+
-- backstop.test.ts fails if this number and the TypeScript constant drift apart.)
88+
--
89+
-- SECURITY DEFINER, deliberately — but not for the reason the surrounding code
90+
-- comments claim. `app/api/payments/checkout/route.ts` says revenue_splits is
91+
-- "super-admin-only under RLS"; it isn't. Despite its name, the SELECT policy from
92+
-- 20260216212440 is:
93+
--
94+
-- "Admins can view own revenue split": tenant_id = get_tenant_id() OR is_super_admin()
95+
--
96+
-- — i.e. every member of the tenant, students included, can read their school's
97+
-- split (verified against a local database). So an INVOKER function would in fact
98+
-- work today, because the transactions INSERT policy already pins
99+
-- NEW.tenant_id = get_tenant_id().
100+
--
101+
-- That is exactly why it must not be INVOKER. The correctness of this trigger would
102+
-- then rest on a SELECT policy whose own name says it should be narrower — and the
103+
-- day someone tightens it to match the name, this function stops seeing the row and
104+
-- silently stamps the 80 fallback onto every checkout for every tenant. Wrong
105+
-- payout numbers, no error, no failing test. DEFINER removes that coupling.
106+
-- search_path is pinned for the usual reason.
54107
CREATE OR REPLACE FUNCTION public.set_transaction_split_snapshot()
55108
RETURNS trigger
56109
LANGUAGE plpgsql
@@ -60,12 +113,28 @@ AS $function$
60113
DECLARE
61114
v_school_percentage numeric;
62115
BEGIN
63-
-- Explicit app-layer value wins, always.
64-
IF NEW.school_percentage_snapshot IS NOT NULL THEN
65-
RETURN NEW;
116+
IF TG_OP = 'UPDATE' THEN
117+
-- Already snapshotted: frozen. Restoring OLD rather than comparing-and-
118+
-- raising means a tampering UPDATE is neutralised instead of failing the
119+
-- whole webhook that happened to carry it.
120+
IF OLD.school_percentage_snapshot IS NOT NULL THEN
121+
NEW.school_percentage_snapshot := OLD.school_percentage_snapshot;
122+
RETURN NEW;
123+
END IF;
124+
125+
-- Legacy NULL row. Fill it only if a provider is activating it right now;
126+
-- otherwise keep the documented NULL fallback and discard whatever the
127+
-- caller tried to put there.
128+
IF NEW.payment_provider IS NOT DISTINCT FROM OLD.payment_provider THEN
129+
NEW.school_percentage_snapshot := NULL;
130+
RETURN NEW;
131+
END IF;
66132
END IF;
67133

134+
-- INSERT, or the legacy-row activation case above. Either way the value is
135+
-- computed here; anything the caller supplied is ignored.
68136
IF NEW.tenant_id IS NULL THEN
137+
NEW.school_percentage_snapshot := NULL;
69138
RETURN NEW;
70139
END IF;
71140

@@ -80,11 +149,31 @@ END;
80149
$function$;
81150

82151
COMMENT ON FUNCTION public.set_transaction_split_snapshot() IS
83-
'Backstop for #496 (issue #512): fills transactions.school_percentage_snapshot from revenue_splits when a caller omits it. Never overwrites a non-NULL snapshot, so historical rows are not repriced. Fires on UPDATE too, because payment_provider is set post-insert by the webhook activation path (lib/payments/webhook-dispatch.ts).';
152+
'Backstop for #496 (issue #512): owns transactions.school_percentage_snapshot. Computes it from revenue_splits on INSERT and freezes it on UPDATE, so neither an insert path that forgets nor a client that tampers (transactions RLS restricts rows, not columns) can affect the payout computation. Legacy NULL rows are filled only when a provider activates them (payment_provider changing), never on an incidental update.';
84153

154+
-- Named without the _insert suffix in an earlier revision of this migration.
85155
DROP TRIGGER IF EXISTS before_transaction_split_snapshot ON transactions;
86-
CREATE TRIGGER before_transaction_split_snapshot
87-
BEFORE INSERT OR UPDATE ON transactions
156+
157+
-- No WHEN clause: the point is to override whatever the caller sent, so this has
158+
-- to run on every insert. It is one index lookup on revenue_splits (UNIQUE
159+
-- (tenant_id), 20260216212440) against a table only written on payment events.
160+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot_insert ON transactions;
161+
CREATE TRIGGER before_transaction_split_snapshot_insert
162+
BEFORE INSERT ON transactions
163+
FOR EACH ROW
164+
EXECUTE FUNCTION public.set_transaction_split_snapshot();
165+
166+
-- OLD cannot be referenced in a WHEN clause on a combined INSERT OR UPDATE
167+
-- trigger, which is why this is a second trigger rather than one. The clause
168+
-- keeps ordinary status-only updates from calling the function at all: it fires
169+
-- only when someone is touching the snapshot, or when a provider is activating
170+
-- the row.
171+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot_update ON transactions;
172+
CREATE TRIGGER before_transaction_split_snapshot_update
173+
BEFORE UPDATE ON transactions
88174
FOR EACH ROW
89-
WHEN (NEW.school_percentage_snapshot IS NULL)
175+
WHEN (
176+
NEW.school_percentage_snapshot IS DISTINCT FROM OLD.school_percentage_snapshot
177+
OR NEW.payment_provider IS DISTINCT FROM OLD.payment_provider
178+
)
90179
EXECUTE FUNCTION public.set_transaction_split_snapshot();
Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
-- Rollback for 20260725110000_transaction_split_snapshot_backstop.sql (#512).
22
--
3-
-- Removes the database backstop that fills transactions.school_percentage_snapshot.
4-
-- After this runs, only app/api/payments/checkout/route.ts sets the snapshot, and
5-
-- any other path that creates a platform-settled transaction (or a webhook that
6-
-- sets payment_provider on an existing row) silently falls back to the tenant's
7-
-- CURRENT split in computeOwedBalances — i.e. the #496 retroactive-repricing bug
8-
-- returns, quietly and with no failure. Only run this to unblock an incident.
9-
--
10-
-- Snapshots already written by the trigger are left in place: they record the
3+
-- Removes the database ownership of transactions.school_percentage_snapshot.
4+
-- After this runs:
5+
--
6+
-- 1. Only app/api/payments/checkout/route.ts sets the snapshot. Any other path
7+
-- that creates a platform-settled transaction — or a webhook that sets
8+
-- payment_provider on an existing row — silently falls back to the tenant's
9+
-- CURRENT split in computeOwedBalances, i.e. the #496 retroactive-repricing
10+
-- bug returns, quietly and with no failure.
11+
-- 2. The column becomes client-writable again. `transactions` grants ALL to
12+
-- `authenticated` and its RLS policies restrict rows, not columns, so a
13+
-- student can set school_percentage_snapshot on their own transaction and
14+
-- inflate what the platform believes it owes their school.
15+
--
16+
-- (2) is the reason to treat this as an incident-only lever rather than a clean
17+
-- revert. If you run it, watch /platform/payouts for balances that move without a
18+
-- corresponding sale.
19+
--
20+
-- Snapshots already written by the triggers are left in place: they record the
1121
-- split that was genuinely in effect, and clearing them would reintroduce the
1222
-- fallback for those rows too.
1323

24+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot_insert ON transactions;
25+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot_update ON transactions;
26+
-- Name used by the first revision of the up-migration; harmless if absent.
1427
DROP TRIGGER IF EXISTS before_transaction_split_snapshot ON transactions;
1528
DROP FUNCTION IF EXISTS public.set_transaction_split_snapshot();

0 commit comments

Comments
 (0)