Skip to content
PG
All projects

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

warehouseproject · preview
Demo data. WarehouseProject overview: monthly sales and revenue, amount pending collection, a six-month sales chart, best-selling products and low-stock products.
Demo data. The same overview on a 390 px phone: cards stack and the chart takes the full width.

Features

Implemented

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.

Demo data. Detail of a confirmed order with its lines, stored unit prices and total.
Implemented

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.

Demo data. Stock movements of a product: entries and exits with date, type, quantity and source order.
Implemented

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.

Demo data. Issued invoice with taxable base, VAT, total, status and a button to download the PDF.
Implemented

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.

Demo data. Product list with search, category and status filters, pagination and the stock of each product.
Implemented

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.

Demo data. Order list on a phone, with customer and status under the order number.
Implemented

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.

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.

WarehouseProject architectureThe React SPA calls nginx, which forwards /api to the Express API. The API passes each request through routes, controller, service and repository, and the repository uses Prisma to talk to PostgreSQL.ClientSPA React 19TanStack Query · RouterHTTPEdgenginxstatic files + /api proxy/apiAPIExpress 5JWT · roles · Zod · errorsroutescontrollerservicerepository → PrismaSQL · transactionsDataPostgreSQL 16NUMERIC · advisory locks
  • 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 UPDATE that only decrements when stock >= 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
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

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
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
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
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.