diff --git a/api/src/helpers/receipt-design.js b/api/src/helpers/receipt-design.js
index a4cb19662..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(
@@ -120,6 +143,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)
@@ -267,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 55153e62a..561c2e31c 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);
@@ -296,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);
@@ -350,7 +355,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..11f2d9c67 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' : '',
@@ -232,6 +235,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 +335,7 @@
'
' +
'
' + esc(t('Your layout')) + '
' + button('reset-template', 'Reset template', 'refresh-cw') + button('undo', 'Undo', 'rotate-ccw', 'disabled') + '
' + esc(t('Reset this format to the standard template? Other formats and branch details are kept. Save designs to apply.')) + '
' + 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.')) + '
' +
'
' +
- '');
+ '');
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 +361,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/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?",
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",
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);
diff --git a/tests/receipt-designer.test.js b/tests/receipt-designer.test.js
index 08fe4142e..fbe53cb02 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';
@@ -1313,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;