Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ authors:
repository-code: https://github.com/Posnic/POS
url: https://www.posnic.com/
license: AGPL-3.0-only
version: 1.8.3
date-released: 2026-09-27
version: 1.8.4
date-released: 2026-09-28
keywords:
- point of sale
- POS software
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ before making a package-level licence statement.
[![Latest release](https://img.shields.io/github/v/release/Posnic/POS?include_prereleases&label=latest&color=blue)](https://github.com/Posnic/POS/releases/latest)
[![Tests](https://img.shields.io/badge/tests-9%2C000%2B%20passing-brightgreen)](docs/DEVELOPMENT.md#running-the-tests)
[![Coverage](https://img.shields.io/badge/coverage-66%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests)
[![API](https://img.shields.io/badge/REST%20API-720%20endpoints-blue)](docs/API.md)
[![API](https://img.shields.io/badge/REST%20API-726%20endpoints-blue)](docs/API.md)
[![Source licence](https://img.shields.io/badge/source%20licence-AGPL--3.0-blue)](LICENSE)
[![Package notices](https://img.shields.io/badge/package%20notices-component%20licences-informational)](THIRD-PARTY-NOTICES.md)
[![Platforms](https://img.shields.io/badge/platforms-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey)](https://github.com/Posnic/POS/releases/latest)
Expand Down Expand Up @@ -235,7 +235,7 @@ the free edition worse. This is written down in [GOVERNANCE.md](docs/GOVERNANCE.
| [Contributor quickstart](docs/CONTRIBUTOR_QUICKSTART.md) | Local setup, test commands, issue map, PR flow and safety rules |
| [Developer guide](docs/DEVELOPMENT.md) | Setup, tests, conventions, good first issues |
| [Architecture](docs/ARCHITECTURE.md) | How it fits together, and the parts that bite |
| [REST API](docs/API.md) | 720 endpoints, generated from the routes |
| [REST API](docs/API.md) | 726 endpoints, generated from the routes |
| [Hardware](docs/HARDWARE_MATRIX.md) | Printers, scanners, drawers, scales — and how far each claim is checked |
| [India e-invoicing](docs/INDIA_EINVOICING_DESIGN.md) | Research, readiness inventory and design for GST e-invoicing as an optional feature; no live IRP submission is built |
| [India GST return gaps](docs/INDIA_GST_RETURNS_GAPS.md) | What the GST reports compute today, measured against GSTR-1, 3B and 9 |
Expand Down
1 change: 1 addition & 0 deletions api/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1126,6 +1126,7 @@ for (const file of ['index.html', 'board.js', 'board.css', 'device.js', 'manifes
file === 'index.html' ? ['/kitchen/', '/kitchen/index.html'] : '/kitchen/' + file,
(_req, res) => {
res.set('Cache-Control', 'no-store');
// Fixed server-owned filenames also work from hidden managed checkouts.
res.sendFile(path.join(__dirname, 'src', 'kitchen-board', file), { dotfiles: 'allow' });
}
);
Expand Down
4 changes: 2 additions & 2 deletions api/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "Api_v2_express",
"version": "1.8.3",
"version": "1.8.4",
"description": "Posnic API v2 - Express.js Backend (1:1 PHP Replica)",
"main": "server.js",
"scripts": {
Expand Down
4 changes: 4 additions & 0 deletions api/src/controllers/items.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,8 @@ class ItemsController extends BaseController {
homebanner: data.store?.homebanner || '',
advertisement: data.store?.advertisement || '',
},
money: require('../utils/currency').policy(data.store || {}),
restaurant_service_v1: true,
kiosk_payment: data.payment || {},
kiosk_print: data.print || {},
},
Expand Down Expand Up @@ -1001,6 +1003,8 @@ class ItemsController extends BaseController {
homebanner: data.store?.homebanner || '',
advertisement: data.store?.advertisement || '',
},
money: require('../utils/currency').policy(data.store || {}),
restaurant_service_v1: true,
kiosk_payment: data.payment || {},
/* The floor plan. The captain app reads this to draw its tables, and
is the only caller that ever did. */
Expand Down
68 changes: 66 additions & 2 deletions api/src/controllers/sales.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -7053,6 +7053,69 @@ class SalesController extends BaseController {
* wall showed an empty list for ever, while setup mode filled itself with
* samples and looked perfect. This is what feeds it.
*/
async kitchenDeliveryReport(req, res) {
try {
return this.success(
res,
await require('../services/kitchen-delivery').report(req),
'Received'
);
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}
async kitchenDisplayReport(req, res) {
try {
return this.success(
res,
await require('../services/kitchen-delivery').displayReport(req),
'Received'
);
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}
async kitchenDeliveryStatus(req, res) {
try {
return this.success(
res,
await require('../services/kitchen-delivery').status(req),
'Delivery status'
);
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}
async handoverStaff(req, res) {
try {
return this.success(res, await require('../services/captain-service').staff(req), 'Staff');
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}
async handoverOrder(req, res) {
try {
return this.success(
res,
await require('../services/captain-service').handover(req),
'Order handed over'
);
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}
async fireKitchenItems(req, res) {
try {
return this.success(
res,
await require('../services/captain-service').fire(req),
'Course sent to kitchen'
);
} catch (error) {
return this.error(res, error.message, error.status || 500);
}
}

async serveKitchenItems(req, res) {
try {
const out = await require('../repositories/sale.repository').serveKitchenItems({
Expand Down Expand Up @@ -7516,6 +7579,7 @@ class SalesController extends BaseController {
*/
async updateOrder(req, res) {
try {
const editPolicy = await require('../services/captain-edit-policy').authorize(req);
const orderId = req.body.order_id;
const items = req.body.items || [];
const totalAmount = req.body.total_amount || 0;
Expand Down Expand Up @@ -7551,7 +7615,7 @@ class SalesController extends BaseController {
newTableNo,
dineType,
personCount,
{ SaleModel, newTableId, seenAt }
{ SaleModel, newTableId, seenAt, editPolicy }
);

if (response.status === true) {
Expand All @@ -7561,7 +7625,7 @@ class SalesController extends BaseController {
}
} catch (error) {
console.error('Error in updateOrder:', error);
return this.error(res, error.message, 500);
return this.error(res, error.message, error.status || 500);
}
}

Expand Down
10 changes: 8 additions & 2 deletions api/src/helpers/bill-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function qtyText(value) {
* and its amount is already inside the total.
*/
function itemLines(sale, branch) {
const monetary = require('../utils/currency').policy(branch || {});
const rows = Array.isArray(sale && sale.items) ? sale.items : [];
return rows
.filter((it) => it && !it.return && String(it.name || it.item_name || '').trim())
Expand Down Expand Up @@ -142,9 +143,12 @@ function itemLines(sale, branch) {
* blank stripe down a year of old bills.
*/
hsn: wants(branch, 'bill_print_hsn') ? String(it.hsncode || '').trim() : '',
rate: rate > 0 ? rate.toFixed(2) : '',
rate: rate > 0 ? rate.toFixed(monetary.currencyDigits) : '',
qty: qtyText(qty),
amount: Math.round(rate * qty * 100) / 100,
amount: require('../utils/currency').fromMinor(
require('../utils/currency').toMinor(rate * qty, monetary),
monetary
),
};
});
}
Expand Down Expand Up @@ -522,6 +526,8 @@ function buildBillPayload(sale = {}, branch = {}) {
* Empty for a shop that has not set one, which prints bare numbers, as
* it always has.
*/
currencyCode: require('../utils/currency').policy(branch || {}).currencyCode,
currencyDigits: require('../utils/currency').policy(branch || {}).currencyDigits,
currency: String((branch && branch.currency) || '')
.trim()
.slice(0, 4),
Expand Down
69 changes: 53 additions & 16 deletions api/src/helpers/kitchen-rounds.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use strict';
const { createHash } = require('crypto');
const orderLine = require('../utils/order-line');
const serviceLine = require('../utils/service-line');

// Change positions are append-only ticket identities, independent of product IDs.
function date(value) {
Expand All @@ -20,20 +22,37 @@ function rounds(sale, { descriptions = true } = {}) {
const result = [];
const current = new Map();
for (const line of sale.items || []) {
const key = product(line);
const key = orderLine.key(line) || product(line);
current.set(key, (current.get(key) || 0) + quantity(line));
}
const changes = Array.isArray(sale.changes) ? sale.changes : [];
for (let c = 0; c < changes.length; c++) {
const change = changes[c];
for (let i = 0; i < (change.items || []).length; i++) {
const line = change.items[i];
const key = product(line);
const key = orderLine.key(line) || product(line);
const qty = quantity(line);
if (String(line.process).toLowerCase() === 'cancel') {
if (String(line.process).toLowerCase() === 'amend') {
for (const original of result.filter((row) => row.line_key === key)) {
const held = original.held;
Object.assign(original, serviceLine.metadata(line), {
held,
note: String(line.item_description || ''),
});
}
} else if (String(line.process).toLowerCase() === 'fire') {
const original = result.find(
(row) => row.id === line.source_round_line && row.line_key === key
);
if (original && original.held) {
original.held = false;
original.fired_at = date(change.timestamp);
original.round = `c${c}`;
}
} else if (String(line.process).toLowerCase() === 'cancel') {
let remaining = qty;
// Cancel the newest outstanding additions first, keeping earlier service history.
for (const previous of [...result].reverse().filter((row) => row.product === key)) {
for (const previous of [...result].reverse().filter((row) => row.line_key === key)) {
const removed = Math.min(remaining, previous.quantity);
previous.quantity -= removed;
remaining -= removed;
Expand All @@ -42,11 +61,15 @@ function rounds(sale, { descriptions = true } = {}) {
result.push({
id: `c${c}i${i}`,
round: `c${c}`,
product: key,
product: product(line),
line_key: key,
...serviceLine.metadata(line),
ordered_at: date(change.timestamp) || date(sale.created_date),
quantity: qty,
name: String(line.item_name || line.name || ''),
note: String(line.item_note || (descriptions && line.item_description) || ''),
note: String(
line.item_note || ((descriptions || line.line_id) && line.item_description) || ''
),
spice_level: line.spice_level,
});
}
Expand All @@ -55,9 +78,9 @@ function rounds(sale, { descriptions = true } = {}) {
// Legacy tickets without complete change logs still appear and can be served.
for (let i = 0; i < (sale.items || []).length; i++) {
const line = sale.items[i],
key = product(line);
key = orderLine.key(line) || product(line);
const logged = result
.filter((row) => row.product === key)
.filter((row) => row.line_key === key)
.reduce((n, row) => n + row.quantity, 0);
const missing = Math.min(quantity(line), Math.max(0, (current.get(key) || 0) - logged));
if (missing)
Expand All @@ -76,25 +99,34 @@ function rounds(sale, { descriptions = true } = {}) {
.digest('hex')
.slice(0, 24),
round: 'legacy',
product: key,
product: product(line),
line_key: key,
...serviceLine.metadata(line),
ordered_at: date(sale.created_date),
quantity: missing,
name: String(line.item_name || line.name || line.sale_inline_item_name || ''),
note: String(line.item_note || (descriptions && line.item_description) || ''),
note: String(
line.item_note || ((descriptions || line.line_id) && line.item_description) || ''
),
spice_level: line.spice_level,
});
}
const groups = new Map();
for (const row of result) {
row.quantity = Math.min(row.quantity, current.get(row.product) || 0);
current.set(row.product, Math.max(0, (current.get(row.product) || 0) - row.quantity));
row.quantity = Math.min(row.quantity, current.get(row.line_key) || 0);
current.set(row.line_key, Math.max(0, (current.get(row.line_key) || 0) - row.quantity));
if (!row.quantity) continue;
const service = (sale.kitchen_service || {})[row.id] || {};
row.served = Math.min(row.quantity, Math.max(0, Number(service.quantity) || 0));
row.served_at = date(service.at);
row.remaining = row.quantity - row.served;
if (!groups.has(row.round))
groups.set(row.round, { id: row.round, ordered_at: row.ordered_at, items: [] });
groups.set(row.round, {
id: row.round,
ordered_at: row.ordered_at,
fired_at: row.fired_at || null,
items: [],
});
groups.get(row.round).items.push(row);
}
return [...groups.values()];
Expand All @@ -104,22 +136,27 @@ function tickets(sale) {
? null
: date(sale.bill_requested_at || sale.bill_printed_at);
return rounds(sale, { descriptions: false }).flatMap((round) => {
if (closed && (!round.ordered_at || round.ordered_at <= closed)) return [];
const kitchenTime = round.fired_at || round.ordered_at;
if (closed && (!kitchenTime || kitchenTime <= closed)) return [];
const items = round.items
.filter((line) => line.remaining > 0)
.filter((line) => !line.held && line.remaining > 0)
.map((line) => ({
id: line.id,
qty: line.remaining,
name: line.name,
note: line.note,
seat: line.seat,
course: line.course,
allergies: line.allergies,
allergy_note: line.allergy_note,
}));
return items.length
? [
{
id: `${sale._id}:${round.id}`,
table: String(sale.table_number || ''),
orderNumber: String(sale.sales_id || sale.token_id || ''),
placedAt: round.ordered_at,
placedAt: kitchenTime,
items,
},
]
Expand Down
6 changes: 5 additions & 1 deletion api/src/helpers/kot-notify.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@ function notifyKotReady(details = {}) {
// Send only the committed delta, with a distinct identity per revision.
if (Array.isArray(details.items)) {
for (const kind of ['cancel', 'add']) {
const items = details.items.filter((item) => item.process === kind);
const items = details.items.filter(
(item) =>
!item.held &&
(item.process === kind || (kind === 'add' && ['fire', 'amend'].includes(item.process)))
);
if (!items.length) continue;
const eventKey = require('crypto')
.createHash('sha256')
Expand Down
2 changes: 2 additions & 0 deletions api/src/kitchen-board/board.css
Original file line number Diff line number Diff line change
Expand Up @@ -349,3 +349,5 @@ footer {
}
}
}

.allergy {display:block;grid-column:1/-1;color:#fff;background:#8b1717;padding:6px 10px;border-radius:6px;font-size:1rem;}
13 changes: 13 additions & 0 deletions api/src/kitchen-board/board.js
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,19 @@
const li = node('li');
li.append(node('span', 'quantity', item.qty + '×'), node('span', 'name', item.name));
if (item.note) li.append(node('span', 'note', item.note));
if (item.seat || item.course)
li.append(
node(
'span',
'note',
[item.seat ? 'Seat ' + item.seat : '', item.course || '']
.filter(Boolean)
.join(' · ')
)
);
const allergies = [...(item.allergies || []), item.allergy_note || ''].filter(Boolean);
if (allergies.length)
li.append(node('strong', 'allergy', 'ALLERGY: ' + allergies.join(', ')));
const total = item.total ?? item.qty,
ready = item.ready ?? (stage === 'ready' ? total : 0);
const collected = item.collected || 0,
Expand Down
Loading
Loading