diff options
| author | Adam Soltys <[email protected]> | 2026-09-21 08:16:00 -0700 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-09-21 08:16:00 -0700 |
| commit | e2ca38a1088a0ec0595432726b2e708b14a811ea (patch) | |
| tree | d3153f4bd4066ccf78ff031dbcb26053d4697a80 /tools | |
| parent | 40257dad7978797d5185e796136596ff10b6abec (diff) | |
| download | coinosv3-e2ca38a1088a0ec0595432726b2e708b14a811ea.tar.gz coinosv3-e2ca38a1088a0ec0595432726b2e708b14a811ea.zip | |
Point of sale: a bill, an optional tip prompt, one invoice, and a ledger that keeps tips apart.
A new deferred feature (src/features/pos.js): type the amount in the display
unit, optionally a note, Charge. With the tip prompt on, the customer sees
the bill and picks a percentage (configurable, default 10/15/20), their own
figure, or no tip; one Lightning invoice is made for the total through the
Ark feature's receive (new hooks arkLnInvoice/arkLnWatch/arkLnUnwatch/
arkLnCancel wrap createLnInvoice and pollArkLn). Paid, expired and cancelled
sales are recorded with the bill and the tip as separate figures, and the
fiat rate at the time; the amount screen shows today's bills, tips and total.
Doors: Settings → Payments card, and a button under the payment address on
Receive (a receiveExtras hook the names pane renders).
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Diffstat (limited to 'tools')
| -rw-r--r-- | tools/pos-test.js | 96 |
1 files changed, 96 insertions, 0 deletions
diff --git a/tools/pos-test.js b/tools/pos-test.js new file mode 100644 index 0000000..3dad594 --- /dev/null +++ b/tools/pos-test.js @@ -0,0 +1,96 @@ +// Point of sale: amount → tip prompt → one invoice for the total → paid, with +// the bill and the tip counted apart. The Ark side is faked: no relays, no +// server, no money. Run: bun tools/pos-test.js +import assert from 'node:assert/strict'; +import puppeteer from 'puppeteer-core'; + +const app = await Bun.file('src/app.js').text(); +const domHelper = app.slice(app.indexOf('function h(tag,'), app.indexOf('// ---------------------------------------------------------------- morphing')); +const entry = ` +import { posFeature } from './src/features/pos.js'; +${domHelper} +const state = {}; +const ui = { screen: 'wallet' }; +let watch = null, cancelled = [], invoices = 0; +const wallet = { loadFeatureState: (k, d) => (k in state ? state[k] : d), saveFeatureState: (k, v) => { state[k] = JSON.parse(JSON.stringify(v)); } }; +const ctx = { + h, ui, wallet, render, toast: (m) => (window.toasts = (window.toasts || [])).push(m), copy: (x) => (window.copied = x), + fmtAmount: (n) => Number(n).toLocaleString('en-US'), unitLabel: () => 'sats', unitTag: () => h('span', { class: 'unit-tag' }, 'sats'), + parseAmount: (v) => { const n = Number(String(v).replace(/,/g, '')); return isFinite(n) ? Math.round(n) : null; }, getUnit: () => 'sats', + brandHeader: () => null, + hook(name, ...a) { + if (name === 'arkReady') return true; + if (name === 'arkLnInvoice') { invoices++; return Promise.resolve({ id: 'ln' + invoices, invoice: 'lnbc' + a[0] + 'n1testinvoice' + invoices, amountSat: a[0] }); } + if (name === 'arkLnWatch') { watch = { id: a[0], cb: a[1] }; return true; } + if (name === 'arkLnUnwatch') { if (watch && watch.id === a[0]) watch = null; return true; } + if (name === 'arkLnCancel') { cancelled.push(a[0]); return Promise.resolve(true); } + return null; + }, +}; +const feature = posFeature(ctx); +function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); } +window.test = { open: () => { feature.openPos(); }, settle: (ok) => { const w = watch; watch = null; w.cb({ step: ok ? 'done' : 'failed' }); }, get watch() { return watch && watch.id; }, get cancelled() { return cancelled; }, get sales() { return (state.pos || {}).sales || []; }, ui }; +render(); +`; +const bundle = await Bun.build({ + entrypoints: ['pos-test-entry'], target: 'browser', + plugins: [{ name: 'pos-test', setup(build) { + build.onResolve({ filter: /^pos-test-entry$/ }, () => ({ path: 'entry', namespace: 'pos-test' })); + build.onLoad({ filter: /.*/, namespace: 'pos-test' }, () => ({ contents: entry, loader: 'js', resolveDir: process.cwd() })); + } }], +}); +assert(bundle.success, bundle.logs.join('\n')); +const css = await Bun.file('src/style.css').text(); +const js = await bundle.outputs[0].text(); +const html = `<!doctype html><meta name="viewport" content="width=device-width, initial-scale=1"><style>${css}</style><div id="app"></div><script type="module">${js}</script>`; +const server = Bun.serve({ port: 0, fetch: () => new Response(html, { headers: { 'content-type': 'text/html' } }) }); +const browser = await puppeteer.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] }); +const page = await browser.newPage(); +const errors = []; +page.on('pageerror', (e) => errors.push(e.message)); +const pause = (ms) => new Promise((r) => setTimeout(r, ms)); +const check = (name, value) => { assert(value, name); console.log(' ✓ ' + name); }; +const text = () => page.evaluate(() => document.body.innerText.replace(/\s+/g, ' ')); +const click = (label) => page.evaluate((x) => { const b = [...document.querySelectorAll('button')].find((n) => (n.querySelector('.pos-tip-label') || n).textContent.trim() === x); if (!b) return false; b.click(); return true; }, label); +const type = async (sel, v) => { await page.evaluate((s) => { const i = document.querySelector(s); i.value = ''; }, sel); await page.type(sel, v); }; +try { + await page.setViewport({ width: 390, height: 844 }); + await page.goto(server.url.href); + await page.waitForSelector('#closed'); + await page.evaluate(() => test.open()); await pause(50); + check('the point of sale opens on the amount screen', /Point of sale/.test(await text()) && !!(await page.$('.pos-amount'))); + await click('Charge'); await pause(50); + check('charging nothing asks for an amount', /Enter an amount first/.test(await text())); + await type('.pos-amount', '1000'); await type('input[placeholder="Note (optional)"]', 'Table 4'); + await click('Charge'); await pause(50); + check('the tip prompt shows the bill and the tip choices', /Your bill 1,000 sats/.test(await text()) && /10%.*15%.*20%.*Other.*No tip/.test(await text())); + await click('15%'); await pause(50); + check('a percentage shows the total', /Total 1,150 sats/.test(await text())); + await click('Continue'); await pause(100); + check('one invoice is made for bill plus tip', await page.evaluate(() => test.watch === 'ln1') && /1,150 sats/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text()) && !!(await page.$('.pos-qr svg'))); + await click('Copy'); + check('copy hands over the invoice', await page.evaluate(() => window.copied === 'lnbc1150n1testinvoice1')); + await page.evaluate(() => test.settle(true)); await pause(50); + check('settlement shows Paid with the breakdown', /Paid/.test(await text()) && /Bill 1,000 \+ tip 150 sats/.test(await text())); + await click('New sale'); await pause(50); + check('the ledger keeps the bill and the tip apart', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text()) && /Total 1,150 sats/.test(await text()) && /Table 4/.test(await text())); + + console.log('\n[a custom tip, then a cancelled sale]'); + await type('.pos-amount', '2000'); await click('Charge'); await pause(50); + await click('Other'); await pause(50); + await page.evaluate(() => { const i = document.querySelector('input[placeholder="0"]:not(.pos-amount)'); i.value = '50'; i.dispatchEvent(new Event('input')); }); await pause(50); + check('a custom tip adds what was typed', /Total 2,050 sats/.test(await text())); + await click('Continue'); await pause(100); + await click('Cancel'); await pause(50); + check('cancelling voids the sale and the invoice', await page.evaluate(() => test.cancelled.includes('ln2') && test.sales.find((s) => s.billSat === 2000).status === 'void') && /cancelled/.test(await text())); + check('a cancelled sale does not count', /Bills 1,000 sats/.test(await text()) && /Tips 150 sats/.test(await text())); + + console.log('\n[no tip prompt]'); + await page.click('input[type=checkbox]'); await pause(50); + await type('.pos-amount', '300'); await click('Charge'); await pause(100); + check('with the prompt off, Charge goes straight to the invoice', /Scan to pay/.test(await text()) && /300 sats/.test(await text()) && await page.evaluate(() => test.watch === 'ln3')); + await page.evaluate(() => test.settle(false)); await pause(50); + check('an expired invoice returns to the amount screen and says so', /That invoice expired/.test(await text())); + check('no browser errors', errors.length === 0); +} finally { await browser.close(); server.stop(true); } +console.log('\n✅ point of sale'); |
