diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index f50a0ca..fe8d2f0 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -5,6 +5,7 @@ import type { ProviderExecutionMetadata, PaymentSource, QueryMode, + SettlementDigest, UsageEvent } from "@query402/shared"; import { config } from "./config.js"; @@ -129,6 +130,10 @@ export async function getAnalyticsSummary( return getStorageRepository().getAnalyticsSummary(options); } +export async function getSettlementDigest(): Promise { + return getStorageRepository().getSettlementDigest(); +} + export async function persistPaidRequest(input: PersistPaidRequestInput): Promise { const payment = buildPaymentAttempt(input); const usage = buildUsageEvent(input, { diff --git a/apps/api/src/lib/storage/memory.ts b/apps/api/src/lib/storage/memory.ts index 23f2e46..b3c0a75 100644 --- a/apps/api/src/lib/storage/memory.ts +++ b/apps/api/src/lib/storage/memory.ts @@ -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 { @@ -15,6 +20,42 @@ function trimNewest(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>( + (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((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[] = []; @@ -82,6 +123,10 @@ export class InMemoryStorageRepository implements StorageRepository { return buildAnalyticsSummary(this.usage, this.payments, options); } + async getSettlementDigest(): Promise { + return buildSettlementDigest(this.payments); + } + private purgeExpiredIdempotency(key: string): void { const record = this.idempotency.get(key); if (!record) { diff --git a/apps/api/src/lib/storage/serialization.ts b/apps/api/src/lib/storage/serialization.ts index 50b21fe..5c6f9dd 100644 --- a/apps/api/src/lib/storage/serialization.ts +++ b/apps/api/src/lib/storage/serialization.ts @@ -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>((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; @@ -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) }; @@ -235,9 +217,7 @@ export function rowToUsageEvent(row: Record): 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 }; } diff --git a/apps/api/src/lib/storage/sqlite/repository.ts b/apps/api/src/lib/storage/sqlite/repository.ts index 076dafe..e183cb6 100644 --- a/apps/api/src/lib/storage/sqlite/repository.ts +++ b/apps/api/src/lib/storage/sqlite/repository.ts @@ -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, @@ -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>( + (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((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(operation: () => T): Promise { return new Promise((resolve, reject) => { setImmediate(() => { @@ -212,6 +253,17 @@ export class SqliteStorageRepository implements StorageRepository { }); } + async getSettlementDigest(): Promise { + return defer(() => { + const database = getAnalyticsDb(this.dbPath); + const paymentRows = database + .prepare(`SELECT * FROM payment_attempts ORDER BY created_at DESC`) + .all() as Record[]; + + return buildSettlementDigest(paymentRows.map(rowToPaymentAttempt)); + }); + } + async acquireIdempotencyLock( key: string, requestHash: string, diff --git a/apps/api/src/lib/storage/types.ts b/apps/api/src/lib/storage/types.ts index 75d3b38..4f0e897 100644 --- a/apps/api/src/lib/storage/types.ts +++ b/apps/api/src/lib/storage/types.ts @@ -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; @@ -39,6 +44,7 @@ export interface StorageRepository { getUsageEvents(options?: PaginationOptions): Promise; getPaymentAttempts(options?: PaginationOptions): Promise; getAnalyticsSummary(options?: AnalyticsQueryOptions): Promise; + getSettlementDigest(): Promise; acquireIdempotencyLock( key: string, diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index 0aea27c..584c908 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -2,7 +2,8 @@ import express from "express"; import request from "supertest"; import { providerCapabilitySchema } from "@query402/shared"; 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", () => { @@ -203,102 +204,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 () => { diff --git a/apps/api/src/routes/public.ts b/apps/api/src/routes/public.ts index 43e9fb4..84a8ad3 100644 --- a/apps/api/src/routes/public.ts +++ b/apps/api/src/routes/public.ts @@ -90,3 +90,12 @@ publicRouter.get("/api/analytics", async (req, res, next) => { next(error); } }); + +publicRouter.get("/api/audit/digest", async (_req, res, next) => { + try { + const digest = await getSettlementDigest(); + res.json(digest); + } catch (error) { + next(error); + } +}); diff --git a/packages/shared/src/schemas.ts b/packages/shared/src/schemas.ts index c759b39..69b4f98 100644 --- a/packages/shared/src/schemas.ts +++ b/packages/shared/src/schemas.ts @@ -116,3 +116,13 @@ export const sponsorshipPreviewResponseSchema = z.object({ }), reason: z.string().optional() }); + +export const settlementDigestSchema = z.object({ + totalPaidRuns: z.number().int().nonnegative(), + totalSettledAmountUsd: z.number().nonnegative(), + settledAmountByAssetNetwork: z.record(z.string(), z.number().nonnegative()), + withPaymentEvidence: z.number().int().nonnegative(), + missingPaymentEvidence: z.number().int().nonnegative(), + latestPaymentTimestamp: z.string().datetime({ offset: true }).nullable(), + generatedAt: z.string().datetime({ offset: true }) +});