duly / web/merchant.js · raw
// 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();}