Skip to main content
The Op-EdFull Stack
2026-08-092 min

Building CardBridge: An Escrow-Powered Card Discount Marketplace

How I built a card-discount marketplace where the rules live in Postgres: a trigger-enforced escrow state machine, an immutable event log, KYC tiers, and Row Level Security on every table.

Pass it on LinkedIn Post RSS

You see a great discount on something you want, but it only applies to a card you don't own. Someone else owns that card and would happily make the purchase for you for a small fee. The hard part isn't the idea, it's trust: the requester needs to know the product will arrive, the cardholder needs to know they'll be paid, and the platform needs a record of every step. CardBridge is my attempt at that marketplace.

The decision that shaped everything was to put the business rules in the database rather than in the React app. A transaction moves through MATCHED, FUNDED, PURCHASED, SHIPPED, DELIVERED and finally RELEASED, with CANCELLED, DISPUTED and REFUNDED as the exits. Every change goes through a transition_transaction function, and a BEFORE UPDATE trigger checks it against a map of legal transitions, the caller's role and the transaction's invariants before stamping the timestamp. An AFTER UPDATE trigger then writes an immutable event log, keeps the original request's status in sync and credits the user's tier. A buggy or malicious client simply can't skip a step, because Postgres refuses it.

Access control works the same way. Only Supabase's public anon key ships to the browser, and Row Level Security is enabled on every table, so each user can only read and change the transactions they're part of. A guard trigger stops people from promoting their own profile privileges, public-safe views expose only what the browse feed needs, and matching has database-level guards too: you can't accept your own request, and the escrow amount is computed on insert rather than trusted from the client.

Trust is layered on top. Users move through a KYC lifecycle of UNVERIFIED, PENDING, VERIFIED or REJECTED, with documents in a private storage bucket, and tiered transaction limits are enforced in both the UI and the database. To be clear about the prototype: the KYC provider and the escrow layer are stubbed. The lifecycle, limits and state machine are real; money and identity checks don't touch a live provider yet.

When something goes wrong, either party can open a dispute from MATCHED onwards, with optional evidence uploaded to its own private bucket. A dispute freezes escrow, since every automatic action skips DISPUTED transactions, and an admin panel gated by RLS lets a moderator release the funds, refund them, or ask for more information. Timeouts are checked when a transaction page loads and by a scheduled Edge Function, so abandoned transactions don't sit in limbo.

Postgres taught me a few things along the way. You can't use a new enum value in the same transaction that adds it, so adding MATCHED and SHIPPED needed its own migration that commits before the state machine. The SELECT and UPDATE policies on requests and transactions referenced each other and recursed, which I broke with SECURITY DEFINER helper functions. The whole schema ended up as fourteen ordered migrations, including those fixes.

What's missing is just as clear. There's no automated test suite yet, and the next thing I'd add is tests that fire concurrent transitions at the RPC to prove it holds under race conditions, followed by real payment and KYC providers. But the core lesson already landed: when money moves, the database should be the referee.

Filed under
ReactTypeScriptSupabasePostgreSQLRLSEscrow