The branch-level point-of-sale application. One instance runs on a dedicated machine inside each physical store — it's the cashier-facing system that handles the cart, checkout, and receipt flow, and keeps working even when the branch loses its internet connection.
This is one half of a two-repo distributed system. The other half is pos-central, the single cloud instance that owns the master catalog and aggregates sales from every branch.
Each branch needs to keep ringing up sales regardless of internet reliability or connectivity to head office. Rather than depend on a live connection to the cloud for every checkout, this service holds its own local copy of the product catalog and queues completed transactions locally, syncing them out whenever a connection to pos-central is available.
Checkout (POST /api/cart/transaction) applies the sale — updates stock, customer totals, and creates the order — and writes the RabbitMQ message it needs to send to pos-central into an unsent_message table, all inside one database transaction. The RabbitMQ publish itself is never attempted inline. A separate loop, running every 5 seconds, drains that table and publishes whatever's in it.
This means a sale is durable the moment the transaction commits, regardless of whether RabbitMQ or the branch's internet is up at that instant — there's no window where the sale succeeded locally but the outbound message was lost.
Cashier checks out (POST /api/cart/transaction)
│
▼
One DB transaction:
- apply sale (stock, customer totals, order + operations)
- write outbound message to unsent_message
│
▼
Outbox drain loop (every 5s) ──(RabbitMQ/internet down)──► leaves row, retries next tick
│
(RabbitMQ reachable)
▼
Publish to RabbitMQ (local_to_central exchange, direct) → row deleted
│
▼
pos-central consumes → writes to central DB
- Database: local PostgreSQL instance, storing a synced copy of the product catalog plus this branch's own transactions and outbox.
- Messaging: no local RabbitMQ broker — this service connects directly to the broker hosted on
pos-central. (Currently over the plain network the two machines share — seepos-central's README for the planned Tailscale setup.) - Catalog sync: product, price, and category changes are pushed down from
pos-centralvia abroadcastexchange (fanout) and applied to the local database. A branch can also request a full resync (POST /api/sync), which asks central to rebroadcast its entire catalog. - Branch identity: this branch's name (used by central to attribute incoming transactions) lives in the local
configtable under thebranch_namekey. - Customers & discounts: customers are classified regular / student / VIP (
Customer.class, synced from central). Checkout applies a discount strategy based on that class — currently a flat 10% off for student and VIP customers, none for regular.
| Layer | Technology |
|---|---|
| Backend | Node.js, Express, TypeScript |
| ORM | TypeORM (PostgreSQL), schema-migration based (see below) |
| Messaging | RabbitMQ via amqplib (connects to remote broker) |
| Auth | None. The app assumes a trusted, single-purpose device on a private network — there's no login on this side (contrast with pos-central's admin panel, which does have one). |
| Frontend | React, Vite, Tailwind CSS |
| Database | PostgreSQL 16 |
| Infra | Docker, Docker Compose |
cp .env.example .env # set RABBITMQ_URL to point at pos-central's broker
docker compose up --buildBrings up three services: postgres, backend (port 3000), and frontend — mapped to host port 5174 (container listens on 5173; bumped so it doesn't collide with pos-central's frontend when running both stacks on one machine). No local RabbitMQ container — RABBITMQ_URL points at the central server's broker address. docker compose up will refuse to start if RABBITMQ_URL isn't set in .env.
Root .env (read by docker-compose.yml; DB credentials are hardcoded in the compose file itself):
| Variable | Description |
|---|---|
RABBITMQ_URL |
Required. e.g. amqp://appuser:apppass@<central-host>:5672. docker compose up fails fast if unset. |
backend/.env (only used when running the backend outside Docker, e.g. npm run dev):
| Variable | Description |
|---|---|
DB_HOST / DB_PORT / DB_USERNAME / DB_PASSWORD / DB_DATABASE |
Postgres connection |
RABBITMQ_URL |
Same as above; falls back to amqp://localhost if unset |
synchronize is disabled in TypeORM — it never auto-alters tables against the live schema. Schema changes go through real TypeORM migrations in backend/src/migrations/, applied automatically on every boot (migrationsRun: true), so a fresh clone against an empty database builds its schema with no manual step.
To change the schema:
cd backend
# edit an entity, then:
npm run migration:generate src/migrations/DescriptiveName
# review the generated SQL, commit it
npm run migration:run # or just restart the app — it runs pending migrations on boot
npm run migration:revert # to undo the last onebackend/src/
├── server.ts # entry point, port 3000
├── config/
│ ├── database.ts # TypeORM DataSource (migrations, not synchronize)
│ └── rabbitmq.config.ts # remote broker connection settings
├── controllers/ # Cart (real checkout), Product, Category, Customer, Operation, Config, SyncData
├── dtos/
├── entities/ # Product, Category, Customer, Order, Operation, Config, UnsentMessage
├── enums/
├── message_brokers/
│ ├── rabbitmq.connection.ts # connection manager, auto-reconnect
│ ├── rabbitmq.consumer.ts # receives catalog updates from central
│ └── rabbitmq.publisher.ts # sends completed transactions, outbox fallback
├── middlewares/
├── migrations/ # TypeORM migrations (see above)
├── repositories/
├── routes/
│ ├── cartRoutes.ts # POST /cart/transaction — the actual checkout endpoint
│ └── ...
├── services/
│ ├── TransactionService.ts # picks Sell/Return strategy for a transaction
│ ├── DiscountService.ts # picks a discount strategy by customer class
│ └── SyncDataService.ts # triggers a full catalog resync from central
├── streategies/ # (repo has this spelling throughout)
│ ├── TransactionStrategies/ # Sell, Return
│ └── DiscountStratigies/ # NoDiscount, Student, VIP
├── types/
└── utils/
- No local message broker. Running RabbitMQ on every branch machine would add memory overhead with no real benefit — branches connect directly to the central broker instead.
- PostgreSQL over SQLite, despite SQLite's lighter footprint.
docker-compose.ymlcapsshared_buffers/work_mem/max_connectionsto keep the footprint down, but this hasn't been tested yet on the actual target hardware — see Roadmap. - No inbound network exposure. The branch machine never needs an inbound connection from the internet — it only ever initiates outbound connections to the central server. Currently that's over whatever network the two machines share; Tailscale is the planned replacement (see
pos-central's README). - Checkout writes its outbox row unconditionally, in the same transaction as the sale, rather than trying RabbitMQ first and falling back on failure — see How it stays resilient offline. This is stronger than a try-then-fallback: there's no gap between "sale succeeded" and "sale is durably queued to sync."
- Tailscale mesh network to
pos-central(currently plain network — seepos-central's README) - Validate the 4GB-branch-laptop deployment target: Docker Engine via WSL2 (not Docker Desktop), capped WSL2 memory, and the tuned Postgres settings above, on actual target hardware. None of this has been tested yet — for now this just runs via
docker compose up --buildlike any other environment. - Packaged launcher (
POS.exe) that starts the Docker stack, checks Tailscale, and opens the browser — not built yet - Shared TypeScript types package with
pos-centralfor RabbitMQ message contracts - Automated update mechanism for branch machines
- Docker image registry distribution instead of manual image builds, once the packaged launcher exists