// The merchant's side: sign in, issue invoices, see money, set the rules.import { h, mount, action, copy, field, fmtDate, fmtDateTime, qr, short, statusChip, toast } from './ui.js';import { NET, apiGet, apiPost, base, gasless, isTest, proof, txUrl, view } from './api.js';import { ONE_CLICK, USDC_ASSET } from './lib/config.js';import { toYocto, viewCall } from './lib/near.js';import { openDocument, sealDocument, toUrlKey, unwrapContentKey } from './lib/doc.js';import { formatAmount, invoiceTotals, parseAmount, percentToBps } from './lib/money.js';import { invoiceRow, toCsv } from './lib/csv.js';import { assertPayoutQuote, payoutQuoteBody, postQuote } from './lib/quote.js';import { addressOk, chainName, groupTokens, parseRefundTag } from './lib/chains.js';import { invoiceSheet } from './pay.js';import { clearRoot, createPasskeyRoot, decodeRecovery, deriveSigner, encodeRecovery, loadRoot, newRoot, openSession, passkeysAvailable, saveRoot, signInWithPasskey,} from './lib/session.js';const REGISTER_DEPOSIT = toYocto('0.01');const INVOICE_DEPOSIT = toYocto('0.006');const PAYEE_ROW_DEPOSIT = toYocto('0.002');const ONE_YOCTO = 1n;const OUT_TGAS = 140n;const DAY = 86_400_000;const LIVE_MAX_INVOICE = 25_000_000n;const LIVE_MIN_INVOICE = 500_000n;let session = null;const docCache = new Map();const contentKeys = new Map();// ------------------------------------------------------------- profileconst profileKey = () => `duly.profile.${NET.id}.${session.account}`;function profile() { try { return JSON.parse(localStorage.getItem(profileKey())) || {}; } catch { return {}; }}function saveProfile(p) { try { localStorage.setItem(profileKey(), JSON.stringify(p)); } catch { // storage may be blocked; the details are simply asked for again }}// ---------------------------------------------------------- page chromefunction shell(active, ...content) { const tab = (href, label, key) => h('a', { href: `${base}${href}`, class: active === key ? 'on' : '' }, label); return h( 'div', {}, isTest && h('div', { class: 'testbar' }, 'Test mode. No real money.'), h('header', { class: 'top no-print' }, h('a', { class: 'brand', href: `${base}/app` }, 'Duly'), h('a', { class: 'btn primary', href: `${base}/app/new` }, 'New invoice')), h('main', { class: 'wrap stack' }, content), h('nav', { class: 'tabs no-print', 'aria-label': 'Main' }, tab('/app', 'Invoices', 'list'), tab('/app/money', 'Money', 'money'), tab('/app/settings', 'Settings', 'settings')), );}// -------------------------------------------------------------- sign inconst savedKey = () => `duly.recovery-saved.${NET.id}.${session.account}`;function recoverySaved() { try { return isTest || localStorage.getItem(savedKey()) === '1'; } catch { return false; }}async function begin(root, remember) { session = await openSession(root); saveRoot(NET.id, root, remember); const m = await view('get_merchant', { account: session.account }); if (!m) await gasless(session.main, 'register', {}, { deposit: REGISTER_DEPOSIT }); location.href = `${base}/app`;}async function recoveryPage(next) { const key = await encodeRecovery(session.root); const tick = h('input', { type: 'checkbox', class: 'tick' }); mount( shell( 'list', h( 'section', { class: 'card stack' }, h('h2', {}, 'Save your recovery key'), h('p', { class: 'muted' }, 'If you lose this device or your passkey, this key is the only way back to your money. Duly cannot recover it for you.'), h('p', { class: 'mono' }, key), h('div', { class: 'row' }, h('button', { onClick: () => copy(key, 'Recovery key copied') }, 'Copy')), h('label', { class: 'row' }, tick, h('span', {}, 'I have saved it somewhere safe')), h('button', { class: 'primary wide', onClick: () => { if (!tick.checked) return toast('Tick the box once you have saved it', 'bad'); try { localStorage.setItem(savedKey(), '1'); } catch { // storage may be blocked; the prompt simply returns next time } next(); }, }, 'Continue'), ), ), );}function signInPage() { const recovery = h('input', { placeholder: 'duly-…', autocomplete: 'off', spellcheck: 'false' }); const start = action(async () => { if (isTest) return begin(newRoot(), true); const root = passkeysAvailable() ? await createPasskeyRoot() : null; if (root) return begin(root); const made = newRoot(); if (window.confirm('This device cannot use a passkey for this. Duly will keep your key here, so save your recovery key from Settings straight after this step.')) { return begin(made, true); } }, 'Setting up'); mount( h( 'div', {}, isTest && h('div', { class: 'testbar' }, 'Test mode. No real money.'), h('header', { class: 'top' }, h('a', { class: 'brand', href: `${base}/` }, 'Duly')), h( 'main', { class: 'wrap narrow stack' }, h( 'section', { class: 'card stack onboard' }, h('span', { class: 'eyebrow' }, isTest ? 'NEAR testnet' : 'NEAR mainnet'), h('h2', {}, isTest ? 'Try Duly with test money' : 'Get paid in a few minutes'), h('p', { class: 'muted' }, isTest ? 'This creates a throwaway account on NEAR testnet. Everything else works as it does live.' : 'No wallet to install and no crypto to buy. Your fingerprint or face is your key, and it stays on your device.'), isTest && h( 'ol', { class: 'onboard-steps' }, h('li', {}, 'Issue an invoice and get its link'), h('li', {}, 'Open it as the client and simulate the payment'), h('li', {}, 'See it close on-chain, with the tax share already set aside'), ), h('button', { class: 'primary wide', onClick: start }, isTest ? 'Start' : 'Create my account'), !isTest && h('button', { class: 'wide', onClick: action(async () => begin(await signInWithPasskey()), 'Signing in') }, 'Sign in with my passkey'), ), h( 'details', { class: 'card' }, h('summary', {}, 'I have a recovery key'), h('div', { class: 'stack' }, field('Recovery key', recovery), h('button', { class: 'wide', onClick: action(async () => begin(await decodeRecovery(recovery.value), false), 'Signing in') }, 'Sign in')), ), ), ), );}// ------------------------------------------------------------ documentsasync function docFor(inv) { if (docCache.has(inv.id)) return docCache.get(inv.id); let doc = null; try { const stored = await apiGet(`/api/doc?id=${encodeURIComponent(inv.id)}`); const key = await unwrapContentKey(stored.wrap, session.docKey); contentKeys.set(inv.id, key); doc = await openDocument(stored.ct, key, inv.doc_hash); } catch { doc = null; } docCache.set(inv.id, doc); return doc;}async function allInvoices(m) { const out = []; for (let from = 1; from <= m.invoices; from += 50) { out.push(...(await view('list_invoices', { merchant: session.account, from_number: from, limit: 50 }))); } return out.reverse();}const payLink = (id) => `${location.origin}${base}/i/${id}#${toUrlKey(contentKeys.get(id))}`;// -------------------------------------------------------------- invoicesasync function listPage() { const m = await view('get_merchant', { account: session.account }); const invoices = await allInvoices(m); const docs = await Promise.all(invoices.map(docFor)); const owed = invoices.reduce((a, i) => a + BigInt(i.due_now), 0n); mount( shell( 'list', h( 'div', { class: 'grid' }, h('div', { class: 'card stat' }, h('div', { class: 'label' }, 'Available'), h('div', { class: 'value' }, `$${formatAmount(m.balance)}`)), h('div', { class: 'card stat' }, h('div', { class: 'label' }, 'Clients owe you'), h('div', { class: 'value' }, `$${formatAmount(owed)}`)), ), h( 'section', { class: 'card' }, h('h2', {}, 'Invoices'), invoices.length === 0 ? h('div', { class: 'stack' }, h('p', { class: 'muted' }, 'Nothing here yet. Your first invoice takes about a minute.'), h('a', { class: 'btn primary', href: `${base}/app/new` }, 'Create an invoice')) : h( 'div', { class: 'list' }, invoices.map((inv, i) => h( 'a', { class: 'item', href: `${base}/app/i/${inv.id}` }, h('div', {}, h('p', {}, docs[i]?.to?.name || `Invoice ${inv.number}`), h('p', { class: 'muted small' }, `No. ${String(inv.number).padStart(4, '0')}, due ${fmtDate(inv.due_at_ms)}`)), h('div', { class: 'right' }, h('p', { class: 'num' }, `$${formatAmount(inv.amount)}`), statusChip(inv.status)), ), ), ), ), ), );}function lineRow(onChange, onRemove) { const d = h('input', { placeholder: 'What you did', 'aria-label': 'Description', maxlength: 120, onInput: onChange }); const q = h('input', { inputmode: 'decimal', value: '1', 'aria-label': 'Quantity', onInput: onChange }); const p = h('input', { inputmode: 'decimal', placeholder: '0.00', 'aria-label': 'Price in USDC', onInput: onChange }); const row = h('div', { class: 'line-row' }, h('div', { class: 'desc' }, d), q, p, h('button', { type: 'button', 'aria-label': 'Remove line', onClick: () => onRemove(row) }, '×')); row.read = () => ({ d: d.value.trim(), q: q.value.trim(), p: p.value.trim() }); return row;}async function newPage() { const saved = profile(); const fromName = h('input', { value: saved.name || '', maxlength: 80, placeholder: 'Your name or business' }); const fromEmail = h('input', { value: saved.email || '', maxlength: 120, type: 'email', placeholder: 'For client questions' }); const toName = h('input', { maxlength: 80, placeholder: 'Client name' }); const toEmail = h('input', { maxlength: 120, type: 'email', placeholder: 'Optional' }); const taxPct = h('input', { inputmode: 'decimal', value: saved.taxPct || '', placeholder: '0' }); const due = h('input', { type: 'date', value: new Date(Date.now() + 14 * DAY).toISOString().slice(0, 10) }); const note = h('textarea', { maxlength: 400, placeholder: 'Optional note to your client' }); const discount = h('input', { inputmode: 'decimal', placeholder: 'For example 2' }); const discountDays = h('input', { inputmode: 'numeric', value: '10' }); const ccy = h('select', {}, ['USD', 'EUR', 'GBP', 'INR', 'NGN', 'BRL', 'PHP', 'IDR', 'CAD', 'AUD'].map((c) => h('option', { value: c }, c))); ccy.value = saved.ccy || 'USD'; const totalText = h('p', { class: 'num' }, 'Total 0.00 USDC'); const lines = h('div', { class: 'stack' }); const readDoc = () => { const items = [...lines.children].map((r) => r.read()).filter((l) => l.d || l.p); if (!items.length) throw new Error('Add at least one line'); for (const it of items) if (!it.d) throw new Error('Each line needs a description'); const doc = { v: 1, from: { name: fromName.value.trim(), email: fromEmail.value.trim() }, to: { name: toName.value.trim(), email: toEmail.value.trim() }, items, taxBps: percentToBps(taxPct.value), note: note.value.trim(), ccy: ccy.value, }; return { doc, totals: invoiceTotals(doc) }; }; const refresh = () => { try { totalText.textContent = `Total ${formatAmount(readDoc().totals.total)} USDC`; } catch { // the form is still being filled in } }; const addLine = () => lines.append(lineRow(refresh, (row) => { row.remove(); refresh(); })); addLine(); const create = action(async () => { if (!fromName.value.trim()) throw new Error('Add your name or business'); if (!toName.value.trim()) throw new Error('Add your client'); const { doc, totals } = readDoc(); if (!isTest && totals.total > LIVE_MAX_INVOICE) throw new Error('Live invoices are limited to 25 USDC for now'); if (!isTest && totals.total < LIVE_MIN_INVOICE) throw new Error('Live invoices start at 0.50 USDC, because smaller payments cannot be converted'); const dueMs = Date.parse(`${due.value}T23:59:59Z`); if (!(dueMs > Date.now())) throw new Error('Pick a due date in the future'); if (doc.ccy !== 'USD') { const fx = await apiGet('/api/fx').catch(() => null); if (fx?.rates?.[doc.ccy]) doc.fx = fx.rates[doc.ccy]; } doc.taxAmount = formatAmount(totals.tax).replace(/,/g, ''); const sealed = await sealDocument(doc, session.docKey); const args = { amount: totals.total.toString(), due_at_ms: String(dueMs), doc_hash: sealed.hash }; const bps = percentToBps(discount.value); if (bps > 0) { args.discount_bps = bps; args.discount_until_ms = String(Date.now() + Math.min(29, Number(discountDays.value) || 10) * DAY); } saveProfile({ ...saved, name: doc.from.name, email: doc.from.email, taxPct: taxPct.value, ccy: doc.ccy }); const res = await gasless(session.main, 'create_invoice', args, { deposit: INVOICE_DEPOSIT }); const id = res.events.find((e) => e.event === 'invoice_created')?.data?.id; if (!id) throw new Error('The invoice was issued but its number could not be read. Open Invoices to see it.'); await apiPost('/api/doc', { id, ct: sealed.ct, wrap: sealed.wrap }); contentKeys.set(id, sealed.contentKey); docCache.set(id, doc); location.href = `${base}/app/i/${id}`; }, 'Issuing'); mount( shell( 'list', h('h2', {}, 'New invoice'), h('section', { class: 'card stack' }, h('div', { class: 'grid' }, field('From', fromName), field('Your email', fromEmail)), h('div', { class: 'grid' }, field('Bill to', toName), field('Client email', toEmail))), h('section', { class: 'card stack' }, h('h3', {}, 'What it is for'), lines, h('button', { type: 'button', onClick: addLine }, 'Add a line'), h('div', { class: 'grid' }, field('Tax %', taxPct), field('Due date', due), field('Show client an amount in', ccy)), field('Note', note), totalText), h('details', { class: 'card' }, h('summary', {}, 'Offer a discount for paying early'), h('div', { class: 'grid' }, field('Discount %', discount), field('If paid within (days)', discountDays))), h('button', { class: 'primary wide', onClick: create }, 'Issue invoice and get link'), h('p', { class: 'muted small' }, 'Client names and line items are encrypted on this device. Only the amount, dates and a fingerprint go on-chain.'), ), );}async function refundRow(inv, p, reload) { const tag = parseRefundTag(p.refund_to); const room = BigInt(inv.paid) - BigInt(inv.refunded); const amount = BigInt(p.amount) < room ? BigInt(p.amount) : room; const send = action(async () => { const where = tag ? `${tag.address} on ${chainName(tag.chain)}` : 'this client'; const already = BigInt(inv.refunded) > 0n ? ` You have already refunded ${formatAmount(inv.refunded)} on this invoice.` : ''; if (!window.confirm(`Send ${formatAmount(amount)} USDC to ${where}? It comes out of your available balance.${already}`)) return; let to; if (isTest) { to = { Near: { account: NET.testPayer } }; } else { const asset = await findAsset(tag.chain, tag.symbol); to = await intentsDestination(asset.assetId, amount.toString(), tag.address); } await gasless(session.main, 'refund', { id: inv.id, amount: amount.toString(), to }, { deposit: ONE_YOCTO, tgas: OUT_TGAS }); toast('Refund sent'); reload(); }, 'Sending'); return h( 'div', { class: 'item' }, h('div', {}, h('p', {}, `${formatAmount(p.amount)} USDC`), h('p', { class: 'muted small' }, `${fmtDateTime(p.at_ms)}${tag ? `, from ${chainName(tag.chain)}` : ''}`)), h('div', { class: 'right' }, amount > 0n && tag ? h('button', { onClick: send }, 'Refund') : h('span', { class: 'muted small' }, tag ? 'Refunded' : 'Refund by hand')), );}async function detailPage(id) { const inv = await view('get_invoice', { id }); if (!inv || inv.merchant !== session.account) { mount(shell('list', h('div', { class: 'card' }, h('h2', {}, 'Invoice not found')))); return; } const doc = await docFor(inv); const reload = () => detailPage(id); const link = contentKeys.has(id) ? payLink(id) : null; const { payments } = await apiGet(`/api/watch?id=${encodeURIComponent(id)}`).catch(() => ({ payments: [] })); const open = BigInt(inv.due_now) > 0n; const rows = await Promise.all(inv.payments.map((p) => refundRow(inv, p, reload))); const shareLink = () => (navigator.share ? navigator.share({ title: `Invoice ${inv.number}`, url: link }).catch(() => null) : copy(link, 'Link copied')); mount( shell( 'list', link && open && h('section', { class: 'card stack no-print' }, h('h3', {}, 'Payment link'), qr(link, 200), h('p', { class: 'mono' }, link), h('div', { class: 'row' }, h('button', { class: 'primary', onClick: shareLink }, 'Share link'), h('button', { onClick: () => copy(link, 'Link copied') }, 'Copy'), h('a', { class: 'btn', href: link }, 'Open as client'))), invoiceSheet(inv, doc), inv.payments.length > 0 && h('section', { class: 'card' }, h('h3', {}, 'Payments'), h('div', { class: 'list' }, rows)), payments.filter((p) => p.near_tx).length > 0 && h('section', { class: 'card stack small' }, payments.filter((p) => p.near_tx).map((p) => h('a', { href: txUrl(p.near_tx) }, `Settlement transaction ${short(p.near_tx)}`))), BigInt(inv.excess) > 0n && h('div', { class: 'banner warn' }, `Your client paid ${formatAmount(inv.excess)} USDC more than this invoice. You can send it back above.`), BigInt(inv.set_aside) > 0n && h('div', { class: 'banner info' }, `${formatAmount(inv.set_aside)} USDC went to your set-asides when this was paid.`), ['open', 'overdue', 'partial'].includes(inv.status) ? h('button', { class: 'danger no-print', onClick: action(async () => { if (!window.confirm('Cancel this invoice? Its link will stop asking for payment.')) return; await gasless(session.main, 'cancel_invoice', { id }); reload(); }, 'Cancelling'), }, 'Cancel invoice') : null, ), );}// ----------------------------------------------------------------- moneylet tokenGroups = null;async function loadTokens() { if (!tokenGroups) tokenGroups = groupTokens(await (await fetch(`${ONE_CLICK}/tokens`)).json()); return tokenGroups;}async function findAsset(chain, symbol) { const groups = await loadTokens(); const asset = groups.find((g) => g.symbol === symbol)?.options.find((o) => o.blockchain === chain); if (!asset) throw new Error(`${symbol} on ${chainName(chain)} is not available right now`); return asset;}/** Quote a payout, check it, and return where the contract should send the USDC. */async function intentsDestination(destinationAsset, amount, recipient) { const q = await postQuote(payoutQuoteBody({ dry: false, destinationAsset, amount, recipient, refundAccount: session.account, deadlineMs: Date.now() + 30 * 60 * 1000 })); await assertPayoutQuote(q, { dry: false, destinationAsset, amount, recipient, refundAccount: session.account }); return { Intents: { deposit_address: q.quote.depositAddress } };}async function jarSigner(share) { return deriveSigner(session.root, `jar/${share.label.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`);}async function exportCsv() { const m = await view('get_merchant', { account: session.account }); const invoices = (await allInvoices(m)).reverse(); const rows = []; for (const inv of invoices) { const doc = await docFor(inv); const { payments } = BigInt(inv.paid) > 0n ? await apiGet(`/api/watch?id=${encodeURIComponent(inv.id)}`).catch(() => ({ payments: [] })) : { payments: [] }; const tx = payments.find((p) => p.near_tx)?.near_tx; rows.push(invoiceRow(inv, doc, tx ? txUrl(tx) : '')); } const url = URL.createObjectURL(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' })); const a = h('a', {}); a.setAttribute('href', url); a.setAttribute('download', `duly-invoices-${new Date().toISOString().slice(0, 10)}.csv`); a.click(); setTimeout(() => URL.revokeObjectURL(url), 10000);}async function moneyPage() { const m = await view('get_merchant', { account: session.account }); const jars = await Promise.all( m.shares.map(async (s) => { const mine = (await jarSigner(s)).account === s.to; return { ...s, mine, balance: await view('balance_of', { account: s.to }) }; }), ); // A payout that fails after leaving the contract refunds to this account on Intents. const stranded = isTest ? '0' : await viewCall(NET.rpc, NET.intents, 'mt_balance_of', { account_id: session.account, token_id: USDC_ASSET }).catch(() => '0'); const amount = h('input', { inputmode: 'decimal', placeholder: '0.00' }); const kind = h('select', {}, h('option', { value: 'chain' }, 'An address on another network'), h('option', { value: 'near' }, 'A NEAR account')); const networkSel = h('select', { 'aria-label': 'Network' }); const address = h('input', { placeholder: 'Your wallet or exchange address', autocomplete: 'off', spellcheck: 'false' }); const preview = h('div', { class: 'stack' }); let usdc = []; if (!isTest) { usdc = (await loadTokens()).find((g) => g.symbol === 'USDC')?.options.filter((o) => o.blockchain !== 'near') || []; networkSel.replaceChildren(...usdc.map((o) => h('option', { value: o.assetId }, chainName(o.blockchain)))); } else { kind.value = 'near'; kind.disabled = true; } const withdraw = action(async () => { const units = parseAmount(amount.value); if (units <= 0n) throw new Error('Enter an amount'); if (units > BigInt(m.balance)) throw new Error('That is more than your available balance'); const target = address.value.trim(); let to; if (kind.value === 'near') { if (!addressOk('near', target)) throw new Error('Enter a NEAR account'); to = { Near: { account: target } }; } else { const asset = usdc.find((o) => o.assetId === networkSel.value); if (!addressOk(asset.blockchain, target)) throw new Error(`Enter a ${chainName(asset.blockchain)} address`); to = await intentsDestination(asset.assetId, units.toString(), target); } await gasless(session.main, 'withdraw', { amount: units.toString(), to }, { deposit: ONE_YOCTO, tgas: OUT_TGAS }); toast('Sent'); moneyPage(); }, 'Sending'); mount( shell( 'money', h('section', { class: 'card stat' }, h('div', { class: 'label' }, 'Available to withdraw'), h('div', { class: 'value' }, `$${formatAmount(m.balance)}`)), BigInt(stranded) > 0n && h('div', { class: 'banner warn' }, `${formatAmount(stranded)} USDC from a payout that could not finish is safe on NEAR Intents under your account. It is not in your balance above, and it can only be moved with your recovery key.`), jars.length > 0 && h( 'section', { class: 'card' }, h('h3', {}, 'Set aside'), h( 'div', { class: 'list' }, jars.map((j) => h( 'div', { class: 'item' }, h('div', {}, h('p', {}, j.label), h('p', { class: 'muted small' }, j.mine ? `${j.bps / 100}% of each payment, kept apart for you` : `${j.bps / 100}% of each payment, paid to ${short(j.to, 10, 6)}`)), h( 'div', { class: 'right' }, h('p', { class: 'num' }, `$${formatAmount(j.balance)}`), j.mine && BigInt(j.balance) > 0n && h('button', { class: 'link', onClick: action(async () => { await gasless(await jarSigner(j), 'send', { to: session.account, amount: j.balance }, { deposit: ONE_YOCTO }); toast('Moved to your available balance'); moneyPage(); }, 'Moving'), }, 'Move to available'), ), ), ), ), ), h('section', { class: 'card stack' }, h('h3', {}, 'Withdraw'), field('Amount (USDC)', amount), field('Send to', kind), !isTest && field('Network', networkSel), field('Address', address), preview, h('button', { class: 'primary wide', onClick: withdraw }, 'Withdraw')), h('section', { class: 'card stack' }, h('h3', {}, 'For your accountant'), h('p', { class: 'muted small' }, 'One row per invoice: amounts, tax, set-asides, refunds and a link to each settlement. Opens in Excel, Numbers or Google Sheets.'), h('button', { onClick: action(exportCsv, 'Preparing') }, 'Download CSV')), ), );}// -------------------------------------------------------------- settingsfunction shareEditor(m, rowsBox) { const addRow = (s = {}) => { const label = h('input', { value: s.label || '', maxlength: 24, placeholder: 'Tax', 'aria-label': 'Name' }); const pct = h('input', { inputmode: 'decimal', value: s.bps ? String(s.bps / 100) : '', placeholder: '20', 'aria-label': 'Percent' }); const payee = h('input', { value: s.external ? s.to : '', placeholder: 'Leave empty to keep it yourself', 'aria-label': 'NEAR account', spellcheck: 'false' }); const row = h('div', { class: 'stack' }, h('div', { class: 'grid' }, field('Name', label), field('Percent', pct)), field('Pay to NEAR account', payee), h('button', { class: 'link', type: 'button', onClick: () => row.remove() }, 'Remove')); row.read = () => ({ label: label.value.trim(), bps: percentToBps(pct.value), payee: payee.value.trim() }); rowsBox.append(row); }; return addRow;}async function settingsPage() { const m = await view('get_merchant', { account: session.account }); const rowsBox = h('div', { class: 'stack' }); const addRow = shareEditor(m, rowsBox); for (const s of m.shares) { const mine = (await jarSigner(s)).account === s.to; addRow({ ...s, external: !mine }); } if (m.shares.length === 0) addRow({ label: 'Tax', bps: 2000 }); const saveShares = action(async () => { const shares = []; for (const row of rowsBox.children) { const r = row.read(); if (!r.bps) continue; if (!/^[A-Za-z0-9 -]{1,24}$/.test(r.label)) throw new Error('Names may use letters, numbers, spaces and hyphens'); if (r.payee && !addressOk('near', r.payee)) throw new Error('That NEAR account does not look right'); const to = r.payee || (await jarSigner(r)).account; shares.push({ to, bps: r.bps, label: r.label }); } const known = new Set(m.shares.map((s) => s.to)); const fresh = BigInt(shares.filter((s) => !known.has(s.to)).length); await gasless(session.main, 'set_shares', { shares }, { deposit: fresh * PAYEE_ROW_DEPOSIT }); toast('Saved. These apply to every payment from now on.'); settingsPage(); }, 'Saving'); const days = h('input', { inputmode: 'numeric', value: '3' }); const pct = h('input', { inputmode: 'decimal', value: m.agent_max_discount_bps ? String(m.agent_max_discount_bps / 100) : '2' }); const sweepTo = h('input', { value: m.sweep_to || '', placeholder: 'Optional NEAR account', spellcheck: 'false' }); const keep = h('input', { inputmode: 'decimal', value: m.sweep_to ? formatAmount(m.sweep_keep).replace(/,/g, '') : '' }); const agentId = isTest ? 'agent-duly.testnet' : 'agent.dulypay.near'; const agentOn = m.agent === agentId; const saveAgent = (enable) => action(async () => { const bps = percentToBps(pct.value); if (sweepTo.value.trim() && !addressOk('near', sweepTo.value.trim())) throw new Error('That NEAR account does not look right'); await gasless( session.main, 'set_agent', { agent: enable ? agentId : null, max_discount_bps: enable ? bps : 0, sweep_to: enable ? sweepTo.value.trim() || null : null, sweep_keep: (keep.value.trim() ? parseAmount(keep.value) : 0n).toString() }, { deposit: ONE_YOCTO }, ); await apiPost('/api/agent/rules', { ...(await proof(session.main, 'agent')), enabled: enable, days: Number(days.value), bps, hours: 72 }); toast(enable ? 'Collections are on' : 'Collections are off'); settingsPage(); }, 'Saving'); const showRecovery = action(async () => { box.replaceChildren(h('div', { class: 'banner warn' }, 'Anyone with this key controls your balance. Keep it somewhere safe and never share it.'), h('p', { class: 'mono' }, await encodeRecovery(session.root))); }); const box = h('div', { class: 'stack' }); const connectTelegram = action(async () => { const { code, bot } = await apiPost('/api/telegram/link', await proof(session.main, 'telegram')); if (!bot) throw new Error('Telegram is not set up yet'); window.open(`https://t.me/${bot}?start=${code}`, '_blank', 'noopener'); }, 'Opening'); mount( shell( 'settings', h( 'section', { class: 'card stack' }, h('h3', {}, 'Set-asides'), h('p', { class: 'muted small' }, 'Each payment splits by these rules the moment it lands, before it reaches your balance. Keep a share in a jar of your own, or pay a subcontractor directly.'), rowsBox, h('div', { class: 'row' }, h('button', { type: 'button', onClick: () => addRow() }, 'Add a set-aside'), h('button', { class: 'primary', onClick: saveShares }, 'Save')), ), h( 'section', { class: 'card stack' }, h('div', { class: 'row between' }, h('h3', {}, 'Collections'), h('span', { class: `chip ${agentOn ? 'good' : 'neutral'}` }, agentOn ? 'On' : 'Off')), h('p', { class: 'muted small' }, 'Watches for overdue invoices, writes the reminder and can offer a discount for quick payment. The contract holds it to these limits. It cannot withdraw, refund or change them.'), h('div', { class: 'grid' }, field('Act after (days overdue)', days), field('Largest discount %', pct)), field('Sweep idle money to', sweepTo, 'The only account it can ever send to. Leave empty to turn sweeping off.'), field('Always keep available', keep), h('div', { class: 'row' }, h('button', { class: 'primary', onClick: saveAgent(true) }, agentOn ? 'Update limits' : 'Turn on'), agentOn && h('button', { onClick: saveAgent(false) }, 'Turn off')), ), h('section', { class: 'card stack' }, h('h3', {}, 'Payment alerts'), h('p', { class: 'muted small' }, 'Get a Telegram message when a client pays or an invoice goes overdue.'), h('button', { onClick: connectTelegram }, 'Connect Telegram')), h( 'section', { class: 'card stack' }, h('h3', {}, 'Your account'), h('p', { class: 'mono' }, session.account), box, h('div', { class: 'row' }, h('button', { onClick: showRecovery }, 'Show recovery key'), h('button', { class: 'danger', onClick: () => { clearRoot(NET.id); location.href = `${base}/`; } }, 'Sign out')), ), ), );}// ---------------------------------------------------------------- routerexport async function appPage(path) { const root = loadRoot(NET.id); if (!root) return signInPage(); session = await openSession(root); const m = await view('get_merchant', { account: session.account }); if (!m) return begin(root, isTest); if (path === '/app') return listPage(); if (path === '/app/new') return recoverySaved() ? newPage() : recoveryPage(newPage); if (path === '/app/money') return moneyPage(); if (path === '/app/settings') return settingsPage(); const inv = path.match(/^\/app\/i\/(\d{1,10}-\d{1,10})$/); if (inv) return detailPage(inv[1]); return listPage();}