Taqnix POS Terminal documentation
A guide to Taqnix POS Terminal: installing and running the app, preparing Odoo, connecting a terminal, selling, payments and receipts, working offline, orders and sync, restaurant tables and kitchen tickets, the Pro screens, closing a session, settings, themes, the tax engine, architecture, security, accuracy and limits.
Documents version 1.0.0Updated Oct 30, 2026
Start here
pos.order.sync_from_ui. There is no Odoo module to install: the terminal is a separate web application that you run on your own server and that talks to your Odoo. It comes in English and Arabic, ready to run (you only need Node.js) and as complete source code, for one company and an unlimited number of terminals.It is made for cafés, shops and restaurants that run their sales on Odoo 19, and for integrators who set it up for them (one purchase for each client company). Chapters 1 to 7 get it running, 8 to 21 are daily use, 22 to 24 are for developers, and 25 to 30 cover security, what was and was not verified, troubleshooting, updates and support.
The pictures come from a demo shop, Harbor Café & Market (US dollars, Dearborn MI) with two points of sale. Main Shop is retail and café: prices shown with tax, cash rounded to 0.05, the loyalty programme Harbor Rewards and the promo code SUMMER10. Harbor Restaurant has the floors Ground floor and Terrace, a Kitchen printer and a Bar printer, and shows prices without tax. The cashier is Sara Haddad, a plain Point of Sale user. The Odoo address on the pictures is a local test server: you type the address of your own Odoo.
Odoo is a trademark of Odoo S.A. This product is not made or endorsed by Odoo S.A.
What you get
| Feature | What it does | Chapter |
|---|---|---|
| Setup and connection test | Server, database, login and API key; a report on the version, the user's rights and the apps found. | 6 |
| Point of sale and opening cash | Choose the point of sale, count the opening cash, open the session. | 7 |
| Sell screen, variants, combos | Catalogue, search, barcode scanner; order panel with numpad, discount, note, pricelist; options and combos. | 8, 9 |
| Customers | Search, create (also offline when the Odoo user may); pricelist and fiscal position follow the customer. | 10 |
| Payment and receipts | Several payments, change, cash rounding, customer account, invoice flag; 58 or 80 mm receipts with a barcode and a QR code. | 11 |
| Offline work, Orders, Sync | Selling goes on without a network; orders wait on the device and are sent when Odoo answers. Reprint, refund, retry, export. | 12 to 14 |
| Restaurant | Floor plan, table orders, kitchen tickets, split, transfer, merge, bill with tip. Only with Odoo's Restaurant app and a restaurant point of sale. | 15 |
| Pro screens | Dashboard, Stock lookup, Layaway, Loyalty and promo codes. Included in the price; each has a switch in Settings. | 16 to 19 |
| Close the session, Settings | Count the cash, close, print the report. Themes, brand colour, language, PIN lock, manager PIN. | 20, 21 |
| Ready-to-run web app | The terminal already built, with a launcher for Windows, macOS and Linux: unzip it, start it, open the address. Only Node.js is needed. | 4 |
| Source code and tools | The complete Next.js source, the tests (3,336 in the standard run), a Dockerfile and an nginx example. | 22 to 24 |
Built with Next.js 16, React 19, TypeScript, Tailwind CSS 4, IndexedDB through Dexie, Zustand and Zod.
What you need
| Item | Details |
|---|---|
| Odoo | Odoo 19 Community with Point of Sale. Enterprise was not tested. Setup warns about an older server and does not refuse it; other versions were not tested. |
| Optional Odoo apps | Restaurant (pos_restaurant), Loyalty (pos_loyalty), Discount (pos_discount, whole-order discount), Inventory (stock, stock lookup). A part whose app is missing does not appear. |
| The server of the terminal | Node.js 20.9 or newer (all the ready-to-run app needs), or Docker (the image uses Node 24 and is built from the source). It serves the app and forwards calls to Odoo. |
| HTTPS | The terminal installs as an app and works offline only from an https:// address or http://localhost (chapter 4). |
| The till | A tablet or PC with Chrome or Edge (service workers, IndexedDB, Web Locks). Tablet layouts (1024 × 768, 768 × 1024) are shown in this guide. |
| An Odoo user | One for each terminal or cashier: Internal User plus Point of Sale / User, with an API key (chapter 5). |
| Printer, scanner | Optional. Printing goes through the browser. A scanner must act like a keyboard. Neither was verified on real hardware (chapter 27). |
Install and run the app
Option 1: the ready-to-run web app. Nothing to install or build.
- Install Node.js 20.9 or newer from nodejs.org (
node -vshows the version you have). - Unzip the package, then unzip
taqnix-pos-terminal-webapp.zipin it. The foldertaqnix-pos-terminal-webappis the finished application. - Open a terminal (Command Prompt on Windows) in that folder and start it with the address of your Odoo:
sh start.sh https://erp.example.comon macOS and Linux,start.bat https://erp.example.comon Windows. - Open
http://localhost:3000in Chrome or Edge and connect the terminal to Odoo (chapter 6). Stop it with Ctrl+C.
# macOS and Linux, in the folder taqnix-pos-terminal-webapp sh start.sh https://erp.example.com # Windows (Command Prompt, or double-click start.bat) start.bat https://erp.example.com # another port; every option sh start.sh https://erp.example.com --port 3100 sh start.sh --help # the same without the launcher script node run.cjs https://erp.example.com
The launcher checks the Node.js version, adds the Odoo address you type to POS_ALLOWED_ORIGINS, reads .env (copy .env.example to .env to keep your settings) and says so when the port is taken. The folder has its own README.txt too.
It listens only on this computer (127.0.0.1) unless you say otherwise. That is enough to try it, and for a till on the same computer: http://localhost counts as a secure address, so the app installs and works offline. For tills on other devices start it with --host 0.0.0.0 (or POS_HOST=0.0.0.0) and put HTTPS in front of it (below). To have it start with the computer and restart after a crash, run node run.cjs with a process manager you already use (systemd, pm2, Windows Task Scheduler).
Option 2: build it from the source code. Use it when you change the app or want to build it on your own server.
- Unzip the package, then unzip
taqnix-pos-terminal.zipand open a terminal insrc/pos-terminal(the folder withpackage.json). - Run
npm ci, then copy.env.exampleto.envand setPOS_ALLOWED_ORIGINSto the address of your Odoo. - Run
npm run build(it also writes the service worker and the list of licences), thennpm start. The server listens on port 3000; usenpm start -- -p 3100to change it. - Put a TLS front in front of the port (below), then open the HTTPS address in Chrome or Edge on the till.
npm ci npm run dev # development server on http://localhost:3000 (permissive proxy defaults) npm run build # production build + service worker (scripts/make-sw.mjs) + licences file npm start # production server (NODE_ENV=production), port 3000 (-p to change) POS_STANDALONE=1 npm run build # .next/standalone, used by the Dockerfile npm run pack:webapp # after that build: a ready-to-run folder in webapp-dist/, like the one in the package docker compose up -d # build + run with the settings of .env (copy .env.example)
The server reads its settings from environment variables, in .env or in the environment of the process:
POS_ALLOWED_ORIGINS=https://erp.example.com # POS_ALLOW_ANY_ORIGIN=1 # POS_ALLOW_PRIVATE=1 # POS_CONNECT_SRC=https://erp.example.com # POS_TRUST_PROXY=1 # POS_DISABLE_JSONRPC=1 # POS_HOST=127.0.0.1 # PORT=3000
| Variable | Meaning |
|---|---|
POS_ALLOWED_ORIGINS | Comma list of the Odoo origins the /api/odoo proxy may reach. In production nothing else is allowed. |
POS_ALLOW_ANY_ORIGIN=1 | Accept any Odoo address typed in Setup. Only for a trusted LAN or development: on a reachable server it makes the proxy a relay to any host. |
POS_ALLOW_PRIVATE=1 | Allow private addresses (10.x, 192.168.x, 127.0.0.1) for origins not listed. Link-local and cloud-metadata addresses are always refused. |
POS_TRUST_PROXY=1 | A reverse proxy sets X-Forwarded-For, -Host and -Proto. Without it these headers are ignored. |
POS_DISABLE_JSONRPC=1 | Refuse /jsonrpc: only API keys (JSON-2), never Odoo passwords. Recommended on Odoo 19. |
POS_CONNECT_SRC | Extra origins in the Content-Security-Policy, only for the direct connection mode. It is read when the headers are built: set it for npm run build and for the start. |
POS_PROXY_LOG=1 | Log the method, path and status of proxied calls, never bodies or keys. |
POS_HOST, PORT | Only for the ready-to-run app: the address (default 127.0.0.1, this computer only) and the port (default 3000) it listens on. 0.0.0.0 opens it to the network: only behind HTTPS. |
Development mode (NODE_ENV not production) allows any origin: never use it on a reachable server.
Docker. Copy .env.example to .env and run docker compose up -d. The Dockerfile (Node 24, Next.js standalone output, non-root user, health check) and the compose file are provided; the image was not built and tested here.
# docker-compose.yml
services:
pos:
build: .
restart: unless-stopped
ports:
- "3000:3000"
env_file: .env
# without compose (header of the Dockerfile)
docker build -t taqnix-pos . && docker run -p 3000:3000 --env-file .env taqnix-posHTTPS. deploy/nginx.conf.example is a TLS front; run the app with POS_TRUST_PROXY=1 behind it. It sets client_max_body_size 26m (the proxy accepts 25 MB), a 70 second read timeout (the proxy waits 60 seconds for Odoo) and never lets /sw.js be cached, or updates would never arrive. On the internet add a VPN, single sign-on or an IP allow-list (chapter 25).
# TLS front for the terminal (a PWA can only be installed from https:// or http://localhost).
# Run the app with POS_TRUST_PROXY=1 behind this proxy (it writes the X-Forwarded-* headers below).
# Internet-facing? /api/odoo has no login of its own: restrict who reaches it (VPN, SSO / identity-aware
# proxy, or an IP allow-list like the commented `allow`/`deny` lines below).
server {
listen 443 ssl http2;
server_name pos.example.com;
ssl_certificate /etc/letsencrypt/live/pos.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/pos.example.com/privkey.pem;
client_max_body_size 26m; # the proxy accepts up to 25 MB (orders with many lines, images)
# allow 203.0.113.0/24; # the shop's public address(es)
# deny all;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
# overwrite (never append) what the client sent: the app trusts these only with POS_TRUST_PROXY=1
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 70s; # the proxy gives Odoo 60 s
}
# The service worker must never be cached by a CDN or by nginx
location = /sw.js {
proxy_pass http://127.0.0.1:3000;
add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}
}On each till. Open the HTTPS address in Chrome or Edge once while online: the browser stores the whole app (a service worker), and from then on the terminal starts without a network. Install it with the install button of the browser. The terminal also offers it in a banner (Install, Dismiss), and Settings → About has an Install app button.
Prepare Odoo
- Create the user (Settings → Users): Internal User with the access right Point of Sale / User, and nothing more unless you need a right below.
- Log in as that user, open the preferences, Account Security, press New API Key, name it after the terminal and copy the key at once: Odoo shows it only one time.
- Check the point of sale: its payment methods (one cash method), cash control if you want the cash counted, and the invoice journal if cashiers will use the Invoice switch.
- Check the taxes and fiscal positions (below), then connect the terminal (chapter 6).
A plain Point of Sale user cannot do a few things in Odoo 19. The Setup test lists what the user lacks, and the terminal works without these rights.
| Right in Odoo | What it unlocks |
|---|---|
| Contacts / Creation | Create and edit customers. Without it the New customer form says so and Odoo refuses the customer. |
| Products / Create | Save a free-text option value (Other (specify)). Without it that order waits in the Sync screen. |
| Accounting / Invoicing | Record cash in and out. This terminal has no button for it: do it in Odoo. |
| Accounting read rights | Odoo's full Z report (Point of Sale → Reporting → Sales Details). A manager prints it in Odoo. |
| Inventory / User | Sell products tracked by lot or serial number. Without it those orders wait in the Sync screen with “missing group: Inventory/User”. |
| Point of Sale / Administrator | Close a session stuck in closing control (Point of Sale → Sessions → Close Session & Post Entries). Not needed daily. |
Restaurant. Floors, tables, the kitchen and bar printers with their product categories, the tip product and the service presets (Dine In, Takeout, Delivery) are set in Odoo (Point of Sale → Configuration). The terminal shows them but does not edit the floor plan.
Connect the terminal
| Field | What to type |
|---|---|
| Server URL | The address you open Odoo with, for example https://erp.example.com, no page path. Without a scheme the terminal tries https, and for a server in your own network (localhost, private address, single name, .local, .lan) then http, and says so. An address typed with https:// is never changed to http. |
| Database | The name. Find databases lists them when the server publishes the list. |
| Login, Password or API key | An API key of a user with only the Point of Sale group is recommended. An API key pasted with a space or line break around it is trimmed. |
| Advanced → Connection | Through this app (proxy) is recommended and works with any Odoo. Direct to Odoo only when your Odoo's reverse proxy allows this app (CORS); then set POS_CONNECT_SRC. |
| Advanced → Protocol | Automatic uses JSON-2 when the server offers it, otherwise JSON-RPC. JSON-2 needs an API key; with a password the terminal uses JSON-RPC, deprecated by Odoo 19 and announced for removal in Odoo 20. |
| Request timeout (seconds) | How long to wait for Odoo before working offline: 5 to 120, default 30. |
| Lock with a PIN | 6 to 12 digits. The key is then stored encrypted and the PIN is asked at start (chapter 25). |
Test connection changes nothing in Odoo. It checks the server, database, login and the right to use the Point of Sale, counts the points of sale, reads the installed apps and asks what the user may do. Save and continue works only after a good test (it runs the test for you if you did not).
The four notes are information, not errors: they list rights the user lacks (chapter 5).
| Message | Cause and fix |
|---|---|
| Cannot connect to {host}. | Wrong host or port, Odoo stopped, no HTTPS certificate or, in direct mode, no CORS. Check host and port; the report offers the http address when there is no certificate. |
| This terminal cannot reach its own server (the app that forwards to Odoo). Odoo itself may be fine. | The Node.js app is stopped or unreachable. Start it and test again. |
| This address does not look like an Odoo server. / This server is not Odoo 19. | Another site answers: use the address of Odoo itself. The second is a warning only; other versions were not tested. |
| This database does not exist on the server. / The login or the password / API key is not accepted. | Press Find databases; check the login and paste the key again (Preferences → Account Security). |
| Too many failed logins from this network. Odoo waits a minute … | Wait one minute and test again. |
| This user cannot use the Point of Sale. / There is no point of sale this user can open. | Give Point of Sale / User, or create a point of sale. |
| This app refused to forward to that server. | Add the server to POS_ALLOWED_ORIGINS and restart. |
| This terminal only accepts API keys: password logins are turned off on this server. | POS_DISABLE_JSONRPC=1 is set. Use an API key. |
Choose the point of sale and open the session
Each card shows the name, Retail or Restaurant, Cash control, and the state of the session. The button says Open, or Continue when a session is already open; it is disabled while a session is being closed. Change server signs out of this server, and the terminal refuses to switch server while orders are unsent.
With cash control you use Count bills and coins (the values come from Odoo; on the picture 4 × 0.05, 5 × 0.10, 8 × 0.25, 4 × 0.50, 10 × 1.00, 6 × 5.00, 5 × 10.00 and 3 × 20.00 make 154.70) or Enter the amount, add an Opening note and press Open session. Without cash control only the note is asked.
| Situation | What happens |
|---|---|
| First opening on a device | Odoo gives the device an identifier (Settings → Terminal identity). Open the first session while online. |
| The terminal starts offline | It continues the session on the device, with no dialog, and checks Odoo in the background. |
| The session was closed in the back office | A red banner: “This session was closed on another device. Open a new one.” with Open a new session. The terminal notices within about a minute; waiting orders go to the new session. |
| The session is in closing control | A red banner: a manager must finish the closing in Odoo (chapter 20). |
The Sell screen
The frame. The rail has Sell, Tables (restaurant only), Orders, Customers, Dashboard, Stock, Sync and Settings. The top bar shows the session chip (a menu with Sync and Close session), the connection chip (Online, Syncing n orders, n sync errors, Offline, Offline · n orders waiting; it opens the Sync screen), the cashier, the keyboard-shortcuts button and, with a lock PIN, Lock the terminal.
The catalogue. The search box (Search by name, reference or barcode) ignores capitals and accents. All, Favourites and the category chips filter the tiles. Options marks a product with attributes, Combo a combo. One tap adds the product; the same product again adds to the quantity of its line, as in Odoo. The … button on a tile (or a long press, or a right click) opens its choices. The grid shows at most 1,200 tiles; then it asks you to search or pick a category.
| Button | What it does |
|---|---|
| Customer (F4) | Choose or create the customer; their pricelist and fiscal position apply (chapter 10). |
| Discount | A percentage on the selected line, or on the whole order when Odoo's Discount app is installed and a discount product is set. Quick values 5, 10, 15, 20 % or any value from 0 to 100. |
| Note | A note for the customer (printed on the receipt) and an internal note. |
| Pricelist | Appears only when the point of sale has more than one pricelist. |
| Numpad | Show or hide it; Settings → Terminal can force it always on or hidden. The modes Qty, % Disc and Price decide what the keys change on the selected line. |
| More | Hold order, New order, Refund, Cancel order, Orders on hold, and the actions of optional parts: Layaways, Rewards & promo code, and in a restaurant Split bill, Bill, Ticket preview, Transfer. |
| Pay | Shows the total and opens the payment screen (chapter 11). |
The terminal numbers orders itself (for example 262-1-000001); Odoo gives its own name to the order when it receives it (Main Shop - 000001). The chip 265.83 is the loyalty balance of the customer, Public the pricelist.
Scanner and keyboard. A scanner that works like a keyboard (it types the code fast and ends with Enter) can be used anywhere on the Sell screen, not only in the search box. The terminal reads codes with Odoo's default barcode rules: product barcodes add the product, EAN-13 codes starting with 23 carry a price, with 21 a weight, codes starting with 22 a discount for the selected line, and 042 selects a customer. Settings → Terminal switches the scanner off and sets the maximum time between characters (35 ms by default) and the shortest barcode (6 characters). Scanners were not verified on real hardware.
| Key | Action |
|---|---|
| F2 or / | Search products |
| F4 | Choose a customer |
| F8 or Ctrl+Enter | Go to payment |
| F9 | New order |
| Q, D, P | Numpad mode: quantity, discount, price |
| ↑ ↓ and Del | Select a line; remove the selected line |
| Enter, Esc, ? | Validate the payment; close or go back; show this list |
When a manager PIN is set, the terminal asks for it before the actions you switched on in Settings → Security: discounts, price changes, refunds, removing a line, cancelling an order (chapter 25).
Tablets. In landscape (1024 × 768) the two panels stay. In portrait (768 × 1024) the order collapses into a bar, Review the order, with the item count, the total and the Pay button; tap the bar to open the order.
Variants and combos
Variants. Each attribute is a group: pills, a drop-down, colour swatches, or extras you can tick several of, as Odoo displays them. The extra price is written on the value. Choices that cannot be combined are greyed and struck through, and a missing combination says “This combination is not available.” A custom value such as Other (specify) asks for a text; Odoo accepts it only from a user with Products / Create (chapter 5).
Combos. A combo is made of groups. A group title shows how many items you picked out of the maximum (“1 of 2”), and a badge how many are included in the combo price. A group with one item is chosen for you. Add to order works only when every group is complete. The price of a combo item cannot be changed alone. In a restaurant each item goes to the printer of its own category.
Customers
Press Customer on the Sell screen. The search looks at the name, the phone (any format), the email and the barcode; New creates a customer without leaving the order.
The card shows the contact data, the pricelist, the fiscal position and the recent orders on this device. Use for the current order sets the customer on the open order. Edit needs the connection and the Contacts / Creation right; here it is greyed and the card says why.
New customer works offline. The customer is saved on the device with the tag Will sync, can be used in orders at once, and is sent to Odoo before the orders that use it. Odoo 19 lets only users with Contacts / Creation create customers: without it the form shows the red box, Odoo refuses the customer, and the orders waiting for it stay in the Sync screen until the right is granted and you press Retry. When not every customer is on the device, Search in Odoo looks on the server (it needs the connection) and keeps what it finds.
Payment, receipt and printing
| Part | How it works |
|---|---|
| Method tiles | One for each payment method of the point of sale. A tap adds a line with the amount due; tap a line to select it and type its amount; the trash button removes it. |
| Exact amount, +10, +20, +50 | Exact amount sets the selected line to what is due. The +keys add that amount to the selected line. |
| Remaining, Change | The grey box shows what is due in amber, then the change in green. Change can only be given back in cash. |
| Cash rounding | When the point of sale rounds cash (Main Shop: 0.05, cash only), a cash amount must be a multiple of it; the screen shows the rounding. |
| Customer Account, Invoice | Both need a customer. Odoo creates the invoice when it receives the order, also if the sale was made offline. |
| Lot and serial numbers | For a tracked product the terminal asks for the numbers when you add it. The cashier needs Inventory / User in Odoo (chapter 5). |
| Keyboard | Digits edit the selected payment, Enter validates, Esc goes back. |
The receipt shows what will print: the company block, receipt and order numbers, the customer, the lines with a tax letter (A, C), the subtotal and the taxes by group with their base (“VAT 15% (A) on 24.79”), the payments and the change, the footer text of the point of sale, a Code-128 barcode of the receipt number, and a QR code “Scan to get your invoice” with the address of Odoo's portal and a code (when Odoo's ticket QR option is on). The right side has a badge (On this device, Waiting to sync, Sending to Odoo…, In Odoo), the switch 58 mm / 80 mm, Print and New order.
Printing. Print uses the print function of the browser and prints only the receipt. Choose the printer once in the dialog, margins None, paper size the width of the roll. Chrome prints without the dialog only when started with --kiosk-printing. Settings → Terminal sets the paper width and Print the receipt automatically. There is no direct ESC/POS printing (chapter 27). Old receipts are reprinted from Orders (chapter 13).
Working offline
What is on the device. A copy of what the terminal needs, in the browser database (IndexedDB): products, variants and combos, customers, taxes and fiscal positions, pricelists, loyalty programmes, the configuration of the point of sale and your open orders. After the first full download the terminal starts from this copy.
| Step | What the terminal does |
|---|---|
| Validate | The paid order is written to the device before the receipt shows, then queued. |
| The queue | One order at a time, oldest first. Each order has a unique id that Odoo recognises, so an answer lost on the way is no problem: the order is sent again and not created twice. |
| Network errors | Retried after 5 s, 15 s, 45 s, 2 min, 5 min, then every 15 minutes. A new session or a reconnection resets the waiting. |
| Refused by Odoo | A business error (a missing right, a validation) is not retried by itself. The order stays in Errors with the reason until you fix the cause and press Retry sync. |
| Waking up | While offline the terminal asks Odoo with a small request (no password) about every 30 seconds and sends as soon as Odoo answers. Chromium also wakes the open page through Background Sync. |
| Catalogue changes | Small updates every 5 minutes (a setting) and on Pull data now; a full download when a point of sale is chosen. A tax changed after a sale does not alter it: the order is sent as recorded and the event is logged. A customer created offline is sent first. |
| Starting offline | The first visit stores the whole app in the browser, so the terminal starts with the server stopped. Two tabs share one database; only one sends. |
Orders
| Tab | Contents |
|---|---|
| Today, This session | The sales of the day on this terminal; the orders of the open session. |
| Waiting to sync, Errors | Orders Odoo has not confirmed; orders Odoo refused, with the reason. |
| Drafts, Layaway | Held orders and open table orders; orders kept open with a deposit (chapter 18). |
Search by receipt number, customer or amount; sort by newest, oldest, largest or smallest. The detail shows the state badges, lines, totals, payments and the Sync history.
| Button | What it does |
|---|---|
| Reprint | Opens the receipt again, marked as a copy, and prints it through the browser. |
| Refund | Only for an order Odoo has confirmed; an unsent order can be refunded once it is sent. |
| Retry sync | Sends an order of the Errors tab again after you fixed the cause. |
| Export JSON | Saves the order as a file, for support or to enter it in Odoo by hand. |
| Discard | Only for an order Odoo refused. The terminal downloads its JSON first, then asks you to type DISCARD and a reason. If the customer paid, settle the money outside the terminal. |
Refund. Choose the lines and quantities (the window shows what was sold and already refunded) or Refund everything, then Continue to the refund. The payment screen opens with negative amounts (Refund to customer); pay it out and validate. The terminal counts the refunds already made on this device, so you cannot refund more than what is left. A refund is an order of its own, sent like any other. A manager PIN is asked first if it protects refunds.
The Sync screen
| Card or button | What it shows or does |
|---|---|
| Queue | Orders and other changes (new customers, loyalty points, table orders to cancel) with state, attempts, next try and last error; problems first. Retry now, Retry all. |
| Event log | The last 500 events; filters All, Warnings, Errors. |
| Connection | Online or offline, server and how it is reached, database, user, Odoo version, transport, last order sent, last download. Measure times a small request to Odoo. |
| Data on this device | Last full download and last update, and the rows of each Odoo model. Re-download everything replaces products, customers, taxes and prices; unsent orders, waiting changes, the connection, the PIN and the settings stay. |
| Storage | Space used, and whether the browser promised to keep it. Keep data on this device asks for that promise. |
| Sync now, Pull data now | Send the queue; download catalogue changes. Both need the connection. |
| Export diagnostics | A JSON file for support: no passwords, keys, PINs, customer data or order lines. |
| Close session | Opens the closing flow (chapter 20). |
Restaurant: tables, kitchen, split, tips
The floor plan. Tables opens it. Floors are tabs (Ground floor, Terrace). Tables sit at the position, shape and size they have in Odoo, scaled to the screen; the plan is never mirrored in Arabic. A table shows its number, its Odoo colour dot and, when busy, total, guests and minutes. White is free, blue Occupied, amber To pay (bill printed, nothing changed since). A red chef-hat badge counts items not yet sent to the kitchen. Merge starts a merge; a wide screen also lists Open tables.
Open a table. Tap it: a free table starts an order with one guest, a busy one opens its order. The Sell screen shows a table chip and the kitchen button Order with the number of changes to send. The chip opens the table sheet: guests (1 to 99), the service (Dine In, Takeout, Delivery; it sets the pricelist and fiscal position), Split bill, Bill, Transfer, Floor plan (or Unmerge) and the kitchen section. Leaving a table sends its changes to Odoo.
The kitchen. Each line shows In the kitchen or Not sent. Order sends only what changed since the last time, as Odoo's own point of sale does. A ticket has the sections NEW, CHANGED (with the new quantity) and CANCELLED, notes and options, and combo items under the combo name. There is one ticket for each kitchen printer of Odoo: a line goes to every printer whose product categories contain one of its categories or their parents. Items no printer takes print on the default station Other items. Tips, discounts and rewards never go to the kitchen. Ticket preview shows the tickets first; Reprint prints the last ones marked DUPLICATE.
--kiosk-printing. The terminal does not drive an Odoo IoT Box or Epson ePOS network printers, and has no raw ESC/POS output: each kitchen station needs such a printer, or its own browser running the terminal. Courses (“fire course”) are not implemented (chapter 27).Split bill. Choose the lines and quantities for the new bill (combo items move with their combo), or use Equal share to select one share of the number of guests. The window shows the New bill and what Stays on the table. Pay this bill opens its payment; Keep open only creates it. The new bill is a separate order named like 5B, 5C, up to 26 parts. Odoo merges every open order of one table into one, so the new bill has no table link on the server; the link is kept on the device and the bill shows on its table in the plan. An order with payments cannot be split. A line cut in two can move one cent between the bills (tax is rounded line by line, as in Odoo).
Bill and tip. Bill opens the Bill preview. Choose No tip, 5, 10, 15 or 20 % of the bill, or a Custom amount and Apply. The tip is a line with Odoo's tip product (set with the Tips option in Odoo); a new choice replaces it, 0 removes it. Print bill prints it, titled “BILL - not a receipt”, and the table turns To pay; Payment goes to the payment screen. Tips after a card payment (a capture) are not supported.
Transfer and merge. Transfer asks for the destination table (“Moving 5: tap the destination table”, with Cancel). To a free table only the table changes; to a busy table the items join its order (equal lines add up) and the emptied order is cancelled, also offline. Merge on the plan does the same: first the table to move, then the destination. Unmerge puts the items of a merged table back on a new order of that table, which must be free.
Dashboard
Buttons: Session, Today, 7 days, 30 days. Cards: Sales, Orders, Average ticket, Tax, Discounts. The chart is by hour for the session and today, by day for 7 and 30 days. Top products lists the eight products with the highest amount, without tips, discounts and rewards.
| Detail | How it works |
|---|---|
| Source | Odoo's figures for the period plus the orders of this terminal that Odoo has not received yet, each counted once. The label reads “As of 02:46 PM” and “includes n orders waiting to sync”. |
| Offline | The last answer for the same period (“Saved answer of … (offline)”) plus this terminal's orders; if never loaded, “This terminal only (offline)”. |
| Discounts | Line discounts only. Discount lines of a whole-order discount are not counted. |
| Payments and Sales | Odoo keeps exact order totals but cash is rounded to the coin, so payments can differ from Sales by the cash rounding; the screen says so (here +0.02). |
Stock lookup
Type a name, reference or barcode. The table shows On hand, Reserved, Available and Incoming for each warehouse, the warehouse of this point of sale first with the tag This shop. Available below 5 is shown as low, 0 or less as out of stock. By location opens the locations; a product with variants lists each variant. Offline, the screen shows the last answer for each product you looked up, with its time: “Offline: this is the last answer of Odoo, not the current stock.” The tiles of the Sell screen show no stock.
Layaway (deposits)
- Choose a customer on the order: a layaway needs someone who owes the balance.
- On the payment screen enter the deposit as a normal payment.
- In the Layaway box press Keep open with deposit. The terminal shows a deposit receipt, LAYAWAY - DEPOSIT, to hand to the customer.
- Later open More → Layaways and press Collect payment for the customer. Pay the balance and Validate.
| Rule | Why |
|---|---|
| A customer is required; the deposit is more than 0 and less than the total; no change, no customer-account deposit, no refund, no table order. | A deposit is real money taken for a balance somebody owes. |
| A deposit is locked: it cannot be edited or removed here, and the order cannot be cancelled here. | So the cashier cannot delete the deposit and collect the full price. Collect the balance, or refund the deposit in Odoo. |
| Odoo cannot close a session that has an open layaway (a draft order). | Collect the balance, or cancel it in Odoo, before closing (chapter 20). |
| A layaway made on another device must be downloaded first (Pull data now). | The list says so. |
Loyalty and promo codes
Choose a customer, then tap the points chip next to the customer or use More → Rewards & promo code. The card shows the Balance from Odoo (with its time), This order and After. Apply adds a reward to the order as a line; the trash button removes it. A reward that no longer fits after a change is removed, with a message. A promo code such as SUMMER10 is typed in Promo code and works offline.
| Supported | Details |
|---|---|
| Programmes | Loyalty cards (one for each customer) and promo codes (rules with a code). |
| Earning | Points per order, per amount or per unit; minimum amount and quantity, product filters, dates, pricelists, usage limits, nominative programmes. |
| Rewards | A discount (percent, fixed for the order, or per point) on the order, the cheapest line or specific products, with a maximum; a free product already in the order. |
| Not supported | What the terminal does |
|---|---|
| Gift cards and eWallet | Not handled. Products that trigger an active gift card or eWallet programme (“Gift Card”, “Top-up eWallet”) are hidden from the catalogue and the barcode lookup, because the terminal cannot create or top up a card. Without an active programme such a product is ordinary, as in Odoo. |
| Coupons, next-order coupons, buy X get Y, automatic promotions, card codes, split points, multi-product rewards, free shipping | Not handled and not shown. |
Using points needs the connection: the terminal asks Odoo first, and takes off the balance the points spent by orders Odoo has not confirmed yet. After the order reaches Odoo the terminal confirms the points once (the queue shows Loyalty points) and the card is credited or debited then. If the loyalty switch is turned off with an order open, its reward lines are removed.
Close the session
Open Close session from the session chip, the Sync screen or Settings → Terminal identity. The box Before closing checks:
| Check | If it is not met |
|---|---|
| Connected to Odoo | Closing needs the connection. |
| Everything sent to Odoo | Press Sync now. Odoo must have every order and waiting change first. |
| No open draft orders | Finish or cancel held and table orders (Resume, Cancel). Drafts left in Odoo by other terminals can be cancelled with the closing, except those with a deposit (layaways): collect them first. |
| Customer account sales have a customer | Odoo refuses otherwise. Set the customer in Odoo (Point of Sale → Orders), then Check again. |
Count the cash by bills and coins or by total. Expected is the opening cash plus cash payments and cash moves. In Other payment methods enter the card terminal's total when it differs from Odoo; the difference is posted at closing. A Closing note stays on the session for the manager. Session summary lists orders, sales, cash, payment methods and, from the orders made on this device, taxes and the products sold most. If the difference is above the threshold (Cash difference that needs a confirmation, 5 by default) you must type the session name. Then press Close the session and confirm.
Print the report through the browser, then Open a new session or Sign out (unsent orders stay on the device). The terminal does not print Odoo's full Z report (it needs accounting rights): a manager prints it in Point of Sale → Reporting → Sales Details.
Settings
| Section | What you can set |
|---|---|
| Appearance | Theme (five). Brand colour (picker, hex field, two contrast readings: AAA, AA, “Large text only”, “Too low”); a colour too pale or dark for the theme is adjusted; Use the theme colour resets it. Density, Text size (90 to 125 %), Language (English, Arabic: also the direction), Digits (123 or ١٢٣). |
| Terminal | Receipt paper width, Numpad of the order panel (Automatic, Always, Hidden), Print the receipt automatically, Show product images, Sounds, and the Barcode scanner (on or off, maximum time between characters 20 to 120 ms, shortest barcode 3 to 12). |
| Printing | How printing works, Print a test page, and the kitchen and bar printers configured in Odoo (read only). |
| Data | Check Odoo for changes every 1 to 60 minutes; Products per download page 50 to 1,000; Keep synced orders for 3 to 365 days (14 by default; unconfirmed orders are never removed); Download product images. |
| Security | Lock PIN, automatic lock, manager PIN, cash difference threshold (chapter 25). |
| Features | Switches for Dashboard, Stock, Layaway and Loyalty; the core screens are always on. |
| Settings file | Export settings; Import settings shows Setting, Now, After import before Apply. It never contains the connection, keys, passwords or PINs; a file from a newer version is refused. |
| Terminal identity | Device identifier, server, database, login, user, connection mode, point of sale, session and Close session. |
About shows the version (1.0.0), the build, the Odoo server, the transport and the apps found (ticked, or a dash when absent), Check for update (chapter 29), Install app when the browser offers it, and the Licences. Wipe local data removes the data downloaded from Odoo and downloads it again. Wipe, keep unsent orders keeps unconfirmed orders and changes, the connection and the settings. Wipe everything also deletes the unsent orders: it asks twice, and the second time you type the number of orders that will be lost.
Themes, brand colour and languages (for developers)
The variables. The light theme is on :root in src/app/globals.css. The other themes override what they change on :root[data-theme="…"]; the rest comes from light. The tokens are --pos-bg, -surface, -surface-2, -border, -fg, -muted, -primary, -primary-fg, -accent, -success, -warning, -danger, then radius, shadow, font and density. An @theme inline block maps Tailwind's utilities to them (bg-surface, text-fg, bg-primary), so components never name a colour.
Add a theme. A theme lives in five places; the example is the real Ocean theme:
/* 1. src/app/globals.css: one block of variables per theme (the tokens a theme does not set come from :root) */
:root[data-theme="ocean"] {
--pos-bg: #eaf3f9;
--pos-surface: #ffffff;
--pos-surface-2: #dcebf4;
--pos-border: #c4dbe9;
--pos-fg: #0e2433;
--pos-muted: #516c7e;
--pos-primary: #0a6595;
--pos-primary-fg: #ffffff;
--pos-accent: #06687f;
}
/* 2. src/lib/theme/prefs.ts: the list of names */
export const THEMES = ['light', 'dark', 'ocean', 'forest', 'sunset'] as const
/* 3. src/lib/theme/themes.ts: the colours of the small theme chips (a test keeps them equal to the stylesheet) */
ocean: { bg: '#eaf3f9', surface: '#ffffff', primary: '#0a6595', accent: '#06687f' },
/* 4. src/app/layout.tsx: the boot script, so there is no flash of the wrong theme */
var T=['light','dark','ocean','forest','sunset'];
/* 5. src/lib/i18n/en/core.ts and ar/core.ts: the name shown on the chip */
'theme.ocean': 'Ocean',Then check the contrast: src/lib/theme/contrast-themes.test.ts reads the stylesheet and requires 4.5:1 (WCAG AA) for every text and background pair the components use, in every theme, and checks that the chip colours equal the stylesheet. Add the new name to the list in that test. Setup and Settings build their theme chips from THEMES.
Brand colour. The picker writes --pos-primary and --pos-primary-fg on <html>. src/lib/theme/contrast.ts picks a readable text colour, and adjusts a colour that is too pale or dark for the cards (below 3:1). Preferences live in localStorage (pos.prefs), and a small script in layout.tsx applies theme, direction, colour and density before the first paint.
Right to left. The language sets dir on <html>. The CSS uses logical utilities (ms-*, ps-*, text-start), amounts are always left to right (.amount), codes like 262-1-000001 keep their order inside Arabic text, and the floor plan is never mirrored. Fonts: Inter and IBM Plex Sans Arabic, bundled.
Languages. English and Arabic are built in. Texts are flat dictionaries ('sell.pay': 'Pay'), one file for each area in src/lib/i18n/en/ and ar/, read with t('key', {vars}); plural messages have the forms of the language (English two, Arabic six); GLOSSARY.md fixes the Arabic terms. Tests fail when Arabic lacks a key or placeholder of English, when a key used in the code does not exist, and when visible text in src/components bypasses t().
Lang and the list LANGS (src/lib/theme/prefs.ts), the direction, the plural rules (plural.ts), the number and date locale (format.ts), the boot script of layout.tsx, the language chips and the registration of the dictionaries all name the two languages.How totals are computed
What it covers. Taxes (percentage, fixed, division, group; price included or excluded; negative rates; reverse charge), fiscal positions, pricelists (fixed, percentage, formula, based on another pricelist, dates, quantity tiers), variant extras, combo price split, cash rounding, the whole-order discount, rounding per line or globally, currencies with 0, 2 and 3 decimals. It is plain TypeScript with no browser, database or network (src/lib/engine/, documented in its README).
| Check | Result |
|---|---|
| 667 reference scenarios built in Odoo 19 (399 orders, 216 unit prices, 36 combos, 16 whole-order discounts), expected numbers from Odoo 19's own Python tax helpers. | Terminal = Odoo's Python on 667 of 667. |
447 of those orders were also sent to Odoo with sync_from_ui and read back. | All accepted and paid. |
| The same scenarios through Odoo's own point-of-sale JavaScript. | Terminal = Odoo's JavaScript on 664 of 667. The 3 documented exceptions are cases where Odoo's JavaScript and Python disagree; the terminal follows the Python, which Odoo's server recomputes. |
An independent replay: 1,149 orders built through the real order flow and sent to Odoo 19 with sync_from_ui as a cashier (several roundings, currencies, sales, refunds). | 1,085 stored; 64 refused by Odoo with an access error on lot or serial products, which a Point of Sale-only cashier cannot read. Every order recomputed with Odoo's Python and JavaScript: terminal = Python on every order. |
Where Odoo's own client and server disagree. The terminal computes what Odoo's point-of-sale client computes and sends, because that is what sync_from_ui stores. In a few cases Odoo's server then stores something slightly different:
| Case | What happens |
|---|---|
| Whole-order cash rounding | The client sends the unrounded total and pays the rounded amount; the server computes the rounded total. The stored total is the client's, except when change is given: then Odoo adds a negative cash payment and recomputes, so the stored total is the rounded one and the amount paid is net of change (with a card payment plus cash change Odoo can even refuse the payment and save the order as “/”). Not a sync conflict. |
| A fiscal position without tax mapping | Odoo's client keeps the taxes, its server removes them. Orders can end as “/” and the closing can be blocked. Map every tax, also to a 0 % tax. |
| Pricing | The server never re-prices POS lines. A negative quantity matches no pricelist rule in the POS, while the backend applies rules with minimum quantity 0. |
| Line totals rounded by the backend | The backend form rounds a line total differently (by 0.01 on some lines in the check); order totals agree. |
| Rounded globally, negative-price line | The invoice tax can differ by 0.01; a sale with a negative total is recomputed by the server on flipped quantities (also 0.01). |
Deliberate differences. Pricelist items are also filtered by date at the moment of the sale (Odoo's POS filters only at loading, so a terminal running for days would keep an expired promotion). Line totals are rounded to the currency. The tax ids inside each tax group are sorted.
Licence. account_tax.ts, float.ts, pricing.ts, fiscal.ts, order.ts, combo.ts, attributes.ts and discount.ts are ported from Odoo 19. They carry a header naming their origin, are listed in NOTICE and stay under the LGPL-3.0 (LICENSE-LGPL-3.0.txt). The test tools that run Odoo's own JavaScript read it from a local Odoo installation; no Odoo file is copied into the package.
Architecture (for developers)
The whole interface is one route, /; screens switch inside it, so a full reload works offline from one cached HTML file. The server side has /api/odoo (the proxy), /manifest.webmanifest and /sw.js (written into public/ by scripts/make-sw.mjs after next build). The browser calls the proxy, which forwards to the allowed Odoo origins (unless you choose the direct mode).
| Folder | What it holds |
|---|---|
src/app/ | Layout and boot script, page, manifest, globals.css, api/odoo/route.ts (the proxy). |
src/lib/odoo/ | One OdooClient over JSON-2 (/json/2/<model>/<method>, bearer key) and JSON-RPC; the connection test; the proxy and its address rules; typed errors; zod schemas. |
src/lib/db/, sync/ | The Dexie schema, queries and PIN encryption; push, pull, scheduler, order payload, closing calls, event log. |
src/lib/engine/, pos/ | Prices, taxes, totals (pure TypeScript); the open-order store, receipt, barcode rules, scanner decoder, Code-128 and QR. |
src/lib/restaurant/, pro/, features/, i18n/, theme/, pwa/, screens/ | Restaurant and Pro logic; the feature registry; dictionaries; themes; service worker registration; the logic behind the Orders, Customers, Sync, Settings, Close and Lock screens. |
src/lib/terminal/ | The facade: the only door from the interface to the connection, the database and the sync. |
src/components/ | Screens and widgets, one folder for each area, and extensions/ where optional parts register. |
deploy/, scripts/ | The nginx examples; deploy/webapp/, the launcher and README of the ready-to-run app; the generators of the service worker and the licences file; scripts/pack-webapp.mjs, which assembles the ready-to-run folder from the standalone build (npm run pack:webapp). |
The local database. One Dexie table for each mirrored Odoo model, named like the model with the dot turned into an underscore, keyed by id (product_template, res_partner, account_tax…), plus meta (connection, session, installed modules, last pull, preferences, caches), orders (by uuid), ops (operations that are not orders), images and log. Zustand stores hold screen state; the open order is written to Dexie shortly after each change.
Data in and out. Settings, taxes, pricelists and so on load with Odoo's own point-of-sale loader pos.session.load_data; the catalogue is paged with product.template.load_product_from_pos; later pulls ask for what changed since the server time of the last pull and check deletions on the server. Orders go out with pos.order.sync_from_ui, one for each call, idempotent by uuid; drafts (tables, layaways) go as drafts. Other operations (new customers, loyalty confirmations, cancelling a table order, closing) are queued in ops and run in order before the orders that depend on them.
The feature registry. registerFeature declares which Odoo apps a feature needs (requires.modules), a condition on the point of sale (requires.config), whether it needs the connection (requires.online), its place in the rail (nav), its screen and the slots it fills (cartSlot, paymentSlot, orderActions, lineBadge, settingsPanel). A feature whose app is missing, or that is switched off, is not mounted and its code is never loaded.
Add a Pro feature (the steps of src/lib/pro/README.md, for an example called giftcards):
- Logic in
src/lib/pro/giftcards/as pure functions with tests. Network only throughgetOdooCaller()(null offline); check every answer with zod. - Cache what must stay readable offline with
readCache/writeCache(metakeyspro.giftcards.*). - The screen in
src/components/pro/giftcards/(default export); wrap a slot withlazy()insrc/components/pro/slots.tsx. - Messages
pro.giftcards.*insrc/lib/i18n/en/pro.tsandar/pro.ts(the test fails if Arabic lacks a key). - Register the feature in the registration file in
src/components/extensions/. - Add the id to
PRO_FEATURE_IDS(src/lib/pro/features.ts) and the textspro.feature.giftcardsand_desc: the feature gets its switch in Settings → Features, on by default. - Never import feature code statically in the registration file: a feature that is off or missing must load nothing.
// the registration file of the Pro features (src/components/extensions/): one call per feature
registerFeature({
id: 'giftcards',
title: 'Gift cards',
tier: 'pro',
requires: { modules: ['pos_loyalty'] },
nav: { icon: 'gift', labelKey: 'pro.giftcards.title', order: 65 }, // place in the left rail (stock is 60, sync is 70)
screen: () => import('@/components/pro/giftcards/giftcards-screen'), // loaded only when the feature is on
// optional: cartSlot, paymentSlot, orderActions, settingsPanel
})Tests. npm test runs Vitest (jsdom, an in-memory IndexedDB, a fake Odoo in the test process): 3,336 pass and 66 live tests that need a running Odoo 19 are skipped. npm run typecheck runs strict TypeScript and npm run lint ESLint. To run the live tests on your own Odoo 19 test database set POS_TEST_URL, POS_TEST_DB, POS_TEST_LOGIN, POS_TEST_SECRET (an API key) and POS_TEST_ADMIN_SECRET (an administrator's key) and run npx vitest run tests/live; some also need psql access (PGPASSWORD). They open and close sessions: never run them on a production database.
Security
The key on the device. The browser keeps the Odoo key in IndexedDB. Without a lock PIN it is stored as it is. With a lock PIN it is encrypted (the PIN goes through PBKDF2-SHA-256 to an AES-GCM key) and the decrypted copy lives only in memory. The PIN is asked at start and after an automatic lock.
| Protection | How it works |
|---|---|
| Lock PIN | 6 to 12 digits, set in Setup or Settings → Security. Lock automatically after Never, 1, 2, 5, 10, 15 or 30 minutes, and the lock button in the top bar. After 5 wrong PINs the terminal makes you wait, 30 seconds first, doubling up to 15 minutes. |
| Manager PIN | 4 to 12 digits, stored only as a salted PBKDF2-SHA-256 hash. Asked before the actions you switch on: Discounts, Price changes, Refunds, Removing a line, Cancelling an order; nothing is asked until a PIN is set. An approval holds for 20 seconds for the same action on the same line or order. Wrong PINs bring a growing wait (up to 5 minutes). Switching a protection off, or importing a settings file that does, asks for the manager PIN. |
| Sign out of this terminal | Needs no PIN, so a locked till can always be released. It removes the login and the downloaded data; unsent orders stay on the device. |
The proxy. Every call of the browser goes to /api/odoo, built as a narrow door:
| Rule | Detail |
|---|---|
| Allow-listed routes | /jsonrpc, /json/2/<model>/<method>, /web/webclient/version_info, /web/version, /web/database/list, and product images (/web/image/… with a unique query only). The path is matched as it arrives, never decoded first. |
| Servers and addresses | Only the origins of POS_ALLOWED_ORIGINS (any origin only with POS_ALLOW_ANY_ORIGIN=1). Every address the host resolves to is checked and the connection is pinned to them (safe against DNS rebinding). Link-local and cloud-metadata addresses are always refused; private networks only with POS_ALLOW_PRIVATE=1 or a listed origin. |
| Requests | Browser cookies are never forwarded, Set-Cookie never returns, redirects are not followed. The request must come from the app's own page (Origin against Host; forwarded headers only with POS_TRUST_PROXY=1), as JSON, up to 25 MB, 60 second limit. |
| Failed logins | Counted for each client and Odoo server: 10 in 10 minutes make the proxy answer 429 for 10 minutes. A missing-rights error does not count. Clients are told apart only with POS_TRUST_PROXY=1; otherwise all share one count, and 10 failed attempts by anyone pause the logins of every terminal for 10 minutes. |
| Logs | With POS_PROXY_LOG=1 only method, path and status; never a body or a key. |
Headers. Every page carries a Content-Security-Policy (default-src 'self': everything from the app itself plus POS_CONNECT_SRC; inline scripts and styles stay allowed because the framework needs them), nosniff, Referrer-Policy: same-origin, X-Frame-Options: SAMEORIGIN and a Permissions-Policy that gives camera, USB, serial and Bluetooth to the app only.
/api/odoo has no login of its own. Anyone who can reach it can try credentials against your Odoo (within the limit above). On an internet-facing server use TLS and put the terminal behind a VPN, single sign-on or an IP allow-list.| Deployment checklist | Do this |
|---|---|
| TLS and access | HTTPS (chapter 4); internet-facing: VPN, single sign-on or IP allow-list in front of /api/odoo. |
| Origins | POS_ALLOWED_ORIGINS set to your Odoo; no POS_ALLOW_ANY_ORIGIN on a reachable server. |
| Proxy | POS_TRUST_PROXY=1 only behind a proxy that overwrites X-Forwarded-*, as the nginx example does. |
| Keys | POS_DISABLE_JSONRPC=1; one API key for each terminal, Point of Sale group only; revoke a lost device's key. |
| On the till | Lock PIN with automatic lock; manager PIN for discounts, price changes and refunds. |
| Exports | An exported order (JSON) holds sale data: keep it private. The diagnostics and settings files hold no keys, passwords or PINs. |
How accurate is it
Totals follow Odoo's point of sale. The terminal matched Odoo 19's own Python tax helpers on 667 of 667 reference scenarios, Odoo's own JavaScript on 664 of 667 (3 documented exceptions), and, in an independent replay of 1,149 orders sent to Odoo 19 as a cashier, Odoo's Python on every order (chapter 23 has the details). Where Odoo's own client and server disagree, the terminal does what Odoo's client does. It does not compute Python-code taxes or pricelists in another currency.
Offline guarantees. A paid order is written to the device before the receipt shows. It is sent once: Odoo matches it by its unique id, so a lost answer cannot create a second order. A synced order is removed from the device only after Odoo confirmed it and after the retention period (14 days by default); an order Odoo has not confirmed is never removed by the terminal. This assumes the browser keeps its data: if the browser data is cleared, unsent orders are lost (chapter 12).
Performance, and where it was measured. All numbers come from one Apple Silicon Mac with Odoo, the app and the browser on the same computer and a window of 1366 × 768 (not a tablet). On a tablet and a real network the times will be longer.
| What was measured | Result on that Mac |
|---|---|
| First full download of 20,000 products and 20,000 customers | 24.5 seconds; IndexedDB 21 MB |
| Warm reload to a usable product grid | 0.78 to 0.87 seconds |
| Product search | About 80 ms, including the 60 ms pause after typing |
| Scrolling the product grid | 60 frames per second |
| Adding a product to the order | 1.7 ms (median) |
| 1,000 orders queued offline, then sent | 169 seconds, in order, no duplicate |
| JavaScript of the app | 1.75 MB raw, 511 KB gzip, in total |
| Starting offline from the first visit | Confirmed with the server stopped |
The tests. npm test passes 3,336 tests; 66 more, which need a running Odoo 19, are skipped by default.
| Area | Tests passed |
|---|---|
| Tax and price engine (1,783 comparison scenarios against Odoo 19 Python, JavaScript and server; 55 unit and property tests; 28 money regressions and fuzzed orders) | 1,866 |
| Connection, proxy, local database and sync (fake Odoo in the test process) | 393 |
| Themes and colour contrast | 357 |
| Screen logic (orders, customers, sync, settings, close, lock, diagnostics) | 257 |
| Order flow, receipt, barcodes, scanner | 158 |
| Interface components (sell, payment, settings, orders, close, sync, setup, lock, shell, no hard-coded text) | 89 |
| Restaurant (floor, kitchen changes, split, merge, tips) | 85 |
| Pro features (dashboard, stock, layaway, loyalty) | 66 |
| Languages (English and Arabic keys, plurals, formats) | 54 |
| Feature registry, PWA, others | 11 |
| Total | 3,336 |
Limits
| Limit | What it means | What to do instead |
|---|---|---|
| Hardware not verified | Receipt and kitchen printers, barcode scanners, cash drawers and payment terminals were not tried on real hardware. | Test your own devices before opening. Use Print a test page. |
| Browser printing only | No raw ESC/POS, no Odoo IoT Box, no Epson ePOS network printing. Chrome prints without a dialog only with --kiosk-printing. Each kitchen station needs a printer the operating system can print to, or its own browser running the terminal. | Install the printers in the operating system and start Chrome with --kiosk-printing. |
| Browsers | Chrome and Edge were used. Safari and Firefox were not verified. Background Sync is a Chromium extra: orders are sent while the terminal page is open. | Use Chrome or Edge and keep the terminal open. |
| Rights of a plain cashier | Cannot create customers (Contacts / Creation), save free-text option values (Products / Create), record cash in and out (Accounting / Invoicing) or print Odoo's full Z report. Lot or serial products need Inventory / User. | Grant the right, or do it in Odoo (chapter 5). |
| No cash in / out button | The terminal has no screen for cash moves. | Record them in Odoo. |
| Restaurant | No courses (“fire course”). The floor plan is edited in Odoo. No tips after a card payment (capture). Cancelling a layaway with a refund of the deposit is not done here. | Do these in Odoo; add the tip before payment. |
| Loyalty | Supported: loyalty cards and promo codes. Not supported: gift cards, eWallet, coupons, next-order coupons, buy X get Y, automatic promotions, card codes. Products of an active gift card or eWallet programme are hidden. | Sell those in Odoo's own point of sale. |
| Fiscal positions, taxes, pricelists | A fiscal position without tax lines is inconsistent in Odoo 19. Python-code taxes cannot be computed. Pricelists in another currency are ignored, as in Odoo's POS. | Map every tax, also to 0 %; use percentage, fixed, division or group taxes. |
| Not in this version | Several companies in one terminal, pricelists in several currencies, a login or PIN for each employee (pos_hr), payment-terminal integration, self-order and kiosk, online payment. | One terminal for each company; one Odoo user for each cashier. |
| Password logins | Odoo 19 deprecated /jsonrpc, used by password logins, and announced its removal in Odoo 20. | Use API keys (JSON-2) and POS_DISABLE_JSONRPC=1. |
| The lock PIN | A convenience lock; it does not stop someone who copies the browser profile (chapter 25). | One key for each terminal, Point of Sale group only; revoke it when a device is lost. |
| Docker, Odoo versions | The Dockerfile and compose file are provided, the image was not built and tested here. Odoo 19 Community was tested; Enterprise and other versions were not. | Build and test the image, and test on a copy of your own Odoo. |
| Ready-to-run app | Started on macOS with Node.js 24. In Chrome this build installed, cached all its files, started offline with the server stopped and connected to Odoo; the sale and sync checks ran on the build from the source (the same code). start.bat on Windows, Linux, and Node.js 20 and 22 were not tried. | Try it on your own computer first; if start.bat does not start, run node run.cjs in a Command Prompt. |
| Closing needs the connection | Odoo posts the session. A session with an open layaway or other open drafts cannot be closed. | Keep selling; close when online. |
| Event log language | The lines of the Event log on the Sync screen are written in English, also when the terminal is in Arabic. | Read the order states and messages on the Orders screen; send the English lines to support when you ask for help. |
| Tablets, tiles | Performance was measured on a Mac, not on a tablet. Tiles show no stock, and the grid shows at most 1,200 tiles. | Measure on your tablets; switch off Show product images on slow ones; use the Stock screen and search. |
Troubleshooting
| Message or symptom | Cause | What to do |
|---|---|---|
| “This session was closed on another device. Open a new one.” (red banner) | The session was closed in the back office or on another device. | Press Open a new session; waiting orders go to it. |
| “This session is being closed: a manager must finish closing it in Odoo.” | The session is in closing control. | A manager closes it in Odoo (chapter 20). |
| “Odoo refused the login …” (red banner) | The key was revoked, the password changed or the user archived. | Press Enter new credentials; unsent orders stay. |
| “Odoo cannot be reached. The terminal keeps working offline.” | Network or Odoo is down. | Keep selling; orders are sent when Odoo answers. |
| “This terminal's own server does not answer.” | The Node.js app of the terminal is stopped or unreachable; Odoo may be fine. | Check that the app runs and is reachable from the till. |
| “Node.js 20.9 or newer is required …” when you start the ready-to-run app | Node.js is older than 20.9, or not installed. | Install the current LTS version from nodejs.org, open a new terminal and check node -v. |
| “Port 3000 is already in use …” | The terminal is already running, or another program uses that port. | Stop it, or start with another port: sh start.sh --port 3100. |
| Another device cannot open the address of the ready-to-run app | It listens on this computer only (127.0.0.1) unless told otherwise. | Start it with --host 0.0.0.0 behind HTTPS (chapter 4) and allow the port in the firewall of the computer. |
| An order in Errors with “missing right: Inventory/User” or another group | The Odoo user lacks a right. | An administrator grants it (chapter 5); press Retry sync. |
| “This order waits until the new customer exists in Odoo.” | The customer was not created yet, or Odoo refused it for lack of Contacts / Creation. | Grant the right and retry the customer change in Sync. |
| “Odoo refused this record. Retrying will not help until the cause is fixed.” | A rule of Odoo refuses the data. | Export JSON, fix the cause in Odoo, retry; or send the file to support. |
| “The order failed the safety check before it was sent …” | Totals or payments do not add up; the terminal did not send it. | The order stays on the device. Export its JSON for support. |
| “Odoo saved the order as paid but could not validate its payment (it is named “/”).” | Odoo could not validate the payment. | It is never sent again. Check its payments in the Odoo back office. |
| “Another device had this table open: Odoo merged both orders …” | Two devices worked on one table; one paid while the other merged. | Both orders are kept, nothing was sent twice. Compare them in Odoo and discard the wrong one. |
| “No session is open in Odoo for this point of sale …” | The session was closed or is stuck in closing control. | Open a new session or have a manager finish the closing. |
| “Too many failed logins …” | Odoo waits a minute; the proxy refuses (429) for 10 minutes after 10 failed logins. | Wait. Without POS_TRUST_PROXY=1 all tills share the count. |
| “The browser may delete the terminal data (unsent orders too) …” / “The storage is almost full.” | The browser did not promise to keep the data, or the device is nearly full. | Press Keep data on this device; install the terminal as an app; sync waiting orders and free space. |
| The print dialog opens for every print | Chrome asks for each job. | Start Chrome with --kiosk-printing. |
| No install button; offline start fails | The address is not HTTPS (and not localhost). | Serve the terminal over HTTPS (chapter 4). |
| “No products here” | No product is “Available in POS”, or the download is not finished. | Mark the products in Odoo, then Pull data now. |
| “A tax of this product cannot be computed offline …” | A tax is computed by Python code. | Replace it with a supported tax (chapter 23). |
| “Some data is missing on the terminal. Download the data again …”; wrong data | The local copy is incomplete or damaged. | Re-download everything in Sync, or Settings → About → Wipe, keep unsent orders. |
| Refund is not available | The order has not reached Odoo, or this device does not know its lines. | Send the order first; an order from another terminal is refunded there or in Odoo. |
| “Wrong PIN” and a wait | Too many wrong PINs. | Wait; or Sign out of this terminal (no PIN) and connect again. |
Update and backup
- Put the new files in place of the old ones and keep your
.env. - Ready-to-run app: unzip the new
taqnix-pos-terminal-webapp.zipinto a new folder, copy your.envinto it, stop the old terminal and start the new one. From the source: runnpm ciandnpm run build, then restart the app (or rebuild the Docker image and start it again). - Each build has its own build id inside the service worker. Open terminals look for a new version every 10 minutes and when you press Check for update in Settings → About.
- The new version downloads in the background and a banner appears: A new version of the terminal is ready. with Later and Update.
- When the cashier presses Update, the terminal saves the open order, reloads into the new version, and the order is back.
Orders not yet sent, the connection, the lock PIN and the settings stay in the browser through an update. Updates of the 1.x versions are included in the price.
Backup. The master data is in Odoo: back it up as usual. A till holds only a copy and the orders Odoo has not received. On the server, keep your .env and any change you made to the source. To set up a till like another one, Export settings on the old one and Import settings on the new one; the file has no connection, so run Setup on the new till.
Support
Write with your Odoo version, the browser and its version, the device and screen size, a screenshot or a short description of what you did, and the file from Sync → Export diagnostics. That file has no passwords, keys or PINs, no customer data and no order lines.
| Licence (summary) | Detail |
|---|---|
| Grant | One purchase gives one company (the legal entity that bought it, with its branches and an unlimited number of terminals) the right to install, run and modify the source code for its own business, on its own or a hosting provider's servers. An agency or integrator deploys it for a client only if that client company has its own purchase. |
| Updates | The version you bought and all 1.x updates published for it. |
| Not allowed | Reselling, sub-licensing, publishing the source code, or offering the application (modified or not) as a hosted multi-tenant service or as a competing product or template. |
| Third-party parts | The engine files in src/lib/engine/ (listed in NOTICE) are adapted from Odoo 19 and stay under the LGPL-3.0 (LICENSE-LGPL-3.0.txt). The fonts Inter and IBM Plex Sans Arabic are under the SIL Open Font License 1.1. The npm packages keep their own licences (MIT or Apache-2.0). |
| No warranty | The software is provided as it is. You must check that receipts, taxes and reports meet the laws of your business and country, and test the terminal against your own Odoo before using it in a shop. |
The full text is licensing/license.txt. Odoo is a trademark of Odoo S.A.; this product is not made or endorsed by Odoo S.A.