From 8086501f43e514de39713af2f1a028f085a480e5 Mon Sep 17 00:00:00 2001 From: wendypetersondev Date: Sat, 27 Jun 2026 02:47:47 +0000 Subject: [PATCH] feat: add invoice payment velocity tracker - Add src/velocityTracker.ts with trackVelocity function - Compute paymentsPerDay from actual payment timestamps - Classify trends: accelerating, steady, stalling - Compare first-half vs second-half payment rates for trend analysis - Export VelocityReport, InvoiceVelocity, PaymentTrend types - Add 4 new tests verifying velocity calculations and trend classification - All 51 tests pass, TypeScript strict mode compliance --- src/index.ts | 4 + src/velocityTracker.ts | 140 ++++++++++++++++++++++++++++++++++ test/client.test.ts | 166 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) create mode 100644 src/velocityTracker.ts diff --git a/src/index.ts b/src/index.ts index 951c40d..e1bf0ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,3 +241,7 @@ export type { SimulationDiffNotComparable, ResourceDelta, } from "./simulationDiff.js"; + +// Payment velocity tracking +export { trackVelocity } from "./velocityTracker.js"; +export type { VelocityReport, InvoiceVelocity, PaymentTrend } from "./velocityTracker.js"; diff --git a/src/velocityTracker.ts b/src/velocityTracker.ts new file mode 100644 index 0000000..54c0269 --- /dev/null +++ b/src/velocityTracker.ts @@ -0,0 +1,140 @@ +import type { StellarSplitClient } from "./client.js"; + +/** Trend classification for invoice payment velocity. */ +export type PaymentTrend = "accelerating" | "steady" | "stalling"; + +/** Details for a single invoice's payment velocity. */ +export interface InvoiceVelocity { + invoiceId: string; + paymentsPerDay: number; + trend: PaymentTrend; +} + +/** Report on payment velocity across all invoices for an address. */ +export interface VelocityReport { + address: string; + invoices: InvoiceVelocity[]; +} + +/** + * Analyze payment velocity for all invoices created by an address. + * + * Fetches all invoices for the given creator address and computes: + * - Payment rate (payments per day) + * - Trend classification based on first-half vs second-half payment rates + * + * @param address - Stellar address of the invoice creator + * @param client - StellarSplitClient instance + * @returns Report containing velocity metrics for each invoice + */ +export async function trackVelocity( + address: string, + client: StellarSplitClient +): Promise { + const invoices: InvoiceVelocity[] = []; + let cursor: string | null = null; + + // Fetch all invoices created by this address + while (true) { + const result = await client.getInvoicesByCreator(address, { + cursor: cursor ?? undefined, + limit: 50, + }); + + for (const invoiceId of result.items) { + const invoice = await client.getInvoice(invoiceId); + + if (invoice.payments.length === 0) { + invoices.push({ + invoiceId, + paymentsPerDay: 0, + trend: "steady", + }); + continue; + } + + const paymentsPerDay = calculatePaymentsPerDay(invoice.payments); + const trend = classifyTrend(invoice.payments); + + invoices.push({ + invoiceId, + paymentsPerDay, + trend, + }); + } + + if (!result.nextCursor) break; + cursor = result.nextCursor; + } + + return { address, invoices }; +} + +/** + * Calculate the average payment rate (payments per day) from payment timestamps. + */ +function calculatePaymentsPerDay(payments: Array<{ timestamp?: number }>): number { + const withTimestamps = payments.filter((p) => p.timestamp !== undefined); + + if (withTimestamps.length < 2) { + return 0; + } + + const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b); + const first = timestamps[0]!; + const last = timestamps[timestamps.length - 1]!; + + const daysElapsed = (last - first) / (24 * 3600); + if (daysElapsed === 0) return 0; + + return withTimestamps.length / daysElapsed; +} + +/** + * Classify payment trend by comparing first-half vs second-half payment rates. + */ +function classifyTrend(payments: Array<{ timestamp?: number }>): PaymentTrend { + const withTimestamps = payments.filter((p) => p.timestamp !== undefined); + + if (withTimestamps.length < 2) { + return "steady"; + } + + const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b); + const midpoint = Math.floor(timestamps.length / 2); + + const firstHalf = timestamps.slice(0, midpoint); + const secondHalf = timestamps.slice(midpoint); + + if (firstHalf.length === 0 || secondHalf.length === 0) { + return "steady"; + } + + const firstHalfRate = calculateRate(firstHalf); + const secondHalfRate = calculateRate(secondHalf); + + // If rates differ by less than 20%, consider it steady + const threshold = 0.2; + const rateDifference = Math.abs(secondHalfRate - firstHalfRate) / Math.max(firstHalfRate, 1); + + if (rateDifference < threshold) { + return "steady"; + } + + return secondHalfRate > firstHalfRate ? "accelerating" : "stalling"; +} + +/** + * Calculate payment rate (payments per day) for a subset of timestamps. + */ +function calculateRate(timestamps: number[]): number { + if (timestamps.length < 2) return 0; + + const first = timestamps[0]!; + const last = timestamps[timestamps.length - 1]!; + + const daysElapsed = (last - first) / (24 * 3600); + if (daysElapsed === 0) return 0; + + return timestamps.length / daysElapsed; +} diff --git a/test/client.test.ts b/test/client.test.ts index 5d3de9b..8c1e0c5 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -874,3 +874,169 @@ describe("resolveCloneChain", () => { await expect(client.resolveCloneChain("x")).rejects.toThrow("clone chain depth exceeded"); }); }); + +describe("trackVelocity", () => { + it("calculates payments per day from payment timestamps", async () => { + const { trackVelocity } = await import("../src/velocityTracker.js"); + + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); + + const now = Math.floor(Date.now() / 1000); + const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + + vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({ + items: ["inv1"], + nextCursor: null, + total: 1, + }); + + vi.spyOn(client, "getInvoice").mockResolvedValue({ + id: "inv1", + creator: creatorAddr, + recipients: [], + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: now + 86_400, + funded: 1_000_000n, + status: "Pending" as const, + payments: [ + { payer: "GPAYER1", amount: 100_000n, timestamp: now }, + { payer: "GPAYER2", amount: 100_000n, timestamp: now + 43_200 }, // 12 hours later + { payer: "GPAYER3", amount: 100_000n, timestamp: now + 86_400 }, // 1 day later + ], + } as any); + + const report = await trackVelocity(creatorAddr, client); + + expect(report.address).toBe(creatorAddr); + expect(report.invoices).toHaveLength(1); + expect(report.invoices[0]!.invoiceId).toBe("inv1"); + expect(report.invoices[0]!.paymentsPerDay).toBeGreaterThan(0); + expect(report.invoices[0]!.paymentsPerDay).toBeLessThan(10); + }); + + it("classifies stalling trend for decreasing payment rate", async () => { + const { trackVelocity } = await import("../src/velocityTracker.js"); + + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); + + const now = Math.floor(Date.now() / 1000); + const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + + vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({ + items: ["inv1"], + nextCursor: null, + total: 1, + }); + + // Payments concentrated early (stalling pattern) + vi.spyOn(client, "getInvoice").mockResolvedValue({ + id: "inv1", + creator: creatorAddr, + recipients: [], + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: now + 864_000, + funded: 1_000_000n, + status: "Pending" as const, + payments: [ + { payer: "GPAYER1", amount: 100_000n, timestamp: now }, + { payer: "GPAYER2", amount: 100_000n, timestamp: now + 3_600 }, + { payer: "GPAYER3", amount: 100_000n, timestamp: now + 7_200 }, + { payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 }, // 5 days later + { payer: "GPAYER5", amount: 100_000n, timestamp: now + 435_600 }, + ], + } as any); + + const report = await trackVelocity(creatorAddr, client); + + expect(report.invoices[0]!.trend).toBe("stalling"); + }); + + it("classifies accelerating trend for increasing payment rate", async () => { + const { trackVelocity } = await import("../src/velocityTracker.js"); + + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); + + const now = Math.floor(Date.now() / 1000); + const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + + vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({ + items: ["inv1"], + nextCursor: null, + total: 1, + }); + + // Payments concentrated later (accelerating pattern) + vi.spyOn(client, "getInvoice").mockResolvedValue({ + id: "inv1", + creator: creatorAddr, + recipients: [], + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: now + 864_000, + funded: 1_000_000n, + status: "Pending" as const, + payments: [ + { payer: "GPAYER1", amount: 100_000n, timestamp: now }, + { payer: "GPAYER2", amount: 100_000n, timestamp: now + 172_800 }, // First half ends here (5 payments / 2 = 2.5) + { payer: "GPAYER3", amount: 100_000n, timestamp: now + 345_600 }, + { payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 }, + { payer: "GPAYER5", amount: 100_000n, timestamp: now + 439_200 }, + ], + } as any); + + const report = await trackVelocity(creatorAddr, client); + + expect(report.invoices[0]!.trend).toBe("accelerating"); + }); + + it("classifies steady trend for constant payment rate", async () => { + const { trackVelocity } = await import("../src/velocityTracker.js"); + + const client = new StellarSplitClient({ + rpcUrl: "https://example.com", + networkPassphrase: "Test Network", + contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()), + }); + + const now = Math.floor(Date.now() / 1000); + const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"; + + vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({ + items: ["inv1"], + nextCursor: null, + total: 1, + }); + + // Evenly distributed payments + vi.spyOn(client, "getInvoice").mockResolvedValue({ + id: "inv1", + creator: creatorAddr, + recipients: [], + token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX", + deadline: now + 864_000, + funded: 1_000_000n, + status: "Pending" as const, + payments: [ + { payer: "GPAYER1", amount: 100_000n, timestamp: now }, + { payer: "GPAYER2", amount: 100_000n, timestamp: now + 86_400 }, + { payer: "GPAYER3", amount: 100_000n, timestamp: now + 172_800 }, + { payer: "GPAYER4", amount: 100_000n, timestamp: now + 259_200 }, + ], + } as any); + + const report = await trackVelocity(creatorAddr, client); + + expect(report.invoices[0]!.trend).toBe("steady"); + }); +});