diff --git a/api/src/controllers/sales.controller.js b/api/src/controllers/sales.controller.js
index 540714e62..7cc13d6e7 100644
--- a/api/src/controllers/sales.controller.js
+++ b/api/src/controllers/sales.controller.js
@@ -696,6 +696,14 @@ class SalesController extends BaseController {
return !isApprovedFor(token, 'discount_apply', req.user && req.user._id);
}
+ canOverrideCounterPrice(req, payload = {}) {
+ if (!req.user?._id) return false;
+ const token = payload.price_override_approval_token || payload.approval_token;
+ return (
+ canPos(req.user, 'price_override') || isApprovedFor(token, 'price_override', req.user._id)
+ );
+ }
+
async buildSaleContext(req) {
const { branch_id, branch_name } = this.resolveBranchContext(req);
const user = req.user || {};
@@ -798,6 +806,7 @@ class SalesController extends BaseController {
// Build Context for Service
const context = await this.buildSaleContext(req);
+ context.allowCounterPriceOverride = this.canOverrideCounterPrice(req, payload);
// Delegate core Add / Hold business logic to salesService.processSale,
// which is a line-by-line port of PHP sales_model::salesInsertUpdate.
@@ -1001,6 +1010,7 @@ class SalesController extends BaseController {
// salesInsertUpdate parity for $process = 'Edit').
// Enrich the context with branch-specific settings via the service
context = await salesService.enrichSaleContext(context);
+ context.allowCounterPriceOverride = this.canOverrideCounterPrice(req, payload);
const result = payload.paid_bill_correction
? await require('../services/paid-bill-edit').amend(req, payload, context)
diff --git a/api/tests/unit/controllers/sales.controller.test.js b/api/tests/unit/controllers/sales.controller.test.js
index 66b198f69..d8fdb6765 100644
--- a/api/tests/unit/controllers/sales.controller.test.js
+++ b/api/tests/unit/controllers/sales.controller.test.js
@@ -361,6 +361,53 @@ describe('SalesController', () => {
});
describe('create', () => {
+ test.each([true, false])(
+ 'counter price permission comes from staff access (%s)',
+ async (allowed) => {
+ const res = mockRes();
+ await ctrl.create(
+ mockReq({
+ user: adminUser({
+ usertype: 'cashier',
+ role: 'cashier',
+ access: { pos: { price_override: allowed } },
+ }),
+ body: {
+ allowCounterPriceOverride: true,
+ items: [{ item_id: VALID_ID, item_quantity: 1 }],
+ },
+ }),
+ res,
+ mockNext()
+ );
+ expect(salesService.processSale.mock.calls[0][3].allowCounterPriceOverride).toBe(allowed);
+ }
+ );
+
+ test('price approval must be signed, current and bound to this cashier and action', () => {
+ const { signApproval } = require('../../../src/utils/approval-token.util');
+ const req = mockReq({
+ user: adminUser({
+ usertype: 'cashier',
+ role: 'cashier',
+ access: { pos: { price_override: false } },
+ }),
+ });
+ for (const [claims, allowed] of [
+ [{ action: 'price_override', cashier_user_id: VALID_ID }, true],
+ [{ action: 'discount_apply', cashier_user_id: VALID_ID }, false],
+ [{ action: 'price_override', cashier_user_id: 'other' }, false],
+ [{ action: 'price_override', cashier_user_id: VALID_ID, exp: Date.now() - 1000 }, false],
+ ]) {
+ expect(
+ ctrl.canOverrideCounterPrice(req, { price_override_approval_token: signApproval(claims) })
+ ).toBe(allowed);
+ }
+ expect(ctrl.canOverrideCounterPrice(req, { price_override_approval_token: 'forged' })).toBe(
+ false
+ );
+ });
+
test('200 success', async () => {
const res = mockRes();
const next = mockNext();
diff --git a/api/tests/unit/services/a-coupon-works-on-the-ordering-page.test.js b/api/tests/unit/services/a-coupon-works-on-the-ordering-page.test.js
index 701050ddf..801eeeeaf 100644
--- a/api/tests/unit/services/a-coupon-works-on-the-ordering-page.test.js
+++ b/api/tests/unit/services/a-coupon-works-on-the-ordering-page.test.js
@@ -169,7 +169,9 @@ describe('the order is where the money is decided', () => {
test('and it cannot make the shop pay the customer', () => {
expect(order).toMatch(/Math\.max\(0, round\(foodTotal - couponDiscount\)\)/);
/* On the food, not on the delivery fee. */
- expect(order).toMatch(/roundSaleTotal\(\s*round\(afterCoupon \+ deliveryFee \+ roomChargeTotal\)/);
+ expect(order).toMatch(
+ /roundSaleTotal\(\s*round\(afterCoupon \+ deliveryFee \+ roomChargeTotal\)/
+ );
});
test('the redemption is recorded only after the order exists', () => {
diff --git a/api/tests/unit/services/pricing-authority.test.js b/api/tests/unit/services/pricing-authority.test.js
index 223e53972..2febe0326 100644
--- a/api/tests/unit/services/pricing-authority.test.js
+++ b/api/tests/unit/services/pricing-authority.test.js
@@ -27,6 +27,16 @@ test('inclusive water stays 30 including tax, including after quantity changes',
test('a channel label does not authorize an arbitrary price', () => {
expect(() => quote({ channel: 'online', submitted: 60 })).toThrow(/does not match/);
});
+test('counter override cannot bypass an outlet, price list or other channel', () => {
+ for (const context of [
+ { channel: 'online' },
+ { outlet: { id: 'room', markup_percent: 20 } },
+ { priceList: { _id: 'wholesale', percent_off: 10 } },
+ { venue: { code: 'hotel', price_adjust_percent: 20 } },
+ ]) {
+ expect(() => quote({ ...context, allowCounterPriceOverride: true, submitted: 70 })).toThrow();
+ }
+});
test('configured outlet and customer prices are enforced', () => {
expect(quote({ outlet: { id: 'room', markup_percent: 20 }, submitted: 54 }).source).toBe(
'outlet'
diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js
index 08d212c46..ca30c087f 100644
--- a/api/tests/unit/services/sale.service.test.js
+++ b/api/tests/unit/services/sale.service.test.js
@@ -235,6 +235,36 @@ describe('SalesService', () => {
});
describe('fixed-price admission', () => {
+ test.each([true, false])(
+ 'sale-only cart price respects host permission (%s)',
+ async (allowed) => {
+ const item = makeItemDoc();
+ mockItemRepositoryInstance.findItemById.mockResolvedValue(item);
+ const result = await salesService.processSale(
+ makeSaleData({
+ items: [makeItemPayload({ sale_inline_item_price: '70' })],
+ sales_total: '140',
+ allowCounterPriceOverride: true,
+ }),
+ '',
+ 'Add',
+ makeContext({ allowCounterPriceOverride: allowed })
+ );
+ expect(result.status).toBe(allowed);
+ expect(item.selling_price).toBe(100);
+ if (allowed) {
+ const saved = salesRepository.create.mock.calls[0][0];
+ expect(saved.items[0].pricing).toMatchObject({
+ selling_price: 70,
+ catalogue_price: 100,
+ source: 'counter_override',
+ });
+ } else {
+ expect(salesRepository.create).not.toHaveBeenCalled();
+ }
+ }
+ );
+
test.each(['undefined', 'null', '', ' ', null, undefined])(
'desktop missing inline override %j uses and validates the selling-price field',
async (inline) => {
diff --git a/api/tests/unit/utils/a-shop-with-no-tables-has-no-table-rules.test.js b/api/tests/unit/utils/a-shop-with-no-tables-has-no-table-rules.test.js
index 67500aff4..9c0a8ea3e 100644
--- a/api/tests/unit/utils/a-shop-with-no-tables-has-no-table-rules.test.js
+++ b/api/tests/unit/utils/a-shop-with-no-tables-has-no-table-rules.test.js
@@ -99,7 +99,9 @@ describe('the table limit asks whether there are tables', () => {
);
test('a shop that does not run table service is never refused for a table', () => {
- expect(REPO).toMatch(/const runsTableService = branchDoc\.table_options === true && !staffRoom;/);
+ expect(REPO).toMatch(
+ /const runsTableService = branchDoc\.table_options === true && !staffRoom;/
+ );
expect(REPO).toMatch(/if \(runsTableService && openTableLimit > 0 && wantsTable\)/);
});
diff --git a/docs/sale-only-price-edit.md b/docs/sale-only-price-edit.md
new file mode 100644
index 000000000..86e66dc0e
--- /dev/null
+++ b/docs/sale-only-price-edit.md
@@ -0,0 +1,26 @@
+# Sale-only cart price edits
+
+Desktop counter checkout now supplies the authenticated cashier's existing
+`price_override` permission to pricing admission. A signed, unexpired manager
+approval for the same cashier can also authorize the change. The cart editor
+passes that approval separately from discount approval. Payload permission flags
+are not trusted. Outlet, price-list and other channel rules remain enforced.
+Persisted historical pricing snapshots remain immutable through this path.
+
+The override is saved in the existing sale line and pricing snapshot; the item
+catalogue price is not updated. This fixes new counter sales and new lines, not
+historical bill repricing. Existing sale audit handling is unchanged.
+
+Synchronization uses the existing branch-scoped `sales` collection in the API
+and Gateway, and the bundled sync agent's realtime sales lane. No new collection
+or transport fields are introduced. Other authorized tills and the web receive
+the saved sale price through that lane; future sales still use the catalogue
+price. Approval tokens are request credentials, not shared business settings.
+No Gateway or agent change is needed. Live offline/reconnection propagation has
+not been exercised for this patch.
+
+Validation: 365 API controller, sale-service and pricing tests; two frontend
+regression tests. Cases cover sale-only 100-to-70 pricing without catalogue
+mutation, denied staff, forged/expired/wrong-cashier/wrong-action approvals and
+channel-specific price enforcement. ESLint reports no errors (existing warnings
+remain). Physical checkout on the client's updated installation is still pending.
diff --git a/docs/shelf-label-printing.md b/docs/shelf-label-printing.md
new file mode 100644
index 000000000..2ec729645
--- /dev/null
+++ b/docs/shelf-label-printing.md
@@ -0,0 +1,31 @@
+# Shelf-label desktop printing
+
+The shelf-label wizard now selects a local printer before submission. A missing selection is an error; it never borrows the receipt or kitchen printer. Printer choices are remembered separately for A4 and roll paper on this computer.
+
+- Initial batch: 5 labels. A single test always submits one label. The 2,000-label maximum remains available only when entered by the operator.
+- A4: render the same label HTML to an A4 PDF, then submit at actual size to the selected printer. Do not open the browser print dialog on desktop.
+- 80 mm receipt printers: explicitly choose the ESC/POS method. Labels may be up to 72 mm wide, matching the 576-dot printable area. Each label uses the existing raster renderer and durable raw queue, with one cut per label. A batch stops on the first failed/uncertain submission and reports the number previously sent.
+- Other label printers: driver printing receives explicit micron dimensions, no margins, 100% scale and no additional landscape rotation. Exact-size jobs never retry using a driver's default paper size.
+- Browser-only installations retain the system print dialog because they do not have the desktop printer bridge.
+
+## Local data and synchronization
+
+Product names, prices and barcodes still come from the existing tenant-scoped Items API, including label pricing. This change does not modify business data or its sync lanes. Printer names, print methods and layout preferences remain local to this installation/browser profile: another till may have different hardware. Print jobs are never synchronized or replayed on another till. No Gateway or sync-agent rollout is required.
+
+## Validation (18 October 2026)
+
+37 targeted shelf-label and desktop-PDF tests passed. Real POS-80C output was submitted using the existing raw queue; the operator confirmed correct horizontal orientation and improved readability on the larger sample. The dotted cutting guide was then removed for the thermal sample. Barcode scanning has not yet been physically verified.
+
+The operator subsequently confirmed Brother DCP-T710W output with a photograph of all five sample labels, arranged in three columns. The test was configured for A4 with 63 x 33 mm labels, 5 mm sheet margins and 2 mm gaps, allowing 24 positions (three columns by eight rows). Only five positions were requested. The photograph confirms readable output and cutting guides, but does not establish measured dimensions or barcode scan reliability. Both an Electron HTML job and an actual-size PDF job had been submitted, so the photograph alone does not identify which submission produced this sheet. A separate direct GDI connection-test page was not specifically confirmed.
+
+The frontend production build also passed, with existing Sass deprecation warnings. The installed application and final packaged installer still require verification with these source changes.
+
+These are source changes, not yet installed on the client's machine or included in a released handover.
+
+## Follow-up design and receipt modes
+
+The full 24-label A4 sheet was physically confirmed by the operator. Product names and prices are larger; roll content starts 1 mm from the label top instead of being vertically centred. The thermal preview shows the 80 mm paper around the 72 mm print head. Feed/cutter margins depend on the printer and are not promised as an exact screen match. Existing saved design preferences are preserved.
+
+The three existing receipt modes remain available under Settings > Printers & paper: Exact design (raster), Fast receipt (text with Exact fallback for unsupported content), and Windows driver. Explicit physical tests were submitted using the actual receipt designer and HardwareManager. Exact and Fast returned success; Windows driver reported no matching spooler job and was not retried. The operator subsequently confirmed all three physical samples. The driver spooler check was a false negative; no duplicate was submitted.
+
+126 targeted tests passed, including receipt modes, printer settings, receipt designer, shelf labels and PDF printing.
diff --git a/frontend/modules/items.html b/frontend/modules/items.html
index 7360a140a..d48115c55 100644
--- a/frontend/modules/items.html
+++ b/frontend/modules/items.html
@@ -221,8 +221,8 @@
It
Sample label
Check before printing
Print shelf labels
PaperTotal labelsPages in this batchScaleActual size · 100%
-
Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.
-
Large selections can be printed in batches of up to 2,000 labels.
+
For an 80 mm receipt printer, use label width up to 72 mm (the printable area). Preview shows an 80 mm roll with 4 mm unprintable sides and a 72 mm printable area. Printer feed and cutter margins vary. Each label is cut separately.
Print directly to the selected printer at the label size shown above.
Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.
+
Start with one test label. The batch starts at 5; increase it when you are ready (maximum 2,000).
';
}
+ function modeDetails() {
+ if (!desktop()) return '';
+ return '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_exact', 'Exact design')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_exact_route', 'Thermal paper: HTML layout rendered as an image, sent directly using ESC/POS. Preserves logos, QR codes, barcodes and alignment. No PDF conversion.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_fast', 'Fast receipt')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_fast_route', 'Thermal paper: standard text sent directly using ESC/POS, without image or PDF rendering. Graphics or unsupported characters automatically use Exact design instead.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_mode_driver', 'Windows driver (advanced)')) + ' — ' + esc(PosnicPro.i18n.t('lang_print_driver_route', 'HTML layout printed through the installed Windows printer driver. Preserves graphics; size and speed depend on the driver. PDF is a fallback only after a retry-safe failure.')) + '
' +
+ '
' + esc(PosnicPro.i18n.t('lang_print_modes_scope', 'These modes apply to sales receipts. Sheet-size receipts use the driver path. Invoices and quotations print as PDF; shelf labels have their own printer settings. Compare sample prints on your printer before saving a mode.')) + '
';
+ }
function languageSettings() {
var words = { '': PosnicPro.i18n.t('lang_item_original_language_print', 'Original item name') };
(window.PosnicItemText ? window.PosnicItemText.languages : []).forEach(function (code) {
@@ -77,7 +85,7 @@
host.html('
' + esc(PosnicPro.i18n.t('lang_print_this_device', 'Saved on this computer')) + '
' +
esc(desktop() ? PosnicPro.i18n.t('lang_print_device_help', 'Choose a printer and paper for each document. Other computers keep their own choices.') : PosnicPro.i18n.t('lang_print_browser_help', 'Paper choices are saved in this browser. Choose the printer and copies in the browser print dialog.')) + '