Skip to content

MagePsycho/b2b-saas

Repository files navigation

B2B SaaS — Order Management for Electrical Product Distributors

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.

Stack

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)

Getting started

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

Demo logins (password Password123!)

Login Role
super@platform.test Platform super admin
admin@volt.test / admin@ampere.test Tenant admin
sales@… warehouse@… accounts@… dealer@… Staff / dealer per tenant

Architecture

System overview

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
Loading

Monorepo & the shared contract

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 power react-hook-form resolvers 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 through decimal.js with 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).

Request lifecycle

Every authenticated request flows through the same spine:

  1. JwtAuthGuard (global) validates the access token (15 min, argon2-hashed rotating refresh token in an httpOnly cookie with a jti claim so rotation is never a no-op).
  2. The guard publishes { userId, tenantId, role, dealerId } into CLS (AsyncLocalStorage). A platform SUPER_ADMIN may impersonate a tenant via the x-tenant-id header.
  3. RolesGuard enforces the @Roles(...) decorator per route; dealers are additionally service-filtered to their own dealerId.
  4. Services query through the tenant-scoped Prisma client (below), so tenant isolation is not something an endpoint can forget.

Multi-tenancy: two independent enforcement layers

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_user role — 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 session SET would leak tenant context across requests. Every scoped operation runs inside a transaction that sets app.tenant_id first (the official Prisma RLS extension pattern); multi-statement flows use an interactive tenantTransaction().
  • Two narrow bypass settings exist, each RLS-scoped to a purpose: app.bypass_tenant for auth flows (login must look up a user by email before any tenant context exists) and app.outbox_worker for 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_user asserting zero cross-tenant rows.

Order state machine

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 --> [*]
Loading

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.

GST invoicing

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)
Loading
  • Numbering — GST requires consecutive, unique numbering per financial year. An atomic INSERT … ON CONFLICT … DO UPDATE … RETURNING on invoice_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/Kolkata regardless 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/FileStorage are interfaces (local disk in dev, S3 in prod).

WhatsApp notifications: transactional outbox

Notifications must be exactly as reliable as the state changes that trigger them, so they use the outbox pattern instead of a message queue:

  1. The outbox row (notification_logs, status PENDING) 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.
  2. A poller (@nestjs/schedule, every 10s) claims due rows with FOR UPDATE SKIP LOCKED — safe with multiple API instances — and sends through a NotificationProvider interface: MetaWhatsAppProvider (Cloud API template messages) in production, FakeWhatsAppProvider in dev/CI.
  3. Failures back off quadratically (1m, 4m, 9m, 16m) and land in FAILED after 5 attempts; admins can re-queue from the notification log screen. Dealers without a phone number get SKIPPED rows (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=fake keeps dev/CI fully offline.

Data model (core)

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.

Security hardening

  • 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

Testing strategy

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)

Deployment (AWS)

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
Loading
  • 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 push dist/ 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.

About

Multi-tenant B2B SaaS for electrical product distributors — dealer ordering, credit-checked approvals, warehouse pick/pack/dispatch, GST-compliant invoicing (PDF) and WhatsApp notifications. NestJS 11 + Prisma + Postgres RLS · React 19 + Vite + shadcn/ui · pnpm/Turborepo monorepo.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages