
Technaut SMS — Coaching & Institute Platform
Multi-Tenant SaaS · EdTech
Runs a water delivery business end to end — a morning refill round becomes one picked list, and every customer still keeps their own price, invoice and balance.
Multi-tenant SaaS for water supply and distribution businesses: orders, deliveries, subscriptions, containers, inventory, invoicing and collections, with one NestJS API serving a Next.js back-office and two Flutter apps.

Aqua Track is a multi-tenant platform for water supply businesses — the kind that run daily refill rounds across households, shops and institutions. Nothing in the product assumes a "20 litre can": products are configurable entities with capacity and unit, sale/rental/returnable/deposit/service behaviour, SKU, barcode, pricing, tax and tenant-defined custom fields.
The design decision that shapes the whole product is bulk ordering. A morning round is picked as a list of customer names, and one request creates a separate order for each — never one merged document. Every customer keeps their own price list, address, delivery, invoice and running balance, and because each order is created independently, one blocked customer does not take the rest of the batch down.
Tenant isolation is enforced in the data layer rather than by convention. A request flows through the auth guard into an AsyncLocalStorage request context, and a Prisma extension injects the tenant filter into every query and refuses to run without a tenant context — so a service that forgets its where clause still cannot read another tenant's rows. An end-to-end suite seeds two tenants and asserts no endpoint can reach across the boundary.
A shared package is the single source of truth for every request and response shape: the API validates with it, the web app infers its types from it, and the Dart models mirror it — with a parity test that fails the build if the database enums and the shared enums ever drift apart.
Water distributors run daily refill rounds across dozens of customers, but the software available either models a single fixed product or forces a round into one merged order. Per-customer pricing, deposits on returnable containers and running balances then have to be reconstructed by hand, and one problem customer can block an entire morning.
Followed a real round end to end and found two assumptions that break every generic tool: the product catalogue is not fixed, and a round is a batch of independent orders that only looks like one document to the person picking it.
Built a multi-tenant NestJS platform where products are fully configurable entities and a bulk order creates one independent order per customer. Tenant isolation is enforced by a Prisma extension in the data layer, and a shared Zod package is the single contract for the API, the Next.js back-office and both Flutter apps.
NestJS 11 + Prisma 7 over MySQL 8, with Zod-validated config and Swagger docs
Request → auth guard → AsyncLocalStorage tenant context → TenantPrismaService, whose Prisma extension injects the tenant filter into every query
Next.js 16 App Router back-office with TanStack Query/Table and a shared design-system package
Flutter admin and delivery apps over a shared aqua_core package — models, API client, auth, theme, Drift cache and sync outbox
pnpm + Turborepo for TypeScript, a Dart pub workspace for mobile; enum-parity test fails the build on schema drift
Cloudflare R2 with presigned uploads for files; Docker Compose and PM2 deployment paths both supported
Application-level tenant scoping relies on every query remembering its where clause — one forgotten filter is a cross-tenant data leak.
Moved the filter below the services: a Prisma client extension injects tenant scoping into every query and refuses to run without a tenant context, and an e2e suite seeds two tenants and asserts no endpoint can cross the boundary. A separate unscoped client exists only for platform tables and is deliberately not injectable into feature modules.
Raising a round as one order would have collapsed per-customer pricing, invoices and balances — but raising them one at a time is unusable at the counter.
Kept the batch in the UI and split it in the API: one bulk endpoint creates a separate, independently-validated order per customer, so a single blocked customer fails alone while the rest of the round goes through.
Three clients in two languages meant request and response shapes drifted, and enum mismatches only surfaced at runtime on a device.
Made a shared Zod package the single source of truth — the API validates with it and the web app infers its types from it — and added a parity spec that fails the build if the database enums and the shared enums diverge.
A distributor runs the whole operation from one system: catalogue, customers, rounds, deliveries, returnable containers, invoices and collections. Because the API is the only contract, the web back-office and both Flutter apps stay in step, and adding a tenant is provisioning rather than a deployment.







