Skip to content

Architecture

raktim edited this page Aug 4, 2026 · 1 revision

Architecture

  Browser / LAN tablet
        │  http://<machine>:1994   (the ONLY exposed port)
        ▼
┌──────────────────────┐   /api/* proxied server-side   ┌──────────────────────┐
│   frontend  :1994    │ ──────────────────────────────▶ │  backend (internal)  │
│   Next.js / React     │ ◀────────────────────────────── │  Express + Prisma    │
└──────────────────────┘   (internal Docker network)      └──────────┬───────────┘
                                                                      │
                                                                      ▼
                                                        SQLite (nodedr-pos_data)
                                                        (named Docker volume)

One port, one origin

The browser only ever talks to the frontend on port 1994. The Next.js server proxies every /api/* request to the backend over the internal Docker network — the backend is not published to the host at all. This buys three things:

  • the app works from any device on the LAN (a counter tablet, a phone), not just the machine running the containers;
  • session cookies are first-party, so there's no cross-origin/CORS fragility;
  • the API isn't exposed on the network, shrinking the attack surface.

The backend and frontend are two separate containers, neither of which needs elevated privileges or host device access — everything still comes up with a single docker compose up. Printing goes through the browser's own print dialog or a server-generated PDF for most receipts; "Print via USB" is the one path that talks to hardware, and it does so from the backend with narrowly scoped device permissions (see Hardware Setup), not a privileged: true container.

Why two containers instead of one

The backend needs the ability to talk to a USB printer; the frontend never does. Splitting them means only the backend carries that (scoped) hardware access, and the frontend stays fully unprivileged.

Native installs mirror the same split

The .deb and .exe installers don't run Docker at all, but they preserve the same two-process architecture: systemd units on Linux, Windows services on Windows, each process supervised and restarted the way the two containers are here. See Installation for which method fits your till.

Tech stack

Layer Choice
Frontend Next.js (App Router), React, TypeScript, Tailwind CSS
Data layer TanStack Query, react-hook-form + Zod
Backend Node.js, Express, Zod validation, helmet, rate limiting
Database SQLite via Prisma ORM (better-sqlite3 driver adapter), persisted in a Docker volume
Auth bcrypt hashing, HttpOnly JWT cookie, admin/cashier roles
API access Browser → Next.js (:1994) → server-side /api proxy → backend
Hardware Browser print dialog + pdfkit for receipts; a custom React hook for the barcode scanner; @zxing/browser for camera scanning
Barcodes jsbarcode (EAN-13/CODE128) + qrcode, both rendered client-side to canvas — no external barcode service
Charts recharts for the dashboard's sales trend, top products, and payment mix graphs
Bulk import multer (multipart upload) + csv-parse, admin-only, for Reference Data CSV imports
Deployment Docker Compose, two node:24-alpine multi-stage images

Data & money model — the parts worth understanding before touching them

  • GST is backed out of the price, never added on top. In India, MRP (the price a shop enters) is legally required to be GST-inclusive. The pricing engine (backend/src/lib/pricing.js, mirrored in frontend/lib/quote.ts for the live POS preview) computes taxAmount = inclusiveAmount - inclusiveAmount / (1 + rate/100) and never adds tax to the subtotal — it's already inside it.
  • Order-level discounts apply to the tax-inclusive subtotal, not a backed-out exclusive base — that's the only ordering where both a percent discount and a flat-rupee discount produce the rupee amount the cashier actually typed.
  • Running money balances (totalDue, creditBalance, totalSpent) are read-modify-write in JS, rounded, and stored via set — never a database-side increment on a floating-point column, which accumulates binary residue across many small transactions.
  • Checkout is a single Prisma transaction that server-prices every item, validates stock, and applies discount/loyalty/due/credit atomically.
  • All of the above is deliberately duplicated across two "why" write-ups if you go digging in commit history — this page states the current, correct behavior; treat it as the spec if you're extending the pricing or balance code.

For endpoint-level detail see API Reference; for the full project layout see the README's Project structure section.

Clone this wiki locally