Developer Guide
Architecture deep-dive, extending the codebase, and key patterns
Dev OnlyBackend Project Structure
| Path | Description |
|---|---|
src/server.ts | Entry point — creates HTTP server, registers Socket.IO, starts BullMQ workers |
src/app.ts | Express 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.ts | Express router — defines paths and attaches middleware per route |
[module].controller.ts | Parses request, calls service, returns HTTP response |
[module].service.ts | All business logic — uses the tenant-scoped Prisma client |
[module].schema.ts | Zod 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.prisma | Database schema — source of truth for all models |
prisma/migrations/ | Auto-generated migration history — never edit manually |
prisma/seed.ts | Demo data seeder script |
scripts/seed-super-admin.ts | Creates the platform Super Admin account |
Module File Pattern
Each of the 14 domain modules follows a consistent 4-file pattern:
Multi-Tenancy Architecture
PosVelo uses a shared PostgreSQL database with application-level tenant isolation. The flow on every authenticated request is:
- Request arrives with a JWT in the
Authorizationheader. - The
authenticatemiddleware verifies the token and extractstenantId. - A Prisma client extension is instantiated and injected into
req.prisma— this extension automatically appendsWHERE tenantId = Xto every query on all tenant-scoped models. - Service functions use
req.prisma— they never specifytenantIdmanually; 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
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
| Permission | SUPER_ADMIN | ADMIN | MANAGER | CASHIER |
|---|---|---|---|---|
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
Create module folder
Create src/modules/your-module/
Define Zod schemas
Create your-module.schema.ts — define Zod schemas for all request inputs.
Implement business logic
Create your-module.service.ts — implement business logic using
req.prisma.
Create controller
Create your-module.controller.ts — parse request, call service, return
response.
Register routes
Create your-module.routes.ts — register paths with authentication
middleware:
Then mount the router in src/app.ts:
Database Workflow
| Task | Command | Notes |
|---|---|---|
| Modify schema | npm run db:migrate | Edit schema.prisma first, then run. Dev only — prompts for a name. |
| Browse data visually | npm run db:studio | Opens Prisma Studio at http://localhost:5555 |
| Apply in production | npm run db:migrate:prod | No prompts. Safe for CI/CD pipelines. |
| Wipe and restart (dev) | npm run db:reset | ⚠️ DESTROYS all data. Never run on production. |
| Regenerate the client | npm run db:generate | Required after every schema.prisma change. |
Database Schema Overview
PosVelo's database consists of 27 models and 12 enums organized into domain groups:
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 NULLfor nullable columns via raw SQL migration
Background Jobs (BullMQ)
Jobs run asynchronously via Redis-backed queues:
| Queue | Purpose | Retry Policy | Retention |
|---|---|---|---|
| invoice | Post-checkout invoice processing | 3 attempts, exponential backoff | 24h completed / 7d failed |
| report | Async report generation | 3 attempts, exponential backoff | 24h / 7d |
| low-stock | Low stock alert notifications | 3 attempts | 24h / 7d |
| daily-snapshot | End-of-day business snapshots | 3 attempts | 24h / 7d |
| notification | Push notifications to users | 5 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 Pattern | Purpose |
|---|---|
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}:admin | Admin 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:
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 Type | HTTP Status | Response Code |
|---|---|---|
| ValidationError | 400 | VALIDATION_ERROR |
| AuthenticationError | 401 | AUTHENTICATION_ERROR |
| AuthorizationError | 403 | AUTHORIZATION_ERROR |
| NotFoundError | 404 | NOT_FOUND |
| ConflictError | 409 | CONFLICT |
| InsufficientStockError | 409 | INSUFFICIENT_STOCK |
| Prisma P2002 | 409 | DUPLICATE_ENTRY |
| Prisma P2025 | 404 | NOT_FOUND |
| Rate Limit | 429 | RATE_LIMIT_EXCEEDED |
| Unknown | 500 | INTERNAL_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