Arsi India Info seal
Portfolio Project · v1.0 Master Plan

Multi-Tenant Order Management System — Implementation Plan

A production-shaped mini-SaaS order management platform — customers, products, inventory, orders, payments, shipping, discounts and full audit history — built to prove enterprise application architecture, not just CRUD. NestJS + SQL Server backend, React + TypeScript frontend, deployed on AWS.

Author
R.M. — Arsi India Info
Project
modern-order-management-system
Repository
github.com/arsiindiainfo/modern-order-management-system
Visibility
Public · MIT-licensed code, trademarked brand

1. Executive Summary

This document is the master build plan for Modern Order Management System — a mini-SaaS platform where multiple independent companies (tenants) each manage their own customers, products, inventory, orders and payments behind one shared application. It is designed and written as a portfolio centerpiece: every decision below is chosen to demonstrate enterprise patterns — multi-tenancy, RBAC, optimistic locking, transactional integrity, a stored-procedure data-access layer, audit trails, structured error handling and cloud deployment — inside a project small enough for one developer to finish and defend in an interview.

10
Core Modules
13
Database Tables
35+
API Endpoints
28
Stored Procedures
6
Build Phases

The system is scoped intentionally small in business surface (one order type, one currency path, a handful of statuses) and intentionally deep in architectural surface (tenant isolation, JWT + RBAC, pagination/filter/sort conventions, optimistic locking with version columns, a stored-procedure-only data-access layer, DB transactions across order + inventory + payment, a full audit log, Swagger-documented contracts, unit + integration tests, Docker, and a real AWS deployment path). That trade-off is deliberate — see §2.

Ownership This project, including its source code, documentation and this plan, is authored and published by Arsi India Info. It is released publicly under the MIT License for portfolio and learning purposes; the Arsi India Info name and logo remain protected brand assets. Full details in §29.

2. Why This Is the #1 Portfolio Project

Most portfolio CRUD apps prove you can wire a form to a database. This project is scoped to prove something stronger: that the author understands how a real B2B SaaS product is put together end to end.

Signal a reviewer looks forWhere this project proves it
Can you design multi-tenant data isolation?§5 — every table carries TenantId; every stored procedure enforces it as a mandatory parameter.
Can you design auth & access control, not just log in/out?§7 — JWT + refresh tokens + 4-role RBAC with route-level and field-level enforcement.
Do you understand data integrity under concurrency?§9 — optimistic locking on Orders.version, atomic multi-table writes inside stored procedures.
Can you write production-grade SQL Server, not just ORM calls?§6 — full DDL for all 13 tables and 28 stored procedures covering every read and write path.
Can you design a real API contract, not just endpoints?§12–§19 — a consistent success/error envelope, a versioned error-code catalog, and a full endpoint index.
Do you think about operability?§10, §24, §26 — audit history, Swagger docs, CloudWatch alarms, structured logging.
Do you sweat UI quality, not just make it "work"?§21 — a documented standard for loading/empty/error states, validation and accessibility on every screen.
Can you ship to the cloud, not just localhost?§26 — S3 + CloudFront for the SPA, Lambda + API Gateway for the API, RDS SQL Server, Secrets Manager.

3. Technology Stack

Frontend

  • React 19 + TypeScript (strict mode)
  • Vite build tooling
  • TanStack React Query for server state, caching, retries
  • Material UI (MUI) component library + theming
  • React Hook Form + Zod for form state and schema validation
  • React Router for routing
  • Axios API client with interceptors (auth header, 401 refresh, error normalization)

Backend

  • NestJS 11 (modular, decorator-based, DI-first)
  • SQL Server via TypeORM for connections/migrations/typed result mapping only — every business query executes a stored procedure (§6.3), never the query builder or .find()
  • Passport JWT strategy + refresh-token rotation
  • class-validator / class-transformer DTOs, global ValidationPipe
  • @nestjs/swagger for OpenAPI docs
  • Jest + Supertest for unit and integration tests
  • nestjs-pino structured logging with request/trace IDs

Database

  • SQL Server — primary relational store (local: SQL Server container; cloud: Amazon RDS for SQL Server)
  • Row-level tenant scoping via TenantId on every table, enforced by every stored procedure (§6.3) — never by an ORM-built WHERE clause
  • TypeORM migrations checked into source control — no schema drift, no "sync: true" in any environment above local dev

AWS

  • S3 — SPA static hosting bucket + a private bucket for generated documents (invoices, packing slips)
  • CloudFront — CDN + TLS in front of the S3 SPA bucket, Origin Access Control locked down
  • Lambda + API Gateway (primary) for the NestJS API — no ALB, no always-on compute; ECS Fargate documented as a fallback only if cold starts become a problem
  • RDS for SQL Server — managed database, single-AZ, free-tier-eligible instance class, private subnet, Secrets Manager–issued credentials

4. Repository & Folder Structure

One repository, two deployable apps, one shared documentation root — matching the scaffold already committed at github.com/arsiindiainfo/modern-order-management-system.

modern-order-management-system/
├── backend/                          # NestJS API
│   └── src/
│       ├── main.ts                   # bootstrap, global pipes/filters/interceptors, Swagger mount
│       ├── app.module.ts
│       ├── config/                   # typed config (env validation with Joi/Zod)
│       ├── database/
│       │   ├── migrations/           # TypeORM migrations — tables AND stored procedures (§6)
│       │   ├── procedures/           # .sql files, one per usp_*, deployed by migration
│       │   └── seeds/                # demo tenant + sample catalog seed script
│       ├── common/
│       │   ├── decorators/           # @CurrentUser, @Roles, @TenantId
│       │   ├── filters/              # AllExceptionsFilter → standard error envelope
│       │   ├── interceptors/         # ResponseEnvelopeInterceptor, AuditInterceptor
│       │   ├── guards/                # JwtAuthGuard, RolesGuard, TenantScopeGuard
│       │   ├── pipes/                 # ParsePaginationPipe
│       │   ├── database/              # StoredProcedureRunner — the only class allowed to EXEC (§6.3)
│       │   └── exceptions/            # domain exceptions (InsufficientStockException, etc.)
│       └── modules/
│           ├── auth/                  # login, refresh, logout
│           ├── tenants/               # tenant provisioning (super-admin only)
│           ├── users/                 # tenant users + roles
│           ├── customers/
│           ├── products/
│           ├── inventory/
│           ├── orders/                # orders, order-lines, status transitions
│           ├── payments/
│           ├── shipping/
│           ├── discounts/
│           └── audit/                 # read-only audit trail endpoints      each module/: *.controller.ts · *.service.ts · *.module.ts                    dto/ (request + response DTOs) · *repository.ts (EXEC wrappers) · *.spec.ts
│
├── frontend/                         # React + Vite SPA
│   └── src/
│       ├── app/                       # routes, providers (QueryClient, Theme, AuthProvider)
│       ├── layouts/                   # AppShell (nav + BrandFooter — see §29), AuthLayout
│       ├── features/                  # one folder per business domain, mirrors backend modules
│       │   ├── orders/
│       │   │   ├── api/               # orderService.ts — axios calls only
│       │   │   ├── hooks/             # useOrders, useOrder, useCreateOrder (React Query)
│       │   │   ├── components/        # OrderTable, OrderForm, OrderStatusBadge
│       │   │   ├── pages/             # OrdersListPage, OrderDetailPage
│       │   │   └── types.ts
│       │   ├── customers/ · products/ · inventory/ · payments/ · discounts/ …
│       │   └── auth/
│       ├── components/ui/             # shared design-system wrappers over MUI (DataTable, FormField, ConfirmDialog)
│       ├── lib/                       # apiClient.ts (axios instance + interceptors), queryClient.ts
│       └── types/                     # API contract types generated/mirrored from backend DTOs
│
├── doc/                               # this plan + ADRs + ERD exports
├── infra/                             # AWS CDK or Terraform (see §26)
├── docker-compose.yml
├── NOTICE                             # Arsi India Info attribution notice — see §29
├── LICENSE                            # MIT (code)
└── README.md
Decision
Feature-folder frontend structure, module-folder backend structure — same domain names on both sides.
"orders" on the backend maps 1:1 to "orders" on the frontend. No generic "components/" dumping ground.
Why: a reviewer (or future-me) can find everything about Orders by looking in one place per app, and the API/UI contract stays obviously in sync.

5. Multi-Tenant Isolation

Single database, single schema, shared tables with a mandatory TenantId column — the standard "pooled" multi-tenancy model for a SaaS at this scale (cheapest to operate, easiest to demo, and the pattern most interviewers expect to see explained correctly).

5.1 How a tenant is established

  1. User authenticates via POST /api/auth/login; the issued JWT carries tenantId and role as claims.
  2. A TenantScopeGuard reads tenantId from the verified JWT (never from a client-supplied header or body field — that would let a caller forge cross-tenant access) and attaches it to a request-scoped TenantContext.
  3. A thin repository layer is the only code allowed to call a stored procedure (via StoredProcedureRunner, §6.3). Every stored procedure takes @TenantId as its mandatory first parameter, sourced only from the request-scoped TenantContext — never from client input. There is no code path to a table without going through an SP, and no SP that skips the tenant filter.
  4. SUPER_ADMIN (Arsi India Info operators, not tenant staff) is the only role exempt from the filter, used solely by the tenants module's own stored procedures to provision/manage tenants.
Guardrail Every integration test for a resource module includes a case that logs in as Tenant A and requests a record owned by Tenant B — asserting a 404 (not a 403, to avoid confirming the record's existence). This is a required test, not an optional one — see §23.

6. Database Schema & Stored Procedures

Every table below carries only the fields the UI and API actually need — no speculative columns. Internal/derived fields (password hashes, raw audit diffs) are never serialized into an API response; see §11.4. Every column that appears in a table below is reachable only through a stored procedure — the application never issues an inline query or lets an ORM build one against these tables.

6.1 Table inventory (13 tables)

TableKey fieldsPurpose
Tenantsid, name, slug, planTier, isActiveOne row per customer company using the platform.
Usersid, tenantId, fullName, email, passwordHash, role, isActive, lastLoginAtLogin identity + RBAC role, scoped to a tenant.
RefreshTokensid, userId, tokenHash, expiresAt, revokedAt, createdAtOne row per issued refresh token, backing rotation/revocation (§7.1).
Customersid, tenantId, name, email, phone, billingAddress, shippingAddressThe tenant's own end customers who place orders.
Productsid, tenantId, sku, name, unitPrice, currency, isActiveSellable catalog items.
InventoryItemsid, tenantId, productId, quantityOnHand, quantityReserved, reorderLevelStock level per product, one row per product.
Ordersid, tenantId, orderNumber, customerId, status, subtotal, discountTotal, taxTotal, shippingTotal, grandTotal, version, placedAtThe order header. version drives optimistic locking (§9).
OrderLinesid, orderId, productId, productName, unitPrice, quantity, lineTotalLine items. productName/unitPrice are snapshotted at order time so later catalog changes never rewrite history.
Paymentsid, orderId, provider, amount, currency, status, transactionRef, paidAtOne or more payment attempts/captures against an order.
Shipmentsid, orderId, carrier, trackingNumber, status, shippedAt, deliveredAtFulfillment record for an order.
Discountsid, tenantId, code, type, value, startsAt, endsAt, usageLimit, timesUsed, isActivePercentage/fixed discount codes, tenant-scoped.
OrderStatusHistoryid, orderId, fromStatus, toStatus, changedByUserId, note, changedAtAppend-only order-status timeline — backs GET /api/orders/:id/history.
AuditLogsid, tenantId, entityName, entityId, action, changedByUserId, changedAtCross-entity "who changed what, when" trail (§10).

6.2 Full DDL — all 13 tables

Applied in this dependency order by the first TypeORM migration. Every TenantId is a foreign key back to Tenants except where noted.

-- ═══ Tenants ═══════════════════════════════════════════════════
CREATE TABLE Tenants (
  Id         UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  Name       NVARCHAR(150) NOT NULL,
  Slug       NVARCHAR(80)  NOT NULL,
  PlanTier   NVARCHAR(20)  NOT NULL DEFAULT 'TRIAL'
    CHECK (PlanTier IN ('TRIAL','STANDARD','PRO')),
  IsActive   BIT NOT NULL DEFAULT 1,
  CreatedAt  DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT UX_Tenants_Slug UNIQUE (Slug)
);

-- ═══ Users ═════════════════════════════════════════════════════
CREATE TABLE Users (
  Id            UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId      UNIQUEIDENTIFIER NOT NULL,
  FullName      NVARCHAR(150) NOT NULL,
  Email         NVARCHAR(255) NOT NULL,
  PasswordHash  NVARCHAR(255) NOT NULL,   -- bcrypt; never selected by a list/get SP
  Role          NVARCHAR(20)  NOT NULL
    CHECK (Role IN ('SUPER_ADMIN','TENANT_ADMIN','MANAGER','STAFF')),
  IsActive      BIT NOT NULL DEFAULT 1,
  LastLoginAt   DATETIME2(3) NULL,
  CreatedAt     DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT UX_Users_Tenant_Email UNIQUE (TenantId, Email),
  CONSTRAINT FK_Users_TenantId FOREIGN KEY (TenantId) REFERENCES Tenants(Id)
);

-- ═══ RefreshTokens ═════════════════════════════════════════════
CREATE TABLE RefreshTokens (
  Id          UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  UserId      UNIQUEIDENTIFIER NOT NULL,
  TokenHash   NVARCHAR(255) NOT NULL,
  ExpiresAt   DATETIME2(3) NOT NULL,
  RevokedAt   DATETIME2(3) NULL,
  CreatedAt   DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT FK_RefreshTokens_UserId FOREIGN KEY (UserId) REFERENCES Users(Id)
);
CREATE INDEX IX_RefreshTokens_TokenHash ON RefreshTokens (TokenHash);

-- ═══ Customers ═════════════════════════════════════════════════
CREATE TABLE Customers (
  Id               UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId         UNIQUEIDENTIFIER NOT NULL,
  Name             NVARCHAR(150) NOT NULL,
  Email            NVARCHAR(255) NOT NULL,
  Phone            NVARCHAR(30)  NULL,
  BillingAddress   NVARCHAR(MAX) NULL,  -- JSON: {line1, city, postalCode, country}
  ShippingAddress  NVARCHAR(MAX) NULL,
  IsActive         BIT NOT NULL DEFAULT 1,
  CreatedAt        DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT FK_Customers_TenantId FOREIGN KEY (TenantId) REFERENCES Tenants(Id)
);
CREATE INDEX IX_Customers_Tenant_Email ON Customers (TenantId, Email);

-- ═══ Products ══════════════════════════════════════════════════
CREATE TABLE Products (
  Id         UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId   UNIQUEIDENTIFIER NOT NULL,
  Sku        NVARCHAR(40)  NOT NULL,
  Name       NVARCHAR(200) NOT NULL,
  UnitPrice  DECIMAL(12,2) NOT NULL,
  Currency   CHAR(3) NOT NULL DEFAULT 'USD',
  IsActive   BIT NOT NULL DEFAULT 1,
  CreatedAt  DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT UX_Products_Tenant_Sku UNIQUE (TenantId, Sku),
  CONSTRAINT FK_Products_TenantId FOREIGN KEY (TenantId) REFERENCES Tenants(Id)
);

-- ═══ InventoryItems ════════════════════════════════════════════
CREATE TABLE InventoryItems (
  Id                UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId          UNIQUEIDENTIFIER NOT NULL,
  ProductId         UNIQUEIDENTIFIER NOT NULL,
  QuantityOnHand    INT NOT NULL DEFAULT 0,
  QuantityReserved  INT NOT NULL DEFAULT 0,
  ReorderLevel      INT NOT NULL DEFAULT 0,
  UpdatedAt         DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT UX_InventoryItems_ProductId UNIQUE (ProductId),
  CONSTRAINT FK_InventoryItems_TenantId  FOREIGN KEY (TenantId)  REFERENCES Tenants(Id),
  CONSTRAINT FK_InventoryItems_ProductId FOREIGN KEY (ProductId) REFERENCES Products(Id),
  CONSTRAINT CK_InventoryItems_NonNegative CHECK (QuantityOnHand >= 0 AND QuantityReserved >= 0)
);

-- ═══ Orders ════════════════════════════════════════════════════
CREATE TABLE Orders (
  Id               UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId         UNIQUEIDENTIFIER NOT NULL,
  OrderNumber      NVARCHAR(20)   NOT NULL,   -- 'ORD-2026-000482'
  CustomerId       UNIQUEIDENTIFIER NOT NULL,
  Status           NVARCHAR(20)   NOT NULL DEFAULT 'PENDING'
    CHECK (Status IN ('PENDING','CONFIRMED','PROCESSING',
                     'ON_HOLD','SHIPPED','DELIVERED','CANCELLED')),
  Currency         CHAR(3)        NOT NULL DEFAULT 'USD',
  Subtotal         DECIMAL(12,2)  NOT NULL DEFAULT 0,
  DiscountTotal    DECIMAL(12,2)  NOT NULL DEFAULT 0,
  TaxTotal         DECIMAL(12,2)  NOT NULL DEFAULT 0,
  ShippingTotal    DECIMAL(12,2)  NOT NULL DEFAULT 0,
  GrandTotal       DECIMAL(12,2)  NOT NULL DEFAULT 0,
  Version          INT            NOT NULL DEFAULT 1,   -- optimistic-locking column (§9.1)
  PlacedAt         DATETIME2(3)   NOT NULL DEFAULT SYSUTCDATETIME(),
  CreatedAt        DATETIME2(3)   NOT NULL DEFAULT SYSUTCDATETIME(),
  UpdatedAt        DATETIME2(3)   NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT UX_Orders_TenantId_OrderNumber UNIQUE (TenantId, OrderNumber),
  CONSTRAINT FK_Orders_TenantId   FOREIGN KEY (TenantId)   REFERENCES Tenants(Id),
  CONSTRAINT FK_Orders_CustomerId FOREIGN KEY (CustomerId) REFERENCES Customers(Id)
);
CREATE INDEX IX_Orders_Tenant_Status   ON Orders (TenantId, Status);
CREATE INDEX IX_Orders_Tenant_Customer ON Orders (TenantId, CustomerId);

-- ═══ OrderLines ════════════════════════════════════════════════
CREATE TABLE OrderLines (
  Id           UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  OrderId      UNIQUEIDENTIFIER NOT NULL,
  ProductId    UNIQUEIDENTIFIER NOT NULL,
  ProductName  NVARCHAR(200) NOT NULL,     -- snapshot at order time
  UnitPrice    DECIMAL(12,2) NOT NULL,     -- snapshot at order time
  Quantity     INT NOT NULL CHECK (Quantity > 0),
  LineTotal    DECIMAL(12,2) NOT NULL,
  CONSTRAINT FK_OrderLines_OrderId   FOREIGN KEY (OrderId)   REFERENCES Orders(Id) ON DELETE CASCADE,
  CONSTRAINT FK_OrderLines_ProductId FOREIGN KEY (ProductId) REFERENCES Products(Id)
);
CREATE INDEX IX_OrderLines_OrderId ON OrderLines (OrderId);

-- ═══ Payments ══════════════════════════════════════════════════
CREATE TABLE Payments (
  Id              UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  OrderId         UNIQUEIDENTIFIER NOT NULL,
  Provider        NVARCHAR(30)  NOT NULL,
  Amount          DECIMAL(12,2) NOT NULL,
  Currency        CHAR(3) NOT NULL DEFAULT 'USD',
  Status          NVARCHAR(20)  NOT NULL
    CHECK (Status IN ('CAPTURED','DECLINED','REFUNDED')),
  TransactionRef  NVARCHAR(100) NOT NULL,
  PaidAt          DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT FK_Payments_OrderId FOREIGN KEY (OrderId) REFERENCES Orders(Id)
);
CREATE INDEX IX_Payments_OrderId ON Payments (OrderId);

-- ═══ Shipments ═════════════════════════════════════════════════
CREATE TABLE Shipments (
  Id              UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  OrderId         UNIQUEIDENTIFIER NOT NULL,
  Carrier         NVARCHAR(60)  NOT NULL,
  TrackingNumber  NVARCHAR(80)  NOT NULL,
  Status          NVARCHAR(20)  NOT NULL DEFAULT 'IN_TRANSIT'
    CHECK (Status IN ('IN_TRANSIT','DELIVERED','RETURNED')),
  ShippedAt       DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  DeliveredAt     DATETIME2(3) NULL,
  CONSTRAINT FK_Shipments_OrderId FOREIGN KEY (OrderId) REFERENCES Orders(Id)
);
CREATE INDEX IX_Shipments_OrderId ON Shipments (OrderId);

-- ═══ Discounts ═════════════════════════════════════════════════
CREATE TABLE Discounts (
  Id           UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId     UNIQUEIDENTIFIER NOT NULL,
  Code         NVARCHAR(40) NOT NULL,
  Type         NVARCHAR(10) NOT NULL CHECK (Type IN ('PERCENT','FIXED')),
  Value        DECIMAL(12,2) NOT NULL,
  StartsAt     DATETIME2(3) NOT NULL,
  EndsAt       DATETIME2(3) NOT NULL,
  UsageLimit   INT NULL,
  TimesUsed    INT NOT NULL DEFAULT 0,
  IsActive     BIT NOT NULL DEFAULT 1,
  CONSTRAINT UX_Discounts_Tenant_Code UNIQUE (TenantId, Code),
  CONSTRAINT FK_Discounts_TenantId FOREIGN KEY (TenantId) REFERENCES Tenants(Id)
);

-- ═══ OrderStatusHistory ════════════════════════════════════════
CREATE TABLE OrderStatusHistory (
  Id              UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  OrderId         UNIQUEIDENTIFIER NOT NULL,
  FromStatus      NVARCHAR(20) NULL,
  ToStatus        NVARCHAR(20) NOT NULL,
  ChangedByUserId UNIQUEIDENTIFIER NULL,   -- NULL = system-initiated
  Note            NVARCHAR(500) NULL,
  ChangedAt       DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT FK_OrderStatusHistory_OrderId FOREIGN KEY (OrderId) REFERENCES Orders(Id)
);
CREATE INDEX IX_OrderStatusHistory_OrderId ON OrderStatusHistory (OrderId, ChangedAt);

-- ═══ AuditLogs ═════════════════════════════════════════════════
CREATE TABLE AuditLogs (
  Id              UNIQUEIDENTIFIER NOT NULL DEFAULT NEWID() PRIMARY KEY,
  TenantId        UNIQUEIDENTIFIER NOT NULL,
  EntityName      NVARCHAR(60)  NOT NULL,
  EntityId        UNIQUEIDENTIFIER NOT NULL,
  Action          NVARCHAR(20)  NOT NULL CHECK (Action IN ('CREATE','UPDATE','DELETE')),
  ChangedByUserId UNIQUEIDENTIFIER NOT NULL,
  ChangedAt       DATETIME2(3) NOT NULL DEFAULT SYSUTCDATETIME(),
  CONSTRAINT FK_AuditLogs_TenantId FOREIGN KEY (TenantId) REFERENCES Tenants(Id)
);
CREATE INDEX IX_AuditLogs_Tenant_Entity ON AuditLogs (TenantId, EntityName, EntityId);

6.3 Stored Procedures — the only data-access layer

Decision
Every read and every write goes through a stored procedure. No ORM-generated SQL, no inline query strings, anywhere in the application.
A single StoredProcedureRunner service wraps parameterized EXEC dbo.usp_X @P1, @P2, ... calls; feature repositories (OrdersRepository, CustomersRepository, …) are the only classes allowed to call it.
Why: this is the pattern most enterprise SQL Server shops actually run — SPs get their own execution-plan caching and can be permissioned per-procedure instead of per-table, and because no request value is ever concatenated into SQL text, injection is closed off by construction rather than by discipline.

Naming convention: usp_<Entity>_<Action>. Every tenant-scoped procedure takes @TenantId as parameter 1 (§5.1); every procedure that writes also takes @ActorUserId for the audit trail (§10).

Pattern A — paginated / filtered / sortable list

CREATE OR ALTER PROCEDURE dbo.usp_Customers_List
  @TenantId  UNIQUEIDENTIFIER,
  @Page      INT = 1,
  @PageSize  INT = 20,
  @SortBy    NVARCHAR(30) = 'createdAt',
  @SortDir   NVARCHAR(4)  = 'desc',
  @Search    NVARCHAR(150) = NULL
AS
BEGIN
  SET NOCOUNT ON;
  DECLARE @Offset INT = (@Page - 1) * @PageSize;

  SELECT Id, Name, Email, Phone, IsActive, CreatedAt,
         COUNT(*) OVER() AS TotalItems
  FROM Customers
  WHERE TenantId = @TenantId
    AND (@Search IS NULL OR Name LIKE '%' + @Search + '%' OR Email LIKE '%' + @Search + '%')
  ORDER BY
    CASE WHEN @SortBy = 'name'      AND @SortDir = 'asc'  THEN Name END ASC,
    CASE WHEN @SortBy = 'name'      AND @SortDir = 'desc' THEN Name END DESC,
    CASE WHEN @SortBy = 'createdAt' AND @SortDir = 'desc' THEN CreatedAt END DESC
  OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;
END

Every other list endpoint (usp_Products_List, usp_Orders_List, usp_Discounts_List, usp_Users_List) follows this exact shape — same parameters, same COUNT(*) OVER() trick for total count in one round trip, only the allow-listed sort columns and filter predicate change per entity (§22).

Pattern B — transactional multi-table write (order creation)

CREATE OR ALTER PROCEDURE dbo.usp_Orders_Create
  @TenantId      UNIQUEIDENTIFIER,
  @ActorUserId   UNIQUEIDENTIFIER,
  @CustomerId    UNIQUEIDENTIFIER,
  @DiscountCode  NVARCHAR(40) = NULL,
  @Lines         dbo.OrderLineInput READONLY   -- table-valued param: ProductId, Quantity
AS
BEGIN
  SET NOCOUNT ON; SET XACT_ABORT ON;
  BEGIN TRY
    BEGIN TRANSACTION;

    -- 1. lock and validate stock for every line before writing anything
    IF EXISTS (
      SELECT 1 FROM @Lines l
      JOIN InventoryItems i WITH (UPDLOCK, HOLDLOCK) ON i.ProductId = l.ProductId
      WHERE (i.QuantityOnHand - i.QuantityReserved) < l.Quantity
    )
    BEGIN
      RAISERROR('INSUFFICIENT_STOCK', 16, 1);
    END

    -- 2. reserve stock
    UPDATE i SET i.QuantityReserved = i.QuantityReserved + l.Quantity
    FROM InventoryItems i JOIN @Lines l ON l.ProductId = i.ProductId;

    -- 3. insert order header + lines (totals/discount resolution omitted for brevity)
    DECLARE @OrderId UNIQUEIDENTIFIER = NEWID();
    INSERT INTO Orders (Id, TenantId, OrderNumber, CustomerId, Status)
    VALUES (@OrderId, @TenantId, dbo.ufn_NextOrderNumber(@TenantId), @CustomerId, 'PENDING');

    INSERT INTO OrderLines (Id, OrderId, ProductId, ProductName, UnitPrice, Quantity, LineTotal)
    SELECT NEWID(), @OrderId, p.Id, p.Name, p.UnitPrice, l.Quantity, p.UnitPrice * l.Quantity
    FROM @Lines l JOIN Products p ON p.Id = l.ProductId;

    -- 4. first history row + audit row, same transaction
    INSERT INTO OrderStatusHistory (Id, OrderId, FromStatus, ToStatus, ChangedByUserId)
    VALUES (NEWID(), @OrderId, NULL, 'PENDING', @ActorUserId);

    INSERT INTO AuditLogs (Id, TenantId, EntityName, EntityId, Action, ChangedByUserId)
    VALUES (NEWID(), @TenantId, 'Order', @OrderId, 'CREATE', @ActorUserId);

    COMMIT TRANSACTION;
    SELECT * FROM Orders WHERE Id = @OrderId;
  END TRY
  BEGIN CATCH
    IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION;
    THROW;   -- app layer maps ERROR_MESSAGE() to the §13 error-code catalog
  END CATCH
END

The whole operation is atomic at the database layer itselfSET XACT_ABORT ON plus TRY/CATCH/ROLLBACK means a dropped connection mid-write still leaves the database consistent; NestJS issues one EXEC call per order creation, never a multi-round-trip transaction held open across the network (contrast with §9.2's TypeORM-only alternative, which this project does not use).

Pattern C — optimistic-locked status transition

CREATE OR ALTER PROCEDURE dbo.usp_Orders_UpdateStatus
  @TenantId       UNIQUEIDENTIFIER,
  @ActorUserId    UNIQUEIDENTIFIER,
  @OrderId        UNIQUEIDENTIFIER,
  @ExpectedVersion INT,
  @ToStatus       NVARCHAR(20),
  @Note           NVARCHAR(500) = NULL
AS
BEGIN
  SET NOCOUNT ON; SET XACT_ABORT ON;
  DECLARE @FromStatus NVARCHAR(20);
  SELECT @FromStatus = Status FROM Orders WHERE Id = @OrderId AND TenantId = @TenantId;

  IF @FromStatus IS NULL RAISERROR('RESOURCE_NOT_FOUND', 16, 1);
  -- legality of @FromStatus → @ToStatus checked here against the same
  -- transition table as OrderStateMachine (§8) — kept in sync by a unit test
  IF NOT EXISTS (SELECT 1 FROM dbo.ufn_LegalOrderTransitions() WHERE FromStatus=@FromStatus AND ToStatus=@ToStatus)
    RAISERROR('INVALID_STATE_TRANSITION', 16, 1);

  BEGIN TRANSACTION;
    UPDATE Orders
    SET Status = @ToStatus, Version = Version + 1, UpdatedAt = SYSUTCDATETIME()
    WHERE Id = @OrderId AND TenantId = @TenantId AND Version = @ExpectedVersion;

    IF @@ROWCOUNT = 0
    BEGIN
      ROLLBACK TRANSACTION;
      RAISERROR('ORDER_VERSION_CONFLICT', 16, 1);  -- app layer → HTTP 409
    END

    INSERT INTO OrderStatusHistory (Id, OrderId, FromStatus, ToStatus, ChangedByUserId, Note)
    VALUES (NEWID(), @OrderId, @FromStatus, @ToStatus, @ActorUserId, @Note);
  COMMIT TRANSACTION;

  SELECT * FROM Orders WHERE Id = @OrderId;
END

Used by hold (@ToStatus='ON_HOLD'), resume, cancel, ship-triggered PROCESSING→SHIPPED, and delivery confirmation — one procedure, five API actions.

Everything else follows one of these three shapes

Simple get-by-id, create, update and deactivate procedures (usp_Products_GetById, usp_Customers_Create, …) are straightforward parameterized single-statement SPs scoped by @TenantId; they aren't reproduced here in full — the complete list and what each backs is in §6.4.

6.4 Stored procedure index (28 procedures → 35+ endpoints)

Stored procedureBacksNotes
usp_Auth_GetUserByEmailPOST /auth/loginReturns PasswordHash to the auth service only — no other caller, no other SP, ever selects this column.
usp_RefreshTokens_Createlogin, refreshInserts the rotated token's hash.
usp_RefreshTokens_RotatePOST /auth/refreshValidates + revokes the old token and inserts the new one atomically; reused-token detection revokes the whole session.
usp_RefreshTokens_RevokePOST /auth/logout 
usp_Tenants_List / _Create/tenantsSUPER_ADMIN only; the two procedures with no @TenantId filter by design.
usp_Users_List / _Create / _Update / _Deactivate/usersPattern A + simple write pattern.
usp_Customers_List / _GetById / _Create / _Update / _Deactivate/customersPattern A shown in full above.
usp_Customers_GetOrdersGET /customers/:id/ordersJoin to Orders, paginated like Pattern A.
usp_Products_List / _GetById / _Create / _Update / _Deactivate/products 
usp_Products_GetInventoryGET /products/:id/inventoryComputes QuantityAvailable = OnHand − Reserved in the SELECT, never stored.
usp_Inventory_AdjustPUT /products/:id/inventoryRequires @Reason; writes an AuditLogs row in the same call.
usp_Orders_List / _GetById/ordersPattern A; _GetById also returns the joined OrderLines as a second result set.
usp_Orders_CreatePOST /ordersPattern B, shown in full above.
usp_Orders_UpdatePUT /orders/:idOptimistic-locked like Pattern C, but for non-status fields (e.g. shipping address).
usp_Orders_UpdateStatushold, resume, cancel, ship-status, deliverPattern C, shown in full above — one SP, five endpoints via @ToStatus.
usp_Orders_RecordPaymentPOST /orders/:id/paymentTransactional like Pattern B: insert Payments row + conditionally advance Orders.Status + history row.
usp_Orders_RecordShipmentPOST /orders/:id/shipInsert Shipments row + release reserved stock + usp_Orders_UpdateStatus-equivalent transition, one transaction.
usp_Orders_GetHistoryGET /orders/:id/historySimple ordered SELECT against OrderStatusHistory.
usp_Discounts_List / _Create/discounts 
usp_Discounts_ValidatePOST /discounts/validateChecks IsActive, date window and TimesUsed < UsageLimit; returns the computed discount amount, writes nothing.
usp_Audit_ListGET /auditReturns only the minimal field set from §10 — never the (nonexistent, by design) before/after diff.

7. Authentication & RBAC

7.1 Auth flow

  1. POST /api/auth/login calls usp_Auth_GetUserByEmail, verifies the password (bcrypt, application-side) → issues a short-lived access token (15 min, JWT) and a long-lived refresh token (7 days, stored as a hash via usp_RefreshTokens_Create, rotated on use).
  2. Access token claims: sub (userId), tenantId, role, email, iat, exp.
  3. POST /api/auth/refresh calls usp_RefreshTokens_Rotate; reuse of an already-rotated refresh token revokes the whole session (replay-attack guard).
  4. POST /api/auth/logout calls usp_RefreshTokens_Revoke.

7.2 Roles

RoleScopeCan do
SUPER_ADMINCross-tenantProvision/suspend tenants. No access to tenant business data (customers/orders) by default — kept separate from operational data on purpose.
TENANT_ADMINOwn tenantEverything within the tenant: manage users, products, discounts, view all orders, void/cancel orders.
MANAGEROwn tenantManage products/inventory/customers, create/update/hold orders, record payments and shipments. Cannot manage users or delete a discount.
STAFFOwn tenantCreate orders, view customers/products/orders. Cannot edit catalog, cannot cancel or delete anything.

7.3 Enforcement

@Roles(Role.TENANT_ADMIN, Role.MANAGER)
@UseGuards(JwtAuthGuard, RolesGuard, TenantScopeGuard)
@Post(':id/hold')
holdOrder(@Param('id') id: string, @CurrentUser() user: AuthUser) {
  return this.ordersService.hold(id, user);
}

RolesGuard reads the @Roles() metadata and the JWT's role claim — a 403 FORBIDDEN_ROLE is returned before the handler runs if the role doesn't match. TenantScopeGuard (see §5) runs alongside it so a request must pass both checks.

8. Order Status Workflow

┌──────────┐ ┌───────────┐ ┌────────────┐ ┌──────────┐ ┌───────────┐ │ PENDING │─────▶│ CONFIRMED │─────▶│ PROCESSING │─────▶│ SHIPPED │─────▶│ DELIVERED │ └──────────┘ └───────────┘ └────────────┘ └──────────┘ └───────────┘ │ │ ▲ │ ▼ │ resume │ ┌────────────┐ └─────────────────────────────▶│ ON_HOLD │ (any pre-shipment state) └────────────┘ │ ▼ ┌─────────────┐ │ CANCELLED │ (terminal — allowed from PENDING/CONFIRMED/PROCESSING/ON_HOLD only) └─────────────┘

Legal transitions are defined once, in the SQL function dbo.ufn_LegalOrderTransitions() that usp_Orders_UpdateStatus checks against (§6.3) — and mirrored in a OrderStateMachine TypeScript constant used only for frontend button visibility (e.g. hiding "Cancel" once an order is SHIPPED). The database, not the application, is the source of truth for legality, since the database is the only place every write is guaranteed to pass through. An illegal transition throws INVALID_STATE_TRANSITION → HTTP 422 (§13). Every transition writes one row to OrderStatusHistory inside the same stored-procedure call as the status update — the history can never fall out of sync with the current status.

9. Optimistic Locking & Transaction Handling

9.1 Optimistic locking

Orders.Version increments on every status/field update. The client must send the version it last read (returned in every order response) as @ExpectedVersion. As shown in §6.3 Pattern C, the owning stored procedure issues UPDATE ... WHERE Id=@Id AND Version=@ExpectedVersion and checks @@ROWCOUNT itself — if zero rows updated, it raises ORDER_VERSION_CONFLICT, which the data-access layer maps to HTTP 409 (§13). The client re-fetches and retries instead of silently clobbering a concurrent edit (e.g. two staff members updating the same order at once).

9.2 Transaction boundaries

OperationWhat's inside one stored-procedure transaction
Create orderusp_Orders_Create — insert Orders row + all OrderLines + reserve stock (rejecting if insufficient, under row locks) + insert first OrderStatusHistory row + audit row.
Record paymentusp_Orders_RecordPayment — insert Payments row + recompute/verify order totals + advance Orders.status if fully paid + history row.
Ship orderusp_Orders_RecordShipment — insert Shipments row + release QuantityReserved / decrement QuantityOnHand + status transition + history row.
Cancel orderusp_Orders_UpdateStatus — release any reserved stock + status transition + history row. Refund handling, if any, is a separate explicit usp_Orders_RecordPayment reversal call, never implicit.

Each of these is one EXEC call wrapping its own BEGIN TRAN … COMMIT / ROLLBACK in T-SQL (§6.3) — the transaction lives and dies inside the database engine itself. NestJS never opens a transaction over the network and holds it across multiple round trips; a dropped connection mid-operation simply never commits, and the database is never left half-written.

10. Audit History

Two distinct, intentionally separate trails:

11. API Design Conventions

11.1 Base path & versioning

All endpoints are prefixed /api. The contract is versioned via an Accept header convention (Accept: application/vnd.oms.v1+json) reserved for future breaking changes — v1 is implicit and default for this build.

11.2 Auth header

Every authenticated request sends Authorization: Bearer <accessToken>. The tenant is never passed by the client — it is derived from the token (§5).

11.3 Naming & verbs

11.4 Field minimalism (demo-portfolio rule)

Rule Every response DTO is an explicit allow-list, not the raw entity or raw SP result set. Fields that exist purely for internal bookkeeping (password hashes, soft-delete flags, raw audit diff JSON, internal foreign keys the UI never renders) are never serialized — enforced with class-transformer @Exclude() plus dedicated Response DTO classes per endpoint, applied via a global ClassSerializerInterceptor. The stored procedures reinforce this at the source: usp_*_List and usp_*_GetById procedures explicitly name their SELECT columns — none of them ever runs SELECT * against a table containing a sensitive column.

The same discipline applies to requests: the global ValidationPipe is configured with whitelist: true, forbidNonWhitelisted: true, so a request body containing a field the DTO doesn't declare is rejected with 400 VALIDATION_FAILED rather than silently ignored or silently persisted.

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,             // strip unknown fields
  forbidNonWhitelisted: true,  // ...or reject the request outright
  transform: true,
  errorHttpStatusCode: 400,
}));

12. Standard Response & Error Envelope

Every response — success or failure — follows one of two shapes, applied globally by a ResponseEnvelopeInterceptor and an AllExceptionsFilter, so frontend code never branches on response shape per-endpoint.

12.1 Success — single resource

HTTP/1.1 200 OK

{
  "success": true,
  "data": {
    "id": "6f1a2e10-9c3d-4b7a-8e2f-1a2b3c4d5e6f",
    "orderNumber": "ORD-2026-000482",
    "status": "CONFIRMED",
    "grandTotal": 249.99,
    "currency": "USD",
    "version": 2
  }
}

12.2 Success — paginated list

HTTP/1.1 200 OK

{
  "success": true,
  "data": [ /* array of resource objects */ ],
  "meta": {
    "page": 1,
    "pageSize": 20,
    "totalItems": 134,
    "totalPages": 7
  }
}

12.3 Error

HTTP/1.1 409 Conflict

{
  "success": false,
  "error": {
    "statusCode": 409,
    "code": "ORDER_VERSION_CONFLICT",
    "message": "This order was modified by someone else. Reload and try again.",
    "traceId": "a1c9f3e2-88b1-4e77-9c2a-df0512aab933"
  }
}

code is a stable machine-readable string the frontend can switch on (e.g. to highlight a specific field); message is the human-readable string shown in a toast; traceId matches the structured server log line, so a bug report ("traceId a1c9f3e2…") is instantly greppable in CloudWatch. Validation errors additionally include a fields array:

{
  "success": false,
  "error": {
    "statusCode": 400,
    "code": "VALIDATION_FAILED",
    "message": "One or more fields are invalid.",
    "traceId": "...",
    "fields": [
      { "field": "email", "message": "email must be a valid email address" },
      { "field": "quantity", "message": "quantity must be a positive integer" }
    ]
  }
}

13. Error Code Catalog

HTTPCodeMeaning
400VALIDATION_FAILEDRequest body/query failed DTO validation, or contained unknown fields.
401UNAUTHENTICATEDMissing, expired or invalid access token.
401INVALID_CREDENTIALSLogin email/password did not match.
403FORBIDDEN_ROLEAuthenticated, but the role lacks permission for this action.
402PAYMENT_FAILEDPayment provider declined or errored on capture.
404RESOURCE_NOT_FOUNDNo record with that id in the caller's tenant (also returned for cross-tenant access — §5), raised by the owning SP.
409DUPLICATE_ENTRYA unique constraint (§6.2) was violated — e.g. duplicate SKU or discount code within a tenant.
409ORDER_VERSION_CONFLICTOptimistic-lock version mismatch, raised inside usp_Orders_UpdateStatus/_Update (§9.1).
409INSUFFICIENT_STOCKRequested quantity exceeds available (on-hand minus reserved) inventory, raised inside usp_Orders_Create.
422INVALID_STATE_TRANSITIONRequested order-status transition is not allowed from the current state (§8), raised inside usp_Orders_UpdateStatus.
422DISCOUNT_NOT_APPLICABLEDiscount code is expired, exhausted, or inactive.
429RATE_LIMITEDToo many requests from this client in the current window.
500INTERNAL_ERRORUnhandled server error. Message is a generic string in production; details go to structured logs keyed by traceId only.

The AllExceptionsFilter maps a SQL Server RAISERROR message text (e.g. 'ORDER_VERSION_CONFLICT', thrown as shown in §6.3) directly to its matching row in this table — the stored procedure and the API contract share one vocabulary of error codes, never two.

14. API Reference — Auth

POST/api/auth/login

Public. Exchanges credentials for a token pair.

// Request
{ "email": "manager@acme-demo.com", "password": "••••••••" }

// 200 Response
{
  "success": true,
  "data": {
    "accessToken": "eyJhbGciOi...",
    "refreshToken": "8f3c1a...".,
    "expiresIn": 900,
    "user": { "id": "...", "fullName": "Priya Shah", "role": "MANAGER", "tenantId": "..." }
  }
}

// 401 Response — wrong credentials
{ "success": false, "error": { "statusCode": 401, "code": "INVALID_CREDENTIALS",
    "message": "Email or password is incorrect.", "traceId": "..." } }

15. API Reference — Customers

GET/api/customers/:id/orders

Roles: all authenticated. Backed by usp_Customers_GetOrders (§6.4). Returns the order history for one customer — the exact shape requested in the brief.

// 200 Response
{
  "success": true,
  "data": [
    { "id": "...", "orderNumber": "ORD-2026-000482", "status": "SHIPPED",
      "grandTotal": 249.99, "currency": "USD", "placedAt": "2026-08-01T09:12:00Z" }
  ],
  "meta": { "page": 1, "pageSize": 20, "totalItems": 3, "totalPages": 1 }
}

// 404 Response — unknown or cross-tenant customer id
{ "success": false, "error": { "statusCode": 404, "code": "RESOURCE_NOT_FOUND",
    "message": "Customer not found.", "traceId": "..." } }

16. API Reference — Products & Inventory

GET/api/products/:id/inventory

Roles: all authenticated. Backed by usp_Products_GetInventory (§6.4). Current stock position for one product.

// 200 Response
{
  "success": true,
  "data": {
    "productId": "...", "sku": "MUG-BLK-11OZ",
    "quantityOnHand": 480, "quantityReserved": 36,
    "quantityAvailable": 444, "reorderLevel": 50
  }
}

quantityAvailable is computed inside the stored procedure's SELECT (onHand − reserved) — never stored, so it can never drift out of sync with its inputs.

17. API Reference — Orders, Payments & Shipping

GET/api/orders

Roles: all authenticated. Paginated, filterable, sortable (§22), backed by usp_Orders_List.

// GET /api/orders?status=PENDING&sort=placedAt:desc&page=1&pageSize=20

// 200 Response
{
  "success": true,
  "data": [
    { "id": "...", "orderNumber": "ORD-2026-000512", "customerName": "Blue Sky Retail",
      "status": "PENDING", "grandTotal": 89.50, "currency": "USD", "placedAt": "2026-08-22T14:03:00Z" }
  ],
  "meta": { "page": 1, "pageSize": 20, "totalItems": 58, "totalPages": 3 }
}

POST/api/orders

Roles: TENANT_ADMIN MANAGER STAFF. Backed by usp_Orders_Create (§6.3 Pattern B).

// Request
{
  "customerId": "c9d1e2f3-...",
  "discountCode": "WELCOME10",
  "lines": [
    { "productId": "p1a2b3c4-...", "quantity": 2 },
    { "productId": "p5d6e7f8-...", "quantity": 1 }
  ]
}

// 201 Response
{
  "success": true,
  "data": {
    "id": "6f1a2e10-...", "orderNumber": "ORD-2026-000513", "status": "PENDING",
    "subtotal": 99.00, "discountTotal": 9.90, "taxTotal": 7.13,
    "shippingTotal": 5.00, "grandTotal": 101.23, "version": 1
  }
}

// 409 Response — a line requests more than is available
{ "success": false, "error": { "statusCode": 409, "code": "INSUFFICIENT_STOCK",
    "message": "Only 1 unit of MUG-BLK-11OZ is available.", "traceId": "..." } }

PUT/api/orders/:id

Updates order-level fields (e.g. shipping address) via usp_Orders_Update. Requires the caller's last-known version — see §9.1.

// Request
{ "version": 1, "shippingAddress": { "line1": "221B Baker Street", "city": "London", "postalCode": "NW1 6XE", "country": "GB" } }

// 200 Response — version increments
{ "success": true, "data": { "id": "...", "version": 2, "...": "..." } }

POST/api/orders/:id/hold

Roles: TENANT_ADMIN MANAGER. Moves the order to ON_HOLD via usp_Orders_UpdateStatus (§6.3 Pattern C); requires a reason.

// Request
{ "reason": "Awaiting customer confirmation on substitute item" }

// 200 Response
{ "success": true, "data": { "id": "...", "status": "ON_HOLD", "version": 3 } }

// 422 Response — order already shipped
{ "success": false, "error": { "statusCode": 422, "code": "INVALID_STATE_TRANSITION",
    "message": "Cannot place a SHIPPED order on hold.", "traceId": "..." } }

POST/api/orders/:id/payment

Records a payment capture via usp_Orders_RecordPayment; advances status when fully paid.

// Request
{ "provider": "STRIPE", "amount": 101.23, "currency": "USD", "transactionRef": "pi_3P9x..." }

// 201 Response
{
  "success": true,
  "data": { "paymentId": "...", "status": "CAPTURED", "order": { "id": "...", "status": "CONFIRMED", "version": 4 } }
}

// 402 Response — provider declined
{ "success": false, "error": { "statusCode": 402, "code": "PAYMENT_FAILED",
    "message": "Payment provider declined the transaction.", "traceId": "..." } }

GET/api/orders/:id/history

Backed by usp_Orders_GetHistory.

// 200 Response
{
  "success": true,
  "data": [
    { "fromStatus": null,        "toStatus": "PENDING",   "changedBy": "Priya Shah", "changedAt": "2026-08-22T14:03:00Z" },
    { "fromStatus": "PENDING",   "toStatus": "CONFIRMED", "changedBy": "System",     "changedAt": "2026-08-22T14:05:12Z", "note": "Payment captured" },
    { "fromStatus": "CONFIRMED", "toStatus": "ON_HOLD",   "changedBy": "Priya Shah", "changedAt": "2026-08-23T09:00:00Z", "note": "Awaiting customer confirmation on substitute item" }
  ]
}

18. API Reference — Discounts

POST/api/discounts/validate

Used by the order form to preview a discount before submitting the order. Backed by usp_Discounts_Validate, which writes nothing.

// Request
{ "code": "WELCOME10", "subtotal": 99.00 }

// 200 Response
{ "success": true, "data": { "code": "WELCOME10", "type": "PERCENT", "value": 10, "discountAmount": 9.90 } }

// 422 Response — expired
{ "success": false, "error": { "statusCode": 422, "code": "DISCOUNT_NOT_APPLICABLE",
    "message": "Discount code WELCOME10 has expired.", "traceId": "..." } }

19. Full Endpoint Index

MethodPathRolesPurpose
POST/api/auth/loginPublicAuthenticate, issue token pair
POST/api/auth/refreshRefresh tokenRotate access/refresh tokens
POST/api/auth/logoutAuthenticatedRevoke current refresh token
GET/api/tenantsSUPER_ADMINList tenants
POST/api/tenantsSUPER_ADMINProvision a new tenant
GET/api/usersTENANT_ADMINList users in the tenant
POST/api/usersTENANT_ADMINInvite/create a user
PUT/api/users/:idTENANT_ADMINUpdate role/status
DELETE/api/users/:idTENANT_ADMINDeactivate a user (soft-delete)
GET/api/customersAllList customers (paginated/filterable)
POST/api/customersAll except STAFFCreate customer
GET/api/customers/:idAllGet one customer
PUT/api/customers/:idAll except STAFFUpdate customer
DELETE/api/customers/:idTENANT_ADMINDeactivate customer
GET/api/customers/:id/ordersAllOrder history for a customer
GET/api/productsAllList products
POST/api/productsAll except STAFFCreate product
GET/api/products/:idAllGet one product
PUT/api/products/:idAll except STAFFUpdate product
DELETE/api/products/:idTENANT_ADMINDeactivate product
GET/api/products/:id/inventoryAllCurrent stock position
PUT/api/products/:id/inventoryAll except STAFFManual stock adjustment (+ reason)
GET/api/ordersAllList orders (paginated/filterable/sortable)
POST/api/ordersAllCreate order (transactional)
GET/api/orders/:idAllGet one order incl. lines
PUT/api/orders/:idAll except STAFFUpdate order (optimistic-locked)
POST/api/orders/:id/holdTENANT_ADMIN, MANAGERPlace order on hold
POST/api/orders/:id/resumeTENANT_ADMIN, MANAGERResume a held order
POST/api/orders/:id/cancelTENANT_ADMIN, MANAGERCancel order, release stock
POST/api/orders/:id/paymentAll except STAFFRecord a payment capture
POST/api/orders/:id/shipAll except STAFFRecord a shipment
GET/api/orders/:id/historyAllStatus-change timeline
GET/api/discountsAll except STAFFList discount codes
POST/api/discountsTENANT_ADMINCreate discount code
POST/api/discounts/validateAllValidate/preview a code against a subtotal
GET/api/auditTENANT_ADMINCross-entity audit trail (minimal fields)

20. Frontend Architecture

Feature-folder structure (§4), one features/<domain> per backend module. Each feature owns its own API calls, hooks, components, pages and types — nothing reaches across features except through the shared components/ui design-system layer and typed API responses.

20.1 Data flow

Page component
  └─ useOrders() / useOrder(id)         // React Query hook
       └─ ordersService.list() / .get()  // axios call, returns typed Order[]
            └─ apiClient (axios instance)
                 ├─ request interceptor: attach Authorization header
                 └─ response interceptor: on 401 → silent refresh → retry once
                                           on error → normalize to AppApiError { code, message, fields }

20.2 State ownership rule

Decision
React Query owns all server data. Local useState only for UI-only state (open/closed, selected tab, draft form values before submit).
No Redux, no global client-side data store. Cache invalidation on mutation (queryClient.invalidateQueries(['orders'])) keeps lists fresh after create/update.
Why: this is the pattern actually used in production React codebases today, and it removes an entire category of "stale local copy vs. server truth" bugs a demo app would otherwise have to fake.

21. UI & Validation Standards

Applied uniformly to every screen — list pages, detail pages, and every form — via the shared components/ui layer, so quality doesn't depend on remembering to add it per page.

ConcernStandard
Loading stateSkeleton rows/cards (MUI Skeleton), never a bare spinner-only screen for list/detail views.
Empty stateDedicated illustration + message + primary action ("No orders yet — Create your first order"), never a blank table.
Error stateInline error card with the envelope's message and a Retry button for queries; a toast (not a blocking alert) for mutations.
Form validationZod schema shared 1:1 with the backend DTO shape, wired through React Hook Form; inline field errors on blur, submit disabled until valid, server-side fields[] errors (§12.3) mapped back onto the exact form field.
Destructive actionsConfirmation dialog naming the record ("Cancel order ORD-2026-000513?") — no silent destructive click.
Optimistic updatesStatus-change buttons (hold/resume/cancel) optimistically update the UI via React Query's onMutate, rolled back automatically on error.
AccessibilityEvery form field has a bound <label>; focus moves to the first invalid field on failed submit; color is never the only signal for order status (icon + text + color).
ResponsivenessTable→card collapse below 768px via the shared DataTable component — implemented once, inherited everywhere.

22. Pagination, Filtering & Sorting

One shared query-parsing pipe (ParsePaginationPipe) applied to every list endpoint, whose output maps 1:1 onto the @Page/@PageSize/@SortBy/@SortDir parameters every list stored procedure accepts (§6.3 Pattern A) — the contract never varies module to module.

GET /api/orders?page=2&pageSize=20&sort=placedAt:desc&status=PENDING&placedAt[gte]=2026-08-01

23. Testing Strategy

Unit tests

  • Every service method — mocked repositories, no DB.
  • The order state machine (§8) — every legal and illegal transition asserted explicitly, checked for parity against dbo.ufn_LegalOrderTransitions().
  • Discount calculation, tax/total computation — pure-function coverage.

Integration tests

  • Supertest against a real test database (Dockerized SQL Server in CI) with all 28 stored procedures deployed by migration.
  • Full order lifecycle: create → pay → ship → history reflects every step.
  • Cross-tenant isolation (§5) — required for every resource module.
  • RBAC — one 403 test per role/endpoint combination that should be denied.
  • Optimistic-locking conflict — two concurrent updates, assert the second gets 409 from usp_Orders_UpdateStatus.
  • SQL injection probes against every text-input endpoint — expected to fail harmlessly precisely because parameters are bound to SP parameters, never concatenated.

Target: ≥80% line coverage on modules/**/*.service.ts, enforced in CI via Jest's coverageThreshold. Frontend: component tests (Testing Library) for the form-validation and empty/error/loading states in §21, run in CI alongside backend tests.

24. Swagger / OpenAPI

@nestjs/swagger mounted at /api/docs (disabled in production behind an env flag, enabled in dev/staging). Every DTO carries @ApiProperty() with an example value, so the generated docs match the exact payloads shown in §14–§18 — the Swagger schema and this plan are generated from the same DTO classes, never hand-duplicated.

export class CreateOrderDto {
  @ApiProperty({ example: 'c9d1e2f3-...' })
  @IsUUID()
  customerId: string;

  @ApiProperty({ required: false, example: 'WELCOME10' })
  @IsOptional() @IsString()
  discountCode?: string;

  @ApiProperty({ type: () => CreateOrderLineDto, isArray: true })
  @ValidateNested({ each: true }) @Type(() => CreateOrderLineDto)
  @ArrayMinSize(1)
  lines: CreateOrderLineDto[];
}

25. Docker & Local Development

# docker-compose.yml — services
sqlserver:   # mcr.microsoft.com/mssql/server, dev credentials; migration step deploys tables + all usp_* procedures on first boot
backend:     # NestJS API, hot-reload volume mount in dev
frontend:    # Vite dev server
localstack:  # S3 emulation for local document-upload testing

Multi-stage Dockerfile per app for production images (build stage → slim runtime stage). docker-compose up is the entire "how do I run this" story in the README — no undocumented setup steps.

26. AWS Deployment Architecture

┌──────────────┐ │ Route 53 │ └──────┬───────┘ ▼ ┌────────────────────┐ ┌─────────────────────────┐ │ CloudFront │◀──────▶│ S3 — SPA bucket │ │ (TLS, OAC-locked) │ │ (private, static site) │ └──────────┬──────────┘ └─────────────────────────┘ │ /api/* ▼ ┌────────────────────┐ │ API Gateway │ └──────────┬──────────┘ ▼ ┌─────────────────────────────┐ │ Lambda (NestJS, per-route) │ (no ALB — ECS Fargate documented as a fallback only) │ private subnet │ └───────────────┬─────────────┘ │ ┌──────────────────┼───────────────────┐ ▼ ▼ ┌─────────────────────┐ ┌───────────────────────────┐ │ RDS — SQL Server │ │ S3 — documents bucket │ │ private subnet, single-AZ │ │ private, presigned-URL access │ │ tables + 28 usp_* procs │ └───────────────────────────┘ └─────────────────────┘ │ ▼ ┌─────────────────────┐ ┌───────────────────────────┐ │ Secrets Manager │ │ CloudWatch Logs + Alarms │ │ DB creds, JWT secret │ │ error rate, p99 latency │ └─────────────────────┘ └───────────────────────────┘
ServiceRole in this project
S3 (SPA bucket)Hosts the built React app; private, served only through CloudFront (Origin Access Control).
CloudFrontCDN + TLS termination for the SPA, and can also front the API path for a single custom domain.
S3 (documents bucket)Generated invoices/packing slips; private, accessed only via short-lived presigned URLs.
Lambda + API GatewayRuns the NestJS API, wrapped with @vendia/serverless-express; no ALB, no idle compute cost for a low-traffic portfolio demo. ECS Fargate is documented as a fallback only if cold starts become a real problem.
RDS for SQL ServerManaged database in a private subnet, single-AZ, free-tier-eligible instance class, hosting both the 13 tables and all 28 stored procedures; credentials issued via Secrets Manager, never in env files.
Secrets ManagerDB credentials, JWT signing secret, third-party API keys.
CloudWatchStructured logs (with traceId, §12.3), alarms on 5xx rate and p99 latency.

27. Build Phases & Roadmap

Phase 0
Foundation
~1 week
  • Repo scaffold (already committed), CI pipeline, lint/format gates
  • Tenant/User/RefreshToken schema + migrations, incl. the first stored procedures (auth)
  • Global error envelope, ValidationPipe, Swagger skeleton, StoredProcedureRunner
Phase 1
Auth & RBAC
~1 week
  • Login/refresh/logout backed by usp_Auth_*/usp_RefreshTokens_*
  • Roles guard + tenant-scope guard
  • Frontend AuthProvider + protected routes
Phase 2
Catalog
~1 week
  • Customers, Products, Inventory tables + their stored procedures — full CRUD
  • Pagination/filter/sort pipe mapped to Pattern A (§22)
  • Frontend list + form pages with §21 standards
Phase 3
Orders Core
~2 weeks
  • usp_Orders_Create, _UpdateStatus, _GetHistory + status state machine
  • Transactional create, optimistic locking, all inside the SPs (§6.3)
  • Order detail UI with status timeline
Phase 4
Payments, Shipping, Discounts
~1.5 weeks
  • usp_Orders_RecordPayment / _RecordShipment + discount SPs
  • Audit-writing built into every mutating stored procedure
  • usp_Audit_List + admin UI
Phase 5
Hardening & Tests
~1.5 weeks
  • Unit + integration test suites to ≥80% coverage
  • Cross-tenant + RBAC + locking-conflict + injection-probe test matrix (§23)
  • Docker Compose polish, seed data script
Phase 6
AWS Deployment
~1 week
  • Infra as code (CDK/Terraform) for S3/CloudFront/ECS/RDS
  • CI/CD deploy pipeline that runs table + stored-procedure migrations, staging + prod environments
  • CloudWatch alarms, README deployment walkthrough

28. Risks & Mitigations

RiskImpactMitigation
Scope creep — turning a portfolio demo into an unshippable full ERPHighField list, table list and endpoint list in this plan (§6, §19) are the fixed contract; new fields/endpoints require updating this doc first.
SQL Server licensing/cost surprise on RDS for a personal demoMediumUse RDS free-tier-eligible instance class for the demo; document teardown steps; local dev always uses the free SQL Server Developer edition container.
Optimistic-locking UX feels jarring if the frontend doesn't handle 409s gracefullyMedium§21 mandates a specific toast + "Reload" affordance for ORDER_VERSION_CONFLICT, not a generic error.
Stored procedures drift from application-layer DTOs over timeMediumEvery SP change ships in the same migration/PR as its DTO and integration-test change; §23's integration suite exercises every SP, so drift fails CI immediately.
Someone forks the public repo and strips attributionMediumSee §29 — layered technical, contractual and provenance mitigations.

29. Copyright, Trademark & Watermark Protection

This repository will be public on GitHub. The goal of this section is to make sure that wherever this code or its UI ends up, it is always possible to prove it originated from Arsi India Info — and to make quietly stripping that attribution both harder to do cleanly and a breach of the license under which the code was obtained.

Arsi India Info
Arsi India Info — Innovate · Integrate · Elevate.
Author of record for this repository and every file in it.

29.1 Two-layer licensing (why MIT alone isn't enough)

MIT is kept as the code license — permissive, portfolio-friendly, and exactly what reviewers expect to see on a public repo. But MIT's own terms already require something useful: "The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software." Anyone who redistributes this code must keep the copyright notice — deleting it from copies is itself a license violation, not a gray area. On top of that legal floor, the project adds a second, separate layer that MIT intentionally does not cover: brand assets.

FileCoversKey term
LICENSE existingThe source codeMIT — free to use/modify/redistribute, provided the copyright notice is retained.
NOTICE newAttribution requirement, plain languageStates that the "Arsi India Info" name, logo and the in-app attribution footer must remain intact and visible in any deployment, fork, or derivative that is publicly hosted, per the license notice-retention clause above.
TRADEMARK.md newThe name & logo themselves"Arsi India Info", its wordmark and the circular emblem are not licensed under MIT. They may not be used to imply endorsement, nor removed from a deployment of this code, without separate written permission.

29.2 Where the signature lives (defense in depth)

No single copy of the attribution is load-bearing — it is deliberately repeated across layers that don't all break at once:

SurfaceMechanismWhat removing it would take
Every UI screen<BrandFooter/> mounted once in the shared AppShell layout (§4) — logo + "Powered by Arsi India Info" + link, rendered on the login screen and every authenticated page.Editing the one shared layout every page depends on — visible in the diff/PR forever, and breaks the "don't touch shared layout casually" review norm.
Every API responseA tiny middleware sets X-Powered-By: Arsi-India-Info on every HTTP response.Editing server middleware — a deliberate code change, not an accidental strip.
API root & docsPublic, unauthenticated GET /api/about returns { name, logoUrl, website, license, buildCommit }; Swagger's info.contact/info.license point at Arsi India Info.Editing generated-docs metadata — again a visible, deliberate change.
Every source file & stored procedure headerA short copyright banner comment (in every .ts file and every usp_*.sql file — §6.3), added by a checked-in header template and verified by a CI step (license-header-check) that fails the build if a file is missing it.CI blocks merges that strip it in this repository; a downstream fork that disables the CI check can still remove it locally — see §29.3.
Generated documentsInvoice/packing-slip PDFs (rendered from the documents bucket, §26) bake the logo and company name into the template itself, not as a decorative overlay.Rewriting the PDF template — part of the business logic, not a cosmetic layer.
This very planEmbedded logo images, a footer copyright block, and a low-opacity tiled watermark behind the page content.N/A — this document is reference material, not executable code.

29.3 Honest limits, and the real proof of authorship

Be realistic No technical measure can make attribution physically un-removable in someone else's copy of the code — anyone with the source can delete a comment, a component, or a middleware line. What these layers actually buy: (1) removal is never accidental — every surface requires an explicit, visible code change; (2) removal without keeping the required copyright notice is a license violation under MIT's own terms (§29.1), giving Arsi India Info a real legal basis to act; and (3) it does not change who can prove authorship.

Authorship is ultimately proven the same way it always is in open source, and this plan leans on it deliberately:

30. Definition of Done

This is a solo portfolio build, so §18's formal sign-off table is replaced with a checklist the author can honestly self-certify against before calling v1 "done":

AreaDone means
Multi-tenancyCross-tenant access test (§5.1) passes for every resource module.
Auth & RBACEvery endpoint in §19 has an explicit role list and a passing 403 test for each disallowed role.
Database & SPsAll 13 tables and all 28 stored procedures in §6 are deployed by migration and covered by at least one integration test each.
Data integrityOrder create/pay/ship/cancel are transactional inside their SPs (§9.2); a concurrent-update test proves the 409 path (§9.1).
API contractEvery endpoint's success and at least one error case match §12's envelope exactly; Swagger matches this document.
UI qualityEvery list/detail/form screen has loading, empty, error and validation states per §21 — verified by clicking through the app, not just assumed from the code.
Tests≥80% service-layer coverage; the full test matrix in §23 is green in CI.
Opsdocker-compose up runs the whole stack from a clean clone; the AWS deployment in §26 has been stood up at least once end to end.
BrandingLICENSE, NOTICE, TRADEMARK.md and the BrandFooter are all present and verified live in the running app before the repository is made public.