diff --git a/docs/api-endpoints.md b/docs/api-endpoints.md index 6515a5f3..ff73934a 100644 --- a/docs/api-endpoints.md +++ b/docs/api-endpoints.md @@ -235,6 +235,52 @@ Queue depth metrics for indexer workers. --- +## Invoice Endpoints + +### GET /invoices/compare + +Compare metrics for up to two invoices in a single request. Accepts a +comma-separated `ids` query parameter; metrics are returned grouped by invoice +ID. Responses are cached for 30 seconds per ID combination. + +- **Auth:** None +- **Query:** `ids` - comma-separated invoice IDs (1 or 2; more returns `400`) +- **Response:** `200 OK` + +``` +GET /invoices/compare?ids=cm123,cm456 +``` + +```json +{ + "success": true, + "data": { + "invoice_ids": ["cm123", "cm456"], + "invoices": { + "cm123": { + "invoice_id": "cm123", + "seller_wallet": "GSELLER...", + "currency": "USDC", + "status": "FUNDED", + "amount": "1000", + "rate": "0.05", + "maturity": "2026-12-31T00:00:00.000Z", + "risk_rating": "A", + "funding_progress": "1", + "seller_stats": { + "wallet": "GSELLER...", + "invoice_count": 4, + "total_amount": "12500", + "average_rate": "0.062" + } + } + } + } +} +``` + +--- + ## Admin Endpoints ### POST /admin/proposals @@ -246,10 +292,13 @@ Create a new multisig proposal requiring multi-sig approval. ```json { - "changeType": "update_fee", - "payload": { "feeBps": 500, "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" }, - "threshold": 2, - "totalSigners": 3 + "changeType": "update_fee", + "payload": { + "feeBps": 500, + "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" + }, + "threshold": 2, + "totalSigners": 3 } ``` @@ -257,26 +306,29 @@ Create a new multisig proposal requiring multi-sig approval. ```json { - "success": true, - "data": { - "id": "cm123...", - "proposalId": "msig-1234567890-abc123", - "changeType": "update_fee", - "payload": { "feeBps": 500, "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" }, - "status": "pending", - "threshold": 2, - "totalSigners": 3, - "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "proposedAt": "2026-09-27T10:30:00.000Z", - "executedAt": null, - "rejectedAt": null, - "rejectedBy": null, - "rejectionReason": null, - "createdAt": "2026-09-27T10:30:00.000Z", - "updatedAt": "2026-09-27T10:30:00.000Z", - "signatures": [], - "approvalCount": 0 - } + "success": true, + "data": { + "id": "cm123...", + "proposalId": "msig-1234567890-abc123", + "changeType": "update_fee", + "payload": { + "feeBps": 500, + "treasuryAddress": "GATREASURYADDRESSFORACCESSLAYERTESTING123456789" + }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [], + "approvalCount": 0 + } } ``` @@ -286,52 +338,52 @@ List all multisig proposals with pagination and optional status filter. - **Auth:** Admin required - **Query Params:** - - `status` (optional): `pending`, `executed`, or `rejected` - - `page` (number, default: 1) - - `limit` (number, default: 20, max: 100) + - `status` (optional): `pending`, `executed`, or `rejected` + - `page` (number, default: 1) + - `limit` (number, default: 20, max: 100) - **Response:** `200 OK` ```json { - "success": true, - "data": { - "items": [ - { - "id": "cm123...", - "proposalId": "msig-1234567890-abc123", - "changeType": "update_fee", - "payload": { "feeBps": 500 }, - "status": "pending", - "threshold": 2, - "totalSigners": 3, - "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "proposedAt": "2026-09-27T10:30:00.000Z", - "executedAt": null, - "rejectedAt": null, - "rejectedBy": null, - "rejectionReason": null, - "createdAt": "2026-09-27T10:30:00.000Z", - "updatedAt": "2026-09-27T10:30:00.000Z", - "signatures": [ - { - "id": "cm456...", + "success": true, + "data": { + "items": [ + { + "id": "cm123...", "proposalId": "msig-1234567890-abc123", - "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "signedAt": "2026-09-27T10:31:00.000Z" - } - ], - "approvalCount": 1 + "changeType": "update_fee", + "payload": { "feeBps": 500 }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [ + { + "id": "cm456...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "signedAt": "2026-09-27T10:31:00.000Z" + } + ], + "approvalCount": 1 + } + ], + "meta": { + "page": 1, + "limit": 20, + "totalCount": 1, + "totalPages": 1, + "hasNextPage": false, + "hasPrevPage": false } - ], - "meta": { - "page": 1, - "limit": 20, - "totalCount": 1, - "totalPages": 1, - "hasNextPage": false, - "hasPrevPage": false - } - } + } } ``` @@ -344,33 +396,33 @@ Get detailed information for a single multisig proposal including all signatures ```json { - "success": true, - "data": { - "id": "cm123...", - "proposalId": "msig-1234567890-abc123", - "changeType": "update_fee", - "payload": { "feeBps": 500 }, - "status": "pending", - "threshold": 2, - "totalSigners": 3, - "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "proposedAt": "2026-09-27T10:30:00.000Z", - "executedAt": null, - "rejectedAt": null, - "rejectedBy": null, - "rejectionReason": null, - "createdAt": "2026-09-27T10:30:00.000Z", - "updatedAt": "2026-09-27T10:30:00.000Z", - "signatures": [ - { - "id": "cm456...", - "proposalId": "msig-1234567890-abc123", - "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "signedAt": "2026-09-27T10:31:00.000Z" - } - ], - "approvalCount": 1 - } + "success": true, + "data": { + "id": "cm123...", + "proposalId": "msig-1234567890-abc123", + "changeType": "update_fee", + "payload": { "feeBps": 500 }, + "status": "pending", + "threshold": 2, + "totalSigners": 3, + "proposedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "proposedAt": "2026-09-27T10:30:00.000Z", + "executedAt": null, + "rejectedAt": null, + "rejectedBy": null, + "rejectionReason": null, + "createdAt": "2026-09-27T10:30:00.000Z", + "updatedAt": "2026-09-27T10:30:00.000Z", + "signatures": [ + { + "id": "cm456...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "signedAt": "2026-09-27T10:31:00.000Z" + } + ], + "approvalCount": 1 + } } ``` @@ -385,7 +437,7 @@ Submit a signature/approval for a multisig proposal. When the threshold is reach ```json { - "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789" + "signer": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789" } ``` @@ -393,28 +445,28 @@ Submit a signature/approval for a multisig proposal. When the threshold is reach ```json { - "success": true, - "data": { - "proposalId": "msig-1234567890-abc123", - "status": "executed", - "approvalCount": 2, - "threshold": 2, - "executed": true, - "signature": { - "id": "cm789...", + "success": true, + "data": { "proposalId": "msig-1234567890-abc123", - "signer": "GAADMIN2WALLETADDRESSFORACCESSLAYERTESTING987654321", - "signedAt": "2026-09-27T10:32:00.000Z" - } - } + "status": "executed", + "approvalCount": 2, + "threshold": 2, + "executed": true, + "signature": { + "id": "cm789...", + "proposalId": "msig-1234567890-abc123", + "signer": "GAADMIN2WALLETADDRESSFORACCESSLAYERTESTING987654321", + "signedAt": "2026-09-27T10:32:00.000Z" + } + } } ``` - **Error Responses:** - - `400 Bad Request` if proposal is not in `pending` state - - `403 Forbidden` if signer is not authorized - - `404 Not Found` if proposal does not exist - - `409 Conflict` if signer has already signed + - `400 Bad Request` if proposal is not in `pending` state + - `403 Forbidden` if signer is not authorized + - `404 Not Found` if proposal does not exist + - `409 Conflict` if signer has already signed ### POST /admin/proposals/:id/reject @@ -425,8 +477,8 @@ Reject a multisig proposal. ```json { - "rejector": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "reason": "Optional rejection reason" + "rejector": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "reason": "Optional rejection reason" } ``` @@ -434,21 +486,21 @@ Reject a multisig proposal. ```json { - "success": true, - "data": { - "proposalId": "msig-1234567890-abc123", - "status": "rejected", - "rejectedAt": "2026-09-27T10:33:00.000Z", - "rejectedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", - "rejectionReason": "Optional rejection reason" - } + "success": true, + "data": { + "proposalId": "msig-1234567890-abc123", + "status": "rejected", + "rejectedAt": "2026-09-27T10:33:00.000Z", + "rejectedBy": "GAADMIN1WALLETADDRESSFORACCESSLAYERTESTING123456789", + "rejectionReason": "Optional rejection reason" + } } ``` - **Error Responses:** - - `400 Bad Request` if proposal is not in `pending` state - - `403 Forbidden` if rejector is not authorized - - `404 Not Found` if proposal does not exist + - `400 Bad Request` if proposal is not in `pending` state + - `403 Forbidden` if rejector is not authorized + - `404 Not Found` if proposal does not exist ### PATCH /admin/creators/:id/metadata @@ -553,4 +605,4 @@ window, with alert and auto-suspension state. --- -See [Local Setup](./local-setup.md) for development environment configuration. \ No newline at end of file +See [Local Setup](./local-setup.md) for development environment configuration. diff --git a/src/modules/index.ts b/src/modules/index.ts index 6cef352b..bbd305aa 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -16,6 +16,7 @@ import subscriptionRouter from './subscriptions/subscription.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; import alertsRouter from './alerts/alert.router'; +import invoiceRouter from './invoice/invoice.routes'; import freezeRouter from './freeze/freeze.routes'; import platformRouter from './platform/platform.routes'; @@ -70,6 +71,7 @@ router.use('/ledger', routeBodySizeLimit('default'), ledgerRouter); router.use('/admin', routeBodySizeLimit('admin'), adminRouter); router.use('/activity', routeBodySizeLimit('default'), activityRouter); router.use('/ownership', routeBodySizeLimit('default'), ownershipRouter); +router.use('/invoices', routeBodySizeLimit('default'), invoiceRouter); router.use('/subscriptions', routeBodySizeLimit('default'), subscriptionRouter); router.use(CREATORS_BASE, routeBodySizeLimit('creators'), webhookRouter); router.use('/wallets', routeBodySizeLimit('default'), walletsRouter); @@ -110,4 +112,4 @@ router.use('/lp', routeBodySizeLimit('default'), lpRouter); router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); -export default router; \ No newline at end of file +export default router; diff --git a/src/modules/invoice/invoice.controllers.test.ts b/src/modules/invoice/invoice.controllers.test.ts new file mode 100644 index 00000000..548c43dc --- /dev/null +++ b/src/modules/invoice/invoice.controllers.test.ts @@ -0,0 +1,148 @@ +// src/modules/invoice/invoice.controllers.test.ts +import express, { Express } from 'express'; +import supertest from 'supertest'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + invoice: { + findMany: jest.fn(), + groupBy: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(), + cacheSetJson: jest.fn(), +})); + +jest.mock('./invoice.service', () => { + const actual = jest.requireActual('./invoice.service'); + return { + ...actual, + getInvoiceComparison: jest.fn(), + }; +}); + +import { getInvoiceComparison, InvoiceNotFoundError } from './invoice.service'; +import invoiceRouter from './invoice.routes'; + +const mockGetComparison = getInvoiceComparison as jest.Mock; + +const metrics = (id: string) => ({ + invoice_id: id, + seller_wallet: 'GSELLER', + currency: 'USDC', + status: 'FUNDED', + amount: '1000', + rate: '0.05', + maturity: '2026-12-31T00:00:00.000Z', + risk_rating: 'A', + funding_progress: '1', + seller_stats: { + wallet: 'GSELLER', + invoice_count: 1, + total_amount: '1000', + average_rate: '0.05', + }, +}); + +describe('Invoice Comparison Routes', () => { + let app: Express; + + beforeAll(() => { + app = express(); + app.use(express.json()); + app.use('/invoices', invoiceRouter); + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockGetComparison.mockResolvedValue({ + invoice_ids: ['inv_a', 'inv_b'], + invoices: { inv_a: metrics('inv_a'), inv_b: metrics('inv_b') }, + }); + }); + + it('returns 400 when ids is missing', async () => { + const res = await supertest(app).get('/invoices/compare'); + + expect(res.status).toBe(400); + expect(res.body.success).toBe(false); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(mockGetComparison).not.toHaveBeenCalled(); + }); + + it('returns 400 when ids contains no usable values', async () => { + const res = await supertest(app).get('/invoices/compare?ids=,,,'); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(mockGetComparison).not.toHaveBeenCalled(); + }); + + it('returns 400 with a clear error when more than 2 ids are provided', async () => { + const res = await supertest(app).get( + '/invoices/compare?ids=inv_a,inv_b,inv_c' + ); + + expect(res.status).toBe(400); + expect(res.body.error.code).toBe('VALIDATION_ERROR'); + expect(res.body.error.message).toContain('Maximum 2 invoice IDs allowed'); + expect(res.body.error.details).toEqual([ + expect.objectContaining({ field: 'ids' }), + ]); + expect(mockGetComparison).not.toHaveBeenCalled(); + }); + + it('returns 404 when an invoice does not exist', async () => { + mockGetComparison.mockRejectedValue( + new InvoiceNotFoundError(['inv_missing']) + ); + + const res = await supertest(app).get( + '/invoices/compare?ids=inv_a,inv_missing' + ); + + expect(res.status).toBe(404); + expect(res.body.error.code).toBe('NOT_FOUND'); + }); + + it('returns metrics grouped by invoice id for two invoices', async () => { + const res = await supertest(app).get('/invoices/compare?ids=inv_a,inv_b'); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.invoice_ids).toEqual(['inv_a', 'inv_b']); + expect(res.body.data.invoices.inv_a.amount).toBe('1000'); + expect(res.body.data.invoices.inv_a.maturity).toBe( + '2026-12-31T00:00:00.000Z' + ); + expect(res.body.data.invoices.inv_a.seller_stats.invoice_count).toBe(1); + expect(mockGetComparison).toHaveBeenCalledWith(['inv_a', 'inv_b']); + }); + + it('supports comparing a single invoice', async () => { + mockGetComparison.mockResolvedValue({ + invoice_ids: ['inv_a'], + invoices: { inv_a: metrics('inv_a') }, + }); + + const res = await supertest(app).get('/invoices/compare?ids=inv_a'); + + expect(res.status).toBe(200); + expect(res.body.data.invoice_ids).toEqual(['inv_a']); + }); + + it('trims whitespace and de-duplicates ids', async () => { + await supertest(app).get('/invoices/compare?ids=%20inv_a%20,inv_b,inv_a'); + + expect(mockGetComparison).toHaveBeenCalledWith(['inv_a', 'inv_b']); + }); + + it('sets a 30 second public cache header', async () => { + const res = await supertest(app).get('/invoices/compare?ids=inv_a,inv_b'); + + expect(res.headers['cache-control']).toBe('public, max-age=30'); + }); +}); diff --git a/src/modules/invoice/invoice.controllers.ts b/src/modules/invoice/invoice.controllers.ts new file mode 100644 index 00000000..03c0f98a --- /dev/null +++ b/src/modules/invoice/invoice.controllers.ts @@ -0,0 +1,67 @@ +// src/modules/invoice/invoice.controllers.ts +import { AsyncController } from '../../types/auth.types'; +import { + sendNotFound, + sendSuccess, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { + InvoiceComparisonQuerySchema, + MAX_COMPARABLE_INVOICES, + parseInvoiceIds, +} from './invoice.schemas'; +import { getInvoiceComparison, InvoiceNotFoundError } from './invoice.service'; + +/** + * GET /invoices/compare?ids=, + * + * Returns comparison metrics for up to {@link MAX_COMPARABLE_INVOICES} + * invoices, grouped by invoice ID. Responses are served from the 30s + * comparison cache keyed by the requested ID combination. + */ +export const httpGetInvoiceComparison: AsyncController = async ( + req, + res, + next +) => { + const parsed = InvoiceComparisonQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + } + + const ids = parseInvoiceIds(parsed.data.ids); + + if (ids.length === 0) { + return sendValidationError(res, 'Invalid query parameters', [ + { field: 'ids', message: 'At least one invoice ID is required' }, + ]); + } + + if (ids.length > MAX_COMPARABLE_INVOICES) { + return sendValidationError( + res, + `Maximum ${MAX_COMPARABLE_INVOICES} invoice IDs allowed`, + [ + { + field: 'ids', + message: `Maximum ${MAX_COMPARABLE_INVOICES} invoice IDs allowed, received ${ids.length}`, + }, + ] + ); + } + + try { + const comparison = await getInvoiceComparison(ids); + return sendSuccess(res, comparison); + } catch (error) { + if (error instanceof InvoiceNotFoundError) { + return sendNotFound(res, 'Invoice'); + } + return next(error); + } +}; diff --git a/src/modules/invoice/invoice.routes.ts b/src/modules/invoice/invoice.routes.ts new file mode 100644 index 00000000..fb4c6998 --- /dev/null +++ b/src/modules/invoice/invoice.routes.ts @@ -0,0 +1,24 @@ +// src/modules/invoice/invoice.routes.ts +import { Router } from 'express'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; +import { INVOICE_COMPARISON_CACHE_TTL_SECONDS } from './invoice.schemas'; +import { httpGetInvoiceComparison } from './invoice.controllers'; + +const invoiceRouter = Router(); + +/** + * GET /invoices/compare?ids=, + * + * Compare metrics for up to two invoices in a single request. Metrics are + * grouped by invoice ID and cached for 30 seconds per ID combination. + */ +invoiceRouter.get( + '/compare', + cacheControl({ + maxAge: INVOICE_COMPARISON_CACHE_TTL_SECONDS, + type: 'public', + }), + httpGetInvoiceComparison +); + +export default invoiceRouter; diff --git a/src/modules/invoice/invoice.schemas.ts b/src/modules/invoice/invoice.schemas.ts new file mode 100644 index 00000000..adef125e --- /dev/null +++ b/src/modules/invoice/invoice.schemas.ts @@ -0,0 +1,87 @@ +// src/modules/invoice/invoice.schemas.ts +import { z } from 'zod'; + +/** + * Maximum number of invoices a single comparison request may reference (#947). + */ +export const MAX_COMPARABLE_INVOICES = 2; + +/** + * Server-side cache TTL for a comparison response, keyed by invoice + * combination (#947). + */ +export const INVOICE_COMPARISON_CACHE_TTL_SECONDS = 30; + +/** + * Parses the comma-separated `ids` query parameter into a de-duplicated list + * that preserves caller order. Returns `[]` when nothing usable was supplied. + */ +export function parseInvoiceIds(raw: string | undefined): string[] { + if (!raw) return []; + const seen = new Set(); + const ids: string[] = []; + for (const part of raw.split(',')) { + const id = part.trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +/** + * Query schema for `GET /invoices/compare`. + * + * Only validates presence/type here; the cardinality rules (`>= 1`, + * `<= MAX_COMPARABLE_INVOICES`) are enforced in the controller so the error + * `details` can name the offending field. + */ +export const InvoiceComparisonQuerySchema = z + .object({ + ids: z.string({ required_error: 'ids query parameter is required' }), + }) + .passthrough(); + +export type InvoiceComparisonQuery = z.infer< + typeof InvoiceComparisonQuerySchema +>; + +/** Aggregate statistics derived from a seller's invoice history. */ +export const SellerStatsSchema = z.object({ + wallet: z.string(), + invoice_count: z.number(), + total_amount: z.string(), + average_rate: z.string().nullable(), +}); + +export type SellerStats = z.infer; + +/** + * Comparison metrics for a single invoice. Decimal columns are stringified + * so the payload survives JSON round-trips without precision loss. + */ +export const InvoiceMetricsSchema = z.object({ + invoice_id: z.string(), + seller_wallet: z.string(), + currency: z.string(), + status: z.string(), + amount: z.string(), + rate: z.string().nullable(), + maturity: z.string().nullable(), + risk_rating: z.string().nullable(), + funding_progress: z.string(), + seller_stats: SellerStatsSchema, +}); + +export type InvoiceMetrics = z.infer; + +/** + * Comparison payload: metrics grouped by invoice ID, in the order the IDs were + * requested. + */ +export const InvoiceComparisonSchema = z.object({ + invoice_ids: z.array(z.string()), + invoices: z.record(InvoiceMetricsSchema), +}); + +export type InvoiceComparison = z.infer; diff --git a/src/modules/invoice/invoice.service.test.ts b/src/modules/invoice/invoice.service.test.ts new file mode 100644 index 00000000..81b39400 --- /dev/null +++ b/src/modules/invoice/invoice.service.test.ts @@ -0,0 +1,195 @@ +// src/modules/invoice/invoice.service.test.ts +import { Decimal } from '@prisma/client/runtime/library'; + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + invoice: { + findMany: jest.fn(), + groupBy: jest.fn(), + }, + }, +})); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(), + cacheSetJson: jest.fn(), +})); + +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; +import { + fetchInvoiceComparison, + getInvoiceComparison, + invoiceComparisonCacheKey, + InvoiceNotFoundError, +} from './invoice.service'; +import { INVOICE_COMPARISON_CACHE_TTL_SECONDS } from './invoice.schemas'; + +const mockFindMany = prisma.invoice.findMany as jest.Mock; +const mockGroupBy = prisma.invoice.groupBy as jest.Mock; +const mockCacheGetJson = cacheGetJson as jest.Mock; +const mockCacheSetJson = cacheSetJson as jest.Mock; + +const SELLER_A = 'GSELLER_A'; +const SELLER_B = 'GSELLER_B'; + +const invoiceRow = (id: string, sellerWallet: string) => ({ + id, + sellerWallet, + currency: 'USDC', + status: 'FUNDED', + amount: new Decimal(1000), + rate: new Decimal(0.05), + maturityDate: new Date('2026-12-31T00:00:00.000Z'), + riskRating: 'A', + fundingProgress: new Decimal(1), +}); + +const sellerStatsGroup = (sellerWallet: string, count: number) => ({ + sellerWallet, + _count: { _all: count }, + _sum: { amount: new Decimal(1000 * count) }, + _avg: { rate: new Decimal(0.05) }, +}); + +describe('Invoice comparison service', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCacheGetJson.mockResolvedValue(null); + mockCacheSetJson.mockResolvedValue(undefined); + }); + + describe('invoiceComparisonCacheKey', () => { + it('is order independent', () => { + expect(invoiceComparisonCacheKey(['b', 'a'])).toBe( + invoiceComparisonCacheKey(['a', 'b']) + ); + }); + + it('namespaces the key', () => { + expect(invoiceComparisonCacheKey(['a', 'b'])).toBe( + 'invoice:compare:a,b' + ); + }); + }); + + describe('fetchInvoiceComparison', () => { + it('throws InvoiceNotFoundError listing the missing ids', async () => { + mockFindMany.mockResolvedValue([invoiceRow('inv_a', SELLER_A)]); + + await expect( + fetchInvoiceComparison(['inv_a', 'inv_missing']) + ).rejects.toBeInstanceOf(InvoiceNotFoundError); + expect(mockGroupBy).not.toHaveBeenCalled(); + }); + + it('groups metrics by invoice id in the requested order', async () => { + mockFindMany.mockResolvedValue([ + invoiceRow('inv_a', SELLER_A), + invoiceRow('inv_b', SELLER_B), + ]); + mockGroupBy.mockResolvedValue([ + sellerStatsGroup(SELLER_A, 2), + sellerStatsGroup(SELLER_B, 1), + ]); + + const result = await fetchInvoiceComparison(['inv_b', 'inv_a']); + + expect(result.invoice_ids).toEqual(['inv_b', 'inv_a']); + expect(Object.keys(result.invoices)).toEqual(['inv_b', 'inv_a']); + + expect(result.invoices.inv_b).toMatchObject({ + invoice_id: 'inv_b', + seller_wallet: SELLER_B, + amount: '1000', + currency: 'USDC', + rate: '0.05', + maturity: '2026-12-31T00:00:00.000Z', + risk_rating: 'A', + funding_progress: '1', + }); + expect(result.invoices.inv_b.seller_stats).toEqual({ + wallet: SELLER_B, + invoice_count: 1, + total_amount: '1000', + average_rate: '0.05', + }); + }); + + it('nulls optional fields and aggregates each seller once', async () => { + mockFindMany.mockResolvedValue([ + { + ...invoiceRow('inv_a', SELLER_A), + rate: null, + maturityDate: null, + riskRating: null, + }, + invoiceRow('inv_b', SELLER_A), + ]); + mockGroupBy.mockResolvedValue([sellerStatsGroup(SELLER_A, 5)]); + + const result = await fetchInvoiceComparison(['inv_a', 'inv_b']); + + expect(result.invoices.inv_a).toMatchObject({ + rate: null, + maturity: null, + risk_rating: null, + }); + expect(mockGroupBy).toHaveBeenCalledWith( + expect.objectContaining({ + by: ['sellerWallet'], + where: { sellerWallet: { in: [SELLER_A] } }, + }) + ); + expect(result.invoices.inv_a.seller_stats.invoice_count).toBe(5); + expect(result.invoices.inv_b.seller_stats.invoice_count).toBe(5); + }); + }); + + describe('getInvoiceComparison', () => { + it('queries the database and caches on a miss', async () => { + mockFindMany.mockResolvedValue([ + invoiceRow('inv_a', SELLER_A), + invoiceRow('inv_b', SELLER_B), + ]); + mockGroupBy.mockResolvedValue([ + sellerStatsGroup(SELLER_A, 1), + sellerStatsGroup(SELLER_B, 1), + ]); + + await getInvoiceComparison(['inv_a', 'inv_b']); + + expect(mockCacheSetJson).toHaveBeenCalledWith( + 'invoice:compare:inv_a,inv_b', + expect.objectContaining({ invoice_ids: ['inv_a', 'inv_b'] }), + INVOICE_COMPARISON_CACHE_TTL_SECONDS + ); + }); + + it('serves from cache and restores the requested order', async () => { + mockCacheGetJson.mockResolvedValue({ + invoice_ids: ['inv_b', 'inv_a'], + invoices: { + inv_a: { invoice_id: 'inv_a' }, + inv_b: { invoice_id: 'inv_b' }, + }, + }); + + const result = await getInvoiceComparison(['inv_a', 'inv_b']); + + expect(mockFindMany).not.toHaveBeenCalled(); + expect(mockCacheSetJson).not.toHaveBeenCalled(); + expect(result.invoice_ids).toEqual(['inv_a', 'inv_b']); + expect(Object.keys(result.invoices)).toEqual(['inv_a', 'inv_b']); + }); + + it('does not cache failures', async () => { + mockFindMany.mockResolvedValue([]); + + await expect( + getInvoiceComparison(['inv_a', 'inv_b']) + ).rejects.toBeInstanceOf(InvoiceNotFoundError); + expect(mockCacheSetJson).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/invoice/invoice.service.ts b/src/modules/invoice/invoice.service.ts new file mode 100644 index 00000000..539c80e9 --- /dev/null +++ b/src/modules/invoice/invoice.service.ts @@ -0,0 +1,163 @@ +// src/modules/invoice/invoice.service.ts +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; +import { + INVOICE_COMPARISON_CACHE_TTL_SECONDS, + InvoiceComparison, + InvoiceMetrics, + SellerStats, +} from './invoice.schemas'; + +/** Raised when one or more requested invoice IDs do not resolve. */ +export class InvoiceNotFoundError extends Error { + readonly missingIds: string[]; + + constructor(missingIds: string[]) { + super( + missingIds.length === 1 + ? `Invoice '${missingIds[0]}' not found` + : `Invoices not found: ${missingIds.join(', ')}` + ); + this.name = 'InvoiceNotFoundError'; + this.missingIds = missingIds; + } +} + +/** + * Cache key for a comparison response. Sorted before joining so the key is + * independent of the order the IDs were requested in (#947). + */ +export function invoiceComparisonCacheKey(ids: string[]): string { + return `invoice:compare:${[...ids].sort().join(',')}`; +} + +function decimalToString( + value: { toString(): string } | null | undefined +): string { + return value === null || value === undefined ? '0' : value.toString(); +} + +/** + * Aggregates per-seller statistics for the sellers owning the compared + * invoices. One `groupBy` covers every requested seller. + */ +async function fetchSellerStats( + sellerWallets: string[] +): Promise> { + if (sellerWallets.length === 0) return new Map(); + + const groups = await prisma.invoice.groupBy({ + by: ['sellerWallet'], + where: { sellerWallet: { in: sellerWallets } }, + _count: { _all: true }, + _sum: { amount: true }, + _avg: { rate: true }, + }); + + return new Map( + groups.map(group => [ + group.sellerWallet, + { + wallet: group.sellerWallet, + invoice_count: group._count._all, + total_amount: decimalToString(group._sum.amount), + average_rate: + group._avg.rate === null || group._avg.rate === undefined + ? null + : group._avg.rate.toString(), + } as SellerStats, + ]) + ); +} + +/** + * Builds the metrics payload for the given invoices, throwing + * {@link InvoiceNotFoundError} if any requested ID does not resolve. + */ +export async function fetchInvoiceComparison( + ids: string[] +): Promise { + const invoices = await prisma.invoice.findMany({ + where: { id: { in: ids } }, + select: { + id: true, + sellerWallet: true, + currency: true, + status: true, + amount: true, + rate: true, + maturityDate: true, + riskRating: true, + fundingProgress: true, + }, + }); + + const found = new Map(invoices.map(invoice => [invoice.id, invoice])); + const missing = ids.filter(id => !found.has(id)); + if (missing.length > 0) { + throw new InvoiceNotFoundError(missing); + } + + const sellerStats = await fetchSellerStats([ + ...new Set(invoices.map(invoice => invoice.sellerWallet)), + ]); + + const metrics: Record = {}; + for (const id of ids) { + const invoice = found.get(id)!; + metrics[id] = { + invoice_id: invoice.id, + seller_wallet: invoice.sellerWallet, + currency: invoice.currency, + status: invoice.status, + amount: decimalToString(invoice.amount), + rate: invoice.rate ? invoice.rate.toString() : null, + maturity: invoice.maturityDate + ? new Date(invoice.maturityDate).toISOString() + : null, + risk_rating: invoice.riskRating ?? null, + funding_progress: decimalToString(invoice.fundingProgress), + seller_stats: sellerStats.get(invoice.sellerWallet) ?? { + wallet: invoice.sellerWallet, + invoice_count: 0, + total_amount: '0', + average_rate: null, + }, + }; + } + + return { invoice_ids: ids, invoices: metrics }; +} + +/** + * Cache-backed wrapper around {@link fetchInvoiceComparison}. Repeated + * requests for the same invoice combination are served from Redis for + * {@link INVOICE_COMPARISON_CACHE_TTL_SECONDS} seconds (#947). + */ +export async function getInvoiceComparison( + ids: string[] +): Promise { + const cacheKey = invoiceComparisonCacheKey(ids); + + const cached = await cacheGetJson(cacheKey); + if (cached) { + // Cached payloads are keyed order-independently; restore the caller's + // requested order before responding. + return { + invoice_ids: ids, + invoices: ids.reduce>((acc, id) => { + const metrics = cached.invoices?.[id]; + if (metrics) acc[id] = metrics; + return acc; + }, {}), + }; + } + + const comparison = await fetchInvoiceComparison(ids); + await cacheSetJson( + cacheKey, + comparison, + INVOICE_COMPARISON_CACHE_TTL_SECONDS + ); + return comparison; +}