Skip to content
Open
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
5 changes: 5 additions & 0 deletions apps/api/src/lib/persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ProviderExecutionMetadata,
PaymentSource,
QueryMode,
SettlementDigest,
UsageEvent
} from "@query402/shared";
import { config } from "./config.js";
Expand Down Expand Up @@ -129,6 +130,10 @@ export async function getAnalyticsSummary(
return getStorageRepository().getAnalyticsSummary(options);
}

export async function getSettlementDigest(): Promise<SettlementDigest> {
return getStorageRepository().getSettlementDigest();
}

export async function persistPaidRequest(input: PersistPaidRequestInput): Promise<void> {
const payment = buildPaymentAttempt(input);
const usage = buildUsageEvent(input, {
Expand Down
47 changes: 46 additions & 1 deletion apps/api/src/lib/storage/memory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AnalyticsSummary, PaymentAttempt, UsageEvent } from "@query402/shared";
import type {
AnalyticsSummary,
PaymentAttempt,
SettlementDigest,
UsageEvent
} from "@query402/shared";
import { MAX_PAYMENT_ATTEMPTS, MAX_USAGE_EVENTS } from "./constants.js";
import { buildAnalyticsSummary } from "./serialization.js";
import type {
Expand All @@ -15,6 +20,42 @@ function trimNewest<T>(items: T[], max: number): T[] {
return items.slice(0, max);
}

function buildSettlementDigest(payments: PaymentAttempt[]): SettlementDigest {
const settledPayments = payments.filter((payment) => payment.status === "settled");
const settledAmountByAssetNetwork = settledPayments.reduce<Record<string, number>>(
(acc, payment) => {
const key =
payment.asset && payment.network ? `${payment.asset}:${payment.network}` : payment.network;
acc[key] = (acc[key] ?? 0) + payment.amountUsd;
return acc;
},
{}
);

const withPaymentEvidence = settledPayments.filter((payment) => {
return Boolean(payment.transactionHash || payment.facilitatorResult || payment.evidenceKind);
}).length;

const latestPaymentTimestamp = settledPayments.reduce<string | null>((latest, payment) => {
if (!latest || payment.createdAt > latest) {
return payment.createdAt;
}
return latest;
}, null);

return {
totalPaidRuns: settledPayments.length,
totalSettledAmountUsd: Number(
settledPayments.reduce((sum, payment) => sum + payment.amountUsd, 0).toFixed(6)
),
settledAmountByAssetNetwork,
withPaymentEvidence,
missingPaymentEvidence: settledPayments.length - withPaymentEvidence,
latestPaymentTimestamp,
generatedAt: new Date().toISOString()
};
}

export class InMemoryStorageRepository implements StorageRepository {
private usage: UsageEvent[] = [];
private payments: PaymentAttempt[] = [];
Expand Down Expand Up @@ -82,6 +123,10 @@ export class InMemoryStorageRepository implements StorageRepository {
return buildAnalyticsSummary(this.usage, this.payments, options);
}

async getSettlementDigest(): Promise<SettlementDigest> {
return buildSettlementDigest(this.payments);
}

private purgeExpiredIdempotency(key: string): void {
const record = this.idempotency.get(key);
if (!record) {
Expand Down
22 changes: 1 addition & 21 deletions apps/api/src/lib/storage/serialization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,15 +112,6 @@ export function buildAnalyticsSummary(
emptyExecutionSummary()
);

const totalDemoQueries = usage.filter((e) => e.paymentStatus === "demo-paid").length;
const totalSettledPayments = usage.filter((e) => e.paymentStatus === "settled").length;

const spendByPaymentSource = payments.reduce<Record<string, number>>((acc, p) => {
const source = p.paymentSource ?? "unknown";
acc[source] = Number(((acc[source] ?? 0) + p.amountUsd).toFixed(6));
return acc;
}, {});

const recentUsageLimit = options?.recentUsageLimit ?? DEFAULT_RECENT_LIMIT;
const recentPaymentLimit = options?.recentPaymentLimit ?? DEFAULT_RECENT_LIMIT;

Expand All @@ -134,15 +125,6 @@ export function buildAnalyticsSummary(
settledSpendByCategory,
demoSpendByCategory,
executionSummary,
totalDemoQueries,
totalSettledPayments,
spendByPaymentSource,
recentDemoActivity: payments
.filter((p) => p.status === "demo-paid")
.slice(0, recentPaymentLimit),
recentSettledPayments: payments
.filter((p) => p.status === "settled")
.slice(0, recentPaymentLimit),
recentTransactions: payments.slice(0, recentPaymentLimit),
recentUsage: usage.slice(0, recentUsageLimit)
};
Expand Down Expand Up @@ -235,9 +217,7 @@ export function rowToUsageEvent(row: Record<string, unknown>): UsageEvent {
: undefined,
sponsorPublicKey: row.sponsor_public_key ? String(row.sponsor_public_key) : undefined,
priceOutlier: priceOutlier || undefined,
priceOutlierReason: row.price_outlier_reason
? String(row.price_outlier_reason)
: undefined
priceOutlierReason: row.price_outlier_reason ? String(row.price_outlier_reason) : undefined
};
}

Expand Down
54 changes: 53 additions & 1 deletion apps/api/src/lib/storage/sqlite/repository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import type Database from "better-sqlite3";
import type { AnalyticsSummary, PaymentAttempt, UsageEvent } from "@query402/shared";
import type {
AnalyticsSummary,
PaymentAttempt,
SettlementDigest,
UsageEvent
} from "@query402/shared";
import { DEFAULT_RECENT_LIMIT, MAX_PAYMENT_ATTEMPTS, MAX_USAGE_EVENTS } from "../constants.js";
import {
buildAnalyticsSummary,
Expand Down Expand Up @@ -75,6 +80,42 @@ function trimPaymentAttempts(database: Database.Database): void {
`);
}

function buildSettlementDigest(payments: PaymentAttempt[]): SettlementDigest {
const settledPayments = payments.filter((payment) => payment.status === "settled");
const settledAmountByAssetNetwork = settledPayments.reduce<Record<string, number>>(
(acc, payment) => {
const key =
payment.asset && payment.network ? `${payment.asset}:${payment.network}` : payment.network;
acc[key] = (acc[key] ?? 0) + payment.amountUsd;
return acc;
},
{}
);

const withPaymentEvidence = settledPayments.filter((payment) => {
return Boolean(payment.transactionHash || payment.facilitatorResult || payment.evidenceKind);
}).length;

const latestPaymentTimestamp = settledPayments.reduce<string | null>((latest, payment) => {
if (!latest || payment.createdAt > latest) {
return payment.createdAt;
}
return latest;
}, null);

return {
totalPaidRuns: settledPayments.length,
totalSettledAmountUsd: Number(
settledPayments.reduce((sum, payment) => sum + payment.amountUsd, 0).toFixed(6)
),
settledAmountByAssetNetwork,
withPaymentEvidence,
missingPaymentEvidence: settledPayments.length - withPaymentEvidence,
latestPaymentTimestamp,
generatedAt: new Date().toISOString()
};
}

function defer<T>(operation: () => T): Promise<T> {
return new Promise((resolve, reject) => {
setImmediate(() => {
Expand Down Expand Up @@ -212,6 +253,17 @@ export class SqliteStorageRepository implements StorageRepository {
});
}

async getSettlementDigest(): Promise<SettlementDigest> {
return defer(() => {
const database = getAnalyticsDb(this.dbPath);
const paymentRows = database
.prepare(`SELECT * FROM payment_attempts ORDER BY created_at DESC`)
.all() as Record<string, unknown>[];

return buildSettlementDigest(paymentRows.map(rowToPaymentAttempt));
});
}

async acquireIdempotencyLock(
key: string,
requestHash: string,
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/lib/storage/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AnalyticsSummary, PaymentAttempt, UsageEvent } from "@query402/shared";
import type {
AnalyticsSummary,
PaymentAttempt,
UsageEvent,
SettlementDigest
} from "@query402/shared";

export interface PaginationOptions {
limit?: number;
Expand Down Expand Up @@ -39,6 +44,7 @@ export interface StorageRepository {
getUsageEvents(options?: PaginationOptions): Promise<UsageEvent[]>;
getPaymentAttempts(options?: PaginationOptions): Promise<PaymentAttempt[]>;
getAnalyticsSummary(options?: AnalyticsQueryOptions): Promise<AnalyticsSummary>;
getSettlementDigest(): Promise<SettlementDigest>;

acquireIdempotencyLock(
key: string,
Expand Down
138 changes: 51 additions & 87 deletions apps/api/src/routes/public.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import express from "express";
import request from "supertest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { buildPaidQueryFixture, buildTestUsageEvent } from "../test/storage-test-helpers.js";
import { persistPaymentAndUsage } from "../lib/persistence.js";
import { buildTestPaymentAttempt, buildTestUsageEvent } from "../test/storage-test-helpers.js";
import { applyApiTestEnv, resetApiTestStorage } from "../test/api-test-helpers.js";

describe("public routes", () => {
Expand Down Expand Up @@ -202,102 +203,65 @@ describe("public routes", () => {
expect(catalogResponse.body.byCategory.scrape.length).toBeGreaterThan(0);
});

describe("paid query fixture", () => {
it("analytics reflects settled paid query from fixture", async () => {
const { persistPaymentAndUsage } = await import("../lib/persistence.js");
await persistPaymentAndUsage(buildPaidQueryFixture());

const app = await createPublicApp();
const analyticsResponse = await request(app).get("/api/analytics");

expect(analyticsResponse.status).toBe(200);
expect(analyticsResponse.body).toMatchObject({
totalQueries: 1,
totalSpendUsd: 0.01,
settledSpendUsd: 0.01,
demoSpendUsd: 0,
spendByCategory: { search: 0.01, news: 0, scrape: 0 },
executionSummary: {
totalExecutions: 1,
liveExecutions: 1,
fallbackExecutions: 0
}
});
});

it("analytics recentUsage and recentTransactions carry fixture evidence fields", async () => {
const { persistPaymentAndUsage } = await import("../lib/persistence.js");
await persistPaymentAndUsage(buildPaidQueryFixture());

const app = await createPublicApp();
const analyticsResponse = await request(app).get("/api/analytics");

expect(analyticsResponse.status).toBe(200);
it("returns an empty settlement digest when no paid runs are recorded", async () => {
const app = await createPublicApp();

const { recentUsage, recentTransactions } = analyticsResponse.body;
const response = await request(app).get("/api/audit/digest");

expect(recentUsage).toHaveLength(1);
expect(recentUsage[0]).toMatchObject({
id: "use_fixture_0001",
mode: "search",
providerId: "search.basic",
paymentStatus: "settled",
paymentKind: "settled",
asset: "USDC:testnet",
traceId: "trace_fixture_0001",
createdAt: "2026-06-30T12:00:00.000Z"
});

expect(recentTransactions).toHaveLength(1);
expect(recentTransactions[0]).toMatchObject({
id: "pay_fixture_0001",
providerId: "search.basic",
amountUsd: 0.01,
evidenceKind: "settled",
asset: "USDC:testnet",
status: "settled"
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
totalPaidRuns: 0,
totalSettledAmountUsd: 0,
settledAmountByAssetNetwork: {},
withPaymentEvidence: 0,
missingPaymentEvidence: 0,
latestPaymentTimestamp: null
});
expect(response.body.generatedAt).toEqual(expect.any(String));
});

it("fixture data is unchanged across multiple insertions into separate stores", async () => {
const first = buildPaidQueryFixture();
const second = buildPaidQueryFixture();

expect(first.payment).toEqual(second.payment);
expect(first.usage).toEqual(second.usage);
it("returns a populated settlement digest for recorded paid runs", async () => {
const firstPayment = buildTestPaymentAttempt({
id: "pay_001",
amountUsd: 1.25,
createdAt: "2026-06-21T10:00:00.000Z",
transactionHash: "tx_001"
});
const firstUsage = buildTestUsageEvent({
id: "use_001",
createdAt: firstPayment.createdAt,
paymentStatus: "settled"
});

it("demo variant records correct payment markers via fixture overrides", async () => {
const { persistPaymentAndUsage } = await import("../lib/persistence.js");
await persistPaymentAndUsage(
buildPaidQueryFixture({
payment: { id: "pay_fixture_demo_01", status: "demo-paid", evidenceKind: "demo", transactionHash: undefined },
usage: { id: "use_fixture_demo_01", paymentStatus: "demo-paid", paymentKind: "demo", paymentTxHash: undefined }
})
);
const secondPayment = buildTestPaymentAttempt({
id: "pay_002",
amountUsd: 0.5,
createdAt: "2026-06-21T10:05:00.000Z"
});
const secondUsage = buildTestUsageEvent({
id: "use_002",
createdAt: secondPayment.createdAt,
paymentStatus: "settled"
});

const app = await createPublicApp();
const analyticsResponse = await request(app).get("/api/analytics");
await persistPaymentAndUsage({ payment: firstPayment, usage: firstUsage });
await persistPaymentAndUsage({ payment: secondPayment, usage: secondUsage });

expect(analyticsResponse.status).toBe(200);
expect(analyticsResponse.body).toMatchObject({
totalQueries: 1,
demoSpendUsd: 0.01,
settledSpendUsd: 0
});
const app = await createPublicApp();
const response = await request(app).get("/api/audit/digest");

const { recentUsage, recentTransactions } = analyticsResponse.body;
expect(recentUsage[0]).toMatchObject({
id: "use_fixture_demo_01",
paymentStatus: "demo-paid",
paymentKind: "demo"
});
expect(recentTransactions[0]).toMatchObject({
id: "pay_fixture_demo_01",
status: "demo-paid",
evidenceKind: "demo"
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
totalPaidRuns: 2,
totalSettledAmountUsd: 1.75,
settledAmountByAssetNetwork: {
"stellar:testnet": 1.75
},
withPaymentEvidence: 1,
missingPaymentEvidence: 1,
latestPaymentTimestamp: secondPayment.createdAt
});
expect(response.body.generatedAt).toEqual(expect.any(String));
});

it("returns safe default analytics shape for fresh storage", async () => {
Expand Down
Loading