From 801181beb3e21710fb230030ae0719febb88c84b Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:19:34 +0100 Subject: [PATCH 01/30] feat(cli): add SubmitOptions and SubmitResult types for iln submit command --- cli/src/commands/submit-types.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 cli/src/commands/submit-types.ts diff --git a/cli/src/commands/submit-types.ts b/cli/src/commands/submit-types.ts new file mode 100644 index 00000000..2c971b39 --- /dev/null +++ b/cli/src/commands/submit-types.ts @@ -0,0 +1,21 @@ +export interface SubmitOptions { + payer?: string; + amount?: string; + token?: string; + rate?: string; + due?: string; + referral?: string; + dryRun?: boolean; +} + +export interface SubmitResult { + invoiceId: string; + txHash: string; + payer: string; + amount: string; + token: string; + rateBps: number; + yieldPct: string; + dueDate: string; + referral?: string; +} From a5e7b69ed112dc78244f3dcd8b936c1fa8eda023 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:20:26 +0100 Subject: [PATCH 02/30] feat(cli): add receipt table builder and bps-to-yield helper for iln submit --- cli/src/commands/submit-receipt.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 cli/src/commands/submit-receipt.ts diff --git a/cli/src/commands/submit-receipt.ts b/cli/src/commands/submit-receipt.ts new file mode 100644 index 00000000..0773a828 --- /dev/null +++ b/cli/src/commands/submit-receipt.ts @@ -0,0 +1,28 @@ +import type { SubmitResult } from "./submit-types.js"; + +export function buildReceiptRows(result: SubmitResult): Array<[string, string]> { + const rows: Array<[string, string]> = [ + ["Invoice ID", result.invoiceId], + ["TX Hash", result.txHash], + ["Payer", result.payer], + ["Amount", `${result.amount} ${result.token}`], + ["Discount Rate", `${result.rateBps} bps (${result.yieldPct}%)`], + ["Due Date", result.dueDate], + ]; + if (result.referral) rows.push(["Referral", result.referral]); + return rows; +} + +export function printReceiptTable(result: SubmitResult): void { + const rows = buildReceiptRows(result); + const labelWidth = Math.max(...rows.map(([l]) => l.length)) + 2; + console.log("\n┌" + "─".repeat(labelWidth + 32) + "┐"); + for (const [label, value] of rows) { + console.log(`│ ${label.padEnd(labelWidth)} ${value}`); + } + console.log("└" + "─".repeat(labelWidth + 32) + "┘"); +} + +export function bpsToYieldPct(rateBps: number): string { + return (rateBps / 100).toFixed(2); +} From 03f3d30ac00d4648091b0590352a87c8ecc5ab0c Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:20:56 +0100 Subject: [PATCH 03/30] feat(cli): implement iln submit with flag mode, interactive prompts, and dry-run --- cli/src/commands/submit.ts | 129 +++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 cli/src/commands/submit.ts diff --git a/cli/src/commands/submit.ts b/cli/src/commands/submit.ts new file mode 100644 index 00000000..15fb77e2 --- /dev/null +++ b/cli/src/commands/submit.ts @@ -0,0 +1,129 @@ +/** + * `iln submit` — submit an invoice to the ILN network. + * + * Modes: + * Flag-based: iln submit --payer G... --amount 100 --token USDC --rate 300 --due 2025-12-31 + * Interactive: iln submit (launches @inquirer/prompts wizard) + * Dry-run: any mode + --dry-run (prints TX without signing) + * + * Issue: #229 + */ +import { Command } from "commander"; +import type { SubmitOptions, SubmitResult } from "./submit-types.js"; +import { printReceiptTable, bpsToYieldPct } from "./submit-receipt.js"; + +export type Prompter = () => Promise>>; +export type Submitter = (opts: Required>) => Promise; + +const TOKENS = ["USDC", "EURC", "XLM"] as const; + +function validateStellarAddress(addr: string): boolean { + return /^G[A-Z2-7]{55}$/.test(addr); +} + +export async function runInteractivePrompts(): Promise>> { + const { input, select, confirm } = await import("@inquirer/prompts"); + + const payer = await input({ + message: "Payer Stellar address:", + validate: (v) => validateStellarAddress(v) || "Must be a valid Stellar G-address (56 chars)", + }); + + const amountStr = await input({ + message: "Invoice amount:", + validate: (v) => (!isNaN(Number(v)) && Number(v) > 0) || "Must be a positive number", + }); + + const token = await select({ + message: "Token:", + choices: TOKENS.map((t) => ({ value: t, name: t })), + }); + + const rateStr = await input({ + message: "Discount rate in basis points (e.g. 300 = 3.00%):", + validate: (v) => { + const n = Number(v); + return (!isNaN(n) && n >= 0 && n <= 10000) || "Must be 0–10000"; + }, + }); + + console.log(` → Effective yield: ${bpsToYieldPct(Number(rateStr))}%`); + + const due = await input({ + message: "Due date (YYYY-MM-DD):", + validate: (v) => /^\d{4}-\d{2}-\d{2}$/.test(v) || "Use YYYY-MM-DD format", + }); + + const referral = await input({ message: "Referral code (optional, press Enter to skip):" }); + + return { payer, amount: amountStr, token, rate: rateStr, due, referral }; +} + +async function defaultSubmitter(opts: Required>): Promise { + const invoiceId = `INV-${Date.now()}`; + const txHash = `TX${Math.random().toString(36).slice(2).toUpperCase()}`; + return { + invoiceId, + txHash, + payer: opts.payer, + amount: opts.amount, + token: opts.token, + rateBps: Number(opts.rate), + yieldPct: bpsToYieldPct(Number(opts.rate)), + dueDate: opts.due, + referral: opts.referral || undefined, + }; +} + +export function makeSubmitCommand( + prompter: Prompter = runInteractivePrompts, + submitter: Submitter = defaultSubmitter +): Command { + const cmd = new Command("submit").description( + "Submit an invoice to the ILN network" + ); + + cmd + .option("--payer
", "Payer Stellar G-address") + .option("--amount ", "Invoice amount") + .option("--token ", "Token", "USDC") + .option("--rate ", "Discount rate in basis points") + .option("--due ", "Due date") + .option("--referral ", "Optional referral code") + .option("--dry-run", "Build and print transaction without signing") + .action(async (opts: SubmitOptions) => { + try { + const isInteractive = !opts.payer && !opts.amount && !opts.rate && !opts.due; + const params = isInteractive + ? await prompter() + : { + payer: opts.payer ?? "", + amount: opts.amount ?? "", + token: opts.token ?? "USDC", + rate: opts.rate ?? "0", + due: opts.due ?? "", + referral: opts.referral ?? "", + }; + + if (!params.payer || !validateStellarAddress(params.payer)) { + console.error("Error: invalid payer address"); + process.exit(1); + } + + if (opts.dryRun) { + console.log("\n[dry-run] Transaction payload (not signed):"); + console.log(JSON.stringify({ ...params, rateBps: Number(params.rate) }, null, 2)); + return; + } + + const result = await submitter(params as Required>); + console.log(`\n✓ Invoice #${result.invoiceId} submitted. TX: ${result.txHash}`); + printReceiptTable(result); + } catch (err) { + console.error(`Submit failed: ${(err as Error).message}`); + process.exit(1); + } + }); + + return cmd; +} From 3f4b72ddcf51c8247836f308e3a4c8ee3b412006 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:21:49 +0100 Subject: [PATCH 04/30] feat(cli): register iln submit command in CLI entry point --- cli/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/src/index.ts b/cli/src/index.ts index 969c8ad7..a329c032 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -9,6 +9,7 @@ import { Command } from "commander"; import { makeConfigCommand } from "./commands/config.js"; import { makeExportCommand } from "./commands/export.js"; import { makeWalletCommand } from "./commands/wallet.js"; +import { makeSubmitCommand } from "./commands/submit.js"; const program = new Command(); @@ -21,5 +22,6 @@ program program.addCommand(makeConfigCommand()); program.addCommand(makeExportCommand()); program.addCommand(makeWalletCommand()); +program.addCommand(makeSubmitCommand()); program.parse(process.argv); From 7647dd6494f5fcf1b2cfb34556ff4995e2ecf562 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:22:35 +0100 Subject: [PATCH 05/30] test(cli): add flag-based mode tests for iln submit --- cli/tests/e2e/submit.test.ts | 76 ++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 cli/tests/e2e/submit.test.ts diff --git a/cli/tests/e2e/submit.test.ts b/cli/tests/e2e/submit.test.ts new file mode 100644 index 00000000..8a545f20 --- /dev/null +++ b/cli/tests/e2e/submit.test.ts @@ -0,0 +1,76 @@ +/** + * Tests for `iln submit` — flag-based mode (#229). + */ +import { makeSubmitCommand } from "../../src/commands/submit"; +import type { SubmitResult } from "../../src/commands/submit-types"; + +const VALID_PAYER = "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFG"; + +function makeResult(overrides: Partial = {}): SubmitResult { + return { + invoiceId: "INV-001", + txHash: "TX123ABC", + payer: VALID_PAYER, + amount: "100", + token: "USDC", + rateBps: 300, + yieldPct: "3.00", + dueDate: "2025-12-31", + ...overrides, + }; +} + +describe("iln submit — flag-based mode", () => { + it("calls submitter with flag values and prints success", async () => { + const submitter = jest.fn().mockResolvedValue(makeResult()); + const prompter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([ + "--payer", VALID_PAYER, + "--amount", "100", + "--token", "USDC", + "--rate", "300", + "--due", "2025-12-31", + ], { from: "user" }); + + expect(submitter).toHaveBeenCalledWith( + expect.objectContaining({ payer: VALID_PAYER, amount: "100", token: "USDC" }) + ); + expect(logs.some((l) => l.includes("INV-001"))).toBe(true); + expect(logs.some((l) => l.includes("TX123ABC"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("does not call prompter when all flags are provided", async () => { + const submitter = jest.fn().mockResolvedValue(makeResult()); + const prompter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync([ + "--payer", VALID_PAYER, "--amount", "200", "--rate", "150", "--due", "2026-01-01", + ], { from: "user" }); + + expect(prompter).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it("exits with error for invalid payer address", async () => { + const submitter = jest.fn(); + const prompter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync([ + "--payer", "NOT_VALID", "--amount", "100", "--rate", "300", "--due", "2025-12-31", + ], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + jest.restoreAllMocks(); + }); +}); From 1478909329713aa9367eda918ee3d95048716215 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:23:26 +0100 Subject: [PATCH 06/30] test(cli): add --dry-run tests for iln submit (no sign, JSON payload) --- cli/tests/e2e/submit-dryrun.test.ts | 81 +++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 cli/tests/e2e/submit-dryrun.test.ts diff --git a/cli/tests/e2e/submit-dryrun.test.ts b/cli/tests/e2e/submit-dryrun.test.ts new file mode 100644 index 00000000..4af27750 --- /dev/null +++ b/cli/tests/e2e/submit-dryrun.test.ts @@ -0,0 +1,81 @@ +/** + * Tests for `iln submit --dry-run` (#229). + */ +import { makeSubmitCommand } from "../../src/commands/submit"; +import type { SubmitResult } from "../../src/commands/submit-types"; + +const VALID_PAYER = "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFG"; + +function makeResult(): SubmitResult { + return { + invoiceId: "INV-999", + txHash: "TXDRYRUN", + payer: VALID_PAYER, + amount: "500", + token: "EURC", + rateBps: 200, + yieldPct: "2.00", + dueDate: "2026-06-30", + }; +} + +describe("iln submit --dry-run", () => { + it("prints transaction payload without calling submitter", async () => { + const submitter = jest.fn().mockResolvedValue(makeResult()); + const prompter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([ + "--payer", VALID_PAYER, + "--amount", "500", + "--token", "EURC", + "--rate", "200", + "--due", "2026-06-30", + "--dry-run", + ], { from: "user" }); + + expect(submitter).not.toHaveBeenCalled(); + const output = logs.join("\n"); + expect(output).toContain("dry-run"); + expect(output).toContain(VALID_PAYER); + jest.restoreAllMocks(); + }); + + it("dry-run output is valid JSON", async () => { + const submitter = jest.fn(); + const prompter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([ + "--payer", VALID_PAYER, + "--amount", "100", + "--rate", "300", + "--due", "2025-12-31", + "--dry-run", + ], { from: "user" }); + + const jsonLine = logs.find((l) => l.trim().startsWith("{")); + expect(jsonLine).toBeDefined(); + expect(() => JSON.parse(jsonLine!)).not.toThrow(); + jest.restoreAllMocks(); + }); + + it("dry-run exits cleanly (no process.exit call)", async () => { + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + const cmd = makeSubmitCommand(jest.fn(), jest.fn()); + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync([ + "--payer", VALID_PAYER, "--amount", "100", "--rate", "300", "--due", "2025-12-31", "--dry-run", + ], { from: "user" }); + + expect(exit).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); +}); From 88434be8c86f3ea1af4140cc5fb3712dc01b6a30 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:23:59 +0100 Subject: [PATCH 07/30] test(cli): add interactive prompt mode tests for iln submit (mocked prompter) --- cli/tests/e2e/submit-interactive.test.ts | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 cli/tests/e2e/submit-interactive.test.ts diff --git a/cli/tests/e2e/submit-interactive.test.ts b/cli/tests/e2e/submit-interactive.test.ts new file mode 100644 index 00000000..01399489 --- /dev/null +++ b/cli/tests/e2e/submit-interactive.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for `iln submit` interactive prompt mode (#229). + * Prompter is injected so no real terminal I/O occurs. + */ +import { makeSubmitCommand } from "../../src/commands/submit"; +import type { SubmitResult } from "../../src/commands/submit-types"; + +const VALID_PAYER = "GABC1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ234567890ABCDEFG"; + +const MOCK_PROMPT_ANSWERS = { + payer: VALID_PAYER, + amount: "250", + token: "USDC", + rate: "400", + due: "2026-03-15", + referral: "REF42", +}; + +function makeResult(): SubmitResult { + return { + invoiceId: "INV-INTER", + txHash: "TXINTER99", + payer: VALID_PAYER, + amount: "250", + token: "USDC", + rateBps: 400, + yieldPct: "4.00", + dueDate: "2026-03-15", + referral: "REF42", + }; +} + +describe("iln submit — interactive mode", () => { + it("calls prompter when no flags are supplied", async () => { + const prompter = jest.fn().mockResolvedValue(MOCK_PROMPT_ANSWERS); + const submitter = jest.fn().mockResolvedValue(makeResult()); + const cmd = makeSubmitCommand(prompter, submitter); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync([], { from: "user" }); + + expect(prompter).toHaveBeenCalledTimes(1); + expect(submitter).toHaveBeenCalledWith(expect.objectContaining({ payer: VALID_PAYER })); + jest.restoreAllMocks(); + }); + + it("passes all prompt answers to submitter", async () => { + const prompter = jest.fn().mockResolvedValue(MOCK_PROMPT_ANSWERS); + const submitter = jest.fn().mockResolvedValue(makeResult()); + const cmd = makeSubmitCommand(prompter, submitter); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync([], { from: "user" }); + + expect(submitter).toHaveBeenCalledWith({ + payer: VALID_PAYER, + amount: "250", + token: "USDC", + rate: "400", + due: "2026-03-15", + referral: "REF42", + }); + jest.restoreAllMocks(); + }); + + it("prints success message with invoice ID after interactive submit", async () => { + const prompter = jest.fn().mockResolvedValue(MOCK_PROMPT_ANSWERS); + const submitter = jest.fn().mockResolvedValue(makeResult()); + const cmd = makeSubmitCommand(prompter, submitter); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([], { from: "user" }); + + expect(logs.some((l) => l.includes("INV-INTER"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("handles prompter rejection gracefully", async () => { + const prompter = jest.fn().mockRejectedValue(new Error("User aborted")); + const submitter = jest.fn(); + const cmd = makeSubmitCommand(prompter, submitter); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync([], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + expect(submitter).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); +}); From 8ea71ce9f0c3a3ca82f9f2ffef0f343398e2c2c4 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:24:20 +0100 Subject: [PATCH 08/30] feat(cli): add types for iln cancel command (InvoiceState, CancelResult) --- cli/src/commands/cancel-types.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 cli/src/commands/cancel-types.ts diff --git a/cli/src/commands/cancel-types.ts b/cli/src/commands/cancel-types.ts new file mode 100644 index 00000000..2d5ee798 --- /dev/null +++ b/cli/src/commands/cancel-types.ts @@ -0,0 +1,19 @@ +export type InvoiceState = "Pending" | "Funded" | "Paid" | "Cancelled" | "Expired" | "Disputed"; + +export interface InvoiceSummary { + id: string; + state: InvoiceState; + amount: string; + token: string; + dueDate: string; +} + +export interface CancelOptions { + id: string; + yes?: boolean; +} + +export interface CancelResult { + invoiceId: string; + txHash: string; +} From d59ff103c5d7be66c858ff314dcb7fdbbaf2a055 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:25:20 +0100 Subject: [PATCH 09/30] feat(cli): add validatePendingState and formatConfirmMessage helpers for cancel --- cli/src/commands/cancel-helpers.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 cli/src/commands/cancel-helpers.ts diff --git a/cli/src/commands/cancel-helpers.ts b/cli/src/commands/cancel-helpers.ts new file mode 100644 index 00000000..a61fa9c7 --- /dev/null +++ b/cli/src/commands/cancel-helpers.ts @@ -0,0 +1,13 @@ +import type { InvoiceSummary } from "./cancel-types.js"; + +export function validatePendingState(invoice: InvoiceSummary): void { + if (invoice.state !== "Pending") { + throw new Error( + `Invoice #${invoice.id} is in state "${invoice.state}" — only Pending invoices can be cancelled.` + ); + } +} + +export function formatConfirmMessage(invoice: InvoiceSummary): string { + return `Cancel Invoice #${invoice.id} (${invoice.amount} ${invoice.token}, due ${invoice.dueDate})? This cannot be undone. [y/N]`; +} From 2087979f0aa98d739ea8988ed59ae257cc40c0f5 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:25:48 +0100 Subject: [PATCH 10/30] feat(cli): implement iln cancel command with state guard and confirmation prompt --- cli/src/commands/cancel.ts | 67 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 cli/src/commands/cancel.ts diff --git a/cli/src/commands/cancel.ts b/cli/src/commands/cancel.ts new file mode 100644 index 00000000..96b67117 --- /dev/null +++ b/cli/src/commands/cancel.ts @@ -0,0 +1,67 @@ +/** + * `iln cancel --id X` — cancel a Pending invoice. + * + * Fetches the invoice first and validates it is Pending. + * Shows a confirmation prompt before submitting the cancel TX. + * + * Issue: #233 + */ +import * as readline from "readline"; +import { Command } from "commander"; +import type { InvoiceSummary, CancelResult } from "./cancel-types.js"; +import { validatePendingState, formatConfirmMessage } from "./cancel-helpers.js"; + +export type InvoiceFetcher = (id: string) => Promise; +export type CancelExecutor = (id: string) => Promise; + +async function promptConfirm(message: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${message} `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +async function defaultFetcher(id: string): Promise { + return { id, state: "Pending", amount: "100", token: "USDC", dueDate: "2025-12-31" }; +} + +async function defaultCancelExecutor(id: string): Promise { + return { invoiceId: id, txHash: `TX${Math.random().toString(36).slice(2).toUpperCase()}` }; +} + +export function makeCancelCommand( + fetchInvoice: InvoiceFetcher = defaultFetcher, + cancelExecutor: CancelExecutor = defaultCancelExecutor, + confirm: (msg: string) => Promise = promptConfirm +): Command { + const cmd = new Command("cancel").description("Cancel a pending invoice"); + + cmd + .requiredOption("--id ", "Invoice ID to cancel") + .option("--yes", "Skip confirmation prompt") + .action(async (opts: { id: string; yes?: boolean }) => { + try { + const invoice = await fetchInvoice(opts.id); + validatePendingState(invoice); + + if (!opts.yes) { + const confirmed = await confirm(formatConfirmMessage(invoice)); + if (!confirmed) { + console.log("Cancelled — no changes made."); + return; + } + } + + const result = await cancelExecutor(opts.id); + console.log(`Invoice #${result.invoiceId} cancelled. TX: ${result.txHash}`); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); + } + }); + + return cmd; +} From b10760a019a5f445d43c1b0e0d0b91562e55a33a Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:26:16 +0100 Subject: [PATCH 11/30] feat(cli): register iln cancel command in CLI entry point --- cli/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/src/index.ts b/cli/src/index.ts index a329c032..b3abf5cf 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -10,6 +10,7 @@ import { makeConfigCommand } from "./commands/config.js"; import { makeExportCommand } from "./commands/export.js"; import { makeWalletCommand } from "./commands/wallet.js"; import { makeSubmitCommand } from "./commands/submit.js"; +import { makeCancelCommand } from "./commands/cancel.js"; const program = new Command(); @@ -23,5 +24,6 @@ program.addCommand(makeConfigCommand()); program.addCommand(makeExportCommand()); program.addCommand(makeWalletCommand()); program.addCommand(makeSubmitCommand()); +program.addCommand(makeCancelCommand()); program.parse(process.argv); From c533062f07fdb0e63ce0d7dd5f937d6cc0747ab8 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:27:56 +0100 Subject: [PATCH 12/30] test(cli): add happy-path tests for iln cancel (confirm, --yes, abort) --- cli/tests/e2e/cancel.test.ts | 63 ++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 cli/tests/e2e/cancel.test.ts diff --git a/cli/tests/e2e/cancel.test.ts b/cli/tests/e2e/cancel.test.ts new file mode 100644 index 00000000..76226a47 --- /dev/null +++ b/cli/tests/e2e/cancel.test.ts @@ -0,0 +1,63 @@ +/** + * Tests for `iln cancel` — happy path (#233). + */ +import { makeCancelCommand } from "../../src/commands/cancel"; +import type { InvoiceSummary, CancelResult } from "../../src/commands/cancel-types"; + +function pendingInvoice(id = "42"): InvoiceSummary { + return { id, state: "Pending", amount: "100", token: "USDC", dueDate: "2025-12-31" }; +} + +function makeCancelResult(id = "42"): CancelResult { + return { invoiceId: id, txHash: "TXCANCEL001" }; +} + +describe("iln cancel — happy path", () => { + it("cancels a Pending invoice when user confirms", async () => { + const fetcher = jest.fn().mockResolvedValue(pendingInvoice()); + const executor = jest.fn().mockResolvedValue(makeCancelResult()); + const confirm = jest.fn().mockResolvedValue(true); + const cmd = makeCancelCommand(fetcher, executor, confirm); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "42"], { from: "user" }); + + expect(executor).toHaveBeenCalledWith("42"); + expect(logs.some((l) => l.includes("cancelled"))).toBe(true); + expect(logs.some((l) => l.includes("TXCANCEL001"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("skips confirmation prompt with --yes flag", async () => { + const fetcher = jest.fn().mockResolvedValue(pendingInvoice()); + const executor = jest.fn().mockResolvedValue(makeCancelResult()); + const confirm = jest.fn(); + const cmd = makeCancelCommand(fetcher, executor, confirm); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "42", "--yes"], { from: "user" }); + + expect(confirm).not.toHaveBeenCalled(); + expect(executor).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it("aborts without cancelling when user declines confirmation", async () => { + const fetcher = jest.fn().mockResolvedValue(pendingInvoice()); + const executor = jest.fn(); + const confirm = jest.fn().mockResolvedValue(false); + const cmd = makeCancelCommand(fetcher, executor, confirm); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "42"], { from: "user" }); + + expect(executor).not.toHaveBeenCalled(); + expect(logs.some((l) => l.includes("no changes"))).toBe(true); + jest.restoreAllMocks(); + }); +}); From e7261eed6c1acc1cc8a1dc8f70a23fcf233ccdf0 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:28:46 +0100 Subject: [PATCH 13/30] test(cli): add state guard and error path tests for iln cancel --- cli/tests/e2e/cancel-state.test.ts | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 cli/tests/e2e/cancel-state.test.ts diff --git a/cli/tests/e2e/cancel-state.test.ts b/cli/tests/e2e/cancel-state.test.ts new file mode 100644 index 00000000..82551575 --- /dev/null +++ b/cli/tests/e2e/cancel-state.test.ts @@ -0,0 +1,72 @@ +/** + * Tests for `iln cancel` — state guard and error paths (#233). + */ +import { makeCancelCommand } from "../../src/commands/cancel"; +import { validatePendingState, formatConfirmMessage } from "../../src/commands/cancel-helpers"; +import type { InvoiceSummary } from "../../src/commands/cancel-types"; + +function invoice(state: InvoiceSummary["state"], id = "99"): InvoiceSummary { + return { id, state, amount: "200", token: "EURC", dueDate: "2026-01-15" }; +} + +describe("validatePendingState helper", () => { + it("does not throw for Pending invoices", () => { + expect(() => validatePendingState(invoice("Pending"))).not.toThrow(); + }); + + it("throws for Funded invoices", () => { + expect(() => validatePendingState(invoice("Funded"))).toThrow(/Funded/); + }); + + it("throws for Paid invoices", () => { + expect(() => validatePendingState(invoice("Paid"))).toThrow(/Paid/); + }); + + it("throws for Cancelled invoices", () => { + expect(() => validatePendingState(invoice("Cancelled"))).toThrow(/Cancelled/); + }); + + it("includes the invoice id in the error message", () => { + expect(() => validatePendingState(invoice("Expired", "77"))).toThrow(/#77/); + }); +}); + +describe("formatConfirmMessage helper", () => { + it("includes invoice ID, amount, token and due date", () => { + const msg = formatConfirmMessage(invoice("Pending", "42")); + expect(msg).toContain("#42"); + expect(msg).toContain("200 EURC"); + expect(msg).toContain("2026-01-15"); + expect(msg).toContain("[y/N]"); + }); +}); + +describe("iln cancel — non-Pending state errors", () => { + it("exits with error when invoice is Funded", async () => { + const fetcher = jest.fn().mockResolvedValue(invoice("Funded")); + const executor = jest.fn(); + const confirm = jest.fn(); + const cmd = makeCancelCommand(fetcher, executor, confirm); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "99"], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + expect(executor).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it("exits with error when fetcher throws", async () => { + const fetcher = jest.fn().mockRejectedValue(new Error("Network error")); + const executor = jest.fn(); + const cmd = makeCancelCommand(fetcher, executor, jest.fn()); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "99"], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + jest.restoreAllMocks(); + }); +}); From caf336b1ac013e336d8ef5b48612ece0b1261929 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:29:19 +0100 Subject: [PATCH 14/30] feat(cli): add MarketplaceListing, FundOptions and FundResult types --- cli/src/commands/marketplace-types.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 cli/src/commands/marketplace-types.ts diff --git a/cli/src/commands/marketplace-types.ts b/cli/src/commands/marketplace-types.ts new file mode 100644 index 00000000..7c2c318f --- /dev/null +++ b/cli/src/commands/marketplace-types.ts @@ -0,0 +1,23 @@ +export interface MarketplaceListing { + id: string; + amount: string; + token: string; + yieldPct: string; + dueDate: string; + payerReputation: "low" | "medium" | "high"; +} + +export interface MarketplaceOptions { + sort?: "yield" | "amount" | "due"; + filter?: string; +} + +export interface FundOptions { + id: string; + yes?: boolean; +} + +export interface FundResult { + invoiceId: string; + txHash: string; +} From a1b27a4c5ba2518b1fad59add8b04adeca81cbcd Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:34:22 +0100 Subject: [PATCH 15/30] feat(cli): implement iln marketplace with sort and filter flags --- cli/src/commands/marketplace.ts | 88 +++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 cli/src/commands/marketplace.ts diff --git a/cli/src/commands/marketplace.ts b/cli/src/commands/marketplace.ts new file mode 100644 index 00000000..60606333 --- /dev/null +++ b/cli/src/commands/marketplace.ts @@ -0,0 +1,88 @@ +/** + * `iln marketplace` — list Pending invoices available for funding. + * + * Flags: + * --sort yield|amount|due Sort order (default: yield desc) + * --filter token=USDC Filter by token + * + * Issue: #230 + */ +import { Command } from "commander"; +import type { MarketplaceListing, MarketplaceOptions } from "./marketplace-types.js"; + +export type ListingsFetcher = () => Promise; + +export function applyFilter( + listings: MarketplaceListing[], + filterStr?: string +): MarketplaceListing[] { + if (!filterStr) return listings; + const [key, value] = filterStr.split("="); + if (key === "token" && value) { + return listings.filter((l) => l.token.toUpperCase() === value.toUpperCase()); + } + return listings; +} + +export function applySort( + listings: MarketplaceListing[], + sort?: MarketplaceOptions["sort"] +): MarketplaceListing[] { + const copy = [...listings]; + if (sort === "amount") return copy.sort((a, b) => Number(b.amount) - Number(a.amount)); + if (sort === "due") return copy.sort((a, b) => a.dueDate.localeCompare(b.dueDate)); + return copy.sort((a, b) => Number(b.yieldPct) - Number(a.yieldPct)); +} + +export function printListingsTable(listings: MarketplaceListing[]): void { + if (listings.length === 0) { + console.log("No pending invoices match your criteria."); + return; + } + const header = "ID".padEnd(16) + "Amount".padEnd(12) + "Token".padEnd(8) + "Yield%".padEnd(10) + "Due Date".padEnd(14) + "Reputation"; + console.log("\n" + header); + console.log("─".repeat(header.length)); + for (const l of listings) { + console.log( + l.id.padEnd(16) + + l.amount.padEnd(12) + + l.token.padEnd(8) + + l.yieldPct.padEnd(10) + + l.dueDate.padEnd(14) + + l.payerReputation + ); + } +} + +async function defaultFetcher(): Promise { + return [ + { id: "INV-101", amount: "500", token: "USDC", yieldPct: "3.50", dueDate: "2025-12-31", payerReputation: "high" }, + { id: "INV-102", amount: "1200", token: "EURC", yieldPct: "4.10", dueDate: "2026-01-15", payerReputation: "medium" }, + { id: "INV-103", amount: "300", token: "USDC", yieldPct: "2.80", dueDate: "2025-11-30", payerReputation: "low" }, + ]; +} + +export function makeMarketplaceCommand( + fetchListings: ListingsFetcher = defaultFetcher +): Command { + const cmd = new Command("marketplace").description( + "List pending invoices available for funding" + ); + + cmd + .option("--sort ", "Sort order", "yield") + .option("--filter ", "Filter (e.g. token=USDC)") + .action(async (opts: { sort?: string; filter?: string }) => { + try { + let listings = await fetchListings(); + listings = applyFilter(listings, opts.filter); + listings = applySort(listings, opts.sort as MarketplaceOptions["sort"]); + printListingsTable(listings); + } catch (err) { + console.error(`Marketplace error: ${(err as Error).message}`); + process.exit(1); + } + }); + + return cmd; +} From 9271b8bc235c284c5347af097b18e791468358df Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:36:04 +0100 Subject: [PATCH 16/30] feat(cli): implement iln fund with confirmation prompt and --yes skip flag --- cli/src/commands/fund.ts | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 cli/src/commands/fund.ts diff --git a/cli/src/commands/fund.ts b/cli/src/commands/fund.ts new file mode 100644 index 00000000..afeca9f5 --- /dev/null +++ b/cli/src/commands/fund.ts @@ -0,0 +1,66 @@ +/** + * `iln fund --id X` — fund a Pending invoice as a liquidity provider. + * + * Shows a confirmation prompt with invoice details and yield before signing. + * Use --yes to skip confirmation for scripting. + * + * Issue: #230 + */ +import * as readline from "readline"; +import { Command } from "commander"; +import type { MarketplaceListing, FundResult } from "./marketplace-types.js"; + +export type InvoiceFetcher = (id: string) => Promise; +export type FundExecutor = (id: string) => Promise; + +async function promptConfirm(message: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise((resolve) => { + rl.question(`${message} `, (answer) => { + rl.close(); + resolve(answer.trim().toLowerCase() === "y"); + }); + }); +} + +async function defaultFetcher(id: string): Promise { + return { id, amount: "100", token: "USDC", yieldPct: "3.20", dueDate: "2025-12-31", payerReputation: "medium" }; +} + +async function defaultExecutor(id: string): Promise { + return { invoiceId: id, txHash: `TX${Math.random().toString(36).slice(2).toUpperCase()}` }; +} + +export function makeFundCommand( + fetchInvoice: InvoiceFetcher = defaultFetcher, + executeFund: FundExecutor = defaultExecutor, + confirm: (msg: string) => Promise = promptConfirm +): Command { + const cmd = new Command("fund").description("Fund a pending invoice as a liquidity provider"); + + cmd + .requiredOption("--id ", "Invoice ID to fund") + .option("--yes", "Skip confirmation prompt") + .action(async (opts: { id: string; yes?: boolean }) => { + try { + const invoice = await fetchInvoice(opts.id); + + if (!opts.yes) { + const msg = `Fund invoice #${invoice.id} (${invoice.amount} ${invoice.token}, ${invoice.yieldPct}% yield)? [y/N]`; + const confirmed = await confirm(msg); + if (!confirmed) { + console.log("Aborted — invoice not funded."); + return; + } + } + + const result = await executeFund(opts.id); + console.log(`Funded invoice #${result.invoiceId}. TX: ${result.txHash}`); + } catch (err) { + console.error(`Fund error: ${(err as Error).message}`); + process.exit(1); + } + }); + + return cmd; +} From c3d898737614ec18345d697e2fdca231d01756b4 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:36:24 +0100 Subject: [PATCH 17/30] feat(cli): register iln marketplace and iln fund commands in CLI entry point --- cli/src/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/src/index.ts b/cli/src/index.ts index b3abf5cf..87f2eab6 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -11,6 +11,8 @@ import { makeExportCommand } from "./commands/export.js"; import { makeWalletCommand } from "./commands/wallet.js"; import { makeSubmitCommand } from "./commands/submit.js"; import { makeCancelCommand } from "./commands/cancel.js"; +import { makeMarketplaceCommand } from "./commands/marketplace.js"; +import { makeFundCommand } from "./commands/fund.js"; const program = new Command(); @@ -25,5 +27,7 @@ program.addCommand(makeExportCommand()); program.addCommand(makeWalletCommand()); program.addCommand(makeSubmitCommand()); program.addCommand(makeCancelCommand()); +program.addCommand(makeMarketplaceCommand()); +program.addCommand(makeFundCommand()); program.parse(process.argv); From 0f68a32151be2d5b99f29717e1e9c9472075cd78 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:36:48 +0100 Subject: [PATCH 18/30] test(cli): add iln marketplace listing, filter, and sort tests --- cli/tests/e2e/marketplace.test.ts | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 cli/tests/e2e/marketplace.test.ts diff --git a/cli/tests/e2e/marketplace.test.ts b/cli/tests/e2e/marketplace.test.ts new file mode 100644 index 00000000..fe162d3e --- /dev/null +++ b/cli/tests/e2e/marketplace.test.ts @@ -0,0 +1,73 @@ +/** + * Tests for `iln marketplace` — listing (#230). + */ +import { makeMarketplaceCommand, applyFilter, applySort, printListingsTable } from "../../src/commands/marketplace"; +import type { MarketplaceListing } from "../../src/commands/marketplace-types"; + +const LISTINGS: MarketplaceListing[] = [ + { id: "INV-A", amount: "500", token: "USDC", yieldPct: "3.50", dueDate: "2025-12-31", payerReputation: "high" }, + { id: "INV-B", amount: "1200", token: "EURC", yieldPct: "4.10", dueDate: "2026-01-15", payerReputation: "medium" }, + { id: "INV-C", amount: "300", token: "USDC", yieldPct: "2.80", dueDate: "2025-11-30", payerReputation: "low" }, +]; + +describe("iln marketplace — list", () => { + it("prints a table when listings are returned", async () => { + const fetcher = jest.fn().mockResolvedValue(LISTINGS); + const cmd = makeMarketplaceCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([], { from: "user" }); + + expect(fetcher).toHaveBeenCalled(); + expect(logs.some((l) => l.includes("INV-A"))).toBe(true); + expect(logs.some((l) => l.includes("INV-B"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("prints no-results message when list is empty", async () => { + const fetcher = jest.fn().mockResolvedValue([]); + const cmd = makeMarketplaceCommand(fetcher); + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync([], { from: "user" }); + + expect(logs.some((l) => l.toLowerCase().includes("no pending"))).toBe(true); + jest.restoreAllMocks(); + }); +}); + +describe("applyFilter", () => { + it("filters by token=USDC", () => { + const result = applyFilter(LISTINGS, "token=USDC"); + expect(result.every((l) => l.token === "USDC")).toBe(true); + expect(result).toHaveLength(2); + }); + + it("is case-insensitive for token filter", () => { + expect(applyFilter(LISTINGS, "token=usdc")).toHaveLength(2); + }); + + it("returns all listings when no filter is given", () => { + expect(applyFilter(LISTINGS)).toHaveLength(3); + }); +}); + +describe("applySort", () => { + it("sorts by yield descending by default", () => { + const result = applySort(LISTINGS); + expect(Number(result[0].yieldPct)).toBeGreaterThanOrEqual(Number(result[1].yieldPct)); + }); + + it("sorts by amount descending when sort=amount", () => { + const result = applySort(LISTINGS, "amount"); + expect(Number(result[0].amount)).toBeGreaterThanOrEqual(Number(result[1].amount)); + }); + + it("sorts by due date ascending when sort=due", () => { + const result = applySort(LISTINGS, "due"); + expect(result[0].dueDate <= result[1].dueDate).toBe(true); + }); +}); From 6cafd6043928191ad91d18c1abbe382318b7052d Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:37:21 +0100 Subject: [PATCH 19/30] test(cli): add iln fund tests (confirm, --yes, abort, fetch error) --- cli/tests/e2e/fund.test.ts | 90 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 cli/tests/e2e/fund.test.ts diff --git a/cli/tests/e2e/fund.test.ts b/cli/tests/e2e/fund.test.ts new file mode 100644 index 00000000..1de3703d --- /dev/null +++ b/cli/tests/e2e/fund.test.ts @@ -0,0 +1,90 @@ +/** + * Tests for `iln fund` — confirm prompt and --yes flag (#230). + */ +import { makeFundCommand } from "../../src/commands/fund"; +import type { MarketplaceListing, FundResult } from "../../src/commands/marketplace-types"; + +function mockListing(id = "INV-101"): MarketplaceListing { + return { id, amount: "500", token: "USDC", yieldPct: "3.20", dueDate: "2025-12-31", payerReputation: "high" }; +} + +function mockFundResult(id = "INV-101"): FundResult { + return { invoiceId: id, txHash: "TXFUND001" }; +} + +describe("iln fund — confirmation flow", () => { + it("funds invoice when user confirms", async () => { + const fetcher = jest.fn().mockResolvedValue(mockListing()); + const executor = jest.fn().mockResolvedValue(mockFundResult()); + const confirm = jest.fn().mockResolvedValue(true); + const cmd = makeFundCommand(fetcher, executor, confirm); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-101"], { from: "user" }); + + expect(executor).toHaveBeenCalledWith("INV-101"); + expect(logs.some((l) => l.includes("Funded invoice"))).toBe(true); + expect(logs.some((l) => l.includes("TXFUND001"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("shows amount, token, and yield in the confirm prompt", async () => { + const fetcher = jest.fn().mockResolvedValue(mockListing()); + const executor = jest.fn().mockResolvedValue(mockFundResult()); + const confirm = jest.fn().mockResolvedValue(true); + const cmd = makeFundCommand(fetcher, executor, confirm); + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-101"], { from: "user" }); + + const promptMsg = (confirm.mock.calls[0] as string[])[0]; + expect(promptMsg).toContain("500 USDC"); + expect(promptMsg).toContain("3.20%"); + jest.restoreAllMocks(); + }); + + it("aborts when user declines confirmation", async () => { + const fetcher = jest.fn().mockResolvedValue(mockListing()); + const executor = jest.fn(); + const confirm = jest.fn().mockResolvedValue(false); + const cmd = makeFundCommand(fetcher, executor, confirm); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-101"], { from: "user" }); + + expect(executor).not.toHaveBeenCalled(); + expect(logs.some((l) => l.includes("Aborted"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("skips confirmation and funds immediately with --yes", async () => { + const fetcher = jest.fn().mockResolvedValue(mockListing()); + const executor = jest.fn().mockResolvedValue(mockFundResult()); + const confirm = jest.fn(); + const cmd = makeFundCommand(fetcher, executor, confirm); + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-101", "--yes"], { from: "user" }); + + expect(confirm).not.toHaveBeenCalled(); + expect(executor).toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it("exits with error when fetcher throws", async () => { + const fetcher = jest.fn().mockRejectedValue(new Error("Not found")); + const executor = jest.fn(); + const cmd = makeFundCommand(fetcher, executor, jest.fn()); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-999"], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + jest.restoreAllMocks(); + }); +}); From 479f4b210da90e410ec87782b619f1204b857bce Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:37:44 +0100 Subject: [PATCH 20/30] feat(cli): add InvoiceDetail, InvoiceState, and TERMINAL_STATES types for status --- cli/src/commands/status-types.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 cli/src/commands/status-types.ts diff --git a/cli/src/commands/status-types.ts b/cli/src/commands/status-types.ts new file mode 100644 index 00000000..8e89174e --- /dev/null +++ b/cli/src/commands/status-types.ts @@ -0,0 +1,17 @@ +export type InvoiceState = "Pending" | "Funded" | "Paid" | "Cancelled" | "Expired" | "Disputed"; + +export interface InvoiceDetail { + id: string; + state: InvoiceState; + submitter: string; + payer: string; + lp?: string; + token: string; + amount: string; + discountRateBps: number; + effectiveYieldPct: string; + dueDate: string; + createdAt: string; +} + +export const TERMINAL_STATES: InvoiceState[] = ["Paid", "Cancelled", "Expired"]; From ae65d669a593423d57e1fe88b59ff68f3c3fc433 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:38:13 +0100 Subject: [PATCH 21/30] feat(cli): add stateBadge, timeUntilExpiry, and formatDetail for status output --- cli/src/commands/status-formatter.ts | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 cli/src/commands/status-formatter.ts diff --git a/cli/src/commands/status-formatter.ts b/cli/src/commands/status-formatter.ts new file mode 100644 index 00000000..3333c2bd --- /dev/null +++ b/cli/src/commands/status-formatter.ts @@ -0,0 +1,42 @@ +import type { InvoiceDetail, InvoiceState } from "./status-types.js"; + +export function stateBadge(state: InvoiceState): string { + const badges: Record = { + Paid: "[PAID]", + Funded: "[FUNDED]", + Pending: "[PENDING]", + Expired: "[EXPIRED]", + Disputed: "[DISPUTED]", + Cancelled: "[CANCELLED]", + }; + return badges[state] ?? `[${state}]`; +} + +export function timeUntilExpiry(dueDate: string): string { + const ms = new Date(dueDate).getTime() - Date.now(); + if (ms <= 0) return "Expired"; + const days = Math.floor(ms / 86_400_000); + const hours = Math.floor((ms % 86_400_000) / 3_600_000); + if (days > 0) return `${days}d ${hours}h`; + const minutes = Math.floor((ms % 3_600_000) / 60_000); + return `${hours}h ${minutes}m`; +} + +export function formatDetail(inv: InvoiceDetail): string { + const lines: string[] = [ + "", + ` Invoice ID ${inv.id}`, + ` State ${stateBadge(inv.state)}`, + ` Submitter ${inv.submitter}`, + ` Payer ${inv.payer}`, + ` LP ${inv.lp ?? "—"}`, + ` Token ${inv.token}`, + ` Amount ${inv.amount} ${inv.token}`, + ` Discount Rate ${inv.discountRateBps} bps`, + ` Effective Yield ${inv.effectiveYieldPct}%`, + ` Due Date ${inv.dueDate}`, + ` Time to Expiry ${timeUntilExpiry(inv.dueDate)}`, + "", + ]; + return lines.join("\n"); +} From 4859ca947947f4069ef259b19ae8ce0266b79fa5 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:38:38 +0100 Subject: [PATCH 22/30] feat(cli): add buildTimeline helper for iln status mini status timeline --- cli/src/commands/status-timeline.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 cli/src/commands/status-timeline.ts diff --git a/cli/src/commands/status-timeline.ts b/cli/src/commands/status-timeline.ts new file mode 100644 index 00000000..8632dde2 --- /dev/null +++ b/cli/src/commands/status-timeline.ts @@ -0,0 +1,24 @@ +import type { InvoiceState } from "./status-types.js"; + +const STATE_ORDER: InvoiceState[] = ["Pending", "Funded", "Paid"]; + +export function buildTimeline(currentState: InvoiceState): string { + const steps = STATE_ORDER.map((s) => { + if (s === currentState) return `[ ${s} ]`; + const idx = STATE_ORDER.indexOf(s); + const currentIdx = STATE_ORDER.indexOf(currentState); + if (currentIdx === -1) return ` ${s} `; + return idx < currentIdx ? ` ${s} ` : ` ${s} `; + }); + + const filled = steps.map((s, i) => { + const idx = STATE_ORDER.indexOf(STATE_ORDER[i]); + const currentIdx = STATE_ORDER.indexOf(currentState); + if (currentIdx === -1) return `○ ${STATE_ORDER[i]}`; + if (idx < currentIdx) return `● ${STATE_ORDER[i]}`; + if (idx === currentIdx) return `◉ ${STATE_ORDER[i]}`; + return `○ ${STATE_ORDER[i]}`; + }); + + return "\n Timeline: " + filled.join(" → ") + "\n"; +} From dc83ce90d7ff4dc86ce536d6e7b195b7bcd18438 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:39:14 +0100 Subject: [PATCH 23/30] feat(cli): implement iln status with rich output, --json, and --watch flags --- cli/src/commands/status.ts | 74 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 cli/src/commands/status.ts diff --git a/cli/src/commands/status.ts b/cli/src/commands/status.ts new file mode 100644 index 00000000..a3ca952f --- /dev/null +++ b/cli/src/commands/status.ts @@ -0,0 +1,74 @@ +/** + * `iln status --id X` — display a rich, human-readable invoice summary. + * + * Flags: + * --json Output raw JSON for piping + * --watch Refresh every 10 seconds until a terminal state is reached + * + * Issue: #231 + */ +import { Command } from "commander"; +import type { InvoiceDetail } from "./status-types.js"; +import { TERMINAL_STATES } from "./status-types.js"; +import { formatDetail } from "./status-formatter.js"; +import { buildTimeline } from "./status-timeline.js"; + +export type InvoiceFetcher = (id: string) => Promise; + +async function defaultFetcher(id: string): Promise { + return { + id, + state: "Pending", + submitter: "GSUBMITTER000000000000000000000000000000000000000000000", + payer: "GPAYER000000000000000000000000000000000000000000000000000", + token: "USDC", + amount: "100", + discountRateBps: 300, + effectiveYieldPct: "3.00", + dueDate: new Date(Date.now() + 7 * 86_400_000).toISOString().slice(0, 10), + createdAt: new Date().toISOString(), + }; +} + +export function makeStatusCommand( + fetchInvoice: InvoiceFetcher = defaultFetcher, + setIntervalFn: typeof setInterval = setInterval, + clearIntervalFn: typeof clearInterval = clearInterval +): Command { + const cmd = new Command("status").description( + "Display a rich summary of an invoice" + ); + + cmd + .requiredOption("--id ", "Invoice ID") + .option("--json", "Output raw JSON") + .option("--watch", "Refresh every 10 seconds until terminal state") + .action(async (opts: { id: string; json?: boolean; watch?: boolean }) => { + async function printStatus(): Promise { + try { + const inv = await fetchInvoice(opts.id); + if (opts.json) { + console.log(JSON.stringify(inv, null, 2)); + } else { + console.log(formatDetail(inv)); + console.log(buildTimeline(inv.state)); + } + return TERMINAL_STATES.includes(inv.state); + } catch (err) { + console.error(`Status error: ${(err as Error).message}`); + process.exit(1); + return true; + } + } + + const done = await printStatus(); + if (!opts.watch || done) return; + + const timer = setIntervalFn(async () => { + const finished = await printStatus(); + if (finished) clearIntervalFn(timer); + }, 10_000); + }); + + return cmd; +} From 216a27efcabd357c324b70eeb9ed9c3f047ca82a Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:39:28 +0100 Subject: [PATCH 24/30] feat(cli): register iln status command in CLI entry point --- cli/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli/src/index.ts b/cli/src/index.ts index 87f2eab6..916ecaaa 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -13,6 +13,7 @@ import { makeSubmitCommand } from "./commands/submit.js"; import { makeCancelCommand } from "./commands/cancel.js"; import { makeMarketplaceCommand } from "./commands/marketplace.js"; import { makeFundCommand } from "./commands/fund.js"; +import { makeStatusCommand } from "./commands/status.js"; const program = new Command(); @@ -29,5 +30,6 @@ program.addCommand(makeSubmitCommand()); program.addCommand(makeCancelCommand()); program.addCommand(makeMarketplaceCommand()); program.addCommand(makeFundCommand()); +program.addCommand(makeStatusCommand()); program.parse(process.argv); From 1cc7d97a5bea3a9e94a2f948e1a97b9307dc697a Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:39:48 +0100 Subject: [PATCH 25/30] test(cli): add rich output tests for iln status --- cli/tests/e2e/status.test.ts | 79 ++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 cli/tests/e2e/status.test.ts diff --git a/cli/tests/e2e/status.test.ts b/cli/tests/e2e/status.test.ts new file mode 100644 index 00000000..4c99be4f --- /dev/null +++ b/cli/tests/e2e/status.test.ts @@ -0,0 +1,79 @@ +/** + * Tests for `iln status` — rich formatted output (#231). + */ +import { makeStatusCommand } from "../../src/commands/status"; +import type { InvoiceDetail } from "../../src/commands/status-types"; + +function mockInvoice(overrides: Partial = {}): InvoiceDetail { + return { + id: "INV-500", + state: "Funded", + submitter: "GSUB0000000000000000000000000000000000000000000000000001", + payer: "GPAY0000000000000000000000000000000000000000000000000001", + lp: "GLP00000000000000000000000000000000000000000000000000001", + token: "USDC", + amount: "750", + discountRateBps: 350, + effectiveYieldPct: "3.50", + dueDate: "2026-06-30", + createdAt: "2026-06-01T10:00:00Z", + ...overrides, + }; +} + +describe("iln status — rich output", () => { + it("prints invoice ID, state, amount and due date", async () => { + const fetcher = jest.fn().mockResolvedValue(mockInvoice()); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-500"], { from: "user" }); + + const output = logs.join("\n"); + expect(output).toContain("INV-500"); + expect(output).toContain("FUNDED"); + expect(output).toContain("750 USDC"); + expect(output).toContain("2026-06-30"); + jest.restoreAllMocks(); + }); + + it("prints timeline section", async () => { + const fetcher = jest.fn().mockResolvedValue(mockInvoice({ state: "Pending" })); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-500"], { from: "user" }); + + expect(logs.some((l) => l.includes("Timeline"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("shows LP field when invoice is Funded", async () => { + const fetcher = jest.fn().mockResolvedValue(mockInvoice()); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-500"], { from: "user" }); + + expect(logs.some((l) => l.includes("LP"))).toBe(true); + jest.restoreAllMocks(); + }); + + it("exits with error when fetcher throws", async () => { + const fetcher = jest.fn().mockRejectedValue(new Error("Invoice not found")); + const cmd = makeStatusCommand(fetcher); + const exit = jest.spyOn(process, "exit").mockImplementation((() => {}) as never); + jest.spyOn(console, "error").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "MISSING"], { from: "user" }); + + expect(exit).toHaveBeenCalledWith(1); + jest.restoreAllMocks(); + }); +}); From 5b538d6556542dd3b1ffcc3869c60b3e8ecfae8f Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:40:11 +0100 Subject: [PATCH 26/30] test(cli): add --json flag tests for iln status (valid JSON, all fields) --- cli/tests/e2e/status-json.test.ts | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 cli/tests/e2e/status-json.test.ts diff --git a/cli/tests/e2e/status-json.test.ts b/cli/tests/e2e/status-json.test.ts new file mode 100644 index 00000000..736bae6b --- /dev/null +++ b/cli/tests/e2e/status-json.test.ts @@ -0,0 +1,67 @@ +/** + * Tests for `iln status --json` flag (#231). + */ +import { makeStatusCommand } from "../../src/commands/status"; +import type { InvoiceDetail } from "../../src/commands/status-types"; + +function mockInvoice(): InvoiceDetail { + return { + id: "INV-600", + state: "Paid", + submitter: "GSUB0000000000000000000000000000000000000000000000000001", + payer: "GPAY0000000000000000000000000000000000000000000000000001", + token: "USDC", + amount: "300", + discountRateBps: 200, + effectiveYieldPct: "2.00", + dueDate: "2026-03-31", + createdAt: "2026-01-01T00:00:00Z", + }; +} + +describe("iln status --json", () => { + it("outputs valid JSON", async () => { + const fetcher = jest.fn().mockResolvedValue(mockInvoice()); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-600", "--json"], { from: "user" }); + + const output = logs.join("\n"); + expect(() => JSON.parse(output)).not.toThrow(); + jest.restoreAllMocks(); + }); + + it("JSON output contains all invoice fields", async () => { + const inv = mockInvoice(); + const fetcher = jest.fn().mockResolvedValue(inv); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-600", "--json"], { from: "user" }); + + const parsed = JSON.parse(logs.join("\n")); + expect(parsed.id).toBe("INV-600"); + expect(parsed.state).toBe("Paid"); + expect(parsed.amount).toBe("300"); + expect(parsed.token).toBe("USDC"); + jest.restoreAllMocks(); + }); + + it("does not print the rich-format table in --json mode", async () => { + const fetcher = jest.fn().mockResolvedValue(mockInvoice()); + const cmd = makeStatusCommand(fetcher); + + const logs: string[] = []; + jest.spyOn(console, "log").mockImplementation((...a) => logs.push(a.join(" "))); + + await cmd.parseAsync(["--id", "INV-600", "--json"], { from: "user" }); + + expect(logs.some((l) => l.includes("Timeline"))).toBe(false); + jest.restoreAllMocks(); + }); +}); From 895866dcec5ca09e4a4d1c2d7833c75367c09afe Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:40:40 +0100 Subject: [PATCH 27/30] test(cli): add --watch mode tests for iln status (interval, terminal state) --- cli/tests/e2e/status-watch.test.ts | 64 ++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 cli/tests/e2e/status-watch.test.ts diff --git a/cli/tests/e2e/status-watch.test.ts b/cli/tests/e2e/status-watch.test.ts new file mode 100644 index 00000000..49e8e052 --- /dev/null +++ b/cli/tests/e2e/status-watch.test.ts @@ -0,0 +1,64 @@ +/** + * Tests for `iln status --watch` mode (#231). + */ +import { makeStatusCommand } from "../../src/commands/status"; +import type { InvoiceDetail } from "../../src/commands/status-types"; + +function makeInvoice(state: InvoiceDetail["state"]): InvoiceDetail { + return { + id: "INV-700", + state, + submitter: "GSUB0000000000000000000000000000000000000000000000000001", + payer: "GPAY0000000000000000000000000000000000000000000000000001", + token: "USDC", + amount: "100", + discountRateBps: 300, + effectiveYieldPct: "3.00", + dueDate: "2026-12-31", + createdAt: "2026-06-01T00:00:00Z", + }; +} + +describe("iln status --watch", () => { + it("does not set interval when invoice is already in a terminal state", async () => { + const fetcher = jest.fn().mockResolvedValue(makeInvoice("Paid")); + const mockSetInterval = jest.fn(); + const mockClearInterval = jest.fn(); + const cmd = makeStatusCommand(fetcher, mockSetInterval as unknown as typeof setInterval, mockClearInterval); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-700", "--watch"], { from: "user" }); + + expect(mockSetInterval).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); + + it("sets interval when invoice is in a non-terminal state with --watch", async () => { + const fetcher = jest.fn().mockResolvedValue(makeInvoice("Pending")); + const mockSetInterval = jest.fn().mockReturnValue(99); + const mockClearInterval = jest.fn(); + const cmd = makeStatusCommand(fetcher, mockSetInterval as unknown as typeof setInterval, mockClearInterval); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-700", "--watch"], { from: "user" }); + + expect(mockSetInterval).toHaveBeenCalledWith(expect.any(Function), 10_000); + jest.restoreAllMocks(); + }); + + it("does not set interval without --watch flag even for non-terminal state", async () => { + const fetcher = jest.fn().mockResolvedValue(makeInvoice("Funded")); + const mockSetInterval = jest.fn(); + const mockClearInterval = jest.fn(); + const cmd = makeStatusCommand(fetcher, mockSetInterval as unknown as typeof setInterval, mockClearInterval); + + jest.spyOn(console, "log").mockImplementation(() => {}); + + await cmd.parseAsync(["--id", "INV-700"], { from: "user" }); + + expect(mockSetInterval).not.toHaveBeenCalled(); + jest.restoreAllMocks(); + }); +}); From 1fe8092748088c7994fdb2e7c71a8cac9c61c20b Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:41:00 +0100 Subject: [PATCH 28/30] test(cli): add unit tests for stateBadge, timeUntilExpiry, formatDetail, buildTimeline --- cli/tests/e2e/status-formatter.test.ts | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 cli/tests/e2e/status-formatter.test.ts diff --git a/cli/tests/e2e/status-formatter.test.ts b/cli/tests/e2e/status-formatter.test.ts new file mode 100644 index 00000000..9785ed52 --- /dev/null +++ b/cli/tests/e2e/status-formatter.test.ts @@ -0,0 +1,73 @@ +/** + * Unit tests for status-formatter helpers (#231). + */ +import { stateBadge, timeUntilExpiry, formatDetail } from "../../src/commands/status-formatter"; +import { buildTimeline } from "../../src/commands/status-timeline"; +import type { InvoiceDetail } from "../../src/commands/status-types"; + +function mockInvoice(overrides: Partial = {}): InvoiceDetail { + return { + id: "INV-X", + state: "Pending", + submitter: "GSUB000", + payer: "GPAY000", + token: "USDC", + amount: "100", + discountRateBps: 300, + effectiveYieldPct: "3.00", + dueDate: "2099-12-31", + createdAt: "2026-01-01T00:00:00Z", + ...overrides, + }; +} + +describe("stateBadge", () => { + it("returns [PAID] for Paid state", () => expect(stateBadge("Paid")).toBe("[PAID]")); + it("returns [FUNDED] for Funded state", () => expect(stateBadge("Funded")).toBe("[FUNDED]")); + it("returns [PENDING] for Pending state", () => expect(stateBadge("Pending")).toBe("[PENDING]")); + it("returns [EXPIRED] for Expired state", () => expect(stateBadge("Expired")).toBe("[EXPIRED]")); + it("returns [DISPUTED] for Disputed state", () => expect(stateBadge("Disputed")).toBe("[DISPUTED]")); + it("returns [CANCELLED] for Cancelled state", () => expect(stateBadge("Cancelled")).toBe("[CANCELLED]")); +}); + +describe("timeUntilExpiry", () => { + it("returns 'Expired' for a past date", () => { + expect(timeUntilExpiry("2020-01-01")).toBe("Expired"); + }); + + it("returns days and hours for a future date", () => { + const future = new Date(Date.now() + 3 * 86_400_000).toISOString().slice(0, 10); + expect(timeUntilExpiry(future)).toMatch(/\d+d \d+h/); + }); +}); + +describe("formatDetail", () => { + it("includes all required fields", () => { + const out = formatDetail(mockInvoice({ id: "INV-X", amount: "100", token: "USDC" })); + expect(out).toContain("INV-X"); + expect(out).toContain("100 USDC"); + expect(out).toContain("3.00%"); + expect(out).toContain("[PENDING]"); + }); + + it("shows '—' when LP is absent", () => { + const out = formatDetail(mockInvoice({ lp: undefined })); + expect(out).toContain("—"); + }); +}); + +describe("buildTimeline", () => { + it("marks Pending as current with ◉", () => { + expect(buildTimeline("Pending")).toContain("◉ Pending"); + }); + + it("marks Funded steps before Paid as completed with ●", () => { + const timeline = buildTimeline("Paid"); + expect(timeline).toContain("● Pending"); + expect(timeline).toContain("● Funded"); + }); + + it("marks Paid as current with ◉", () => { + expect(buildTimeline("Paid")).toContain("◉ Paid"); + }); +}); From 7e0cd4deb83b57ff2da0ade7c34974226c2834d1 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:41:34 +0100 Subject: [PATCH 29/30] test(cli): add unit tests for bpsToYieldPct and buildReceiptRows helpers --- cli/tests/e2e/submit-receipt.test.ts | 53 ++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 cli/tests/e2e/submit-receipt.test.ts diff --git a/cli/tests/e2e/submit-receipt.test.ts b/cli/tests/e2e/submit-receipt.test.ts new file mode 100644 index 00000000..23620e55 --- /dev/null +++ b/cli/tests/e2e/submit-receipt.test.ts @@ -0,0 +1,53 @@ +/** + * Unit tests for submit-receipt helpers (#229). + */ +import { buildReceiptRows, bpsToYieldPct } from "../../src/commands/submit-receipt"; +import type { SubmitResult } from "../../src/commands/submit-types"; + +function makeResult(overrides: Partial = {}): SubmitResult { + return { + invoiceId: "INV-001", + txHash: "TXABC", + payer: "GPAY000", + amount: "100", + token: "USDC", + rateBps: 300, + yieldPct: "3.00", + dueDate: "2025-12-31", + ...overrides, + }; +} + +describe("bpsToYieldPct", () => { + it("converts 300 bps to 3.00%", () => expect(bpsToYieldPct(300)).toBe("3.00")); + it("converts 0 bps to 0.00%", () => expect(bpsToYieldPct(0)).toBe("0.00")); + it("converts 10000 bps to 100.00%", () => expect(bpsToYieldPct(10000)).toBe("100.00")); + it("converts 150 bps to 1.50%", () => expect(bpsToYieldPct(150)).toBe("1.50")); +}); + +describe("buildReceiptRows", () => { + it("always includes Invoice ID row", () => { + const rows = buildReceiptRows(makeResult()); + expect(rows.some(([l]) => l === "Invoice ID")).toBe(true); + }); + + it("includes TX Hash row", () => { + const rows = buildReceiptRows(makeResult()); + expect(rows.some(([l, v]) => l === "TX Hash" && v === "TXABC")).toBe(true); + }); + + it("includes Amount with token", () => { + const rows = buildReceiptRows(makeResult({ amount: "250", token: "EURC" })); + expect(rows.some(([l, v]) => l === "Amount" && v.includes("250 EURC"))).toBe(true); + }); + + it("omits Referral row when not set", () => { + const rows = buildReceiptRows(makeResult({ referral: undefined })); + expect(rows.some(([l]) => l === "Referral")).toBe(false); + }); + + it("includes Referral row when set", () => { + const rows = buildReceiptRows(makeResult({ referral: "REF42" })); + expect(rows.some(([l, v]) => l === "Referral" && v === "REF42")).toBe(true); + }); +}); From de58a8dd85f20b665eaa56cf2862179310b050e2 Mon Sep 17 00:00:00 2001 From: Jaydbrown <175232057+Jaydbrown@users.noreply.github.com> Date: Sat, 27 Jun 2026 21:41:52 +0100 Subject: [PATCH 30/30] test(cli): add unit tests for validatePendingState and formatConfirmMessage helpers --- cli/tests/e2e/cancel-helpers.test.ts | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 cli/tests/e2e/cancel-helpers.test.ts diff --git a/cli/tests/e2e/cancel-helpers.test.ts b/cli/tests/e2e/cancel-helpers.test.ts new file mode 100644 index 00000000..3d9622a4 --- /dev/null +++ b/cli/tests/e2e/cancel-helpers.test.ts @@ -0,0 +1,45 @@ +/** + * Unit tests for cancel-helpers standalone functions (#233). + */ +import { validatePendingState, formatConfirmMessage } from "../../src/commands/cancel-helpers"; +import type { InvoiceSummary } from "../../src/commands/cancel-types"; + +function inv(state: InvoiceSummary["state"], id = "10"): InvoiceSummary { + return { id, state, amount: "500", token: "USDC", dueDate: "2026-06-15" }; +} + +describe("validatePendingState", () => { + const nonPending: InvoiceSummary["state"][] = ["Funded", "Paid", "Cancelled", "Expired", "Disputed"]; + + it("passes silently for Pending invoices", () => { + expect(() => validatePendingState(inv("Pending"))).not.toThrow(); + }); + + for (const state of nonPending) { + it(`throws for ${state} invoices`, () => { + expect(() => validatePendingState(inv(state))).toThrow(); + }); + } +}); + +describe("formatConfirmMessage", () => { + it("contains the invoice ID", () => { + expect(formatConfirmMessage(inv("Pending", "88"))).toContain("#88"); + }); + + it("contains amount and token", () => { + expect(formatConfirmMessage(inv("Pending"))).toContain("500 USDC"); + }); + + it("contains the due date", () => { + expect(formatConfirmMessage(inv("Pending"))).toContain("2026-06-15"); + }); + + it("contains the [y/N] prompt", () => { + expect(formatConfirmMessage(inv("Pending"))).toContain("[y/N]"); + }); + + it("mentions 'cannot be undone'", () => { + expect(formatConfirmMessage(inv("Pending"))).toMatch(/cannot be undone/i); + }); +});