From 5379f1cf10bb3fc64ee7385149f595d746002b16 Mon Sep 17 00:00:00 2001 From: Happybello365 Date: Mon, 31 Aug 2026 08:09:15 +0000 Subject: [PATCH] fix: reject expired invoices in clone preflight --- src/preflight/InvoiceCloneabilityValidator.ts | 11 +++++-- test/invoiceCloneabilityValidator.test.ts | 13 ++++++-- test/roundingAuditor.test.ts | 31 ++++++++++++++++++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/src/preflight/InvoiceCloneabilityValidator.ts b/src/preflight/InvoiceCloneabilityValidator.ts index 420754c..ac538d5 100644 --- a/src/preflight/InvoiceCloneabilityValidator.ts +++ b/src/preflight/InvoiceCloneabilityValidator.ts @@ -179,12 +179,19 @@ export class InvoiceCloneabilityValidator { const deadlineMs = invoice.deadline * 1_000; const minFutureMs = nowMs + this._options.minDeadlineBufferMs; - if (deadlineMs <= minFutureMs) { + if (deadlineMs <= nowMs) { + reports.push({ + field: "deadline", + valid: false, + reason: `Deadline (${new Date(deadlineMs).toISOString()}) is already expired as of the current ledger time (${new Date(nowMs).toISOString()}).`, + suggestedFix: "Create a new invoice or pass a newDeadline override that is set after the current ledger time.", + }); + } else if (deadlineMs <= minFutureMs) { const shortfallSec = Math.ceil((minFutureMs - deadlineMs) / 1_000); reports.push({ field: "deadline", valid: false, - reason: `Deadline (${new Date(deadlineMs).toISOString()}) is in the past or too close to now (buffer: ${this._options.minDeadlineBufferMs}ms). Shortfall: ${shortfallSec}s.`, + reason: `Deadline (${new Date(deadlineMs).toISOString()}) is too close to now (buffer: ${this._options.minDeadlineBufferMs}ms). Shortfall: ${shortfallSec}s.`, suggestedFix: `Pass a \`newDeadline\` override that is at least ${this._options.minDeadlineBufferMs / 1_000}s in the future when calling cloneInvoice().`, }); } diff --git a/test/invoiceCloneabilityValidator.test.ts b/test/invoiceCloneabilityValidator.test.ts index 21397a0..18fb442 100644 --- a/test/invoiceCloneabilityValidator.test.ts +++ b/test/invoiceCloneabilityValidator.test.ts @@ -114,6 +114,15 @@ describe("InvoiceCloneabilityValidator — status check", () => { const statusField = report.fieldReports.find((f) => f.field === "status"); expect(statusField).toBeUndefined(); }); + + it("blocks cloning an already expired invoice", async () => { + const invoice = makeInvoice({ deadline: PAST_DEADLINE }); + const validator = new InvoiceCloneabilityValidator({ rpcUrl: "https://rpc.example.com" }); + const report = await validator.validate(invoice); + + expect(report.cloneable).toBe(false); + expect(report.fieldReports.some((field) => field.field === "deadline")).toBe(true); + }); }); describe("InvoiceCloneabilityValidator — deadline check", () => { @@ -128,8 +137,8 @@ describe("InvoiceCloneabilityValidator — deadline check", () => { const deadlineField = report.fieldReports.find((f) => f.field === "deadline"); expect(deadlineField).toBeDefined(); expect(deadlineField!.valid).toBe(false); - expect(deadlineField!.reason).toMatch(/past|buffer/i); - expect(deadlineField!.suggestedFix).toMatch(/newDeadline/); + expect(deadlineField!.reason).toMatch(/expired|past|buffer/i); + expect(deadlineField!.suggestedFix).toMatch(/newDeadline|new invoice/i); }); it("passes when deadline is sufficiently in the future", async () => { diff --git a/test/roundingAuditor.test.ts b/test/roundingAuditor.test.ts index f18d350..8c2cf54 100644 --- a/test/roundingAuditor.test.ts +++ b/test/roundingAuditor.test.ts @@ -11,7 +11,12 @@ import { describe, it, expect } from "vitest"; import { auditSplitRounding, RoundingOverflowError } from "../src/invoice/rounding.js"; -import { calculateSplitAmounts, computeAmounts } from "../src/invoice/calculator.js"; +import { + calculateInvoiceBreakdown, + calculateInvoiceSubtotal, + calculateSplitAmounts, + computeAmounts, +} from "../src/invoice/calculator.js"; import type { SplitLine } from "../src/types.js"; // --------------------------------------------------------------------------- @@ -393,3 +398,27 @@ describe("calculateSplitAmounts (calculator integration)", () => { expect(() => calculateSplitAmounts(1000n, splits)).toThrow(RoundingOverflowError); }); }); + +describe("invoice subtotal and fee breakdown", () => { + it("calculates the subtotal before fees are applied", () => { + expect(calculateInvoiceSubtotal([10n, 15n, 25n, 5n])).toBe(55n); + }); + + it("returns subtotal, fee and total separately for downstream receipts", () => { + const result = calculateInvoiceBreakdown(1_000n, 250); + + expect(result.subtotal).toBe(1_000n); + expect(result.fee).toBe(25n); + expect(result.total).toBe(1_025n); + }); + + it("supports configurable rounding mode", () => { + const result = auditSplitRounding(1n, [ + { recipientId: "A", ratio: 0.5 }, + { recipientId: "B", ratio: 0.5 }, + ], { mode: "bankers" }); + + expect(sumAmounts(result.amounts)).toBe(1n); + expect(Object.values(result.amounts).sort((a, b) => Number(a - b))).toEqual([0n, 1n]); + }); +});