ClipHaven

B2B2C marketplace connecting sponsors with content creators. Built the full double-entry financial system, adaptive scraping, and fraud detection from scratch.

TypeScriptNestJSNext.jsPostgreSQLStripe ConnectBullMQ
January 10, 20254 min read

A marketplace where sponsors fund campaigns and content creators earn based on view performance. Money flows in multiple directions, view counts come from third-party APIs that lie sometimes, and the whole thing needs to pay people correctly every week. I led full-stack development and owned the architecture -- database schema, financial system, scraping pipeline, admin tooling, the works. Team of 5+ developers.

The hard part

Getting money right. In a marketplace like this, you can't just increment a balance field and call it a day. Concurrent writes, partial failures, refunds, chargebacks -- any of these can leave your books in an inconsistent state. The answer was a double-entry ledger.

Double-entry ledger

Every financial event creates immutable ledger entries. Accruals, payouts, platform fees, transfers, refunds -- each one gets a debit and a credit. Balances are always derived by aggregating entries, never stored directly. This means the books are auditable by construction. You can reconstruct the state of any account at any point in time.

Concurrent write conflicts were a real problem. Multiple clips earning simultaneously against the same campaign budget means competing transactions. I used Prisma's ReadCommitted isolation with retry logic on P2034 (deadlock) errors -- exponential backoff at 100ms, 250ms, 600ms, then fail. Only transient conflicts get retried; everything else fails fast.

Earnings calculations use integer math throughout. No floating point. Half-up rounding for CPM: Math.floor((value + 500) / 1000). Campaigns auto-complete when their budget hits zero, and there's anomaly detection that flags cases where processed view counts somehow exceed the latest scrape (which shouldn't happen, but I've learned to trust nothing).

Loading diagram...

Adaptive scraping

View counts come from Apify (TikTok, Instagram) and YouTube Data API. Polling every clip every hour would burn through API credits fast, and most of it would be wasted on old clips that barely move. So polling intervals scale with content age: hourly for fresh clips, gradually widening to every 72 hours for month-old content.

The scraping runs in batches of 25 clips per Apify call to amortize actor start costs. Results are matched by URL and video ID, not array position -- Apify doesn't guarantee ordering, and I didn't want to find that out in production. TikTok short URLs (vm.tiktok.com) get resolved via HEAD redirects to canonical URLs before scraping, because the short links expire.

When Apify returns no data for a clip, we skip it rather than overwriting with zero. And there's a sanity check: if views drop more than 20%, we log a warning (platforms remove bot views sometimes) but don't panic.

Queue architecture

BullMQ handles the async work. Four queues: scraping (runs every minute, velocity-gated), payouts (weekly on Mondays, with a separate retry queue for pending bank transfers), notifications (3 retries with backoff), and channel ownership verification.

Payouts are two-phase: first transfer to the creator's Stripe Connect account, then a bank payout intent. Idempotency keys are built from batch ID, user ID, campaign ID, and amount, so duplicate transfers can't happen even if the job retries. I got burned by this exact problem early on -- a retry created a double transfer during testing. The idempotency keys fixed it permanently.

Authentication

JWT with refresh token rotation. Access tokens expire in 15 minutes with a jti for Redis-backed blacklisting. Refresh tokens are SHA-256 hashed, stored in the database, and single-use -- consumed on refresh, new one issued. There's also magic link auth with crypto.randomBytes(32) tokens, single-use with a used flag.

Tech stack

TypeScript, NestJS, Next.js 16, React 19, PostgreSQL, Prisma, Redis, BullMQ, Stripe Connect, Apify, YouTube Data API, Resend, DigitalOcean Spaces, Docker, Coolify, Tailwind CSS, Zustand, TanStack Query, PostHog.