WFDASH-WEBAPP/docs/superpowers/plans/2026-09-03-pos-network-printer.md

832 lines
34 KiB
Markdown
Raw Permalink Normal View History

# Network (Ethernet) Thermal Printer Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add support for printing sale receipts and delivery notes to a network (Ethernet) Epson TM-T20II printer from the `public/pos` PWA, alongside the existing Web Bluetooth path, working on both Android and iOS.
**Architecture:** The printer exposes Epson's ePOS-Print XML API over **HTTPS** (confirmed live against the shop's TM-T20II at `192.168.1.33` — see verification notes below), with an open CORS header (`Access-Control-Allow-Origin: *`). Because both the PWA and the printer are HTTPS, calling the printer directly from the browser hits neither mixed-content blocking nor Safari's lack of Web Bluetooth support — no native wrapper is needed. A new `public/pos/epos-print.js` module builds the ePOS-Print XML (mirroring the existing `buildEscPos()` byte-builder in `receipt.html`) and POSTs it with `fetch()`. `receipt.html` gains a printer-type setting (Bluetooth vs. Network) persisted in `localStorage`, and `printThermal()`/`printDelivery()` branch on it.
**Tech Stack:** Vanilla JS (no build step — this is a static PWA), `fetch`, `<canvas>` for logo rasterization, Epson ePOS-Print XML.
**Verified findings this plan relies on (from live testing against the shop printer):**
- `http://192.168.1.33/` 301-redirects to `https://192.168.1.33/` (self-signed cert).
- `POST https://192.168.1.33/cgi-bin/epos/service.cgi?devid=local_printer&timeout=10000` with an ePOS-Print SOAP XML body returns `HTTP 200` and `<response success="true" .../>`, and a real slip printed with text, item list, subtotal/VAT/total, and the WFDASH logo image.
- Raw ESC/POS bytes also print successfully over plain TCP to port 9100 (confirms the byte-building logic already in `buildEscPos()` is sound, though this plan uses the HTTPS path since browsers can't open raw sockets).
- The response's `success="true"` was observed once without a physical printout (cause not fully isolated — possibly a transient state on the printer). Task 10 below re-verifies each XML feature individually against the real printer before calling this done.
- Each till device (iOS or Android) must visit `https://192.168.1.33` once in its browser and accept the self-signed certificate warning before `fetch()` calls from the PWA will succeed silently.
---
### Task 1: Fix the pre-existing `const d` redeclaration bug
**Files:**
- Modify: `public/pos/receipt.html:535-591` (the `buildEscPos` function)
This is a blocking bug unrelated to network printing, but it sits inside the exact function this plan extends, and it currently breaks the entire inline `<script>` block (lines 319-657) — meaning `renderReceipt`, `printThermal`, and everything else on this page silently fails to run today.
- [ ] **Step 1: Confirm the bug**
Run: `grep -n "const d" public/pos/receipt.html`
Expected output includes both:
```
552: const d = new Date(s.createdAt);
565: const d = s.discount || { kind: 'none', input: 0, amount: 0 };
```
Both are in the same function scope (`buildEscPos`, no intervening block) — this is a `SyntaxError: Identifier 'd' has already been declared`.
- [ ] **Step 2: Rename the second declaration**
In `public/pos/receipt.html`, inside `buildEscPos(s){...}`, change:
```js
const sub = s.sub != null ? s.sub : (s.total - (s.vat||0));
const vat = s.vat != null ? s.vat : (s.total * 15/115);
const d = s.discount || { kind: 'none', input: 0, amount: 0 };
const hasDiscount = d.kind !== 'none' && Number(d.amount) > 0;
if (hasDiscount) {
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;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${d.kind==='percent' ? ` (${d.input}%)` : ''}${' '.repeat(15)}- R ${d.amount.toFixed(2)}\n`);
}
```
to:
```js
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;
if (hasDiscount) {
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;
cmds.push(`Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}\n`);
cmds.push(`VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}\n`);
cmds.push(`Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}\n`);
cmds.push(`Discount${discount.kind==='percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}\n`);
}
```
- [ ] **Step 3: Verify the script now parses**
Run: `node --check <(sed -n '319,657p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
(This extracts just the inline script body and checks it as a standalone JS file.) Expected: no output, exit code 0. If Node reports a different syntax error, fix it before continuing — do not proceed to Task 2 with a script that doesn't parse.
- [ ] **Step 4: Commit**
```bash
git add public/pos/receipt.html
git commit -m "fix(pos): resolve duplicate const d declaration breaking receipt.html script"
```
---
### Task 2: Create `epos-print.js` with printer settings persistence
**Files:**
- Create: `public/pos/epos-print.js`
- [ ] **Step 1: Create the file with settings get/set**
```js
// 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://<printer-ip>` 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));
}
return { getSettings, setSettings };
})();
```
- [ ] **Step 2: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add printer settings persistence for network printing"
```
---
### Task 3: Add logo rasterization to `epos-print.js`
**Files:**
- Modify: `public/pos/epos-print.js`
The ePOS-Print `<image>` tag needs a packed 1-bit-per-pixel bitmap, MSB-first, each row padded to a whole byte, where bit `1` means "print a dot". This matches the format manually verified against the real printer during testing (Python/Pillow prototype), reimplemented here with `<canvas>` since the PWA has no build step to run Python.
- [ ] **Step 1: Add `imageToRaster` inside the IIFE, before the `return` statement**
```js
// Rasterize a same-origin image into the packed 1bpp bitmap the
// ePOS-Print <image> 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;
});
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement at the end of the IIFE from:
```js
return { getSettings, setSettings };
```
to:
```js
return { getSettings, setSettings, imageToRaster };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check**
Serve `public/` locally (e.g. `npx serve public` or via the app's own dev server) and open `public/pos/index.html`'s console, then run:
```js
EposPrint.imageToRaster('/wfdash-logo.png', 384).then(r => console.log(r.width, r.height, r.base64.length))
```
Expected: logs `384 <some-height> <some-length>` with no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add canvas-based logo rasterization for ePOS-Print"
```
---
### Task 4: Add ePOS-Print XML builder for sale receipts
**Files:**
- Modify: `public/pos/epos-print.js`
This mirrors `buildEscPos()` in `receipt.html:535-591` line-for-line in content (same fields, same discount handling, same layout), just emitting ePOS-Print XML tags instead of raw ESC/POS bytes.
- [ ] **Step 1: Add `xmlEscape` and `buildEposXml` inside the IIFE, before the `return` statement**
```js
function xmlEscape(s) {
return String(s == null ? '' : s).replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]));
}
// Mirrors buildEscPos() in receipt.html — same fields, same discount
// math — but emits ePOS-Print XML tags instead of raw ESC/POS bytes.
function buildEposXml(s, logo) {
const parts = [];
if (logo) parts.push(`<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>`);
parts.push('<feed line="1"/>');
parts.push('<text align="center" width="2" height="2">WFSALES POS&#10;</text>');
if (s.letterhead && s.letterhead.name) {
parts.push(`<text align="center">${xmlEscape(s.letterhead.name)}&#10;</text>`);
}
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(`<text align="center">${addrLines.join('&#10;')}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleString('en-ZA'))}&#10;Slip: ${xmlEscape((s.localId || '').slice(-8).toUpperCase())}&#10;</text>`);
if (s.customer) parts.push(`<text align="left">Customer: ${xmlEscape(s.customer.name)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(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)}&#10;${xmlEscape(priceLine)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
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;
if (hasDiscount) {
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(`<text align="left">Subtotal (gross)${' '.repeat(10)}R ${grossSub.toFixed(2)}&#10;VAT 15% (gross)${' '.repeat(10)}R ${grossVat.toFixed(2)}&#10;Total (gross)${' '.repeat(13)}R ${gross.toFixed(2)}&#10;Discount${discount.kind === 'percent' ? ` (${discount.input}%)` : ''}${' '.repeat(15)}- R ${discount.amount.toFixed(2)}&#10;</text>`);
}
parts.push(`<text align="left">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)}&#10;VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)}&#10;</text>`);
parts.push(`<text align="left" em="true">TOTAL${' '.repeat(28)}R ${s.total.toFixed(2)}&#10;</text>`);
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
parts.push(`<text align="left">Paid: ${xmlEscape(paidLabel)}&#10;</text>`);
if (s.payment === 'cash') parts.push(`<text align="left">Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (s.letterhead && s.letterhead.footer_text) parts.push(`<text align="center">${xmlEscape(s.letterhead.footer_text)}&#10;</text>`);
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual browser check with a fake sale**
In the browser console (same page as Task 3's check):
```js
const fakeSale = {
createdAt: new Date().toISOString(),
localId: 'test0001',
payment: 'cash', cashGiven: 300, change: 5.51,
items: [{ description: 'Widget A', qty: 2, selling_price: 45 }],
sub: 78.26, vat: 11.74, total: 90,
letterhead: { name: 'WFDASH POS', address: '123 Shop Street', phone: '021 555 0123' }
};
console.log(EposPrint.buildEposXml(fakeSale, null));
```
Expected: a full XML string starting with `<?xml version="1.0"...` and containing `<epos-print`, item text, and `<cut type="feed"/>`, no thrown error.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): add ePOS-Print XML builder for sale receipts"
```
---
### Task 5: Add the network transport (`printViaNetwork`)
**Files:**
- Modify: `public/pos/epos-print.js`
- [ ] **Step 1: Add `printViaNetwork` inside the IIFE, before the `return` statement**
```js
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 + ')' : ''));
}
}
```
- [ ] **Step 2: Export it from the module**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Manual test against the real printer**
With a device on the shop wifi that has already visited `https://192.168.1.33` and trusted the cert, open the browser console on any page served from the app (must be same-origin `fetch`, no special config needed since the printer sends `Access-Control-Allow-Origin: *`) and run:
```js
EposPrint.printViaNetwork('192.168.1.33', EposPrint.buildEposXml(fakeSale, null)).then(() => console.log('OK')).catch(e => console.error(e));
```
(reuse `fakeSale` from Task 4's check). Expected: logs `OK`, and a real slip prints on the TM-T20II with the fake sale's items and totals.
- [ ] **Step 5: Commit**
```bash
git add public/pos/epos-print.js
git commit -m "feat(pos): send ePOS-Print jobs over HTTPS to network printers"
```
---
### Task 6: Add printer settings UI to `receipt.html`
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add CSS for the settings block**
In `public/pos/receipt.html`, find the `.status` rule block (around line 134):
```css
.status{padding:12px 14px;border-radius:12px;background:var(--surface);border:1px solid var(--border);font-size:13px;display:flex;align-items:center;gap:10px;color:var(--fg);line-height:1.4}
```
Add immediately after it:
```css
.printer-settings{padding:12px 14px;border-radius:12px;border:1px solid var(--border);background:var(--surface-card);display:flex;flex-direction:column;gap:8px}
.printer-settings label{font-size:12px;font-weight:600;color:var(--ink-soft);display:flex;flex-direction:column;gap:4px}
.printer-settings select,.printer-settings input{padding:9px 10px;border-radius:8px;border:1px solid var(--muted);background:var(--bg);color:var(--fg);font:inherit}
```
- [ ] **Step 2: Add the settings toggle row and form to the markup**
In `public/pos/receipt.html`, find (around line 295-298):
```html
<div class="status" id="status">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16v.5"/></svg></span>
<span class="t" id="statusText">Ready to print.</span>
</div>
```
Add immediately after it:
```html
<div class="status" id="printerSettingsToggle" style="cursor:pointer" onclick="togglePrinterSettings()">
<span class="ic"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor"><path d="M3 7h18v6H3zM7 13v6h10v-6M7 17h10"/></svg></span>
<span class="t" id="printerSettingsLabel">Printer: Bluetooth</span>
</div>
<div class="printer-settings" id="printerSettingsForm" style="display:none">
<label>Printer type
<select id="printerTypeSelect">
<option value="bluetooth">Bluetooth</option>
<option value="network">Network (Ethernet)</option>
</select>
</label>
<label>Printer IP address
<input id="printerIpInput" placeholder="192.168.1.33">
</label>
<button class="btn" style="min-height:40px" onclick="savePrinterSettings()">Save printer settings</button>
</div>
```
- [ ] **Step 3: Add `epos-print.js` script tag**
Find (around line 317):
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
```
Change to:
```html
<script src="pos-config.js"></script>
<script src="theme.js"></script>
<script src="epos-print.js"></script>
```
- [ ] **Step 4: Add the settings toggle/save functions and init call**
Inside the main inline `<script>` IIFE in `receipt.html`, immediately after the `paintNet();` call near the end of the file (currently the last line before `})();`), add — but call it earlier, right after the existing `paintNet();` line at the bottom (around line 655):
Find:
```js
paintNet();
})();
```
Change to:
```js
function togglePrinterSettings(){
const form = document.getElementById('printerSettingsForm');
form.style.display = form.style.display === 'none' ? 'flex' : 'none';
}
function savePrinterSettings(){
const type = document.getElementById('printerTypeSelect').value;
const ip = document.getElementById('printerIpInput').value.trim();
window.EposPrint.setSettings({ type, ip });
paintPrinterSettings();
document.getElementById('printerSettingsForm').style.display = 'none';
}
function paintPrinterSettings(){
const settings = window.EposPrint.getSettings();
document.getElementById('printerTypeSelect').value = settings.type;
document.getElementById('printerIpInput').value = settings.ip || '';
document.getElementById('printerSettingsLabel').textContent =
'Printer: ' + (settings.type === 'network' ? ('Network' + (settings.ip ? ' (' + settings.ip + ')' : '')) : 'Bluetooth');
}
paintPrinterSettings();
paintNet();
})();
```
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,700p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust the line range in the `sed -n` command to match wherever the inline `<script>...</script>` block now starts and ends after your edits.)
- [ ] **Step 6: Manual browser check**
Open `receipt.html` in a browser (with a `pos.lastSale` value in `sessionStorage`, or just check that the page doesn't error in the console). Click the new "Printer: Bluetooth" row — the settings form should expand. Switch the select to "Network (Ethernet)", type `192.168.1.33`, click "Save printer settings" — the row should update to read "Printer: Network (192.168.1.33)" and the value should persist across a page reload (check via `localStorage.getItem('pos.printerSettings')` in the console).
- [ ] **Step 7: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): add printer type/IP settings UI to receipt page"
```
---
### Task 7: Wire `printThermal()` to the network path
**Files:**
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Rename the existing Bluetooth flow**
Find (around line 487):
```js
async function printThermal(){
const btn = document.getElementById('printBtn');
```
Change the function name only:
```js
async function printThermalBluetooth(){
const btn = document.getElementById('printBtn');
```
(Leave the rest of that function's body untouched.)
- [ ] **Step 2: Add the network variant and the dispatcher**
Immediately before the (now renamed) `printThermalBluetooth` function, add:
```js
async function printThermal(){
const settings = window.EposPrint.getSettings();
if(settings.type === 'network') return printThermalNetwork(settings);
return printThermalBluetooth();
}
async function printThermalNetwork(settings){
const btn = document.getElementById('printBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
if(!settings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
status.textContent = 'Sending to ' + settings.ip + '…';
try{
const logo = await window.EposPrint.imageToRaster('/wfdash-logo.png', 384).catch(()=>null);
const xml = window.EposPrint.buildEposXml(sale, logo);
await window.EposPrint.printViaNetwork(settings.ip, xml);
status.textContent = 'Printed · ' + settings.ip;
}catch(e){
status.textContent = 'Print failed: ' + e.message;
errBox.classList.add('err');
}finally{
btn.disabled = false;
}
}
```
- [ ] **Step 3: Verify the script still parses**
Run: `node --check <(sed -n '319,760p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 4: Manual end-to-end test against the real printer**
On a device on the shop wifi that has trusted the printer's certificate, open a real sale's receipt page (so `sessionStorage['pos.lastSale']` is populated), set Printer type to Network with IP `192.168.1.33` via the UI from Task 6, tap "Print slip". Expected: status text shows "Printed · 192.168.1.33" and a real receipt (logo, items, subtotal, VAT, total, footer) prints on the TM-T20II.
- [ ] **Step 5: Commit**
```bash
git add public/pos/receipt.html
git commit -m "feat(pos): print sale receipts over the network transport"
```
---
### Task 8: Add network transport for delivery notes
**Files:**
- Modify: `public/pos/epos-print.js`
- Modify: `public/pos/receipt.html`
- [ ] **Step 1: Add `buildEposDeliveryXml` to `epos-print.js`, before the `return` statement**
Mirrors the delivery-note ESC/POS builder in `receipt.html`'s `printDelivery()` (currently inline, lines ~607-637).
```js
function buildEposDeliveryXml(s, showPrices) {
const parts = [];
parts.push('<text align="center" width="1" height="2">WFSALES POS&#10;</text>');
parts.push('<text align="center">Delivery Note&#10;</text>');
if (s.customer && s.customer.company) parts.push(`<text align="left">Company: ${xmlEscape(s.customer.company)}&#10;</text>`);
parts.push(`<text align="left">Customer: ${xmlEscape((s.customer && s.customer.name) || '—')}&#10;</text>`);
const d = new Date(s.createdAt);
parts.push(`<text align="left">Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))}&#10;</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
const itemLines = s.items.map(it => {
const qtyLine = ` ${it.qty} ${it.unit || 'ea'}`;
const priceSuffix = showPrices ? ` R ${((it.selling_price || 0) * it.qty).toFixed(2)}` : '';
return `${xmlEscape(it.description || it.sku)}&#10;${xmlEscape(qtyLine + priceSuffix)}&#10;`;
}).join('');
parts.push(`<text align="left">${itemLines}</text>`);
parts.push('<text align="left">--------------------------------&#10;</text>');
if (showPrices) parts.push(`<text align="left">TOTAL: R ${s.total.toFixed(2)}&#10;</text>`);
parts.push('<text align="left">Received by: __________ Driver: __________&#10;</text>');
parts.push('<feed line="3"/>');
parts.push('<cut type="feed"/>');
return `<?xml version="1.0" encoding="utf-8"?><s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"><s:Body><epos-print xmlns="http://www.epson-pos.com/schemas/2011/03/epos-print">${parts.join('')}</epos-print></s:Body></s:Envelope>`;
}
```
- [ ] **Step 2: Export it**
Change the `return` statement to:
```js
return { getSettings, setSettings, imageToRaster, buildEposXml, printViaNetwork, buildEposDeliveryXml };
```
- [ ] **Step 3: Verify it loads without error**
Run: `node --check public/pos/epos-print.js`
Expected: no output, exit code 0.
- [ ] **Step 4: Wire `printDelivery()` in `receipt.html`**
Find (around line 593):
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
Change to:
```js
async function printDelivery(){
if(!sale) return;
const btn = document.getElementById('printDnBtn');
const status = document.getElementById('statusText');
const errBox = document.getElementById('status');
errBox.classList.remove('err');
btn.disabled = true;
status.textContent = 'Printing delivery note…';
const printerSettings = window.EposPrint.getSettings();
if(printerSettings.type === 'network'){
if(!printerSettings.ip){
status.textContent = 'Set the printer IP address in Printer settings first.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
const dnToggleNet = document.getElementById('dnShowPrices');
const showPricesNet = dnToggleNet ? !!dnToggleNet.checked : true;
try{
const xml = window.EposPrint.buildEposDeliveryXml(sale, showPricesNet);
await window.EposPrint.printViaNetwork(printerSettings.ip, xml);
status.textContent = 'Delivery note printed · ' + printerSettings.ip;
}catch(e){
errBox.classList.add('err');
status.textContent = 'DN print failed: ' + e.message;
}finally{
btn.disabled = false;
}
return;
}
if(!btChar){
status.textContent = 'Pair a Bluetooth printer first to print.';
errBox.classList.add('err');
btn.disabled = false;
return;
}
```
(Leave the rest of the existing Bluetooth body below this point unchanged.)
- [ ] **Step 5: Verify the script still parses**
Run: `node --check <(sed -n '319,800p' public/pos/receipt.html | sed '1s/.*<script>//; $s/<\/script>.*//')`
Expected: no output, exit code 0. (Adjust line range as needed.)
- [ ] **Step 6: Manual test against the real printer**
With Printer type set to Network in the UI, open an account-payment sale's receipt (so the delivery note section is visible), tap "Print delivery note". Expected: a delivery-note slip prints with company/customer, item list, and the received-by/driver line.
- [ ] **Step 7: Commit**
```bash
git add public/pos/epos-print.js public/pos/receipt.html
git commit -m "feat(pos): print delivery notes over the network transport"
```
---
### Task 9: Register `epos-print.js` with the service worker
**Files:**
- Modify: `public/pos/sw.js`
Without this, the till would work online but `epos-print.js` wouldn't be available offline (the printer itself is only reachable on the shop LAN anyway, but the PWA shell — including this script — should still be cached like every other JS file here).
- [ ] **Step 1: Add the file to the precache list and bump the cache version**
Find (lines 1-17):
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v15";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
Change to:
```js
// Service worker — cache app shell, network-first for HTML + theme.js, cache-first for other static.
const CACHE = "pos-v16";
const APP_SHELL = [
'./',
'./index.html',
'./pos.html',
'./receipt.html',
'./history.html',
'./cashup.html',
'./pos-config.js',
'./pos-app.js',
'./epos-print.js',
'./theme.js',
'./manifest.json',
// Font Awesome 6 Free (solid) — bundled so the till still renders icons offline.
'./fa/all.min.css',
'./fa/webfonts/fa-solid-900.woff2',
];
```
- [ ] **Step 2: Verify the file parses**
Run: `node --check public/pos/sw.js`
Expected: no output, exit code 0.
- [ ] **Step 3: Manual check**
Open `receipt.html` in a browser with DevTools open to the Application/Service Worker panel, force-reload, and confirm the new service worker (`pos-v16`) activates and `epos-print.js` appears in the Cache Storage listing for `pos-v16`.
- [ ] **Step 4: Commit**
```bash
git add public/pos/sw.js
git commit -m "chore(pos): cache epos-print.js in the PWA app shell"
```
---
### Task 10: Full end-to-end verification against the physical printer
**Files:** none (verification only)
- [ ] **Step 1: Confirm the certificate-trust step on a fresh device**
On an iPhone or iPad that has never visited the printer before, open Safari, navigate to `https://192.168.1.33`, and confirm you get a certificate warning; tap through "visit this website" (or equivalent). Confirm subsequent `fetch()` calls from the PWA's origin succeed without a further prompt.
- [ ] **Step 2: Ring up a real (or test) sale with items and a discount applied**
Use the till to create a sale with at least 2 line items and a discount, so the "gross" block in `buildEposXml` is exercised, not just the plain-total path.
- [ ] **Step 3: Set Printer type to Network with IP `192.168.1.33` and print the slip**
Confirm the physical printout shows: WFDASH logo, "WFSALES POS" header, date/slip number, item lines, gross/discount/net totals, VAT, TOTAL in bold, payment method, and footer text — matching what the on-screen receipt shows.
- [ ] **Step 4: If it's an account-payment sale, print the delivery note over network too**
Confirm the physical printout matches the on-screen delivery note (company/customer, item list respecting the "Include prices" toggle, received-by/driver line).
- [ ] **Step 5: Repeat Steps 2-4 on an Android device**
Confirms parity between iOS Safari and Android Chrome for this transport (Bluetooth already only works on Android, per Task 6-8 branching).
- [ ] **Step 6: Note any printer status codes seen during testing**
If any print job returns `success="true"` but nothing physically prints (as was observed once during initial protocol testing), capture the full XML response (including the `status` attribute) and check it against the printer's technical reference guide before shipping this to the till — do not treat `success="true"` alone as proof of a physical printout without confirming paper actually came out.