fix(db): make the split snapshot database-owned, not caller-supplied (#512) - #527
Closed
guillermoscript wants to merge 2 commits into
Closed
fix(db): make the split snapshot database-owned, not caller-supplied (#512)#527guillermoscript wants to merge 2 commits into
guillermoscript wants to merge 2 commits into
Conversation
… site (#512) #496 fixed retroactive repricing by snapshotting revenue_splits.school_percentage onto each transaction, but the write lives at exactly one call site (app/api/payments/checkout/route.ts). Any other path that creates a platform-settled transaction falls back to the tenant's CURRENT split in computeOwedBalances — the #496 bug returning silently, with no failure. The issue proposed BEFORE INSERT, reasoning that the other insert sites are harmless because they leave payment_provider NULL or use Stripe Connect. That holds for the insert but not for the row's lifetime: payment_provider is also written by UPDATE, post-insert, by the shared webhook activation path (webhook-dispatch.ts:205, stripe/webhook/route.ts:406). dispatchBillingEvent serves PayPal and the unified provider webhook — precisely the settlesToPlatformAccount providers getPayoutsOwed() reads. So a row can be inserted NULL, pass a BEFORE INSERT trigger with nothing to do, and only then become platform-settled. Hence BEFORE INSERT OR UPDATE. The trigger acts only while the snapshot is NULL, so an explicit app-layer value always wins and a historical row is never re-stamped with a newer split — re-stamping would be the #496 bug, not a fix for it. Existing NULLs are not backfilled for the same reason: stamping today's split onto history would manufacture the repricing #496 removed, and would look authoritative doing it. A CHECK constraint (the issue's fallback suggestion) cannot read revenue_splits, and one on `payment_provider IS NULL OR snapshot IS NOT NULL` would hard-fail the webhook UPDATE for any legacy NULL-snapshot row a provider later activates, turning a quiet mispricing into a lost sale. Ships with a rollback script and executable verification SQL covering all four cases, since the trigger's behaviour cannot be reached from a node-environment vitest suite. The unit test guards what can be checked statically: that the fallback percentage cannot drift from DEFAULT_SCHOOL_PERCENTAGE.
…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
This was referenced Jul 25, 2026
transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale
#528
Closed
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #512. Builds on @DPS0340's #525 — their commit is preserved as the first commit here, and the second is the hardening that came out of reviewing it against a live database. If this lands, #525 should be closed as superseded (the credit for the core insight is theirs).
What #525 got right, and why it needed a second pass
The central finding in #525 is correct and worth restating:
payment_provider— the fieldgetPayoutsOwed()filters on — is written by UPDATE, after the row exists (lib/payments/webhook-dispatch.ts:205,app/api/stripe/webhook/route.ts:406). ABEFORE INSERTtrigger, which is what the issue asked for, never sees that path. Covering UPDATE is right.Reviewing it against a local database turned up two problems with the rule the trigger applied.
1. The column is client-writable, and "an explicit value always wins" preserves the one write we need to override
transactionsgrantsALLtoauthenticated(20260126190500_lms_complete.sql:3692), and the RLS policies restrict which rows a user may write, never which columns (20260313025318_rls_transactions.sql):So
school_percentage_snapshotcan be supplied by a student, and it flows straight intocomputeOwedBalances()'sgrossOwed. Measured against #525 as submitted, on a local DB where the tenant's real split is 70:Both survive, because the trigger's first rule is to return early when the value is non-NULL. Same two statements after this PR:
The column is now database-owned: computed from
revenue_splitson INSERT (noWHENguard — the point is to override what the caller sent), and frozen on UPDATE (the previous value is restored verbatim rather than raising, so a tampering UPDATE is neutralised instead of failing the webhook that carried it).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. What #525 did to them was stamp today's split, permanently, at whatever arbitrary moment something incidental first touched the row. A May sale refunded next week would be frozen at next week's split.
That is the retroactive repricing #496 removed; it does not stop being that because it happens one row at a time. #525's own description argues against backfilling for exactly this reason, so this is a case of the implementation not matching its stated intent rather than a disagreement about the goal.
A legacy NULL is now filled in exactly one case:
payment_provideris changing, i.e. a provider is activating the row right now — the one moment at which today's split is the honest answer for it. This needs two triggers rather than one, becauseOLDcannot be referenced in aWHENclause on a combinedINSERT OR UPDATEtrigger.Correction:
revenue_splitsis not super-admin-onlyThe migration, the checkout route's comment, and #525's description all repeat that
revenue_splitsis super-admin-only under RLS. It isn't. Verified on a local DB:Despite the policy's name, every member of the tenant — students included — can read their school's split. Two consequences:
SECURITY DEFINERis kept, but for the opposite reason to the one given. An INVOKER function would work today; it would also make this trigger's correctness depend on a permissive policy whose own name contradicts it. The day someone tightens that policy to match its name, an INVOKER trigger stops seeing the row and silently stamps the 80 fallback onto every checkout, for every tenant — wrong payout numbers, no error, no failing test.app/api/payments/checkout/route.tsclaiming the admin client is required there is corrected. It's belt-and-braces, not a requirement.Unfiled follow-up, not touched here: that policy's name and its predicate disagree. The predicate looks deliberate (
dashboard/teacher/revenuereads the table on the user-scoped client), so the name is what's wrong.Also in this PR
ALTER TABLE transactions DISABLE TRIGGER, which locks the live payments table against INSERT/UPDATE for as long as the script holds it, requires table ownership (it won't run asservice_role), and leaves the trigger disabled for other sessions if the script dies with the transaction open. It also grew from 5 cases to 7 — both tamper paths and the no-lazy-backfill rule.DEFAULT_SCHOOL_PERCENTAGEdrift guard earns its place (that constant is genuinely duplicated into the plpgsql); the tautologicalnot.toMatch(/UPDATE\s+transactions\s+SET/i)is dropped, and a guard that the INSERT trigger stays unconditional is added. A green run of that file is not evidence the migration works — the snippet is.Blast radius
transactionsINSERT now always does one index lookup onrevenue_splits(UNIQUE (tenant_id)), on a table written only on payment events. UPDATE calls the function only when the snapshot orpayment_provideris actually changing — ordinary status-only updates don't fire it at all. The pre-existingafter_transaction_insert/after_transaction_updatetriggers are AFTER, so ordering is unaffected. No app behaviour changes: the checkout route's own write is kept (it computes the identical number and keeps the rollback a safe lever), and every other insert path simply stops being wrong.Testing
Run against a local DB, not just statically:
Expected, and what I got:
Also verified on the local DB:
SECURITY DEFINERworks from the real checkout shape —SET LOCAL ROLE authenticatedwith a tenant JWT claim, inserting with no snapshot supplied, yields the tenant's real split (65 in the fixture), not the 80 fallback.Suite level:
npm run test:unit321 passed (28 files) ·npm run typecheckclean ·npx eslintclean on changed files.Not yet deployed to cloud —
supabase db pushis still to run.Reviewer notes
supabase/snippets/despite that folder otherwise holding Studio dumps.supabase/migrations/would auto-apply it andsupabase/tests/is reserved for pgTAP (supabase test db), so this is the least-bad existing home. Happy to move it if there's a preferred spot.status: 'successful', payment_provider: 'paypal'on their own row, which both self-enrols them viatrigger_manage_transactionsand lands a fabricated sale in the payout math. Filed separately.🤖 Generated with Claude Code
https://claude.ai/code/session_013NffLeUiW78cokcUXrt68n