Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,9 @@ PAID_NOTICE_INTERVAL_MS=30000
# How often the chain worker sends referral bounties; 0 disables it. The operator
# key lives only in the worker, so core-api never runs this.
REFERRAL_INTERVAL_MS=5000
# Days after expiry that an unpaid payment is deleted (analytics keeps counting it).
# Never touches a payment with funds, or a FAILED one. 0 turns pruning off.
EXPIRED_PAYMENT_RETENTION_DAYS=2

# =============================================================================
# Quote layer — venue pricing, oracle deviation guard, order signing
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/chain-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,30 @@ if (referralTimer !== undefined) {
console.log(`[chain-worker] paying referral bounties every ${config.referralIntervalMs}ms`);
}

// Unpaid payments past their retention are deleted; analytics keeps their count. An hour
// is plenty: the batch is small, and a backlog drains one batch per pass.
const PRUNE_INTERVAL_MS = 60 * 60_000;
const PRUNE_BATCH = 100;
const DAY_MS = 24 * 60 * 60_000;
let pruning = false;
const pruneTimer =
config.expiredPaymentRetentionDays <= 0
? undefined
: setInterval(() => {
if (pruning) return;
pruning = true;
const cutoff = new Date(Date.now() - config.expiredPaymentRetentionDays * DAY_MS);
void container.expiredPayments
.prune(cutoff, PRUNE_BATCH)
.then((pruned) => {
if (pruned > 0) console.log(`[chain-worker] pruned ${pruned} unpaid payments`);
})
.catch((error: unknown) => console.error("[chain-worker] prune failed", error))
.finally(() => {
pruning = false;
});
}, PRUNE_INTERVAL_MS);

// Funds that reached a deposit address after its payment expired: money with no
// payment to settle. Reported once per payment per process.
const LATE_DEPOSIT_INTERVAL_MS = 5 * 60_000;
Expand Down Expand Up @@ -346,6 +370,7 @@ const shutdown = async (signal: NodeJS.Signals): Promise<void> => {
clearInterval(operatorFloatTimer);
clearInterval(gasRefillTimer);
clearInterval(referralTimer);
clearInterval(pruneTimer);
clearInterval(resumeTimer);
clearInterval(stuckPaymentTimer);
if (depositReturnTimer !== undefined) clearInterval(depositReturnTimer);
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,13 @@ const configSchema = z.object({
* to one instance (the cursor is read, then written).
*/
referralIntervalMs: z.coerce.number().int().min(0).default(5_000),
/**
* Days after its expiry that an unpaid payment (expired, or never confirmed) is
* deleted. Two is past the watcher's 24 hours, so a late deposit is still found
* first. A payment with any sign of funds is never deleted, and `FAILED` is kept.
* `0` turns pruning off. Only the chain worker runs it.
*/
expiredPaymentRetentionDays: z.coerce.number().int().min(0).default(2),

// --- Quote layer (RFC #6/#7, wired in the container) -------------------
/**
Expand Down Expand Up @@ -1520,6 +1527,7 @@ export function loadConfig(
webhookIntervalMs: env.WEBHOOK_INTERVAL_MS,
paidNoticeIntervalMs: env.PAID_NOTICE_INTERVAL_MS,
referralIntervalMs: env.REFERRAL_INTERVAL_MS,
expiredPaymentRetentionDays: env.EXPIRED_PAYMENT_RETENTION_DAYS,
quoteEnabled: env.QUOTE_ENABLED,
quoteVenues: env.QUOTE_VENUES,
quoteOracle: env.QUOTE_ORACLE,
Expand Down
7 changes: 7 additions & 0 deletions apps/api/src/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
DrizzleDepositAddressRepository,
DrizzleDepositRepository,
DrizzleEscrowDisputeRepository,
DrizzleExpiredPaymentPruner,
DrizzleInternalSettlementStore,
DrizzleInvoiceRepository,
DrizzleLedgerRepository,
Expand Down Expand Up @@ -89,6 +90,7 @@ import {
} from "@mayarin/notifications";
import {
DerivedRailCatalog,
type ExpiredPaymentPruner,
type MerchantAssetPolicySource,
PaymentIntentService,
type RailCatalog,
Expand Down Expand Up @@ -230,6 +232,8 @@ export interface Container {
readonly paymentApp: PaymentAppService;
/** The dashboard's analytics figures, for the merchant API. */
readonly analytics: AnalyticsService;
/** Deletes abandoned payments after the retention window; the chain worker runs it. */
readonly expiredPayments: ExpiredPaymentPruner;
/** Refunds against settled payments (#12). */
readonly refunds: RefundService;
/** Closes the operator key's sent-transaction rows once the chain settles them (SEC-04). */
Expand Down Expand Up @@ -930,6 +934,7 @@ export function createContainer({
});

const intentRepository = new DrizzlePaymentIntentRepository(handle.db);
const expiredPayments = new DrizzleExpiredPaymentPruner(handle.db);
const intents = new PaymentIntentService({
repository: intentRepository,
clock,
Expand Down Expand Up @@ -1629,8 +1634,10 @@ export function createContainer({
analytics: new AnalyticsService({
intents: intentRepository,
clearing: clearingRepository,
pruned: expiredPayments,
clock,
}),
expiredPayments,
escrow: new EscrowService(paymentApp, config.chainRpcUrls, disputes, clock, disputeNotices),
payerStanding,
riskAssessments,
Expand Down
69 changes: 63 additions & 6 deletions apps/api/src/services/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@

import type { ClearingTransaction } from "@mayarin/clearing";
import {
type ExpiredPaymentPruner,
isExpired,
type PaymentIntent,
type PaymentIntentRepository,
type PrunedPaymentCount,
} from "@mayarin/payment-intent";
import {
assetDecimals,
Expand Down Expand Up @@ -180,6 +182,7 @@ const emptyBucket = (): Bucket => ({
function gather(
payments: readonly PaymentIntent[],
byIntent: ReadonlyMap<string, ClearingTransaction>,
pruned: readonly PrunedPaymentCount[],
range: Range,
interval: AnalyticsInterval,
): ReadonlyMap<string, Bucket> {
Expand All @@ -198,6 +201,15 @@ function gather(
if (elapsed !== undefined) bucket.completion.push(elapsed);
}

// A pruned payment was an abandoned checkout: one of each, as if it were still here.
for (const row of pruned) {
if (!inRange(new Date(timeOf(row.day)), range)) continue;
const bucket = buckets.get(bucketOf(row.day, interval));
if (bucket === undefined) continue;
bucket.checkouts += row.count;
bucket.failed += row.count;
}

for (const intent of payments) {
const tx = byIntent.get(intent.id);
if (tx === undefined || !isSettled(tx)) continue;
Expand Down Expand Up @@ -304,6 +316,7 @@ function shares(keys: readonly string[]): readonly AnalyticsShareDto[] {
function performanceBy(
payments: readonly PaymentIntent[],
keyOf: (intent: PaymentIntent) => string,
prunedBad: ReadonlyMap<string, number>,
): readonly AnalyticsPerformanceDto[] {
const groups = new Map<string, { ok: number; bad: number; seconds: number[] }>();
for (const intent of payments) {
Expand All @@ -318,6 +331,11 @@ function performanceBy(
} else group.bad += 1;
groups.set(key, group);
}
for (const [key, count] of prunedBad) {
const group = groups.get(key) ?? { ok: 0, bad: 0, seconds: [] };
group.bad += count;
groups.set(key, group);
}
return [...groups]
.map(([key, group]) => ({
key,
Expand All @@ -337,15 +355,36 @@ function payerCountry(intent: PaymentIntent): string {
/* Report */
/* -------------------------------------------------------------------------- */

/** Pruned payments summed per key; a payer who never chose a rail is "Unknown". */
function prunedBy(
pruned: readonly PrunedPaymentCount[],
keyOf: (row: PrunedPaymentCount) => string | undefined,
): ReadonlyMap<string, number> {
const totals = new Map<string, number>();
for (const row of pruned) {
const key = keyOf(row) ?? "Unknown";
totals.set(key, (totals.get(key) ?? 0) + row.count);
}
return totals;
}

export interface AnalyticsInput {
/** Every payment of the merchant, expiry already applied. */
readonly payments: readonly PaymentIntent[];
/** What was deleted as abandoned; counted as failed and expired. */
readonly pruned: readonly PrunedPaymentCount[];
readonly clearing: readonly ClearingTransaction[];
readonly query: AnalyticsQuery;
readonly now: Date;
}

export function buildAnalytics({ payments, clearing, query, now }: AnalyticsInput): AnalyticsDto {
export function buildAnalytics({
payments,
pruned,
clearing,
query,
now,
}: AnalyticsInput): AnalyticsDto {
const range = rangeOf(query, now);
const { interval } = query;
const filtered = payments.filter(
Expand All @@ -354,9 +393,14 @@ export function buildAnalytics({ payments, clearing, query, now }: AnalyticsInpu
(query.asset === undefined || intent.payment?.asset === query.asset),
);
const byIntent = new Map(clearing.map((tx) => [tx.paymentIntentId, tx]));
const keptCounts = pruned.filter(
(row) =>
(query.chain === undefined || row.chain === query.chain) &&
(query.asset === undefined || row.asset === query.asset),
);

const current = gather(filtered, byIntent, range, interval);
const previous = fold(gather(filtered, byIntent, previousRange(range), interval));
const current = gather(filtered, byIntent, keptCounts, range, interval);
const previous = fold(gather(filtered, byIntent, keptCounts, previousRange(range), interval));
const whole = fold(current);

const keys = query.metrics ?? ANALYTICS_METRICS;
Expand All @@ -375,6 +419,7 @@ export function buildAnalytics({ payments, clearing, query, now }: AnalyticsInpu

const inWindow = filtered.filter((intent) => inRange(intent.createdAt, range));
const completed = inWindow.filter((intent) => intent.status === "COMPLETED");
const prunedInWindow = keptCounts.filter((row) => inRange(new Date(timeOf(row.day)), range));
return {
currency: "USD",
range,
Expand All @@ -386,24 +431,35 @@ export function buildAnalytics({ payments, clearing, query, now }: AnalyticsInpu
countries: shares(completed.map(payerCountry)),
},
performance: {
networks: performanceBy(inWindow, (intent) => intent.payment?.chain ?? "Unknown"),
assets: performanceBy(inWindow, (intent) => intent.payment?.asset ?? "Unknown"),
networks: performanceBy(
inWindow,
(intent) => intent.payment?.chain ?? "Unknown",
prunedBy(prunedInWindow, (row) => row.chain),
),
assets: performanceBy(
inWindow,
(intent) => intent.payment?.asset ?? "Unknown",
prunedBy(prunedInWindow, (row) => row.asset),
),
},
};
}

export class AnalyticsService {
readonly #intents: PaymentIntentRepository;
readonly #clearing: AnalyticsClearingReader;
readonly #pruned: Pick<ExpiredPaymentPruner, "prunedCounts">;
readonly #clock: Clock;

constructor(options: {
readonly intents: PaymentIntentRepository;
readonly clearing: AnalyticsClearingReader;
readonly pruned: Pick<ExpiredPaymentPruner, "prunedCounts">;
readonly clock: Clock;
}) {
this.#intents = options.intents;
this.#clearing = options.clearing;
this.#pruned = options.pruned;
this.#clock = options.clock;
}

Expand All @@ -429,6 +485,7 @@ export class AnalyticsService {
isExpired(intent, now) ? { ...intent, status: "EXPIRED" as const } : intent,
);
const clearing = await this.#clearing.listByPaymentIntentIds(viewed.map((intent) => intent.id));
return buildAnalytics({ payments: viewed, clearing, query, now });
const pruned = await this.#pruned.prunedCounts(merchantId);
return buildAnalytics({ payments: viewed, pruned, clearing, query, now });
}
}
44 changes: 41 additions & 3 deletions apps/api/test/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ describe("buildAnalytics", () => {
intent("c", at(3), { status: "FAILED", unfinished: true }),
intent("d", at(3), { status: "CREATED", unfinished: true }),
];
const report = buildAnalytics({ payments, clearing: [], query: query(), now: NOW });
const report = buildAnalytics({ payments, pruned: [], clearing: [], query: query(), now: NOW });

test("revenue sums completed payments per day and zero-fills the rest", () => {
expect(metric(report, "revenue")?.points.map((point) => point.value)).toEqual([
Expand All @@ -58,15 +58,45 @@ describe("buildAnalytics", () => {
expect(metric(report, "time_to_pay_p90")?.total).toBe(30);
});

test("a pruned payment still counts as a failed checkout", () => {
const pruned = [
{ day: "2026-10-03", chain: undefined, asset: undefined, count: 4 },
{ day: "2026-10-03", chain: "base-sepolia", asset: "USDC", count: 1 },
{ day: "2026-09-01", chain: undefined, asset: undefined, count: 9 },
];
const withPruned = buildAnalytics({ payments, pruned, clearing: [], query: query(), now: NOW });
// 4 kept checkouts + 5 pruned in range; the one outside the range is ignored.
expect(metric(withPruned, "checkouts")?.total).toBe(9);
expect(metric(withPruned, "failed_checkouts")?.total).toBe(6);
expect(metric(withPruned, "conversion_rate")?.total).toBeCloseTo(2 / 9);
expect(withPruned.performance.networks.find((row) => row.key === "Unknown")?.finished).toBe(4);

const onBase = buildAnalytics({
payments,
pruned,
clearing: [],
query: query({ chain: "base-sepolia" }),
now: NOW,
});
expect(metric(onBase, "checkouts")?.total).toBe(5);
});

test("a rate with nothing behind it is null, not zero", () => {
const empty = buildAnalytics({ payments: [], clearing: [], query: query(), now: NOW });
const empty = buildAnalytics({
payments: [],
pruned: [],
clearing: [],
query: query(),
now: NOW,
});
expect(metric(empty, "conversion_rate")?.total).toBeNull();
expect(metric(empty, "time_to_pay")?.points[0]?.value).toBeNull();
});

test("change compares against the range of the same length before", () => {
const withPrevious = buildAnalytics({
payments: [...payments, intent("p", new Date(Date.UTC(2026, 8, 28, 10)))],
pruned: [],
clearing: [],
query: query(),
now: NOW,
Expand All @@ -84,7 +114,13 @@ describe("buildAnalytics", () => {
netAmount: { asset: "USDC", amount: 9_900_000n },
fee: { asset: "USDC", amount: 100_000n },
} as ClearingTransaction;
const settled = buildAnalytics({ payments, clearing: [tx], query: query(), now: NOW });
const settled = buildAnalytics({
payments,
pruned: [],
clearing: [tx],
query: query(),
now: NOW,
});
expect(metric(settled, "net_revenue")?.total).toBe(990);
expect(metric(settled, "fees")?.total).toBe(10);
expect(metric(settled, "settlements")?.total).toBe(1);
Expand All @@ -100,6 +136,7 @@ describe("buildAnalytics", () => {
test("a network filter keeps only payments on it", () => {
const other = buildAnalytics({
payments,
pruned: [],
clearing: [],
query: query({ chain: "arc-testnet" }),
now: NOW,
Expand All @@ -110,6 +147,7 @@ describe("buildAnalytics", () => {
test("metrics narrows the response to the keys asked for", () => {
const some = buildAnalytics({
payments,
pruned: [],
clearing: [],
query: query({ metrics: "orders, success_rate" }),
now: NOW,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/test/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function createApiHarness(options: ApiHarnessOptions = {}) {
const registry = new InMemoryStablecoinRegistry(config.stablecoins);

const intentRepository = new InMemoryPaymentIntentRepository();
const expiredPayments = { prune: async () => 0, prunedCounts: async () => [] };
// The fraud gate over in-memory history. `market` is built further down, which is
// fine: the rules are read when a payment is confirmed, not when this is built.
const riskHistory = new InMemoryRiskHistory();
Expand Down Expand Up @@ -364,8 +365,10 @@ export function createApiHarness(options: ApiHarnessOptions = {}) {
analytics: new AnalyticsService({
intents: intentRepository,
clearing: clearingRepository,
pruned: expiredPayments,
clock,
}),
expiredPayments,
escrow: new EscrowService(paymentApp, {}, escrowDisputes, clock, {
opened: async () => {},
message: async (dispute, message) => {
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/src/components/payment-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ function reasonOf(error: unknown): string {
return match(error)
.when(
(e): e is ApiError => e instanceof ApiError && e.status === 404,
() => "No payment with that id belongs to this merchant.",
() =>
"This payment is not available. It may belong to another merchant, or it went unpaid and was removed two days after it expired.",
)
.when(
(e): e is ApiError => e instanceof ApiError,
Expand Down
Loading
Loading