A customer who brings goods back needs the sale reversed: the items go
back into stock and the money comes back off the till. There was no way
to mark a POS sale as refunded, so nothing stopped the same sale being
refunded twice — which credits the customer twice and puts the same
stock back on the shelf twice.
Refunds are WHOLE-SALE only by operator decision; no partial refunds.
That is what lets `pos_sales.refunded` be a boolean instead of a
per-line refund table. Three columns: `refunded`, `refunded_at`,
`refund_amount`, stamped from the sale's post-discount total, which is
what the customer actually paid.
DOUBLE-REFUND GUARD, twice over, because this is the one failure that
costs real money:
- `refunded` is read before anything else, and a flagged sale returns
409 with no stock writes at all
- the flag is stamped BEFORE any stock write, so a second concurrent
tap sees it and bails. Writing stock first and flagging last would
let both taps through.
A stock write that fails part-way does NOT roll the flag back. The
customer has their money either way, so re-running would double-credit
them; the failed lines come back in `stockErrors` for a manual count
correction, which is strictly better than a second refund.
Stock needs no schema: `return` was already in STOCK_REASON_VALUES,
just never used. Rows are written with POSITIVE change_qty and
reference_type "pos_sale_refund", leaving the original
customer_fulfillment rows untouched so the sale's own audit trail still
shows what was originally rung up.
cashup.html had expected drawer = sum of cash sales, so a cash refund
would have shown as a false variance on every cash-up after it. It now
subtracts cash refunds, and refunded sales contribute nothing to
takings for any tender.
The tender is read off the sale, never off the request — an earlier
version accepted it in the body, which would have let a caller assert
that a cash sale was really an EFT and talk the drawer out of balance.
At the till: tap still reprints, hold a sale row for 600ms to refund —
moving money sits behind a gesture you cannot do by accident. Refunded
sales stay in the list, dimmed and badged, because deleting them would
destroy the audit trail. The sheet names the items and says plainly what
happens next ("Hand back R10.57 in cash. The drawer count for this day
will drop by that amount.").
The credit slip reuses the sale renderer and the whole printer path with
`refund: true`, so there is no second printer to configure — but note
there were THREE render paths to fix (on-screen, Bluetooth text, ePOS
XML), and missing the third would have printed "TOTAL" on a credit note.
Migration applied to live PB and verified idempotent; field shapes
round-tripped on a real sale and restored. 10 new tests, 148 POS tests
pass, tsc clean, build passes. sw.js v34 -> v35 (history, cashup,
receipt and epos-print are all cache-first in the shell).
446 lines
23 KiB
JavaScript
446 lines
23 KiB
JavaScript
// 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));
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
}
|
|
|
|
// 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 <text> style attributes (width, height, em, ul,
|
|
// reverse) are STATEFUL — the printer keeps whatever a previous
|
|
// <text> 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
|
|
? '<text align="center" width="2" height="2">CREDIT NOTE </text>'
|
|
: '';
|
|
return '<feed line="1"/>' + credit + '<text align="center" width="2" height="2">' + xmlEscape(title) + ' </text>';
|
|
},
|
|
logo: function () {
|
|
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
|
|
},
|
|
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(`<text align="center" ${NORMAL}>${xmlEscape(bizName)} </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" ${NORMAL}>${addrLines.join(' ')} </text>`);
|
|
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 `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleString('en-ZA'))} ${ref} </text>`;
|
|
},
|
|
customer: function () {
|
|
return s.customer ? `<text align="left" ${NORMAL}>Customer: ${xmlEscape(s.customer.name)} </text>` : '';
|
|
},
|
|
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 `<text align="left" ${NORMAL}>-------------------------------- ${itemLines}</text>`;
|
|
},
|
|
totals: function () {
|
|
const parts = [`<text align="left" ${NORMAL}>-------------------------------- </text>`];
|
|
// 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(`<text align="left" ${NORMAL}>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)} </text>`);
|
|
}
|
|
parts.push(`<text align="left" width="1" height="1">Subtotal${' '.repeat(22)}R ${sub.toFixed(2)} VAT 15%${' '.repeat(22)}R ${vat.toFixed(2)} </text>`);
|
|
parts.push(`<text align="left" width="1" height="1" em="true">${isRefund ? 'CREDIT' : 'TOTAL'}${' '.repeat(28)}R ${s.total.toFixed(2)} </text>`);
|
|
return parts.join('');
|
|
},
|
|
payment: function () {
|
|
if (isRefund) {
|
|
const how = s.payment === 'cash' ? 'CASH'
|
|
: s.payment === 'account' ? 'ACCOUNT CREDIT'
|
|
: (s.payment || '').toUpperCase();
|
|
return `<text align="left" ${NORMAL}>Refunded by: ${xmlEscape(how)} </text>`
|
|
+ `<text align="left" ${NORMAL}>Goods returned to stock. </text>`;
|
|
}
|
|
const paidLabel = s.payment === 'cash' ? `CASH R ${(s.cashGiven || 0).toFixed(2)}` : s.payment.toUpperCase();
|
|
let out = `<text align="left" ${NORMAL}>Paid: ${xmlEscape(paidLabel)} </text>`;
|
|
if (s.payment === 'cash') out += `<text align="left" ${NORMAL}>Change${' '.repeat(24)}R ${(s.change || 0).toFixed(2)} </text>`;
|
|
return out;
|
|
},
|
|
footer: function (block) {
|
|
const text = block.text || (s.letterhead && s.letterhead.footer_text);
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (text) out += `<text align="center" ${NORMAL}>${xmlEscape(text)} </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 `<?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">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
|
|
}
|
|
|
|
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 ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
|
|
},
|
|
header_title: function (block) {
|
|
const title = block.text || 'WFSALES POS';
|
|
return '<feed line="1"/>'
|
|
+ `<text align="center" width="2" height="2">${xmlEscape(title)} </text>`
|
|
+ `<text align="center" ${NORMAL}>Delivery Note </text>`;
|
|
},
|
|
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(`<text align="center" ${NORMAL}>${xmlEscape(bizName)} </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" ${NORMAL}>${addrLines.join(' ')} </text>`);
|
|
return parts.join('');
|
|
},
|
|
customer: function () {
|
|
let out = '';
|
|
if (s.customer && s.customer.company) out += `<text align="left" ${NORMAL}>Company: ${xmlEscape(s.customer.company)} </text>`;
|
|
out += `<text align="left" ${NORMAL}>Customer: ${xmlEscape((s.customer && s.customer.name) || '—')} </text>`;
|
|
return out;
|
|
},
|
|
date: function () {
|
|
const d = new Date(s.createdAt);
|
|
return `<text align="left" ${NORMAL}>Date: ${xmlEscape(d.toLocaleDateString('en-ZA'))} </text>`;
|
|
},
|
|
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 `<text align="left" ${NORMAL}>-------------------------------- ${itemLines}</text>`;
|
|
},
|
|
totals: function () {
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (showPricing) out += `<text align="left" ${NORMAL}>TOTAL: R ${s.total.toFixed(2)} </text>`;
|
|
return out;
|
|
},
|
|
signature: function () {
|
|
return `<text align="left" ${NORMAL}>Received by: __________ Driver: __________ </text>`;
|
|
},
|
|
};
|
|
}
|
|
|
|
// `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 `<?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">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
|
|
}
|
|
|
|
// 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 '<feed line="1"/>'
|
|
+ `<text align="center" width="2" height="2">${xmlEscape(title)} </text>`
|
|
+ '<text align="center" width="1" height="1">CASH-UP REPORT </text>';
|
|
},
|
|
logo: function () {
|
|
return logo ? `<image width="${logo.width}" height="${logo.height}" color="color_1" mode="mono" align="center">${logo.base64}</image>` : '';
|
|
},
|
|
business_info: function () {
|
|
const parts = [];
|
|
if (lh.display_name) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(lh.display_name)} </text>`);
|
|
if (lh.address) parts.push(`<text align="center" ${NORMAL}>${xmlEscape(lh.address.split('\n')[0])} </text>`);
|
|
if (lh.phone) parts.push(`<text align="center" ${NORMAL}>Tel: ${xmlEscape(lh.phone)} </text>`);
|
|
if (lh.vat_number) parts.push(`<text align="center" ${NORMAL}>VAT: ${xmlEscape(lh.vat_number)} </text>`);
|
|
return parts.join('');
|
|
},
|
|
shift_date: function () {
|
|
let out = `<text align="left" ${NORMAL}>-------------------------------- </text>`;
|
|
if (r.shiftLabel || r.dateLabel) out += `<text align="left" ${NORMAL}>Shift: ${xmlEscape(r.shiftLabel || '')} Date: ${xmlEscape(r.dateLabel || '')} </text>`;
|
|
if (r.counts) out += `<text align="left" ${NORMAL}>Total sales: ${r.counts.cash + r.counts.card + r.counts.eft + r.counts.account} </text>`;
|
|
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 `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>PAYMENT METHOD BREAKDOWN </text>`
|
|
+ `<text align="left" ${NORMAL}>${lines.join(' ')} </text>`;
|
|
},
|
|
grand_total: function () {
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" width="1" height="1" em="true">GRAND TOTAL${' '.repeat(12)}R ${fmtMoney(r.grand || 0)} </text>`;
|
|
},
|
|
cash_reconciliation: function () {
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>CASH RECONCILIATION </text>`
|
|
+ `<text align="left" ${NORMAL}>Cash sales: R ${fmtMoney((r.totals && r.totals.cash) || 0)} </text>`
|
|
+ `<text align="left" ${NORMAL}>Cash counted: R ${fmtMoney(r.cashCounted || 0)} </text>`
|
|
+ `<text align="left" width="1" height="1" em="${((r.variance || 0) === 0) ? 'true' : 'false'}">Variance: R ${fmtMoney(r.variance || 0)} </text>`;
|
|
},
|
|
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 `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="left" ${NORMAL}>DENOMINATIONS COUNTED </text>`
|
|
+ `<text align="left" ${NORMAL}>${dlines.join(' ')} </text>`;
|
|
},
|
|
footer: function (block) {
|
|
const text = block.text || lh.footer_text;
|
|
if (!text) return '';
|
|
return `<text align="left" ${NORMAL}>-------------------------------- </text>`
|
|
+ `<text align="center" ${NORMAL}>${xmlEscape(text)} </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 `<?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">${body}<feed line="3"/><cut type="feed"/></epos-print></s:Body></s:Envelope>`;
|
|
}
|
|
|
|
// 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 };
|
|
})();
|