aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-09-21 11:15:11 -0700
committerAdam Soltys <[email protected]>2026-09-21 11:15:11 -0700
commit01827dd23f29a6e0d6db673f5dc330cd7f4a6f4f (patch)
treef4634d212fe5a81020afcc51fe8a0cb9fa5f20f9
parent792b15ffbb90b82f1e6c07ce5430bae18f239f1e (diff)
downloadcoinosv3-01827dd23f29a6e0d6db673f5dc330cd7f4a6f4f.tar.gz
coinosv3-01827dd23f29a6e0d6db673f5dc330cd7f4a6f4f.zip
Point of sale: a paid sale opens its payment detail, which now says bill and tip.
The row finds the ln-receive movement by the invoice it recorded and opens the same detail screen the history page shows; the detail asks features for more (arkMoveDetailExtra) and the till adds Bills, Tips (with the percentage) and the note. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Tmfg8k5f3TbNJS34qiqHAp
-rw-r--r--src/features/ark.js2
-rw-r--r--src/features/pos.js28
-rw-r--r--src/i18n.js2
-rw-r--r--tools/pos-test.js10
4 files changed, 40 insertions, 2 deletions
diff --git a/src/features/ark.js b/src/features/ark.js
index ef5d8fc..4cc0188 100644
--- a/src/features/ark.js
+++ b/src/features/ark.js
@@ -1403,6 +1403,8 @@ export function arkFeature(ctx) {
(failed ? '' : incoming ? '+' : '-') + fmtAmount(m.amountSat, m.ts / 1000) + ' ' + unitLabel()),
row(t('dateLabel'), new Date(m.ts).toLocaleString()),
ctx.worthLine ? ctx.worthLine(m.amountSat, m.ts / 1000, row) : null,
+ // a feature that knows more about this movement (the till: bill and tip)
+ ctx.hook ? ctx.hook('arkMoveDetailExtra', m, row) : null,
(() => {
const pk = zapNoteFor(m);
const chip = pk ? ctx.hook('profileChip', pk) : null;
diff --git a/src/features/pos.js b/src/features/pos.js
index 127effc..7f47f88 100644
--- a/src/features/pos.js
+++ b/src/features/pos.js
@@ -145,6 +145,17 @@ export function posFeature(ctx) {
render();
}
+ // A paid sale's row opens the same payment detail the history page shows,
+ // found by the invoice the movement recorded.
+ const movementOf = (sale) => (hook('arkMovements') || []).find((m) => m.type === 'ln-receive' && m.status === 'complete' && m.invoice && m.invoice === sale.invoice) || null;
+ function openSale(sale) {
+ const m = movementOf(sale);
+ if (!m) { toast(t('posNoDetail')); return; }
+ ui.arkMoveDetail = m.id;
+ render();
+ }
+ const saleOfMovement = (m) => (m && m.type === 'ln-receive' && m.invoice ? st().sales.find((x) => x.invoice === m.invoice) : null);
+
// ---- screens -------------------------------------------------------------
const big = (text) => h('div', { class: 'pos-big' }, text);
const money = (sats, rate) => h('div', { class: 'col', style: 'align-items:center;gap:2px' },
@@ -198,7 +209,11 @@ export function posFeature(ctx) {
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') },
+ const row = (x) => h('div', {
+ class: 'row between pos-sale' + (x.status === 'paid' ? '' : ' faint'),
+ style: x.status === 'paid' ? 'cursor:pointer' : '',
+ onClick: x.status === 'paid' ? () => openSale(x) : undefined,
+ },
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' },
@@ -302,5 +317,16 @@ export function posFeature(ctx) {
h('p', { class: 'small faint', style: 'margin:0' }, t('posLinkHint', { url })))];
},
openPos: open,
+ // The history detail of a till payment says what was the bill and what the tip.
+ arkMoveDetailExtra(m, row) {
+ const sale = saleOfMovement(m);
+ if (!sale) return null;
+ const line = row || ((k, v) => h('div', { class: 'line' }, h('span', { class: 'k' }, k), h('span', { class: 'v' }, v)));
+ return [
+ line(t('posBills'), fmtAmount(sale.billSat) + ' ' + unitLabel()),
+ line(t('posTips'), fmtAmount(sale.tipSat) + ' ' + unitLabel() + (sale.tipPct ? ' (' + sale.tipPct + '%)' : '')),
+ sale.note ? line(t('posNoteLabel'), sale.note) : null,
+ ];
+ },
};
}
diff --git a/src/i18n.js b/src/i18n.js
index 2283fd6..033198a 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -277,6 +277,8 @@ const STR = {
posAwaiting: 'awaiting payment',
posMakingInvoice: 'Creating the invoice…',
posBackspace: 'Delete',
+ posNoDetail: 'No payment record for this sale yet',
+ posNoteLabel: 'Note',
posModeToggle: 'Start in point of sale on this device',
posLinkHint: 'The till can also be bookmarked or added to the home screen: {url}',
linkHandlerTitle: 'Open links here',
diff --git a/tools/pos-test.js b/tools/pos-test.js
index 4882279..6e3d801 100644
--- a/tools/pos-test.js
+++ b/tools/pos-test.js
@@ -24,10 +24,11 @@ const ctx = {
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); }
+ if (name === 'arkMovements') return window.__movements || [];
return null;
},
};
-const feature = posFeature(ctx);
+const feature = posFeature(ctx); window.__feature = feature;
function render() { document.querySelector('#app').replaceChildren(feature.screenView() || h('div', { id: 'closed' }, 'closed')); }
window.test = { open: () => { feature.openPos(); }, init: () => { feature.init(); render(); }, 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();
@@ -81,6 +82,13 @@ try {
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()));
+ await page.evaluate(() => { [...document.querySelectorAll('.pos-sale')].find((r) => /Table 4/.test(r.textContent)).click(); }); await pause(50);
+ check('a sale with no payment record yet says so', await page.evaluate(() => (window.toasts || []).at(-1) === 'No payment record for this sale yet'));
+ await page.evaluate(() => { window.__movements = [{ id: 'mv1', type: 'ln-receive', status: 'complete', amountSat: 1150, invoice: 'lnbc1150n1testinvoice1', ts: Date.now() }]; });
+ await page.evaluate(() => { [...document.querySelectorAll('.pos-sale')].find((r) => /Table 4/.test(r.textContent)).click(); }); await pause(50);
+ check('a paid sale opens the payment detail by its movement', await page.evaluate(() => test.ui.arkMoveDetail === 'mv1'));
+ check('...and the detail knows the bill, the tip and the note', await page.evaluate(() => { const lines = window.__feature.arkMoveDetailExtra(window.__movements[0], null).filter(Boolean).map((n) => n.textContent); return JSON.stringify(lines); }) === JSON.stringify(['Bills1,000 sats', 'Tips150 sats (15%)', 'NoteTable 4']));
+ await page.evaluate(() => { test.ui.arkMoveDetail = null; });
console.log('\n[a custom tip, then a cancelled sale]');
await type('.pos-amount', '2000'); await click('Charge'); await pause(50);