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 /src/features/pos.js | |
| 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 'src/features/pos.js')
| -rw-r--r-- | src/features/pos.js | 264 |
1 files changed, 264 insertions, 0 deletions
diff --git a/src/features/pos.js b/src/features/pos.js new file mode 100644 index 0000000..c7e5882 --- /dev/null +++ b/src/features/pos.js @@ -0,0 +1,264 @@ +// Point of sale: a bill for a stated amount, an optional tip prompt for the +// customer (a few percentages, or their own figure), one Lightning invoice +// for the total — and a ledger that keeps the bill and the tip apart, so a +// day's tips can be counted on their own. +// +// Everything money-shaped goes through the Ark feature's Lightning receive +// (the same invoices the Receive tab hands out); this file is the flow and +// the ledger. Amounts are typed in the display unit (sats / BTC / your +// currency) and kept in sats, with the fiat rate at the time of the sale +// remembered so a receipt reads the same later. +import { qrSvg } from '../qr.js'; +import { rateNow, getCurrency, fmtFiat } from '../rates.js'; +import { t } from '../i18n.js'; + +export function posFeature(ctx) { + const { h, ui, render, wallet, toast, copy, fmtAmount, unitLabel, unitTag, parseAmount, getUnit } = ctx; + const hook = ctx.hook || (() => null); + + // ---- state --------------------------------------------------------------- + // { sales: [{ id, at, billSat, tipSat, tipPct, note, invoice, lnId, status: 'open'|'paid'|'void', fiat: { code, rate } }], + // askTip: true, tips: [10, 15, 20] } + const st = () => { + const s = wallet.loadFeatureState('pos', {}) || {}; + s.sales ||= []; + if (s.askTip == null) s.askTip = true; + if (!Array.isArray(s.tips) || !s.tips.length) s.tips = [10, 15, 20]; + return s; + }; + const save = (s) => { try { wallet.saveFeatureState('pos', s); } catch {} }; + const SALES_MAX = 500; + + const fiatLine = (sats, rate = rateNow(), code = getCurrency()) => (rate ? fmtFiat(sats, rate, code) : ''); + const pct = (sats, p) => Math.round(sats * p / 100); + const dayKey = (ms) => new Date(ms).toDateString(); + + // ---- flow ---------------------------------------------------------------- + // ui.pos: { step: 'amount'|'tip'|'invoice'|'paid', amount, note, tipMode, tipCustom, sale, error, busy } + function open() { + ui.pos = { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }; + render(); + } + function close() { + const p = ui.pos; + if (p && p.sale && p.sale.status === 'open') voidSale(p.sale); + ui.pos = null; + render(); + } + function billSat() { + const p = ui.pos; + const n = parseAmount(p.amount, getUnit()); + return n && n > 0 ? n : 0; + } + function charge() { + const p = ui.pos; + const bill = billSat(); + if (!bill) { p.error = t('posEnterAmount'); render(); return; } + p.error = ''; + if (!hook('arkReady')) { p.error = t('posNeedsSpending'); render(); return; } + if (st().askTip) { p.step = 'tip'; p.tipMode = null; p.tipCustom = ''; render(); return; } + makeSale(bill, 0, 0); + } + function tipSat() { + const p = ui.pos, bill = billSat(); + if (p.tipMode == null || p.tipMode === 'none') return 0; + if (p.tipMode === 'custom') { const n = parseAmount(p.tipCustom, getUnit()); return n && n > 0 ? n : 0; } + return pct(bill, p.tipMode); + } + async function makeSale(bill, tip, tipPct) { + const p = ui.pos; + p.busy = true; p.error = ''; render(); + const s = st(); + const sale = { + id: 's' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), + at: Date.now(), billSat: bill, tipSat: tip, tipPct, note: (p.note || '').trim().slice(0, 80), + status: 'open', fiat: rateNow() ? { code: getCurrency(), rate: rateNow() } : null, + }; + try { + const memo = (sale.note ? sale.note + ' · ' : '') + t('posMemo', { bill: fmtAmount(bill) + ' ' + unitLabel(), tip: tip ? fmtAmount(tip) + ' ' + unitLabel() : '0' }); + const inv = await hook('arkLnInvoice', bill + tip, memo.slice(0, 120)); + if (!inv || !inv.invoice) throw new Error(t('posInvoiceFailed')); + sale.invoice = inv.invoice; sale.lnId = inv.id; + s.sales.push(sale); + if (s.sales.length > SALES_MAX) s.sales = s.sales.slice(-SALES_MAX); + save(s); + p.sale = sale; p.step = 'invoice'; + hook('arkLnWatch', inv.id, (a) => settle(sale, a)); + } catch (e) { + p.error = e.message || String(e); + } + p.busy = false; render(); + } + function settle(sale, action) { + const s = st(); + const row = s.sales.find((x) => x.id === sale.id); + const paid = action && action.step === 'done'; + if (row) { row.status = paid ? 'paid' : 'void'; if (paid) row.paidAt = Date.now(); save(s); } + if (ui.pos && ui.pos.sale && ui.pos.sale.id === sale.id) { + ui.pos.sale = row || sale; + ui.pos.step = paid ? 'paid' : 'amount'; + if (!paid) ui.pos.error = t('posInvoiceExpired'); + } + render(); + } + function voidSale(sale) { + const s = st(); + const row = s.sales.find((x) => x.id === sale.id); + if (row && row.status === 'open') { row.status = 'void'; save(s); } + if (sale.lnId) { hook('arkLnUnwatch', sale.lnId); Promise.resolve(hook('arkLnCancel', sale.lnId)).catch(() => {}); } + } + function newSale() { + const p = ui.pos; + if (p.sale && p.sale.status === 'open') voidSale(p.sale); + Object.assign(p, { step: 'amount', amount: '', note: '', tipMode: null, tipCustom: '', sale: null, error: '' }); + render(); + } + + // ---- screens ------------------------------------------------------------- + const big = (text) => h('div', { class: 'pos-big' }, text); + const money = (sats, rate) => h('div', { class: 'col', style: 'align-items:center;gap:2px' }, + big(fmtAmount(sats) + ' ' + unitLabel()), + fiatLine(sats, rate) && getUnit() !== 'fiat' ? h('div', { class: 'muted' }, fiatLine(sats, rate)) : null); + const back = () => h('button', { class: 'btn-ghost btn-block', onClick: close }, t('back')); + + function amountScreen(p) { + const s = st(); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px' }, + h('h3', { style: 'margin:0' }, t('posTitle')), + h('div', { class: 'input-group' }, + h('input', { + type: 'text', inputmode: 'decimal', class: 'pos-amount', placeholder: '0', value: p.amount, autofocus: true, + onInput: (e) => { p.amount = e.target.value; }, + onKeydown: (e) => { if (e.key === 'Enter') charge(); }, + }), + unitTag()), + getUnit() !== 'fiat' && billSat() && fiatLine(billSat()) ? h('div', { class: 'small muted', style: 'text-align:right' }, fiatLine(billSat())) : null, + h('input', { type: 'text', placeholder: t('posNoteHint'), value: p.note, maxlength: '80', onInput: (e) => { p.note = e.target.value; } }), + p.error ? h('div', { class: 'notice error small' }, p.error) : null, + h('button', { class: 'btn-primary btn-block', disabled: !!p.busy, onClick: charge }, p.busy ? h('span', { class: 'spinner sm' }) : t('posCharge'))), + tipSettingsCard(s), + salesCard(s), + back()); + } + function tipSettingsCard(s) { + return h('div', { class: 'card col', style: 'gap:8px' }, + h('label', { class: 'row gap6', style: 'align-items:center;cursor:pointer' }, + h('input', { type: 'checkbox', checked: !!s.askTip, style: 'width:18px;height:18px;accent-color:var(--accent);margin:0', + onChange: (e) => { const x = st(); x.askTip = e.target.checked; save(x); render(); } }), + h('span', {}, t('posAskTip'))), + s.askTip ? h('label', { class: 'row gap6', style: 'align-items:center' }, + h('span', { class: 'small muted', style: 'flex:0 0 auto' }, t('posTipChoices')), + h('input', { type: 'text', class: 'grow', value: s.tips.join(', '), inputmode: 'numeric', + onChange: (e) => { + const x = st(); + const list = [...new Set(e.target.value.split(/[\s,]+/).map((v) => parseInt(v, 10)).filter((n) => n > 0 && n <= 100))].slice(0, 5); + if (list.length) { x.tips = list; save(x); } + render(); + } }), + h('span', { class: 'small muted' }, '%')) : null); + } + function salesCard(s) { + const sales = [...s.sales].reverse(); + if (!sales.length) return null; + const today = dayKey(Date.now()); + const paidToday = sales.filter((x) => x.status === 'paid' && dayKey(x.paidAt || x.at) === today); + const sum = (k) => paidToday.reduce((n, x) => n + (x[k] || 0), 0); + const row = (x) => h('div', { class: 'row between pos-sale' + (x.status === 'paid' ? '' : ' faint') }, + h('div', { class: 'col', style: 'min-width:0;gap:1px' }, + h('span', {}, fmtAmount(x.billSat) + ' ' + unitLabel() + (x.tipSat ? ' + ' + fmtAmount(x.tipSat) + ' ' + t('posTipWord') : '')), + h('span', { class: 'small muted', style: 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, + new Date(x.paidAt || x.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + (x.note ? ' · ' + x.note : '') + + (x.status === 'open' ? ' · ' + t('posAwaiting') : x.status === 'void' ? ' · ' + t('posVoid') : ''))), + h('span', { class: 'amount-pos', style: 'white-space:nowrap' }, (x.status === 'paid' ? '+' : '') + fmtAmount(x.billSat + x.tipSat))); + return h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', { style: 'margin:0' }, t('posSalesToday')), + h('div', { class: 'row between' }, h('span', { class: 'muted' }, t('posBills')), h('span', {}, fmtAmount(sum('billSat')) + ' ' + unitLabel())), + h('div', { class: 'row between' }, h('span', { class: 'muted' }, t('posTips')), h('span', {}, fmtAmount(sum('tipSat')) + ' ' + unitLabel())), + h('div', { class: 'row between', style: 'font-weight:600' }, h('span', {}, t('posTotal')), h('span', {}, fmtAmount(sum('billSat') + sum('tipSat')) + ' ' + unitLabel())), + h('div', { class: 'col', style: 'gap:6px;margin-top:4px' }, ...sales.slice(0, 30).map(row))); + } + // The customer's screen: the bill, and how much to add to it. + function tipScreen(p) { + const bill = billSat(); + const s = st(); + const choice = (mode, label, sub) => h('button', { + class: 'pos-tip' + (p.tipMode === mode ? ' on' : ''), type: 'button', + onClick: () => { p.tipMode = mode; render(); }, + }, h('span', { class: 'pos-tip-label' }, label), sub ? h('span', { class: 'small muted' }, sub) : null); + const tip = tipSat(); + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:14px;align-items:center' }, + h('div', { class: 'muted' }, t('posYourBill')), + money(bill), + h('div', { class: 'muted', style: 'margin-top:6px' }, t('posAddTip')), + h('div', { class: 'pos-tips' }, + ...s.tips.map((n) => choice(n, n + '%', '+' + fmtAmount(pct(bill, n)) + ' ' + unitLabel())), + choice('custom', t('posTipCustom'), null), + choice('none', t('posNoTip'), null)), + p.tipMode === 'custom' ? h('div', { class: 'input-group', style: 'width:100%' }, + h('input', { type: 'text', inputmode: 'decimal', placeholder: '0', value: p.tipCustom, autofocus: true, onInput: (e) => { p.tipCustom = e.target.value; render(); } }), + h('span', { class: 'small muted', style: 'align-self:center;padding:0 8px' }, unitLabel())) : null, + p.tipMode != null ? h('div', { class: 'small muted' }, t('posTotalLine', { n: fmtAmount(bill + tip) + ' ' + unitLabel() })) : null, + h('button', { class: 'btn-primary btn-block', disabled: p.tipMode == null || !!p.busy, + onClick: () => makeSale(bill, tip, p.tipMode === 'custom' || p.tipMode === 'none' ? 0 : p.tipMode) }, + p.busy ? h('span', { class: 'spinner sm' }) : t('posContinue'))), + h('button', { class: 'btn-ghost btn-block', onClick: () => { p.step = 'amount'; render(); } }, t('back'))); + } + function invoiceScreen(p) { + const sale = p.sale; + const total = sale.billSat + sale.tipSat; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('div', { class: 'muted' }, t('posScanToPay')), + money(total, sale.fiat && sale.fiat.rate), + sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null, + h('div', { class: 'pos-qr', html: qrSvg(sale.invoice.toUpperCase()) }), + h('div', { class: 'row gap6', style: 'width:100%' }, + h('button', { class: 'grow', onClick: () => copy(sale.invoice) }, t('copy')), + h('button', { class: 'btn-ghost grow', onClick: newSale }, t('cancel'))), + h('div', { class: 'row gap6', style: 'align-items:center' }, h('span', { class: 'spinner sm' }), h('span', { class: 'small muted' }, t('posWaiting'))))); + } + function paidScreen(p) { + const sale = p.sale; + return h('div', { class: 'col', style: 'gap:16px' }, + ctx.brandHeader(false), + h('div', { class: 'card col', style: 'gap:12px;align-items:center' }, + h('div', { class: 'pos-check' }, '✓'), + h('h3', { style: 'margin:0' }, t('posPaid')), + money(sale.billSat + sale.tipSat, sale.fiat && sale.fiat.rate), + sale.tipSat ? h('div', { class: 'small muted' }, t('posBreakdown', { bill: fmtAmount(sale.billSat), tip: fmtAmount(sale.tipSat), u: unitLabel() })) : null, + h('button', { class: 'btn-primary btn-block', onClick: newSale }, t('posNewSale'))), + back()); + } + function posScreen() { + const p = ui.pos; + if (p.step === 'tip') return tipScreen(p); + if (p.step === 'invoice' && p.sale) return invoiceScreen(p); + if (p.step === 'paid' && p.sale) return paidScreen(p); + return amountScreen(p); + } + + return { + id: 'pos', + screenView() { + if (ui.screen !== 'wallet' || !ui.pos) return null; + return posScreen(); + }, + // Settings → Payments: the door, and the tip choices. + settingsCards() { + return [h('div', { class: 'card col', style: 'gap:8px' }, + h('h3', {}, t('posTitle')), + h('p', { class: 'small muted', style: 'margin:0' }, t('posDesc')), + h('button', { class: 'btn-primary', onClick: open }, t('posOpen')))]; + }, + // Under the payment address on Receive. + receiveExtras() { + return h('button', { class: 'btn-sm', onClick: open }, '\u{1F9FE} ' + t('posTitle')); + }, + openPos: open, + }; +} |
