Skip to content

Commit 2dfd9e9

Browse files
authored
Merge pull request #352 from wendypetersondev/feat/invoice-velocity-tracker
Add invoice payment velocity tracker
2 parents a3491c5 + a407240 commit 2dfd9e9

3 files changed

Lines changed: 309 additions & 0 deletions

File tree

src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,9 @@ export type {
279279
ResourceDelta,
280280
} from "./simulationDiff.js";
281281

282+
// Payment velocity tracking
283+
export { trackVelocity } from "./velocityTracker.js";
284+
export type { VelocityReport, InvoiceVelocity, PaymentTrend } from "./velocityTracker.js";
282285
export { Sep41Adapter, createSep41Adapter } from "./sep41Adapter.js";
283286
export type { Sep41TokenCapabilities } from "./sep41Adapter.js";
284287

src/velocityTracker.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import type { StellarSplitClient } from "./client.js";
2+
3+
/** Trend classification for invoice payment velocity. */
4+
export type PaymentTrend = "accelerating" | "steady" | "stalling";
5+
6+
/** Details for a single invoice's payment velocity. */
7+
export interface InvoiceVelocity {
8+
invoiceId: string;
9+
paymentsPerDay: number;
10+
trend: PaymentTrend;
11+
}
12+
13+
/** Report on payment velocity across all invoices for an address. */
14+
export interface VelocityReport {
15+
address: string;
16+
invoices: InvoiceVelocity[];
17+
}
18+
19+
/**
20+
* Analyze payment velocity for all invoices created by an address.
21+
*
22+
* Fetches all invoices for the given creator address and computes:
23+
* - Payment rate (payments per day)
24+
* - Trend classification based on first-half vs second-half payment rates
25+
*
26+
* @param address - Stellar address of the invoice creator
27+
* @param client - StellarSplitClient instance
28+
* @returns Report containing velocity metrics for each invoice
29+
*/
30+
export async function trackVelocity(
31+
address: string,
32+
client: StellarSplitClient
33+
): Promise<VelocityReport> {
34+
const invoices: InvoiceVelocity[] = [];
35+
let cursor: string | null = null;
36+
37+
// Fetch all invoices created by this address
38+
while (true) {
39+
const result = await client.getInvoicesByCreator(address, {
40+
cursor: cursor ?? undefined,
41+
limit: 50,
42+
});
43+
44+
for (const invoiceId of result.items) {
45+
const invoice = await client.getInvoice(invoiceId);
46+
47+
if (invoice.payments.length === 0) {
48+
invoices.push({
49+
invoiceId,
50+
paymentsPerDay: 0,
51+
trend: "steady",
52+
});
53+
continue;
54+
}
55+
56+
const paymentsPerDay = calculatePaymentsPerDay(invoice.payments);
57+
const trend = classifyTrend(invoice.payments);
58+
59+
invoices.push({
60+
invoiceId,
61+
paymentsPerDay,
62+
trend,
63+
});
64+
}
65+
66+
if (!result.nextCursor) break;
67+
cursor = result.nextCursor;
68+
}
69+
70+
return { address, invoices };
71+
}
72+
73+
/**
74+
* Calculate the average payment rate (payments per day) from payment timestamps.
75+
*/
76+
function calculatePaymentsPerDay(payments: Array<{ timestamp?: number }>): number {
77+
const withTimestamps = payments.filter((p) => p.timestamp !== undefined);
78+
79+
if (withTimestamps.length < 2) {
80+
return 0;
81+
}
82+
83+
const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b);
84+
const first = timestamps[0]!;
85+
const last = timestamps[timestamps.length - 1]!;
86+
87+
const daysElapsed = (last - first) / (24 * 3600);
88+
if (daysElapsed === 0) return 0;
89+
90+
return withTimestamps.length / daysElapsed;
91+
}
92+
93+
/**
94+
* Classify payment trend by comparing first-half vs second-half payment rates.
95+
*/
96+
function classifyTrend(payments: Array<{ timestamp?: number }>): PaymentTrend {
97+
const withTimestamps = payments.filter((p) => p.timestamp !== undefined);
98+
99+
if (withTimestamps.length < 2) {
100+
return "steady";
101+
}
102+
103+
const timestamps = withTimestamps.map((p) => p.timestamp!).sort((a, b) => a - b);
104+
const midpoint = Math.floor(timestamps.length / 2);
105+
106+
const firstHalf = timestamps.slice(0, midpoint);
107+
const secondHalf = timestamps.slice(midpoint);
108+
109+
if (firstHalf.length === 0 || secondHalf.length === 0) {
110+
return "steady";
111+
}
112+
113+
const firstHalfRate = calculateRate(firstHalf);
114+
const secondHalfRate = calculateRate(secondHalf);
115+
116+
// If rates differ by less than 20%, consider it steady
117+
const threshold = 0.2;
118+
const rateDifference = Math.abs(secondHalfRate - firstHalfRate) / Math.max(firstHalfRate, 1);
119+
120+
if (rateDifference < threshold) {
121+
return "steady";
122+
}
123+
124+
return secondHalfRate > firstHalfRate ? "accelerating" : "stalling";
125+
}
126+
127+
/**
128+
* Calculate payment rate (payments per day) for a subset of timestamps.
129+
*/
130+
function calculateRate(timestamps: number[]): number {
131+
if (timestamps.length < 2) return 0;
132+
133+
const first = timestamps[0]!;
134+
const last = timestamps[timestamps.length - 1]!;
135+
136+
const daysElapsed = (last - first) / (24 * 3600);
137+
if (daysElapsed === 0) return 0;
138+
139+
return timestamps.length / daysElapsed;
140+
}

test/client.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -956,3 +956,169 @@ describe("resolveCloneChain", () => {
956956
await expect(client.resolveCloneChain("x")).rejects.toThrow("clone chain depth exceeded");
957957
});
958958
});
959+
960+
describe("trackVelocity", () => {
961+
it("calculates payments per day from payment timestamps", async () => {
962+
const { trackVelocity } = await import("../src/velocityTracker.js");
963+
964+
const client = new StellarSplitClient({
965+
rpcUrl: "https://example.com",
966+
networkPassphrase: "Test Network",
967+
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
968+
});
969+
970+
const now = Math.floor(Date.now() / 1000);
971+
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
972+
973+
vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
974+
items: ["inv1"],
975+
nextCursor: null,
976+
total: 1,
977+
});
978+
979+
vi.spyOn(client, "getInvoice").mockResolvedValue({
980+
id: "inv1",
981+
creator: creatorAddr,
982+
recipients: [],
983+
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
984+
deadline: now + 86_400,
985+
funded: 1_000_000n,
986+
status: "Pending" as const,
987+
payments: [
988+
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
989+
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 43_200 }, // 12 hours later
990+
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 86_400 }, // 1 day later
991+
],
992+
} as any);
993+
994+
const report = await trackVelocity(creatorAddr, client);
995+
996+
expect(report.address).toBe(creatorAddr);
997+
expect(report.invoices).toHaveLength(1);
998+
expect(report.invoices[0]!.invoiceId).toBe("inv1");
999+
expect(report.invoices[0]!.paymentsPerDay).toBeGreaterThan(0);
1000+
expect(report.invoices[0]!.paymentsPerDay).toBeLessThan(10);
1001+
});
1002+
1003+
it("classifies stalling trend for decreasing payment rate", async () => {
1004+
const { trackVelocity } = await import("../src/velocityTracker.js");
1005+
1006+
const client = new StellarSplitClient({
1007+
rpcUrl: "https://example.com",
1008+
networkPassphrase: "Test Network",
1009+
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
1010+
});
1011+
1012+
const now = Math.floor(Date.now() / 1000);
1013+
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1014+
1015+
vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
1016+
items: ["inv1"],
1017+
nextCursor: null,
1018+
total: 1,
1019+
});
1020+
1021+
// Payments concentrated early (stalling pattern)
1022+
vi.spyOn(client, "getInvoice").mockResolvedValue({
1023+
id: "inv1",
1024+
creator: creatorAddr,
1025+
recipients: [],
1026+
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1027+
deadline: now + 864_000,
1028+
funded: 1_000_000n,
1029+
status: "Pending" as const,
1030+
payments: [
1031+
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
1032+
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 3_600 },
1033+
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 7_200 },
1034+
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 }, // 5 days later
1035+
{ payer: "GPAYER5", amount: 100_000n, timestamp: now + 435_600 },
1036+
],
1037+
} as any);
1038+
1039+
const report = await trackVelocity(creatorAddr, client);
1040+
1041+
expect(report.invoices[0]!.trend).toBe("stalling");
1042+
});
1043+
1044+
it("classifies accelerating trend for increasing payment rate", async () => {
1045+
const { trackVelocity } = await import("../src/velocityTracker.js");
1046+
1047+
const client = new StellarSplitClient({
1048+
rpcUrl: "https://example.com",
1049+
networkPassphrase: "Test Network",
1050+
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
1051+
});
1052+
1053+
const now = Math.floor(Date.now() / 1000);
1054+
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1055+
1056+
vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
1057+
items: ["inv1"],
1058+
nextCursor: null,
1059+
total: 1,
1060+
});
1061+
1062+
// Payments concentrated later (accelerating pattern)
1063+
vi.spyOn(client, "getInvoice").mockResolvedValue({
1064+
id: "inv1",
1065+
creator: creatorAddr,
1066+
recipients: [],
1067+
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1068+
deadline: now + 864_000,
1069+
funded: 1_000_000n,
1070+
status: "Pending" as const,
1071+
payments: [
1072+
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
1073+
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 172_800 }, // First half ends here (5 payments / 2 = 2.5)
1074+
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 345_600 },
1075+
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 432_000 },
1076+
{ payer: "GPAYER5", amount: 100_000n, timestamp: now + 439_200 },
1077+
],
1078+
} as any);
1079+
1080+
const report = await trackVelocity(creatorAddr, client);
1081+
1082+
expect(report.invoices[0]!.trend).toBe("accelerating");
1083+
});
1084+
1085+
it("classifies steady trend for constant payment rate", async () => {
1086+
const { trackVelocity } = await import("../src/velocityTracker.js");
1087+
1088+
const client = new StellarSplitClient({
1089+
rpcUrl: "https://example.com",
1090+
networkPassphrase: "Test Network",
1091+
contractId: StrKey.encodeContract(Keypair.random().rawPublicKey()),
1092+
});
1093+
1094+
const now = Math.floor(Date.now() / 1000);
1095+
const creatorAddr = "GCREATORXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1096+
1097+
vi.spyOn(client, "getInvoicesByCreator").mockResolvedValue({
1098+
items: ["inv1"],
1099+
nextCursor: null,
1100+
total: 1,
1101+
});
1102+
1103+
// Evenly distributed payments
1104+
vi.spyOn(client, "getInvoice").mockResolvedValue({
1105+
id: "inv1",
1106+
creator: creatorAddr,
1107+
recipients: [],
1108+
token: "GUSDCXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1109+
deadline: now + 864_000,
1110+
funded: 1_000_000n,
1111+
status: "Pending" as const,
1112+
payments: [
1113+
{ payer: "GPAYER1", amount: 100_000n, timestamp: now },
1114+
{ payer: "GPAYER2", amount: 100_000n, timestamp: now + 86_400 },
1115+
{ payer: "GPAYER3", amount: 100_000n, timestamp: now + 172_800 },
1116+
{ payer: "GPAYER4", amount: 100_000n, timestamp: now + 259_200 },
1117+
],
1118+
} as any);
1119+
1120+
const report = await trackVelocity(creatorAddr, client);
1121+
1122+
expect(report.invoices[0]!.trend).toBe("steady");
1123+
});
1124+
});

0 commit comments

Comments
 (0)