From 336ece8e2dfe50efa29dc3c8205ac1ab5dd3d797 Mon Sep 17 00:00:00 2001 From: Jerry_tekh Date: Wed, 26 Aug 2026 00:05:48 +0100 Subject: [PATCH] feat(invoices): add invoice analytics endpoint and integration test for seller authorization --- src/controllers/invoice.controller.ts | 29 ++++ src/routes/invoice.routes.ts | 7 + src/services/invoice.service.ts | 42 +++++ .../invoice-analytics.integration.test.ts | 158 ++++++++++++++++++ 4 files changed, 236 insertions(+) create mode 100644 tests/integration/invoice-analytics.integration.test.ts diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index f5bcf86..60da991 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -386,6 +386,35 @@ export function createInvoiceController(invoiceService: InvoiceService) { } }, + async getInvoiceAnalytics( + req: Request & { params: { id: string } }, + res: Response, + next: NextFunction, + ): Promise { + try { + const authReq = req as AuthenticatedRequest; + if (!authReq.user) { + throw new HttpError(401, "Authentication required"); + } + + const { id } = req.params; + + const result = await invoiceService.getInvoiceAnalytics(id, authReq.user.id); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + async calculateTerms( req: Request, res: Response, diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 80784d5..44333df 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -242,6 +242,13 @@ export function createInvoiceRouter({ controller.getInvoiceEscrowStatus, ); + // GET /api/v1/invoices/:id/analytics - Get invoice analytics (owner only) + router.get( + "/:id/analytics", + authenticateJWT, + controller.getInvoiceAnalytics, + ); + // POST /api/v1/invoices/calculate-terms - Calculate invoice discounting terms, fees, and APR router.post( "/calculate-terms", diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 1de7d2e..63720b1 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -618,6 +618,48 @@ export class InvoiceService { }; } + /** + * Get analytics data for an invoice (views, click-throughs, investor interest). + * Only the owning seller can access this endpoint. + */ + async getInvoiceAnalytics(invoiceId: string, sellerId: string): Promise<{ + invoiceId: string; + views: number; + clickThroughs: number; + investorInterest: number; + }> { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + if (invoice.sellerId !== sellerId) { + throw new ServiceError( + "forbidden", + "You do not have access to this invoice's analytics", + 403, + ); + } + + let investorInterest = 0; + if (this.dataSource) { + const investmentRepository = this.dataSource.getRepository(Investment); + investorInterest = await investmentRepository.count({ + where: { invoiceId }, + }); + } + + return { + invoiceId: invoice.id, + views: 0, + clickThroughs: 0, + investorInterest, + }; + } + /** * Convert Invoice model to DTO */ diff --git a/tests/integration/invoice-analytics.integration.test.ts b/tests/integration/invoice-analytics.integration.test.ts new file mode 100644 index 0000000..1b76868 --- /dev/null +++ b/tests/integration/invoice-analytics.integration.test.ts @@ -0,0 +1,158 @@ +import { InvoiceService } from "../../src/services/invoice.service"; +import { Invoice } from "../../src/models/Invoice.model"; +import { Investment } from "../../src/models/Investment.model"; +import { User } from "../../src/models/User.model"; +import { InvoiceStatus, UserType, KYCStatus } from "../../src/types/enums"; +import type { IPFSService } from "../../src/services/ipfs.service"; + +const mockIPFSService = {} as IPFSService; + +function createInvoiceRepository(invoices: Map) { + return { + findOne: async (options: { where: { id: string } }) => { + return invoices.get(options.where.id) || null; + }, + findOneBy: async (options: { id?: string; invoiceNumber?: string }) => { + if (options.id) { + return invoices.get(options.id) || null; + } + return null; + }, + } as any; +} + +function createInvestmentRepository(investments: Map) { + return { + count: async (options: { where: { invoiceId: string } }) => { + return [...investments.values()].filter( + (inv) => inv.invoiceId === options.where.invoiceId + ).length; + }, + } as any; +} + +function createFakeDataSource(investments: Map) { + return { + getRepository: jest.fn().mockImplementation((entity: any) => { + if (entity === Investment) { + return createInvestmentRepository(investments); + } + return {}; + }), + } as any; +} + +describe("Invoice analytics integration: seller authorization scoping", () => { + let invoices: Map; + let investments: Map; + let sellerA: User; + let sellerB: User; + let invoiceA: Invoice; + let invoiceService: InvoiceService; + + beforeEach(() => { + invoices = new Map(); + investments = new Map(); + + sellerA = { + id: "seller-a-id", + stellarAddress: "GSELLERA123", + email: "sellerA@test.com", + userType: UserType.SELLER, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as User; + + sellerB = { + id: "seller-b-id", + stellarAddress: "GSELLERB123", + email: "sellerB@test.com", + userType: UserType.SELLER, + kycStatus: KYCStatus.APPROVED, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as User; + + invoiceA = { + id: "invoice-a-id", + sellerId: sellerA.id, + invoiceNumber: "INV-A-ANALYTICS-001", + customerName: "Customer A", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + status: InvoiceStatus.PUBLISHED, + ipfsHash: null, + riskScore: null, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Invoice; + + invoices.set(invoiceA.id, invoiceA); + + const investmentA = { + id: "investment-a-id", + invoiceId: invoiceA.id, + investorId: sellerB.id, + investmentAmount: "100.0000", + expectedReturn: "105.0000", + status: "pending" as any, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Investment; + + investments.set(investmentA.id, investmentA); + + const invoiceRepository = createInvoiceRepository(invoices); + const fakeDataSource = createFakeDataSource(investments); + + invoiceService = new InvoiceService({ + invoiceRepository, + ipfsService: mockIPFSService, + dataSource: fakeDataSource, + }); + }); + + it("returns 403 with error code forbidden when seller B requests analytics for seller A's invoice", async () => { + await expect( + invoiceService.getInvoiceAnalytics(invoiceA.id, sellerB.id), + ).rejects.toMatchObject({ + code: "forbidden", + statusCode: 403, + }); + }); + + it("returns 200 with analytics data when seller A requests analytics for their own invoice", async () => { + const result = await invoiceService.getInvoiceAnalytics(invoiceA.id, sellerA.id); + + expect(result).toBeDefined(); + expect(result.invoiceId).toBe(invoiceA.id); + expect(result.views).toBe(0); + expect(result.clickThroughs).toBe(0); + expect(result.investorInterest).toBe(1); + }); + + it("returns 404 for non-existent invoice", async () => { + await expect( + invoiceService.getInvoiceAnalytics("non-existent-id", sellerA.id), + ).rejects.toMatchObject({ + code: "invoice_not_found", + statusCode: 404, + }); + }); + + it("returns investorInterest of 0 when invoice has no investments", async () => { + investments.clear(); + + const result = await invoiceService.getInvoiceAnalytics(invoiceA.id, sellerA.id); + + expect(result.investorInterest).toBe(0); + }); +});