Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions api/src/helpers/receipt-design.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -267,6 +295,7 @@
return out;
}
return {
taxLabel: taxLabel,
taxRegistrationLabel: taxRegistrationLabel,
formats: formats,
fields: fields,
Expand Down
19 changes: 12 additions & 7 deletions frontend/static/script/js/core/receipt-designer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 += '<section class="rd-block rd-branding" style="text-align:center;font-size:10px">Made with Posnic &middot; posnic.com</section>';
if (designs.showBranding !== false) html += '<section class="rd-block rd-branding" style="text-align:center;font-size:10px;white-space:pre-line">' + esc(String(designs.brandingText || '').trim() || 'Made with Posnic · posnic.com') + '</section>';
if (pendingGoods) html += '<section class="rd-block rd-payment-status">' + esc(label('Payment pending')) + '</section>';
if (sheet) {
var notes = '';
Expand Down
12 changes: 9 additions & 3 deletions frontend/static/script/js/modules/js/receipt-designer-editor.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' : '',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -331,7 +335,7 @@
'<div class="rd-workspace"><aside class="rd-library"><h4>' + esc(t('Add a block')) + '</h4><p>' + esc(t('Click to add. Drag blocks to reorder.')) + '</p><div class="rd-library-buttons">' + ['text', 'qr', 'image', 'logo', 'barcode', 'divider', 'signature'].map(function (type) { return '<button type="button" data-add="' + type + '"><span>+</span>' + esc(t(names[type])) + '</button>'; }).join('') + '</div><h4>' + esc(t('Sale fields')) + '</h4><div class="rd-library-buttons">' + Object.keys(schema.fields).filter(function (f) { return schema.fieldAvailable(f, branch); }).map(function (field) { return '<button type="button" data-add="field" data-field="' + field + '"><span>+</span>' + esc(t(schema.fields[field])) + '</button>'; }).join('') + '</div></aside>' +
'<section class="rd-layout"><div class="rd-section-heading"><h4>' + esc(t('Your layout')) + ' <small class="rd-block-count"></small></h4><div>' + button('reset-template', 'Reset template', 'refresh-cw') + button('undo', 'Undo', 'rotate-ccw', 'disabled') + '</div></div><div class="rd-reset-confirm" hidden><p>' + esc(t('Reset this format to the standard template? Other formats and branch details are kept. Save designs to apply.')) + '</p><button type="button" class="btn btn-outline-primary btn-sm" data-action="confirm-reset">' + esc(t('Reset template')) + '</button> <button type="button" class="btn btn-light btn-sm" data-action="cancel-reset">' + esc(t('Cancel')) + '</button></div><div class="rd-font-control"><label for="rd-text-size">' + esc(t('Default text size')) + '</label><select id="rd-text-size" aria-describedby="rd-font-scope">' + [8,9,10,11,12,13,14,16,18].map(function (n) { return '<option value="' + n + '">' + n + ' px</option>'; }).join('') + '</select><small id="rd-font-scope">' + esc(t('Applies to this paper format. Select a block to override its text size or make it bold.')) + '</small></div><ol class="rd-block-list"></ol><p class="rd-help">' + esc(t('Store details, receipt details, items and totals are always included.')) + '</p></section>' +
'<aside class="rd-preview"><div class="rd-section-heading"><h4>' + esc(t('Live preview')) + '</h4><select class="rd-preview-fit" aria-label="' + esc(t('Preview zoom')) + '"><option value="page">' + esc(t('Fit whole receipt')) + '</option><option value="width">' + esc(t('Fit width')) + '</option></select></div><div class="rd-preview-stage"><div class="rd-preview-page"></div></div><div class="rd-preview-footer"><strong class="rd-preview-name"></strong><span class="rd-dimensions"></span><span class="rd-sample-label">' + esc(t('Sample sale')) + '</span></div></aside></div>' +
'<section class="rd-print-options"><label><input type="checkbox" id="rd-branding" ' + (design.showBranding !== false ? 'checked' : '') + '> Made with Posnic &middot; posnic.com</label><div class="rd-existing-options"></div></section>');
'<section class="rd-print-options"><label><input type="checkbox" id="rd-branding" ' + (design.showBranding !== false ? 'checked' : '') + '> ' + esc(t('Show footer text')) + '</label><label for="rd-branding-text">' + esc(t('Footer text')) + '</label><textarea id="rd-branding-text" class="form-control" rows="2" maxlength="200" placeholder="Made with Posnic &middot; posnic.com" data-t-placeholder="lang_made_with_posnic_posnic_com">' + esc(design.brandingText || '') + '</textarea><small>' + esc(t('Leave blank to use the default. Applies to all paper sizes.')) + '</small><div class="rd-existing-options"></div></section>');
if (sampleTaxEnabled()) {
box.find('.rd-preview-footer').append('<label>' + esc(PosnicPro.i18n.t('lang_rd_sample_tax_help', 'Example tax: 5%. Actual receipts use saved sale taxes.')) + (sampleIndianGst() ? '<select id="rd-sample-tax"><option value="intra">CGST + SGST</option><option value="inter" data-t="lang_igst_tax_receiving">IGST</option></select>' : '') + '</label>');
box.find('#rd-sample-tax').val(sampleInterstate ? 'inter' : 'intra').on('change', function () { sampleInterstate = this.value === 'inter'; schedulePreview(); });
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions languages/_english.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 &middot; posnic.com",
"lang_mail": "Report Email Settings",
"lang_make_the_change": "Make the change",
"lang_making_this_shop_yours": "Making this shop yours?",
Expand Down
1 change: 1 addition & 0 deletions languages/nl.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
11 changes: 9 additions & 2 deletions src/hardware-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', ''))
Expand Down Expand Up @@ -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;
Expand Down
20 changes: 19 additions & 1 deletion src/windows-spooler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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) };
40 changes: 40 additions & 0 deletions tests/default-printer.test.js
Original file line number Diff line number Diff line change
@@ -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);
}
});
Loading
Loading