Skip to content

Commit 7cab2ed

Browse files
DPS0340claude
andauthored
fix(db): make the split snapshot database-owned, not caller-supplied (#512) (#525)
Backs the #496 revenue-split snapshot with a database trigger instead of a single call site, and makes the column database-owned rather than caller-supplied. - BEFORE INSERT computes school_percentage_snapshot from revenue_splits with no WHEN guard, so a caller-supplied value cannot survive — `transactions` grants ALL to `authenticated` and its RLS policies restrict rows, not columns. - BEFORE UPDATE restores the previous value when one exists (history is never re-stamped) and fills a legacy NULL only when payment_provider is changing, i.e. when a provider is activating the row. Two triggers, because OLD cannot be referenced in a WHEN clause on a combined INSERT OR UPDATE trigger. - Covering UPDATE is what closes the real gap: payment_provider is written post-insert by the webhook activation path (lib/payments/webhook-dispatch.ts). Verified on a local database: 7/7 verification cases, and all seven transaction-insert paths in the repo now snapshot. Rollback script drops cleanly and the migration re-applies. Co-authored-by: Jiho Lee <optional.int@kakao.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8715795 commit 7cab2ed

6 files changed

Lines changed: 442 additions & 7 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: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,33 @@
1414
* snapshotted at insert time in `app/api/payments/checkout/route.ts` — see
1515
* issue #496), not the tenant's current split applied retroactively to the
1616
* whole sum. A plan change (which rewrites `revenue_splits`) therefore only
17-
* affects transactions created after the change. Transactions predating the
18-
* snapshot column (`schoolPercentageSnapshot: null`) fall back to the
19-
* tenant's current `schoolPercentage` — the same behavior this module had
20-
* before #496, so old data isn't retroactively wrong either way.
17+
* affects transactions created after the change.
18+
*
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.
35+
*
36+
* Transactions predating the snapshot column (`schoolPercentageSnapshot:
37+
* null`) still fall back to the tenant's current `schoolPercentage` — the
38+
* same behavior this module had before #496, so old data isn't retroactively
39+
* wrong either way. They were deliberately not backfilled: stamping today's
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.
2144
*
2245
* Balances are grouped PER CURRENCY (issue #497) — a tenant with both USD and
2346
* EUR sales owes two separate numbers, never one meaningless summed total.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
-- Issue #512 — database backstop for the revenue-split snapshot.
2+
--
3+
-- #496 fixed retroactive repricing by snapshotting revenue_splits.school_percentage
4+
-- onto each transaction, but that snapshot is written at exactly ONE call site
5+
-- (app/api/payments/checkout/route.ts). Every other transaction-insert path
6+
-- omits it, and nothing fails when they do: computeOwedBalances() silently falls
7+
-- back to the tenant's CURRENT split, which is the #496 bug returning by a side
8+
-- door. The number is just quietly wrong.
9+
--
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.
13+
--
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:
44+
--
45+
-- lib/payments/webhook-dispatch.ts:205 .update({ status, payment_provider })
46+
-- app/api/stripe/webhook/route.ts:406 same shape, Stripe subscriptions
47+
--
48+
-- dispatchBillingEvent is the shared activation path for PayPal, the unified
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.
54+
--
55+
-- WHAT IT DELIBERATELY DOES NOT DO.
56+
--
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.
61+
--
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.
71+
--
72+
-- A CHECK constraint was the alternative offered in the issue. It cannot read
73+
-- revenue_splits, and a check like `payment_provider IS NULL OR snapshot IS NOT
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
76+
-- 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.
81+
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.
107+
CREATE OR REPLACE FUNCTION public.set_transaction_split_snapshot()
108+
RETURNS trigger
109+
LANGUAGE plpgsql
110+
SECURITY DEFINER
111+
SET search_path TO 'public'
112+
AS $function$
113+
DECLARE
114+
v_school_percentage numeric;
115+
BEGIN
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;
132+
END IF;
133+
134+
-- INSERT, or the legacy-row activation case above. Either way the value is
135+
-- computed here; anything the caller supplied is ignored.
136+
IF NEW.tenant_id IS NULL THEN
137+
NEW.school_percentage_snapshot := NULL;
138+
RETURN NEW;
139+
END IF;
140+
141+
SELECT school_percentage INTO v_school_percentage
142+
FROM revenue_splits
143+
WHERE tenant_id = NEW.tenant_id;
144+
145+
NEW.school_percentage_snapshot := COALESCE(v_school_percentage, 80);
146+
147+
RETURN NEW;
148+
END;
149+
$function$;
150+
151+
COMMENT ON FUNCTION public.set_transaction_split_snapshot() IS
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.';
153+
154+
-- Named without the _insert suffix in an earlier revision of this migration.
155+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot 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
174+
FOR EACH ROW
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+
)
179+
EXECUTE FUNCTION public.set_transaction_split_snapshot();
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- Rollback for 20260725110000_transaction_split_snapshot_backstop.sql (#512).
2+
--
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
21+
-- split that was genuinely in effect, and clearing them would reintroduce the
22+
-- fallback for those rows too.
23+
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.
27+
DROP TRIGGER IF EXISTS before_transaction_split_snapshot ON transactions;
28+
DROP FUNCTION IF EXISTS public.set_transaction_split_snapshot();

0 commit comments

Comments
 (0)