From 026735c1e7349224ab3530f81aaa5b524a87dc4d Mon Sep 17 00:00:00 2001 From: Henry Peters <96546584+henrypeters@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:20:38 +0000 Subject: [PATCH] feat: add getInvoiceAge and getFundingVelocity helpers (#611) Adds two derived invoice metrics to invoiceStats, both computed locally from existing Invoice fields with no network calls: - getInvoiceAge(invoice) returns { days, hours, minutes } since createdAt as a calendar-style breakdown, using Date.now() internally. - getFundingVelocity(invoice) returns stroops funded per day since creation, and 0 when createdAt is within the same second as now to avoid dividing by a zero age. Both accept createdAt as Unix seconds or milliseconds, auto-detected by magnitude (> 1e12 means ms), and treat absent, zero, negative, or non-finite values as unknown rather than as epoch 1970. Adds the optional createdAt field to Invoice, notes that populating it changes hashInvoice output, and documents how the new lifetime velocity differs from InvoiceStats.fundingVelocity. Closes #611 --- src/index.ts | 2 + src/invoiceStats.ts | 117 ++++++++++++++++ src/types.ts | 22 +++- test/invoiceStats.test.ts | 271 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 test/invoiceStats.test.ts diff --git a/src/index.ts b/src/index.ts index 5bdf5fe..285db12 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1038,6 +1038,8 @@ export type { ChannelStateFetcher, } from "./channelReconciler.js"; export { getInvoiceStats, computeInvoiceStats } from "./invoiceStats.js"; +export { getInvoiceAge, getFundingVelocity } from "./invoiceStats.js"; +export type { InvoiceAge } from "./invoiceStats.js"; export { previewSplitRules } from "./splitPreview.js"; diff --git a/src/invoiceStats.ts b/src/invoiceStats.ts index e9d8725..0e3aa5a 100644 --- a/src/invoiceStats.ts +++ b/src/invoiceStats.ts @@ -7,6 +7,123 @@ interface InvoiceStatsClient { const SECONDS_PER_DAY = 86_400; +const MS_PER_SECOND = 1_000; +const MS_PER_MINUTE = 60 * MS_PER_SECOND; +const MS_PER_HOUR = 60 * MS_PER_MINUTE; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +/** + * Any epoch value above this magnitude is already expressed in milliseconds. + * + * `1e12` ms is 2001-09-09, while `1e12` seconds is the year 33658 — so the + * threshold unambiguously separates second- and millisecond-based timestamps + * for every realistic invoice. + */ +const MS_DETECTION_THRESHOLD = 1e12; + +/** + * Normalise a `createdAt` value to epoch milliseconds. + * + * Accepts either Unix seconds or milliseconds and auto-detects the unit by + * magnitude: values greater than 1e12 are treated as milliseconds, everything + * else is treated as seconds. + * + * Non-positive values are rejected rather than interpreted: `0` is the default + * a Soroban `u64` decoder (or a partially populated payload) produces for an + * unset field, and treating it as epoch 1970 would report a ~20,000-day age + * instead of "unknown". + * + * @param timestamp - Epoch value in seconds or milliseconds. + * @returns Epoch milliseconds, or `null` when the input is not a finite, + * positive number. + */ +function toEpochMs(timestamp: number | undefined): number | null { + if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return null; + if (timestamp <= 0) return null; + return timestamp > MS_DETECTION_THRESHOLD + ? timestamp + : timestamp * MS_PER_SECOND; +} + +/** + * Milliseconds elapsed since `invoice.createdAt`, clamped at 0. + * + * Returns `null` when the invoice carries no usable `createdAt` (absent, or a + * non-positive / non-finite sentinel). + */ +function elapsedMsSinceCreation(invoice: Invoice): number | null { + const createdMs = toEpochMs(invoice.createdAt); + if (createdMs === null) return null; + const elapsed = Date.now() - createdMs; + return elapsed > 0 ? elapsed : 0; +} + +/** How old an invoice is, split into whole days, hours, and minutes. */ +export interface InvoiceAge { + /** Whole days elapsed since creation. */ + days: number; + /** Whole hours remaining after `days` (0–23). */ + hours: number; + /** Whole minutes remaining after `days` and `hours` (0–59). */ + minutes: number; +} + +/** + * Compute how long ago an invoice was created. + * + * The result is a calendar-style breakdown: `hours` is the remainder after + * whole `days`, and `minutes` is the remainder after whole `hours`. Uses + * `Date.now()` internally and performs no network calls. + * + * `invoice.createdAt` may be a Unix timestamp in seconds or in milliseconds — + * the unit is detected automatically by magnitude (`> 1e12` means ms). Invoices + * with no usable `createdAt` (absent, `0`, or negative), or with a `createdAt` + * in the future, report a zero age. + * + * @param invoice - The invoice to measure. + * @returns The elapsed age as {@link InvoiceAge}. + */ +export function getInvoiceAge(invoice: Invoice): InvoiceAge { + const elapsed = elapsedMsSinceCreation(invoice) ?? 0; + + return { + days: Math.floor(elapsed / MS_PER_DAY), + hours: Math.floor((elapsed % MS_PER_DAY) / MS_PER_HOUR), + minutes: Math.floor((elapsed % MS_PER_HOUR) / MS_PER_MINUTE), + }; +} + +/** + * Compute how fast an invoice is being funded since it was created. + * + * Derived purely from `invoice.funded` and `invoice.createdAt` — no network + * calls. `createdAt` may be in seconds or milliseconds (auto-detected by + * magnitude, `> 1e12` means ms). + * + * The returned rate is in the same base units as `invoice.funded` — stroops per + * day, **not** USDC per day. Divide by `10_000_000` before displaying a USDC + * figure. + * + * Not to be confused with {@link InvoiceStats.fundingVelocity} returned by + * {@link computeInvoiceStats}: that one is a payment-window rate (sum of + * payment amounts over the span between the first and last payment), whereas + * this is a lifetime average over the whole age of the invoice. The two + * deliberately differ for the same invoice. + * + * Returns `0` when the invoice has no usable `createdAt` (absent, `0`, or + * negative) or when `createdAt` falls within the same second as `Date.now()`, + * which guards against dividing by a zero (or negative) age. + * + * @param invoice - The invoice to measure. + * @returns Stroops funded per day since creation, or `0` for a zero age. + */ +export function getFundingVelocity(invoice: Invoice): number { + const elapsed = elapsedMsSinceCreation(invoice); + if (elapsed === null || elapsed < MS_PER_SECOND) return 0; + + return Number(invoice.funded) / (elapsed / MS_PER_DAY); +} + /** * Compute rich analytics for an invoice purely from its payment history. * diff --git a/src/types.ts b/src/types.ts index eb7325c..ee300a5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -259,6 +259,19 @@ export interface Invoice { token: string; /** Unix timestamp deadline (seconds). */ deadline: number; + /** + * When the invoice was created. Accepted as a Unix timestamp in either + * seconds or milliseconds; helpers such as `getInvoiceAge` and + * `getFundingVelocity` detect the unit automatically by magnitude (values + * greater than 1e12 are treated as milliseconds). `0`, negative, and + * non-finite values are treated as "unknown" rather than as epoch 1970. + * + * Note: `hashInvoice()` canonicalises every key present on the invoice + * object, so populating this field changes an invoice's `contentHash`. + * Recompute any stored hashes before relying on `verifyInvoice()` or + * `submitPayment({ expectedContentHash })` for invoices that gain it. + */ + createdAt?: number; /** Total amount funded so far in stroops. */ funded: bigint; /** Current lifecycle status. */ @@ -374,7 +387,14 @@ export interface InvoiceStats { totalPayers: number; /** Mean payment size in stroops (0 when there are no payments). */ avgPayment: bigint; - /** Tokens funded per day since the first payment. */ + /** + * Stroops funded per day across the payment window, i.e. the sum of payment + * amounts divided by the span between the first and last payment. + * + * This is deliberately different from the exported `getFundingVelocity()`, + * which is a lifetime average over the whole age of the invoice (`funded` + * since `createdAt`). Expect the two to disagree for the same invoice. + */ fundingVelocity: number; /** Seconds from first to last payment once completed, else null. */ timeToCompletion: number | null; diff --git a/test/invoiceStats.test.ts b/test/invoiceStats.test.ts new file mode 100644 index 0000000..31bfe03 --- /dev/null +++ b/test/invoiceStats.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, afterEach, vi } from "vitest"; +import { + getInvoiceAge, + getFundingVelocity, + computeInvoiceStats, +} from "../src/invoiceStats.js"; +import type { Invoice } from "../src/types.js"; + +/** Fixed "now" used by every test: 2024-01-11T00:00:00.000Z. */ +const NOW_MS = 1_704_931_200_000; +const NOW_SECONDS = NOW_MS / 1000; + +const MS_PER_MINUTE = 60_000; +const MS_PER_HOUR = 60 * MS_PER_MINUTE; +const MS_PER_DAY = 24 * MS_PER_HOUR; + +function makeInvoice(overrides: Partial = {}): Invoice { + return { + id: "1", + creator: "GCREATOR", + recipients: [{ address: "GRECIPIENT", amount: 1_000n }], + token: "TOKEN_USDC", + deadline: 2_000_000_000, + funded: 0n, + status: "Pending", + payments: [], + ...overrides, + }; +} + +/** Pin `Date.now()` so age-based maths is deterministic. */ +function freezeNow(nowMs: number = NOW_MS): void { + vi.useFakeTimers(); + vi.setSystemTime(nowMs); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("getInvoiceAge", () => { + it("reports 2 days for an invoice created exactly 2 days ago", () => { + freezeNow(); + const invoice = makeInvoice({ createdAt: NOW_SECONDS - 2 * 86_400 }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 2, hours: 0, minutes: 0 }); + }); + + it("breaks the age down into days, remainder hours, and remainder minutes", () => { + freezeNow(); + const createdMs = NOW_MS - (3 * MS_PER_DAY + 5 * MS_PER_HOUR + 47 * MS_PER_MINUTE); + const invoice = makeInvoice({ createdAt: createdMs }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 3, hours: 5, minutes: 47 }); + }); + + it("truncates sub-minute remainders instead of rounding up", () => { + freezeNow(); + const invoice = makeInvoice({ createdAt: NOW_MS - (59 * 1000 + 999) }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 0, hours: 0, minutes: 0 }); + }); + + it("returns a zero age for an invoice created right now", () => { + freezeNow(); + const invoice = makeInvoice({ createdAt: NOW_MS }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 0, hours: 0, minutes: 0 }); + }); + + it("clamps a future createdAt to a zero age", () => { + freezeNow(); + const invoice = makeInvoice({ createdAt: NOW_SECONDS + 86_400 }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 0, hours: 0, minutes: 0 }); + }); + + it("returns a zero age when createdAt is absent", () => { + freezeNow(); + + expect(getInvoiceAge(makeInvoice())).toEqual({ + days: 0, + hours: 0, + minutes: 0, + }); + }); + + it("treats createdAt: 0 as unknown rather than epoch 1970", () => { + freezeNow(); + + // Read as a real timestamp this would report a ~20,000-day age. + expect(getInvoiceAge(makeInvoice({ createdAt: 0 }))).toEqual({ + days: 0, + hours: 0, + minutes: 0, + }); + }); + + it("treats negative and non-finite createdAt values as unknown", () => { + freezeNow(); + + for (const createdAt of [-1, -NOW_SECONDS, Number.NaN, Infinity, -Infinity]) { + expect(getInvoiceAge(makeInvoice({ createdAt }))).toEqual({ + days: 0, + hours: 0, + minutes: 0, + }); + } + }); + + it("uses Date.now() internally, so the age grows as time passes", () => { + freezeNow(); + const invoice = makeInvoice({ createdAt: NOW_MS }); + + expect(getInvoiceAge(invoice).days).toBe(0); + + vi.setSystemTime(NOW_MS + 2 * MS_PER_DAY + 3 * MS_PER_HOUR); + + expect(getInvoiceAge(invoice)).toEqual({ days: 2, hours: 3, minutes: 0 }); + }); +}); + +describe("getInvoiceAge — timestamp format auto-detection", () => { + it("treats values above 1e12 as milliseconds and below as seconds", () => { + freezeNow(); + const twoDays = 2 * 86_400; + + const seconds = makeInvoice({ createdAt: NOW_SECONDS - twoDays }); + const millis = makeInvoice({ createdAt: NOW_MS - twoDays * 1000 }); + + expect(getInvoiceAge(seconds)).toEqual(getInvoiceAge(millis)); + expect(getInvoiceAge(seconds).days).toBe(2); + }); + + it("does not mistake a seconds timestamp for milliseconds", () => { + freezeNow(); + // 1_704_844_800 s = 1 day before NOW; read as ms it would be ~1970. + const invoice = makeInvoice({ createdAt: 1_704_844_800 }); + + expect(getInvoiceAge(invoice)).toEqual({ days: 1, hours: 0, minutes: 0 }); + }); +}); + +describe("getFundingVelocity", () => { + it("returns funded units per day for a known funded/age ratio", () => { + freezeNow(); + // 500 funded over 4 days => 125 per day. + const invoice = makeInvoice({ + funded: 500n, + createdAt: NOW_SECONDS - 4 * 86_400, + }); + + expect(getFundingVelocity(invoice)).toBeCloseTo(125, 10); + }); + + it("scales linearly with the funded amount", () => { + freezeNow(); + const createdAt = NOW_SECONDS - 86_400 / 2; // half a day old + + expect( + getFundingVelocity(makeInvoice({ funded: 100n, createdAt })) + ).toBeCloseTo(200, 10); + expect( + getFundingVelocity(makeInvoice({ funded: 250n, createdAt })) + ).toBeCloseTo(500, 10); + }); + + it("handles large bigint funded amounts without overflowing", () => { + freezeNow(); + const invoice = makeInvoice({ + funded: 10_000_000_000n, // 1_000 USDC in stroops + createdAt: NOW_SECONDS - 10 * 86_400, + }); + + expect(getFundingVelocity(invoice)).toBeCloseTo(1_000_000_000, 0); + }); + + it("returns 0 when createdAt is within the same second as now", () => { + freezeNow(); + + expect(getFundingVelocity(makeInvoice({ funded: 500n, createdAt: NOW_MS }))).toBe(0); + expect( + getFundingVelocity(makeInvoice({ funded: 500n, createdAt: NOW_MS - 999 })) + ).toBe(0); + expect( + getFundingVelocity(makeInvoice({ funded: 500n, createdAt: NOW_SECONDS })) + ).toBe(0); + }); + + it("returns 0 for a future createdAt", () => { + freezeNow(); + const invoice = makeInvoice({ + funded: 500n, + createdAt: NOW_SECONDS + 3_600, + }); + + expect(getFundingVelocity(invoice)).toBe(0); + }); + + it("returns 0 when createdAt is absent", () => { + freezeNow(); + + expect(getFundingVelocity(makeInvoice({ funded: 500n }))).toBe(0); + }); + + it("returns 0 for the createdAt: 0 sentinel instead of a bogus rate", () => { + freezeNow(); + + expect(getFundingVelocity(makeInvoice({ funded: 500n, createdAt: 0 }))).toBe(0); + }); + + it("returns 0 for negative and non-finite createdAt values", () => { + freezeNow(); + + for (const createdAt of [-1, -NOW_SECONDS, Number.NaN, Infinity, -Infinity]) { + expect(getFundingVelocity(makeInvoice({ funded: 500n, createdAt }))).toBe(0); + } + }); + + it("returns 0 for an unfunded invoice", () => { + freezeNow(); + const invoice = makeInvoice({ + funded: 0n, + createdAt: NOW_SECONDS - 7 * 86_400, + }); + + expect(getFundingVelocity(invoice)).toBe(0); + }); + + it("detects seconds and milliseconds createdAt formats identically", () => { + freezeNow(); + const twoDays = 2 * 86_400; + + const seconds = makeInvoice({ + funded: 1_000n, + createdAt: NOW_SECONDS - twoDays, + }); + const millis = makeInvoice({ + funded: 1_000n, + createdAt: NOW_MS - twoDays * 1000, + }); + + expect(getFundingVelocity(seconds)).toBeCloseTo(500, 10); + expect(getFundingVelocity(millis)).toBeCloseTo( + getFundingVelocity(seconds), + 10 + ); + }); +}); + +describe("computeInvoiceStats — unchanged by the new helpers", () => { + it("still derives aggregate stats from the payment history", () => { + const invoice = makeInvoice({ + funded: 1_000n, + status: "Released", + recipients: [{ address: "R1", amount: 1_000n }], + payments: [ + { payer: "P1", amount: 400n, timestamp: 1_000 }, + { payer: "P2", amount: 600n, timestamp: 1_000 + 86_400 }, + ], + }); + + const stats = computeInvoiceStats(invoice); + + expect(stats.totalPayers).toBe(2); + expect(stats.avgPayment).toBe(500n); + expect(stats.fundingVelocity).toBeCloseTo(1_000, 10); + expect(stats.timeToCompletion).toBe(86_400); + expect(stats.completionBps).toBe(10_000); + }); +});