WarehouseProject
A lightweight ERP for a shop or warehouse: orders that decrement stock atomically, VAT invoices as PDF and three roles with their own permissions.
In 30 seconds
- Category
- Full stack · Inventory and sales
- Status
- Complete · no public demo
- Stack
- Node.js · Express 5 · Prisma · PostgreSQL 16 · React 19
- Tests
- 36 integration tests against a real PostgreSQL, including concurrency
- Security
- JWT with rotating refresh token · 3 roles enforced by the backend
- Delivery
- Docker Compose · CI on GitHub Actions · deployment prepared for Render and Vercel
- Live demo
- No public demo
Overview
- What it is
- A complete web application (REST API and SPA) to manage the catalogue, inventory, customers, orders and invoicing of a small shop or warehouse.
- The problem it solves
- Small businesses often keep stock and invoicing in spreadsheets that break as soon as two people work at once: items get sold that are no longer there, and nobody knows why a number changed.
- Who it is for
- A small team with three profiles: administration, sales staff who create orders, and warehouse staff who record stock movements.
- Goal
- Keep stock always explainable and correct even with several people working at once, and let each role see and do only what it should.
Preview


Features
Orders with transactional stock decrement
An order moves through draft, confirmed, paid or cancelled. Confirming it decrements the stock of every line in a single transaction; cancelling it returns the stock.
Why it matters: If any line lacks stock nothing is confirmed, so an order is never left half-done.

Stock movement ledger
Every stock entry or exit is recorded: restocking, shrinkage, adjustments and the exits caused by orders.
Why it matters: The current stock can always be explained by its history.

Invoices with VAT, PDF and due dates
Invoices are issued from an order, store base amount, VAT and total, download as PDF and become overdue on their own once the due date passes.
Why it matters: An invoice’s amounts never change, even if prices or VAT change later.

Three roles with per-resource permissions
ADMIN, COMERCIAL (sales) and ALMACEN (warehouse) can read everything, but each role only writes where it should. The backend always enforces it; the frontend hides what you cannot do.
Why it matters: Nobody can bypass a permission by calling the API directly.

Mobile-ready layout
On narrow screens secondary columns are hidden and the essentials move under the main value. Checked by hand at 360, 768, 1024, 1280 and 1440 px.
Why it matters: Warehouse staff can record movements from a phone.

Business metrics dashboard
Monthly sales and revenue, amount pending collection, sales over the last six months, best sellers, low stock and orders by status.
Why it matters: A quick view of how the business is doing without exporting anything.
Gallery
Pending assetPending video: confirming an order and watching the stock go down
Stack
Backend
- Node.js + Express 5
- REST API
- TypeScript (strict)
- Types across the project
- Zod
- Validation of requests and environment variables
- jsonwebtoken + bcryptjs
- Authentication
- helmet · express-rate-limit · cors
- Secure headers and login attempt limiting
- pdfkit
- Invoice PDFs
Database
- PostgreSQL 16
- Relational data, amounts as NUMERIC
- Prisma 6
- Schema, migrations and data access
Frontend
- React 19 + Vite
- SPA with on-demand page loading
- TanStack Query
- Server data and caching
- React Hook Form + Zod
- Forms
- Tailwind CSS 4
- Styling
- Recharts
- Dashboard charts
Testing
- Jest + Supertest
- Integration tests against PostgreSQL
- Vitest
- Pure frontend logic
Infrastructure
- Docker + Docker Compose
- PostgreSQL, API and frontend with one command
- nginx
- Serves the SPA and forwards /api
- GitHub Actions
- Lint, format, types, tests, build and images
- Render + Vercel
- Deployment prepared (blueprint and config)
Architecture
A monorepo with two independent projects. The API follows a layered architecture where each layer has a single job, and the service decides which operations share a transaction.
- Routes only chain middlewares (authentication, role, validation); the controller translates HTTP; the service holds business logic and opens transactions; the repository is the only layer that talks to Prisma.
- Repositories receive the transaction client, so the same method works inside or outside a transaction without knowing it.
- Every error ends in a single middleware: Zod → 400, business → 404/409, Prisma errors translated, and never a stack trace.
- In Docker, nginx serves the SPA and forwards /api to the API, which applies migrations on start-up.
Engineering decisions
Each decision with the problem, the solution, the reason and the exact place in the code where it lives.
Stock without race conditions
- Problem
- Two sales people confirm an order for the last unit of a product at the same time. If stock is read first and then decremented, both see one unit available and stock ends at −1.
- Solution
- A single conditional
UPDATEthat only decrements whenstock >= quantity. If it affects no rows, the API answers 409 and the whole order transaction is rolled back. - Why
- The database decides atomically, with no prior read and no manual locking. A test fires both requests at the same time.
- Code
- pedido-estado.repository.ts:32 (opens in a new tab)backend/src/modules/pedido/pedido-estado.repository.ts
- Test
“dos pedidos a la vez por el último stock: solo uno se confirma”
pedidos.test.ts:114 (opens in a new tab)
No deadlocks between orders
- Problem
- Two orders sharing products can lock rows in different orders (A then B, B then A) and wait for each other forever.
- Solution
- An order’s lines are always processed sorted by
productoId. - Why
- If every transaction locks in the same order, no waiting cycle can form. It costs a single line of code.
- Code
- pedido-estado.repository.ts:18 (opens in a new tab)backend/src/modules/pedido/pedido-estado.repository.ts
Sequential numbering without duplicates
- Problem
- Orders and invoices carry sequential numbers (
PED-2026-0001). Two simultaneous inserts can read the same last number and produce the same next one. - Solution
- The next number is computed under a PostgreSQL advisory lock (
pg_advisory_xact_lock) that is released automatically when the transaction ends. - Why
- It serialises only the numbering, not the whole table, and cannot leave a forgotten lock behind.
- Code
- numeracion.ts:26 (opens in a new tab)backend/src/common/numeracion.ts
- Test
“pedidos creados a la vez reciben números distintos”
pedidos.test.ts:71 (opens in a new tab)
Exact money and an immutable history
- Problem
- Floating-point amounts accumulate rounding errors, and if an invoice reads the product’s current price, its total changes when the price does.
- Solution
- Amounts stored as
NUMERIC(10,2)with explicit VAT rounding. Each line stores the price at that moment and each invoice its own amounts (snapshots). - Why
- An accounting document must not change once issued.
- Code
- schema.prisma:185 (opens in a new tab)backend/prisma/schema.prisma
- Test
“guarda el precio del momento aunque luego cambie el del producto”
pedidos.test.ts:36 (opens in a new tab)
Rotating refresh token and shared renewal
- Problem
- A stolen refresh token works forever if it is never invalidated, and in the frontend several requests expiring at once would trigger several renewals.
- Solution
- Each refresh token works once and is stored as a SHA-256 hash. In the frontend, requests that expire together wait for a single renewal in progress.
- Why
- If someone uses a stolen token, the legitimate user’s next refresh fails and the theft shows. And rotation does not break the session when requests run in parallel.
- Code
- auth.service.ts:68 (opens in a new tab)backend/src/modules/auth/auth.service.ts
- api.ts:43 (opens in a new tab)frontend/src/lib/api.ts
- Test
“rota el refresh token: el usado ya no vale”
auth.test.ts:67 (opens in a new tab)
What is missing
- A cancelled invoice cannot be reissued: corrective invoices are not implemented.
- Order form selectors load up to 100 items; remote search is missing.
- No end-to-end tests for the frontend.