Multi-tenant B2B SaaS that streamlines the distributor order workflow: dealer order placement → admin approval → warehouse pick/pack/dispatch → GST invoice generation, with WhatsApp notifications.
| Layer | Tech |
|---|---|
| Monorepo | pnpm workspaces + Turborepo, TypeScript strict |
| API | NestJS 11, Prisma, PostgreSQL 16, JWT (argon2), zod validation |
| Web | Vite + React 19 SPA, shadcn/ui + Tailwind CSS 4, React Router 7, TanStack Query, react-hook-form + zod |
| Multi-tenancy | Shared schema (tenant_id) + Postgres Row-Level Security + CLS tenant context |
| Tests | Jest / Vitest unit, Supertest + Testcontainers API integration, Playwright e2e |
| Infra | Docker Compose (dev), GitHub Actions CI, AWS ECS + RDS (target) |
Prerequisites: Node ≥ 22, pnpm ≥ 9, Docker.
cp .env.example .env
docker compose up -d # Postgres 16 + Mailpit
pnpm install
pnpm --filter @b2b/api db:generate
pnpm --filter @b2b/api db:migrate && pnpm --filter @b2b/api db:seed # schema + demo data
pnpm dev # api on :3000, web on :5173- Web: http://localhost:5173
- API health: http://localhost:3000/api/health
- Mailpit UI: http://localhost:8025
| Login | Role |
|---|---|
super@platform.test |
Platform super admin |
admin@volt.test / admin@ampere.test |
Tenant admin |
sales@… warehouse@… accounts@… dealer@… |
Staff / dealer per tenant |
flowchart LR
subgraph Client
SPA["React 19 SPA<br/>dealer portal · admin · platform"]
end
subgraph API["NestJS 11 API"]
direction TB
Guards["JWT guard → RolesGuard<br/>→ CLS tenant context"]
Modules["auth · tenants · users · catalog<br/>dealers · pricing · orders · warehouse<br/>invoices · notifications · reports"]
SM["Order state machine"]
Outbox["Notification outbox poller<br/>(every 10s)"]
PDF["Puppeteer PDF renderer"]
Guards --> Modules --> SM
end
PG[("PostgreSQL 16<br/>shared schema + RLS")]
Meta["Meta WhatsApp<br/>Cloud API"]
Store[("File storage<br/>local / S3")]
SPA -- "REST /api (zod-validated)" --> Guards
Modules --> PG
Outbox --> PG
Outbox --> Meta
PDF --> Store
apps/
api/ NestJS API (+ prisma schema, migrations, seed)
web/ React SPA (dealer portal /portal, admin /admin, platform /super)
packages/
shared/ @b2b/shared — zod schemas, enums, GST/money helpers, order state machine
config/ @b2b/config — shared tsconfig / eslint bases
@b2b/shared is the single source of truth that keeps API and UI from drifting:
- Zod schemas validate every request body on the server (global
ZodValidationPipe) and powerreact-hook-formresolvers on the client — identical error rules on both sides for free. The web API client also parses responses against the same schemas, so the contract is runtime-verified, not assumed. - The order state machine transition table (status → allowed next statuses → allowed roles) is consumed by API validation and by the UI to render only the action buttons a user may press. There is no way for them to disagree.
- Pure business helpers — GST split, GSTIN checksum, financial-year (Asia/Kolkata), amount-in-INR-words, Decimal money utilities — with an ESLint guard that forbids Nest/React/Prisma imports in this package.
- Money is never a JS
number. Amounts cross the API as strings, all arithmetic goes throughdecimal.jswith one tested rounding policy (per-line half-up to 2 dp; invoice grand total rounded to the whole rupee with an explicit round-off line).
Every authenticated request flows through the same spine:
JwtAuthGuard(global) validates the access token (15 min, argon2-hashed rotating refresh token in an httpOnly cookie with ajticlaim so rotation is never a no-op).- The guard publishes
{ userId, tenantId, role, dealerId }into CLS (AsyncLocalStorage). A platformSUPER_ADMINmay impersonate a tenant via thex-tenant-idheader. RolesGuardenforces the@Roles(...)decorator per route; dealers are additionally service-filtered to their owndealerId.- Services query through the tenant-scoped Prisma client (below), so tenant isolation is not something an endpoint can forget.
Every tenant-scoped table carries tenant_id. Isolation is enforced twice, so a bug in one layer is caught by the other:
Layer 1 — application. A Prisma client extension intercepts every operation on tenant-scoped models, injects the CLS tenant_id into where/data, and fails closed (throws) when no tenant context exists.
Layer 2 — database. Postgres Row-Level Security with FORCE ROW LEVEL SECURITY:
CREATE POLICY orders_tenant_isolation ON orders
FOR ALL
USING (tenant_id = current_setting('app.tenant_id', TRUE))
WITH CHECK (tenant_id = current_setting('app.tenant_id', TRUE));The critical operational details:
- The API connects as the non-owner
app_userrole — table owners silently bypass RLS, which is the classic false-confidence trap. Migrations run as owner in a separate one-off task. set_config(..., TRUE)is transaction-local, never session-level — connections are pooled and a sessionSETwould leak tenant context across requests. Every scoped operation runs inside a transaction that setsapp.tenant_idfirst (the official Prisma RLS extension pattern); multi-statement flows use an interactivetenantTransaction().- Two narrow bypass settings exist, each RLS-scoped to a purpose:
app.bypass_tenantfor auth flows (login must look up a user by email before any tenant context exists) andapp.outbox_workerfor the notification poller (which legitimately claims due rows across tenants). - CI proves isolation at both layers: API-level tests (tenant A reading tenant B's data → 404) and raw-SQL tests running as
app_userasserting zero cross-tenant rows.
One shared transition table drives everything. OrderStateMachineService.transition() is the single entry point: it takes a row lock, validates the transition and the actor's role against the shared table, applies side effects, and writes the audit history — all in one RLS transaction.
stateDiagram-v2
[*] --> PLACED : dealer places (prices resolved & snapshotted)
PLACED --> APPROVED : sales/admin — credit hold applied
PLACED --> REJECTED : sales/admin (reason recorded)
PLACED --> CANCELLED : dealer
APPROVED --> PICKING : warehouse — pick list auto-created
APPROVED --> CANCELLED : sales/admin — credit hold released
PICKING --> PACKED : warehouse — every line fully picked
PACKED --> DISPATCHED : warehouse — stock deducted + shipment + GST invoice
DISPATCHED --> DELIVERED : warehouse/sales
REJECTED --> [*]
CANCELLED --> [*]
DELIVERED --> [*]
Side effects per transition (same transaction, so they are all-or-nothing):
| Transition | Side effects |
|---|---|
| place | server-side price resolution (dealer override → dealer price list → default list → base), full product snapshot per line, Decimal GST math, atomic per-tenant/year order number |
| → APPROVED | credit check under a row lock: creditUsed + total ≤ creditLimit (422 otherwise), hold applied |
| → PICKING | pick list generated from order lines |
| → PACKED | rejected unless every line is fully picked |
| → DISPATCHED | per-line stock deduction under row locks (422 on shortfall) + stock-ledger entries + shipment (transporter/LR/e-way) + GST invoice generated |
| → CANCELLED (after approval) | credit hold released |
Order/invoice lines are snapshots (SKU, name, HSN, GST rate, unit price copied at placement) — later catalog or price edits can never rewrite history.
Generated inside the dispatch transaction — there is no dispatched order without its invoice, and no invoice number burned on a failed dispatch.
sequenceDiagram
participant W as Warehouse user
participant SM as State machine (1 tx)
participant DB as PostgreSQL
participant PDF as Puppeteer (post-commit)
W->>SM: PACKED → DISPATCHED (+ transporter details)
SM->>DB: lock order · deduct stock · ledger rows · shipment
SM->>DB: INSERT..ON CONFLICT..RETURNING on invoice_sequences (tenant, FY)
Note over SM,DB: consecutive, gap-free numbering —<br/>rollback returns the number implicitly
SM->>DB: invoice + per-line CGST/SGST/IGST split, round-off, words
SM-->>W: DISPATCHED + invoice number
SM--)PDF: post-commit event → render & cache PDF
PDF->>DB: pdfUrl (re-rendered on demand if missing)
- Numbering — GST requires consecutive, unique numbering per financial year. An atomic
INSERT … ON CONFLICT … DO UPDATE … RETURNINGoninvoice_sequences (tenant_id, financial_year)hands out numbers inside the same transaction as the invoice insert. Verified by a 20-way concurrent-dispatch test: 20 unique, consecutive numbers, no gaps. - Tax split — place of supply = shipping-address state. Same state as the seller → CGST + SGST (half rate each); otherwise IGST. Financial-year boundaries computed in
Asia/Kolkataregardless of server timezone. - PDF — dense tax-invoice layout (line items, HSN-wise tax summary, declaration) is an HTML template rendered by Puppeteer. Rendering is post-commit and on-demand — a Chromium hiccup can never roll back a dispatch. The renderer detects a dead cached browser and relaunches.
PdfRenderer/FileStorageare interfaces (local disk in dev, S3 in prod).
Notifications must be exactly as reliable as the state changes that trigger them, so they use the outbox pattern instead of a message queue:
- The outbox row (
notification_logs, statusPENDING) is written in the same transaction as the order change — a rolled-back order can't produce a phantom message; a committed one can't lose it. - A poller (
@nestjs/schedule, every 10s) claims due rows withFOR UPDATE SKIP LOCKED— safe with multiple API instances — and sends through aNotificationProviderinterface:MetaWhatsAppProvider(Cloud API template messages) in production,FakeWhatsAppProviderin dev/CI. - Failures back off quadratically (1m, 4m, 9m, 16m) and land in
FAILEDafter 5 attempts; admins can re-queue from the notification log screen. Dealers without a phone number getSKIPPEDrows (recorded, never retried).
No Redis/BullMQ dependency was needed at MVP volume; the poller service is the only component to swap if that changes.
Meta WABA note: template messages must be pre-approved and business verification takes days — start it before you need it.
WHATSAPP_PROVIDER=fakekeeps dev/CI fully offline.
Tenant · User (role enum, optional dealerId) · Category/Product (HSN, GST rate, cached stockQty) · Dealer (+addresses — shipping state drives the GST split, credit limit/used) · PriceList/PriceListItem/DealerPrice · Order/OrderItem (snapshots)/OrderStatusHistory · PickList/Shipment · Invoice/InvoiceItem/InvoiceSequence · StockLedger (append-only, Product.stockQty is its cached balance) · NotificationLog (outbox). All tenant-scoped tables: composite uniques starting with tenant_id, RLS policies from hand-written SQL migrations.
- helmet, per-IP rate limiting (100 req/10s; 10/min on login), CORS locked to the web origin
- argon2id password hashing; refresh-token rotation with replay rejection
- structured pino logging with authorization/cookie redaction
- zod-validated environment config — the app refuses to boot on a bad config
| Layer | Tooling | What it proves |
|---|---|---|
| Unit (shared) | Vitest | GST split (intra/inter-state, rounding), GSTIN checksum, FY boundary in IST, INR words, state-machine role matrix |
| Unit (api/web) | Jest / Vitest + Testing Library | pricing resolution order, credit checks, route guards, forms |
| Integration | Supertest + Testcontainers (real Postgres, migrations + RLS, non-owner app_user) |
tenant isolation at API and raw-SQL level, auth rotation, full order lifecycle, invoice-numbering concurrency, outbox retry/backoff |
| E2E | Playwright | real-browser journey: dealer login → cart → order → approval → pick/pack/dispatch → invoice PDF download; role-based redirects |
pnpm build | lint | typecheck | test # turbo across all packages
pnpm test:integration # API integration tests (Testcontainers)
pnpm test:e2e # Playwright (needs seeded dev stack)flowchart LR
U((Users)) --> CF["CloudFront + S3<br/>(web static)"]
U --> ALB["ALB"] --> ECS["ECS Fargate<br/>api (Node 24 + Chromium)"]
ECS --> RDS[("RDS PostgreSQL<br/>app_user, RLS enforced")]
ECS --> S3P[("S3 — invoice PDFs")]
ECS --> META["Meta WhatsApp Cloud API"]
MIG["One-off ECS task<br/>prisma migrate deploy (owner)"] --> RDS
SM2["Secrets Manager"] -.-> ECS
apps/api/Dockerfile— Node 24 + Chromium for invoice PDFs; migrations run as a separate one-off task with the owner connection (db:deploy), keeping owner credentials out of the service.apps/web/Dockerfile— static Vite build behind nginx (SPA fallback), or pushdist/to S3+CloudFront..github/workflows/deploy.yml— ready-to-fill ECR/ECS pipeline; CI runs lint/typecheck/unit/build, Testcontainers integration, and Playwright e2e on every push.