diff --git a/src/invoiceStats.ts b/src/invoiceStats.ts index e9d8725..dde1eff 100644 --- a/src/invoiceStats.ts +++ b/src/invoiceStats.ts @@ -7,6 +7,27 @@ interface InvoiceStatsClient { const SECONDS_PER_DAY = 86_400; +/** + * Median of a list of stroop amounts, computed with a sort (no dependencies). + * + * Returns 0 for an empty list. For an even-length list the two middle values + * are averaged with integer (truncating) division, matching how `avgPayment` + * handles the fractional stroop. + * + * @param amounts - Unsorted payment amounts in stroops. + * @returns The median amount in stroops. + */ +function medianOf(amounts: bigint[]): bigint { + if (amounts.length === 0) return 0n; + + const sorted = [...amounts].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); + const mid = sorted.length >> 1; + + return sorted.length % 2 === 1 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2n; +} + /** * Compute rich analytics for an invoice purely from its payment history. * @@ -25,6 +46,8 @@ export function computeInvoiceStats(invoice: Invoice): InvoiceStats { const avgPayment = payments.length === 0 ? 0n : totalFunded / BigInt(payments.length); + const medianAmount = medianOf(payments.map((p) => p.amount)); + const timestamps = payments .map((p) => p.timestamp) .filter((t): t is number => typeof t === "number") @@ -58,6 +81,7 @@ export function computeInvoiceStats(invoice: Invoice): InvoiceStats { return { totalPayers, avgPayment, + medianAmount, fundingVelocity, timeToCompletion, completionBps, diff --git a/src/types.ts b/src/types.ts index eb7325c..ffc6e15 100644 --- a/src/types.ts +++ b/src/types.ts @@ -374,6 +374,8 @@ export interface InvoiceStats { totalPayers: number; /** Mean payment size in stroops (0 when there are no payments). */ avgPayment: bigint; + /** Median payment size in stroops (0 when there are no payments). */ + medianAmount: bigint; /** Tokens funded per day since the first payment. */ fundingVelocity: number; /** Seconds from first to last payment once completed, else null. */ diff --git a/test/invoiceStats.test.ts b/test/invoiceStats.test.ts new file mode 100644 index 0000000..c0e5817 --- /dev/null +++ b/test/invoiceStats.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from "vitest"; +import { computeInvoiceStats } from "../src/invoiceStats.js"; +import type { Invoice, Payment } from "../src/types.js"; + +// === Fixtures === + +function payment(amount: bigint, payer = "GPAYER"): Payment { + return { payer, amount }; +} + +function invoiceWith(payments: Payment[]): Invoice { + return { + id: "1", + creator: "GCREATOR", + recipients: [{ address: "GRECIPIENT", amount: 1_000n }], + token: "GTOKEN", + deadline: 0, + funded: payments.reduce((sum, p) => sum + p.amount, 0n), + status: "Pending", + payments, + }; +} + +// === medianAmount === + +describe("computeInvoiceStats — medianAmount", () => { + it("returns 0 for an empty invoice set", () => { + expect(computeInvoiceStats(invoiceWith([])).medianAmount).toBe(0n); + }); + + it("returns the single value for a one-payment set", () => { + expect(computeInvoiceStats(invoiceWith([payment(500n)])).medianAmount).toBe( + 500n, + ); + }); + + it("returns the middle value for an odd-length set", () => { + const stats = computeInvoiceStats( + invoiceWith([payment(300n), payment(100n), payment(200n)]), + ); + expect(stats.medianAmount).toBe(200n); + }); + + it("averages the two middle values for an even-length set", () => { + const stats = computeInvoiceStats( + invoiceWith([payment(10n), payment(20n), payment(30n), payment(40n)]), + ); + // middle two are 20 and 30 -> (20 + 30) / 2 = 25 + expect(stats.medianAmount).toBe(25n); + }); + + it("truncates the fractional stroop when the two middle values sum to an odd number", () => { + const stats = computeInvoiceStats( + invoiceWith([payment(1n), payment(2n), payment(4n), payment(4n)]), + ); + // middle two are 2 and 4 -> 6 / 2 = 3 + expect(stats.medianAmount).toBe(3n); + + const odd = computeInvoiceStats( + invoiceWith([payment(1n), payment(2n), payment(3n), payment(4n)]), + ); + // middle two are 2 and 3 -> 5n / 2n = 2n + expect(odd.medianAmount).toBe(2n); + }); + + it("is robust to a single large outlier where the mean is not", () => { + const stats = computeInvoiceStats( + invoiceWith([ + payment(100n), + payment(100n), + payment(100n), + payment(1_000_000n), + ]), + ); + expect(stats.medianAmount).toBe(100n); + expect(stats.avgPayment).toBe(250_075n); + }); + + it("does not depend on payment insertion order", () => { + const ascending = computeInvoiceStats( + invoiceWith([payment(1n), payment(5n), payment(9n)]), + ).medianAmount; + const descending = computeInvoiceStats( + invoiceWith([payment(9n), payment(5n), payment(1n)]), + ).medianAmount; + expect(ascending).toBe(5n); + expect(descending).toBe(5n); + }); +});