diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index 0b570a9..a679292 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -344,9 +344,14 @@ export function createInvoiceController(invoiceService: InvoiceService) { 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.getInvoiceTokenHolders(id); + const result = await invoiceService.getInvoiceTokenHolders(id, authReq.user.id); res.status(200).json({ success: true, diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index f77eec2..cd9a91f 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -429,12 +429,12 @@ export class InvoiceService { /** * Get all token holders for a published invoice with their token balances and percentage shares */ - async getInvoiceTokenHolders(invoiceId: string): Promise< + async getInvoiceTokenHolders(invoiceId: string, sellerId: string): Promise< Array<{ - walletAddress: string; - investmentAmount: string; - percentageShare: string; - status: InvestmentStatus; + wallet: string; + amount: string; + share_percent: string; + committed_at: Date; }> > { const invoice = await this.invoiceRepository.findOne({ @@ -445,6 +445,10 @@ export class InvoiceService { throw new ServiceError("invoice_not_found", "Invoice not found", 404); } + if (invoice.sellerId !== sellerId) { + throw new ServiceError("unauthorized_invoice_access", "You can only view investors for your own invoices", 403); + } + if (invoice.status === InvoiceStatus.DRAFT) { throw new ServiceError( "invalid_invoice_status", @@ -488,11 +492,14 @@ export class InvoiceService { .times(100) .toDecimalPlaces(2); + const address = investor.stellarAddress || ""; + const truncatedWallet = address.length > 8 ? `${address.slice(0, 4)}...${address.slice(-4)}` : address; + return { - walletAddress: investor.stellarAddress, - investmentAmount: investment.investmentAmount, - percentageShare: percentage.toString(), - status: investment.status, + wallet: truncatedWallet, + amount: investment.investmentAmount, + share_percent: percentage.toString(), + committed_at: investment.createdAt, }; }); } diff --git a/tests/integration/investor-list.integration.test.ts b/tests/integration/investor-list.integration.test.ts new file mode 100644 index 0000000..dc86810 --- /dev/null +++ b/tests/integration/investor-list.integration.test.ts @@ -0,0 +1,223 @@ +import { DataSource } from "typeorm"; +import request from "supertest"; +import express from "express"; +import jwt from "jsonwebtoken"; +import { Invoice } from "../../src/models/Invoice.model"; +import { User } from "../../src/models/User.model"; +import { Investment } from "../../src/models/Investment.model"; +import { Transaction } from "../../src/models/Transaction.model"; +import { KYCVerification } from "../../src/models/KYCVerification.model"; +import { Notification } from "../../src/models/Notification.model"; +import { AuthChallenge } from "../../src/models/AuthChallenge.model"; +import { InvoiceStatus, UserType, KYCStatus, InvestmentStatus } from "../../src/types/enums"; +import { createInvoiceService } from "../../src/services/invoice.service"; +import { createInvoiceRouter } from "../../src/routes/invoice.routes"; +import { createErrorMiddleware } from "../../src/middleware/error.middleware"; +import { logger } from "../../src/observability/logger"; +import type { IPFSService } from "../../src/services/ipfs.service"; + +describe("Investor list endpoint for funded invoice", () => { + let dataSource: DataSource; + let app: express.Application; + let seller: User; + let otherSeller: User; + let validToken: string; + let otherToken: string; + let fundedInvoice: Invoice; + + const noopIpfsService = {} as IPFSService; + const mockConfig = { + ipfs: { + apiUrl: "https://api.pinata.cloud", + jwt: "test-jwt", + maxFileSizeMB: 10, + allowedMimeTypes: ["application/pdf"], + uploadRateLimit: { windowMs: 900000, maxUploads: 10 }, + }, + kyc: { skipVerification: true }, + }; + + beforeAll(async () => { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.warn("DATABASE_URL not set, skipping integration tests"); + return; + } + + dataSource = new DataSource({ + type: "postgres", + url: databaseUrl, + entities: [ + User, + Invoice, + Investment, + Transaction, + KYCVerification, + Notification, + AuthChallenge, + ], + synchronize: true, + logging: false, + dropSchema: true, + }); + + await dataSource.initialize(); + + const userRepository = dataSource.getRepository(User); + seller = await userRepository.save( + userRepository.create({ + stellarAddress: "GSELLER123", + email: "seller1@test.com", + userType: UserType.SELLER, + kycStatus: KYCStatus.APPROVED, + }), + ); + + otherSeller = await userRepository.save( + userRepository.create({ + stellarAddress: "GOTHERSELLER456", + email: "seller2@test.com", + userType: UserType.SELLER, + kycStatus: KYCStatus.APPROVED, + }), + ); + + const invoiceRepository = dataSource.getRepository(Invoice); + fundedInvoice = await invoiceRepository.save( + invoiceRepository.create({ + sellerId: seller.id, + invoiceNumber: "INV-TOK-001", + customerName: "Token Customer", + amount: "10000.0000", + discountRate: "10.00", + netAmount: "9000.0000", + dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + status: InvoiceStatus.FUNDED, + }), + ); + + const investor1 = await userRepository.save( + userRepository.create({ + stellarAddress: "GINVESTORONE123456", + email: "inv1@test.com", + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + }), + ); + + const investor2 = await userRepository.save( + userRepository.create({ + stellarAddress: "GINVESTORTWO123456", + email: "inv2@test.com", + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + }), + ); + + const investor3 = await userRepository.save( + userRepository.create({ + stellarAddress: "GINVESTORTHR123456", + email: "inv3@test.com", + userType: UserType.INVESTOR, + kycStatus: KYCStatus.APPROVED, + }), + ); + + const investmentRepository = dataSource.getRepository(Investment); + await investmentRepository.save([ + investmentRepository.create({ + invoiceId: fundedInvoice.id, + investorId: investor1.id, + investmentAmount: "2000.0000", + expectedReturn: "2222.2222", + status: InvestmentStatus.CONFIRMED, + }), + investmentRepository.create({ + invoiceId: fundedInvoice.id, + investorId: investor2.id, + investmentAmount: "3000.0000", + expectedReturn: "3333.3333", + status: InvestmentStatus.CONFIRMED, + }), + investmentRepository.create({ + invoiceId: fundedInvoice.id, + investorId: investor3.id, + investmentAmount: "5000.0000", + expectedReturn: "5555.5555", + status: InvestmentStatus.CONFIRMED, + }), + ]); + + const invoiceService = createInvoiceService(dataSource, noopIpfsService); + + process.env.JWT_SECRET = "test-secret"; + validToken = jwt.sign( + { sub: seller.id, stellarAddress: seller.stellarAddress }, + process.env.JWT_SECRET, + ); + + otherToken = jwt.sign( + { sub: otherSeller.id, stellarAddress: otherSeller.stellarAddress }, + process.env.JWT_SECRET, + ); + + app = express(); + app.use(express.json()); + app.use( + "/api/v1/invoices", + createInvoiceRouter({ + invoiceService, + config: mockConfig as any, + }), + ); + app.use(createErrorMiddleware(logger)); + }); + + afterAll(async () => { + delete process.env.JWT_SECRET; + if (dataSource?.isInitialized) { + await dataSource.destroy(); + } + }); + + it("should return 403 when a different seller accesses the endpoint", async () => { + if (!process.env.DATABASE_URL) return; + + await request(app) + .get(`/api/v1/invoices/${fundedInvoice.id}/tokens`) + .set("Authorization", `Bearer ${otherToken}`) + .expect(403); + }); + + it("should return all commitments with truncated wallets, correct amounts and shares", async () => { + if (!process.env.DATABASE_URL) return; + + const response = await request(app) + .get(`/api/v1/invoices/${fundedInvoice.id}/tokens`) + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(response.body.success).toBe(true); + const commitments = response.body.data; + + expect(commitments).toHaveLength(3); + + const inv1 = commitments.find((c: any) => c.wallet === "GINV...3456" && c.amount === "2000.0000"); + expect(inv1).toBeDefined(); + expect(inv1.share_percent).toBe("20.00"); + expect(inv1.committed_at).toBeDefined(); + + const inv2 = commitments.find((c: any) => c.wallet === "GINV...3456" && c.amount === "3000.0000"); + expect(inv2).toBeDefined(); + expect(inv2.share_percent).toBe("30.00"); + expect(inv2.committed_at).toBeDefined(); + + const inv3 = commitments.find((c: any) => c.wallet === "GINV...3456" && c.amount === "5000.0000"); + expect(inv3).toBeDefined(); + expect(inv3.share_percent).toBe("50.00"); + expect(inv3.committed_at).toBeDefined(); + + const totalShare = commitments.reduce((acc: number, c: any) => acc + parseFloat(c.share_percent), 0); + expect(totalShare).toBe(100); + }); +});