Skip to content

About

Offline-first POS and inventory management system — Docker, Next.js, Express, Prisma, ESC/POS thermal printing

Topics

Resources

Code of conduct

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Repository files navigation

NodeDR POS logo

nodedr-pos

License: AGPL v3 Docker Compose Node Offline-first CasaOS / ZimaOS

Try NodeDR POS · Case study · Maintainer: Raktim Ranjit

NodeDR POS: animated overview with an illustrative sample screen

A free, open-source, offline-first point-of-sale and inventory management system for small retail shops. It runs on a machine in your shop with Docker Compose, so normal checkout and inventory work without an internet connection or subscription. Your data stays on that machine by default. It's also just a portable two-container stack, so if you'd rather manage it remotely, the identical setup runs on any VPS/cloud provider too — see Where to run it for the trade-offs either way.

Built for a barcode-scanner counter setup: scan an item to sell it, press Enter to check out, then print or download the receipt with one click. No USB scanner handy? Scan barcodes and QR codes straight from a phone or tablet's camera instead. No barcode at all? Generate one and print a label, right from the app.

Access it at http://<machine>:1994 — from the shop's own machine or any tablet/phone on the same network.

Install

Animated terminal: clone the repo and run install.sh

NodeDR POS is a web app that runs on one machine in your shop (the "till" or a small server). Every phone, tablet or PC on the same Wi-Fi then opens it in a browser at http://<that-machine>:1994. Pick the option that matches that machine:

I have… Best option Needs
🖥️ Windows 10 or 11 Microsoft Store app nothing else
🐳 Docker host, NAS or VPS One-command Docker Compose install Docker
🏠 CasaOS / ZimaOS box CasaOS app nothing else

These are the supported ways to install NodeDR POS. Standalone Windows, macOS, and Linux installer downloads are not distributed. Use Docker Compose on Mac or Linux.

One-command install (Windows, macOS, or Linux)

Paste the command for your terminal. The quickstart checks requirements, installs Docker where supported if it is missing, then downloads and starts NodeDR POS. On first use, Windows may ask for one restart while it enables WSL2; run the same command again after restarting.

Windows PowerShell

irm https://raw.githubusercontent.com/Raktim94/nodedr-pos/master/scripts/quickstart.ps1 | iex

macOS / Linux Terminal

curl -fsSL https://raw.githubusercontent.com/Raktim94/nodedr-pos/master/scripts/quickstart.sh | bash

When installation finishes, open http://localhost:1994. On another phone, tablet, or computer on the same Wi-Fi, open http://<computer-ip>:1994 using the address of the machine running NodeDR POS. On Linux, the installer may need sudo to install Docker. On macOS, Homebrew is needed if Docker is not already installed. The Microsoft Store and CasaOS app remain the simplest options for those platforms.

For a VPS, copy .env.example to .env, set FRONTEND_ORIGIN=https://pos.yourdomain.com, COOKIE_SECURE=true and PUBLIC_BASE_URL=https://pos.yourdomain.com, and put HTTPS in front of the app (see Where to run it).

Updating

Docker and CasaOS installs include a small updater container (nodedr-pos-updater, see updater/). When a newer version is published, admins see an "Update available" banner in the app. Go to Settings → Updates, click Update now and confirm your password — the updater downloads that exact version, swaps the two app containers (about a minute) and the page reloads. If the download fails (no internet) nothing is changed and the error is shown. Your data volume is never touched.

The updater does nothing on its own and only ever touches the backend and frontend containers. It needs the Docker socket, which is why it is a separate, single-purpose container reachable only over the internal Docker network and only with a shared token (UPDATER_TOKEN — the default is fine on a LAN; set your own in .env on a public server). To update by hand instead: docker compose pull && docker compose up -d.

First launch

  1. Create the admin account (name, e-mail, password — keep it safe).
  2. Shop details: name, address, currency, and your GST number if you charge GST.
  3. You land on the dashboard. Next, in this order:
    • Inventory → Add product (or Bulk import a CSV/Excel file).
    • Settings → Invoice & signature — pick the bill style and upload your authorised signature.
    • Settings → Payments — add your UPI id to show a QR at checkout.
    • POS Checkout — scan or search products and sell.
  4. Optional: Settings → Features — turn on IMEI / serial numbers & warranty if you sell phones, laptops or appliances. It is off by default; when on, the IMEI is asked when you sell a product (nothing is entered into stock beforehand).
  5. Optional: connect your online store (Settings → Online stores / Integrations) — see docs/API.md.

Printers, barcode scanners and other hardware: Hardware setup. What is verified and what still needs real-device testing: docs/ROADMAP.md.

How to use NodeDR POS

Start at the dashboard, then follow this workflow. The full step-by-step tutorial includes the complete setup guide and screenshot gallery.

1. Create your shop

On first launch, create an administrator account with your name, email, and a strong password. Enter your shop name and address, then choose the currency and tax options that fit your business. Finish setup to open the dashboard. Company and tax details can be changed later in Settings.

NodeDR POS dashboard showing sales, inventory alerts, and online orders

2. Add products and set stock

Open Inventory → Add product. Enter the product name, cost and selling prices, opening stock, and reorder level. Add a SKU, scan an existing barcode, or use Generate when an item has no barcode. Set a category and tax rate where applicable, then save. Products are then ready for checkout. Use CSV or Excel import for a larger catalogue.

Inventory list with product quantities

Add-product form with barcode scan and generate options

Optional product features include weighted items and IMEI/serial tracking. Turn on IMEI tracking in Settings → Features if you sell phones, laptops, or appliances. It is off by default. When enabled, enter the identifier of each unit at checkout; there is no need to register identifiers in stock first.

Settings with optional IMEI tracking

3. Complete a sale

Open POS Checkout, scan a barcode or search for a product, and add it to the cart. Scan again or adjust quantity to sell multiple units. Review the items and total, optionally attach a customer, choose an available payment method, enter the amount received, and complete the sale. From the confirmation you can print a receipt or download a PDF. Past invoices are available in Sales for review and reprinting.

POS checkout with a weighed item and payment QR

For IMEI-tracked items, checkout asks for each unit's identifier and the bill can include its warranty details.

IMEI captured during checkout

4. Manage customers, suppliers, and orders

  • Customers: add and search customer records, link them to sales, and share a loyalty page when needed.
  • Purchasing: add suppliers, create and review purchase orders, and use Reorder suggestions. Products appear there when stock reaches their configured reorder level.
  • Online orders: connected website orders move through New, Packing, Ready, and Collected. Connect WooCommerce or Shopify in Settings → Online stores, or connect a custom website using the API guide. Products are matched by SKU. Orders paid online can be handed over; pay-at-pickup orders can be billed at collection.
  • Team: administrators can add staff accounts and assign access roles.

Online orders board with New, Packing, Ready, and Collected columns

5. Review reports, invoices, and warranty

In Reports, choose 7, 30, or 90 days to review revenue, gross profit, average bill, category sales, hourly trends, and top products. Tax summaries are available as CSV; review tax figures with your accountant before filing. Configure Settings → Receipt for receipt details and Settings → Invoice & signature for A4 invoices and the shop signature.

Reports with revenue, margin, category, and hourly trends

A4 tax invoice with warranty table and signature

When serial tracking is enabled, open Warranty and scan or enter an identifier to find its sale and warranty status.

Warranty lookup showing sale date and warranty status

Back up and update

Docker Compose and CasaOS installations store app data in persistent Docker storage. Back it up regularly. Use the in-product updater when an update is available, or update a manual Compose installation with docker compose pull && docker compose up -d. See Backing up your data and Updating for details.

Screenshot gallery

All tutorial screenshots are in docs/screenshots/:

App screen Screenshot
Dashboard View screenshot
Inventory View screenshot
Add product View screenshot
Product features View screenshot
POS checkout View screenshot
IMEI product setup View screenshot
IMEI checkout View screenshot
Online orders View screenshot
Reports View screenshot
A4 invoice View screenshot
Warranty lookup View screenshot

Contents

What's new in 1.5

  • Find products online — Inventory → Find online searches free community databases (Open Food / Beauty / Products Facts) by name or barcode. Results are validated (barcode check digit, junk names, wrong-code records) and shown with warnings (only the barcode or search text leaves your server, never shop data); Review & add opens the normal form pre-filled. Scanning an unknown barcode in Inventory does this automatically. In any product form, Check online compares the barcode with the online record and lets you replace the name, category or photo field by field. Nothing is saved until you press Save.
  • Product photos — add a photo (file or phone camera) when registering or editing a product; shown in the inventory list.
  • Customer display on any device — open the link from POS → Customer display on a phone, tablet or second PC on the same network; it follows the till live, including the UPI payment QR. See Customer display. A chime (and a spoken amount for UPI/card) confirms each completed sale.
  • Online orders — billing follows who took the money: orders paid on the website are handed over without a POS bill; pay-at-pickup orders are billed in the POS at hand-over (the cashier can override). Connect your own website through the API — see docs/CUSTOM_STORE.md.
  • Purchasing — edit/delete suppliers, create purchase orders by hand, cancel orders.
  • Smarter installs — install.sh picks a free port if 1994 is taken, and the CasaOS README explains the "ports in use" orphan-container fix.

Update an existing install from Settings → Updates → Update now (see Updating).

What's new in 1.2

optional IMEI / serial-number tracking with warranty lookup (off by default, captured at sale) · bulk product import (CSV/XLSX) · an e-commerce REST API + MCP server that can take bills, reserve click-and-collect orders and check warranty · A4 tax-invoice PDFs with your authorised signature and a UPI QR · UPI QR / multi-currency / card-terminal payments · profit, category and hourly-sales reports with GSTR-1 exports · suppliers & purchase orders · shifts and granular staff permissions · online-orders board for e-commerce orders, customer loyalty page · WooCommerce/Shopify order sync · optional self-hosted multi-branch sync · an optional desktop tray helper · a redesigned light forest-green UI.

Per-item status (what is verified and what still needs real hardware or accounts): docs/ROADMAP.md. API and MCP reference: docs/API.md. Security review: docs/SECURITY-AUDIT.md.

Features

  • Guided onboarding — first launch walks you through creating an admin account and configuring your company (name, address, currency, GST, loyalty) before you ever see the dashboard.
  • Barcode-driven POS checkout — scan to add to the cart, scan again to bump quantity, press Enter to finalize. Unknown barcodes surface a toast instead of blocking the register. No USB scanner? Scan barcodes and QR codes from a phone/tablet camera instead — see Camera barcode & QR scanning.
  • GST / tax — per-product GST rates with HSN/SAC codes. The price you enter is treated as MRP (GST-inclusive, as required by law) — GST is never added on top of it, only broken out as CGST/SGST on the receipt for compliance, alongside your GSTIN. Toggle GST on/off in settings.
  • Discounts — percentage or flat-amount discount per sale (applied correctly across mixed tax rates), and a standing per-product discount % you can set once in Inventory that applies automatically every time that product is sold.
  • Loyalty program — customers (by phone) earn points on every purchase, redeemable as a discount at checkout — enter an amount or tap Use all. Configurable earn rate and point value. The dashboard ranks your top customers by current point balance.
  • Customer dues ("udhaar") — a cash sale can be paid short of the total when a customer is attached; the shortfall becomes a running due balance you can record payments against later, with a full payment history. See Customer dues.
  • Returns & exchanges — look up any past bill by invoice number right from the register, queue one or more lines to return, and either settle it as a standalone refund or fold it straight into a new sale as an exchange. Returned stock goes back into inventory automatically, and the same unit can never be returned twice, even across several partial returns. See Returns & exchanges.
  • Store credit — a refund can be kept with the customer as store credit instead of paid out in cash, tracked as its own running balance per customer (separate from an udhaar due) and spendable on any future purchase with a tap of Use store credit at checkout. See Returns & exchanges.
  • Runs on your own hardware or in the cloud — it's a two-container Docker Compose stack, so the same docker compose up that runs it on a shop's counter machine also runs it unmodified on any VPS/cloud provider, if you'd rather manage it remotely or across locations. See Where to run it.
  • Multi-currency — over 20 major currencies (₹ INR, $ USD, € EUR, £ GBP, HK$ HKD, and more), switchable in settings; the symbol flows through the whole app and onto receipts. One source of truth on the backend (backend/src/lib/currency.js) so onboarding and settings can never drift apart.
  • Offline barcode & QR generator — no barcode on the product? Generate a valid EAN-13 in one click and print or download a label as a PNG or JPG, no internet or external barcode service involved. See Barcode & QR label generator.
  • GST/PAN validation & reference data — live format validation for GSTIN and PAN, GST state-code decoding, quick-select GST rate slabs, and a per-product unit (UQC). Optionally import the official HSN/SAC/PIN/IFSC CSVs for autocomplete and address/bank lookups. See Reference data & validation.
  • Works on phones and tablets — the nav collapses to a slide-out drawer below desktop width, so the register is fully usable from a counter tablet or phone, not just a desktop browser.
  • Customers — a directory with visit counts, total spend, and loyalty balances.
  • Customizable receipts — set a header and footer; the thermal receipt renders your branding, GST breakdown, discounts, and loyalty summary.
  • Multiple payment methods — Cash (with change calculation), UPI, Card.
  • Staff accounts & roles — an admin plus any number of cashier logins; admins manage staff and settings, cashiers run the register.
  • Sales history — searchable past invoices with a detail view and one-click receipt reprint.
  • Inventory management — scan a known barcode to edit stock; an unknown one is looked up online and opens "Add Product" pre-filled (blank if nothing is found or you're offline). Low-stock dashboard alerts. An "allow negative stock" setting lets you keep selling past zero when your counts run behind reality, instead of blocking the register.
  • Print or download receipts, three ways — "Print" opens a formatted receipt and triggers your browser's own print dialog, so you pick whichever printer the OS/CUPS has configured (thermal, laser, or "Save as PDF"); "Download PDF" always generates a real PDF file; "Print via USB" sends raw ESC/POS bytes straight to a USB thermal printer with no print dialog at all. All three are laid out to use as little paper as possible. An "auto-print" setting can skip the extra click and send the receipt to print the moment a sale completes. See printing & receipts.
  • Sales dashboard with charts — revenue trend, best seller, top-selling products, payment method mix, top loyalty customers, and today's totals and low-stock alerts — plus a one-click CSV export of your sales history.
  • Zero external calls at runtime — once built, the app never talks to anything outside your machine.

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)

Receipts print through the browser's own print dialog (a hidden iframe that calls window.print()), download as a server-generated PDF, or — via "Print via USB" — go straight to a USB thermal printer as raw ESC/POS bytes from the backend, which is the one place in this app that does need scoped hardware access (see printing & receipts).

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 means:

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

The Windows Store app runs the same two-process split natively. Docker Compose deployments use separate frontend and backend containers, each supervised and updated independently.

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
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 the Reference Data CSV imports
Deployment Docker Compose, two node:24-alpine multi-stage images

Quick start

Requires Docker and Docker Compose (bundled with current Docker Desktop/Engine).

One-click install

Use the one-command installer above to install and start NodeDR POS from PowerShell or a macOS/Linux terminal. It downloads the project and runs the included setup for you.

If you already cloned the repository and have Docker running, use install.sh to build both images, start the stack, wait for the backend to report healthy, and print the URL to open:

git clone https://github.com/Raktim94/nodedr-pos.git
cd nodedr-pos
./install.sh

Manual install

If you'd rather run each step yourself (or install.sh doesn't fit your setup), here's exactly what it does, one command at a time:

# 1. Get the code
git clone https://github.com/Raktim94/nodedr-pos.git
cd nodedr-pos

# 2. Build the backend and frontend images (multi-stage, node:24-alpine).
#    First run takes a few minutes; later runs are cached and fast.
docker compose build

# 3. Start both containers in the background. Compose automatically
#    creates the named volume declared in docker-compose.yml
#    (nodedr-pos_data) the first time this runs.
docker compose up -d

# 4. (optional) Watch the logs until you see "listening on port 4000"
#    and the Next.js server ready message.
docker compose logs -f

# 5. (later) Stop the stack without deleting your data:
docker compose down

Then open http://localhost:1994. The first launch walks you through:

  1. Admin account — your name, email, and password.
  2. Shop setup — shop name, address, currency symbol, low-stock threshold.
  3. You're dropped onto the dashboard, ready to add products and sell.

All data (the SQLite database and the auto-generated session secret) lives in the nodedr-pos_data Docker volume, not inside the containers, so it survives docker compose down, container recreation, and image rebuilds. It's only removed if you explicitly delete it (see Resetting below).

Want the web UI on a different port, or to deploy somewhere other than localhost? Copy .env.example to .env and set values there — docker compose reads it automatically. docker-compose.yml itself never needs editing, on a shop LAN box or a VPS alike.

Where to run it

Because the whole app is just the two containers in docker-compose.yml, git clone + docker compose up is all that's required to run it anywhere Docker runs — there's nothing hardcoded to a local machine.

  • On your own machine or shop LAN (recommended default). The register, the database, and the receipts never leave a box you physically control — no provider has your sales data, and there's nothing to keep paying for. This is what the rest of this README (and the Security section) assumes: HTTP is fine because only the shop's trusted network can reach it. No .env file is required for this — the built-in defaults already point at localhost.
  • On a VPS/cloud host, if you want it. The identical docker compose up also runs on any VPS (DigitalOcean, Hetzner, a Raspberry Pi you colo, etc.) if you'd rather manage one instance remotely, run it from a machine you don't keep on-site, or reach it from more than one location. Nothing about the app is tied to a specific host, and nothing in the compose file needs to change: copy .env.example to .env, set FRONTEND_ORIGIN to your public https:// URL and COOKIE_SECURE=true, then docker compose up -d. The trade-off: a VPS is reachable from the internet, not just your shop's LAN, so treat it like any other publicly reachable service — put a reverse proxy with real HTTPS in front (Caddy/Nginx + Let's Encrypt) so the session cookie is never sent over plain HTTP, and firewall the port to only the IPs that should reach it. See Security for the full list — none of it is optional once this stops being a purely local box.

If data residency or "nothing leaves the building" is the priority, the local-machine option is the more secure default and needs none of the above; the VPS option trades a bit of that isolation for remote reach.

Hardware setup

Barcode scanner

No configuration needed. Any USB barcode scanner that acts as a HID keyboard (i.e. "just types" the barcode followed by Enter — this is true of the vast majority of consumer scanners) works out of the box. The frontend's useBarcodeScanner hook listens for keystrokes and, based on inter-keystroke timing, tells scanner input apart from a human typing so it never interferes with normal form fields.

Camera barcode & QR scanning

No USB scanner? Every place a barcode can be typed or scanned — POS checkout and the Add/Edit Product form — also has a Scan with camera / Scan button that opens a live camera preview and decodes barcodes and QR codes directly in the browser (CameraScannerModal, via @zxing/browser). On a phone or tablet it requests the back camera by default, with a Flip camera button to switch to the front camera (or the device's only camera on laptops/desktops with no back/front distinction). This works alongside the USB scanner above, not instead of it — either input path feeds the exact same lookup/add-to-cart logic. The camera requests a 1080p frame (not whatever low default the browser picks), asks for continuous autofocus where the device supports it, and only scans for the barcode/QR formats this app actually uses — all aimed at the same problem: a small code held close to the lens needs enough resolution and focus to resolve, not just camera access.

Camera scanning over HTTPS

Browsers only allow camera access (getUserMedia) on a secure context: an https:// origin, or localhost on the machine itself. The default Docker setup serves the app over plain http://<lan-ip>:1994, so opening it from a phone/tablet works fine for everything except the camera — the browser blocks it silently, with no permission prompt at all, before the app ever gets a chance to ask. On the till machine itself this is a non-issue (http://localhost:1994 is always secure), the gap only shows up on a second device over the LAN.

Three ways to fix it, depending on your devices and how you want to reach the app:

  • Android / desktop Chrome or Edge — no server change needed. Visit chrome://flags/#unsafely-treat-insecure-origin-as-secure, add http://<this-machine's-LAN-IP>:1994, enable the flag, and restart the browser. Camera access then works exactly like on localhost. This is a per-browser setting — only do it for a network you trust, and you'll need to repeat it on each device.

  • iPhone/iPad, or any browser without that flag (Safari has none) — turn on the bundled HTTPS front door. A caddy service ships in docker-compose.yml behind the https Compose profile, off by default — a plain docker compose up never starts it. Turn it on with:

    docker compose --profile https up -d

    Then open https://<this-machine's-LAN-IP> (no :1994 — Caddy listens on the standard HTTPS port, 443 by default, overridable via HTTPS_PORT in .env) on the phone/tablet instead. Caddy generates a self-signed certificate automatically (via its built-in CA — see Caddyfile); every browser, including Safari on iOS, shows a one-time "this certificate isn't trusted" warning to tap/click past, and the connection is then genuinely HTTPS, so the camera works from then on. Nothing else changes — it's the same app, same login, same data, just reached through an extra reverse-proxy hop. install.sh and the plain http://<ip>:1994 path keep working unmodified whether or not you ever turn this on.

  • Any device, no cert warning at all, works from outside your LAN too — Cloudflare Tunnel. Unlike Caddy's self-signed certificate above, this gets you a real, publicly-trusted one (Cloudflare's), so there's nothing to click through on any device — at the cost of your traffic routing through Cloudflare's network instead of staying fully local. Two modes, both off by default:

    # Free, no Cloudflare account needed — URL changes every restart. Check
    # `docker compose logs cloudflared-quick` for the current one.
    docker compose --profile cloudflare-quick up -d
    
    # A stable URL on your own domain, survives restarts. Requires a
    # Cloudflare account with your domain added, a tunnel created under
    # Zero Trust > Networks > Tunnels with a Public Hostname routed to
    # http://frontend:3000, and that tunnel's token set as
    # CLOUDFLARE_TUNNEL_TOKEN in .env.
    docker compose --profile cloudflare up -d

    Whichever URL you get (*.trycloudflare.com or your own domain), open it on the phone/tablet the same way you would http://<ip>:1994 — same app, same login, same data. Recommended if you want a permanent, stable way to reach the till from staff phones/tablets without touching per-device browser settings.

Barcode & QR label generator

Not every product arrives with a scannable barcode — loose produce, house brands, or anything repackaged in-store. Rather than force a manual code, the Add Product form has a Generate button next to the barcode field: it produces a structurally valid EAN-13 using the 20–29 prefix range GS1 reserves for restricted/internal circulation — i.e. exactly this use case, not a real resellable product code (see frontend/lib/barcode.ts). It's checked against your existing catalog for uniqueness and retried automatically on collision.

From the Inventory page, the barcode icon on any row opens a label with a Barcode / QR code toggle, rendered client-side with jsbarcode / qrcode — no network call, works fully offline. From there:

  • Print label opens a new tab and calls window.print(), same as receipts — any printer your OS/CUPS knows about, including small label printers.
  • Download PNG or JPG saves the rendered code as an image — pick the format with the radio buttons above the download button.

Printing & receipts

After checkout (or from the Sales page on any past invoice), you get three buttons:

  • Print opens a formatted receipt in a hidden iframe and immediately triggers window.print(). The browser's own print dialog shows every printer your OS knows about — a USB/network thermal printer set up through CUPS (Linux/macOS) or the Windows print spooler, a regular office printer, or a "Save as PDF" virtual printer. Set up your thermal printer once at the OS level (CUPS, or the manufacturer's driver) the same way you would for any other application, and it shows up here. The backend never touches a USB device for this path.
  • Download PDF generates a real PDF file server-side (via pdfkit, a pure-JS renderer — no shell-out, no native dependencies) and downloads it, for emailing, archiving, or printing later from any device.
  • Print via USB sends the receipt straight to a USB thermal printer as raw ESC/POS commands (see backend/src/lib/escposUsb.js and escposReceipt.js) — no print dialog, no OS driver/CUPS setup, just the receipt cutting off the roll a moment after you click. This needs the one-time Docker device passthrough described below.

Turn on Settings → Receipt → Print automatically after every sale to skip the extra click on the Print button entirely — it does not trigger USB printing, since that has no print-dialog "preview" step to skip in the first place; use "Print via USB" directly when you want that.

Both the HTML and PDF layouts are kept deliberately tight, since every extra millimetre of whitespace is real, recurring thermal paper cost: the HTML receipt's CSS has no unnecessary paragraph margins, and the PDF's page height is computed per-receipt (short receipts get a short page) instead of a fixed size — a one-item receipt is roughly half the page length it used to be.

Because Print/Download PDF are just normal browser actions, the whole app — including checkout — works identically on a machine with no printer attached at all; you'd simply skip those buttons, or "Print via USB" would fail with a clear "no USB printer found" message instead of crashing anything.

Direct-USB (ESC/POS) printer setup

On a Linux till this works by default after docker compose up — the backend container ships with the USB access it needs and no vendor/product ID to configure. "Print via USB" reaches the printer over two transports, tried in order (see backend/src/lib/escposUsb.js):

  1. The kernel usblp character device (/dev/usb/lp0) — the exact path a plain echo "hi" > /dev/usb/lp0 writes to. This is the primary transport because most cheap 80mm printers present a vendor-specific USB class, which the kernel binds via usblp but a class-based scan would miss.
  2. libusb raw bulk transfer — a fallback for printers exposing the standard USB Printer class, detaching the kernel driver first so the interface can be claimed.

Check it works — one click, no sale needed: go to Settings → Receipt → USB printer check and press Check printer (reports the kernel printer nodes and any USB printer-class device the backend can see) and Print test slip (sends a real cut-off test receipt). Set USB printer paper width (80mm standard / 58mm compact) so the fixed-width layout uses the right column count. Optionally turn on Print automatically after every sale and set Auto-print using → Direct USB so every receipt prints with no dialog.

What the container needs (already in docker-compose.yml, Linux host only): the backend service bind-mounts /dev/bus/usb and grants device_cgroup_rules on two device majors — 189 (usbfs, for libusb) and 180 (usblp, the kernel printer driver). This is not privileged: true; the backend still can't touch anything else on the host, and the container runs as root only so it can open the printer node. Nothing here fails at compose up when no printer is attached.

Does it need root?

The backend container runs as root — this is required for direct-USB printing and is already configured, so you do not run anything extra. The kernel printer node /dev/usb/lp0 is owned root:lp (mode 660), and the container creates it on demand with mknod; both need root inside the container. That's why backend/Dockerfile has no USER line and docker-compose.yml sets no user: for the backend — leave it that way. If you add a non-root USER/user:, "Print via USB" stops working (it can't open or create the printer node); the browser Print button still works.

On the host you run Docker the normal way — root is only needed if your login isn't in the docker group:

# If your user is in the `docker` group (check with: id -nG | grep docker):
docker compose up -d --build

# Otherwise, run Docker with sudo:
sudo docker compose up -d --build

To print a receipt straight from the host terminal (bypassing the app, e.g. to confirm the hardware), you need root or membership of the lp group, because of that root:lp 660 node:

sudo sh -c 'printf "test\n\n\n" > /dev/usb/lp0'   # one-off, as root
# ...or grant your user ongoing access and skip sudo next time:
sudo usermod -aG lp "$USER"      # then log out and back in
printf "test\n\n\n" > /dev/usb/lp0

Windows / macOS: the USB passthrough above is Linux-only. On a Windows till, install the printer's own driver and use the Print button (the browser print dialog) — set Auto-print using → Browser print dialog. If docker compose up errors on the /dev/bus/usb mount on Docker Desktop, delete the volumes: /dev/bus/usb line and the device_cgroup_rules: block from the backend service — only direct-USB printing needs them.

Printer compatibility

Any USB thermal receipt printer that speaks ESC/POS works — the overwhelming majority of 58mm and 80mm receipt printers, cheap to premium:

  • Budget / generic 58mm & 80mm: Xprinter (XP-58, XP-80, XP-T80), Everycom (EC-58, EC-80), Rugtek, Retsol, TVS RP series, Pos-80 / POS-5890 clones, Black Copper, Hoin, Gprinter — the typical ₹1,500–5,000 counter printers.
  • Mid / premium: Epson TM-T82 / TM-T88, Bixolon SRP series, Citizen CT-S series, TVS RP 3200/3230.

Not supported by the USB path: non-ESC/POS label printers (e.g. Zebra ZPL), and Bluetooth/Wi-Fi-only printers. For those, or on Windows, use Print (browser dialog) or Download PDF instead — those work with any printer your OS knows about.

Troubleshooting (Linux till)

Run these on the host (the machine with the printer), not inside Docker:

lsusb                          # confirm the printer is listed at all
ls -l /dev/usb/lp0             # confirm the kernel bound it (usblp)
dmesg | grep -i usblp          # what happened when you plugged it in
echo -e "TEST\n\n\n" > /dev/usb/lp0   # print directly, bypassing the app
  • If echo > /dev/usb/lp0 prints but the app's Print via USB doesn't, re-run Check printer in Settings — and make sure the backend container has the /dev/bus/usb mount and device_cgroup_rules (see above).
  • Permission denied writing to /dev/usb/lp0: add your user to the lp group — sudo usermod -aG lp $USER, then log out and back in.
  • "Printer is busy": another program or the OS/CUPS print queue is holding the device. Close it (or remove the printer from CUPS) and retry — the raw USB path wants exclusive access.
  • Nothing detected: try a different USB cable/port; some printers only bind usblp when powered on before being plugged in.

One real limitation worth knowing: generic ESC/POS printers default to a single-byte codepage (usually CP437), not UTF-8. The USB print path transliterates accented Latin letters (café → cafe) and falls back non-ASCII currency symbols to the 3-letter currency code (€ → EUR), but non-Latin scripts (Devanagari, Arabic, etc.) print as ? — the Print and Download PDF buttons remain the correct choice whenever exact non-Latin text on the receipt matters, since both render full Unicode.

Receipt layout

Both the print view and the PDF share the same data: shop header/footer, GST/CGST/SGST breakdown, discount, and loyalty summary, driven by your settings. The HTML template lives in backend/src/lib/receipt.js (buildReceiptHtml) and the PDF renderer in backend/src/lib/pdf.js (buildReceiptPdf).

================================================
                  Raktim Store
                   1 MG Road
                    Pune, MH
             GSTIN: 27ABCDE1234F1Z5
================================================
Date: 11-07-2026 18:06     Bill: #INV-2026-00002
Cust: Rahul
Ph:   9990001111
------------------------------------------------
Item                      Qty     Rate    Amount
------------------------------------------------
Biscuits                    1   118.00    118.00
  GST @ 18%
------------------------------------------------
Subtotal                              Rs. 118.00
CGST (incl.)                            Rs. 9.00
SGST (incl.)                            Rs. 9.00
Loyalty (100 pts)                     Rs. -10.00
================================================
GRAND TOTAL                           Rs. 108.00
================================================
Paid (UPI)                            Rs. 108.00
------------------------------------------------
         You earned 108 loyalty points!
================================================
            Thank You! Visit Again.
================================================

The "Rate" you enter on a product is its MRP — GST-inclusive, as required by law — so CGST/SGST are a breakup of tax already inside that price, not an addition to it: Subtotal is the MRP total, and GRAND TOTAL is Subtotal minus discounts, full stop.

The header, footer, currency, GSTIN, and whether the GST breakdown shows are all driven by your settings, so this layout adapts to how you configure the shop.

Customer display (phone, tablet or second screen)

The customer display shows the cart and total, and the UPI payment QR for the exact amount (INR shops with a UPI id set). It works on any device on the same network — no cables, no app.

  1. On the POS page press Customer display.
  2. Open the link it shows on the phone/tablet/second PC, or scan its QR code. The link looks like http://<machine>:1994/display?key=<secret> — for example http://192.168.1.40:1994/display?key=….
  3. That's it. The display follows the till automatically (about once a second) and reconnects by itself if Wi-Fi drops. The POS shows how many displays are connected.

Notes: the key is a secret generated on first use and stored in data/display-key — anyone with the link can watch the cart, so keep it to your own screens. Opening /display without a key only mirrors a window in the same browser as the till. Use the browser's fullscreen / "Add to Home screen" on a tablet for a kiosk look. Completing a sale also plays a chime (and speaks the amount for UPI/card) on the till; toggle it with Payment sound on the POS page.

Reference data & validation

Built-in (small, stable lists)

frontend/lib/masters.ts bundles small, stable reference data used across the app's forms:

Data Used for
GST_RATES Quick-select chips (0/5/12/18/28%) on the product GST rate field
GST_STATE_CODES Decoding a GSTIN's first two digits to a state name in Settings
UQC_UNITS The Unit dropdown on each product (KGS, PCS, BOX, …) — see below
BUSINESS_TYPES Common Indian business registration types
GENERIC_PRODUCT_CATEGORIES Datalist suggestions on the product Category field (plus whatever categories are already in your catalog)
isValidGstinFormat / isValidPanFormat Live, non-blocking format hints on the GSTIN/PAN fields in Settings

These are structural/format checks only (regex, not the GSTIN checksum digit algorithm) — deliberately advisory rather than hard validation, so a real GSTIN/PAN is never rejected by a subtly wrong client-side rule.

Bulk data (admin-imported CSV)

Full HSN/SAC code catalogs (tens of thousands of entries), PIN codes, and IFSC codes are deliberately not bundled — these are large, frequently- revised government/RBI datasets, and shipping a fixed snapshot in an offline app would go stale and risks being silently wrong on real tax filings or bank transfers.

Instead, Settings → Reference Data (admin only) lets you import a CSV of the current official file yourself, at any time:

Dataset CSV columns Official source
HSN codes code, description, gstRate (rate optional) CBIC HSN directory
SAC codes code, description, gstRate (rate optional) CBIC SAC directory
PIN codes pincode, area, district, state India Post / data.gov.in
IFSC codes ifsc, bank, branch, address, district, state RBI IFSC directory

Each import replaces the existing rows for that dataset — re-importing an updated file is always safe, there's no incremental merge to reason about. Once loaded:

  • The product HSN/SAC field autocompletes against the imported tax codes.
  • Company settings gets a PIN-code lookup that autofills city/state.
  • Reference Data also has a standalone IFSC search box, for looking up a bank/branch by code without it being tied to any other field.

Nothing here is required — the app works fully without ever visiting this tab; it only adds autocomplete/autofill convenience once you've loaded data.

Product units (UQC)

Each product has an optional Unit (e.g. KGS, PCS, BOX, from the standard GST Unit Quantity Codes). It's snapshotted onto each invoice line at sale time — same pattern as name/taxRate — so it shows on receipts ("3 KGS") and doesn't change retroactively if you edit the product later.

Customer dues ("udhaar")

A cash sale can be finalized with Amount received less than the total whenever a customer (with a phone number) is attached — the shortfall is added to that customer's running due balance instead of blocking the sale. The POS warns you before checkout if a looked-up customer already has an outstanding due, and again if the current sale will add to it.

Record a payment against a due from the Customers page — click the due amount next to any customer to open a small form; it's capped at their current balance and kept as its own history (CustomerDuePayment) rather than just decrementing a number, so there's an audit trail of who paid what and when.

Returns & exchanges

The Return / Exchange panel on the POS screen lets a cashier look up any past bill by invoice number, then choose which lines — and what quantity of each — to return. There's no separate "returns" page: it's part of the same checkout screen as a new sale, so a customer swapping one item for another is a single transaction, not two.

  • Standalone return. Queue lines from a past bill with nothing else in the cart and finalize — the returned quantity restocks immediately, and the refund pays out as Cash, UPI, Card, or is kept as the customer's store credit (a running creditBalance, separate from an udhaar/due balance) for them to spend on a future purchase.
  • Exchange. Queue a return and ring up new items in the same checkout. The return's value is netted against the new sale total before anything else: if the new items cost more, the customer pays the difference as usual; if the return is worth more, the leftover pays down any outstanding due first, then refunds as cash/UPI/card or store credit for whatever's still left over. A return netted this way is recorded with refundMethod: "BILL_OFFSET" rather than one of the payment methods above, since no money actually changed hands for that portion.
  • Partial & repeat returns are safe. How much of a line is still returnable is computed by summing every prior ReturnItem against that invoice line (not a running counter), so returning 1 of 3 units today and the remaining 2 next week works correctly, and a unit that's already been returned can never be returned again.
  • Refund amount is capped, not just quantity. The refund per line defaults to what was actually charged for that quantity (accounting for any per-line discount already applied), and the cashier can lower it — but never raise it above what the customer paid.

See backend/src/routes/returns.js for the standalone flow and the returns handling inside backend/src/routes/invoices.js for how an exchange nets against a new sale; both share the same Return / ReturnItem tables so a bill's full return history is queryable either way.

Updating

To pull the latest code and redeploy:

# 1. Get the latest commits. Run this from inside your nodedr-pos
#    directory — if you're not already there, cd into it first:
#    cd nodedr-pos
git pull

# 2. Rebuild the images and recreate the containers with the new code.
#    Re-running install.sh does exactly this too.
docker compose up -d --build

Your data is safe across updates — the SQLite database and session secret live in the nodedr-pos_data Docker volume, entirely separate from the container filesystem, so rebuilding or recreating containers never touches them. Run docker volume ls to see it.

Backing up your data

The database lives inside a Docker-managed volume rather than a plain host folder, so back it up via a throwaway container that mounts the volume read-only and copies the file out:

docker run --rm -v nodedr-pos_data:/data:ro -v "$PWD":/backup alpine \
  cp /data/pos.db /backup/pos-backup-$(date +%Y%m%d).db

That drops a timestamped copy of pos.db in your current directory on the host.

Resetting / clearing data

To wipe everything (admin account, shop settings, products, invoices) and go through onboarding again — useful after testing, or to start a real shop from a clean slate:

# 1. Stop the stack AND remove the named volume (the -v is what deletes
#    the database and session secret; without it, `down` only removes
#    the containers and your data is untouched).
docker compose down -v

# 2. Start back up — a fresh volume is created automatically and
#    you'll land on the onboarding wizard again.
docker compose up -d

To remove the volume without also touching the containers: docker volume rm nodedr-pos_data (stack must be stopped first).

If you only want to clear the catalog and sales history but keep your admin login and shop settings, don't delete the files — instead delete products/invoices from inside the app (Inventory page), since there's no current admin-account-preserving "factory reset" endpoint.

Project structure

nodedr-pos/
├── docker-compose.yml         # declares the nodedr-pos_data named volume
├── docs/screenshots/          # README images
├── casaos/                    # CasaOS/ZimaOS app store manifest + assets — see casaos/README.md
├── packaging/                 # optional desktop tray helper
├── backend/
│   ├── Dockerfile
│   ├── prisma/schema.prisma  # User, ShopSettings, Product, Invoice, InvoiceItem, Return
│   └── src/
│       ├── server.js
│       ├── routes/           # auth, settings, products, invoices, returns, print
│       ├── middleware/auth.js
│       └── lib/              # prisma client, JWT secret, currencies, receipt HTML + PDF rendering
└── frontend/
    ├── Dockerfile
    ├── next.config.ts        # /api → backend proxy (rewrites)
    ├── app/
    │   ├── onboarding/, login/                         # unauthenticated flows
    │   └── (app)/dashboard, pos, inventory, customers, sales, settings
    ├── components/           # AppShell, GlobalSearch, CameraScannerModal, ProductModal, BarcodeLabelModal, ReturnPanel, SalesCharts, ReceiptActions, ui/* (incl. Sparkline)
    ├── lib/                  # api client, format helpers, barcode.ts, masters.ts (reference data)
    └── hooks/                # useBarcodeScanner, useProducts, useCustomers, useInvoices, useReturns, useAuth, useShopSettings, useSyncStatus

Local development (without Docker)

Run the backend and frontend in two terminals.

# Terminal 1 — backend on :4000
cd backend
cp .env.example .env
npm install
npm run prisma:migrate:dev
npm run dev

# Terminal 2 — frontend on :1994 (proxies /api to the backend)
cd frontend
npm install
BACKEND_URL=http://localhost:4000 npm run dev

Open http://localhost:1994. The browser only talks to :1994; the Next.js dev server proxies /api/* to BACKEND_URL (default http://localhost:4000). No API URL is ever baked into the browser bundle.

API overview

All endpoints are under /api and reached through the frontend proxy at http://<machine>:1994/api. Except /auth/status, /auth/login, /auth/register, and the one-time POST /settings, every route requires the nodedr_session cookie; admin-only routes also require the admin role.

Method & path Purpose
GET /auth/status Whether an admin account exists yet
POST /auth/register Onboarding — create the first admin (once)
POST /auth/login / /logout Session management
POST /auth/change-password Change your own password
GET/POST/PUT /auth/users Staff account management (admin only)
GET/POST/PUT /settings Company/currency/GST/loyalty/receipt config (PUT is admin only)
GET/POST/PUT/DELETE /products Catalog CRUD, +/barcode/:code, /low-stock
GET/POST/PUT /customers Customer directory, +/phone/:phone lookup, +/top-loyalty ranking
POST /customers/:id/settle-due, GET /customers/:id/due-payments Record / list payments against a customer's due balance
POST /invoices Finalize a sale — server computes price, tax, discount, loyalty, due; decrements stock; all transactional. Can also carry returns/creditApplied, netting an exchange against the same bill
GET /invoices, /invoices/summary, /invoices/:id Sales history & dashboard totals
POST /returns Standalone return against a past invoice — restocks quantity, refunds cash/UPI/card or store credit
GET /returns/by-invoice/:invoiceId Returns already made against an invoice, for computing remaining returnable quantity
GET /invoices/analytics Dashboard chart data — revenue trend, top products, payment mix
GET /invoices/export.csv Downloads sales history as CSV (optional ?from=&to= range)
GET /print/:invoiceId/receipt Self-printing HTML receipt (opens in a new tab, calls window.print())
GET /print/:invoiceId/pdf Downloads the receipt as a PDF file
GET /masters/summary Row counts for the Reference Data settings tab
POST /masters/tax-codes/import, GET /masters/tax-codes/search Import (admin only) / autocomplete HSN or SAC codes
POST /masters/pincodes/import, GET /masters/pincodes/:code Import (admin only) / look up a PIN code
POST /masters/ifsc/import, GET /masters/ifsc/:code Import (admin only) / look up an IFSC code
GET/POST/PUT/DELETE /api-keys Manage External Stock API credentials (admin only, step-up password confirm) — see below

External Stock API (e-commerce integration)

A separate, key-authenticated API — not the session-cookie auth every other route above uses — for an external system (typically an e-commerce storefront) to read this shop's live stock and, if you allow it, report its own sales back so stock stays correct in both places without manual reconciliation.

Setting it up

  1. In the app, go to Settings → Integrations → New API key.
  2. Give it a name, decide whether it can only read stock or also write it (write access lets the connected system decrement stock when it sells something), and optionally a https:// webhook URL on the other system that should be notified whenever a linked product's stock changes here.
  3. Copy the API key and (if you set a webhook URL) the webhook secret shown — both are shown exactly once and can't be retrieved again. Store them as secrets in the other system, not in its repo.
  4. On each product you want to expose (Inventory → edit product), set its SKU to the same value the other system uses to identify that product. A product with no SKU is never returned by this API.

Endpoints

All under /api/external, reached the same way as everything else — through the frontend proxy (https://<your-domain>/api/external/...), so whatever HTTPS front door you've set up (see Where to run it) is what secures this too; don't expose the backend's own port directly. Authenticate with Authorization: Bearer <api key>.

Method & path Access Purpose
GET /external/products products:read Every product with a SKU linked (paginated, updatedSince for incremental sync): { sku, barcode, name, stock, available, unit, sellingPrice, taxRate, … }
GET /external/products/:sku products:read Same shape, single product
POST /external/bills, GET /external/bills/:ref[/pdf], POST /external/orders, GET /external/warranty/:serial, POST /mcp scoped Take bills, PDFs, click-and-collect orders, warranty, MCP — see docs/API.md
PATCH /external/products/:sku/stock stock:write Adjust stock — body is { "delta": -2 } (relative) or { "set": 17 } (absolute), never both. Rejects going negative unless Allow selling below zero stock is on in Settings, same rule a POS checkout follows. Add "idempotencyKey": "<your event id>" to make a retried call safe to resend — a repeated key returns the original result instead of applying the change twice (kept in memory for 24h)

Webhook (stock pushed to you)

If a key has a webhook URL, this shop POSTs it whenever a linked product's stock changes here — a POS sale, a return, or a manual stock edit:

POST <your webhook URL>
X-Nodedr-Signature: sha256=<hex>
Content-Type: application/json

{
  "event": "stock.updated",
  "timestamp": "2026-08-26T12:00:00.000Z",
  "changes": [{ "sku": "MUG-001", "stock": 14 }]
}

Verify the signature before trusting the body: it's an HMAC-SHA256 of the raw request bytes using that key's own webhook secret (from step 3 above), hex-encoded. Delivery is best-effort with an 8s timeout and is never retried automatically by this app — treat "set stock to N for this SKU" as idempotent on your side (safe to apply even if a delivery is ever repeated or arrives out of order) rather than relying on exactly-once delivery.

Design notes

  • Keys are hashed (SHA-256) at rest — the plaintext key and webhook secret are never stored and never shown again after creation.
  • A write key can only ever decrease this shop's uncertainty by reporting real sales — it can't read or touch anything outside /api/external (no customer data, no settings, no other product fields).
  • Revoking a key is immediate and permanent; issue a new one rather than expecting a revoked key to come back.
  • This surface has its own, stricter rate limit (120 req/min per IP) on top of the app-wide one, since — unlike the rest of this app — it's meant to be reachable from outside your shop's LAN.

Security

Security posture (verified end-to-end):

  • Passwords: bcrypt (cost 12); login is timing-uniform and returns an identical error for unknown-user vs wrong-password. Login is rate-limited (10 / 15 min) on top of a global limiter (300 req/min).
  • Sessions: HttpOnly, SameSite=Lax JWT cookie (HS256, algorithm pinned). Every request re-checks the account still exists and is active, so disabling a staff member logs them out immediately. Set COOKIE_SECURE=true when serving over HTTPS.
  • Authorization: all data routes require auth; settings and staff management require the admin role. The last active admin can't be demoted or disabled.
  • Server-authoritative money: prices, tax, discount caps, loyalty value, and change are always computed server-side from the catalog and settings — the client only sends product ids, quantities, and intent, so a tampered request can't alter what a sale charges. Redeemable points are capped at the customer's balance.
  • Input validation: every write endpoint validates with Zod (allowlisted fields — no mass assignment); all DB access is parameterized via Prisma. CSV imports are admin-only, capped at 25MB, and parsed in-memory (no temp files, no shell-out).
  • Reduced surface: the backend is not published to the host — only the frontend (:1994) is reachable, and it proxies to the backend privately. Helmet sets X-Content-Type-Options, X-Frame-Options, etc. Neither container runs privileged: true or needs host device access — printing goes through the browser's own print dialog, not a driver bundled into the app.
  • Secrets: the JWT signing secret is auto-generated on first boot and stored (mode 600) in the data volume — never in the repo.

Designed for a trusted local network (a shop's LAN or a single machine). It's HTTP by default; if you expose it beyond the counter, terminate HTTPS in front of it and set COOKIE_SECURE=true in .env (see Where to run it) — no other file needs to change.

Contributing

Issues and PRs are welcome — see CONTRIBUTING.md for dev setup, the conventions this codebase leans on (server-authoritative money, ESC/POS printer quirks, barcode-scanner HID handling), and the PR/issue process. This is a small, focused tool — please keep contributions aligned with "offline-first single-shop POS" rather than expanding scope into multi-tenant/cloud territory. This project follows the Contributor Covenant.

License

Copyright © 2026 Nodedr Infotech Private Limited and Raktim Ranjit. Licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). In short: you're free to self-host, use, and modify this software for your shop. If you modify it and run that modified version as a network service for others, you must make your modified source available to those users under the same license — this keeps improvements to a self-hosted business tool in the open rather than disappearing into a closed commercial fork.

See MAINTAINERS.md for project maintainers.

Case study

Read the NodeDR POS case study for the product background and design decisions.

Maintainer: Raktim Ranjit

About

Offline-first POS and inventory management system — Docker, Next.js, Express, Prisma, ESC/POS thermal printing

Topics

Resources

Code of conduct

Contributing

Stars

5 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages