Multi-tenant billing platform with hybrid data isolation, deterministic proration, and a full enterprise dashboard — built from scratch with NestJS, Next.js 14, PostgreSQL, and Stripe.
Every SaaS billing tool I've used gets the hard stuff wrong. They slap WHERE tenant_id = ? on queries and call it "multi-tenancy." They delegate proration to Stripe's black box and hope the numbers match. They treat webhook delivery as fire-and-forget.
StrataPay is the platform I wanted to exist:
- Real tenant isolation — not just a column filter. Small tenants share a PostgreSQL schema with RLS enforcement. Enterprise tenants get their own schema, switched at the connection level via
search_path. Same ORM, same pool, different isolation guarantees. - Proration you can verify — every cent is computed with
decimal.js(ROUND_HALF_UP), recorded in a PlanTransition audit trail, then sent to Stripe as a fixed amount. The preview in the UI matches the invoice. Always. - Webhook delivery that doesn't drop events — exponential backoff, dead letter queue, per-endpoint delivery logs with one-click retry.
┌──────────────────────────────────────────────────────────┐
│ Next.js 14 (App Router) │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Admin UI │ │ Tenant Dash │ │ Auth (Login) │ │
│ └──────────┘ └──────────────┘ └──────────────────┘ │
└───────────────────────┬──────────────────────────────────┘
│ HTTP / JWT
┌───────────────────────▼──────────────────────────────────┐
│ NestJS API │
│ ┌────────┐ ┌──────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ Auth │ │ Tenancy │ │ Billing │ │ Webhooks │ │
│ │ RBAC │ │ Context │ │ Prorate │ │ Audit │ │
│ └────────┘ └────┬─────┘ └────┬────┘ └──────────────┘ │
│ │ │ │
│ ┌──────▼────────────▼──────┐ │
│ │ Prisma + AsyncLocalStore │ │
│ │ (tenant-scoped $tx) │ │
│ └──────────┬───────────────┘ │
└───────────────────────┼──────────────────────────────────┘
│
┌─────────────┼─────────────┐
▼ ▼ ▼
┌────────────┐ ┌──────────┐ ┌──────────┐
│ PostgreSQL │ │ Redis │ │ Stripe │
│ 16 + RLS │ │ 7 │ │ API │
│ │ │ BullMQ │ │ │
└────────────┘ └──────────┘ └──────────┘
| Layer | Technology | Rationale |
|---|---|---|
| Backend | NestJS + TypeScript (strict) | Decorators + DI make multi-tenancy middleware clean |
| ORM | Prisma | Type-safe queries & migrations, raw SQL only for RLS policies |
| Database | PostgreSQL 16 | RLS, schema namespacing, search_path — the multi-tenancy trifecta |
| Queue | Redis 7 + BullMQ | Webhook retries, event dedup, tenant cache |
| Payments | Stripe | Subscription lifecycle, but we own the proration math |
| Frontend | Next.js 14 + shadcn/ui + Tailwind | App Router route groups, server components where possible |
| Infra | Docker Compose + GitHub Actions CI | Single command to spin up the full stack |
Two isolation modes running side-by-side on one PostgreSQL instance:
- Shared (RLS) — tenants share tables, isolated by row-level security policies. Zero overhead per tenant.
- Dedicated (Schema) — enterprise tenants get their own Postgres schema. Tenant context set via
SET LOCAL search_pathat connection level — not query params, not middleware hacks. - Live migration — promote any tenant from shared → dedicated without downtime. Background job copies data, swaps the routing entry, verifies row counts.
- All money math uses
decimal.jswith explicit rounding — no IEEE 754 floats in the money path - Credit (unused days on old plan) and charge (remaining days on new plan) rounded independently
- Every plan change writes a
PlanTransitionrecord with the full formula breakdown - UI preview shows the exact amount before the user confirms
- Stripe is told "charge this exact amount" — we don't use their proration
- Tenant-scoped endpoint registration with secret signing keys
- Event delivery with exponential backoff (1m → 2m → 4m → 8m → dead letter)
- Full delivery log per endpoint with HTTP status codes and response times
- One-click retry from the dashboard for failed/dead-letter deliveries
- Every create/update/delete is captured by a NestJS interceptor
- Stores actor, entity, old values, new values, IP, user agent
- Expandable diff view in the dashboard with dark-themed JSON blocks
- Filterable by action type, entity type, and date range
- Scoped permissions (billing:read, billing:write, team:read, etc.)
- Key prefix + last four visible, full key shown once on creation
- Expiration dates, usage tracking, security scoring
- One-click revocation with immediate effect
git clone <repo-url> && cd stratapay
npm install
# start infrastructure
docker-compose up -d postgres redis
# setup database
cp .env.example .env
npx prisma migrate dev --schema=apps/api/prisma/schema.prisma
npx prisma db seed --schema=apps/api/prisma/schema.prisma
# run both apps
npm run dev:api # → localhost:4000
npm run dev:web # → localhost:3000Or just docker-compose up for everything at once.
- Log in → Admin portal with dark sidebar, tenant management view
- Browse all tenants — filter by status, isolation mode, search by name/slug
- Click into a tenant → see subscription details, user list, isolation upgrade option
- Check Metrics → MRR trend chart, plan distribution, new vs churned tenants
- Log in → Dashboard with subscription card, quick links, usage stats
- Billing → Current plan, invoices with status badges, payment method management
- Team → Invite members, change roles, remove users
- API Keys → Create scoped keys, track usage, revoke instantly
- Webhooks → Register endpoints, view delivery logs, retry failed deliveries
- Audit Log → Expandable entries with JSON diffs, metadata, filters
- Same dashboard, usage bar shows current consumption against plan limits
- "Upgrade Plan" CTA visible in sidebar and billing page
All demo accounts use password:
password123
stratapay/
├── apps/
│ ├── api/ # NestJS backend
│ │ ├── src/
│ │ │ ├── auth/ # JWT, RBAC, guards
│ │ │ ├── tenancy/ # Schema router, RLS, context propagation
│ │ │ ├── billing/ # Proration engine, Stripe integration
│ │ │ ├── webhooks/ # Endpoint mgmt, delivery, dead letter queue
│ │ │ └── audit/ # Interceptor-based logging
│ │ └── prisma/ # Schema, migrations, seed
│ └── web/ # Next.js 14 frontend
│ └── src/
│ ├── app/
│ │ ├── (auth)/ # Login, registration
│ │ ├── (dashboard)/ # Tenant-facing pages
│ │ └── (admin)/ # Super admin portal
│ ├── components/ui/ # shadcn/ui primitives
│ └── lib/ # API client, auth hooks, utils
├── packages/
│ └── shared/ # Cross-app types, constants, validation
├── docs/ # Screenshots
├── docker-compose.yml
└── .github/workflows/ci.yml
| Decision | What I chose | Why |
|---|---|---|
| Tenant isolation | search_path at connection level |
Impossible to forget isolation in a new method — it's baked into the connection |
| Proration | Own engine over Stripe's | Stripe's proration is a black box with edge cases around trials and rounding. I needed the UI preview to match the charge exactly |
| Transaction atomicity | Stripe-first, then DB | If payment fails, nothing changes. Reverse (DB-first) risks showing a plan nobody's paying for |
| Context propagation | AsyncLocalStorage |
Tenant context set once in middleware, available everywhere in the call chain without passing it through 15 function signatures |
| Frontend routing | Next.js route groups | (auth), (dashboard), (admin) each get their own layout without URL nesting |
| Role | Tenant | Plan | |
|---|---|---|---|
admin@stratapay.dev |
Super Admin | Platform | — |
admin@acme.com |
Tenant Admin | Acme Corp | Starter ($29/mo) |
dev@acme.com |
Member | Acme Corp | — |
cto@globex.com |
Tenant Admin | Globex Corp | Enterprise ($299/mo, dedicated) |
founder@coolstartup.io |
Tenant Admin | Cool Startup | Free |
- DDL propagation to dedicated schemas is manual — need to hook into Prisma's migration lifecycle or use
pg_cron - Expired
ProcessedStripeEventrows accumulate — need a cleanup cron - No email transport wired up yet — magic link tokens log to console in dev
- Webhook delivery table lacks
tenantIdcolumn — dedicated schemas sidestep this but shared schema needs it - Rate limiting on API key endpoints not implemented yet
MIT




