From f1ef6c48f942340a30ad8c8943aa93aebe592a05 Mon Sep 17 00:00:00 2001 From: Vladimir Rogojin Date: Fri, 5 Jun 2026 14:57:38 +0200 Subject: [PATCH] fix(cli)(#36): invoice pay --amount accepts human units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the cross-command inconsistency PR #35 deferred: payments send 50 UCT → 50 whole UCT ✓ human units invoice create --asset 50 UCT → 50 whole UCT ✓ human units (PR #35) invoice return … --asset 50 UCT → 50 whole UCT ✓ human units (PR #35) invoice pay --amount 50 → 50 whole UCT ✓ NOW human units PR #35 left `invoice-pay --amount` parsing the raw value as smallest units because the command takes only the amount (no coin token); the coin is implicit in the invoice's target/asset entry. This patch resolves the invoice via `sphere.accounting!.getInvoice(invoiceId)`, reads the target's first asset, fails fast on NFT targets or missing targets, looks up decimals through `resolveCoin(symbol)`, and pipes the value through `toSmallestUnit()` — exactly the same conversion path PR #35 used for create/return. Side-effect on COMMAND_HELP: - description: mentions partial/over-payment use case (per the issue's "Why deferred" rationale) - --amount flag desc: documents human-unit semantics with a fractional example - examples: switched to `sphere invoice pay …` form with human-readable amounts (0.5 UCT, 25 UCT) - notes: explicit reminder that NFT targets reject --amount Picked Option A from the issue (--amount stays single-token, coin inferred from the invoice). Option B (--amount ) was rejected because forcing the user to re-type a coin the invoice already pins adds friction without information; on `--asset` the coin is being *declared* (create/return), on `pay` it's *fixed by the invoice*. == Verification == - npx tsc --noEmit: clean - npx vitest run: 126/126 pass - node bin/sphere.mjs invoice pay --help: renders cleanly with new description / flag / examples / notes - npx tsup: clean ESM + CJS + DTS build Issue: https://github.com/unicity-sphere/sphere-cli/issues/36 --- src/legacy/legacy-cli.ts | 50 ++++++++++++++++++++++++++++++++-------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/src/legacy/legacy-cli.ts b/src/legacy/legacy-cli.ts index 80f9994..c4354e7 100644 --- a/src/legacy/legacy-cli.ts +++ b/src/legacy/legacy-cli.ts @@ -1511,15 +1511,19 @@ const COMMAND_HELP: Record = { }, 'invoice-pay': { usage: 'invoice-pay [--amount ] [--target-index ]', - description: 'Pay an invoice. By default pays the remaining amount for the first target. For multi-target invoices, specify --target-index.', + description: 'Pay an invoice. By default pays the remaining amount for the first target. Use --amount for partial or over-payment; the coin is implicit in the invoice.', flags: [ - { flag: '--amount ', description: 'Amount to pay in smallest units (positive integer). Defaults to remaining amount.' }, + { flag: '--amount ', description: 'Amount to pay in HUMAN units of the invoice\'s coin (e.g. "50" = 50 UCT, "0.5" = 0.5 UCT). Defaults to remaining amount needed to cover the target.' }, { flag: '--target-index ', description: 'Target index for multi-target invoices (0-based)', default: '0' }, ], examples: [ - 'npm run cli -- invoice-pay a1b2c3d4', - 'npm run cli -- invoice-pay a1b2c3d4 --amount 500000', - 'npm run cli -- invoice-pay a1b2c3d4 --target-index 1 --amount 250000', + 'sphere invoice pay a1b2c3d4', + 'sphere invoice pay a1b2c3d4 --amount 0.5', + 'sphere invoice pay a1b2c3d4 --target-index 1 --amount 25', + ], + notes: [ + '--amount is in human units (whole coins, fractional allowed) — matches `payments send` and `invoice create --asset`.', + 'NFT targets reject --amount (use full-pay form without --amount).', ], }, 'invoice-return': { @@ -4793,11 +4797,39 @@ async function main(): Promise { const payParamsMut: Record = { targetIndex }; if (amountIdx2 !== -1 && args[amountIdx2 + 1]) { - const rawAmount = args[amountIdx2 + 1]; - if (!/^[1-9][0-9]*$/.test(rawAmount!)) { - failWithHelp('invoice-pay', `invalid amount "${rawAmount}" — must be a positive integer in smallest units (no decimals, no leading zeros)`); + const rawAmount = args[amountIdx2 + 1]!; + // Issue #36 — canonical UX: `--amount ` is in HUMAN units + // (e.g. "50" = 50 whole UCT, "0.5" = 0.5 UCT), matching + // `payments send 50 UCT` and `invoice create --asset 50 UCT`. + // The coin is implicit in the invoice's target/asset entry. + // Convert to smallest-unit integer here before handing off; the + // SDK's PayInvoiceParams.amount is a raw atom-count string. + const invoiceRef = sphere.accounting!.getInvoice(invoiceId); + if (!invoiceRef) { + failWithHelp('invoice-pay', `invoice ${invoiceId} not found in memory`); + } + const target = invoiceRef.terms.targets[targetIndex]; + if (!target) { + failWithHelp('invoice-pay', `--target-index ${targetIndex} out of range (invoice has ${invoiceRef.terms.targets.length} target(s))`); + } + // assetIndex is currently CLI-fixed to 0 (see "Out of scope" in #36). + const asset = target.assets[0]; + if (!asset || !asset.coin) { + failWithHelp('invoice-pay', '--amount only applies to coin-asset targets, not NFT'); + } + const [coinSymbol] = asset.coin; + const { decimals } = resolveCoin(coinSymbol); + let smallest: bigint; + try { + smallest = toSmallestUnit(rawAmount, decimals); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failWithHelp('invoice-pay', `invalid amount "${rawAmount}" — ${msg}`); + } + if (smallest <= 0n) { + failWithHelp('invoice-pay', `invalid amount "${rawAmount}" — must be positive`); } - payParamsMut['amount'] = rawAmount; + payParamsMut['amount'] = smallest.toString(); } const payParams = payParamsMut as unknown as PayInvoiceParams;