// Network (ePOS-Print) thermal printing for Epson TM-series Ethernet // printers. Runs alongside the existing Web Bluetooth path in // receipt.html — the till picks a transport in Printer settings. // // The printer's ePOS-Print endpoint is served over HTTPS with a // self-signed cert and open CORS (`Access-Control-Allow-Origin: *`), // confirmed by direct testing against the shop's TM-T20II. Each till // device must visit `https://` once in its browser and // accept the certificate warning before fetch() calls here succeed. window.EposPrint = (function () { 'use strict'; const SETTINGS_KEY = 'pos.printerSettings'; function getSettings() { try { return JSON.parse(localStorage.getItem(SETTINGS_KEY)) || { type: 'bluetooth', ip: '' }; } catch (e) { return { type: 'bluetooth', ip: '' }; } } function setSettings(settings) { localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); } // Rasterize a same-origin image into the packed 1bpp bitmap the // ePOS-Print tag expects. targetWidth must be a multiple of // 8; 384 is the width verified against the shop's TM-T20II. function imageToRaster(url, targetWidth) { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => { const targetHeight = Math.round(img.height * targetWidth / img.width); const canvas = document.createElement('canvas'); canvas.width = targetWidth; canvas.height = targetHeight; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, targetWidth, targetHeight); ctx.drawImage(img, 0, 0, targetWidth, targetHeight); let imageData; try { imageData = ctx.getImageData(0, 0, targetWidth, targetHeight); } catch (e) { reject(new Error('Could not read logo pixels (cross-origin image?): ' + e.message)); return; } const data = imageData.data; const rowBytes = Math.ceil(targetWidth / 8); const packed = new Uint8Array(rowBytes * targetHeight); for (let y = 0; y < targetHeight; y++) { for (let x = 0; x < targetWidth; x++) { const i = (y * targetWidth + x) * 4; const luminance = data[i] * 0.3 + data[i + 1] * 0.59 + data[i + 2] * 0.11; const alpha = data[i + 3]; if (alpha > 128 && luminance < 128) { packed[y * rowBytes + (x >> 3)] |= 0x80 >> (x % 8); } } } let binary = ''; for (let i = 0; i < packed.length; i++) binary += String.fromCharCode(packed[i]); resolve({ width: targetWidth, height: targetHeight, base64: btoa(binary) }); }; img.onerror = () => reject(new Error('Could not load logo image: ' + url)); img.src = url; }); } // Rasterize the ACTIVE LETTERHEAD's logo for printing — not the // app's bundled logo. `letterhead.logo_url` is served same-origin // through /api/v1/files/ (see pos-letterhead-service.ts); a raw // PocketBase URL would taint the canvas in imageToRaster() and // silently print no logo at all. // // Returns null when the letterhead has no logo uploaded — we // deliberately do NOT fall back to the bundled WFDASH logo, since // printing one company's mark on another company's slip is worse // than printing none. async function letterheadLogoRaster(letterhead, width) { const url = letterhead && letterhead.logo_url; if (!url) return null; return imageToRaster(url, width || 384).catch(function () { return null; }); } function xmlEscape(s) { return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&', '<': '<', '>': '>' }[c])); } // Per-block XML fragment renderers for the sale slip. Each function // returns the exact XML this block contributes; `block` is the // resolved block ({id, enabled, text}) so header_title/footer can // read their custom text. Dividers are owned by whichever block // came after them in the original fixed sequence — reordering a // block takes its leading divider with it. // ePOS-Print's style attributes (width, height, em, ul, // reverse) are STATEFUL — the printer keeps whatever a previous // tag set until a later tag explicitly changes it back; they // don't reset per-tag the way a browser resets inline styles. Since // blocks are user-reorderable/toggleable, no block can assume what // state a neighboring block left behind — every plain-style tag // here explicitly declares width="1" height="1" em="false" so it's // correct regardless of what printed immediately before it. const NORMAL = 'width="1" height="1" em="false"'; function saleBlockRenderers(s, logo) { const sub = s.sub != null ? s.sub : (s.total - (s.vat || 0)); const vat = s.vat != null ? s.vat : (s.total * 15 / 115); const discount = s.discount || { kind: 'none', input: 0, amount: 0 }; const hasDiscount = discount.kind !== 'none' && Number(discount.amount) > 0; // A credit slip reuses this whole renderer with `s.refund` set — same // blocks, same printer settings, same ePOS-Print envelope. Only the // wording changes, so there is no second printer path to keep working. const isRefund = !!s.refund; return { header_title: function (block) { const title = block.text || 'WFSALES POS'; const credit = isRefund ? 'CREDIT NOTE ' : ''; return '' + credit + '' + xmlEscape(title) + ' '; }, logo: function () { return logo ? `${logo.base64}` : ''; }, business_info: function () { const parts = []; // The POS letterhead record exposes `display_name`, not `name` // — reading `.name` printed nothing at all on every slip. const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name); if (bizName) parts.push(`${xmlEscape(bizName)} `); const addrLines = []; if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); if (addrLines.length) parts.push(`${addrLines.join(' ')} `); return parts.join(''); }, date_slip: function () { const d = new Date(s.createdAt); // A credit slip is worthless without the sale it reverses, so it // prints that number rather than its own local id. const ref = isRefund ? `Refund of: ${xmlEscape(s.saleNumber || (s.localId || '').slice(-8).toUpperCase())}` : `Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}`; return `-------------------------------- ` + `Date: ${xmlEscape(d.toLocaleString('en-ZA'))} ${ref} `; }, customer: function () { return s.customer ? `Customer: ${xmlEscape(s.customer.name)} ` : ''; }, items: function () { const itemLines = s.items.map(function (it) { const priceLine = ` ${it.qty} x ${(it.selling_price || 0).toFixed(2)} ${(it.selling_price * it.qty).toFixed(2)}`; return `${xmlEscape(it.description || it.sku)} ${xmlEscape(priceLine)} `; }).join(''); return `-------------------------------- ${itemLines}`; }, totals: function () { const parts = [`-------------------------------- `]; // A refund credits back what the customer paid, so the pre-discount // breakdown would be a different number and is not printed. if (hasDiscount && !isRefund) { const gross = Number(s.gross) || s.items.reduce((sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.qty) || 0), 0); const grossSub = gross / 1.15; const grossVat = gross - grossSub; parts.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)} VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)} Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)} Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)} `); } parts.push(`Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} `); parts.push(`${isRefund ? 'CREDIT' : 'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)} `); return parts.join(''); }, payment: function () { if (isRefund) { const how = s.payment === 'cash' ? 'CASH' : s.payment === 'account' ? 'ACCOUNT CREDIT' : (s.payment || '').toUpperCase(); return `Refunded by: ${xmlEscape(how)} ` + `Goods returned to stock. `; } const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase(); let out = `Paid: ${xmlEscape(paidLabel)} `; if (s.payment === 'cash') out += `Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} `; return out; }, footer: function (block) { const text = block.text || (s.letterhead && s.letterhead.footer_text); let out = `-------------------------------- `; if (text) out += `${xmlEscape(text)} `; return out; }, }; } // `blocks` is the resolved block list from PosSlipLayout.resolveBlocks('sale', ...) // (or getBlocksForPrint). Renders each enabled block in order, then // the fixed closing feed+cut every sale slip ends with. function buildEposXml(s, logo, blocks) { const renderers = saleBlockRenderers(s, logo); const body = blocks .filter(function (b) { return b.enabled; }) .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) .join(''); return `${body}`; } async function printViaNetwork(ip, xml) { const res = await fetch(`https://${ip}/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000`, { method: 'POST', headers: { 'Content-Type': 'text/xml; charset=utf-8', 'SOAPAction': '""' }, body: xml }); if (!res.ok) throw new Error('Printer HTTP error: ' + res.status); const text = await res.text(); const doc = new DOMParser().parseFromString(text, 'text/xml'); const response = doc.getElementsByTagName('response')[0]; const success = response && response.getAttribute('success') === 'true'; if (!success) { const code = response ? response.getAttribute('code') : null; throw new Error('Printer rejected job' + (code ? ' (code: ' + code + ')' : '')); } } function deliveryBlockRenderers(s, showPricing, logo) { return { // Matches the sale slip's header exactly (same logo, same 2x2 // title, same business-info block) — a delivery note is handed // to the same customer and should look like the same company's // paperwork, not a stripped-down variant. logo: function () { return logo ? `${logo.base64}` : ''; }, header_title: function (block) { const title = block.text || 'WFSALES POS'; return '' + `${xmlEscape(title)} ` + `Delivery Note `; }, business_info: function () { const parts = []; // The POS letterhead record exposes `display_name`, not `name` // — reading `.name` printed nothing at all on every slip. const bizName = s.letterhead && (s.letterhead.display_name || s.letterhead.name); if (bizName) parts.push(`${xmlEscape(bizName)} `); const addrLines = []; if (s.letterhead && s.letterhead.address) addrLines.push(xmlEscape(s.letterhead.address.split('\n')[0])); if (s.letterhead && s.letterhead.phone) addrLines.push('Tel: ' + xmlEscape(s.letterhead.phone)); if (s.letterhead && s.letterhead.vat_number) addrLines.push('VAT: ' + xmlEscape(s.letterhead.vat_number)); if (addrLines.length) parts.push(`${addrLines.join(' ')} `); return parts.join(''); }, customer: function () { let out = ''; if (s.customer && s.customer.company) out += `Company: ${xmlEscape(s.customer.company)} `; out += `Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} `; return out; }, date: function () { const d = new Date(s.createdAt); return `Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} `; }, items: function () { const itemLines = s.items.map(function (it) { const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`; const priceSuffix = showPricing ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : ''; return `${xmlEscape(it.description || it.sku)} ${xmlEscape(qtyLine + priceSuffix)} `; }).join(''); return `-------------------------------- ${itemLines}`; }, totals: function () { let out = `-------------------------------- `; if (showPricing) out += `TOTAL: R ${s.total.toFixed(2)} `; return out; }, signature: function () { return `Received by: __________ Driver: __________ `; }, }; } // `blocks` is the resolved block list for 'delivery'. `showPricing` // is the combined "Show pricing" setting — it gates the per-item // price and the totals block together (see slip-layout.js). // `logo` is a pre-rasterized {width,height,base64} or null. function buildEposDeliveryXml(s, blocks, showPricing, logo) { const renderers = deliveryBlockRenderers(s, showPricing, logo || null); const body = blocks .filter(function (b) { return b.enabled; }) .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) .join(''); return `${body}`; } // Mirrors the cashup page's "Print cash-up" button. Renders an // ePOS-Print XML slip with the day's takings broken down by // payment method + the operator's cash-counted variance. The // cashup.html page collects the data and calls this — we just // format the XML. // // `report` shape: // { // shiftLabel: "Today" | "Yesterday" | string, // dateLabel: "04 Sep 2026", // counts: { cash, card, eft, account }, // totals: { cash, card, eft, account }, // grand: number, // cashCounted: number, // variance: number, // denoms: [{ label: "R200", value: 200, count: 2 }, ...], // } function cashupBlockRenderers(lh, r, logo) { return { header_title: function (block) { const title = block.text || 'WFSALES POS'; return '' + `${xmlEscape(title)} ` + 'CASH-UP REPORT '; }, logo: function () { return logo ? `${logo.base64}` : ''; }, business_info: function () { const parts = []; if (lh.display_name) parts.push(`${xmlEscape(lh.display_name)} `); if (lh.address) parts.push(`${xmlEscape(lh.address.split('\n')[0])} `); if (lh.phone) parts.push(`Tel: ${xmlEscape(lh.phone)} `); if (lh.vat_number) parts.push(`VAT: ${xmlEscape(lh.vat_number)} `); return parts.join(''); }, shift_date: function () { let out = `-------------------------------- `; if (r.shiftLabel || r.dateLabel) out += `Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} `; if (r.counts) out += `Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} `; return out; }, payment_breakdown: function () { const lines = [ `Cash ${pad((r.counts && r.counts.cash) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.cash) || 0)}`, `Card ${pad((r.counts && r.counts.card) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.card) || 0)}`, `EFT ${pad((r.counts && r.counts.eft) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.eft) || 0)}`, `Acct ${pad((r.counts && r.counts.account) || 0, 3)} sales R ${fmtMoney((r.totals && r.totals.account) || 0)}`, ]; return `-------------------------------- ` + `PAYMENT METHOD BREAKDOWN ` + `${lines.join(' ')} `; }, grand_total: function () { return `-------------------------------- ` + `GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} `; }, cash_reconciliation: function () { return `-------------------------------- ` + `CASH RECONCILIATION ` + `Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} ` + `Cash counted: R ${fmtMoney(r.cashCounted || 0)} ` + `Variance: R ${fmtMoney(r.variance || 0)} `; }, denominations: function () { if (!Array.isArray(r.denoms) || !r.denoms.length) return ''; const dlines = r.denoms .filter(function (d) { return (d.count || 0) > 0; }) .map(function (d) { return `${d.label} x ${pad(d.count || 0, 3)} = R ${fmtMoney((d.value || 0) * (d.count || 0))}`; }); if (!dlines.length) return ''; return `-------------------------------- ` + `DENOMINATIONS COUNTED ` + `${dlines.join(' ')} `; }, footer: function (block) { const text = block.text || lh.footer_text; if (!text) return ''; return `-------------------------------- ` + `${xmlEscape(text)} `; }, }; } // `blocks` is the resolved block list for 'cashup'. `logo` is a // pre-rasterized {width,height,base64} (see imageToRaster) or null — // rasterizing is async, so the caller does it before calling this. function buildEposCashupXml(letterhead, report, blocks, logo) { const lh = letterhead || {}; const r = report || {}; const renderers = cashupBlockRenderers(lh, r, logo || null); const body = blocks .filter(function (b) { return b.enabled; }) .map(function (b) { return renderers[b.id] ? renderers[b.id](b) : ''; }) .join(''); return `${body}`; } // Small helper for padded numbers in the cashup slip. function pad(n, w) { const s = String(n == null ? 0 : n); return s.length >= w ? s : (' '.repeat(w - s.length) + s); } function fmtMoney(n) { return (Number(n) || 0).toFixed(2); } // Test print — used by the Settings page's "Test print" button to // verify the printer is reachable + the letterhead renders. Builds // a tiny "Sample slip" with the active letterhead and pushes it // through whichever transport the till is currently configured to // use. Throws on failure (caller surfaces the error). async function printTest(letterhead) { const settings = getSettings(); const lh = letterhead || {}; const now = new Date(); const sale = { createdAt: now.toISOString(), localId: 'TEST-' + now.getTime().toString(36).toUpperCase(), payment: 'cash', cashGiven: 0, change: 0, gross: 0, discount: { kind: 'none', input: 0, amount: 0 }, sub: 0, vat: 0, total: 0, items: [], customer: null, letterhead: lh, }; // Render the regular sale XML — it'll show the letterhead + // payment line + footer, which is what the operator wants to // eyeball when they tap "Test print". We don't need to swap in // a custom test layout. const logo = await letterheadLogoRaster(lh, 384); const blocks = window.PosSlipLayout.getBlocksForPrint(lh.id, 'sale'); const xml = buildEposXml(sale, logo, blocks); if (settings.type === 'network') { if (!settings.ip) { throw new Error('Set the printer IP address in Printer settings first.'); } await printViaNetwork(settings.ip, xml); return 'Printed · ' + settings.ip; } // Bluetooth — the existing epos-print.js doesn't speak BT, // only the receipt page does. Surface a clear message rather // than pretending. throw new Error('Test print on Bluetooth is not supported here — use the till\'s Print slip button.'); } return { getSettings, setSettings, imageToRaster, letterheadLogoRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml, buildEposCashupXml, printTest }; })();