backend / pour-me-kunu
Pour Me Kunu
A creator tipping and membership backend that takes one-off tips and recurring Paystack subscriptions, keeps a kobo-denominated wallet per creator and pays out by Paystack transfer.
2026 · paused · open source
- [*]LanguagesTypeScript
- [*]FrameworksNext.js (App Router) · tRPC v11 · Drizzle ORM · Better Auth
- [*]StoresPostgres (Neon serverless HTTP)
- [*]AsyncPaystack webhooks
- [*]InfraVercel
- [*]Third partiesPaystack (Transactions, Plans, Subscriptions, Transfers, Banks, Account Resolve)
A Nigerian 'buy me a coffee' built in 34 commits over two days (14 to 15 May 2026), all by the owner. It is a Next.js App Router app with a tRPC v11 API (29 procedures across 5 routers), Better Auth (email/password plus optional Google), and Drizzle over Neon's serverless HTTP driver. There are 11 tables across 3 drizzle-kit migrations.
The money flows through Paystack. Tips start a Paystack transaction with a 5% platform fee split out in kobo. Memberships create a Paystack plan per tier and subscribe supporters to it. Creators add a bank account (resolved and turned into a Paystack transfer recipient) and request payouts, which become Paystack transfers.
Correctness rests on conditional single-statement updates rather than transactions, which the Neon HTTP driver does not offer here. A tip flips pending to paid with a compare-and-set that both the webhook and the browser-callback path race for. Subscription charges are inserted once, keyed on the unique Paystack reference. Payout requests debit the wallet only if the balance covers the amount. Failed or reversed transfers refund only if the payout is still open.
No tests. A PAYOUTS_SIMULATE flag short-circuits transfers to a marked TRF_simulated_ success, because Paystack transfers need business KYC that the account did not have.
How it holds up
- Auth
- Better Auth sessions (email/password, Google when configured) behind tRPC protectedProcedure. Admin pages are gated by an ADMIN_EMAILS allowlist checked in the admin layout. Paystack webhooks are authenticated by HMAC-SHA512 over the raw body with a constant-time compare.
- Data
- 11 tables across 3 migrations: user, session, account, verification, creator_profile, wallet_balance, tip, membership_tier, subscription, subscription_charge and payout, plus 4 pg enums. All amounts are integer kobo. A tip stores amount, fee and net, and the wallet keeps available and lifetime balances.
- Consistency
- Every state change is a conditional UPDATE ... WHERE status IN (...) RETURNING or an INSERT ... ON CONFLICT, so replayed webhooks and the callback/webhook race are harmless. Wallet credits are atomic upserts (available = available + net). There are no multi-statement transactions: if the CAS flip succeeds and the wallet upsert then fails, the retry finds the tip already paid and never credits it.
- Failure
- If Paystack rejects a transfer, requestPayout refunds the held balance and marks the payout failed. The webhook returns 500 on a handler error so Paystack retries. Unknown references and duplicate events return early. Tip and subscribe starts are rate-limited by an in-memory token bucket per IPv4 /24, which is per instance only. invoice.payment_failed is left as a TODO.
- Testing
- 0 test files.
Techniques
- Webhook/callback race settled by compare-and-setThe browser callback (after verifyTransaction) and the charge.success webhook both run UPDATE tip SET status='paid' WHERE reference=? AND status='pending' RETURNING. Only the caller that gets a row back credits the wallet, so a tip is credited once whichever path arrives first.
src/app/api/paystack/webhook/route.ts:96src/app/checkout/callback/page.tsx:226 - Raw-body HMAC-SHA512 webhook verificationThe handler reads the body as text before parsing, computes HMAC-SHA512 with the Paystack secret and compares it with timingSafeEqual after a length check. Bad signatures get 401; handler errors get 500 so Paystack retries.
src/server/paystack/webhook-verify.ts:9src/app/api/paystack/webhook/route.ts:17 - Idempotent subscription-charge ledgerRecurring charges are inserted with ON CONFLICT DO NOTHING on the unique Paystack reference, and the wallet is credited only when the insert returned a row. Redelivered renewal webhooks therefore credit nothing.
src/app/api/paystack/webhook/route.ts:162 - Guarded balance debit with refund on transfer failureA payout request debits the wallet with UPDATE ... WHERE available_kobo >= amount RETURNING, so two concurrent payout requests cannot overdraw it. If Paystack's initiateTransfer throws, the amount goes back and the payout is marked failed; transfer.failed and transfer.reversed webhooks refund only while the payout is still processing or requested.
src/server/trpc/routers/payouts.ts:208src/server/trpc/routers/payouts.ts:280src/app/api/paystack/webhook/route.ts:319 - Subnet-bucketed token-bucket rate limitPayment-start endpoints draw from a continuous-refill token bucket keyed by endpoint and the client's IPv4 /24, at 10 tips or 5 subscribes per minute, and return TOO_MANY_REQUESTS when empty.
src/server/rate-limit.ts:24src/server/rate-limit.ts:44src/server/trpc/routers/memberships.ts:191
Counted
- 34commits
git rev-list --count HEAD - 1 (Halleluyah Oludele)authors
git shortlog -sne HEAD - 9823TypeScript LOC (all tracked .ts/.tsx)
git ls-files '*.ts' '*.tsx' | xargs wc -l | tail -1 - 4801server + API route LOC
git ls-files src/server src/app/api | xargs wc -l | tail -1 - 11 / 3 / 4DB tables / migrations / enums
grep 'CREATE TABLE' src/server/db/migrations/*.sql; grep pgEnum( src/server/db/schema - 5 / 29tRPC routers / procedures
grep -cE '^\s+[a-zA-Z]+: (protected|public)Procedure' src/server/trpc/routers/*.ts - 7Paystack webhook events handled
src/app/api/paystack/webhook/route.ts:33-58 - 8Paystack client calls
grep 'export function' src/server/paystack/client.ts (excl. reference generator) - 5%platform fee
src/app/api/paystack/webhook/route.ts:13 - 0test files
git ls-files | grep -iE 'test|spec' | wc -l