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

34 KiB

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:

  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:

  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
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

// 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
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
  // 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:

  return { getSettings, setSettings };

to:

  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:

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
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
  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:

  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):

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
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

  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:

  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:

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
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):

.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:

.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):

  <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:

  <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):

<script src="pos-config.js"></script>
<script src="theme.js"></script>

Change to:

<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:

paintNet();
})();

Change to:

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
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):

async function printThermal(){
  const btn = document.getElementById('printBtn');

Change the function name only:

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:

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
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).

  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:

  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):

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:

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
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):

// 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:

// 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
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.