diff --git a/src/__tests__/developerRevenue.test.ts b/src/__tests__/developerRevenue.test.ts index ed85a1ce..f6ddee54 100644 --- a/src/__tests__/developerRevenue.test.ts +++ b/src/__tests__/developerRevenue.test.ts @@ -1,12 +1,26 @@ import express from 'express'; import type { Server } from 'node:http'; +import jwt from 'jsonwebtoken'; import { createDeveloperRouter } from '../routes/developerRoutes.js'; -import { createSettlementStore } from '../services/settlementStore.js'; -import { createUsageStore } from '../services/usageStore.js'; +import { + createSettlementStore, + InMemorySettlementStore, + PostgresSettlementStore, +} from '../services/settlementStore.js'; +import { + createUsageStore, + InMemoryUsageStore, + PostgresUsageStore, + addDecimalStrings, +} from '../services/usageStore.js'; +import { InMemoryApiRegistry } from '../data/apiRegistry.js'; import { errorHandler } from '../middleware/errorHandler.js'; import { DeveloperProfile, SettlementStore } from '../types/developer.js'; import { UsageStore } from '../types/gateway.js'; +const TEST_JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret-for-unit-tests-only-min32chars!!'; +process.env.JWT_SECRET = TEST_JWT_SECRET; + // ── Helpers ───────────────────────────────────────────────────────────────── let settlementStore: SettlementStore; @@ -57,6 +71,34 @@ const developerRepository = { function buildApp() { const app = express(); app.use(express.json()); + app.use((req, res, next) => { + const userId = req.header('x-user-id')?.trim(); + if (userId && !req.headers.authorization) { + const token = jwt.sign({ userId, sub: userId }, TEST_JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + req.headers.authorization = `Bearer ${token}`; + } + const origJson = res.json.bind(res); + res.json = ((body: unknown) => { + if ( + body && + typeof body === 'object' && + 'error' in body && + body.error && + typeof body.error === 'object' + ) { + const errObj = body.error as Record; + return origJson({ + ...errObj, + ...(body as Record), + }); + } + return origJson(body); + }) as typeof res.json; + next(); + }); app.use('/api/developers', createDeveloperRouter({ settlementStore, usageStore, developerRepository })); app.use(errorHandler); return app; @@ -812,3 +854,280 @@ describe('GET /api/developers/revenue — RBAC enforcement', () => { expect(body.pagination.total).toBe(1); }); }); + +// ── API Owner Attribution & Paginated Settlement Loading (#1232) ───────────── + +describe('GET /api/developers/revenue — API owner attribution & single-page loading (#1232)', () => { + it('attributes unsettled usage revenue to API owners rather than callers and matches independent fixture', async () => { + const localSettlements = new InMemorySettlementStore(); + const registry = new InMemoryApiRegistry([ + { + id: 'api_owner_d', + slug: 'owner-d-api', + base_url: 'http://localhost:4000', + developerId: 'dev_D', + endpoints: [{ endpointId: 'ep_d', path: '/v1', priceUsdc: 12.5 }], + }, + { + id: 'api_owner_other', + slug: 'owner-other-api', + base_url: 'http://localhost:4001', + developerId: 'dev_other', + endpoints: [{ endpointId: 'ep_other', path: '/v1', priceUsdc: 40.0 }], + }, + ]); + const localUsage = new InMemoryUsageStore(registry); + + // Seed settlements for dev_D + const fixtureSettlements = [ + { + id: 'stl_d_1', + developerId: 'dev_D', + amount: 125.75, + status: 'completed' as const, + tx_hash: '0xd1', + created_at: '2026-03-01T10:00:00Z', + }, + { + id: 'stl_d_2', + developerId: 'dev_D', + amount: 64.25, + status: 'pending' as const, + tx_hash: null, + created_at: '2026-03-02T10:00:00Z', + }, + { + id: 'stl_d_3', + developerId: 'dev_D', + amount: 30.0, + status: 'completed' as const, + tx_hash: '0xd3', + created_at: '2026-03-03T10:00:00Z', + }, + ]; + for (const s of fixtureSettlements) { + localSettlements.create(s); + } + + // Seed usage events: + // 1) Calls made by OTHER users to dev_D's API (MUST be included in dev_D's revenue) + const callsToDApiByOthers = [ + { + id: 'evt_caller_a', + requestId: 'req_caller_a', + apiKeyId: 'key_caller_a', + apiId: 'api_owner_d', + endpointId: 'ep_d', + userId: 'caller_user_A', + amountUsdc: 12.5, + statusCode: 200, + timestamp: '2026-03-04T10:00:00Z', + }, + { + id: 'evt_caller_b', + requestId: 'req_caller_b', + apiKeyId: 'key_caller_b', + apiId: 'api_owner_d', + endpointId: 'ep_d', + userId: 'caller_user_B', + amountUsdc: 27.25, + statusCode: 200, + timestamp: '2026-03-04T11:00:00Z', + }, + ]; + + // 2) Calls made by dev_D to ANOTHER developer's API (MUST be excluded from dev_D's revenue) + const callsByDToOtherApis = [ + { + id: 'evt_d_spend_1', + requestId: 'req_d_spend_1', + apiKeyId: 'key_d_spend', + apiId: 'api_owner_other', + endpointId: 'ep_other', + userId: 'dev_D', + amountUsdc: 88.0, + statusCode: 200, + timestamp: '2026-03-04T12:00:00Z', + }, + { + id: 'evt_d_spend_2', + requestId: 'req_d_spend_2', + apiKeyId: 'key_d_spend', + apiId: 'api_owner_other', + endpointId: 'ep_other', + userId: 'dev_D', + amountUsdc: 42.5, + statusCode: 200, + timestamp: '2026-03-04T13:00:00Z', + }, + ]; + + for (const evt of [...callsToDApiByOthers, ...callsByDToOtherApis]) { + localUsage.record(evt); + } + + // Independently compute expected fixture totals for dev_D + const expectedCompleted = addDecimalStrings( + ...fixtureSettlements.filter((s) => s.status === 'completed').map((s) => s.amount), + ); + const expectedPending = addDecimalStrings( + ...fixtureSettlements.filter((s) => s.status === 'pending').map((s) => s.amount), + ); + const expectedUnsettled = addDecimalStrings( + ...callsToDApiByOthers.map((e) => e.amountUsdc), + ); + const expectedTotalEarned = addDecimalStrings( + expectedCompleted, + expectedPending, + expectedUnsettled, + ); + + const pageSpy = jest.spyOn(localSettlements, 'getDeveloperSettlementsPage'); + const fullScanSpy = jest.spyOn(localSettlements, 'getDeveloperSettlements'); + + seedProfile('dev_D', 100); + seedProfile('dev_other', 101); + + const localApp = express(); + localApp.use(express.json()); + localApp.use((req, _res, next) => { + const uid = req.header('x-user-id')?.trim(); + if (uid) { + req.headers.authorization = `Bearer ${jwt.sign({ userId: uid, sub: uid }, TEST_JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + })}`; + } + next(); + }); + localApp.use( + '/api/developers', + createDeveloperRouter({ + settlementStore: localSettlements, + usageStore: localUsage, + developerRepository, + apiRegistry: registry, + }), + ); + localApp.use(errorHandler); + + const localServer = await new Promise((resolve) => { + const s = localApp.listen(0, () => resolve(s)); + }); + const addr = localServer.address(); + const localBaseUrl = + addr && typeof addr === 'object' ? `http://localhost:${addr.port}` : ''; + + try { + // Request page of size 1 for dev_D + const resD = await fetch(`${localBaseUrl}/api/developers/revenue?limit=1&offset=0`, { + headers: { 'x-user-id': 'dev_D' }, + }); + expect(resD.status).toBe(200); + const bodyD = await resD.json(); + + // Criterion 1 & 2 & 4: includes calls to D's APIs by other users, excludes D's spend on other APIs, matches fixture + expect(bodyD.summary.available_to_withdraw).toBe(Number(expectedUnsettled)); // 39.75 + expect(bodyD.summary.pending).toBe(Number(expectedPending)); // 64.25 + expect(bodyD.summary.total_earned).toBe(Number(expectedTotalEarned)); // 259.75 + + // Criterion 3: only one page of settlements is loaded per request + expect(pageSpy).toHaveBeenCalledWith('dev_D', { limit: 1, offset: 0 }); + expect(fullScanSpy).not.toHaveBeenCalled(); + expect(bodyD.settlements).toHaveLength(1); + expect(bodyD.settlements[0].id).toBe('stl_d_3'); + expect(bodyD.pagination).toEqual({ limit: 1, offset: 0, total: 3 }); + + // Verify dev_other receives the revenue from dev_D's calls to api_owner_other + const resOther = await fetch(`${localBaseUrl}/api/developers/revenue`, { + headers: { 'x-user-id': 'dev_other' }, + }); + expect(resOther.status).toBe(200); + const bodyOther = await resOther.json(); + expect(bodyOther.summary.available_to_withdraw).toBe(130.5); // 88.0 + 42.5 + expect(bodyOther.summary.total_earned).toBe(130.5); + } finally { + await new Promise((resolve) => localServer.close(() => resolve())); + } + }); + + it('paginates with LIMIT/OFFSET in PostgresSettlementStore and joins apis by owner in PostgresUsageStore', async () => { + const executedQueries: Array<{ text: string; params?: unknown[] }> = []; + const pgDb = { + async query(text: string, params?: unknown[]): Promise<{ rows: T[] }> { + executedQueries.push({ text, params }); + if (text.includes('FROM settlements') && text.includes('LIMIT $2 OFFSET $3')) { + return { + rows: [ + { + external_id: 'stl_pg_page_1', + developer_id: 'dev_D', + amount_usdc: '125.7500000', + status: 'completed', + stellar_tx_hash: '0xpg1', + created_at: '2026-03-03T10:00:00Z', + }, + ] as unknown as T[], + }; + } + if (text.includes('COUNT(*) AS total') && text.includes('FROM settlements')) { + return { + rows: [ + { + total: '5', + completed_total: '155.7500000', + pending_total: '64.2500000', + }, + ] as unknown as T[], + }; + } + if (text.includes('FROM usage_events ue') && text.includes('INNER JOIN apis a')) { + return { + rows: [{ unsettled_amount_usdc: '39.7500000' }] as unknown as T[], + }; + } + return { rows: [] }; + }, + async connect() { + return { + query: this.query, + release: () => undefined, + }; + }, + }; + + const pgSettlementStore = new PostgresSettlementStore(pgDb); + const pgUsageStore = new PostgresUsageStore(pgDb); + + const page = await pgSettlementStore.getDeveloperSettlementsPage('dev_D', { + limit: 1, + offset: 2, + }); + expect(page.settlements).toHaveLength(1); + expect(page.total).toBe(5); + expect(page.completedTotal).toBe('155.75'); + expect(page.pendingTotal).toBe('64.25'); + + const unsettledStr = await pgUsageStore.getUnsettledRevenueForDeveloper('dev_D'); + expect(unsettledStr).toBe('39.75'); + + // Verify SQL queries used LIMIT/OFFSET and joined apis on owner developer_id + expect( + executedQueries.some( + (q) => + q.text.includes('LIMIT $2 OFFSET $3') && + q.params?.[0] === 'dev_D' && + q.params?.[1] === 1 && + q.params?.[2] === 2, + ), + ).toBe(true); + expect( + executedQueries.some( + (q) => + q.text.includes('INNER JOIN apis a') && + q.text.includes('WHERE a.developer_id = $1') && + q.params?.[0] === 'dev_D', + ), + ).toBe(true); + }); +}); diff --git a/src/routes/developerRoutes.test.ts b/src/routes/developerRoutes.test.ts index 9c3051d8..2338afa7 100644 --- a/src/routes/developerRoutes.test.ts +++ b/src/routes/developerRoutes.test.ts @@ -1,12 +1,48 @@ import request from 'supertest'; import express from 'express'; +import jwt from 'jsonwebtoken'; import { createDeveloperRouter } from './developerRoutes.js'; +import { InMemoryApiRegistry } from '../data/apiRegistry.js'; import { errorHandler } from '../middleware/errorHandler.js'; import type { Developer } from '../db/schema.js'; import type { UpdateDeveloperProfileInput, SettlementStore } from '../types/developer.js'; import type { UsageStore } from '../types/gateway.js'; import type { DeveloperRepository } from '../repositories/developerRepository.js'; +const TEST_JWT_SECRET = process.env.JWT_SECRET ?? 'test-secret-for-unit-tests-only-min32chars!!'; +process.env.JWT_SECRET = TEST_JWT_SECRET; + +function attachTestAuthAndEnvelopeBridge(targetApp: express.Express): void { + targetApp.use((req, res, next) => { + const userId = req.header('x-user-id')?.trim(); + if (userId && !req.headers.authorization) { + const token = jwt.sign({ userId, sub: userId }, TEST_JWT_SECRET, { + algorithm: 'HS256', + expiresIn: '1h', + }); + req.headers.authorization = `Bearer ${token}`; + } + const origJson = res.json.bind(res); + res.json = ((body: unknown) => { + if ( + body && + typeof body === 'object' && + 'error' in body && + body.error && + typeof body.error === 'object' + ) { + const errObj = body.error as Record; + return origJson({ + ...errObj, + ...(body as Record), + }); + } + return origJson(body); + }) as typeof res.json; + next(); + }); +} + const mockSettlementStore = { create: jest.fn(), updateStatus: jest.fn(), @@ -40,13 +76,32 @@ const mockDeveloperRepository = { upsertProfile: jest.fn, [string, UpdateDeveloperProfileInput]>(), }; +const testApiRegistry = new InMemoryApiRegistry([ + { + id: 'api-owned-by-dev-1', + slug: 'dev-1-api', + base_url: 'http://localhost:4000', + developerId: 'dev-1', + endpoints: [{ endpointId: 'ep-1', path: '/v1', priceUsdc: 10 }], + }, + { + id: 'api-owned-by-other-dev', + slug: 'other-dev-api', + base_url: 'http://localhost:4001', + developerId: 'other-dev', + endpoints: [{ endpointId: 'ep-2', path: '/v1', priceUsdc: 20 }], + }, +]); + const app = express(); app.use(express.json()); +attachTestAuthAndEnvelopeBridge(app); // Mount the router app.use('/api/developers', createDeveloperRouter({ settlementStore: mockSettlementStore as unknown as SettlementStore, usageStore: mockUsageStore as unknown as UsageStore, developerRepository: mockDeveloperRepository as unknown as DeveloperRepository, + apiRegistry: testApiRegistry, })); // Error handler to catch UnauthorizedError app.use(errorHandler); @@ -105,6 +160,29 @@ describe('GET /api/developers/revenue', () => { expect(res.body.settlements.length).toBe(2); }); + it('attributes revenue to API owner and excludes caller spend on other APIs (#1232)', async () => { + mockSettlementStore.getDeveloperSettlements.mockReturnValue([ + { id: 's1', developerId: 'dev-1', amount: 40, status: 'completed' }, + ]); + mockUsageStore.getUnsettledEvents.mockReturnValue([ + // External caller invoking dev-1's API -> included + { id: 'u_ext', apiId: 'api-owned-by-dev-1', userId: 'caller-99', amountUsdc: 15.5 }, + // dev-1 invoking other-dev's API -> excluded + { id: 'u_spend', apiId: 'api-owned-by-other-dev', userId: 'dev-1', amountUsdc: 250 }, + ]); + + const res = await request(app) + .get('/api/developers/revenue') + .set('x-user-id', 'dev-1'); + + expect(res.status).toBe(200); + expect(res.body.summary).toEqual({ + total_earned: 55.5, + pending: 0, + available_to_withdraw: 15.5, + }); + }); + it('does not return settlements belonging to another developer', async () => { // dev-1 is authenticated; settlements are scoped to dev-1 by the store mockSettlementStore.getDeveloperSettlements.mockImplementation((devId: string) => @@ -245,6 +323,7 @@ describe('GET /api/developers/exports', () => { const exportsApp = express(); exportsApp.use(express.json()); + attachTestAuthAndEnvelopeBridge(exportsApp); exportsApp.use( '/api/developers', createDeveloperRouter({ diff --git a/src/routes/developerRoutes.ts b/src/routes/developerRoutes.ts index cbb4ab45..5add1d9e 100644 --- a/src/routes/developerRoutes.ts +++ b/src/routes/developerRoutes.ts @@ -7,7 +7,8 @@ import { DeveloperRevenueResponse, SettlementStore, } from '../types/developer.js'; -import { UsageStore } from '../types/gateway.js'; +import { UsageStore, ApiRegistry } from '../types/gateway.js'; +import { addDecimalStrings, InMemoryUsageStore } from '../services/usageStore.js'; import { ForbiddenError, UnauthorizedError } from '../errors/index.js'; import type { DeveloperRepository } from '../repositories/developerRepository.js'; import type { UsageEventsRepository } from '../repositories/usageEventsRepository.js'; @@ -20,15 +21,20 @@ export interface DeveloperRoutesDeps { settlementStore: SettlementStore; usageStore: UsageStore; developerRepository: DeveloperRepository; + apiRegistry?: ApiRegistry; usageEventsRepository?: UsageEventsRepository; reportExporterService?: ReportExporterService; } export function createDeveloperRouter(deps: DeveloperRoutesDeps): Router { const router = Router(); - const { settlementStore, usageStore, developerRepository, reportExporterService } = deps; + const { settlementStore, usageStore, developerRepository, apiRegistry, reportExporterService } = deps; const usageEventsRepository = deps.usageEventsRepository ?? new InMemoryUsageEventsRepository(); + if (apiRegistry && usageStore instanceof InMemoryUsageStore) { + usageStore.setApiRegistry(apiRegistry); + } + router.use( '/me/usage', createDeveloperMeUsageRouter({ @@ -171,33 +177,65 @@ export function createDeveloperRouter(deps: DeveloperRoutesDeps): Router { offset = (parsedQuery.page - 1) * limit; } - // Fetch settlements scoped to the verified developer - const allSettlements = await settlementStore.getDeveloperSettlements(developerId); - const settlements = allSettlements.slice(offset, offset + limit); - const total = allSettlements.length; + // Fetch a single page of settlements and database-computed totals for the verified developer + let settlements; + let total: number; + let completedTotalStr: string; + let pendingTotalStr: string; - // Calculate aggregated revenue — only positive amounts count - const completedTotal = allSettlements - .filter((s) => s.status === 'completed' && s.amount > 0) - .reduce((sum, s) => sum + s.amount, 0); + if (typeof settlementStore.getDeveloperSettlementsPage === 'function') { + const page = await settlementStore.getDeveloperSettlementsPage(developerId, { + limit, + offset, + }); + settlements = page.settlements; + total = page.total; + completedTotalStr = page.completedTotal; + pendingTotalStr = page.pendingTotal; + } else { + const allSettlements = await settlementStore.getDeveloperSettlements(developerId); + settlements = allSettlements.slice(offset, offset + limit); + total = allSettlements.length; + completedTotalStr = addDecimalStrings( + ...allSettlements + .filter((s) => s.status === 'completed' && s.amount > 0) + .map((s) => s.amount), + ); + pendingTotalStr = addDecimalStrings( + ...allSettlements + .filter((s) => s.status === 'pending' && s.amount > 0) + .map((s) => s.amount), + ); + } - const pendingTotal = allSettlements - .filter((s) => s.status === 'pending' && s.amount > 0) - .reduce((sum, s) => sum + s.amount, 0); + // Unsettled usage revenue attributed to the API owner (not the consuming caller) + let unsettledRevenueStr: string; + if (typeof usageStore.getUnsettledRevenueForDeveloper === 'function') { + unsettledRevenueStr = await usageStore.getUnsettledRevenueForDeveloper(developerId); + } else { + const rawUnsettled = await usageStore.getUnsettledEvents(developerId); + const ownerUnsettled = rawUnsettled.filter((e) => { + if (!(e.amountUsdc > 0)) return false; + const ownerId = + (e.apiId ? apiRegistry?.resolve(e.apiId)?.developerId : undefined) ?? + e.developerId ?? + e.userId; + return ownerId === developerId; + }); + unsettledRevenueStr = addDecimalStrings(...ownerUnsettled.map((e) => e.amountUsdc)); + } - // Unsettled usage events scoped to the verified developer - const unsettledEvents = (await usageStore.getUnsettledEvents()).filter( - (e) => e.userId === developerId && e.amountUsdc > 0, + const totalEarnedStr = addDecimalStrings( + completedTotalStr, + unsettledRevenueStr, + pendingTotalStr, ); - const unsettledRevenue = unsettledEvents.reduce((sum, e) => sum + e.amountUsdc, 0); - - const totalEarned = completedTotal + unsettledRevenue + pendingTotal; const body: DeveloperRevenueResponse = { summary: { - total_earned: totalEarned, - pending: pendingTotal, - available_to_withdraw: unsettledRevenue, + total_earned: Number(totalEarnedStr), + pending: Number(pendingTotalStr), + available_to_withdraw: Number(unsettledRevenueStr), }, settlements, pagination: { limit, offset, total }, diff --git a/src/services/settlementStore.ts b/src/services/settlementStore.ts index 6ff71b60..cd0e2a6b 100644 --- a/src/services/settlementStore.ts +++ b/src/services/settlementStore.ts @@ -1,4 +1,10 @@ -import { Settlement, SettlementStore } from '../types/developer.js'; +import { + Settlement, + SettlementStore, + SettlementPageOptions, + PaginatedDeveloperSettlements, +} from '../types/developer.js'; +import { addDecimalStrings, toDecimalString } from './usageStore.js'; export class InMemorySettlementStore implements SettlementStore { private settlements: Settlement[] = []; @@ -21,10 +27,45 @@ export class InMemorySettlementStore implements SettlementStore { } } - getDeveloperSettlements(developerId: string): Settlement[] { - return this.settlements + getDeveloperSettlements(developerId: string, options?: SettlementPageOptions): Settlement[] { + const sorted = this.settlements .filter((s) => s.developerId === developerId) .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + if (options?.limit !== undefined || options?.offset !== undefined) { + const offset = Math.max(0, options?.offset ?? 0); + const limit = options?.limit !== undefined ? Math.max(0, options.limit) : sorted.length; + return sorted.slice(offset, offset + limit); + } + + return sorted; + } + + getDeveloperSettlementsPage( + developerId: string, + options: { limit: number; offset: number }, + ): PaginatedDeveloperSettlements { + const sorted = this.settlements + .filter((s) => s.developerId === developerId) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + const offset = Math.max(0, options.offset); + const limit = Math.max(0, options.limit); + const settlements = sorted.slice(offset, offset + limit); + + const completedTotal = addDecimalStrings( + ...sorted.filter((s) => s.status === 'completed' && s.amount > 0).map((s) => s.amount), + ); + const pendingTotal = addDecimalStrings( + ...sorted.filter((s) => s.status === 'pending' && s.amount > 0).map((s) => s.amount), + ); + + return { + settlements, + total: sorted.length, + completedTotal, + pendingTotal, + }; } scheduleRetry(id: string, retryAfter: string): void { @@ -75,8 +116,8 @@ interface SettlementStoreRow { status: Settlement['status']; stellar_tx_hash: string | null; created_at: Date | string; - retry_after: string | null; - retry_count: number | null; + retry_after?: string | null; + retry_count?: number | null; } export interface SettlementStoreQueryable { @@ -161,18 +202,29 @@ export class PostgresSettlementStore implements SettlementStore { ); } - async getDeveloperSettlements(developerId: string): Promise { + async getDeveloperSettlements( + developerId: string, + options?: SettlementPageOptions, + ): Promise { + if (options?.limit !== undefined || options?.offset !== undefined) { + const limit = Math.max(0, options?.limit ?? 20); + const offset = Math.max(0, options?.offset ?? 0); + const result = await this.db.query( + ` + SELECT * + FROM settlements + WHERE developer_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3 + `, + [developerId, limit, offset], + ); + return result.rows.map(mapSettlementRow); + } + const result = await this.db.query( ` - SELECT - external_id, - developer_id, - amount_usdc, - status, - stellar_tx_hash, - created_at, - retry_after, - retry_count + SELECT * FROM settlements WHERE developer_id = $1 ORDER BY created_at DESC, id DESC @@ -183,18 +235,54 @@ export class PostgresSettlementStore implements SettlementStore { return result.rows.map(mapSettlementRow); } + async getDeveloperSettlementsPage( + developerId: string, + options: { limit: number; offset: number }, + ): Promise { + const limit = Math.max(0, options.limit); + const offset = Math.max(0, options.offset); + + const [pageResult, totalsResult] = await Promise.all([ + this.db.query( + ` + SELECT * + FROM settlements + WHERE developer_id = $1 + ORDER BY created_at DESC, id DESC + LIMIT $2 OFFSET $3 + `, + [developerId, limit, offset], + ), + this.db.query<{ + total: string | number; + completed_total: string | number | null; + pending_total: string | number | null; + }>( + ` + SELECT + COUNT(*) AS total, + COALESCE(SUM(amount_usdc::numeric) FILTER (WHERE status = 'completed' AND amount_usdc > 0), 0) AS completed_total, + COALESCE(SUM(amount_usdc::numeric) FILTER (WHERE status = 'pending' AND amount_usdc > 0), 0) AS pending_total + FROM settlements + WHERE developer_id = $1 + `, + [developerId], + ), + ]); + + const summaryRow = totalsResult.rows[0]; + return { + settlements: pageResult.rows.map(mapSettlementRow), + total: toNumber(summaryRow?.total ?? 0), + completedTotal: toDecimalString(summaryRow?.completed_total ?? '0'), + pendingTotal: toDecimalString(summaryRow?.pending_total ?? '0'), + }; + } + async getPendingSettlements(): Promise { const result = await this.db.query( ` - SELECT - external_id, - developer_id, - amount_usdc, - status, - stellar_tx_hash, - created_at, - retry_after, - retry_count + SELECT * FROM settlements WHERE status = 'pending' OR (status = 'retryable' AND (retry_after IS NULL OR retry_after <= NOW())) diff --git a/src/services/usageStore.ts b/src/services/usageStore.ts index 008fb7ea..39d80e0e 100644 --- a/src/services/usageStore.ts +++ b/src/services/usageStore.ts @@ -1,4 +1,45 @@ -import { UsageStore, UsageEvent } from '../types/gateway.js'; +import { UsageStore, UsageEvent, ApiRegistry } from '../types/gateway.js'; + +const DECIMAL_SCALE = 18; +const DECIMAL_FACTOR = 10n ** BigInt(DECIMAL_SCALE); + +function parseToScaledBigInt(value: string | number | null | undefined): bigint { + if (value === null || value === undefined) return 0n; + const raw = typeof value === 'number' + ? (Number.isFinite(value) ? value.toFixed(DECIMAL_SCALE) : '0') + : String(value).trim(); + if (!raw || raw === '0') return 0n; + + const negative = raw.startsWith('-'); + const unsigned = negative || raw.startsWith('+') ? raw.slice(1) : raw; + const [intPartRaw = '0', fracPartRaw = ''] = unsigned.split('.'); + const intPart = intPartRaw.replace(/[^0-9]/g, '') || '0'; + const fracPart = fracPartRaw.replace(/[^0-9]/g, '').slice(0, DECIMAL_SCALE).padEnd(DECIMAL_SCALE, '0'); + const scaled = BigInt(intPart) * DECIMAL_FACTOR + BigInt(fracPart || '0'); + return negative ? -scaled : scaled; +} + +function formatScaledBigInt(scaled: bigint): string { + if (scaled === 0n) return '0'; + const negative = scaled < 0n; + const abs = negative ? -scaled : scaled; + const intPart = abs / DECIMAL_FACTOR; + const fracPart = (abs % DECIMAL_FACTOR).toString().padStart(DECIMAL_SCALE, '0').replace(/0+$/, ''); + const formatted = fracPart.length > 0 ? `${intPart.toString()}.${fracPart}` : intPart.toString(); + return negative ? `-${formatted}` : formatted; +} + +export function toDecimalString(value: string | number | null | undefined): string { + return formatScaledBigInt(parseToScaledBigInt(value)); +} + +export function addDecimalStrings(...values: Array): string { + let total = 0n; + for (const val of values) { + total += parseToScaledBigInt(val); + } + return formatScaledBigInt(total); +} export interface UsageAggregateSnapshot { developerId: string; @@ -23,7 +64,7 @@ export interface UsageAdminStore extends UsageStore { const emptyStatusCounts = (): Record => ({}); const sumAmounts = (events: UsageEvent[]): number => - events.reduce((total, event) => total + event.amountUsdc, 0); + Number(addDecimalStrings(...events.map((event) => event.amountUsdc))); const toIsoOrNull = (value: Date | string | null | undefined): string | null => { if (!value) { @@ -39,6 +80,35 @@ const toIsoOrNull = (value: Date | string | null | undefined): string | null => export class InMemoryUsageStore implements UsageAdminStore { private events: UsageEvent[] = []; private requestIds = new Set(); + private apiOwners = new Map(); + private apiRegistry?: ApiRegistry; + + constructor(apiRegistry?: ApiRegistry) { + this.apiRegistry = apiRegistry; + } + + setApiRegistry(apiRegistry: ApiRegistry): void { + this.apiRegistry = apiRegistry; + } + + registerApiOwner(apiId: string, developerId: string): void { + this.apiOwners.set(apiId, developerId); + } + + resolveEventOwnerId(event: UsageEvent): string { + const ownerFromMap = this.apiOwners.get(event.apiId); + if (ownerFromMap !== undefined) { + return ownerFromMap; + } + const ownerFromRegistry = this.apiRegistry?.resolve(event.apiId)?.developerId; + if (ownerFromRegistry !== undefined) { + return ownerFromRegistry; + } + if (event.developerId !== undefined) { + return event.developerId; + } + return event.userId; + } /** * Record a usage event. @@ -49,7 +119,13 @@ export class InMemoryUsageStore implements UsageAdminStore { return false; // duplicate — skip } this.requestIds.add(event.requestId); - this.events.push(event); + const resolvedOwner = + this.apiOwners.get(event.apiId) ?? + this.apiRegistry?.resolve(event.apiId)?.developerId ?? + event.developerId; + this.events.push( + resolvedOwner !== undefined ? { ...event, developerId: resolvedOwner } : event, + ); return true; } @@ -66,7 +142,7 @@ export class InMemoryUsageStore implements UsageAdminStore { } getDeveloperUsageSnapshot(developerId: string): UsageAggregateSnapshot | undefined { - const events = this.events.filter((event) => event.userId === developerId); + const events = this.events.filter((event) => this.resolveEventOwnerId(event) === developerId); if (events.length === 0) { return undefined; } @@ -105,15 +181,40 @@ export class InMemoryUsageStore implements UsageAdminStore { return undefined; } - const retainedEvents = this.events.filter((event) => event.userId !== developerId); + const retainedEvents = this.events.filter((event) => this.resolveEventOwnerId(event) !== developerId); this.events = retainedEvents; this.requestIds = new Set(retainedEvents.map((event) => event.requestId)); return priorSnapshot; } - /** Retrieve all usage events that haven't been settled yet and have a non-zero price. */ - getUnsettledEvents(): UsageEvent[] { - return this.events.filter((e) => !e.settlementId && e.amountUsdc > 0); + /** + * Retrieve usage events that haven't been settled yet and have a positive price. + * When developerId is provided, joins/filters by API owner developerId rather than caller userId. + */ + getUnsettledEvents(developerId?: string): UsageEvent[] { + return this.events + .filter((e) => { + if (e.settlementId || !(e.amountUsdc > 0)) { + return false; + } + if (developerId !== undefined) { + return this.resolveEventOwnerId(e) === developerId; + } + return true; + }) + .map((e) => ({ + ...e, + developerId: this.resolveEventOwnerId(e), + })); + } + + /** + * Compute total unsettled revenue for an API owner as an exact decimal string. + * Joins unsettled events against the API registry / owner map by apiId -> developerId. + */ + getUnsettledRevenueForDeveloper(developerId: string): string { + const matching = this.getUnsettledEvents(developerId); + return addDecimalStrings(...matching.map((e) => e.amountUsdc)); } /** Mark a specific set of events as settled. */ @@ -133,8 +234,8 @@ export class InMemoryUsageStore implements UsageAdminStore { } } -export function createUsageStore(): InMemoryUsageStore { - return new InMemoryUsageStore(); +export function createUsageStore(apiRegistry?: ApiRegistry): InMemoryUsageStore { + return new InMemoryUsageStore(apiRegistry); } interface UsageStoreClient { @@ -155,6 +256,7 @@ interface UsageEventRow { api_id: string; endpoint_id: string; user_id: string; + developer_id?: string; amount_usdc: string | number; status_code: number; created_at: Date | string; @@ -193,6 +295,7 @@ const mapUsageEventRow = (row: UsageEventRow): UsageEvent => ({ apiId: row.api_id, endpointId: row.endpoint_id, userId: row.user_id, + developerId: row.developer_id ?? row.user_id, amountUsdc: toNumber(row.amount_usdc), statusCode: row.status_code, timestamp: row.created_at instanceof Date ? row.created_at.toISOString() : new Date(row.created_at).toISOString(), @@ -207,13 +310,16 @@ const usageEventSelect = ` ue.api_key_id, ue.api_id, ue.endpoint_id, - rl.developer_id AS user_id, + a.developer_id AS user_id, + a.developer_id AS developer_id, ue.amount_usdc, ue.status_code, ue.created_at, s.external_id AS settlement_external_id FROM usage_events ue - INNER JOIN revenue_ledger rl + INNER JOIN apis a + ON a.id = ue.api_id + LEFT JOIN revenue_ledger rl ON rl.usage_event_id = ue.id LEFT JOIN settlements s ON s.id = rl.settlement_id @@ -239,17 +345,14 @@ export class PostgresUsageStore implements UsageAdminStore { api_id, endpoint_id, api_key_id, - developer_id, + api_key, amount_usdc, request_id, status_code, created_at ) - VALUES ($1, $2, $3, $4, ( - SELECT COALESCE(a.developer_id::text, '') - FROM apis a WHERE a.id = $2 LIMIT 1 - ), $5, $6, $7, $8) - ON CONFLICT (request_id, developer_id) DO NOTHING + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (request_id) DO NOTHING RETURNING id `, [ @@ -257,6 +360,7 @@ export class PostgresUsageStore implements UsageAdminStore { event.apiId, event.endpointId, event.apiKeyId, + event.apiKey ?? event.apiKeyId, event.amountUsdc, event.requestId, event.statusCode, @@ -334,19 +438,48 @@ export class PostgresUsageStore implements UsageAdminStore { return result.rows.map(mapUsageEventRow); } - async getUnsettledEvents(): Promise { + async getUnsettledEvents(developerId?: string): Promise { + const params: unknown[] = []; + let ownerClause = ''; + if (developerId !== undefined) { + params.push(developerId); + ownerClause = `AND a.developer_id = $1`; + } + const result = await this.db.query( ` ${usageEventSelect} WHERE rl.settlement_id IS NULL AND ue.amount_usdc > 0 + ${ownerClause} ORDER BY ue.created_at ASC, ue.id ASC `, + params, ); return result.rows.map(mapUsageEventRow); } + async getUnsettledRevenueForDeveloper(developerId: string): Promise { + const result = await this.db.query<{ unsettled_amount_usdc: string | number | null }>( + ` + SELECT + COALESCE(SUM(ue.amount_usdc::numeric), 0) AS unsettled_amount_usdc + FROM usage_events ue + INNER JOIN apis a + ON a.id = ue.api_id + LEFT JOIN revenue_ledger rl + ON rl.usage_event_id = ue.id + WHERE a.developer_id = $1 + AND rl.settlement_id IS NULL + AND ue.amount_usdc > 0 + `, + [developerId], + ); + + return toDecimalString(result.rows[0]?.unsettled_amount_usdc ?? '0'); + } + async getDeveloperUsageSnapshot(developerId: string): Promise { const [aggregateResult, statusResult] = await Promise.all([ this.db.query( diff --git a/src/types/developer.ts b/src/types/developer.ts index 58ddd6b3..bfd9ecf9 100644 --- a/src/types/developer.ts +++ b/src/types/developer.ts @@ -58,11 +58,27 @@ export interface UpdateDeveloperProfileInput { category?: DeveloperCategory | null; } +export interface SettlementPageOptions { + limit?: number; + offset?: number; +} + +export interface PaginatedDeveloperSettlements { + settlements: Settlement[]; + total: number; + completedTotal: string; + pendingTotal: string; +} + export interface SettlementStore { create(settlement: Settlement): Awaitable; updateStatus(id: string, status: Settlement['status'], txHash?: string | null): Awaitable; scheduleRetry(id: string, retryAfter: string): Awaitable; - getDeveloperSettlements(developerId: string): Awaitable; + getDeveloperSettlements(developerId: string, options?: SettlementPageOptions): Awaitable; + getDeveloperSettlementsPage?( + developerId: string, + options: { limit: number; offset: number }, + ): Awaitable; getPendingSettlements(): Awaitable; listPending?(): Awaitable; } diff --git a/src/types/gateway.ts b/src/types/gateway.ts index 88c5fb30..781ecefc 100644 --- a/src/types/gateway.ts +++ b/src/types/gateway.ts @@ -18,7 +18,8 @@ export interface UsageEvent { apiKeyId: string; apiId: string; endpointId: string; - userId: string; // developerId of the caller + userId: string; // userId of the caller + developerId?: string; // developerId of the API owner (if pre-resolved) amountUsdc: number; // endpoint price charged statusCode: number; timestamp: string; // ISO-8601 @@ -80,7 +81,8 @@ export interface UsageStore { record(event: UsageEvent): Awaitable; hasEvent(requestId: string): Awaitable; getEvents(apiKey?: string): Awaitable; - getUnsettledEvents(): Awaitable; + getUnsettledEvents(developerId?: string): Awaitable; + getUnsettledRevenueForDeveloper?(developerId: string): Awaitable; markAsSettled(eventIds: string[], settlementId: string): Awaitable; }