You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
-- 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 FROMOLD.payment_provider THEN
129
+
NEW.school_percentage_snapshot :=NULL;
130
+
RETURN NEW;
131
+
END IF;
66
132
END IF;
67
133
134
+
-- INSERT, or the legacy-row activation case above. Either way the value is
135
+
-- computed here; anything the caller supplied is ignored.
68
136
IF NEW.tenant_id IS NULL THEN
137
+
NEW.school_percentage_snapshot :=NULL;
69
138
RETURN NEW;
70
139
END IF;
71
140
@@ -80,11 +149,31 @@ END;
80
149
$function$;
81
150
82
151
COMMENT ON FUNCTION public.set_transaction_split_snapshot() IS
83
-
'Backstop for #496 (issue #512): fills transactions.school_percentage_snapshotfrom 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.';
84
153
154
+
-- Named without the _insert suffix in an earlier revision of this migration.
85
155
DROPTRIGGER IF EXISTS before_transaction_split_snapshot ON transactions;
86
-
CREATETRIGGERbefore_transaction_split_snapshot
87
-
BEFORE INSERT ORUPDATEON 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
+
DROPTRIGGER IF EXISTS before_transaction_split_snapshot_insert ON transactions;
0 commit comments