From e37b6a89111690bd38ea4578e819062b16f8a843 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 08:48:55 +0530 Subject: [PATCH 01/13] Fix system default receipt printer resolution --- src/hardware-manager.js | 11 +++++++-- src/windows-spooler.js | 20 +++++++++++++++- tests/default-printer.test.js | 40 +++++++++++++++++++++++++++++++ tests/local-billing-print.test.js | 14 +++++++++++ 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/default-printer.test.js diff --git a/src/hardware-manager.js b/src/hardware-manager.js index 36d630da9..cb8eb0e58 100644 --- a/src/hardware-manager.js +++ b/src/hardware-manager.js @@ -561,7 +561,8 @@ class HardwareManager { // A text-only thermal driver may accept HTML/GDI and still feed blank paper. // Render the complete design before submitting; never fall back after RAW acceptance. if (options.thermalRaster === true && ['58mm', '80mm', '112mm'].includes(options.pageSize)) { - const deviceName = await this._resolvePrinterName(options.printerName); + const deviceName = await this._resolvePrinterName(options.printerName) || (await this.getDefaultPrinter())?.name; + if (!deviceName) throw new Error('No default printer is available. Choose a sales receipt printer in Printer Settings.'); receiptPrinter = deviceName; const bytes = options.fastReceiptLines ? require('./receipt-fast').renderFastReceipt(options.fastReceiptLines, options.pageSize.replace('mm', '')) @@ -740,13 +741,19 @@ class HardwareManager { async getDefaultPrinter() { try { const printers = await this.listPrinters(); + // Electron can omit isDefault on Windows. Enumeration order is not a + // default: its first entry may be an offline or kitchen printer. + if (process.platform === 'win32') { + const name = await spooler.defaultPrinterName(); + return name ? printers.find(p => p.name === name) || { name, displayName: name, isDefault: true } : null; + } const defaultPrinter = printers.find(p => p.isDefault); if (defaultPrinter) { console.log('Default printer:', defaultPrinter.name); return defaultPrinter; } console.warn('No default printer found'); - return printers.length > 0 ? printers[0] : null; + return null; } catch (error) { console.error('Failed to get default printer:', error); return null; diff --git a/src/windows-spooler.js b/src/windows-spooler.js index bdd1845b3..d05b8b4aa 100644 --- a/src/windows-spooler.js +++ b/src/windows-spooler.js @@ -3,6 +3,24 @@ const { execFile } = require('child_process'); const crypto = require('crypto'); +async function defaultPrinterName(run = execFile) { + const script = `[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +$ErrorActionPreference = 'Stop' +@(Get-CimInstance Win32_Printer | Where-Object { $_.Default } | Select-Object -ExpandProperty Name) | ConvertTo-Json -Compress`; + return new Promise((resolve, reject) => run('powershell.exe', + ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], + { windowsHide: true, timeout: 5000, maxBuffer: 128 * 1024, encoding: 'utf8' }, + (error, stdout) => { + if (error) return reject(new Error('Could not read the Windows default printer')); + try { + const text = stdout.replace(/^\uFEFF/, '').trim(); + const value = text ? JSON.parse(text) : []; + const names = Array.isArray(value) ? value : [value]; + resolve(names.length === 1 && typeof names[0] === 'string' ? names[0] : null); + } catch (_) { reject(new Error('Invalid Windows default printer response')); } + })); +} + function result(printerName, values = {}) { return { printerName, jobId: null, state: 'failed', submitted: false, error: '', retryable: true, success: false, ...values }; @@ -103,5 +121,5 @@ function createSpooler({ inspect = inspectWindows, platform = process.platform, } const service = createSpooler(); -module.exports = { ...service, createSpooler, result, +module.exports = { ...service, createSpooler, result, defaultPrinterName, documentName: (key, index) => 'Posnic-KOT-' + crypto.createHash('sha256').update(`${key}:${index}`).digest('hex').slice(0, 32) }; diff --git a/tests/default-printer.test.js b/tests/default-printer.test.js new file mode 100644 index 000000000..d4f3e15aa --- /dev/null +++ b/tests/default-printer.test.js @@ -0,0 +1,40 @@ +'use strict'; +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const vm = require('node:vm'); +const { defaultPrinterName } = require('../src/windows-spooler'); +const source = fs.readFileSync(require.resolve('../src/hardware-manager'), 'utf8'); +const start = source.indexOf(' async getDefaultPrinter()'); +const end = source.indexOf('\n // ', start); +function resolveDefault(platform, lookup) { + return vm.runInNewContext('({' + source.slice(start, end) + '}).getDefaultPrinter', { + process: { platform }, spooler: { defaultPrinterName: lookup }, console, + }); +} +test('Windows default is used when Electron omits its default flag and lists an offline printer first', async () => { + const printers = [{ name: 'Offline Xprinter' }, { name: 'Reception' }]; + assert.equal((await resolveDefault('win32', async () => 'Reception').call({ listPrinters: async () => printers })).name, 'Reception'); +}); +test('missing or unreadable Windows default never sends a receipt to the first enumerated printer', async () => { + for (const lookup of [async () => null, async () => { throw Error('Unavailable'); }]) { + assert.equal(await resolveDefault('win32', lookup).call({ listPrinters: async () => [{ name: 'Kitchen' }] }), null); + } +}); +test('non-Windows defaults remain explicit and never depend on enumeration order', async () => { + for (const platform of ['darwin', 'linux']) { + const method = resolveDefault(platform, () => { throw Error('Windows must not be queried'); }); + assert.equal((await method.call({ listPrinters: async () => [{ name: 'Other' }, { name: 'Counter', isDefault: true }] })).name, 'Counter'); + assert.equal(await method.call({ listPrinters: async () => [{ name: 'Other' }] }), null); + } +}); +test('Windows default query is bounded, hidden, Unicode-safe and handles no default', async () => { + for (const [output, expected] of [['"Réception தமிழ்"', 'Réception தமிழ்'], ['', null], ['[]', null], ['["One","Two"]', null]]) { + assert.equal(await defaultPrinterName((file, args, options, done) => { + assert.equal(file, 'powershell.exe'); assert.equal(options.windowsHide, true); + assert.equal(options.timeout, 5000); + assert.match(Buffer.from(args.at(-1), 'base64').toString('utf16le'), /Win32_Printer/); + done(null, output); + }), expected); + } +}); diff --git a/tests/local-billing-print.test.js b/tests/local-billing-print.test.js index 7d81ecdc2..768ee37f5 100644 --- a/tests/local-billing-print.test.js +++ b/tests/local-billing-print.test.js @@ -47,6 +47,20 @@ test('designed thermal copies use RAW once each, with distinct delivery identiti assert.notEqual(calls[0][3].jobId, calls[1][3].jobId); }); +test('thermal System Default resolves to the actual default before RAW submission', async () => { + const calls = []; + const method = printMethod(async () => Buffer.from('receipt')); + const ctx = { _resolvePrinterName: async () => '', getDefaultPrinter: async () => ({ name: 'Reception' }), + sendRawToPrinter: async name => { calls.push(name); return { success: true }; } }; + assert.equal((await method.call(ctx, '', { thermalRaster: true, pageSize: '80mm' })).success, true); + assert.deepEqual(calls, ['Reception']); + ctx.getDefaultPrinter = async () => null; + const failed = await method.call(ctx, '', { thermalRaster: true, pageSize: '80mm' }); + assert.equal(failed.success, false); + assert.match(failed.error, /Choose a sales receipt printer/); + assert.equal(calls.length, 1); +}); + test('capture-only renders the actual receipt but submits no copies and reports no delivery', async () => { const { DiagnosticSession } = require('../src/diagnostic-session'); const session = new DiagnosticSession(); session.start(); session.setCapture(true); From e7da6357c4bbc73c7f51e52004c41319039f0cc1 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:00:26 +0530 Subject: [PATCH 02/13] Allow custom receipt footer text and omit placeholder addresses --- api/src/helpers/receipt-design.js | 5 ++ .../static/script/js/core/receipt-designer.js | 8 +++- .../js/modules/js/receipt-designer-editor.js | 5 +- tests/receipt-designer.test.js | 46 +++++++++++++++++++ 4 files changed, 61 insertions(+), 3 deletions(-) diff --git a/api/src/helpers/receipt-design.js b/api/src/helpers/receipt-design.js index a4cb19662..346337ef0 100644 --- a/api/src/helpers/receipt-design.js +++ b/api/src/helpers/receipt-design.js @@ -120,6 +120,11 @@ showBranding: value.showBranding !== false, layouts: {}, }; + if (value.brandingText != null) { + if (typeof value.brandingText !== 'string' || value.brandingText.length > 200) + throw new Error('Receipt footer text must be at most 200 characters.'); + out.brandingText = value.brandingText.trim(); + } Object.keys(formats).forEach(function (format) { const layout = layoutFor(value, format); if (!layout || !Array.isArray(layout.blocks) || layout.blocks.length > 40) diff --git a/frontend/static/script/js/core/receipt-designer.js b/frontend/static/script/js/core/receipt-designer.js index 55153e62a..7a753e000 100644 --- a/frontend/static/script/js/core/receipt-designer.js +++ b/frontend/static/script/js/core/receipt-designer.js @@ -231,7 +231,11 @@ if (b.type === 'store') { function storeText(key, fallback) { return String(b[key] || '').trim() || fallback || ''; } var storeName = storeText('nameText', data.branch_name || data.store_name || PosnicPro.local.get('branchname')); - var storeAddress = storeText('addressText', plain(data.printing_address || data.store_address || '')); + function addressText(value) { + var text = plain(value || '').trim(); + return /^(not provided|not available|not set|n\/a|null|undefined)$/i.test(text) ? '' : text; + } + var storeAddress = addressText(b.addressText) || addressText(data.printing_address) || addressText(data.store_address); var storePhone = storeText('phoneText', data.store_telephone); var storeEmail = storeText('emailText', data.store_email); var storeRegistration = storeText('registrationText', data.branch_gstin_number); @@ -350,7 +354,7 @@ }).filter(Boolean); rendered = pairFields(rendered); var html = sheet ? composeSheet(rendered) : rendered.map(function (b) { return b.html; }).join(''); - if (designs.showBranding !== false) html += '
Made with Posnic · posnic.com
'; + if (designs.showBranding !== false) html += '
' + esc(String(designs.brandingText || '').trim() || 'Made with Posnic · posnic.com') + '
'; if (pendingGoods) html += '
' + esc(label('Payment pending')) + '
'; if (sheet) { var notes = ''; diff --git a/frontend/static/script/js/modules/js/receipt-designer-editor.js b/frontend/static/script/js/modules/js/receipt-designer-editor.js index 600ec56d7..781a65efe 100644 --- a/frontend/static/script/js/modules/js/receipt-designer-editor.js +++ b/frontend/static/script/js/modules/js/receipt-designer-editor.js @@ -232,6 +232,7 @@ renderFormats(); box.find('#rd-text-size').val(layout().fontSize); box.find('#rd-branding').prop('checked', design.showBranding !== false); + box.find('#rd-branding-text').val(design.brandingText || ''); renderList(); preview(); } function move(id, at) { @@ -331,7 +332,7 @@ '
' + '

' + esc(t('Your layout')) + '

' + button('reset-template', 'Reset template', 'refresh-cw') + button('undo', 'Undo', 'rotate-ccw', 'disabled') + '
' + esc(t('Applies to this paper format. Select a block to override its text size or make it bold.')) + '

    ' + esc(t('Store details, receipt details, items and totals are always included.')) + '

    ' + '
    ' + - '
    '); + '
    ' + esc(t('Leave blank to use the default. Applies to all paper sizes.')) + '
    '); if (sampleTaxEnabled()) { box.find('.rd-preview-footer').append(''); box.find('#rd-sample-tax').val(sampleInterstate ? 'inter' : 'intra').on('change', function () { sampleInterstate = this.value === 'inter'; schedulePreview(); }); @@ -357,6 +358,8 @@ format = this.getAttribute('data-format'); selected = null; box.find('.rd-preview-stage').scrollTop(0); renderEditor(); }).on('change.receiptDesigner', '#rd-branding', function () { checkpoint(); design.showBranding = this.checked; changed(); + }).on('input.receiptDesigner', '#rd-branding-text', function () { + checkpoint(); design.brandingText = this.value; changed(); }).on('click.receiptDesigner', '[data-default-format]', function () { var next = this.getAttribute('data-default-format'); if (next === design.defaultFormat) return; diff --git a/tests/receipt-designer.test.js b/tests/receipt-designer.test.js index 08fe4142e..0c1cff864 100644 --- a/tests/receipt-designer.test.js +++ b/tests/receipt-designer.test.js @@ -1246,6 +1246,34 @@ test('sale printing sends receipt identity to desktop logging while sample print dom.window.close(); }); +test('custom footer text saves, reloads and renders safely on every paper size', () => { + const { dom, w, $, branch, sale, engine } = setup(); + let sent; + w.PosnicPro.put = (request, done) => { sent = JSON.parse(request.data); done({ type: 'success', data: { receipt_designs: sent.receipt_designs } }); }; + w.PosnicPro.receiptDesignerEditor.load(branch); + const custom = 'Printed by · example.com'; + $('#rd-branding-text').val(custom).trigger('input'); + $('[data-action="save"]').trigger('click'); + const saved = contract.normalize(sent.receipt_designs); + assert.equal(saved.brandingText, custom); + w.PosnicPro.receiptDesignerEditor.load({ ...branch, receipt_designs: saved }); + assert.equal($('#rd-branding-text').val(), custom); + sale.receipt_designs = saved; + for (const format of Object.keys(contract.formats)) { + const footer = $('
    ').html(engine.render(sale, format, false)).find('.rd-branding'); + assert.equal(footer.text(), custom); + assert.equal(footer.children().length, 0); + } + saved.showBranding = false; + assert.equal($('
    ').html(engine.render(sale, '80', false)).find('.rd-branding').length, 0); + saved.showBranding = true; + saved.brandingText = ' '; + assert.equal($('
    ').html(engine.render(sale, '80', false)).find('.rd-branding').text(), 'Made with Posnic · posnic.com'); + assert.throws(() => contract.normalize({ ...saved, brandingText: 'x'.repeat(201) }), /200 characters/); + assert.throws(() => contract.normalize({ ...saved, brandingText: {} }), /200 characters/); + dom.window.close(); +}); + test('store print overrides persist per format without changing branch contact data', () => { const { dom, w, $, branch, sale, engine } = setup(); branch.store_telephone = '+919876543210'; @@ -1271,6 +1299,24 @@ test('store print overrides persist per format without changing branch contact d dom.window.close(); }); +test('missing branch addresses never print placeholder text in any receipt format', () => { + const { dom, $, engine, sale, design } = setup(); + for (const format of Object.keys(contract.formats)) { + const store = design.layouts[format].blocks.find(b => b.type === 'store'); + Object.assign(sale, { printing_address: ' Not provided ', store_address: '' }); + sale.receipt_designs = design; + const address = () => $('
    ').html(engine.render(sale, format, false)).find('.rd-store-contact').text(); + assert.equal(address(), ''); + sale.store_address = '12 Market Road'; + assert.equal(address(), '12 Market Road'); + store.addressText = 'Receipt address'; + assert.equal(address(), 'Receipt address'); + store.addressText = 'Not provided'; + assert.equal(address(), '12 Market Road'); + } + dom.window.close(); +}); + test('store visibility, empty overrides, escaping and validation apply to every format', () => { const { dom, $, engine, sale, design } = setup(); sale.store_telephone = '+919876543210'; From 53c4fb85fdc26585a301104e943e95b80fd41e40 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:27:12 +0530 Subject: [PATCH 03/13] Hide zero receipt taxes and align sample tax labels with country --- api/src/helpers/receipt-design.js | 24 ++++++++++++++ .../static/script/js/core/receipt-designer.js | 11 ++++--- .../js/modules/js/receipt-designer-editor.js | 7 ++-- tests/receipt-designer.test.js | 32 +++++++++++++++++++ 4 files changed, 67 insertions(+), 7 deletions(-) diff --git a/api/src/helpers/receipt-design.js b/api/src/helpers/receipt-design.js index 346337ef0..de51ef159 100644 --- a/api/src/helpers/receipt-design.js +++ b/api/src/helpers/receipt-design.js @@ -5,6 +5,29 @@ else root.PosnicReceiptDesign = factory(); })(typeof window !== 'undefined' ? window : this, function () { 'use strict'; + function taxLabel(data, fallbackCountry) { + data = data || {}; + const country = String( + data.branch_country || data.country || data.sortname || fallbackCountry || '' + ) + .trim() + .toLowerCase(); + if (['in', 'ind', 'india', 'au', 'aus', 'australia'].includes(country)) return 'GST'; + if ( + [ + 'gb', + 'gbr', + 'uk', + 'united kingdom', + 'united kingdom of great britain and northern ireland', + 'za', + 'zaf', + 'south africa', + ].includes(country) + ) + return 'VAT'; + return 'Tax'; + } function taxRegistrationLabel(data, fallbackCountry) { data = data || {}; const country = String( @@ -272,6 +295,7 @@ return out; } return { + taxLabel: taxLabel, taxRegistrationLabel: taxRegistrationLabel, formats: formats, fields: fields, diff --git a/frontend/static/script/js/core/receipt-designer.js b/frontend/static/script/js/core/receipt-designer.js index 7a753e000..561c2e31c 100644 --- a/frontend/static/script/js/core/receipt-designer.js +++ b/frontend/static/script/js/core/receipt-designer.js @@ -300,13 +300,14 @@ var igst = items.reduce(function (n, i) { return n + Number(i.igst_tax || 0); }, 0); var cgst = items.reduce(function (n, i) { return n + Number(i.cgst_tax || 0); }, 0); var sgst = items.reduce(function (n, i) { return n + Number(i.sgst_tax == null ? i.cgst_tax || 0 : i.sgst_tax); }, 0); - if (Array.isArray(data.receipt_tax_rows) && data.receipt_tax_rows.length) data.receipt_tax_rows.forEach(function (row) { content += pair(row.label, money(row.amount)); }); + var hasTaxAmount = function (amount) { return Number.isFinite(Number(amount)) && Number(Number(amount).toFixed(2)) !== 0; }; + if (Array.isArray(data.receipt_tax_rows) && data.receipt_tax_rows.length) data.receipt_tax_rows.forEach(function (row) { if (hasTaxAmount(row.amount)) content += pair(row.label, money(row.amount)); }); else if (igst || cgst || sgst) { - if (igst) content += pair('IGST', money(igst)); - if (cgst) content += pair('CGST', money(cgst)); - if (sgst) content += pair('SGST', money(sgst)); + if (hasTaxAmount(igst)) content += pair('IGST', money(igst)); + if (hasTaxAmount(cgst)) content += pair('CGST', money(cgst)); + if (hasTaxAmount(sgst)) content += pair('SGST', money(sgst)); } - else if (Number(data.tax)) content += pair('Tax', money(data.tax)); + else if (hasTaxAmount(data.tax)) content += pair(contract.taxLabel(data, PosnicPro.local.get('country_setting')), money(data.tax)); (data.charges || []).forEach(function (c) { content += pair(c.name || 'Charge', money(Number(c.amount || 0) + Number(c.tax_amount || 0))); }); if (Number(data.round_off)) content += pair('Round Off', money(data.round_off)); content += pair('Total', money(data.items_total), true); diff --git a/frontend/static/script/js/modules/js/receipt-designer-editor.js b/frontend/static/script/js/modules/js/receipt-designer-editor.js index 781a65efe..c01c516aa 100644 --- a/frontend/static/script/js/modules/js/receipt-designer-editor.js +++ b/frontend/static/script/js/modules/js/receipt-designer-editor.js @@ -26,11 +26,14 @@ function changed() { status(t('Unsaved changes')); box.find('[data-action="undo"]').prop('disabled', !undo.length); schedulePreview(); } var sampleInterstate = false; function sampleTaxEnabled() { return ![false, 'false', 'disable'].includes(branch.module_tax_enable); } - function sampleIndianGst() { return ['gst_on', 'enable', true, 'true'].includes(branch.indian_gst) || ['enable', true, 'true'].includes(branch.gst); } + function sampleIndianGst() { + var country = String(branch.branch_country || branch.country || branch.sortname || PosnicPro.local.get('country_setting') || '').trim().toLowerCase(); + return country ? ['india', 'in', 'ind'].includes(country) : ['gst_on', 'enable', true, 'true'].includes(branch.indian_gst) || ['enable', true, 'true'].includes(branch.gst); + } function sample() { var item = function (name, price, qty) { return { item_name: name, item_price: price, item_quantity: qty, item_unit: 'ea', total_amount: price * qty, hsn_code: '1234' }; }; var tax = sampleTaxEnabled() ? 2.25 : 0; - var taxRows = !tax ? [] : sampleIndianGst() ? (sampleInterstate ? [{ label: t('IGST') + ' 5%', amount: tax }] : [{ label: t('CGST') + ' 2.5%', amount: 1.13 }, { label: t('SGST') + ' 2.5%', amount: 1.12 }]) : [{ label: t('Tax') + ' 5%', amount: tax }]; + var taxRows = !tax ? [] : sampleIndianGst() ? (sampleInterstate ? [{ label: t('IGST') + ' 5%', amount: tax }] : [{ label: t('CGST') + ' 2.5%', amount: 1.13 }, { label: t('SGST') + ' 2.5%', amount: 1.12 }]) : [{ label: t(schema.taxLabel(branch, PosnicPro.local.get('country_setting'))) + ' 5%', amount: tax }]; return Object.assign({}, branch, printOptions(), { receipt_designs: design, sales_id: 'S-000128', created_date: new Date().toLocaleString('en-GB', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hour12: false }), customer_name: 'Alex Morgan', customer_phone: '+1 202 555 0148', customer_email: 'alex@example.com', customer_address: '24 Market Street', customer_gstin: '', customer_tax_number: layout().blocks.some(function (b) { return b.field === 'customer_tax_number'; }) ? 'TAX-123456' : '', diff --git a/tests/receipt-designer.test.js b/tests/receipt-designer.test.js index 0c1cff864..fbe53cb02 100644 --- a/tests/receipt-designer.test.js +++ b/tests/receipt-designer.test.js @@ -1359,6 +1359,38 @@ test('designer samples include GST, switch to IGST, and respect tax disabled', a dom.window.close(); }); +test('zero tax rows are omitted while real small amounts and saved tax labels remain', () => { + const { dom, engine, sale, $ } = setup(); + for (const format of Object.keys(contract.formats)) { + sale.tax = 0; + sale.receipt_tax_rows = [{ label: 'VAT', amount: 0 }, { label: 'CGST', amount: '0.00' }, { label: 'SGST', amount: 0.000001 }]; + const totals = () => $('
    ').html(engine.render(sale, format, false)).find('.rd-totals').text(); + assert.doesNotMatch(totals(), /VAT|CGST|SGST/); + sale.receipt_tax_rows = [{ label: 'CGST', amount: 0.02 }, { label: 'SGST', amount: 0.01 }]; + assert.match(totals(), /CGST.*0.02.*SGST.*0.01/); + sale.receipt_tax_rows = [{ label: 'Local sales levy', amount: 2 }]; + assert.match(totals(), /Local sales levy/); + } + dom.window.close(); +}); + +test('sample tax follows country even without the old India GST flag and hides when disabled', async () => { + const { dom, w, engine, branch } = setup(); + let document; + engine.print = async html => { document = html; return { success: true }; }; + for (const [country, label] of [['India', 'CGST 2.5%'], ['South Africa', 'VAT 5%'], ['United Kingdom', 'VAT 5%'], ['Australia', 'GST 5%']]) { + Object.assign(branch, { country, indian_gst: 'gst_off', module_tax_enable: true }); + w.PosnicPro.receiptDesignerEditor.load(branch); + await w.PosnicPro.receiptDesignerEditor.printSample(); + assert.ok(document.includes(label), country); + branch.module_tax_enable = false; + w.PosnicPro.receiptDesignerEditor.load(branch); + await w.PosnicPro.receiptDesignerEditor.printSample(); + assert.ok(!document.includes(label), country); + } + dom.window.close(); +}); + test('receipt fallback preserves independently saved CGST and SGST amounts', () => { const { dom, engine, sale, $ } = setup(); sale.items[0].cgst_tax = 1.13; sale.items[0].sgst_tax = 1.12; From fdb58a5d4fa7cf5371a6d7da989fb2b87d257d76 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:31:19 +0530 Subject: [PATCH 04/13] Expose public shop names for Mobile POS discovery --- api/src/routes/mobile-pos.routes.js | 9 +++++++++ api/tests/mobile-pos.integration.cjs | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index d0dca1901..91fe69df4 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -28,6 +28,15 @@ const limiter = rateLimit({ legacyHeaders: false, }); +// Discovery exposes only public display names, never branch/staff identifiers. +router.get('/discovery', rateLimit({ windowMs: 60000, limit: 60 }), wrap(async (req) => { + const rows = await req.db.collection('branches').find( + { module_mobile_pos_enable: { $nin: [false, 0, '0', 'false'] } }, + { projection: { _id: 0, branch_name: 1 } } + ).limit(10).toArray(); + return { shopName: [...new Set(rows.map(row => String(row.branch_name || '').trim()).filter(Boolean))].join(' · ').slice(0,160) }; +})); + router.post( '/enrolment-proof', rateLimit({ windowMs: 60000, limit: 120, standardHeaders: true, legacyHeaders: false }), diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index ccffa91d7..62ee1af66 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -1304,3 +1304,9 @@ test('mobile snapshot sync carries disabled methods and still accepts a previous await db.collection('branches').replaceOne({ _id: branch._id }, savedBranch); } }); + +test('public mobile discovery exposes a shop name without credentials or internal identifiers', async () => { + const result = await call('/mobile/v1/discovery', undefined, null); + assert.equal(result.status,200); + assert.deepEqual(result.data,{shopName:branch.branch_name}); +}); From 7d948095a7dbe23455c6f992f18e0eb3c0ea3713 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:33:40 +0530 Subject: [PATCH 05/13] Document the Mobile POS shop discovery endpoint --- docs/API.md | 7 ++++--- docs/openapi.json | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/API.md b/docs/API.md index 6ff9a51b0..c026ff575 100644 --- a/docs/API.md +++ b/docs/API.md @@ -3,7 +3,7 @@ Generated from `api/src/routes/` by `npm run docs:api`. Do not edit this file by hand; edit the routes and regenerate. -**894 endpoints across 53 route groups.** +**895 endpoints across 53 route groups.** All paths below are relative to the API root. On a desktop install the API runs on a port derived per installation (see `local-ports.js`); in the cloud it sits @@ -15,8 +15,8 @@ behind the tenant hostname. Postman, Insomnia or Bruno, point Swagger UI or Redoc at it, or generate a client from it. It is OpenAPI 3.0.3. -**104 of 894 endpoints carry a request schema** (12%), recovered from -their `express-validator` middleware. The remaining 790 are listed with +**104 of 895 endpoints carry a request schema** (12%), recovered from +their `express-validator` middleware. The remaining 791 are listed with path, method and handler but no body, because inventing a schema is worse than admitting the gap. @@ -730,6 +730,7 @@ Mounted at `/mobile/v1`. Source: `api/src/routes/mobile-pos.routes.js`. | Method | Path | Body documented | Handler | |---|---|---|---| +| GET | `/mobile/v1/discovery` | — | `Boolean` | | POST | `/mobile/v1/enrolment-proof` | — | `hex` | | POST | `/mobile/v1/pair` | — | `handsetLifetimeSeconds` | | GET | `/mobile/v1/inventory/policy` | — | `inventory.policy` | diff --git a/docs/openapi.json b/docs/openapi.json index 780ee3ed1..2d5099212 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -12953,6 +12953,29 @@ "description": "Request body is not yet documented. This endpoint has no validation middleware, so its shape cannot be derived from the source. Adding validation improves both runtime safety and this document." } }, + "/mobile/v1/discovery": { + "get": { + "summary": "Boolean", + "operationId": "get_mobile_v1_discovery", + "tags": [ + "Mobile pos" + ], + "responses": { + "200": { + "description": "Success" + }, + "400": { + "description": "Validation failed" + }, + "401": { + "description": "Authentication required" + }, + "500": { + "description": "Server error" + } + } + } + }, "/mobile/v1/enrolment-proof": { "post": { "summary": "hex", From 7f39d810794df5263648df7b27d80b23edc93526 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:34:20 +0530 Subject: [PATCH 06/13] Format discovery route and integration test --- api/src/routes/mobile-pos.routes.js | 28 +++++++++++++++++++++------- api/tests/mobile-pos.integration.cjs | 6 +++--- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/api/src/routes/mobile-pos.routes.js b/api/src/routes/mobile-pos.routes.js index 91fe69df4..a3a15ba92 100644 --- a/api/src/routes/mobile-pos.routes.js +++ b/api/src/routes/mobile-pos.routes.js @@ -29,13 +29,27 @@ const limiter = rateLimit({ }); // Discovery exposes only public display names, never branch/staff identifiers. -router.get('/discovery', rateLimit({ windowMs: 60000, limit: 60 }), wrap(async (req) => { - const rows = await req.db.collection('branches').find( - { module_mobile_pos_enable: { $nin: [false, 0, '0', 'false'] } }, - { projection: { _id: 0, branch_name: 1 } } - ).limit(10).toArray(); - return { shopName: [...new Set(rows.map(row => String(row.branch_name || '').trim()).filter(Boolean))].join(' · ').slice(0,160) }; -})); +router.get( + '/discovery', + rateLimit({ windowMs: 60000, limit: 60 }), + wrap(async (req) => { + const rows = await req.db + .collection('branches') + .find( + { module_mobile_pos_enable: { $nin: [false, 0, '0', 'false'] } }, + { projection: { _id: 0, branch_name: 1 } } + ) + .limit(10) + .toArray(); + return { + shopName: [ + ...new Set(rows.map((row) => String(row.branch_name || '').trim()).filter(Boolean)), + ] + .join(' · ') + .slice(0, 160), + }; + }) +); router.post( '/enrolment-proof', diff --git a/api/tests/mobile-pos.integration.cjs b/api/tests/mobile-pos.integration.cjs index 62ee1af66..8364c6a1c 100644 --- a/api/tests/mobile-pos.integration.cjs +++ b/api/tests/mobile-pos.integration.cjs @@ -1306,7 +1306,7 @@ test('mobile snapshot sync carries disabled methods and still accepts a previous }); test('public mobile discovery exposes a shop name without credentials or internal identifiers', async () => { - const result = await call('/mobile/v1/discovery', undefined, null); - assert.equal(result.status,200); - assert.deepEqual(result.data,{shopName:branch.branch_name}); + const result = await call('/mobile/v1/discovery', undefined, null); + assert.equal(result.status, 200); + assert.deepEqual(result.data, { shopName: branch.branch_name }); }); From fce9c1ec4a858ebc3d41dbcce0942f9ec39c301f Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:34:50 +0530 Subject: [PATCH 07/13] Register receipt footer placeholder for translation --- frontend/static/script/js/modules/js/receipt-designer-editor.js | 2 +- languages/_english.json | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/static/script/js/modules/js/receipt-designer-editor.js b/frontend/static/script/js/modules/js/receipt-designer-editor.js index c01c516aa..11f2d9c67 100644 --- a/frontend/static/script/js/modules/js/receipt-designer-editor.js +++ b/frontend/static/script/js/modules/js/receipt-designer-editor.js @@ -335,7 +335,7 @@ '
    ' + '

    ' + esc(t('Your layout')) + '

    ' + button('reset-template', 'Reset template', 'refresh-cw') + button('undo', 'Undo', 'rotate-ccw', 'disabled') + '
    ' + esc(t('Applies to this paper format. Select a block to override its text size or make it bold.')) + '

      ' + esc(t('Store details, receipt details, items and totals are always included.')) + '

      ' + '
      ' + - '
      ' + esc(t('Leave blank to use the default. Applies to all paper sizes.')) + '
      '); + '
      ' + esc(t('Leave blank to use the default. Applies to all paper sizes.')) + '
      '); if (sampleTaxEnabled()) { box.find('.rd-preview-footer').append(''); box.find('#rd-sample-tax').val(sampleInterstate ? 'inter' : 'intra').on('change', function () { sampleInterstate = this.value === 'inter'; schedulePreview(); }); diff --git a/languages/_english.json b/languages/_english.json index 00f0fe2ec..9e5662614 100644 --- a/languages/_english.json +++ b/languages/_english.json @@ -2360,6 +2360,7 @@ "lang_m_r_p": "M.R.P", "lang_machine_host_ip_optional": "Machine host / IP (optional)", "lang_macos": "macOS", + "lang_made_with_posnic_posnic_com": "Made with Posnic · posnic.com", "lang_mail": "Report Email Settings", "lang_make_the_change": "Make the change", "lang_making_this_shop_yours": "Making this shop yours?", From af840fcdcc09db59b38cb21cc7614537732a49c2 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:38:02 +0530 Subject: [PATCH 08/13] Isolate legacy index regression fixture from startup migrations --- api/tests/api/rest-smoke.test.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/api/tests/api/rest-smoke.test.js b/api/tests/api/rest-smoke.test.js index 1d61e2025..5d709a23a 100644 --- a/api/tests/api/rest-smoke.test.js +++ b/api/tests/api/rest-smoke.test.js @@ -168,7 +168,9 @@ describeIfDb('the REST API, against a real database', () => { * branch would not save. It cannot be caught with a mock: the constraint * lives in MongoDB, not in the code. */ - const customers = client.db(DB_NAME).collection('customers'); + // Keep this deliberate legacy-index fixture separate from startup migrations, + // which normalize blank emails in the real customers collection asynchronously. + const customers = client.db(DB_NAME).collection('customers_legacy_index_fixture'); await customers.createIndex({ email: 1 }, { unique: true, sparse: true }); await customers.insertOne({ name: 'First', phone: '' }); From f0b0765a20bfa8e1ebe89e418a44172bd061c838 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:39:34 +0530 Subject: [PATCH 09/13] Translate receipt footer placeholder in Dutch --- languages/nl.json | 1 + 1 file changed, 1 insertion(+) diff --git a/languages/nl.json b/languages/nl.json index 468bd0586..3a3209c39 100644 --- a/languages/nl.json +++ b/languages/nl.json @@ -1,4 +1,5 @@ { + "lang_made_with_posnic_posnic_com": "Gemaakt met Posnic · posnic.com", "lang_actual_size": "Werkelijke grootte", "lang_fit_whole_page": "Hele pagina passend weergeven", "lang_select_products_on_this_page": "Producten op deze pagina selecteren", From 5e9683af1d9686b6dbfbed4b2d8915372aea9b58 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:45:48 +0530 Subject: [PATCH 10/13] Document public mobile discovery and update endpoint count --- README.md | 4 ++-- tests/public-routes.test.js | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index dd05ab66d..2cd6773f6 100644 --- a/README.md +++ b/README.md @@ -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-69%25%20statements-yellow)](docs/DEVELOPMENT.md#running-the-tests) -[![API](https://img.shields.io/badge/REST%20API-894%20endpoints-blue)](docs/API.md) +[![API](https://img.shields.io/badge/REST%20API-895%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) @@ -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) | 894 endpoints, generated from the routes | +| [REST API](docs/API.md) | 895 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 | diff --git a/tests/public-routes.test.js b/tests/public-routes.test.js index 64a38d672..57e70be8a 100644 --- a/tests/public-routes.test.js +++ b/tests/public-routes.test.js @@ -77,6 +77,9 @@ const ALLOWED_ANONYMOUS = { '/discovery', ], 'mobile-pos.routes.js': [ + // Rate-limited discovery exposes only bounded public shop display names. + // No identifiers, credentials or business data; pairing still proves access. + '/discovery', // Pairing is a rate-limited authentication entry point with an expiring, // one-use secret; cloud grants also require the approved phone and proof. // Discovery proof returns only a nonce MAC for an unexpired random grant, From 4cc97bd224307c4909e024666a50d040926a1b8a Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 09:52:41 +0530 Subject: [PATCH 11/13] Use branch state for walk-in GST and normalize customer states --- api/src/helpers/bill-payload.js | 10 +---- api/src/helpers/gst-place.js | 29 ++++++++++++++ api/src/repositories/sale.repository.js | 8 ++-- api/src/services/sale.service.js | 11 +++++- api/tests/unit/helpers/gst-place.test.js | 23 +++++++++++ api/tests/unit/services/sale.service.test.js | 40 ++++++++++++++++++++ 6 files changed, 106 insertions(+), 15 deletions(-) create mode 100644 api/src/helpers/gst-place.js create mode 100644 api/tests/unit/helpers/gst-place.test.js diff --git a/api/src/helpers/bill-payload.js b/api/src/helpers/bill-payload.js index 00f714382..9da6183df 100644 --- a/api/src/helpers/bill-payload.js +++ b/api/src/helpers/bill-payload.js @@ -254,15 +254,7 @@ function taxRows(sale, branch, allocation = null) { const taxMinor = money.toMinor(tax, policy); const firstHalf = Math.ceil(taxMinor / 2); const rate = gstRate(sale); - const stateKey = (value) => { - const key = String(value || '') - .trim() - .toLowerCase(); - const match = require('../json/gst_state_code.json').gststate.find( - (row) => row.id === key.padStart(2, '0') || row.value.toLowerCase() === key - ); - return match ? match.id : key; - }; + const { stateKey } = require('./gst-place'); const supplierState = stateKey(sale.branch_state || branch.state); const supplyState = stateKey(sale.place_of_supply || sale.customer_state); if (supplierState && supplyState && supplierState !== supplyState) { diff --git a/api/src/helpers/gst-place.js b/api/src/helpers/gst-place.js new file mode 100644 index 000000000..acfa037c9 --- /dev/null +++ b/api/src/helpers/gst-place.js @@ -0,0 +1,29 @@ +const { gststate } = require('../json/gst_state_code.json'); + +function stateKey(value) { + const key = String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); + const state = gststate.find( + (row) => row.id === key.padStart(2, '0') || row.value.toLowerCase() === key + ); + return state ? state.id : key; +} + +function isInterState(supplierState, customerState) { + const supplier = stateKey(supplierState); + const customer = stateKey(customerState); + return Boolean(supplier && customer && supplier !== customer); +} + +function customerState(data, branch, customer) { + const supplier = String(branch.state || '').trim(); + const isDefault = + !data.customer_id || + (branch.default_customer && String(data.customer_id) === String(branch.default_customer)); + if (isDefault) return supplier; + return String(data.customer_state || customer?.state || '').trim() || supplier; +} + +module.exports = { stateKey, isInterState, customerState }; diff --git a/api/src/repositories/sale.repository.js b/api/src/repositories/sale.repository.js index 60f0de45c..67d14b962 100644 --- a/api/src/repositories/sale.repository.js +++ b/api/src/repositories/sale.repository.js @@ -949,10 +949,10 @@ class SalesRepository { const customerState = (doc.customer_state || '').toString().trim(); const branchState = (branchDoc?.state || '').toString().trim(); - const isInterState = - customerState && - branchState && - customerState.toLowerCase() !== branchState.toLowerCase(); + const isInterState = require('../helpers/gst-place').isInterState( + branchState, + customerState + ); if (isInterState) { cloned.igst_tax = perLineGstAmount; diff --git a/api/src/services/sale.service.js b/api/src/services/sale.service.js index 1726d29d9..bd7963e02 100644 --- a/api/src/services/sale.service.js +++ b/api/src/services/sale.service.js @@ -426,6 +426,13 @@ const processSale = async ( const pricingCustomer = data.customer_id ? await customerRepository.findById(data.customer_id) : null; + const gstPlace = require('../helpers/gst-place'); + const supplierState = context.branchState || pricingBranch.state || ''; + const saleCustomerState = gstPlace.customerState( + data, + { ...pricingBranch, state: supplierState }, + pricingCustomer + ); let priceList; if (pricingCustomer?.category_id) { const pricingDb = await BaseModel.getDb(); @@ -662,7 +669,7 @@ const processSale = async ( const indianGstSetting = context.branchSettings?.indian_gst || 'gst_off'; - const interPlace = (data.customer_state || '').trim() !== (context.branchState || '').trim(); + const interPlace = gstPlace.isInterState(supplierState, saleCustomerState); if (indianGstSetting === 'gst_on' && gstValue > 0) { if (interPlace) { igst_value = gstValue; @@ -1274,7 +1281,7 @@ const processSale = async ( } : {}), customer_email: (data.customer_email || '').trim(), - customer_state: (data.customer_state || '').trim(), + customer_state: saleCustomerState, customer_country: (data.customer_country || '').trim(), country_sort: finalCountrySort, customer_gst_type: (data.customer_gst_type || '').trim(), diff --git a/api/tests/unit/helpers/gst-place.test.js b/api/tests/unit/helpers/gst-place.test.js new file mode 100644 index 000000000..cddd9d081 --- /dev/null +++ b/api/tests/unit/helpers/gst-place.test.js @@ -0,0 +1,23 @@ +const { stateKey, isInterState, customerState } = require('../../../src/helpers/gst-place'); + +test.each([ + ['Tamil Nadu', '33', false], + ['33', ' tamil nadu ', false], + ['Tamil Nadu', 'Karnataka', true], + ['33', '29', true], + ['33', '', false], + ['', '29', false], +])('GST state comparison %s / %s is interstate: %s', (supplier, customer, expected) => { + expect(isInterState(supplier, customer)).toBe(expected); +}); + +test('walk-in and configured default customers use the branch state', () => { + const branch = { state: 'Tamil Nadu', default_customer: 'walkin-id' }; + expect(customerState({}, branch)).toBe('Tamil Nadu'); + expect(customerState({ customer_id: 'walkin-id', customer_state: '29' }, branch)).toBe( + 'Tamil Nadu' + ); + expect(customerState({ customer_id: 'named-id' }, branch, { state: '29' })).toBe('29'); + expect(customerState({ customer_id: 'named-id' }, branch)).toBe('Tamil Nadu'); + expect(stateKey(' 33 ')).toBe('33'); +}); diff --git a/api/tests/unit/services/sale.service.test.js b/api/tests/unit/services/sale.service.test.js index 26f6aa551..fe88197ce 100644 --- a/api/tests/unit/services/sale.service.test.js +++ b/api/tests/unit/services/sale.service.test.js @@ -187,6 +187,46 @@ describe('SalesService', () => { mockRegisterRepositoryInstance.validateSessionOwner.mockResolvedValue({ status: true }); }); + test.each([ + ['', undefined, '', false], + ['64f8f2f4c2b9c0a1e4b22222', '', '', false], + ['64f8f2f4c2b9c0a1e4b22222', '33', '', false], + ['64f8f2f4c2b9c0a1e4b22222', ' tamil nadu ', '', false], + ['64f8f2f4c2b9c0a1e4b22222', 'Karnataka', '', true], + ['64f8f2f4c2b9c0a1e4b22222', '29', '', true], + ['64f8f2f4c2b9c0a1e4b22222', '29', '64f8f2f4c2b9c0a1e4b22222', false], + ])( + 'saves GST by customer state (%s, %s, default %s)', + async (customer_id, customer_state, default_customer, inter) => { + mockItemRepositoryInstance.findItemById.mockResolvedValue( + makeItemDoc({ selling_price: 100, tax: 18, tax_type: 'exclusive' }) + ); + const result = await salesService.processSale( + makeSaleData({ + customer_id, + customer_state, + sales_total: '236', + multi_payment: { Cash: 236 }, + }), + '', + 'Add', + makeContext({ + branchState: 'Tamil Nadu', + branchSettings: { sortname: 'IN', indian_gst: 'gst_on', default_customer }, + }) + ); + expect(result.status).toBe(true); + const saved = salesRepository.create.mock.calls[0][0]; + expect(saved.items[0].igst_tax).toBe(inter ? 36 : 0); + expect(saved.items[0].cgst_tax).toBe(inter ? 0 : 18); + expect(saved.items[0].sgst_tax).toBe(inter ? 0 : 18); + expect(saved.items[0].tax_components.map((component) => component.name)).toEqual( + inter ? ['IGST'] : ['CGST', 'SGST'] + ); + if (!customer_state || default_customer) expect(saved.customer_state).toBe('Tamil Nadu'); + } + ); + afterEach(() => { process.removeListener(KOT_EVENT, onKotReady); consoleErrorSpy.mockRestore(); From 34416c1d913761e740806cc1b8c969f8ef4ad110 Mon Sep 17 00:00:00 2001 From: Sridhar Bala Date: Fri, 16 Oct 2026 10:23:39 +0530 Subject: [PATCH 12/13] Make mobile inventory discoverable and available by default --- api/src/models/branch.model.js | 2 +- api/src/services/mobile-inventory.js | 9 ++-- api/src/services/mobile-pos.js | 5 ++- api/src/utils/mobile-inventory-policy.js | 5 +++ api/tests/mobile-inventory.integration.cjs | 45 ++++++++++++++----- docs/MOBILE_INVENTORY_IMPLEMENTATION.md | 10 ++++- frontend/modules/settings_write.html | 2 + .../js/modules/js/item-settings-report.js | 6 +-- languages/_english.json | 1 + 9 files changed, 61 insertions(+), 24 deletions(-) create mode 100644 api/src/utils/mobile-inventory-policy.js diff --git a/api/src/models/branch.model.js b/api/src/models/branch.model.js index 6854779b5..e783dfcf7 100644 --- a/api/src/models/branch.model.js +++ b/api/src/models/branch.model.js @@ -98,7 +98,7 @@ const branchSchema = new Schema( whatsapp_device_id: { type: String, default: '' }, item_stock_default: { type: String, enum: ['always_available', 'track_quantities'] }, inventory_policy: { - enabled: { type: Boolean, default: false }, + enabled: { type: Boolean, default: true }, approvalActions: { type: [String], enum: ['count', 'receive', 'damage', 'loss'], diff --git a/api/src/services/mobile-inventory.js b/api/src/services/mobile-inventory.js index f67fcc443..c2e2524d2 100644 --- a/api/src/services/mobile-inventory.js +++ b/api/src/services/mobile-inventory.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Inventory uses the existing item balance and stocklogs. The effect marker is // written atomically with the balance, so a retry cannot apply a line twice. @@ -25,8 +26,8 @@ const revision = (row) => const wall = (c) => ({ license: c.license, branch_id: c.branchId }); async function access(req, write = false) { const c = await mobile.context(req, Boolean(req.handsetDevice)); - if (req.handsetDevice && c.branch.inventory_policy?.enabled !== true) - mobile.fail('Enable mobile inventory in Core Settings → Items/Inventory.', 403); + if (req.handsetDevice && !inventoryEnabled(c.branch)) + mobile.fail('Enable mobile inventory in Core Settings → Inventory → Mobile inventory.', 403); if (write && req.handsetDevice) { const scope = req.body?.scope; if ( @@ -497,7 +498,7 @@ async function policy(req) { if (req.method === 'POST') { if (!canManage) mobile.fail('An administrator must change inventory policy.', 403); const actions = req.body?.approvalActions; - const enabled = req.body?.enabled ?? c.branch.inventory_policy?.enabled === true; + const enabled = req.body?.enabled ?? inventoryEnabled(c.branch); if ( typeof enabled !== 'boolean' || !Array.isArray(actions) || @@ -519,7 +520,7 @@ async function policy(req) { } return { canManage, - enabled: c.branch.inventory_policy?.enabled === true, + enabled: inventoryEnabled(c.branch), approvalActions: permissions(req, c).approvalActions, }; } diff --git a/api/src/services/mobile-pos.js b/api/src/services/mobile-pos.js index d7afa3842..a8d2a9764 100644 --- a/api/src/services/mobile-pos.js +++ b/api/src/services/mobile-pos.js @@ -1,4 +1,5 @@ 'use strict'; +const inventoryEnabled = require('../utils/mobile-inventory-policy'); // Mobile writes have a durable intent before any effects. Each effect has its // own stable identity, so a lost response or process restart can resume safely @@ -13,7 +14,7 @@ const { fail } = require('../utils/branch-access'); const { allowed } = require('../utils/mobile-pos-access'); const canConnect = (user, branch) => allowed(user, 'sales') || - (allowed(user, 'item', 'read') && (!branch || branch.inventory_policy?.enabled === true)); + (allowed(user, 'item', 'read') && (!branch || inventoryEnabled(branch))); const minor = (n) => Math.round(Number(n || 0) * 100); function canonical(v) { if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'; @@ -220,7 +221,7 @@ async function bootstrap(req) { upiAccounts: c.config.upiAccounts, defaultUpiAccountId: c.config.defaultUpiAccountId || undefined, capabilities: { - inventory: c.branch.inventory_policy?.enabled === true && allowed(req.user, 'item', 'read'), + inventory: inventoryEnabled(c.branch) && allowed(req.user, 'item', 'read'), saleSync: true, devicePairing: true, tillPrint: true, diff --git a/api/src/utils/mobile-inventory-policy.js b/api/src/utils/mobile-inventory-policy.js new file mode 100644 index 000000000..cef01b070 --- /dev/null +++ b/api/src/utils/mobile-inventory-policy.js @@ -0,0 +1,5 @@ +'use strict'; + +// Available by default; an explicit administrator opt-out survives upgrades. +module.exports = (branch) => + branch?.inventory_policy?.enabled == null || branch.inventory_policy.enabled === true; diff --git a/api/tests/mobile-inventory.integration.cjs b/api/tests/mobile-inventory.integration.cjs index 9263ac2e9..32e91e351 100644 --- a/api/tests/mobile-inventory.integration.cjs +++ b/api/tests/mobile-inventory.integration.cjs @@ -173,9 +173,9 @@ test('inventory-only staff can enroll without gaining selling permission', async .updateOne({ _id: row.branch_id }, { $set: { module_mobile_pos_enable: false } }); await assert.rejects(inventory.catalogue(req), { status: 403 }); }); -test('approval policy is administrator-only and persists explicit opt-in', async () => { +test('approval policy is administrator-only and persists selected approval actions', async () => { assert.deepEqual((await inventory.policy(req)).approvalActions, []); - assert.equal((await inventory.policy(req)).enabled, false); + assert.equal((await inventory.policy(req)).enabled, true); req.method = 'POST'; req.body = { enabled: true, approvalActions: ['damage'] }; assert.deepEqual((await inventory.policy(req)).approvalActions, ['damage']); @@ -193,16 +193,14 @@ test('stock history reads the shared desktop audit and remains branch scoped', a assert.equal(history.rows[0].opening, '10'); assert.equal(history.rows[0].closing, '15'); assert.equal(history.rows[0].reason, 'Mobile inventory receive'); - await db - .collection('stocklogs') - .insertOne({ - _id: new ObjectId(), - view_item_id: row._id, - license: row.license, - branch_id: new ObjectId(), - created_date: new Date(), - process: 'Other branch', - }); + await db.collection('stocklogs').insertOne({ + _id: new ObjectId(), + view_item_id: row._id, + license: row.license, + branch_id: new ObjectId(), + created_date: new Date(), + process: 'Other branch', + }); assert.equal((await inventory.history(req)).rows.length, 1); }); test('catalogue is fully paged, including categories and suppliers from the server', async () => { @@ -267,3 +265,26 @@ test('null lines and case-variant duplicate ObjectIds are rejected before writin ]; assert.throws(() => inventory.validate(body), { status: 400 }); }); + +test('mobile inventory defaults on, preserves explicit off and existing approval choices', async () => { + req.method = 'GET'; + assert.equal((await inventory.policy(req)).enabled, true); + req.handsetDevice = 'test-phone'; + assert.equal((await inventory.catalogue(req)).rows.length, 1); + await db.collection('branches').updateOne( + { _id: row.branch_id }, + { + $set: { inventory_policy: { enabled: false, approvalActions: ['count'] } }, + } + ); + assert.equal((await inventory.policy(req)).enabled, false); + assert.deepEqual((await inventory.policy(req)).approvalActions, ['count']); + await assert.rejects(inventory.catalogue(req), { status: 403 }); + req.method = 'POST'; + req.body = { approvalActions: ['count'] }; + assert.equal((await inventory.policy(req)).enabled, false); + req.body.enabled = true; + assert.equal((await inventory.policy(req)).enabled, true); + assert.equal((await inventory.catalogue(req)).rows.length, 1); + assert.equal((await db.collection('items').findOne({ _id: row._id })).available_quantity, 10); +}); diff --git a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md index ee4bd7e42..70b8e8faf 100644 --- a/docs/MOBILE_INVENTORY_IMPLEMENTATION.md +++ b/docs/MOBILE_INVENTORY_IMPLEMENTATION.md @@ -2,7 +2,7 @@ The mobile client and POS server must both include this change. Beta 20 does not include inventory. This document records implemented scope; it does not mark the complete inventory roadmap finished. -Mobile inventory is off by default. An administrator enables **Enable mobile inventory (pilot)** under **Core Settings → Items/Inventory**, then refreshes the phone's connection/catalogue. Existing explicit approval choices are preserved. +Mobile inventory is enabled by default for new branches and branches without a saved choice. An explicit saved disable remains off. Administrators manage **Enable mobile inventory** and approval actions under **Core Settings → Inventory → Mobile inventory**, then refresh the phone's connection/catalogue. Existing approval choices and role permissions are preserved. ## Implemented @@ -12,7 +12,7 @@ Mobile inventory is off by default. An administrator enables **Enable mobile inv - Persistent partial-count, direct-receiving, damage and loss drafts. Counted zero differs from an item never added to a count. Quantities use thousandths, with whole-unit validation. - Server prepare/review/confirm/status/cancel endpoints. A durable request identity and atomic per-item effect prevent duplicate stock changes after a lost reply or restart. Conflicted operations report applied and unapplied lines separately. - Current server ACL and branch/tenant checks, including approval policy rechecking at confirmation. An unchanged count uses an atomic expected-quantity/date/revision check; a changed item requires another observation. Pending sales on this phone block count confirmation. -- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Items/Inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. +- Direct confirmation by default. Administrators can require approval for selected actions under Core Settings → Inventory → Mobile inventory. Owners/admins approve in Inventory → Stock Counts or in the phone's operation history. - Stock changes update the existing item balance and stocklogs; the normal POS sync transports these records. Operation journals remain on the issuing server and are never replayed against a different server. - Mobile labels and messages in all 18 app languages (beta translations; native-speaker review remains necessary). @@ -33,3 +33,9 @@ Local tests use a disposable MongoDB database, never production shop stock: - Mobile: `npm run check`, web export and Playwright tests including receiving/review/confirm and saved zero counts across restart. Device acceptance still needs: offline count/restart, reconnect, interrupted confirmation, actual cashier/admin roles, branch isolation, fractional quantities, camera permission recovery and the installed desktop's sync behavior. + +## Settings rollout and synchronization + +The policy remains `branches.inventory_policy`, scoped to the selected licensed branch. Saving it updates `updated_date`; POS and Gateway classify branches as global tenant data, and the sync agent carries branches on its existing batch lane. No new collection or transport is introduced. Saved true/false and approval choices travel with the branch; unset is interpreted consistently by bootstrap, inventory access, policy reads and the branch model after this update. Operation journals remain local to their issuing server. + +Deploy the POS API and frontend together, then update connected tills to the same implementation. Refresh the mobile snapshot after changing policy. The menu is a server/web change and does not require a new APK. Mixed-version servers may interpret an unset policy differently until upgraded; saving an explicit choice avoids that ambiguity. Live cloud-to-till and other-till delivery is not verified by the local API tests. diff --git a/frontend/modules/settings_write.html b/frontend/modules/settings_write.html index 28aae6323..ab3bfd8bd 100644 --- a/frontend/modules/settings_write.html +++ b/frontend/modules/settings_write.html @@ -5149,6 +5149,7 @@
      General Stock preference +Mobile inventory Expiry reminders
      @@ -5210,6 +5211,7 @@

      Applies to new items in this branch. Existing items keep their stock settings. You can still mark an item sold out manually.

      +
      Expiry reminders

      Reminders appear in the POS bell for products with stock and a saved expiry date. Dates use the shop timezone. No email or WhatsApp messages are sent.

      diff --git a/frontend/static/script/js/modules/js/item-settings-report.js b/frontend/static/script/js/modules/js/item-settings-report.js index 6344c1083..a2d58636a 100644 --- a/frontend/static/script/js/modules/js/item-settings-report.js +++ b/frontend/static/script/js/modules/js/item-settings-report.js @@ -15,11 +15,11 @@ }, load: function () { if (!$('#inventory_approval_settings').length) { - var panel = $('
      Inventory confirmation

      Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:

      '); - $('').appendTo(panel.find('.card-body')); + var panel = $('
      Mobile inventory

      Staff with item write permission confirm directly by default. Require manager approval for selected mobile inventory actions:

      '); + $('').appendTo(panel.find('.card-body')); ['count','receive','damage','loss'].forEach(function (mode) { $('
      diff --git a/frontend/static/script/js/core/PosnicPro.js b/frontend/static/script/js/core/PosnicPro.js index b888abc3e..1188bf235 100644 --- a/frontend/static/script/js/core/PosnicPro.js +++ b/frontend/static/script/js/core/PosnicPro.js @@ -2955,7 +2955,7 @@ PosnicPro = { $('#manage_li_deliverypartners').toggle(on('module_delivery_partners_enable')); $('#manage_li_webshop').toggle(on('module_webshop_enable')); $('#manage_li_theme').toggle(on('module_themes_enable')); - $('#manage_li_ai').toggle(on('ai_enabled') || s.ask_posnic_enabled === true); + $('#manage_li_ai').toggle(s.ask_posnic_enabled === true); $('[data-ask-entry]').toggle(s.ask_posnic_enabled === true); if (PosnicPro.askposnic) PosnicPro.askposnic.setEnabled(s.ask_posnic_enabled === true); $('#manage_li_recyclebin').toggle(on('module_recyclebin_enable')); diff --git a/frontend/static/script/js/core/print-settings.js b/frontend/static/script/js/core/print-settings.js index 43c6fa929..8cfc78204 100644 --- a/frontend/static/script/js/core/print-settings.js +++ b/frontend/static/script/js/core/print-settings.js @@ -79,7 +79,7 @@ state.sales.map(function (v, i) { return row('sales', v, i); }).join('') + row('invoice', state.invoice) + row('quotation', state.quotation) + '' + languageSettings() + '
      ' + (desktop() ? '' : '') + - '
      '); + ''); features(featureState); } function collect() { @@ -109,7 +109,8 @@ } mirror(next); host.find('[data-print-status]').text(PosnicPro.i18n.t('lang_print_settings_saved', 'Print settings saved.')); - } catch (error) { host.find('[data-print-status]').text(error.message); } + return true; + } catch (error) { host.find('[data-print-status]').text(error.message); return false; } finally { button.prop('disabled', false); } } async function mount(data) { diff --git a/frontend/static/script/js/modules/js/ask_posnic.js b/frontend/static/script/js/modules/js/ask_posnic.js index e18c3ef25..d748a38fc 100644 --- a/frontend/static/script/js/modules/js/ask_posnic.js +++ b/frontend/static/script/js/modules/js/ask_posnic.js @@ -39,7 +39,7 @@ PosnicPro.askposnic = { this.loadStatus(); }, settingsTab: function (key, focus) { - if (!this.admin || ['general', 'knowledge', 'access', 'usage', 'schedules'].indexOf(key) === -1) return; + if (!this.enabled || !this.admin || ['general', 'knowledge', 'access', 'usage', 'schedules'].indexOf(key) === -1) return; this.activeSettingsTab = key; $('[data-ask-tab]').each(function () { var active = $(this).attr('data-ask-tab') === key; @@ -576,10 +576,12 @@ PosnicPro.askposnic = { }); $('#ask_posnic_preferences_form').on('submit', function (event) { event.preventDefault(); - PosnicPro.request({ url: 'ask-posnic/preferences', method: 'PUT', data: JSON.stringify({ own_key_semantic: $('#ask_pref_own_semantic').is(':checked'), own_key_semantic_budget: Number($('#ask_pref_embedding_budget').val()), help_enabled: $('#ask_pref_help').is(':checked'), insights_enabled: $('#ask_pref_insights').is(':checked'), actions_enabled: $('#ask_pref_actions').is(':checked'), store_conversations: $('#ask_pref_history').is(':checked'), retention_days: Number($('#ask_pref_retention').val()), default_period: $('#ask_pref_period').val(), response_language: $('#ask_pref_language').val(), help_instructions: $('#ask_help_instructions').val(), roles: { help: $('#ask_pref_help_roles').val() || [], insights: $('#ask_pref_insight_roles').val() || [], actions: $('#ask_pref_action_roles').val() || [] }, allowed_actions: $('.ask-allowed-action:checked').map(function () { return this.value; }).get() }) }, function (response) { + function savePreferences() { PosnicPro.request({ url: 'ask-posnic/preferences', method: 'PUT', data: JSON.stringify({ own_key_semantic: $('#ask_pref_own_semantic').is(':checked'), own_key_semantic_budget: Number($('#ask_pref_embedding_budget').val()), help_enabled: $('#ask_pref_help').is(':checked'), insights_enabled: $('#ask_pref_insights').is(':checked'), actions_enabled: $('#ask_pref_actions').is(':checked'), store_conversations: $('#ask_pref_history').is(':checked'), retention_days: Number($('#ask_pref_retention').val()), default_period: $('#ask_pref_period').val(), response_language: $('#ask_pref_language').val(), help_instructions: $('#ask_help_instructions').val(), roles: { help: $('#ask_pref_help_roles').val() || [], insights: $('#ask_pref_insight_roles').val() || [], actions: $('#ask_pref_action_roles').val() || [] }, allowed_actions: $('.ask-allowed-action:checked').map(function () { return this.value; }).get() }) }, function (response) { if (response && response.type === 'success') { self.loadStatus(); PosnicPro.alert('success', response.message); } else PosnicPro.alert('error', response.message || 'Could not save Ask Posnic settings.'); - }); + }); } + if (self.activeSettingsTab === 'usage') PosnicPro.settings.ai.save(savePreferences); + else savePreferences(); }); $('#ask_posnic_schedules').on('click', '.ask-schedule-delete', function () { PosnicPro.request({ url: 'ask-posnic/schedules/' + encodeURIComponent($(this).data('id')), method: 'DELETE' }, function (response) { if (response && response.type === 'success') self.loadSchedules(); }); diff --git a/frontend/static/script/js/modules/js/receipt-designer-editor.js b/frontend/static/script/js/modules/js/receipt-designer-editor.js index 11f2d9c67..9730d306b 100644 --- a/frontend/static/script/js/modules/js/receipt-designer-editor.js +++ b/frontend/static/script/js/modules/js/receipt-designer-editor.js @@ -303,10 +303,15 @@ status(xhr && xhr.responseJSON && xhr.responseJSON.message || t('Could not save designs. Your changes are still here.'), true); }); } - function saveBehaviour() { + async function saveBehaviour() { var button = $('#save-print-behaviour'), note = $('#print-behaviour-status'); + if (button.prop('disabled')) return; var values = printOptions(), payload = { printall: values.printall, bill_print_copies: values.bill_print_copies }; button.prop('disabled', true); + if (PosnicPro.printSettings && await PosnicPro.printSettings.save() === false) { + button.prop('disabled', false); + return; + } PosnicPro.put({ url: 'setting/updateCommonSettings', data: JSON.stringify(payload) }, function (res) { button.prop('disabled', false); if (res.type !== 'success') { note.text(res.message || PosnicPro.i18n.t('lang_print_save_failed', 'Could not save print settings.')); return; } diff --git a/frontend/static/script/js/modules/js/settings.js b/frontend/static/script/js/modules/js/settings.js index 4f34c1c4e..2277ee59b 100644 --- a/frontend/static/script/js/modules/js/settings.js +++ b/frontend/static/script/js/modules/js/settings.js @@ -207,7 +207,7 @@ PosnicPro.settings = { $('#india_qrmp_row').toggle(!above); } }, - taxSystemSave: function () { + taxSystemSave: function (done) { var values = { tax_regime: $('#tax_regime_override').val() || null, india_gst_type: $('#india_gst_type').val(), @@ -216,6 +216,7 @@ PosnicPro.settings = { us_resale_certificate: $.trim($('#us_resale_certificate').val()) }; PosnicPro.put({ url: 'settings/group/tax', data: JSON.stringify(values) }, function (r) { + if (r.type === 'success' && done) { done(); return; } PosnicPro.alert(r.type || 'success', r.message || 'Tax system saved'); PosnicPro.settings.taxSystemLoad(); }, function (xhr) { @@ -2178,7 +2179,7 @@ if ($wrapper.length) { /* successLabel: what the toast says on success - each Save button names its own act ("Module switches saved") instead of the generic server line, which reads the same from four different screens. */ - updateCommonSetting: function (successLabel) { + updateCommonSetting: function (successLabel, done) { var loader = $(".loader-view-mystore"); $("
      ").appendTo(loader); var taxDetail = $("#tax_percentage").select2("data"); @@ -2482,9 +2483,10 @@ if ($("#sale_quick_edit").is(":checked")) { } } } - PosnicPro.alert(response.type, + if (response.type !== 'success' || !done) PosnicPro.alert(response.type, (response.type === 'success' && successLabel) ? successLabel : response.message); loader.find(".loadingSpinner:first").remove(); + if (response.type === 'success' && done) done(); }, function (xhr) { var response = jQuery.parseJSON(xhr.responseText); PosnicPro.alert(response.type, response.message); @@ -2561,6 +2563,7 @@ if ($("#sale_quick_edit").is(":checked")) { var on = function (k) { return s[k] !== false; }; $('#v-pills-taxmodule-tab').toggle(on('module_tax_enable')); + $('#v-pills-ai-tab').toggle(s.ask_posnic_enabled === true); $('#v-pills-credit-tab').toggle(on('module_credit_enable')); $('#v-pills-marketingmodule-tab').toggle(on('module_marketing_enable')); $('#v-pills-messagingmodule-tab').toggle(on('module_messaging_enable')); @@ -4986,7 +4989,11 @@ $("#tax_discount_add").submit(function (event) { return; } if ($('#tax_discount_add').valid()) { // checks form for validity - PosnicPro.settings.updateCommonSetting('Core Settings saved'); + PosnicPro.settings.updateCommonSetting('Core Settings saved', function () { + if (PosnicPro.settings._sharingLoaded && PosnicPro.settings._sharingDirty) { + PosnicPro.settings.saveSharing(); + } else PosnicPro.alert('success', PosnicPro.i18n.t('lang_successfully_completed', 'Successfully Completed')); + }); } }); $("#tax_checked_all").click(function () { @@ -7133,6 +7140,8 @@ PosnicPro.settings.SHARING_KEYS = ['share_customers', 'share_suppliers']; PosnicPro.settings.loadSharing = function () { if (!$('#sharing_fieldset').length) { return; } + PosnicPro.settings._sharingLoaded = false; + PosnicPro.settings._sharingDirty = false; PosnicPro.get({ url: 'settings/group/sharing', data: { level: 'account' } }, function (r) { var values = (r && r.data && r.data.values) || {}; $.each(PosnicPro.settings.SHARING_KEYS, function (i, key) { @@ -7142,6 +7151,7 @@ PosnicPro.settings.loadSharing = function () { $('#set_' + key).prop('checked', PosnicPro.settings._sharingOn(values[key])); }); $('#sharing_status').text(''); + PosnicPro.settings._sharingLoaded = r && r.type === 'success'; }, function () { $('#sharing_status').text(PosnicPro.i18n.t('lang_could_not_read_the_current_setting', 'Could not read the current setting.')); }); @@ -7169,6 +7179,7 @@ PosnicPro.settings.saveSharing = function () { PosnicPro.put({ url: 'settings/group/sharing', data: JSON.stringify(payload) }, function (r) { $('#sharing_save_btn').prop('disabled', false); $('#sharing_status').text(r.type === 'success' ? 'Saved. Applies to every shop.' : ''); + if (r.type === 'success') PosnicPro.settings._sharingDirty = false; PosnicPro.alert(r.type, r.type === 'success' ? 'Sharing saved' : r.message); }, function (xhr) { $('#sharing_save_btn').prop('disabled', false); @@ -10176,7 +10187,7 @@ PosnicPro.settings.ai = { }); }, function () { /* kept */ }); }, - save: function () { + save: function (done) { var provider = $('#ai_provider').val() || ''; var key = String($('#ai_api_key').val() || ''); var cap = String($('#ai_monthly_cap').val() || '').trim(); @@ -10206,13 +10217,14 @@ PosnicPro.settings.ai = { that emptiness through would blank the shop's credential the first time anybody changed the limit. */ if (!key) { - PosnicPro.alert('success', PosnicPro.i18n.t('lang_ai_saved', + if (!done) PosnicPro.alert('success', PosnicPro.i18n.t('lang_ai_saved', 'Saved. The AI button now appears on the item screen, beside Description.')); /* The item screen asks once per session whether AI is usable; that answer is now stale, so let it ask again rather than leaving the button hidden until a reload. */ if (PosnicPro.items) { PosnicPro.items._aiAvailable = null; } $('#ai_api_key').val(''); + if (done) done(); return; } PosnicPro.put({ @@ -10220,7 +10232,7 @@ PosnicPro.settings.ai = { data: JSON.stringify({ ai_api_key: key }) }, function (second) { if (second.type === 'success') { - PosnicPro.alert('success', PosnicPro.i18n.t('lang_ai_saved', + if (!done) PosnicPro.alert('success', PosnicPro.i18n.t('lang_ai_saved', 'Saved. The AI button now appears on the item screen, beside Description.')); /* The item screen asks once per session whether AI is usable; that answer is now stale, so let it ask again rather than leaving the @@ -10228,6 +10240,7 @@ PosnicPro.settings.ai = { if (PosnicPro.items) { PosnicPro.items._aiAvailable = null; } $('#ai_api_key').val(''); PosnicPro.settings.ai.load(); + if (done) done(); } else { PosnicPro.alert(second.type, second.message); } @@ -10342,3 +10355,5 @@ $(document).on('click','#save_captain_tables',function(){ button.prop('disabled',false);$('#captain_tables_message').text(PosnicPro.i18n.t('lang_successfully_completed','Successfully Completed')); },function(){button.prop('disabled',false);$('#captain_tables_message').text(PosnicPro.i18n.t('lang_could_not_save_table_settings_please_retry', 'Could not save table settings. Please retry.'));}); }); + +$(document).on("change", "#sharing_fieldset input", function () { PosnicPro.settings._sharingDirty = true; }); diff --git a/frontend/static/style/css/receipt-designer.css b/frontend/static/style/css/receipt-designer.css index f24a3417a..adb3725d3 100644 --- a/frontend/static/style/css/receipt-designer.css +++ b/frontend/static/style/css/receipt-designer.css @@ -1,6 +1,10 @@ #receipt-designer { --rd-border: #dce3ee; --rd-soft: #f5f7fb; --rd-ink: #203047; color: var(--rd-ink); } #receipt-designer * { box-sizing: border-box; } .card:has(#core-tab-print.active) > .card-footer { display: none; } +.card:has(#core-tab-inventory.active #inv-sub-stock.active) > .card-footer, +.card:has(#core-tab-inventory.active #inv-sub-expiry.active) > .card-footer, +.card:has(#core-tab-inventory.active #inv-sub-mobile.active) > .card-footer, +.card:has(#core-tab-payments.active #pay-sub-gateway.active) > .card-footer { display: none; } #receipt-designer button, #receipt-designer input, #receipt-designer select, #receipt-designer textarea { font: inherit; } #receipt-designer button:focus-visible, #receipt-designer input:focus-visible, #receipt-designer select:focus-visible, #receipt-designer textarea:focus-visible { outline: 3px solid #8bb6ff; outline-offset: 2px; } .rd-topbar { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; justify-content: space-between; margin: 0 0 12px; } diff --git a/languages/_english.json b/languages/_english.json index 8918bcc15..4c52e17bb 100644 --- a/languages/_english.json +++ b/languages/_english.json @@ -221,7 +221,6 @@ "lang_ai_ordering_assistant": "Let customers ask the assistant on your online ordering page", "lang_ai_ordering_assistant_hint": "A spark appears on your ordering page. Customers ask in their own words, and the assistant answers from your menu and can add to their order. It never places or pays. Every question is billed to your account above, within the monthly limit, and it is off until you switch it on.", "lang_ai_provider": "Who answers", - "lang_ai_save": "Save", "lang_ai_saved": "Saved. The AI button now appears on the item screen, beside Description.", "lang_ai_settings": "AI settings", "lang_ai_spend_calls": "Calls", @@ -1938,8 +1937,6 @@ "lang_inv": "INV-", "lang_invalid_data": "Invalid data...!!!", "lang_invenlog_title": "Inventory Logs", - "lang_mobile_inventory": "Mobile inventory", - "lang_inventory_confirmation": "Inventory confirmation", "lang_inventory_count": "Inventory count", "lang_inventory_log": "Inventory Log", "lang_inventory_logs": "Inventory logs", @@ -2441,6 +2438,7 @@ "lang_minimum_spend_to_earn": "Minimum spend to earn (", "lang_minutes_ago": "minutes ago", "lang_mobile": "Mobile Number", + "lang_mobile_inventory": "Mobile inventory", "lang_mobile_inventory_operations": "Mobile inventory operations", "lang_mobile_no": "Mobile No:", "lang_mobile_pos_settings": "Mobile POS settings", @@ -3277,7 +3275,6 @@ "lang_print_sales_receipts": "Sales receipts", "lang_print_sample": "Print sample", "lang_print_save_failed": "Could not save print settings.", - "lang_print_save_settings": "Save print settings", "lang_print_settings": "Print settings", "lang_print_settings_saved": "Print settings saved.", "lang_print_shelf_labels": "Print shelf labels", @@ -3930,12 +3927,10 @@ "lang_save_loyalty_settings": "Save loyalty settings", "lang_save_note": "Save note", "lang_save_outlet": "Save outlet", - "lang_save_printing_options": "Save printing options", "lang_save_quotation": "Save quotation", "lang_save_quotation_settings": "Save quotation settings", "lang_save_role": "Save Role", "lang_save_sale": "Save Sale", - "lang_save_sharing": "Save sharing", "lang_save_targets": "Save Targets", "lang_save_template": "Save Template", "lang_save_the_user_first_then_assign_a_card": "Save the user first, then assign a card.", diff --git a/languages/ne.json b/languages/ne.json index f24dc0b9f..e33ac09f9 100644 --- a/languages/ne.json +++ b/languages/ne.json @@ -1,4 +1,5 @@ { + "lang_mobile_inventory": "मोबाइल इन्भेन्टरी", "lang_background_colour": "पृष्ठभूमि रङ", "lang_barcode_format": "बारकोड", "lang_barcode_margin": "बार मार्जिन", diff --git a/languages/nl.json b/languages/nl.json index 3a3209c39..4867f8b17 100644 --- a/languages/nl.json +++ b/languages/nl.json @@ -1,4 +1,5 @@ { + "lang_mobile_inventory": "Mobiele voorraad", "lang_made_with_posnic_posnic_com": "Gemaakt met Posnic · posnic.com", "lang_actual_size": "Werkelijke grootte", "lang_fit_whole_page": "Hele pagina passend weergeven", diff --git a/tests/ai-feature-switch.test.js b/tests/ai-feature-switch.test.js index 292f5aa83..166f9dd6a 100644 --- a/tests/ai-feature-switch.test.js +++ b/tests/ai-feature-switch.test.js @@ -131,7 +131,7 @@ test('the sidebar has a way in, gated on the switch', () => { reachable only by scrolling the settings rail and hoping. */ assert.match(sidebar, /id="manage_li_ai"/, 'the AI row is gone from the sidebar'); assert.match(sidebar, /href="#\/settings\/ai"/, 'the AI row points nowhere'); - assert.match(core, /\$\('#manage_li_ai'\)\.toggle\(on\('ai_enabled'\) \|\| s.ask_posnic_enabled === true\)/, + assert.match(core, /\$\('#manage_li_ai'\)\.toggle\(s.ask_posnic_enabled === true\)/, 'the AI sidebar row is not gated on the switch'); }); diff --git a/tests/ask-posnic-focused-ux.test.js b/tests/ask-posnic-focused-ux.test.js index 6c691a7fc..8dd330bfc 100644 --- a/tests/ask-posnic-focused-ux.test.js +++ b/tests/ask-posnic-focused-ux.test.js @@ -56,6 +56,29 @@ test('preferences retain form ownership across tabs and save every section', () assert.equal(saved.own_key_semantic, true); assert.deepEqual(saved.allowed_actions, ['stock_count']); $('[id^="ask_pref_"],#ask_help_instructions,.ask-allowed-action').each(function () { assert.equal(this.form.id, 'ask_posnic_preferences_form'); }); }); + +test('disabled Ask Posnic never loads protected settings tab APIs', () => { + const { ui, calls } = setup(); + ui.showSettings(); + ui.setEnabled(false); + const before = calls.length; + for (const tab of ['knowledge', 'usage', 'schedules']) ui.settingsTab(tab); + assert.equal(calls.length, before); +}); + +test('one AI usage save writes provider settings before assistant preferences', () => { + const { $, ui, PosnicPro } = setup(); + ui.showSettings(); ui.settingsTab('usage'); + let finishProvider; + let preferenceWrites = 0; + PosnicPro.settings.ai.save = done => { finishProvider = done; }; + PosnicPro.request = () => { preferenceWrites++; }; + $('#ask_posnic_preferences_form').triggerHandler('submit'); + assert.equal($('#ai_save').length, 0); + assert.equal(preferenceWrites, 0); + finishProvider(); + assert.equal(preferenceWrites, 1); +}); test('question progress, Enter and new conversation keep the chat usable', () => { const { $, ui, PosnicPro } = setup(); ui.showDataTablePage(); diff --git a/tests/branch-data-sharing.test.js b/tests/branch-data-sharing.test.js index 54511295d..135a1472b 100644 --- a/tests/branch-data-sharing.test.js +++ b/tests/branch-data-sharing.test.js @@ -122,13 +122,14 @@ test('Settings carries the switches the branch form promised', () => { for (const id of ['set_share_customers', 'set_share_suppliers']) { assert.ok(new RegExp(`id="${id}"`).test(settingsHtml), `${id} is missing from Settings`); } - assert.match(settingsHtml, /id="sharing_save_btn"/, 'there is no way to save it'); + assert.match(settingsHtml, /id="tax_discount_add"/, 'there is no Core Settings save form'); }); -test('it has its OWN save, not the general form save', () => { - /* Everything around it writes to this branch. Riding that save would let a - screen opened on one shop push that shop's view onto all of them. */ - assert.match(settingsHtml, /onclick="PosnicPro\.settings\.saveSharing\(\);"/, 'no dedicated save'); +test('the unified button keeps sharing on its own account-level endpoint', () => { + assert.doesNotMatch(settingsHtml, /id="sharing_save_btn"/, 'duplicate save button'); + const submit = blockAt(settingsJs, '$("#tax_discount_add").submit(function (event) {'); + assert.match(submit, /_sharingLoaded && PosnicPro.settings._sharingDirty/); + assert.match(submit, /PosnicPro.settings.saveSharing\(\)/); const save = blockAt(settingsJs, 'PosnicPro.settings.saveSharing = function () {'); assert.match(save, /url: 'settings\/group\/sharing'/, 'it must use the sharing endpoint alone'); assert.match(save, /level: 'account'/, 'writing at branch level would not share anything'); diff --git a/tests/receipt-designer.test.js b/tests/receipt-designer.test.js index fbe53cb02..5064d2071 100644 --- a/tests/receipt-designer.test.js +++ b/tests/receipt-designer.test.js @@ -160,10 +160,12 @@ test('editor adds and reorders blocks, keeps formats independent, and sends a pa dom.window.close(); }); -test('saving printing behaviour does not submit or discard unsaved receipt designs', () => { +test('saving printing behaviour does not submit or discard unsaved receipt designs', async () => { const { dom, w, $, branch } = setup(); $('body').append('
      '); const sent = []; + let printerSaves = 0; + w.PosnicPro.printSettings = { save: async () => { printerSaves++; return true; } }; w.PosnicPro.put = (request, done) => { const data = JSON.parse(request.data); sent.push(data); done({ type: 'success', data: data }); @@ -173,6 +175,8 @@ test('saving printing behaviour does not submit or discard unsaved receipt desig $('#rd-block-text').val('New layout text').trigger('input'); $('#printall').prop('checked', true).trigger('change'); $('#save-print-behaviour').trigger('click'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(printerSaves, 1); assert.deepEqual(sent[0], { printall: 'true', bill_print_copies: '1' }); assert.match($('#print-behaviour-status').text(), /saved/); assert.match($('.rd-status').text(), /Unsaved/); @@ -182,6 +186,20 @@ test('saving printing behaviour does not submit or discard unsaved receipt desig dom.window.close(); }); +test('a failed device printer save prevents a misleading printing-options success', async () => { + const { dom, w, $, branch } = setup(); + $('body').append(''); + let writes = 0; + w.PosnicPro.printSettings = { save: async () => false }; + w.PosnicPro.put = () => { writes++; }; + w.PosnicPro.receiptDesignerEditor.load(branch); + $('#save-print-behaviour').trigger('click'); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(writes, 0); + assert.equal($('#save-print-behaviour').prop('disabled'), false); + dom.window.close(); +}); + test('FSSAI prints from current branch details on all formats and an explicit field controls its position', () => { const { dom, engine, design, sale, $ } = setup(); sale.table_options = 'enable'; sale.country = 'India'; sale.branch_fssai_number = ' 12345678901234 '; diff --git a/tests/settings-save-routing.test.js b/tests/settings-save-routing.test.js index 9a4f5e30a..970477bc6 100644 --- a/tests/settings-save-routing.test.js +++ b/tests/settings-save-routing.test.js @@ -64,3 +64,24 @@ test('the Core Settings form still saves the receipt designer when Receipt Print assert.equal(h.requests.length, 1); } finally { h.dom.window.close(); } }); + +test('the single Core Save includes changed account sharing only after the branch save succeeds', () => { + const h = setup(); + h.$('#core-tab-print').removeClass('active'); + let next, sharing = 0; + h.w.PosnicPro.alert = () => {}; + h.w.PosnicPro.i18n = { t: (_key, fallback) => fallback }; + h.w.PosnicPro.settings.updateCommonSetting = (_label, done) => { next = done; }; + h.w.PosnicPro.settings.saveSharing = () => { sharing++; }; + h.w.PosnicPro.settings._sharingLoaded = true; + h.w.PosnicPro.settings._sharingDirty = true; + h.$('#tax_discount_add').triggerHandler('submit'); + assert.equal(sharing, 0); + next(); + assert.equal(sharing, 1); + h.w.PosnicPro.settings._sharingDirty = false; + h.$('#tax_discount_add').triggerHandler('submit'); + next(); + assert.equal(sharing, 1, 'unchanged account sharing must not be overwritten'); + h.dom.window.close(); +}); diff --git a/tests/settings-single-save.test.js b/tests/settings-single-save.test.js new file mode 100644 index 000000000..89fccb931 --- /dev/null +++ b/tests/settings-single-save.test.js @@ -0,0 +1,41 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const { JSDOM } = require('jsdom'); + +test('Ask Posnic menu follows its opt-in, even when other AI features are enabled', () => { + const dom = new JSDOM('
    1. ', { runScripts: 'outside-only' }); + const w = dom.window; + w.$ = require('jquery')(w); + w.PosnicPro = { local: { get() {} }, demoSamples: { load() {} } }; + const source = fs.readFileSync('frontend/static/script/js/core/PosnicPro.js', 'utf8'); + const start = source.indexOf(' applyModuleSidebar: function () {'); + const end = source.indexOf('\n },', start); + const apply = w.eval('(' + source.slice(start, end + 6).replace('applyModuleSidebar:', '') + ')'); + for (const enabled of [undefined, false, true, false]) { + w.PosnicPro.local.get = key => key === 'general_settings' ? JSON.stringify({ ai_enabled: true, ask_posnic_enabled: enabled }) : null; + apply(); + assert.equal(w.$('#manage_li_ai').css('display') !== 'none', enabled === true); + assert.equal(w.$('[data-ask-entry]').css('display') !== 'none', enabled === true); + } + w.close(); +}); + +test('specialised Core Settings views hide only the duplicate outer footer', () => { + const html = fs.readFileSync('frontend/modules/settings_write.html', 'utf8'); + const css = fs.readFileSync('frontend/static/style/css/receipt-designer.css', 'utf8'); + const dom = new JSDOM('' + html); + const { document, getComputedStyle } = dom.window; + const footer = document.querySelector('#settings_edit_reset').parentElement; + for (const [tab, sub] of [['inventory', 'stock'], ['inventory', 'expiry'], ['inventory', 'mobile'], ['payments', 'gateway']]) { + document.querySelectorAll('[id^="core-tab-"], [id^="inv-sub-"], [id^="pay-sub-"]').forEach(node => node.classList.remove('active')); + document.querySelector('#core-tab-' + tab).classList.add('active'); + document.querySelector('#' + (tab === 'inventory' ? 'inv' : 'pay') + '-sub-' + sub).classList.add('active'); + assert.equal(getComputedStyle(footer).display, 'none', tab + '/' + sub); + } + assert.equal(document.querySelectorAll('#pay-sub-general button').length, 0); + assert.equal(document.querySelectorAll('#tax-sub-general button').length, 1); + assert.equal(document.querySelector('#sharing_save_btn'), null); + assert.equal(document.querySelector('#ai_save'), null); + dom.window.close(); +});