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); + }); +});