Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
d468975
The bill reads like a bill
sridharkalaibala Sep 16, 2026
349136e
And the hotel bill test knows where the count went
sridharkalaibala Sep 16, 2026
67769ed
Develop is green again, for the third time
sridharkalaibala Sep 16, 2026
fac3b5d
Merge pull request #836 from Posnic/fix/develop-green-again-2
sridharkalaibala Sep 16, 2026
aaf03ba
Merge branch 'develop' into feat/the-bill-reads-like-a-bill
sridharkalaibala Sep 16, 2026
90cec1e
Merge pull request #835 from Posnic/feat/the-bill-reads-like-a-bill
sridharkalaibala Sep 16, 2026
22602b2
The kitchen hears a ticket arrive
sridharkalaibala Sep 16, 2026
d163cbc
A setting that reaches the till
sridharkalaibala Sep 16, 2026
993566c
Merge pull request #837 from Posnic/feat/the-kitchen-hears-a-ticket
sridharkalaibala Sep 16, 2026
0ef57c0
Merge pull request #838 from Posnic/fix/a-setting-that-reaches-the-till
sridharkalaibala Sep 16, 2026
daaec67
The kitchen is told what it is worth, where that matters
sridharkalaibala Sep 16, 2026
7b22651
Merge pull request #840 from Posnic/feat/the-kitchen-is-told-what-it-…
sridharkalaibala Sep 16, 2026
ae646e9
The kitchen files are packaged, or the installer cannot find them
sridharkalaibala Sep 16, 2026
47f8620
Merge pull request #841 from Posnic/fix/the-kitchen-files-are-packaged
sridharkalaibala Sep 16, 2026
2ff6620
The kitchen can actually hear it: a voice, real pauses, and no gesture
sridharkalaibala Sep 16, 2026
fcc3be9
How many plates are coming, printed and said
sridharkalaibala Sep 16, 2026
ce426d4
Merge pull request #842 from Posnic/fix/the-kitchen-can-actually-hear-it
sridharkalaibala Sep 16, 2026
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
2 changes: 1 addition & 1 deletion 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-658%20endpoints-blue)](docs/API.md)
[![API](https://img.shields.io/badge/REST%20API-659%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
29 changes: 24 additions & 5 deletions api/src/helpers/bill-payload.js
Original file line number Diff line number Diff line change
Expand Up @@ -415,15 +415,31 @@ function extraRows(sale, branch, items) {
*/
add('bill_print_steward', 'Steward', sale && (sale.created_by || sale.user_name));

/*
* Total quantity is NOT here. It is a count of what was bought, so it belongs
* beside the subtotal where a reader is already adding things up, not in the
* header with the table number. Every printed bill that carries an item count
* puts it there. See totalQty on the payload.
*/

return out;
}

/**
* How many dishes, as opposed to how many lines.
*
* Four parathas and a pulao is five, which is the number a hotel prints and the
* number a guest counts.
*
* @returns {string} the count, or '' when the shop has not asked for it
*/
function totalQuantity(branch, items) {
if (!wants(branch, 'bill_print_total_qty')) return '';
const qty = (items || []).reduce(
(sum, it) => sum + num(String(it.qty == null ? '' : it.qty).split(' ')[0]),
0
);
/* Total quantity is dishes, not lines: 4 parathas and a pulao is 5, which is
the number a hotel prints and the number a guest counts. */
add('bill_print_total_qty', 'Total Qty', qty > 0 ? String(Math.round(qty * 1000) / 1000) : '');

return out;
return qty > 0 ? String(Math.round(qty * 1000) / 1000) : '';
}

/**
Expand Down Expand Up @@ -466,6 +482,9 @@ function buildBillPayload(sale = {}, branch = {}) {

/* Not "RECEIPT" and not "TAX INVOICE". Nobody has paid yet, and calling it
either would be a document this shop has not issued. */
/* Beside the subtotal, not in the header - see totalQuantity. */
totalQty: totalQuantity(branch, items),

title: 'BILL',
billNo: String(sale.sales_id || '').trim(),
date: stamp(sale.date || sale.created_at),
Expand Down
46 changes: 46 additions & 0 deletions api/src/models/setting.model.js
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,29 @@ class SettingModel extends BaseModel {
license: this.normalizeId(this.licenseId),
};

/*
* STAMP IT, OR THE TILL NEVER LEARNS.
*
* These settings live on the branch document, and `branches` is a synced
* collection at global scope - "tenant-wide data every device needs". But
* the sync gateway only offers a device rows whose updated_date has
* moved:
*
* { updated_date: { $gt: since.ts } }
* { $expr: { $gt: ['$updated_date', '$_syncMeta.at'] } }
*
* and this write goes through the NATIVE driver, so the schema's
* `timestamps: { updatedAt: 'updated_date' }` never fires. Nothing else
* here set it either.
*
* The result was a shop whose branch document still carried its creation
* date ten months later. Every settings change it had ever made was
* correct in the cloud, correct on the web, and invisible to its till -
* the address on its printed bills, the bill-print switches, all of it.
* Nothing reported a failure because nothing had failed; the row was
* simply never eligible to travel.
*/
updateData.updated_date = new Date();
const updateResult = await collection.updateOne(filter, { $set: updateData });

// Check if no document was matched
Expand Down Expand Up @@ -1091,6 +1114,29 @@ class SettingModel extends BaseModel {
_id: this.normalizeId(this.branchId),
license: this.normalizeId(this.licenseId),
};
/*
* STAMP IT, OR THE TILL NEVER LEARNS.
*
* These settings live on the branch document, and `branches` is a synced
* collection at global scope - "tenant-wide data every device needs". But
* the sync gateway only offers a device rows whose updated_date has
* moved:
*
* { updated_date: { $gt: since.ts } }
* { $expr: { $gt: ['$updated_date', '$_syncMeta.at'] } }
*
* and this write goes through the NATIVE driver, so the schema's
* `timestamps: { updatedAt: 'updated_date' }` never fires. Nothing else
* here set it either.
*
* The result was a shop whose branch document still carried its creation
* date ten months later. Every settings change it had ever made was
* correct in the cloud, correct on the web, and invisible to its till -
* the address on its printed bills, the bill-print switches, all of it.
* Nothing reported a failure because nothing had failed; the row was
* simply never eligible to travel.
*/
updateData.updated_date = new Date();
const result = await collection.updateOne(filter, { $set: updateData });
if (result.matchedCount === 0) {
return {
Expand Down
24 changes: 24 additions & 0 deletions api/src/repositories/sale.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -10343,6 +10343,10 @@ class SalesRepository {
const extras = await this._priceModifiers(item.modifiers, itemDoc, branchDoc);
if (extras.status === false) return extras;

/* A one-off invented at the table: the shop never chose this price on a
card, somebody agreed it with a guest. Same reason as a market price. */
const oneOff = String(itemDoc.item_status || '').toLowerCase() === 'instant';

const sellingPrice = partnerVenues.priceFor(
(dynamic ? asked : catalogue) + extras.delta,
servicePoint.venue
Expand Down Expand Up @@ -10370,6 +10374,26 @@ class SalesRepository {
unit_price: round(baseUnitPrice),
tax_amount: taxAmt,
total: itemTotal,
/*
* WHAT THE KITCHEN NEEDS TO KNOW ABOUT THE MONEY, and only where it
* does.
*
* Owner: "lets customer wants to have fish for rs500 so that kitchen
* will prepare according to that."
*
* For a whole fish, a crab, or something a waiter typed in at the
* table, the PRICE IS THE SPECIFICATION. Five hundred rupees of fish
* is a particular fish; the kitchen cannot pick one from the name
* alone. For an ordinary dish off the card it is noise - a cook does
* not choose a biryani differently because it costs 220 - and a ticket
* that prints money on every line is one where the line that matters
* stops standing out.
*
* So it travels only for the two kinds that were priced at the table:
* a dish the shop prices on the day, and a one-off somebody invented
* for this bill.
*/
...(dynamic || oneOff ? { priced_at_table: round(finalUnit) } : {}),
/*
* What the table actually asked for, kept beside the money it cost.
* The kitchen ticket needs it to cook the right thing and the bill
Expand Down
110 changes: 110 additions & 0 deletions api/tests/unit/a-setting-that-reaches-the-till.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
'use strict';

/*
* A SETTING THAT NEVER LEAVES THE CLOUD.
*
* A shop changed its address, and its till kept printing the old one on every
* bill. Not for a moment - for ten months. Its branch document still carried
* `updated_date` equal to `created_date`, and every settings change it had ever
* made was correct in the cloud, correct on the web, and invisible to the
* machine at the counter.
*
* Nothing had failed. The sync gateway offers a device only the rows whose
* watermark has moved:
*
* { updated_date: { $gt: since.ts } }
* { $expr: { $gt: ['$updated_date', '$_syncMeta.at'] } }
*
* and the settings write goes through the NATIVE driver, so the schema's
* `timestamps: { updatedAt: 'updated_date' }` never fires. The row was simply
* never eligible to travel, and no error exists for that.
*
* This is the shape of bug this codebase keeps producing: a thing that is
* written, is correct, and is read by nobody. It is the same family as the
* module packaged where nothing could require it and the guard whose regex
* could never match.
*
* So this asserts on the WRITE ITSELF - what actually reaches updateOne -
* rather than on a helper that might not be the one the save path calls.
*/

const path = require('path');
const fs = require('fs');

const SOURCE = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'models', 'setting.model.js'),
'utf8'
);

/* Comments stripped before anything is asserted about the body: an assertion
that matches its own explaining comment has been written here before. */
const CODE = SOURCE.replace(/\/\*[\s\S]*?\*\//g, '')
.split('\n')
.map((line) => line.replace(/(^|[^:])\/\/.*$/, '$1'))
.join('\n');

describe('a settings save', () => {
test('STAMPS updated_date, or no till will ever be offered the change', () => {
const stamps = (CODE.match(/updateData\.updated_date\s*=/g) || []).length;
expect(stamps).toBeGreaterThan(0);
});

test('and every branch write is stamped, not just the first one found', () => {
/*
* Two separate saves write to the branch document - the feature switches
* and the locale/currency block. Fixing one and not the other leaves half
* the settings screen silently stuck, which is worse than fixing neither,
* because it looks like it works.
*/
/* Every `const collection = await this.getCollection(<arg>)`, with where it
is and what it opened. An empty arg is the branch collection; the payment
settings name their own. */
const opens = [...CODE.matchAll(/await this\.getCollection\(([^)]*)\)/g)].map((m) => ({
at: m.index,
arg: m[1].trim(),
}));
const writes = [
...CODE.matchAll(/await collection\.updateOne\(filter, \{ \$set: updateData \}\)/g),
];

const unstamped = [];
let branchWrites = 0;
for (const write of writes) {
const opened = opens.filter((o) => o.at < write.index).pop();
if (!opened || opened.arg !== '') continue;
branchWrites += 1;
const before = CODE.slice(Math.max(0, write.index - 400), write.index);
if (!/updateData\.updated_date\s*=/.test(before)) {
unstamped.push(CODE.slice(0, write.index).split('\n').length);
}
}

expect(branchWrites).toBeGreaterThan(1);
expect(unstamped).toEqual([]);
});

test('the stamp is a real date, not a string the gateway cannot compare', () => {
/* `updated_date: '2026-09-16'` would sort as text against a BSON date and
the comparison in the gateway would stop meaning anything. */
expect(CODE).toMatch(/updateData\.updated_date\s*=\s*new Date\(\)/);
});
});

describe('why this cannot be left to the schema', () => {
test('the write goes through the native driver, where timestamps do not fire', () => {
/*
* If this ever becomes a Mongoose call, `timestamps` handles it and the
* explicit stamp is redundant - but it is not redundant today, and this
* test is what will say so if the answer changes.
*/
expect(CODE).toMatch(/await collection\.updateOne\(/);
});

test('and the model does declare the timestamp it is not getting', () => {
const branchModel = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'models', 'branch.model.js'),
'utf8'
);
expect(branchModel).toMatch(/updatedAt:\s*'updated_date'/);
});
});
13 changes: 13 additions & 0 deletions api/tests/unit/middleware/items.validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ jest.mock('express-validator', () => {
isFloat: jest.fn().mockReturnThis(),
optional: jest.fn().mockReturnThis(),
isMongoId: jest.fn().mockReturnThis(),
/* Every link the real chain offers. A fake that is missing one does not
fail where it is missing - it fails at require time, and takes the whole
suite down with "is not a function" and zero tests run. isBoolean
arrived with the sold-out validator. */
isBoolean: jest.fn().mockReturnThis(),
isInt: jest.fn().mockReturnThis(),
isString: jest.fn().mockReturnThis(),
isIn: jest.fn().mockReturnThis(),
toBoolean: jest.fn().mockReturnThis(),
toInt: jest.fn().mockReturnThis(),
bail: jest.fn().mockReturnThis(),
custom: jest.fn().mockReturnThis(),
exists: jest.fn().mockReturnThis(),
});
return { body: jest.fn(() => chain()), param: jest.fn(() => chain()) };
});
Expand Down
61 changes: 61 additions & 0 deletions api/tests/unit/repositories/a-dish-priced-on-the-day.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -418,3 +418,64 @@ describe('an ordinary dish', () => {
expect(out.line.total).toBe(997.5);
});
});

/*
* WHAT THE KITCHEN IS TOLD ABOUT THE MONEY.
*
* Owner: "lets customer wants to have fish for rs500 so that kitchen will
* prepare according to that."
*
* Five hundred rupees of fish is a particular fish. For a dish priced at the
* table the price IS the specification, and the kitchen cannot pick the right
* one from the name. For an ordinary dish off the card it is noise: a cook
* does not choose a biryani differently because it costs 220, and a ticket
* with money on every line is one where the line that matters stops standing
* out.
*
* So the line carries it only where it means something, and the kitchen ticket
* prints what it is given. See src/escpos-kot.js.
*/
describe('the price the kitchen is told', () => {
test('A DISH PRICED AT THE TABLE CARRIES ITS PRICE', async () => {
const doc = await anItem({ selling_price: 0 });

const out = await price(doc, { item_id: String(doc._id), item_quantity: 1, item_price: 500 });

expect(out.line.priced_at_table).toBe(500);
});

test('an ordinary dish carries none', async () => {
const doc = await anItem({ selling_price: 220 });

const out = await price(doc, { item_id: String(doc._id), item_quantity: 2 });

expect(out.line.priced_at_table).toBeUndefined();
});

test('a one-off invented at the table carries it too', async () => {
/*
* A quick sale has a catalogue price - it was just created - so the
* dynamic test alone would miss it. Its price was still agreed with a
* guest rather than chosen on a card, which is the thing that matters.
*/
const doc = await anItem({ selling_price: 750, item_status: 'instant' });

const out = await price(doc, { item_id: String(doc._id), item_quantity: 1 });

expect(out.line.priced_at_table).toBe(750);
});

test('a dish priced this morning carries none, because the card prices it', async () => {
/* daily_price set TODAY is an ordinary dish at the catalogue rate - that
is the whole point of the shop updating it when they open. */
const doc = await anItem({
selling_price: 900,
daily_price: true,
price_set_on: new Date(),
});

const out = await price(doc, { item_id: String(doc._id), item_quantity: 1 });

expect(out.line.priced_at_table).toBeUndefined();
});
});
4 changes: 4 additions & 0 deletions api/tests/unit/routes/items.routes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ jest.mock('../../../src/middleware/auth', () => ({
jest.mock('../../../src/middleware/items.validation', () => ({
validateCreateItem: [],
validateUpdateItem: [],
/* A validator the route names and the mock does not provide is `undefined`
handed to router.post, which throws "argument handler must be a function"
at require time and fails the entire suite before a single test runs. */
validateSoldOut: [],
ensureValidItemIdParam: jest.fn((req, res, next) => next()),
}));

Expand Down
1 change: 1 addition & 0 deletions frontend/pages_css_js_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@
"static/script/js/core/demo-mode.js",
"static/script/js/core/desktop-nudge.js",
"static/script/js/core/order-alert.js",
"static/script/js/core/kitchen-call.js",
"static/script/js/core/online-order-watch.js",
"static/script/js/core/request-dock.js"
]
Expand Down
Loading
Loading