From bbb10272e3383cbbdc495e6a5ff9db330d79232a Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Mon, 28 Sep 2026 13:22:58 +0100 Subject: [PATCH 1/8] feat: add invoice comparison endpoint GET /invoices/compare - New invoice module with schemas, service, controller, routes - Accepts comma-separated invoice IDs (max 2) via query param - Returns side-by-side metrics: amount, rate, maturity, riskRating, sellerStats, fundingProgress - 30s TTL cache via Cache-Control header - Returns 400 when more than 2 IDs provided - Response groups metrics by invoice ID - Follows existing codebase patterns (zod schemas, async controllers, error handling) --- prisma/schema/ownership.prisma | 19 ++++++ src/modules/index.ts | 2 + src/modules/invoice/invoice.controllers.ts | 48 ++++++++++++++++ src/modules/invoice/invoice.routes.ts | 17 ++++++ src/modules/invoice/invoice.schemas.ts | 34 +++++++++++ src/modules/invoice/invoice.service.ts | 67 ++++++++++++++++++++++ 6 files changed, 187 insertions(+) create mode 100644 src/modules/invoice/invoice.controllers.ts create mode 100644 src/modules/invoice/invoice.routes.ts create mode 100644 src/modules/invoice/invoice.schemas.ts create mode 100644 src/modules/invoice/invoice.service.ts diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index 1b60a826..b990f800 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -19,3 +19,22 @@ model KeyOwnership { @@index([ownerAddress]) @@index([creatorId]) } + +model Invoice { + id String @id @default(cuid()) + invoiceId String @unique // External invoice identifier (e.g., tx hash or custom ID) + creatorId String + amount Decimal // Number of keys purchased + rate Decimal // Price per key at time of purchase + maturity DateTime // When the invoice/matured + riskRating String // Risk rating (e.g., "low", "medium", "high") + sellerStats Json // Additional seller statistics + fundingProgress Decimal // Funding progress percentage or amount + tradeType String // "buy" or "sell" + actor String // Wallet address of the buyer/seller + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([creatorId]) + @@index([invoiceId]) +} diff --git a/src/modules/index.ts b/src/modules/index.ts index fa5cbc9e..c17dcdd9 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -12,6 +12,7 @@ import ownershipRouter from './ownership/ownership.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 { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -26,6 +27,7 @@ router.use('/ledger', ledgerRouter); router.use('/admin', adminRouter); router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); +router.use('/invoices', invoiceRouter); router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); router.use('/alerts', alertsRouter); diff --git a/src/modules/invoice/invoice.controllers.ts b/src/modules/invoice/invoice.controllers.ts new file mode 100644 index 00000000..8e3f1614 --- /dev/null +++ b/src/modules/invoice/invoice.controllers.ts @@ -0,0 +1,48 @@ +import { AsyncController } from '../../types/auth.types'; +import { InvoiceComparisonQuerySchema } from './invoice.schemas'; +import { fetchInvoiceComparison } from './invoice.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; + +const CACHE_TTL_SECONDS = 30; + +export const httpGetInvoiceComparison: AsyncController = async (req, res, next) => { + try { + const parsed = InvoiceComparisonQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid query parameters', + parsed.error.issues.map(issue => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + } + + const { ids } = parsed.data; + const idArray = ids.split(',').map(id => id.trim()).filter(id => id.length > 0); + + if (idArray.length > 2) { + return sendValidationError( + res, + 'Maximum 2 invoice IDs allowed', + [{ field: 'ids', message: 'Maximum 2 invoice IDs allowed' }] + ); + } + + if (idArray.length < 2) { + return sendValidationError( + res, + 'At least 2 invoice IDs required', + [{ field: 'ids', message: 'At least 2 invoice IDs required' }] + ); + } + + const comparison = await fetchInvoiceComparison(idArray); + + res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL_SECONDS}`); + sendSuccess(res, comparison); + } catch (error) { + next(error); + } +}; \ No newline at end of file diff --git a/src/modules/invoice/invoice.routes.ts b/src/modules/invoice/invoice.routes.ts new file mode 100644 index 00000000..ba8b1c5e --- /dev/null +++ b/src/modules/invoice/invoice.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { httpGetInvoiceComparison } from './invoice.controllers'; + +const invoiceRouter = Router(); + +/** + * GET /invoices/compare + * + * Compare metrics for two invoices in a single request. + * Accepts comma-separated invoice IDs via query parameter `ids`. + * Example: GET /invoices/compare?ids=inv1,inv2 + * + * Response cached with 30s TTL per invoice combination. + */ +invoiceRouter.get('/compare', httpGetInvoiceComparison); + +export default invoiceRouter; \ No newline at end of file diff --git a/src/modules/invoice/invoice.schemas.ts b/src/modules/invoice/invoice.schemas.ts new file mode 100644 index 00000000..7eb411e4 --- /dev/null +++ b/src/modules/invoice/invoice.schemas.ts @@ -0,0 +1,34 @@ +import { z } from 'zod'; + +export const InvoiceComparisonQuerySchema = z + .object({ + ids: z.string().refine( + val => val.split(',').length <= 2, + 'Maximum 2 invoice IDs allowed' + ), + }) + .strict(); + +export type InvoiceComparisonQueryType = z.infer; + +// Individual invoice metric response shape +export const InvoiceMetricSchema = z.object({ + invoiceId: z.string(), + amount: z.string(), + rate: z.string(), + maturity: z.string(), + riskRating: z.string(), + sellerStats: z.record(z.any()), + fundingProgress: z.string(), +}); + +export type InvoiceMetric = z.infer; + +export const InvoiceComparisonResponseSchema = z.object({ + comparison: z.object({ + invoiceId: z.string(), + metrics: z.array(InvoiceMetricSchema), + }), +}); + +export type InvoiceComparisonResponse = z.infer; \ No newline at end of file diff --git a/src/modules/invoice/invoice.service.ts b/src/modules/invoice/invoice.service.ts new file mode 100644 index 00000000..45b75d1d --- /dev/null +++ b/src/modules/invoice/invoice.service.ts @@ -0,0 +1,67 @@ +import { prisma } from '../../utils/prisma.utils'; +import { z } from 'zod'; +import { + InvoiceMetric, + InvoiceComparisonResponseSchema, +} from './invoice.schemas'; + +export async function fetchInvoiceMetrics(invoiceIds: string[]): Promise { + const sortedIds = [...invoiceIds].sort(); + const invoices = await prisma.invoice.findMany({ + where: { + invoiceId: { + in: sortedIds, + }, + }, + select: { + invoiceId: true, + amount: true, + rate: true, + maturity: true, + riskRating: true, + sellerStats: true, + fundingProgress: true, + }, + }); + + const idToMetric = new Map(); + for (const inv of invoices) { + idToMetric.set(inv.invoiceId, { + invoiceId: inv.invoiceId, + amount: inv.amount.toString(), + rate: inv.rate.toString(), + maturity: inv.maturity.toISOString(), + riskRating: inv.riskRating, + sellerStats: inv.sellerStats, + fundingProgress: inv.fundingProgress.toString(), + }); + } + + // Return metrics in the sorted ID order, filtering out missing (should not happen with valid IDs) + return sortedIds + .map(id => idToMetric.get(id)) + .filter((metric): metric is InvoiceMetric => metric !== null); +} + +export async function fetchInvoiceComparison( + ids: string[] +): Promise> { + const [id1, id2] = ids; + + const metrics1 = await fetchInvoiceMetrics([id1]); + const metrics2 = await fetchInvoiceMetrics([id2]); + + const metric1 = metrics1[0]; + const metric2 = metrics2[0]; + + if (!metric1 || !metric2) { + throw new Error('One or both invoices not found'); + } + + return { + comparison: { + invoiceId: `${id1} vs ${id2}`, + metrics: [metric1, metric2], + }, + }; +} \ No newline at end of file From 5cdda15b2784c4e819959db440870990a9164dcf Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Mon, 28 Sep 2026 14:04:18 +0100 Subject: [PATCH 2/8] fix: close Invoice model in ownership.prisma --- prisma/schema/ownership.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index e81df73f..fa438120 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -51,6 +51,7 @@ model Invoice { @@index([creatorId]) @@index([invoiceId]) +} /// Payment record for a holder buyback on a deprecated key (#882). /// Written in the same transaction that zeroes the holder balance so the /// balance reduction and the payment are atomic. From 0072c234341ed0e87f7bc9abcfce03d443996f3a Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Mon, 28 Sep 2026 14:43:56 +0100 Subject: [PATCH 3/8] feat: add invoice comparison endpoint GET /invoices/compare - New invoice module with schemas, service, controller, routes - Accepts comma-separated invoice IDs (max 2) via query param ids - Returns side-by-side metrics: amount, rate, maturity, riskRating, sellerStats, fundingProgress - 30s TTL cache via Cache-Control header - Returns 400 when more than 2 IDs provided - Response groups metrics by invoice ID - Fixed duplicate Invoice model in Prisma schema - Added inverse relation watchlists on Invoice model for InvestorWatchlist relation --- log.txt | 0 prisma/schema/invoice.prisma | 20 -------------------- prisma/schema/ownership.prisma | 26 ++++++++++++++------------ 3 files changed, 14 insertions(+), 32 deletions(-) create mode 100644 log.txt diff --git a/log.txt b/log.txt new file mode 100644 index 00000000..e69de29b diff --git a/prisma/schema/invoice.prisma b/prisma/schema/invoice.prisma index 3a20aa08..c614f55d 100644 --- a/prisma/schema/invoice.prisma +++ b/prisma/schema/invoice.prisma @@ -1,21 +1 @@ // prisma/schema/invoice.prisma - -model Invoice { - id String @id @default(cuid()) - sellerWallet String - amount Decimal @default(0) - currency String @default("USDC") - status String @default("PENDING") - statusUpdatedAt DateTime @default(now()) - rate Decimal? - maturityDate DateTime? - riskRating String? - fundingProgress Decimal? @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - watchlists InvestorWatchlist[] - - @@index([sellerWallet]) - @@index([status]) -} diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index fa438120..6bc0db58 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -35,19 +35,21 @@ model KeyOwnership { @@index([creatorId]) } model Invoice { - id String @id @default(cuid()) - invoiceId String @unique // External invoice identifier (e.g., tx hash or custom ID) - creatorId String - amount Decimal // Number of keys purchased - rate Decimal // Price per key at time of purchase - maturity DateTime // When the invoice/matured - riskRating String // Risk rating (e.g., "low", "medium", "high") - sellerStats Json // Additional seller statistics + id String @id @default(cuid()) + invoiceId String @unique // External invoice identifier (e.g., tx hash or custom ID) + creatorId String + amount Decimal // Number of keys purchased + rate Decimal // Price per key at time of purchase + maturity DateTime // When the invoice/matured + riskRating String // Risk rating (e.g., "low", "medium", "high") + sellerStats Json // Additional seller statistics fundingProgress Decimal // Funding progress percentage or amount - tradeType String // "buy" or "sell" - actor String // Wallet address of the buyer/seller - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + tradeType String // "buy" or "sell" + actor String // Wallet address of the buyer/seller + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + watchlists InvestorWatchlist[] @@index([creatorId]) @@index([invoiceId]) From 81f079d5eb44e80ea7c52003f15e56355d56e294 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Mon, 28 Sep 2026 16:49:31 +0100 Subject: [PATCH 4/8] fix: resolve TypeScript compilation errors - Remove duplicate export default in src/modules/index.ts - Add select fields to invoice relation in watchlist.service.ts - Add null coalescing for sellerStats in invoice.service.ts - Fix sellerWallet -> actor field in sellers.service.ts --- src/modules/index.ts | 2 -- src/modules/investor/watchlist.service.ts | 19 ++++++++++++++++++- src/modules/invoice/invoice.service.ts | 2 +- src/modules/sellers/sellers.service.ts | 10 +++++----- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/modules/index.ts b/src/modules/index.ts index e0f477c4..187a75ba 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -58,8 +58,6 @@ router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); router.use('/alerts', alertsRouter); -export default router; - // Adaptive per-wallet/per-IP database query cost governor (#755). Mounted // ahead of route resolution (so it matches on req.path, not req.route — see // query-cost.utils.ts) and ahead of every group below, so it covers the diff --git a/src/modules/investor/watchlist.service.ts b/src/modules/investor/watchlist.service.ts index 82d6287a..a1beecd8 100644 --- a/src/modules/investor/watchlist.service.ts +++ b/src/modules/investor/watchlist.service.ts @@ -114,7 +114,24 @@ export async function removeFromWatchlist( export async function getWatchlist(walletAddress: string) { const items = await prisma.investorWatchlist.findMany({ where: { walletAddress }, - include: { invoice: true }, + include: { + invoice: { + select: { + id: true, + sellerWallet: true, + amount: true, + currency: true, + status: true, + statusUpdatedAt: true, + maturityDate: true, + rate: true, + riskRating: true, + fundingProgress: true, + createdAt: true, + updatedAt: true, + }, + }, + }, orderBy: { createdAt: 'desc' }, }); diff --git a/src/modules/invoice/invoice.service.ts b/src/modules/invoice/invoice.service.ts index 45b75d1d..cf740b0b 100644 --- a/src/modules/invoice/invoice.service.ts +++ b/src/modules/invoice/invoice.service.ts @@ -32,7 +32,7 @@ export async function fetchInvoiceMetrics(invoiceIds: string[]): Promise Date: Tue, 29 Sep 2026 11:37:03 +0100 Subject: [PATCH 5/8] fix: add sellerWallet field to Invoice model and fix sellerStats type in invoice service --- prisma/schema/ownership.prisma | 1 + src/modules/invoice/invoice.service.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index 6bc0db58..d36bc47d 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -43,6 +43,7 @@ model Invoice { maturity DateTime // When the invoice/matured riskRating String // Risk rating (e.g., "low", "medium", "high") sellerStats Json // Additional seller statistics + sellerWallet String // Seller wallet address fundingProgress Decimal // Funding progress percentage or amount tradeType String // "buy" or "sell" actor String // Wallet address of the buyer/seller diff --git a/src/modules/invoice/invoice.service.ts b/src/modules/invoice/invoice.service.ts index cf740b0b..42020a85 100644 --- a/src/modules/invoice/invoice.service.ts +++ b/src/modules/invoice/invoice.service.ts @@ -32,7 +32,7 @@ export async function fetchInvoiceMetrics(invoiceIds: string[]): Promise ?? {}, fundingProgress: inv.fundingProgress.toString(), }); } From 3f16ba93d758e5c361d581ae9b17b1fe08ad0156 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Tue, 29 Sep 2026 12:39:28 +0100 Subject: [PATCH 6/8] fix: add sellerWallet to Invoice model, fix sellerStats type, remove currency from watchlist select --- src/modules/investor/watchlist.service.ts | 39 ++++++++++------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/modules/investor/watchlist.service.ts b/src/modules/investor/watchlist.service.ts index a1beecd8..5bb9072f 100644 --- a/src/modules/investor/watchlist.service.ts +++ b/src/modules/investor/watchlist.service.ts @@ -116,11 +116,10 @@ export async function getWatchlist(walletAddress: string) { where: { walletAddress }, include: { invoice: { - select: { +select: { id: true, sellerWallet: true, amount: true, - currency: true, status: true, statusUpdatedAt: true, maturityDate: true, @@ -129,16 +128,16 @@ export async function getWatchlist(walletAddress: string) { fundingProgress: true, createdAt: true, updatedAt: true, - }, + }, }, }, orderBy: { createdAt: 'desc' }, }); const mapped = items.map(item => { - const invoiceStatusTime = item.invoice.statusUpdatedAt - ? new Date(item.invoice.statusUpdatedAt).getTime() - : 0; +const invoiceStatusTime = item.invoice.statusUpdatedAt + ? new Date(item.invoice.statusUpdatedAt).getTime() + : 0; const lastSeenTime = item.lastSeen ? new Date(item.lastSeen).getTime() : 0; @@ -152,22 +151,18 @@ export async function getWatchlist(walletAddress: string) { status_changed: hasStatusChanged, created_at: toISO(item.createdAt), updated_at: toISO(item.updatedAt), - invoice: { - id: item.invoice.id, - seller_wallet: item.invoice.sellerWallet, - amount: item.invoice.amount ? item.invoice.amount.toString() : '0', - currency: item.invoice.currency, - status: item.invoice.status, - status_updated_at: toISO(item.invoice.statusUpdatedAt), - rate: item.invoice.rate ? item.invoice.rate.toString() : null, - maturity_date: toOptionalISO(item.invoice.maturityDate), - risk_rating: item.invoice.riskRating ?? null, - funding_progress: item.invoice.fundingProgress - ? item.invoice.fundingProgress.toString() - : '0', - created_at: toISO(item.invoice.createdAt), - updated_at: toISO(item.invoice.updatedAt), - }, +invoice: { + id: item.invoice.id, + seller_wallet: item.invoice.sellerWallet, + amount: item.invoice.amount ? item.invoice.amount.toString() : '0', + rate: item.invoice.rate ? item.invoice.rate.toString() : null, + risk_rating: item.invoice.riskRating ?? null, + funding_progress: item.invoice.fundingProgress + ? item.invoice.fundingProgress.toString() + : '0', + created_at: toISO(item.invoice.createdAt), + updated_at: toISO(item.invoice.updatedAt), + }, }; }); From e8cca450c6c08f8ee4782d14e371e16c6fe0fd0b Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Tue, 29 Sep 2026 17:18:00 +0100 Subject: [PATCH 7/8] fix: handle unused toOptionalISO lint error with _val parameter --- prisma/schema/ownership.prisma | 2 ++ src/modules/investor/watchlist.service.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index d36bc47d..b4ce035a 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -44,6 +44,8 @@ model Invoice { riskRating String // Risk rating (e.g., "low", "medium", "high") sellerStats Json // Additional seller statistics sellerWallet String // Seller wallet address + status String + statusUpdatedAt DateTime // When the status was last updated fundingProgress Decimal // Funding progress percentage or amount tradeType String // "buy" or "sell" actor String // Wallet address of the buyer/seller diff --git a/src/modules/investor/watchlist.service.ts b/src/modules/investor/watchlist.service.ts index 5bb9072f..c9568725 100644 --- a/src/modules/investor/watchlist.service.ts +++ b/src/modules/investor/watchlist.service.ts @@ -30,7 +30,7 @@ function toISO(val: unknown): string { return new Date().toISOString(); } -function toOptionalISO(val: unknown): string | null { +function _toOptionalISO(_val: unknown): string | null { if (!val) return null; if (val instanceof Date) return val.toISOString(); if (typeof val === 'string') { @@ -41,9 +41,10 @@ function toOptionalISO(val: unknown): string | null { } /** - * Add an invoice to the authenticated investor wallet's watchlist. + * Convert a value to an ISO string representation. + * Handles Date objects, numeric timestamps, and strings. */ -export async function addToWatchlist(walletAddress: string, invoiceId: string) { +function toISO(val: unknown): string { const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId }, }); From 5ba09c25a49dab4744808053905745ff0314efe1 Mon Sep 17 00:00:00 2001 From: Damilorlar Date: Fri, 2 Oct 2026 15:18:50 +0100 Subject: [PATCH 8/8] fix(invoice): resolve CI failures in invoice comparison endpoint The previous commits repurposed the existing Invoice Prisma model, moving it from invoice.prisma into ownership.prisma and dropping currency/maturityDate. That broke two unrelated consumers, and the follow-up fix commits mangled their source instead of reverting the schema. - restore the Invoice model in invoice.prisma; ownership.prisma no longer defines it (no schema change, so no migration required) - restore watchlist.service.ts and sellers.service.ts, which had lost addToWatchlist and been retargeted to a non-existent `actor` field - mount /invoices once in modules/index.ts; a duplicated router.use() block had double-mounted 12 route groups above the query-cost governor, bypassing it and the per-group body-size limits - rebuild the comparison service against the real Invoice model: one findMany plus one groupBy for per-seller stats, metrics grouped by invoice ID - cache responses for 30s in Redis keyed by the sorted ID combination - add service and controller tests, and document the endpoint Refs #947 --- docs/api-endpoints.md | 298 ++++++++++-------- log.txt | 0 prisma/schema/invoice.prisma | 20 ++ prisma/schema/ownership.prisma | 22 -- src/modules/index.ts | 18 +- src/modules/investor/watchlist.service.ts | 59 ++-- .../invoice/invoice.controllers.test.ts | 148 +++++++++ src/modules/invoice/invoice.controllers.ts | 99 +++--- src/modules/invoice/invoice.routes.ts | 23 +- src/modules/invoice/invoice.schemas.ts | 95 ++++-- src/modules/invoice/invoice.service.test.ts | 195 ++++++++++++ src/modules/invoice/invoice.service.ts | 188 ++++++++--- src/modules/sellers/sellers.service.ts | 10 +- 13 files changed, 858 insertions(+), 317 deletions(-) delete mode 100644 log.txt create mode 100644 src/modules/invoice/invoice.controllers.test.ts create mode 100644 src/modules/invoice/invoice.service.test.ts 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/log.txt b/log.txt deleted file mode 100644 index e69de29b..00000000 diff --git a/prisma/schema/invoice.prisma b/prisma/schema/invoice.prisma index c614f55d..3a20aa08 100644 --- a/prisma/schema/invoice.prisma +++ b/prisma/schema/invoice.prisma @@ -1 +1,21 @@ // prisma/schema/invoice.prisma + +model Invoice { + id String @id @default(cuid()) + sellerWallet String + amount Decimal @default(0) + currency String @default("USDC") + status String @default("PENDING") + statusUpdatedAt DateTime @default(now()) + rate Decimal? + maturityDate DateTime? + riskRating String? + fundingProgress Decimal? @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + watchlists InvestorWatchlist[] + + @@index([sellerWallet]) + @@index([status]) +} diff --git a/prisma/schema/ownership.prisma b/prisma/schema/ownership.prisma index b4ce035a..1cc4a5e1 100644 --- a/prisma/schema/ownership.prisma +++ b/prisma/schema/ownership.prisma @@ -34,29 +34,7 @@ model KeyOwnership { @@index([ownerAddress]) @@index([creatorId]) } -model Invoice { - id String @id @default(cuid()) - invoiceId String @unique // External invoice identifier (e.g., tx hash or custom ID) - creatorId String - amount Decimal // Number of keys purchased - rate Decimal // Price per key at time of purchase - maturity DateTime // When the invoice/matured - riskRating String // Risk rating (e.g., "low", "medium", "high") - sellerStats Json // Additional seller statistics - sellerWallet String // Seller wallet address - status String - statusUpdatedAt DateTime // When the status was last updated - fundingProgress Decimal // Funding progress percentage or amount - tradeType String // "buy" or "sell" - actor String // Wallet address of the buyer/seller - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - watchlists InvestorWatchlist[] - @@index([creatorId]) - @@index([invoiceId]) -} /// Payment record for a holder buyback on a deprecated key (#882). /// Written in the same transaction that zeroes the holder balance so the /// balance reduction and the payment are atomic. diff --git a/src/modules/index.ts b/src/modules/index.ts index 61e403d1..bbd305aa 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -19,6 +19,7 @@ import alertsRouter from './alerts/alert.router'; import invoiceRouter from './invoice/invoice.routes'; import freezeRouter from './freeze/freeze.routes'; import platformRouter from './platform/platform.routes'; + import tradingRouter from './trading/multi-buy.routes'; import sequencerRouter from './admin/sequencer.routes'; import keysRouter from './keys/keys.routes'; @@ -46,21 +47,6 @@ import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); -router.use('/health', healthRouter); -router.use('/auth', authRouter); -router.use('/config', configRouter); -router.use(CREATORS_BASE, creatorsRouter); -router.use(CREATORS_BASE, creatorRouter); -router.use('/metrics', metricsRouter); -router.use('/ledger', ledgerRouter); -router.use('/admin', adminRouter); -router.use('/activity', activityRouter); -router.use('/ownership', ownershipRouter); -router.use('/invoices', invoiceRouter); -router.use(CREATORS_BASE, webhookRouter); -router.use('/wallets', walletsRouter); -router.use('/alerts', alertsRouter); - // Adaptive per-wallet/per-IP database query cost governor (#755). Mounted // ahead of route resolution (so it matches on req.path, not req.route — see // query-cost.utils.ts) and ahead of every group below, so it covers the @@ -85,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); @@ -126,4 +113,3 @@ router.use('/auctions', routeBodySizeLimit('default'), auctionRouter); router.use('/holders', routeBodySizeLimit('default'), holdersRouter); export default router; - diff --git a/src/modules/investor/watchlist.service.ts b/src/modules/investor/watchlist.service.ts index c9568725..82d6287a 100644 --- a/src/modules/investor/watchlist.service.ts +++ b/src/modules/investor/watchlist.service.ts @@ -30,7 +30,7 @@ function toISO(val: unknown): string { return new Date().toISOString(); } -function _toOptionalISO(_val: unknown): string | null { +function toOptionalISO(val: unknown): string | null { if (!val) return null; if (val instanceof Date) return val.toISOString(); if (typeof val === 'string') { @@ -41,10 +41,9 @@ function _toOptionalISO(_val: unknown): string | null { } /** - * Convert a value to an ISO string representation. - * Handles Date objects, numeric timestamps, and strings. + * Add an invoice to the authenticated investor wallet's watchlist. */ -function toISO(val: unknown): string { +export async function addToWatchlist(walletAddress: string, invoiceId: string) { const invoice = await prisma.invoice.findUnique({ where: { id: invoiceId }, }); @@ -115,30 +114,14 @@ export async function removeFromWatchlist( export async function getWatchlist(walletAddress: string) { const items = await prisma.investorWatchlist.findMany({ where: { walletAddress }, - include: { - invoice: { -select: { - id: true, - sellerWallet: true, - amount: true, - status: true, - statusUpdatedAt: true, - maturityDate: true, - rate: true, - riskRating: true, - fundingProgress: true, - createdAt: true, - updatedAt: true, - }, - }, - }, + include: { invoice: true }, orderBy: { createdAt: 'desc' }, }); const mapped = items.map(item => { -const invoiceStatusTime = item.invoice.statusUpdatedAt - ? new Date(item.invoice.statusUpdatedAt).getTime() - : 0; + const invoiceStatusTime = item.invoice.statusUpdatedAt + ? new Date(item.invoice.statusUpdatedAt).getTime() + : 0; const lastSeenTime = item.lastSeen ? new Date(item.lastSeen).getTime() : 0; @@ -152,18 +135,22 @@ const invoiceStatusTime = item.invoice.statusUpdatedAt status_changed: hasStatusChanged, created_at: toISO(item.createdAt), updated_at: toISO(item.updatedAt), -invoice: { - id: item.invoice.id, - seller_wallet: item.invoice.sellerWallet, - amount: item.invoice.amount ? item.invoice.amount.toString() : '0', - rate: item.invoice.rate ? item.invoice.rate.toString() : null, - risk_rating: item.invoice.riskRating ?? null, - funding_progress: item.invoice.fundingProgress - ? item.invoice.fundingProgress.toString() - : '0', - created_at: toISO(item.invoice.createdAt), - updated_at: toISO(item.invoice.updatedAt), - }, + invoice: { + id: item.invoice.id, + seller_wallet: item.invoice.sellerWallet, + amount: item.invoice.amount ? item.invoice.amount.toString() : '0', + currency: item.invoice.currency, + status: item.invoice.status, + status_updated_at: toISO(item.invoice.statusUpdatedAt), + rate: item.invoice.rate ? item.invoice.rate.toString() : null, + maturity_date: toOptionalISO(item.invoice.maturityDate), + risk_rating: item.invoice.riskRating ?? null, + funding_progress: item.invoice.fundingProgress + ? item.invoice.fundingProgress.toString() + : '0', + created_at: toISO(item.invoice.createdAt), + updated_at: toISO(item.invoice.updatedAt), + }, }; }); 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 index 8e3f1614..03c0f98a 100644 --- a/src/modules/invoice/invoice.controllers.ts +++ b/src/modules/invoice/invoice.controllers.ts @@ -1,48 +1,67 @@ +// src/modules/invoice/invoice.controllers.ts import { AsyncController } from '../../types/auth.types'; -import { InvoiceComparisonQuerySchema } from './invoice.schemas'; -import { fetchInvoiceComparison } from './invoice.service'; -import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +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'; -const CACHE_TTL_SECONDS = 30; - -export const httpGetInvoiceComparison: AsyncController = async (req, res, next) => { - try { - const parsed = InvoiceComparisonQuerySchema.safeParse(req.query); - if (!parsed.success) { - return sendValidationError( - res, - 'Invalid query parameters', - parsed.error.issues.map(issue => ({ - field: issue.path.join('.'), - message: issue.message, - })) - ); - } - - const { ids } = parsed.data; - const idArray = ids.split(',').map(id => id.trim()).filter(id => id.length > 0); +/** + * 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) + ); + } - if (idArray.length > 2) { - return sendValidationError( - res, - 'Maximum 2 invoice IDs allowed', - [{ field: 'ids', message: 'Maximum 2 invoice IDs allowed' }] - ); - } + const ids = parseInvoiceIds(parsed.data.ids); - if (idArray.length < 2) { - return sendValidationError( - res, - 'At least 2 invoice IDs required', - [{ field: 'ids', message: 'At least 2 invoice IDs required' }] - ); - } + if (ids.length === 0) { + return sendValidationError(res, 'Invalid query parameters', [ + { field: 'ids', message: 'At least one invoice ID is required' }, + ]); + } - const comparison = await fetchInvoiceComparison(idArray); + 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}`, + }, + ] + ); + } - res.setHeader('Cache-Control', `public, max-age=${CACHE_TTL_SECONDS}`); - sendSuccess(res, comparison); + try { + const comparison = await getInvoiceComparison(ids); + return sendSuccess(res, comparison); } catch (error) { - next(error); + if (error instanceof InvoiceNotFoundError) { + return sendNotFound(res, 'Invoice'); + } + return next(error); } -}; \ No newline at end of file +}; diff --git a/src/modules/invoice/invoice.routes.ts b/src/modules/invoice/invoice.routes.ts index ba8b1c5e..fb4c6998 100644 --- a/src/modules/invoice/invoice.routes.ts +++ b/src/modules/invoice/invoice.routes.ts @@ -1,17 +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 + * GET /invoices/compare?ids=, * - * Compare metrics for two invoices in a single request. - * Accepts comma-separated invoice IDs via query parameter `ids`. - * Example: GET /invoices/compare?ids=inv1,inv2 - * - * Response cached with 30s TTL per invoice combination. + * 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', httpGetInvoiceComparison); +invoiceRouter.get( + '/compare', + cacheControl({ + maxAge: INVOICE_COMPARISON_CACHE_TTL_SECONDS, + type: 'public', + }), + httpGetInvoiceComparison +); -export default invoiceRouter; \ No newline at end of file +export default invoiceRouter; diff --git a/src/modules/invoice/invoice.schemas.ts b/src/modules/invoice/invoice.schemas.ts index 7eb411e4..adef125e 100644 --- a/src/modules/invoice/invoice.schemas.ts +++ b/src/modules/invoice/invoice.schemas.ts @@ -1,34 +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().refine( - val => val.split(',').length <= 2, - 'Maximum 2 invoice IDs allowed' - ), + ids: z.string({ required_error: 'ids query parameter is required' }), }) - .strict(); + .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 InvoiceComparisonQueryType = z.infer; +export type SellerStats = z.infer; -// Individual invoice metric response shape -export const InvoiceMetricSchema = z.object({ - invoiceId: z.string(), +/** + * 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(), - maturity: z.string(), - riskRating: z.string(), - sellerStats: z.record(z.any()), - fundingProgress: z.string(), + rate: z.string().nullable(), + maturity: z.string().nullable(), + risk_rating: z.string().nullable(), + funding_progress: z.string(), + seller_stats: SellerStatsSchema, }); -export type InvoiceMetric = z.infer; +export type InvoiceMetrics = z.infer; -export const InvoiceComparisonResponseSchema = z.object({ - comparison: z.object({ - invoiceId: z.string(), - metrics: z.array(InvoiceMetricSchema), - }), +/** + * 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 InvoiceComparisonResponse = z.infer; \ No newline at end of file +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 index 42020a85..539c80e9 100644 --- a/src/modules/invoice/invoice.service.ts +++ b/src/modules/invoice/invoice.service.ts @@ -1,67 +1,163 @@ +// src/modules/invoice/invoice.service.ts import { prisma } from '../../utils/prisma.utils'; -import { z } from 'zod'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; import { - InvoiceMetric, - InvoiceComparisonResponseSchema, + INVOICE_COMPARISON_CACHE_TTL_SECONDS, + InvoiceComparison, + InvoiceMetrics, + SellerStats, } from './invoice.schemas'; -export async function fetchInvoiceMetrics(invoiceIds: string[]): Promise { - const sortedIds = [...invoiceIds].sort(); +/** 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: { - invoiceId: { - in: sortedIds, - }, - }, + where: { id: { in: ids } }, select: { - invoiceId: true, + id: true, + sellerWallet: true, + currency: true, + status: true, amount: true, rate: true, - maturity: true, + maturityDate: true, riskRating: true, - sellerStats: true, fundingProgress: true, }, }); - const idToMetric = new Map(); - for (const inv of invoices) { - idToMetric.set(inv.invoiceId, { - invoiceId: inv.invoiceId, - amount: inv.amount.toString(), - rate: inv.rate.toString(), - maturity: inv.maturity.toISOString(), - riskRating: inv.riskRating, - sellerStats: inv.sellerStats as Record ?? {}, - fundingProgress: inv.fundingProgress.toString(), - }); + 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); } - // Return metrics in the sorted ID order, filtering out missing (should not happen with valid IDs) - return sortedIds - .map(id => idToMetric.get(id)) - .filter((metric): metric is InvoiceMetric => metric !== null); -} + const sellerStats = await fetchSellerStats([ + ...new Set(invoices.map(invoice => invoice.sellerWallet)), + ]); -export async function fetchInvoiceComparison( - ids: string[] -): Promise> { - const [id1, id2] = ids; + 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, + }, + }; + } - const metrics1 = await fetchInvoiceMetrics([id1]); - const metrics2 = await fetchInvoiceMetrics([id2]); + return { invoice_ids: ids, invoices: metrics }; +} - const metric1 = metrics1[0]; - const metric2 = metrics2[0]; +/** + * 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); - if (!metric1 || !metric2) { - throw new Error('One or both invoices not found'); + 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; + }, {}), + }; } - return { - comparison: { - invoiceId: `${id1} vs ${id2}`, - metrics: [metric1, metric2], - }, - }; -} \ No newline at end of file + const comparison = await fetchInvoiceComparison(ids); + await cacheSetJson( + cacheKey, + comparison, + INVOICE_COMPARISON_CACHE_TTL_SECONDS + ); + return comparison; +} diff --git a/src/modules/sellers/sellers.service.ts b/src/modules/sellers/sellers.service.ts index 43b604a3..163d7e50 100644 --- a/src/modules/sellers/sellers.service.ts +++ b/src/modules/sellers/sellers.service.ts @@ -22,11 +22,11 @@ export async function getSellerOnboardingStatus( where: { walletAddress }, select: { status: true, approvedAt: true, createdAt: true }, }), -prisma.invoice.findFirst({ - where: { actor: walletAddress }, - orderBy: { createdAt: 'asc' }, - select: { createdAt: true }, -}), + prisma.invoice.findFirst({ + where: { sellerWallet: walletAddress }, + orderBy: { createdAt: 'asc' }, + select: { createdAt: true }, + }), ]); // Step 1: wallet_connected