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
10 changes: 10 additions & 0 deletions api/src/controllers/sales.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || {};
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down
47 changes: 47 additions & 0 deletions api/tests/unit/controllers/sales.controller.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 10 additions & 0 deletions api/tests/unit/services/pricing-authority.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
30 changes: 30 additions & 0 deletions api/tests/unit/services/sale.service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
26 changes: 26 additions & 0 deletions docs/sale-only-price-edit.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 31 additions & 0 deletions docs/shelf-label-printing.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions frontend/modules/items.html
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,8 @@ <h4 class="page-title break_title mb-0"> <lang class="lang_item_master_title">It
</div><div><h3><lang class="lang_sample_label">Sample label</lang></h3><div class="sl-sample-area"><div id="sl-sample" class="sl-preview-host"></div></div><div id="sl-dimensions" class="sl-dimensions"></div></div></div></section>
<section class="sl-panel" data-sl-panel="2" hidden><h3><lang class="lang_check_before_printing">Check before printing</lang></h3><div class="sl-row"><strong id="sl-layout-summary"></strong><div><select id="sl-preview-zoom" class="sl-input" aria-label="Preview zoom" data-t-aria-label="lang_rd_preview_zoom" style="width:auto;display:inline-block;margin-right:8px"><option value="fit"><lang class="lang_fit_whole_page">Fit whole page</lang></option><option value="actual"><lang class="lang_actual_size">Actual size</lang></option></select><button type="button" class="sl-button" id="sl-prev"><lang class="lang_previous_page">Previous page</lang></button> <span id="sl-page"></span> <button type="button" class="sl-button" id="sl-next"><lang class="lang_next_page">Next page</lang></button></div></div><p id="sl-summary" class="sl-muted" style="margin-bottom:14px"></p><div class="sl-page-area"><div id="sl-preview" class="sl-preview-host"></div></div></section>
<section class="sl-panel" data-sl-panel="3" hidden><h3><lang class="lang_print_shelf_labels">Print shelf labels</lang></h3><div class="sl-two-col"><div><div class="sl-job"><span><lang class="lang_desktop_paper">Paper</lang></span><strong id="sl-job-paper"></strong><span><lang class="lang_total_labels">Total labels</lang></span><strong id="sl-job-total"></strong><span><lang class="lang_pages_in_this_batch">Pages in this batch</lang></span><strong id="sl-job-pages"></strong><span><lang class="lang_scale">Scale</lang></span><strong><lang class="lang_actual_size_100">Actual size · 100%</lang></strong></div>
<p class="sl-muted" style="margin-bottom:18px"><lang class="lang_choose_the_printer_in_the_browser_print_di">Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.</lang></p><div class="sl-fields"><label class="sl-field"><lang class="lang_from_label">From label</lang><input id="sl-from" class="sl-input" type="number" value="1" min="1"></label><label class="sl-field"><lang class="lang_labels_in_this_batch">Labels in this batch</lang><input id="sl-batch" class="sl-input" type="number" value="2000" min="1" max="2000"></label></div>
<button type="button" class="sl-button" id="sl-test"><lang class="lang_print_one_test_label">Print one test label</lang></button><p class="sl-muted" style="margin-top:14px"><lang class="lang_large_selections_can_be_printed_in_batches">Large selections can be printed in batches of up to 2,000 labels.</lang></p></div><div class="sl-page-area"><div id="sl-print-sample" class="sl-preview-host"></div></div></div></section>
<div id="sl-desktop-printer" hidden><label class="sl-field"><lang class="lang_printer">Printer</lang><select id="sl-printer" class="sl-input"><option value=""><lang class="lang_choose_a_printer">Choose a printer</lang></option></select></label><label class="sl-field"><lang class="lang_print_method">Print method</lang><select id="sl-print-method" class="sl-input"><option value="driver"><lang class="lang_printer_driver_a4_or_label_printer">Printer driver (A4 or label printer)</lang></option><option value="thermal"><lang class="lang_80_mm_receipt_printer_esc_pos">80 mm receipt printer (ESC/POS)</lang></option></select></label><p id="sl-thermal-help" class="sl-muted" hidden><lang class="lang_for_an_80_mm_receipt_printer_use_label_wid">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.</lang></p><button type="button" class="sl-button" id="sl-refresh-printers"><lang class="lang_print_refresh_printers">Refresh printers</lang></button><p class="sl-muted"><lang class="lang_print_directly_to_the_selected_printer_at">Print directly to the selected printer at the label size shown above.</lang></p></div><p id="sl-browser-print-help" class="sl-muted" style="margin-bottom:18px"><lang class="lang_choose_the_printer_in_the_browser_print_di">Choose the printer in the browser print dialog. Use matching paper, 100% scale, no browser margins and no headers or footers.</lang></p><div class="sl-fields"><label class="sl-field"><lang class="lang_from_label">From label</lang><input id="sl-from" class="sl-input" type="number" value="1" min="1"></label><label class="sl-field"><lang class="lang_labels_in_this_batch">Labels in this batch</lang><input id="sl-batch" class="sl-input" type="number" value="5" min="1" max="2000"></label></div>
<button type="button" class="sl-button" id="sl-test"><lang class="lang_print_one_test_label">Print one test label</lang></button><p class="sl-muted" style="margin-top:14px"><lang class="lang_start_with_one_test_label_the_batch_starts">Start with one test label. The batch starts at 5; increase it when you are ready (maximum 2,000).</lang></p></div><div class="sl-page-area"><div id="sl-print-sample" class="sl-preview-host"></div></div></div></section>
</div><div id="sl-status" class="sl-status" role="status" aria-live="polite"></div>
<footer class="sl-footer"><span id="sl-selection-count" class="sl-muted" aria-live="polite"></span><div class="sl-footer-actions"><button type="button" class="sl-button" id="sl-back" hidden><lang class="lang_back_title">Back</lang></button><button type="button" class="sl-button sl-primary" id="sl-next-step" ><lang class="lang_sl_choose_layout">Choose layout →</lang></button><button type="button" id="sl-print" class="sl-button sl-primary" hidden><lang class="lang_print_2">Print…</lang></button></div></footer>
<div hidden><input type="hidden" id="document_width" value="2.4803149606299213"><input type="hidden" id="document_height" value="1.299212598425197"><input type="hidden" id="userInput"><input type="hidden" id="branch-value"><input type="hidden" id="price-value"><input type="hidden" id="mrp-price-value"><input type="hidden" id="mfg-date-value"><input type="hidden" id="exp-date-value"><span id="bar-width-display"></span><span id="bar-height-display"></span><span id="bar-fontSize-display"></span><span id="bar-margin-display"></span><span id="bar-text-margin-display"></span></div>
Expand Down
Loading
Loading