aboutsummaryrefslogtreecommitdiffstats
path: root/src/features
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-21 08:16:00 -0700
committerAdam Soltys <[email protected]>2026-09-21 08:16:00 -0700
commite2ca38a1088a0ec0595432726b2e708b14a811ea (patch)
treed3153f4bd4066ccf78ff031dbcb26053d4697a80 /src/features
parent40257dad7978797d5185e796136596ff10b6abec (diff)
downloadcoinosv3-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')
-rw-r--r--src/features/ark.js10
-rw-r--r--src/features/index.js5
-rw-r--r--src/features/names.js3
-rw-r--r--src/features/pos.js264
4 files changed, 279 insertions, 3 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index 5327a23..ef5d8fc 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -3238,6 +3238,16 @@ export function arkFeature(ctx) {
},
arkSpendableSat() { const b = arkBalance(); return b ? b.spendableSat : 0; },
arkReady() { return arkAvailable(); },
+ // A Lightning invoice for another feature to watch (point of sale):
+ // the action id is the handle, the poller tells it when it settles.
+ async arkLnInvoice(amountSat, description) {
+ const mgr = await connectArk();
+ const a = await mgr.createLnInvoice(amountSat, description);
+ return { id: a.id, invoice: a.invoice, paymentHash: a.paymentHash, amountSat, expiresAt: a.expiresAt };
+ },
+ arkLnWatch(id, onSettle) { pollArkLn(id, onSettle); return true; },
+ arkLnUnwatch(id) { stopArkLnPoll(id); return true; },
+ async arkLnCancel(id) { const mgr = await connectArk(); await mgr.cancelLnInvoice(id); return true; },
async arkMakeInvoice(amountSat, description) {
const mgr = await connectArk();
const a = await mgr.createLnInvoice(amountSat, description);
diff --git a/src/features/index.js b/src/features/index.js
index a32bced..73369c3 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -22,14 +22,15 @@ import { syncFeature } from './sync.js';
import { messagesFeature } from './messages.js';
export function buildFeatures(ctx) {
- return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx)];
+ return [{}, arkFeature(ctx), nostrLoginFeature(ctx), namesFeature(ctx), zapsFeature(ctx), {}, {}, syncFeature(ctx), messagesFeature(ctx), {}];
}
export async function loadDeferredFeatures(ctx, features) {
const late = [];
await Promise.all([
import('./gifts.js').then((m) => { late.push(features[0] = m.giftsFeature(ctx)); }),
import('./nwc.js').then((m) => { late.push(features[5] = m.nwcFeature(ctx)); }),
- import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); })
+ import('./hats.js').then((m) => { late.push(features[6] = m.hatsFeature(ctx)); }),
+ import('./pos.js').then((m) => { late.push(features[9] = m.posFeature(ctx)); }),
]);
return late;
}
diff --git a/src/features/names.js b/src/features/names.js
index f05ac09..0024847 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -588,7 +588,8 @@ export function namesFeature(ctx) {
onClick: () => copy(addr),
html: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>',
}))),
- moreSection());
+ moreSection(),
+ (ctx.hook && ctx.hook('receiveExtras')) || null);
}
// ---- BOLT 12 offers with a memo ----------------------------------------
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,
+ };
+}