Developer Guide

Architecture deep-dive, extending the codebase, and key patterns

Dev Only

Backend Project Structure

PathDescription
src/server.tsEntry point — creates HTTP server, registers Socket.IO, starts BullMQ workers
src/app.tsExpress app factory — registers middleware, mounts all module routers
src/config/Zod env validation schema, app constants, feature flags
src/modules/[module]/Feature module folder — each module contains 4 files (see below)
[module].routes.tsExpress router — defines paths and attaches middleware per route
[module].controller.tsParses request, calls service, returns HTTP response
[module].service.tsAll business logic — uses the tenant-scoped Prisma client
[module].schema.tsZod schemas for request body, params, and query validation
src/middleware/authenticate, requirePermission, errorHandler, rateLimiter
src/lib/Prisma client factory, Redis client, BullMQ queues, Socket.IO instance
src/jobs/BullMQ worker files — one per queue
src/utils/Pagination helper, Decimal.js wrapper, standard response formatter
prisma/schema.prismaDatabase schema — source of truth for all models
prisma/migrations/Auto-generated migration history — never edit manually
prisma/seed.tsDemo data seeder script
scripts/seed-super-admin.tsCreates the platform Super Admin account

Module File Pattern

Each of the 14 domain modules follows a consistent 4-file pattern:

Module Structure

Multi-Tenancy Architecture

PosVelo uses a shared PostgreSQL database with application-level tenant isolation. The flow on every authenticated request is:

  1. Request arrives with a JWT in the Authorization header.
  2. The authenticate middleware verifies the token and extracts tenantId.
  3. A Prisma client extension is instantiated and injected into req.prisma — this extension automatically appends WHERE tenantId = X to every query on all tenant-scoped models.
  4. Service functions use req.prisma — they never specify tenantId manually; isolation is guaranteed at the query level.

Dual-Layer Scoping

Layer 1 — Direct Tenant Models (20 models with tenantId column): The Prisma extension automatically injects tenantId into every WHERE clause on reads, every data payload on creates, and every WHERE clause on mutations. Even if application code forgets to filter by tenant, the extension silently adds the filter.

Layer 2 — Child Models via Parent Relation (5 models without tenantId): Models like ProductVariant, StoreStock, SaleItem, PurchaseItem, and StockTransferItem don't have their own tenantId column. The extension injects a parent relation filter that generates a SQL JOIN enforcing tenant scoping at the database level.

Tenant Client Creation

typescript
Note

The Super Admin uses the reserved __platform__ tenant ID which bypasses tenant scoping. This role cannot be assigned to any regular tenant user and is only valid inside the platform admin context.

RBAC — Roles & Permissions

Permissions are string constants in the format resource:action (e.g. product:create, sale:void). The ROLE_PERMISSIONS map in src/config/permissions.ts assigns permission sets to each role.

Role Hierarchy

Permission Matrix

PermissionSUPER_ADMINADMINMANAGERCASHIER
tenant:manage
store:manage
user:manage
product:read
product:write
inventory:read
inventory:write
sale:create
sale:read
sale:read:own
sale:void
sale:return
customer:read
customer:write
report:read
settings:manage
receipt:generate

Store-Level Access Control

Beyond role permissions, the storeGuard middleware restricts non-admin users to their assigned store(s). A CASHIER assigned to Store A cannot create sales in Store B, even though they have sale:create permission. Admins bypass the store guard entirely.

Adding a New API Route — Step by Step

1

Create module folder

Create src/modules/your-module/

2

Define Zod schemas

Create your-module.schema.ts — define Zod schemas for all request inputs.

3

Implement business logic

Create your-module.service.ts — implement business logic using req.prisma.

4

Create controller

Create your-module.controller.ts — parse request, call service, return response.

5

Register routes

Create your-module.routes.ts — register paths with authentication middleware:

typescript

Then mount the router in src/app.ts:

typescript

Database Workflow

TaskCommandNotes
Modify schemanpm run db:migrateEdit schema.prisma first, then run. Dev only — prompts for a name.
Browse data visuallynpm run db:studioOpens Prisma Studio at http://localhost:5555
Apply in productionnpm run db:migrate:prodNo prompts. Safe for CI/CD pipelines.
Wipe and restart (dev)npm run db:reset⚠️ DESTROYS all data. Never run on production.
Regenerate the clientnpm run db:generateRequired after every schema.prisma change.

Database Schema Overview

PosVelo's database consists of 27 models and 12 enums organized into domain groups:

Model Organization

Indexing Strategy

The schema uses a deliberate indexing strategy aligned to query patterns:

  • Tenant isolation indexes@@index([tenantId]) on all tenant-scoped models
  • Composite unique constraints@@unique([tenantId, sku]), @@unique([tenantId, invoiceNo]) prevent cross-tenant collisions
  • Time-series indexes@@index([tenantId, createdAt]) on Sales, Movements, Payments for date-range reports
  • Lookup indexes@@index([tenantId, barcode]) on Products for POS scanner speed
  • Partial unique indexes — PostgreSQL WHERE ... IS NOT NULL for nullable columns via raw SQL migration

Background Jobs (BullMQ)

Jobs run asynchronously via Redis-backed queues:

QueuePurposeRetry PolicyRetention
invoicePost-checkout invoice processing3 attempts, exponential backoff24h completed / 7d failed
reportAsync report generation3 attempts, exponential backoff24h / 7d
low-stockLow stock alert notifications3 attempts24h / 7d
daily-snapshotEnd-of-day business snapshots3 attempts24h / 7d
notificationPush notifications to users5 attempts (elevated)24h / 7d

To add a new job: create a queue in src/lib/queues.ts, create a worker file in src/jobs/your-job.worker.ts, then register the worker in src/server.ts.

Socket.IO Room Architecture

Room PatternPurpose
tenant:{tenantId}:store:{storeId}POS terminal and mobile camera scanner share this room. The phone relays barcode events to the POS in real time.
tenant:{tenantId}:adminAdmin dashboard receives live stock alerts and new-sale notifications.

USB/Bluetooth Scanner Detection

The useBarcodeScanner hook uses keystroke timing analysis: hardware scanners type at < 50ms per keystroke while humans type at > 100ms. The buffer accumulates rapid keystrokes, and the Enter key triggers barcode submission if the average interval is below the threshold.

Checkout — Critical Path

The checkout is the most performance-critical and concurrency-sensitive operation:

Checkout Flow

Financial Precision Rules

All monetary values use Decimal.js at 4 decimal places with ROUND_HALF_EVEN (banker's rounding). This matches enterprise accounting standards. Never use native JavaScript floating-point for money. Always use the Decimal utility functions in src/utils/decimal.ts.

The same Decimal.js configuration is mirrored in both backend and frontend, ensuring the checkout preview matches the final invoice to the penny.

Error Handling

All errors flow through a centralized error handler that produces consistent JSON responses:

Error TypeHTTP StatusResponse Code
ValidationError400VALIDATION_ERROR
AuthenticationError401AUTHENTICATION_ERROR
AuthorizationError403AUTHORIZATION_ERROR
NotFoundError404NOT_FOUND
ConflictError409CONFLICT
InsufficientStockError409INSUFFICIENT_STOCK
Prisma P2002409DUPLICATE_ENTRY
Prisma P2025404NOT_FOUND
Rate Limit429RATE_LIMIT_EXCEEDED
Unknown500INTERNAL_ERROR

Authentication Architecture

Token System

  • Access Token — stored in memory (RAM), 15-minute lifespan, contains userId, tenantId, storeId, role, email
  • Refresh Token — stored in HTTP-only cookie + database, 7-day lifespan, single-use with rotation

Security Features

  • Argon2 Password Hashing — winner of the Password Hashing Competition
  • Refresh Token Rotation — each token is single-use; after use, a new one is issued
  • Token Theft Detection — if a valid JWT is not found in the database, all user tokens are revoked
  • Silent Token Refresh — frontend intercepts 401 responses and transparently refreshes
  • Single-Flight Refresh — multiple concurrent 401s share a single refresh request
  • Auth Rate Limiting — 5 attempts per 15 minutes per IP (fails closed if Redis is down)
  • Password Change Revocation — changing password revokes all sessions across devices