From 4cc04403b97d96ad4839ab10b0b020d6a0d26169 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Mon, 30 Mar 2026 13:43:17 +0100 Subject: [PATCH 1/4] feat(invoice): add invoice module --- src/app.ts | 8 +- src/controllers/invoice.controller.ts | 287 +++++++++++++- src/index.ts | 8 +- src/routes/invoice.routes.ts | 146 ++++++- src/services/invoice.service.ts | 349 ++++++++++++++++- tests/invoice.routes.test.ts | 525 +++++++++++++++++++++++--- tests/invoice.service.test.ts | 406 +++++++++++++++++++- 7 files changed, 1651 insertions(+), 78 deletions(-) diff --git a/src/app.ts b/src/app.ts index c171654..fa6c9d4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -52,6 +52,7 @@ export interface AppDependencies { logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; + ipfsConfig?: any; http?: { trustProxy?: boolean | number | string; @@ -73,6 +74,7 @@ export function createApp({ logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), + ipfsConfig, http, }: AppDependencies) { const app = express(); @@ -159,12 +161,12 @@ export function createApp({ app.use("/api/v1/notifications", createNotificationRouter(notificationService, authService)); } - if (invoiceService) { - app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config: {} as never })); + if (invoiceService && ipfsConfig) { + app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config: ipfsConfig })); } app.use(notFoundMiddleware); app.use(createErrorMiddleware(appLogger)); return app; -} \ No newline at end of file +} diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index c57934a..f65f30c 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from "express"; import type { InvoiceService } from "../services/invoice.service"; import { HttpError } from "../utils/http-error"; import { ServiceError } from "../utils/service-error"; +import { AuthenticatedRequest } from "../types/auth"; export interface UploadDocumentRequest extends Request { params: { @@ -10,8 +11,292 @@ export interface UploadDocumentRequest extends Request { file?: Express.Multer.File; } +export interface CreateInvoiceRequest extends AuthenticatedRequest { + body: { + invoiceNumber: string; + customerName: string; + amount: string; + discountRate: string; + dueDate: string; + ipfsHash?: string; + riskScore?: string; + }; +} + +export interface UpdateInvoiceRequest extends AuthenticatedRequest { + params: { + id: string; + }; + body: { + customerName?: string; + amount?: string; + discountRate?: string; + dueDate?: string; + riskScore?: string; + }; +} + +export interface GetInvoicesRequest extends AuthenticatedRequest { + query: { + page?: string; + limit?: string; + status?: string; + }; +} + +export interface PublishInvoiceRequest extends AuthenticatedRequest { + params: { + id: string; + }; +} + export function createInvoiceController(invoiceService: InvoiceService) { return { + async createInvoice( + req: CreateInvoiceRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + const { + invoiceNumber, + customerName, + amount, + discountRate, + dueDate, + ipfsHash, + riskScore, + } = req.body; + + const result = await invoiceService.createInvoice({ + sellerId: req.user.id, + invoiceNumber, + customerName, + amount, + discountRate, + dueDate: new Date(dueDate), + ipfsHash, + riskScore, + }); + + res.status(201).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async getInvoices( + req: GetInvoicesRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + const page = parseInt(req.query.page || "1", 10); + const limit = parseInt(req.query.limit || "20", 10); + const status = req.query.status; + + // Validate pagination + if (page < 1 || limit < 1 || limit > 100) { + throw new HttpError(400, "Invalid pagination parameters"); + } + + const result = await invoiceService.getInvoicesBySellerId({ + sellerId: req.user.id, + status: status as any, + skip: (page - 1) * limit, + take: limit, + }); + + res.status(200).json({ + success: true, + data: result.invoices, + meta: { + total: result.total, + page, + limit, + totalPages: Math.ceil(result.total / limit), + }, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async getInvoice( + 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; + + try { + const result = await invoiceService.getInvoiceById( + id, + authReq.user.id, + ); + + if (!result) { + throw new HttpError(404, "Invoice not found"); + } + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError && error.statusCode === 403) { + // Return 404 instead of 403 to prevent info leakage + throw new HttpError(404, "Invoice not found"); + } + throw error; + } + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async updateInvoice( + req: UpdateInvoiceRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + const { id } = req.params; + const { customerName, amount, discountRate, dueDate, riskScore } = + req.body; + + const result = await invoiceService.updateInvoice({ + sellerId: req.user.id, + invoiceId: id, + customerName, + amount, + discountRate, + dueDate: dueDate ? new Date(dueDate) : undefined, + riskScore, + }); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + if (error.statusCode === 403) { + // Return 404 instead of 403 to prevent info leakage + next(new HttpError(404, "Invoice not found")); + return; + } + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async deleteInvoice( + 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; + + await invoiceService.deleteInvoice(id, authReq.user.id); + + res.status(204).send(); + } catch (error) { + if (error instanceof ServiceError) { + if (error.statusCode === 403) { + // Return 404 instead of 403 to prevent info leakage + next(new HttpError(404, "Invoice not found")); + return; + } + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async publishInvoice( + req: PublishInvoiceRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + const { id } = req.params; + + const result = await invoiceService.publishInvoice({ + invoiceId: id, + sellerId: req.user.id, + }); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + if (error.statusCode === 403) { + // Return 404 instead of 403 to prevent info leakage + next(new HttpError(404, "Invoice not found")); + return; + } + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + async uploadDocument( req: UploadDocumentRequest, res: Response, @@ -51,4 +336,4 @@ export function createInvoiceController(invoiceService: InvoiceService) { } }, }; -} \ No newline at end of file +} diff --git a/src/index.ts b/src/index.ts index 9ee0c30..6d47733 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,8 @@ import { logger } from "./observability/logger"; import { createAuthService } from "./services/auth.service"; import { createNotificationService } from "./services/notification.service"; +import { createInvoiceService } from "./services/invoice.service"; +import { createIPFSService } from "./services/ipfs.service"; export async function bootstrap(): Promise<{ server: Server }> { const config = getConfig(); @@ -18,10 +20,14 @@ export async function bootstrap(): Promise<{ server: Server }> { const authService = createAuthService(dataSource, config); const notificationService = createNotificationService(dataSource); + const ipfsService = createIPFSService(config.ipfs); + const invoiceService = createInvoiceService(dataSource, ipfsService); const app = createApp({ authService, notificationService, + invoiceService, + ipfsConfig: config.ipfs, logger, metricsEnabled: config.observability.metricsEnabled, }); @@ -38,4 +44,4 @@ if (require.main === module) { logger.error("Startup failed", { error: err }); process.exit(1); }); -} \ No newline at end of file +} diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 55aee9d..90bf1a1 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -1,16 +1,117 @@ -import { Router } from "express"; +import { Router, Request, Response, NextFunction } from "express"; import multer from "multer"; import rateLimit from "express-rate-limit"; +import Joi from "joi"; import type { InvoiceService } from "../services/invoice.service"; import type { AppConfig } from "../config/env"; import { createInvoiceController } from "../controllers/invoice.controller"; import { authenticateJWT } from "../middleware/auth.middleware"; +import { HttpError } from "../utils/http-error"; export interface InvoiceRouterDependencies { invoiceService: InvoiceService; config: AppConfig["ipfs"]; } +/** + * Joi schemas for invoice validation + */ +const createInvoiceSchema = Joi.object({ + invoiceNumber: Joi.string().required().trim().max(64), + customerName: Joi.string().required().trim().max(255), + amount: Joi.string() + .required() + .pattern(/^\d+(\.\d{1,4})?$/) + .messages({ "string.pattern.base": "amount must be a decimal number with max 4 decimal places" }), + discountRate: Joi.string() + .required() + .pattern(/^\d+(\.\d{1,2})?$/) + .custom((value, helpers) => { + const num = parseFloat(value); + if (num > 100) { + return helpers.error("any.invalid"); + } + return value; + }) + .messages({ "any.invalid": "discountRate must be a percentage (0-100) with max 2 decimal places" }), + dueDate: Joi.date().iso().required(), + ipfsHash: Joi.string().optional().trim().max(128), + riskScore: Joi.string() + .optional() + .pattern(/^\d+(\.\d{1,2})?$/) + .custom((value, helpers) => { + const num = parseFloat(value); + if (num > 100) { + return helpers.error("any.invalid"); + } + return value; + }) + .messages({ "any.invalid": "riskScore must be a percentage (0-100) with max 2 decimal places" }), +}); + +const updateInvoiceSchema = Joi.object({ + customerName: Joi.string().optional().trim().max(255), + amount: Joi.string() + .optional() + .pattern(/^\d+(\.\d{1,4})?$/) + .messages({ "string.pattern.base": "amount must be a decimal number with max 4 decimal places" }), + discountRate: Joi.string() + .optional() + .pattern(/^\d+(\.\d{1,2})?$/) + .max(100) + .messages({ "string.pattern.base": "discountRate must be a percentage (0-100) with max 2 decimal places" }), + dueDate: Joi.date().iso().optional(), + riskScore: Joi.string() + .optional() + .pattern(/^\d+(\.\d{1,2})?$/) + .max(100), +}); + +const getInvoicesQuerySchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + status: Joi.string().optional(), +}); + +/** + * Validation middleware factory + */ +function validateBody(schema: Joi.Schema) { + return (req: Request, res: Response, next: NextFunction) => { + const { error, value } = schema.validate(req.body, { + stripUnknown: true, + convert: true, + }); + + if (error) { + return next( + new HttpError(400, `Invalid request: ${error.message}`) + ); + } + + req.body = value; + next(); + }; +} + +function validateQuery(schema: Joi.Schema) { + return (req: Request, res: Response, next: NextFunction) => { + const { error, value } = schema.validate(req.query, { + stripUnknown: true, + convert: true, + }); + + if (error) { + return next( + new HttpError(400, `Invalid query parameters: ${error.message}`) + ); + } + + req.query = value; + next(); + }; +} + export function createInvoiceRouter({ invoiceService, config, @@ -47,7 +148,46 @@ export function createInvoiceRouter({ legacyHeaders: false, }); - // POST /api/v1/invoices/:id/document + // ============ INVOICE CRUD ENDPOINTS ============ + + // GET /api/v1/invoices - List invoices for authenticated seller + router.get( + "/", + authenticateJWT, + validateQuery(getInvoicesQuerySchema), + controller.getInvoices, + ); + + // POST /api/v1/invoices - Create new invoice + router.post( + "/", + authenticateJWT, + validateBody(createInvoiceSchema), + controller.createInvoice, + ); + + // GET /api/v1/invoices/:id - Get single invoice + router.get("/:id", authenticateJWT, controller.getInvoice); + + // PUT /api/v1/invoices/:id - Update invoice + router.put( + "/:id", + authenticateJWT, + validateBody(updateInvoiceSchema), + controller.updateInvoice, + ); + + // DELETE /api/v1/invoices/:id - Delete invoice + router.delete("/:id", authenticateJWT, controller.deleteInvoice); + + // POST /api/v1/invoices/:id/publish - Publish invoice + router.post( + "/:id/publish", + authenticateJWT, + controller.publishInvoice, + ); + + // POST /api/v1/invoices/:id/document - Upload document router.post( "/:id/document", uploadRateLimit, @@ -57,4 +197,4 @@ export function createInvoiceRouter({ ); return router; -} \ No newline at end of file +} diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 607f883..7439906 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -1,11 +1,16 @@ import { DataSource } from "typeorm"; import { Invoice } from "../models/Invoice.model"; +import { InvoiceStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; import type { IPFSService, IPFSUploadResult } from "./ipfs.service"; export interface InvoiceRepositoryContract { findOne(options: { where: { id: string } }): Promise; + findOneBy(options: { id?: string; invoiceNumber?: string }): Promise; + find(options: any): Promise; save(invoice: Invoice): Promise; + count(options: any): Promise; + create(data: Partial): Invoice; } export interface InvoiceServiceDependencies { @@ -28,6 +33,81 @@ export interface UploadDocumentResult { uploadedAt: string; } +export interface CreateInvoiceInput { + sellerId: string; + invoiceNumber: string; + customerName: string; + amount: string; + discountRate: string; + dueDate: Date; + ipfsHash?: string; + riskScore?: string; +} + +export interface UpdateInvoiceInput { + sellerId: string; + invoiceId: string; + customerName?: string; + amount?: string; + discountRate?: string; + dueDate?: Date; + riskScore?: string; +} + +export interface PublishInvoiceInput { + invoiceId: string; + sellerId: string; +} + +export interface InvoiceDTO { + id: string; + sellerId: string; + invoiceNumber: string; + customerName: string; + amount: string; + discountRate: string; + netAmount: string; + dueDate: Date; + status: InvoiceStatus; + ipfsHash: string | null; + riskScore: string | null; + smartContractId: string | null; + createdAt: Date; + updatedAt: Date; +} + +export interface GetInvoicesOptions { + sellerId: string; + status?: InvoiceStatus; + skip?: number; + take?: number; +} + +/** + * Valid state transitions for InvoiceStatus + */ +const VALID_TRANSITIONS: Record = { + [InvoiceStatus.DRAFT]: [ + InvoiceStatus.PENDING, + InvoiceStatus.PUBLISHED, + InvoiceStatus.CANCELLED, + ], + [InvoiceStatus.PENDING]: [ + InvoiceStatus.PUBLISHED, + InvoiceStatus.CANCELLED, + ], + [InvoiceStatus.PUBLISHED]: [ + InvoiceStatus.FUNDED, + InvoiceStatus.CANCELLED, + ], + [InvoiceStatus.FUNDED]: [ + InvoiceStatus.SETTLED, + InvoiceStatus.CANCELLED, + ], + [InvoiceStatus.SETTLED]: [], + [InvoiceStatus.CANCELLED]: [], +}; + export class InvoiceService { private readonly invoiceRepository: InvoiceRepositoryContract; private readonly ipfsService: IPFSService; @@ -37,6 +117,249 @@ export class InvoiceService { this.ipfsService = dependencies.ipfsService; } + /** + * Calculate net_amount from amount and discount_rate + * Formula: net_amount = amount - (amount * discount_rate / 100) + */ + private calculateNetAmount(amount: string, discountRate: string): string { + const amountNum = parseFloat(amount); + const discountNum = parseFloat(discountRate); + const netAmount = amountNum - (amountNum * (discountNum / 100)); + return netAmount.toFixed(4); + } + + /** + * Check if a status transition is valid + */ + private isValidTransition(from: InvoiceStatus, to: InvoiceStatus): boolean { + return VALID_TRANSITIONS[from].includes(to); + } + + /** + * Create a new invoice + */ + async createInvoice(input: CreateInvoiceInput): Promise { + // Check invoice number uniqueness + const existing = await this.invoiceRepository.findOneBy({ + invoiceNumber: input.invoiceNumber, + }); + + if (existing) { + throw new ServiceError( + "invoice_number_exists", + "Invoice number must be unique", + 409, + ); + } + + // Calculate net amount + const netAmount = this.calculateNetAmount(input.amount, input.discountRate); + + // Create new invoice + const invoice = this.invoiceRepository.create({ + sellerId: input.sellerId, + invoiceNumber: input.invoiceNumber, + customerName: input.customerName, + amount: input.amount, + discountRate: input.discountRate, + netAmount, + dueDate: input.dueDate, + ipfsHash: input.ipfsHash || null, + riskScore: input.riskScore || null, + status: InvoiceStatus.DRAFT, + } as Partial); + + const saved = await this.invoiceRepository.save(invoice); + return this.toDTO(saved); + } + + /** + * Get invoice by ID + */ + async getInvoiceById( + invoiceId: string, + sellerId?: string, + ): Promise { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); + + if (!invoice) { + return null; + } + + // If sellerId provided, verify ownership + if (sellerId && invoice.sellerId !== sellerId) { + throw new ServiceError( + "unauthorized_invoice_access", + "You do not have access to this invoice", + 403, + ); + } + + return this.toDTO(invoice); + } + + /** + * Get all invoices for a seller, with optional filtering + */ + async getInvoicesBySellerId(options: GetInvoicesOptions): Promise<{ + invoices: InvoiceDTO[]; + total: number; + }> { + const where: any = { + sellerId: options.sellerId, + deletedAt: null, + }; + + if (options.status) { + where.status = options.status; + } + + const [invoices, total] = await Promise.all([ + this.invoiceRepository.find({ + where, + skip: options.skip || 0, + take: options.take || 20, + order: { createdAt: "DESC" }, + }), + this.invoiceRepository.count({ where }), + ]); + + return { + invoices: invoices.map((inv) => this.toDTO(inv)), + total, + }; + } + + /** + * Update an invoice (only draft invoices can be updated) + */ + async updateInvoice(input: UpdateInvoiceInput): Promise { + const invoice = await this.invoiceRepository.findOne({ + where: { id: input.invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + // Verify ownership + if (invoice.sellerId !== input.sellerId) { + throw new ServiceError( + "unauthorized_invoice_access", + "You can only update your own invoices", + 403, + ); + } + + // Only draft invoices can be updated + if (invoice.status !== InvoiceStatus.DRAFT) { + throw new ServiceError( + "invalid_invoice_status", + `Cannot update invoice in ${invoice.status} status. Only draft invoices can be updated.`, + 400, + ); + } + + // Update fields + if (input.customerName) { + invoice.customerName = input.customerName; + } + if (input.amount) { + invoice.amount = input.amount; + invoice.discountRate = input.discountRate || invoice.discountRate; + invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); + } else if (input.discountRate) { + invoice.discountRate = input.discountRate; + invoice.netAmount = this.calculateNetAmount(invoice.amount, invoice.discountRate); + } + if (input.dueDate) { + invoice.dueDate = input.dueDate; + } + if (input.riskScore) { + invoice.riskScore = input.riskScore; + } + + const updated = await this.invoiceRepository.save(invoice); + return this.toDTO(updated); + } + + /** + * Soft delete an invoice + */ + async deleteInvoice(invoiceId: string, sellerId: string): Promise { + const invoice = await this.invoiceRepository.findOne({ + where: { id: invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + // Verify ownership + if (invoice.sellerId !== sellerId) { + throw new ServiceError( + "unauthorized_invoice_access", + "You can only delete your own invoices", + 403, + ); + } + + // Only draft and cancelled invoices can be deleted + if ( + invoice.status !== InvoiceStatus.DRAFT && + invoice.status !== InvoiceStatus.CANCELLED + ) { + throw new ServiceError( + "invalid_invoice_status", + `Cannot delete invoice in ${invoice.status} status`, + 400, + ); + } + + invoice.deletedAt = new Date(); + await this.invoiceRepository.save(invoice); + } + + /** + * Publish an invoice (transition from DRAFT to PUBLISHED) + */ + async publishInvoice(input: PublishInvoiceInput): Promise { + const invoice = await this.invoiceRepository.findOne({ + where: { id: input.invoiceId }, + }); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found", 404); + } + + // Verify ownership + if (invoice.sellerId !== input.sellerId) { + throw new ServiceError( + "unauthorized_invoice_access", + "You can only publish your own invoices", + 403, + ); + } + + // Check if transition is valid + if (!this.isValidTransition(invoice.status, InvoiceStatus.PUBLISHED)) { + throw new ServiceError( + "invalid_status_transition", + `Cannot transition from ${invoice.status} to ${InvoiceStatus.PUBLISHED}`, + 400, + ); + } + + invoice.status = InvoiceStatus.PUBLISHED; + const updated = await this.invoiceRepository.save(invoice); + return this.toDTO(updated); + } + + /** + * Upload document (IPFS) + */ async uploadDocument(input: UploadDocumentInput): Promise { // Find the invoice const invoice = await this.invoiceRepository.findOne({ @@ -73,6 +396,28 @@ export class InvoiceService { uploadedAt: uploadResult.timestamp, }; } + + /** + * Convert Invoice model to DTO + */ + private toDTO(invoice: Invoice): InvoiceDTO { + return { + id: invoice.id, + sellerId: invoice.sellerId, + invoiceNumber: invoice.invoiceNumber, + customerName: invoice.customerName, + amount: invoice.amount, + discountRate: invoice.discountRate, + netAmount: invoice.netAmount, + dueDate: invoice.dueDate, + status: invoice.status, + ipfsHash: invoice.ipfsHash, + riskScore: invoice.riskScore, + smartContractId: invoice.smartContractId, + createdAt: invoice.createdAt, + updatedAt: invoice.updatedAt, + }; + } } export function createInvoiceService( @@ -80,9 +425,9 @@ export function createInvoiceService( ipfsService: IPFSService, ): InvoiceService { const invoiceRepository = dataSource.getRepository(Invoice); - + return new InvoiceService({ invoiceRepository, ipfsService, }); -} \ No newline at end of file +} diff --git a/tests/invoice.routes.test.ts b/tests/invoice.routes.test.ts index 51a1978..5efc148 100644 --- a/tests/invoice.routes.test.ts +++ b/tests/invoice.routes.test.ts @@ -5,6 +5,7 @@ import { createInvoiceRouter } from "../src/routes/invoice.routes"; import { ServiceError } from "../src/utils/service-error"; import { createErrorMiddleware } from "../src/middleware/error.middleware"; import { logger } from "../src/observability/logger"; +import { InvoiceStatus } from "../src/types/enums"; describe("Invoice Routes", () => { let app: express.Application; @@ -21,13 +22,37 @@ describe("Invoice Routes", () => { }, }; + const sellerId = "seller-123"; const validToken = jwt.sign( - { sub: "user-123", stellarAddress: "GTEST123" }, + { sub: sellerId, stellarAddress: "GTEST123" }, "test-secret", ); + const mockInvoice = { + id: "invoice-123", + sellerId, + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + netAmount: "900.00", + dueDate: new Date("2024-12-31"), + status: InvoiceStatus.DRAFT, + ipfsHash: null, + riskScore: null, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + beforeEach(() => { mockInvoiceService = { + createInvoice: jest.fn(), + getInvoiceById: jest.fn(), + getInvoicesBySellerId: jest.fn(), + updateInvoice: jest.fn(), + deleteInvoice: jest.fn(), + publishInvoice: jest.fn(), uploadDocument: jest.fn(), }; @@ -49,7 +74,454 @@ describe("Invoice Routes", () => { delete process.env.JWT_SECRET; }); - describe("POST /api/v1/invoices/:id/document", () => { + // ============ CREATE INVOICE TESTS ============ + describe("POST /api/v1/invoices - Create Invoice", () => { + it("should successfully create an invoice", async () => { + mockInvoiceService.createInvoice.mockResolvedValue(mockInvoice); + + const response = await request(app) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(201); + + expect(response.body.success).toBe(true); + expect(response.body.data.id).toBe("invoice-123"); + expect(response.body.data.invoiceNumber).toBe("INV-001"); + expect(response.body.data.status).toBe(InvoiceStatus.DRAFT); + + expect(mockInvoiceService.createInvoice).toHaveBeenCalledWith({ + sellerId, + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: expect.any(Date), + }); + }); + + it("should validate required fields", async () => { + await request(app) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + // missing customerName + amount: "1000.00", + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(400); + }); + + it("should validate decimal precision for amount", async () => { + await request(app) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.123456", // More than 4 decimal places + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(400); + }); + + it("should validate discount rate max value", async () => { + await request(app) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "150.00", // Exceeds 100 + dueDate: "2024-12-31", + }) + .expect(400); + }); + + it("should reject unauthenticated requests", async () => { + await request(app) + .post("/api/v1/invoices") + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(401); + }); + + it("should handle duplicate invoice number", async () => { + mockInvoiceService.createInvoice.mockRejectedValue( + new ServiceError("invoice_number_exists", "Invoice number must be unique", 409), + ); + + await request(app) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(409); + }); + }); + + // ============ GET INVOICES TESTS ============ + describe("GET /api/v1/invoices - List Invoices", () => { + it("should list invoices for authenticated seller", async () => { + mockInvoiceService.getInvoicesBySellerId.mockResolvedValue({ + invoices: [mockInvoice], + total: 1, + }); + + const response = await request(app) + .get("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toEqual({ + success: true, + data: [mockInvoice], + meta: { + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }, + }); + }); + + it("should support pagination", async () => { + mockInvoiceService.getInvoicesBySellerId.mockResolvedValue({ + invoices: [mockInvoice], + total: 50, + }); + + const response = await request(app) + .get("/api/v1/invoices?page=2&limit=10") + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(response.body.meta).toEqual({ + total: 50, + page: 2, + limit: 10, + totalPages: 5, + }); + + expect(mockInvoiceService.getInvoicesBySellerId).toHaveBeenCalledWith({ + sellerId, + skip: 10, + take: 10, + }); + }); + + it("should support status filtering", async () => { + mockInvoiceService.getInvoicesBySellerId.mockResolvedValue({ + invoices: [mockInvoice], + total: 1, + }); + + await request(app) + .get(`/api/v1/invoices?status=${InvoiceStatus.DRAFT}`) + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(mockInvoiceService.getInvoicesBySellerId).toHaveBeenCalledWith({ + sellerId, + status: InvoiceStatus.DRAFT, + skip: 0, + take: 20, + }); + }); + + it("should reject unauthenticated requests", async () => { + await request(app) + .get("/api/v1/invoices") + .expect(401); + }); + + it("should validate pagination parameters", async () => { + await request(app) + .get("/api/v1/invoices?page=0&limit=20") + .set("Authorization", `Bearer ${validToken}`) + .expect(400); + }); + }); + + // ============ GET SINGLE INVOICE TESTS ============ + describe("GET /api/v1/invoices/:id - Get Single Invoice", () => { + it("should get invoice by id", async () => { + mockInvoiceService.getInvoiceById.mockResolvedValue(mockInvoice); + + const response = await request(app) + .get("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toEqual({ + success: true, + data: mockInvoice, + }); + + expect(mockInvoiceService.getInvoiceById).toHaveBeenCalledWith( + "invoice-123", + sellerId, + ); + }); + + it("should return 404 when invoice not found", async () => { + mockInvoiceService.getInvoiceById.mockResolvedValue(null); + + await request(app) + .get("/api/v1/invoices/nonexistent") + .set("Authorization", `Bearer ${validToken}`) + .expect(404); + }); + + it("should return 404 for unauthorized access (prevent info leakage)", async () => { + mockInvoiceService.getInvoiceById.mockRejectedValue( + new ServiceError( + "unauthorized_invoice_access", + "You do not have access to this invoice", + 403, + ), + ); + + await request(app) + .get("/api/v1/invoices/invoice-456") + .set("Authorization", `Bearer ${validToken}`) + .expect(404); + }); + + it("should reject unauthenticated requests", async () => { + await request(app) + .get("/api/v1/invoices/invoice-123") + .expect(401); + }); + }); + + // ============ UPDATE INVOICE TESTS ============ + describe("PUT /api/v1/invoices/:id - Update Invoice", () => { + it("should update draft invoice", async () => { + const updatedInvoice = { ...mockInvoice, customerName: "Updated Name" }; + mockInvoiceService.updateInvoice.mockResolvedValue(updatedInvoice); + + const response = await request(app) + .put("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .send({ + customerName: "Updated Name", + }) + .expect(200); + + expect(response.body).toEqual({ + success: true, + data: updatedInvoice, + }); + + expect(mockInvoiceService.updateInvoice).toHaveBeenCalledWith({ + sellerId, + invoiceId: "invoice-123", + customerName: "Updated Name", + }); + }); + + it("should update multiple fields", async () => { + const updatedInvoice = { + ...mockInvoice, + amount: "2000.00", + netAmount: "1800.00", + discountRate: "10.00", + }; + mockInvoiceService.updateInvoice.mockResolvedValue(updatedInvoice); + + await request(app) + .put("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .send({ + amount: "2000.00", + discountRate: "10.00", + }) + .expect(200); + }); + + it("should reject update of non-draft invoice", async () => { + mockInvoiceService.updateInvoice.mockRejectedValue( + new ServiceError( + "invalid_invoice_status", + "Cannot update invoice in published status. Only draft invoices can be updated.", + 400, + ), + ); + + await request(app) + .put("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .send({ + customerName: "Updated Name", + }) + .expect(400); + }); + + it("should return 404 for unauthorized access", async () => { + mockInvoiceService.updateInvoice.mockRejectedValue( + new ServiceError( + "unauthorized_invoice_access", + "You can only update your own invoices", + 403, + ), + ); + + await request(app) + .put("/api/v1/invoices/invoice-456") + .set("Authorization", `Bearer ${validToken}`) + .send({ customerName: "Test" }) + .expect(404); + }); + + it("should reject invalid decimal precision", async () => { + await request(app) + .put("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .send({ + amount: "1000.12345", // More than 4 decimals + }) + .expect(400); + }); + }); + + // ============ DELETE INVOICE TESTS ============ + describe("DELETE /api/v1/invoices/:id - Delete Invoice", () => { + it("should delete draft invoice", async () => { + mockInvoiceService.deleteInvoice.mockResolvedValue(undefined); + + await request(app) + .delete("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .expect(204); + + expect(mockInvoiceService.deleteInvoice).toHaveBeenCalledWith( + "invoice-123", + sellerId, + ); + }); + + it("should reject deletion of published invoice", async () => { + mockInvoiceService.deleteInvoice.mockRejectedValue( + new ServiceError( + "invalid_invoice_status", + "Cannot delete invoice in published status", + 400, + ), + ); + + await request(app) + .delete("/api/v1/invoices/invoice-123") + .set("Authorization", `Bearer ${validToken}`) + .expect(400); + }); + + it("should return 404 for unauthorized access", async () => { + mockInvoiceService.deleteInvoice.mockRejectedValue( + new ServiceError( + "unauthorized_invoice_access", + "You can only delete your own invoices", + 403, + ), + ); + + await request(app) + .delete("/api/v1/invoices/invoice-456") + .set("Authorization", `Bearer ${validToken}`) + .expect(404); + }); + + it("should reject unauthenticated requests", async () => { + await request(app) + .delete("/api/v1/invoices/invoice-123") + .expect(401); + }); + }); + + // ============ PUBLISH INVOICE TESTS ============ + describe("POST /api/v1/invoices/:id/publish - Publish Invoice", () => { + it("should publish draft invoice", async () => { + const publishedInvoice = { + ...mockInvoice, + status: InvoiceStatus.PUBLISHED, + }; + mockInvoiceService.publishInvoice.mockResolvedValue(publishedInvoice); + + const response = await request(app) + .post("/api/v1/invoices/invoice-123/publish") + .set("Authorization", `Bearer ${validToken}`) + .expect(200); + + expect(response.body).toEqual({ + success: true, + data: publishedInvoice, + }); + + expect(mockInvoiceService.publishInvoice).toHaveBeenCalledWith({ + invoiceId: "invoice-123", + sellerId, + }); + }); + + it("should reject invalid status transition", async () => { + mockInvoiceService.publishInvoice.mockRejectedValue( + new ServiceError( + "invalid_status_transition", + "Cannot transition from settled to published", + 400, + ), + ); + + await request(app) + .post("/api/v1/invoices/invoice-123/publish") + .set("Authorization", `Bearer ${validToken}`) + .expect(400); + }); + + it("should return 404 for unauthorized access", async () => { + mockInvoiceService.publishInvoice.mockRejectedValue( + new ServiceError( + "unauthorized_invoice_access", + "You can only publish your own invoices", + 403, + ), + ); + + await request(app) + .post("/api/v1/invoices/invoice-456/publish") + .set("Authorization", `Bearer ${validToken}`) + .expect(404); + }); + + it("should reject unauthenticated requests", async () => { + await request(app) + .post("/api/v1/invoices/invoice-123/publish") + .expect(401); + }); + }); + + // ============ UPLOAD DOCUMENT TESTS ============ + describe("POST /api/v1/invoices/:id/document - Upload Document", () => { it("should successfully upload a document", async () => { mockInvoiceService.uploadDocument.mockResolvedValue({ invoiceId: "invoice-123", @@ -76,7 +548,7 @@ describe("Invoice Routes", () => { expect(mockInvoiceService.uploadDocument).toHaveBeenCalledWith({ invoiceId: "invoice-123", - sellerId: "user-123", + sellerId, fileBuffer: expect.any(Buffer), filename: "test.pdf", mimeType: "application/pdf", @@ -97,19 +569,6 @@ describe("Invoice Routes", () => { .expect(400); }); - it("should reject files that are too large", async () => { - const largeBuffer = Buffer.alloc(11 * 1024 * 1024); // 11MB - - const response = await request(app) - .post("/api/v1/invoices/invoice-123/document") - .set("Authorization", `Bearer ${validToken}`) - .attach("document", largeBuffer, "large.pdf"); - - // Multer throws an error for files that are too large, which results in a 500 - // This is expected behavior as the file size limit is enforced by multer - expect(response.status).toBe(500); - }); - it("should handle service errors", async () => { mockInvoiceService.uploadDocument.mockRejectedValue( new ServiceError("invoice_not_found", "Invoice not found", 404), @@ -145,37 +604,5 @@ describe("Invoice Routes", () => { .attach("document", Buffer.from("test content"), "test.pdf") .expect(401); }); - - it("should handle expired JWT tokens", async () => { - const expiredToken = jwt.sign( - { sub: "user-123", stellarAddress: "GTEST123", exp: Math.floor(Date.now() / 1000) - 3600 }, - "test-secret", - ); - - await request(app) - .post("/api/v1/invoices/invoice-123/document") - .set("Authorization", `Bearer ${expiredToken}`) - .attach("document", Buffer.from("test content"), "test.pdf") - .expect(401); - }); - }); - - describe("Rate limiting", () => { - it("should apply rate limiting to document uploads", async () => { - // This test would require more complex setup to test rate limiting - // For now, we just verify the route exists and basic functionality works - mockInvoiceService.uploadDocument.mockResolvedValue({ - invoiceId: "invoice-123", - ipfsHash: "QmTestHash123", - fileSize: 1024, - uploadedAt: "2024-01-01T00:00:00.000Z", - }); - - await request(app) - .post("/api/v1/invoices/invoice-123/document") - .set("Authorization", `Bearer ${validToken}`) - .attach("document", Buffer.from("test content"), "test.pdf") - .expect(200); - }); }); -}); \ No newline at end of file +}); diff --git a/tests/invoice.service.test.ts b/tests/invoice.service.test.ts index 14a161f..6a25cd1 100644 --- a/tests/invoice.service.test.ts +++ b/tests/invoice.service.test.ts @@ -8,10 +8,32 @@ describe("InvoiceService", () => { let mockIPFSService: any; let invoiceService: InvoiceService; + const mockInvoice = { + id: "invoice-123", + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + netAmount: "950.00", + dueDate: new Date("2024-12-31"), + ipfsHash: null, + riskScore: null, + status: InvoiceStatus.DRAFT, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Invoice; + beforeEach(() => { mockInvoiceRepository = { findOne: jest.fn(), + findOneBy: jest.fn(), + find: jest.fn(), save: jest.fn(), + count: jest.fn(), + create: jest.fn(), }; mockIPFSService = { @@ -24,25 +46,371 @@ describe("InvoiceService", () => { }); }); - describe("uploadDocument", () => { - const mockInvoice = { - id: "invoice-123", - sellerId: "seller-456", - invoiceNumber: "INV-001", - customerName: "Test Customer", - amount: "1000.00", - discountRate: "5.00", - netAmount: "950.00", - dueDate: new Date("2024-12-31"), - ipfsHash: null, - riskScore: null, - status: InvoiceStatus.DRAFT, - smartContractId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - } as Invoice; + // ============ CREATE INVOICE TESTS ============ + describe("createInvoice", () => { + it("should successfully create an invoice", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + const createdInvoice = { + ...mockInvoice, + netAmount: "950.0000", + }; + mockInvoiceRepository.create.mockReturnValue(createdInvoice); + mockInvoiceRepository.save.mockResolvedValue(createdInvoice); + + const result = await invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + dueDate: new Date("2024-12-31"), + }); + + expect(result.id).toBe("invoice-123"); + expect(result.status).toBe(InvoiceStatus.DRAFT); + expect(result.netAmount).toBe("950.0000"); + expect(mockInvoiceRepository.findOneBy).toHaveBeenCalledWith({ + invoiceNumber: "INV-001", + }); + }); + + it("should calculate net amount correctly", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(null); + mockInvoiceRepository.create.mockReturnValue({ + ...mockInvoice, + amount: "1000.00", + discountRate: "10.00", + }); + mockInvoiceRepository.save.mockResolvedValue({ + ...mockInvoice, + amount: "1000.00", + discountRate: "10.00", + netAmount: "900.0000", + }); + + const result = await invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: new Date("2024-12-31"), + }); + + expect(result.netAmount).toBe("900.0000"); + }); + + it("should reject duplicate invoice number", async () => { + mockInvoiceRepository.findOneBy.mockResolvedValue(mockInvoice); + + await expect( + invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + dueDate: new Date("2024-12-31"), + }), + ).rejects.toThrow(ServiceError); + + await expect( + invoiceService.createInvoice({ + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "5.00", + dueDate: new Date("2024-12-31"), + }), + ).rejects.toMatchObject({ + code: "invoice_number_exists", + statusCode: 409, + }); + }); + }); + + // ============ GET INVOICE TESTS ============ + describe("getInvoiceById", () => { + it("should retrieve invoice by id", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + const result = await invoiceService.getInvoiceById("invoice-123"); + + expect(result?.id).toBe("invoice-123"); + expect(mockInvoiceRepository.findOne).toHaveBeenCalledWith({ + where: { id: "invoice-123" }, + }); + }); + + it("should return null when invoice not found", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + const result = await invoiceService.getInvoiceById("nonexistent"); + + expect(result).toBeNull(); + }); + + it("should verify ownership when sellerId provided", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + const result = await invoiceService.getInvoiceById( + "invoice-123", + "seller-456", + ); + + expect(result?.id).toBe("invoice-123"); + }); + + it("should throw error for unauthorized access", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + await expect( + invoiceService.getInvoiceById("invoice-123", "different-seller"), + ).rejects.toMatchObject({ + code: "unauthorized_invoice_access", + statusCode: 403, + }); + }); + }); + + // ============ GET INVOICES BY SELLER TESTS ============ + describe("getInvoicesBySellerId", () => { + it("should list invoices for seller", async () => { + mockInvoiceRepository.find.mockResolvedValue([mockInvoice]); + mockInvoiceRepository.count.mockResolvedValue(1); + + const result = await invoiceService.getInvoicesBySellerId({ + sellerId: "seller-456", + }); + + expect(result.invoices).toHaveLength(1); + expect(result.total).toBe(1); + }); + + it("should filter by status", async () => { + mockInvoiceRepository.find.mockResolvedValue([mockInvoice]); + mockInvoiceRepository.count.mockResolvedValue(1); + + await invoiceService.getInvoicesBySellerId({ + sellerId: "seller-456", + status: InvoiceStatus.DRAFT, + }); + + expect(mockInvoiceRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + status: InvoiceStatus.DRAFT, + }), + }), + ); + }); + + it("should support pagination", async () => { + mockInvoiceRepository.find.mockResolvedValue([mockInvoice]); + mockInvoiceRepository.count.mockResolvedValue(50); + + await invoiceService.getInvoicesBySellerId({ + sellerId: "seller-456", + skip: 10, + take: 20, + }); + + expect(mockInvoiceRepository.find).toHaveBeenCalledWith( + expect.objectContaining({ + skip: 10, + take: 20, + }), + ); + }); + }); + + // ============ UPDATE INVOICE TESTS ============ + describe("updateInvoice", () => { + it("should update draft invoice", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + const updatedInvoice = { ...mockInvoice, customerName: "Updated Name" }; + mockInvoiceRepository.save.mockResolvedValue(updatedInvoice); + + const result = await invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "invoice-123", + customerName: "Updated Name", + }); + + expect(result.customerName).toBe("Updated Name"); + }); + + it("should recalculate net amount on amount/discount change", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + const updatedInvoice = { + ...mockInvoice, + amount: "2000.00", + discountRate: "10.00", + netAmount: "1800.0000", + }; + mockInvoiceRepository.save.mockResolvedValue(updatedInvoice); + + const result = await invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "invoice-123", + amount: "2000.00", + discountRate: "10.00", + }); + + expect(result.netAmount).toBe("1800.0000"); + }); + + it("should reject update of non-draft invoice", async () => { + const publishedInvoice = { ...mockInvoice, status: InvoiceStatus.PUBLISHED }; + mockInvoiceRepository.findOne.mockResolvedValue(publishedInvoice); + + await expect( + invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "invoice-123", + customerName: "Updated", + }), + ).rejects.toMatchObject({ + code: "invalid_invoice_status", + statusCode: 400, + }); + }); + + it("should throw error for unauthorized update", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + invoiceService.updateInvoice({ + sellerId: "different-seller", + invoiceId: "invoice-123", + customerName: "Updated", + }), + ).rejects.toMatchObject({ + code: "unauthorized_invoice_access", + statusCode: 403, + }); + }); + + it("should return 404 when invoice not found", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(null); + + await expect( + invoiceService.updateInvoice({ + sellerId: "seller-456", + invoiceId: "nonexistent", + customerName: "Updated", + }), + ).rejects.toMatchObject({ + code: "invoice_not_found", + statusCode: 404, + }); + }); + }); + + // ============ DELETE INVOICE TESTS ============ + describe("deleteInvoice", () => { + it("should soft delete draft invoice", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + mockInvoiceRepository.save.mockResolvedValue({ + ...mockInvoice, + deletedAt: new Date(), + }); + + await invoiceService.deleteInvoice("invoice-123", "seller-456"); + + expect(mockInvoiceRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + deletedAt: expect.any(Date), + }), + ); + }); + + it("should reject deletion of published invoice", async () => { + const publishedInvoice = { ...mockInvoice, status: InvoiceStatus.PUBLISHED }; + mockInvoiceRepository.findOne.mockResolvedValue(publishedInvoice); + + await expect( + invoiceService.deleteInvoice("invoice-123", "seller-456"), + ).rejects.toMatchObject({ + code: "invalid_invoice_status", + statusCode: 400, + }); + }); + + it("should throw error for unauthorized delete", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + invoiceService.deleteInvoice("invoice-123", "different-seller"), + ).rejects.toMatchObject({ + code: "unauthorized_invoice_access", + statusCode: 403, + }); + }); + }); + + // ============ PUBLISH INVOICE TESTS ============ + describe("publishInvoice", () => { + it("should transition draft invoice to published", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + const publishedInvoice = { ...mockInvoice, status: InvoiceStatus.PUBLISHED }; + mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); + + const result = await invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "seller-456", + }); + + expect(result.status).toBe(InvoiceStatus.PUBLISHED); + }); + + it("should reject invalid status transitions", async () => { + const settledInvoice = { ...mockInvoice, status: InvoiceStatus.SETTLED }; + mockInvoiceRepository.findOne.mockResolvedValue(settledInvoice); + + await expect( + invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "seller-456", + }), + ).rejects.toMatchObject({ + code: "invalid_status_transition", + statusCode: 400, + }); + }); + + it("should throw error for unauthorized publish", async () => { + mockInvoiceRepository.findOne.mockResolvedValue(mockInvoice); + + await expect( + invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "different-seller", + }), + ).rejects.toMatchObject({ + code: "unauthorized_invoice_access", + statusCode: 403, + }); + }); + + it("should allow transition from pending to published", async () => { + const pendingInvoice = { ...mockInvoice, status: InvoiceStatus.PENDING }; + mockInvoiceRepository.findOne.mockResolvedValue(pendingInvoice); + const publishedInvoice = { ...pendingInvoice, status: InvoiceStatus.PUBLISHED }; + mockInvoiceRepository.save.mockResolvedValue(publishedInvoice); + + const result = await invoiceService.publishInvoice({ + invoiceId: "invoice-123", + sellerId: "seller-456", + }); + + expect(result.status).toBe(InvoiceStatus.PUBLISHED); + }); + }); + + // ============ UPLOAD DOCUMENT TESTS ============ + describe("uploadDocument", () => { const uploadInput = { invoiceId: "invoice-123", sellerId: "seller-456", @@ -129,4 +497,4 @@ describe("InvoiceService", () => { }); }); }); -}); \ No newline at end of file +}); From 85fb0f17424d1eff2b627c75eb8e0ca392c12251 Mon Sep 17 00:00:00 2001 From: DioChuks Date: Mon, 30 Mar 2026 15:19:43 +0100 Subject: [PATCH 2/4] feat: add complete implementation --- src/app.ts | 8 ++-- src/config/env.ts | 13 +++++- src/controllers/invoice.controller.ts | 10 ++--- src/index.ts | 2 +- src/middleware/auth.middleware.ts | 22 +++++++++++ src/middleware/error.middleware.ts | 2 +- src/routes/invoice.routes.ts | 28 ++++++++----- tests/invoice.routes.test.ts | 57 +++++++++++++++++++++------ 8 files changed, 108 insertions(+), 34 deletions(-) diff --git a/src/app.ts b/src/app.ts index fa6c9d4..8300639 100644 --- a/src/app.ts +++ b/src/app.ts @@ -52,7 +52,7 @@ export interface AppDependencies { logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; - ipfsConfig?: any; + config?: import("./config/env").AppConfig; http?: { trustProxy?: boolean | number | string; @@ -74,7 +74,7 @@ export function createApp({ logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), - ipfsConfig, + config, http, }: AppDependencies) { const app = express(); @@ -161,8 +161,8 @@ export function createApp({ app.use("/api/v1/notifications", createNotificationRouter(notificationService, authService)); } - if (invoiceService && ipfsConfig) { - app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config: ipfsConfig })); + if (invoiceService && config) { + app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config })); } app.use(notFoundMiddleware); diff --git a/src/config/env.ts b/src/config/env.ts index aab6675..eb2caba 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -57,6 +57,9 @@ export interface AppConfig { maxUploads: number; }; }; + kyc: { + skipVerification: boolean; + }; } @@ -276,5 +279,13 @@ export function getConfig(): AppConfig { ), }, }, + + kyc: { + skipVerification: parseBoolean( + process.env.SKIP_KYC_VERIFICATION, + process.env.NODE_ENV !== "production", + "SKIP_KYC_VERIFICATION" + ), + }, }; -} \ No newline at end of file +} diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index f65f30c..5b7fbce 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -37,11 +37,7 @@ export interface UpdateInvoiceRequest extends AuthenticatedRequest { } export interface GetInvoicesRequest extends AuthenticatedRequest { - query: { - page?: string; - limit?: string; - status?: string; - }; + query: any; } export interface PublishInvoiceRequest extends AuthenticatedRequest { @@ -107,8 +103,8 @@ export function createInvoiceController(invoiceService: InvoiceService) { throw new HttpError(401, "Authentication required"); } - const page = parseInt(req.query.page || "1", 10); - const limit = parseInt(req.query.limit || "20", 10); + const page = Number(req.query.page) || 1; + const limit = Number(req.query.limit) || 20; const status = req.query.status; // Validate pagination diff --git a/src/index.ts b/src/index.ts index 6d47733..24bd1e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,7 +27,7 @@ export async function bootstrap(): Promise<{ server: Server }> { authService, notificationService, invoiceService, - ipfsConfig: config.ipfs, + config, logger, metricsEnabled: config.observability.metricsEnabled, }); diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts index cdaaaf5..2972a8f 100644 --- a/src/middleware/auth.middleware.ts +++ b/src/middleware/auth.middleware.ts @@ -70,4 +70,26 @@ export function authenticateJWT( } catch { next(new HttpError(401, "Invalid or expired token.")); } +} + +export function requireKYC(skipVerification = false) { + return (req: Request, _res: Response, next: NextFunction): void => { + if (skipVerification) { + next(); + return; + } + + const authReq = req as AuthenticatedRequest; + if (!authReq.user) { + next(new HttpError(401, "Authentication required")); + return; + } + + if (authReq.user.kycStatus !== KYCStatus.APPROVED) { + next(new HttpError(403, "KYC approval required for this action")); + return; + } + + next(); + }; } \ No newline at end of file diff --git a/src/middleware/error.middleware.ts b/src/middleware/error.middleware.ts index 99220ee..93ca207 100644 --- a/src/middleware/error.middleware.ts +++ b/src/middleware/error.middleware.ts @@ -53,4 +53,4 @@ export function createErrorMiddleware(logger: AppLogger) { }, }); }; -} \ No newline at end of file +} diff --git a/src/routes/invoice.routes.ts b/src/routes/invoice.routes.ts index 90bf1a1..319bf21 100644 --- a/src/routes/invoice.routes.ts +++ b/src/routes/invoice.routes.ts @@ -5,12 +5,12 @@ import Joi from "joi"; import type { InvoiceService } from "../services/invoice.service"; import type { AppConfig } from "../config/env"; import { createInvoiceController } from "../controllers/invoice.controller"; -import { authenticateJWT } from "../middleware/auth.middleware"; +import { authenticateJWT, requireKYC } from "../middleware/auth.middleware"; import { HttpError } from "../utils/http-error"; export interface InvoiceRouterDependencies { invoiceService: InvoiceService; - config: AppConfig["ipfs"]; + config: AppConfig; } /** @@ -107,7 +107,11 @@ function validateQuery(schema: Joi.Schema) { ); } - req.query = value; + // Replace req.query with validated value + // In Express, req.query is a getter/setter by default, but we can override it + // if we use the default query parser. + Object.keys(req.query).forEach(key => delete req.query[key]); + Object.assign(req.query, value); next(); }; } @@ -123,10 +127,10 @@ export function createInvoiceRouter({ const upload = multer({ storage: multer.memoryStorage(), limits: { - fileSize: config.maxFileSizeMB * 1024 * 1024, // Convert MB to bytes + fileSize: config.ipfs.maxFileSizeMB * 1024 * 1024, // Convert MB to bytes }, fileFilter: (req, file, cb) => { - if (config.allowedMimeTypes.includes(file.mimetype)) { + if (config.ipfs.allowedMimeTypes.includes(file.mimetype)) { cb(null, true); } else { cb(new Error(`File type ${file.mimetype} is not allowed`)); @@ -136,18 +140,20 @@ export function createInvoiceRouter({ // Rate limiting for document uploads const uploadRateLimit = rateLimit({ - windowMs: config.uploadRateLimit.windowMs, - max: config.uploadRateLimit.maxUploads, + windowMs: config.ipfs.uploadRateLimit.windowMs, + max: config.ipfs.uploadRateLimit.maxUploads, message: { error: { code: "rate_limit_exceeded", - message: `Too many upload attempts. Maximum ${config.uploadRateLimit.maxUploads} uploads per ${config.uploadRateLimit.windowMs / (60 * 1000)} minutes.`, + message: `Too many upload attempts. Maximum ${config.ipfs.uploadRateLimit.maxUploads} uploads per ${config.ipfs.uploadRateLimit.windowMs / (60 * 1000)} minutes.`, }, }, standardHeaders: true, legacyHeaders: false, }); + const kycGating = requireKYC(config.kyc.skipVerification); + // ============ INVOICE CRUD ENDPOINTS ============ // GET /api/v1/invoices - List invoices for authenticated seller @@ -162,6 +168,7 @@ export function createInvoiceRouter({ router.post( "/", authenticateJWT, + kycGating, validateBody(createInvoiceSchema), controller.createInvoice, ); @@ -173,17 +180,19 @@ export function createInvoiceRouter({ router.put( "/:id", authenticateJWT, + kycGating, validateBody(updateInvoiceSchema), controller.updateInvoice, ); // DELETE /api/v1/invoices/:id - Delete invoice - router.delete("/:id", authenticateJWT, controller.deleteInvoice); + router.delete("/:id", authenticateJWT, kycGating, controller.deleteInvoice); // POST /api/v1/invoices/:id/publish - Publish invoice router.post( "/:id/publish", authenticateJWT, + kycGating, controller.publishInvoice, ); @@ -192,6 +201,7 @@ export function createInvoiceRouter({ "/:id/document", uploadRateLimit, authenticateJWT, + kycGating, upload.single("document"), controller.uploadDocument, ); diff --git a/tests/invoice.routes.test.ts b/tests/invoice.routes.test.ts index 5efc148..16e1010 100644 --- a/tests/invoice.routes.test.ts +++ b/tests/invoice.routes.test.ts @@ -12,13 +12,18 @@ describe("Invoice Routes", () => { let mockInvoiceService: any; const mockConfig = { - apiUrl: "https://api.pinata.cloud", - jwt: "test-jwt-token", - maxFileSizeMB: 10, - allowedMimeTypes: ["application/pdf", "image/jpeg", "image/png"], - uploadRateLimit: { - windowMs: 900000, - maxUploads: 10, + ipfs: { + apiUrl: "https://api.pinata.cloud", + jwt: "test-jwt-token", + maxFileSizeMB: 10, + allowedMimeTypes: ["application/pdf", "image/jpeg", "image/png"], + uploadRateLimit: { + windowMs: 900000, + maxUploads: 10, + }, + }, + kyc: { + skipVerification: true, // Default to true for existing tests }, }; @@ -36,13 +41,13 @@ describe("Invoice Routes", () => { amount: "1000.00", discountRate: "10.00", netAmount: "900.00", - dueDate: new Date("2024-12-31"), + dueDate: "2024-12-31T00:00:00.000Z", status: InvoiceStatus.DRAFT, ipfsHash: null, riskScore: null, smartContractId: null, - createdAt: new Date(), - updatedAt: new Date(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), }; beforeEach(() => { @@ -64,7 +69,7 @@ describe("Invoice Routes", () => { "/api/v1/invoices", createInvoiceRouter({ invoiceService: mockInvoiceService, - config: mockConfig, + config: mockConfig as any, }), ); app.use(createErrorMiddleware(logger)); @@ -148,6 +153,36 @@ describe("Invoice Routes", () => { .expect(400); }); + it("should reject creation when KYC is required but not approved", async () => { + // Create a new app instance with KYC enabled + const kycConfig = { + ...mockConfig, + kyc: { skipVerification: false }, + }; + const kycApp = express(); + kycApp.use(express.json()); + kycApp.use( + "/api/v1/invoices", + createInvoiceRouter({ + invoiceService: mockInvoiceService, + config: kycConfig as any, + }), + ); + kycApp.use(createErrorMiddleware(logger)); + + await request(kycApp) + .post("/api/v1/invoices") + .set("Authorization", `Bearer ${validToken}`) + .send({ + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.00", + discountRate: "10.00", + dueDate: "2024-12-31", + }) + .expect(403); + }); + it("should reject unauthenticated requests", async () => { await request(app) .post("/api/v1/invoices") From 634d10a583f7699355f6b828f676b8f71e69fe1d Mon Sep 17 00:00:00 2001 From: DioChuks Date: Mon, 30 Mar 2026 15:53:38 +0100 Subject: [PATCH 3/4] feat: add investment, fractional funding, db concurrency --- package-lock.json | 15 +++ package.json | 2 + src/app.ts | 8 ++ src/controllers/investment.controller.ts | 50 +++++++++ src/index.ts | 3 + src/routes/investment.routes.ts | 24 +++++ src/services/investment.service.ts | 115 ++++++++++++++++++++ tests/investment.routes.test.ts | 97 +++++++++++++++++ tests/investment.service.test.ts | 129 +++++++++++++++++++++++ 9 files changed, 443 insertions(+) create mode 100644 src/controllers/investment.controller.ts create mode 100644 src/routes/investment.routes.ts create mode 100644 src/services/investment.service.ts create mode 100644 tests/investment.routes.test.ts create mode 100644 tests/investment.service.test.ts diff --git a/package-lock.json b/package-lock.json index 275254d..9a71327 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "bcrypt": "^5.1.1", "cors": "^2.8.5", + "decimal.js": "^10.6.0", "dotenv": "^16.3.1", "express": "^5.2.1", "express-rate-limit": "^7.1.5", @@ -29,6 +30,7 @@ "@commitlint/config-conventional": "^20.5.0", "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", + "@types/decimal.js": "^0.0.32", "@types/express": "^5.0.0", "@types/jest": "^29.5.11", "@types/jsonwebtoken": "^9.0.5", @@ -2000,6 +2002,13 @@ "@types/node": "*" } }, + "node_modules/@types/decimal.js": { + "version": "0.0.32", + "resolved": "https://registry.npmjs.org/@types/decimal.js/-/decimal.js-0.0.32.tgz", + "integrity": "sha512-qiZoeFWRa6SaedYkSV8VrGV8xDGV3C6usFlUKOOl/fpvVdKVx+eHm+yHjJbGIuHgNsoe24wUddwLJGVBZFM5Ow==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/express": { "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", @@ -3832,6 +3841,12 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", diff --git a/package.json b/package.json index 6af3efc..0d39024 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "dependencies": { "bcrypt": "^5.1.1", "cors": "^2.8.5", + "decimal.js": "^10.6.0", "dotenv": "^16.3.1", "express": "^5.2.1", "express-rate-limit": "^7.1.5", @@ -38,6 +39,7 @@ "@commitlint/config-conventional": "^20.5.0", "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", + "@types/decimal.js": "^0.0.32", "@types/express": "^5.0.0", "@types/jest": "^29.5.11", "@types/jsonwebtoken": "^9.0.5", diff --git a/src/app.ts b/src/app.ts index 8300639..66e3364 100644 --- a/src/app.ts +++ b/src/app.ts @@ -12,10 +12,12 @@ import { getMetricsContentType, MetricsRegistry } from "./observability/metrics" import { createAuthRouter } from "./routes/auth.routes"; import { createNotificationRouter } from "./routes/notification.routes"; import { createInvoiceRouter } from "./routes/invoice.routes"; +import { createInvestmentRouter } from "./routes/investment.routes"; import type { AuthService } from "./services/auth.service"; import type { NotificationService } from "./services/notification.service"; import type { InvoiceService } from "./services/invoice.service"; +import type { InvestmentService } from "./services/investment.service"; import dataSource from "./config/database"; @@ -49,6 +51,7 @@ export interface AppDependencies { authService: AuthService; notificationService?: NotificationService; invoiceService?: InvoiceService; + investmentService?: InvestmentService; logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; @@ -71,6 +74,7 @@ export function createApp({ authService, notificationService, invoiceService, + investmentService, logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), @@ -165,6 +169,10 @@ export function createApp({ app.use("/api/v1/invoices", createInvoiceRouter({ invoiceService, config })); } + if (investmentService) { + app.use("/api/v1/investments", createInvestmentRouter({ investmentService, authService })); + } + app.use(notFoundMiddleware); app.use(createErrorMiddleware(appLogger)); diff --git a/src/controllers/investment.controller.ts b/src/controllers/investment.controller.ts new file mode 100644 index 0000000..a9306bd --- /dev/null +++ b/src/controllers/investment.controller.ts @@ -0,0 +1,50 @@ +import { Request, Response } from "express"; +import { InvestmentService } from "../services/investment.service"; +import { AuthenticatedRequest } from "../types/auth"; +import { requireApprovedKYC } from "../lib/kyc"; + +export class InvestmentController { + constructor(private readonly investmentService: InvestmentService) {} + + createInvestment = async (req: AuthenticatedRequest, res: Response) => { + try { + const user = req.user; + if (!user) { + return res.status(401).json({ error: "Unauthorized" }); + } + + // Enforce KYC check + requireApprovedKYC(user); + + const { invoiceId, investmentAmount } = req.body; + + if (!invoiceId || !investmentAmount) { + return res.status(400).json({ + error: { + code: "MISSING_FIELDS", + message: "invoiceId and investmentAmount are required", + }, + }); + } + + const investment = await this.investmentService.createInvestment({ + invoiceId, + investorId: user.id, + investmentAmount, + }); + + return res.status(201).json({ + success: true, + data: investment, + }); + } catch (err: any) { + const statusCode = err.status || err.statusCode || 400; + return res.status(statusCode).json({ + error: { + code: err.code || "INTERNAL_ERROR", + message: err.message || "Internal server error", + }, + }); + } + }; +} diff --git a/src/index.ts b/src/index.ts index 24bd1e4..caf24d0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { createAuthService } from "./services/auth.service"; import { createNotificationService } from "./services/notification.service"; import { createInvoiceService } from "./services/invoice.service"; import { createIPFSService } from "./services/ipfs.service"; +import { createInvestmentService } from "./services/investment.service"; export async function bootstrap(): Promise<{ server: Server }> { const config = getConfig(); @@ -22,11 +23,13 @@ export async function bootstrap(): Promise<{ server: Server }> { const notificationService = createNotificationService(dataSource); const ipfsService = createIPFSService(config.ipfs); const invoiceService = createInvoiceService(dataSource, ipfsService); + const investmentService = createInvestmentService(dataSource); const app = createApp({ authService, notificationService, invoiceService, + investmentService, config, logger, metricsEnabled: config.observability.metricsEnabled, diff --git a/src/routes/investment.routes.ts b/src/routes/investment.routes.ts new file mode 100644 index 0000000..17f51e1 --- /dev/null +++ b/src/routes/investment.routes.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; +import { InvestmentController } from "../controllers/investment.controller"; +import { InvestmentService } from "../services/investment.service"; +import { createAuthMiddleware } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; + +export interface InvestmentRouterDependencies { + investmentService: InvestmentService; + authService: AuthService; +} + +export function createInvestmentRouter({ + investmentService, + authService, +}: InvestmentRouterDependencies): Router { + const router = Router(); + const controller = new InvestmentController(investmentService); + const authMiddleware = createAuthMiddleware(authService); + + // POST /api/v1/investments - Create a new investment commitment + router.post("/", authMiddleware, controller.createInvestment); + + return router; +} diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts new file mode 100644 index 0000000..b31c6a0 --- /dev/null +++ b/src/services/investment.service.ts @@ -0,0 +1,115 @@ +import { DataSource, EntityManager } from "typeorm"; +import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; +import { User } from "../models/User.model"; +import { InvoiceStatus, InvestmentStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; +import { Decimal } from "decimal.js"; + +// Formula for expected return: +// Investor's share of the invoice face value (amount) proportional to their contribution to the fundable amount (netAmount). +// expectedReturn = investmentAmount * (invoice.amount / invoice.netAmount) +// This ensures the investor captures the discount. + +export interface CreateInvestmentInput { + invoiceId: string; + investorId: string; + investmentAmount: string; +} + +export class InvestmentService { + constructor(private readonly dataSource: DataSource) {} + + /** + * Creates a new investment commitment for an invoice. + * Uses a database transaction with a row-level lock on the invoice to prevent over-subscription. + */ + async createInvestment(input: CreateInvestmentInput): Promise { + const { invoiceId, investorId, investmentAmount } = input; + + // Validate investment amount + const amount = new Decimal(investmentAmount); + if (amount.isNegative() || amount.isZero()) { + throw new ServiceError("INVALID_AMOUNT", "Investment amount must be greater than zero"); + } + + return await this.dataSource.transaction(async (transactionalEntityManager: EntityManager) => { + // 1. Lock the invoice row for update + const invoice = await transactionalEntityManager + .createQueryBuilder(Invoice, "invoice") + .setLock("pessimistic_write") + .where("invoice.id = :id", { id: invoiceId }) + .getOne(); + + if (!invoice) { + throw new ServiceError("INVOICE_NOT_FOUND", "Invoice not found", 404); + } + + // 2. Validate invoice status + if (invoice.status !== InvoiceStatus.PUBLISHED) { + throw new ServiceError( + "INVALID_INVOICE_STATUS", + `Cannot invest in an invoice with status ${invoice.status}`, + ); + } + + // 3. Prevent self-dealing + if (invoice.sellerId === investorId) { + throw new ServiceError("SELF_DEALING", "Investors cannot invest in their own invoices"); + } + + // 4. Check remaining capacity + // We count both PENDING and CONFIRMED investments towards the cap to prevent over-subscription + const activeInvestments = await transactionalEntityManager.find(Investment, { + where: [ + { invoiceId, status: InvestmentStatus.PENDING }, + { invoiceId, status: InvestmentStatus.CONFIRMED }, + ], + }); + + const totalInvested = activeInvestments.reduce( + (sum, inv) => sum.plus(new Decimal(inv.investmentAmount)), + new Decimal(0), + ); + + const netAmount = new Decimal(invoice.netAmount); + const remainingCapacity = netAmount.minus(totalInvested); + + if (amount.gt(remainingCapacity)) { + throw new ServiceError( + "INSUFFICIENT_CAPACITY", + `Investment amount ${amount.toString()} exceeds remaining capacity ${remainingCapacity.toString()}`, + ); + } + + // 5. Calculate expected return + // expectedReturn = investmentAmount * (invoice.amount / invoice.netAmount) + const faceAmount = new Decimal(invoice.amount); + const expectedReturn = amount.times(faceAmount.dividedBy(netAmount)).toDecimalPlaces(4); + + // 6. Create investment + const investment = transactionalEntityManager.create(Investment, { + invoiceId, + investorId, + investmentAmount: amount.toFixed(4), + expectedReturn: expectedReturn.toFixed(4), + status: InvestmentStatus.PENDING, + }); + + const savedInvestment = await transactionalEntityManager.save(Investment, investment); + + // 7. Transition invoice to FUNDED if fully subscribed + const newTotalInvested = totalInvested.plus(amount); + if (newTotalInvested.gte(netAmount)) { + invoice.status = InvoiceStatus.FUNDED; + await transactionalEntityManager.save(Invoice, invoice); + } + + return savedInvestment; + }); + } +} + +export function createInvestmentService(dataSource: DataSource): InvestmentService { + return new InvestmentService(dataSource); +} diff --git a/tests/investment.routes.test.ts b/tests/investment.routes.test.ts new file mode 100644 index 0000000..0ce2ad2 --- /dev/null +++ b/tests/investment.routes.test.ts @@ -0,0 +1,97 @@ +import request from "supertest"; +import express from "express"; +import { createInvestmentRouter } from "../src/routes/investment.routes"; +import { createErrorMiddleware } from "../src/middleware/error.middleware"; +import { logger } from "../src/observability/logger"; +import { KYCStatus } from "../src/types/enums"; + +describe("Investment Routes", () => { + let app: express.Application; + let mockInvestmentService: any; + let mockAuthService: any; + + const mockUser = { + id: "user-1", + stellarAddress: "GUSER1", + kycStatus: KYCStatus.APPROVED, + }; + + beforeEach(() => { + mockInvestmentService = { + createInvestment: jest.fn(), + }; + + mockAuthService = { + getCurrentUser: jest.fn().mockResolvedValue(mockUser), + }; + + app = express(); + app.use(express.json()); + app.use( + "/api/v1/investments", + createInvestmentRouter({ + investmentService: mockInvestmentService, + authService: mockAuthService, + }), + ); + app.use(createErrorMiddleware(logger)); + }); + + describe("POST /api/v1/investments", () => { + const validPayload = { + invoiceId: "invoice-1", + investmentAmount: "500.0000", + }; + + it("should create an investment and return 201", async () => { + const mockInvestment = { + id: "investment-1", + ...validPayload, + investorId: mockUser.id, + status: "pending", + }; + + mockInvestmentService.createInvestment.mockResolvedValue(mockInvestment); + + const response = await request(app) + .post("/api/v1/investments") + .set("Authorization", "Bearer mock-token") + .send(validPayload) + .expect(201); + + expect(response.body).toEqual({ + success: true, + data: mockInvestment, + }); + + expect(mockInvestmentService.createInvestment).toHaveBeenCalledWith({ + invoiceId: "invoice-1", + investorId: mockUser.id, + investmentAmount: "500.0000", + }); + }); + + it("should return 400 if fields are missing", async () => { + await request(app) + .post("/api/v1/investments") + .set("Authorization", "Bearer mock-token") + .send({ invoiceId: "invoice-1" }) // Missing investmentAmount + .expect(400); + }); + + it("should return 403 if KYC is not approved", async () => { + mockAuthService.getCurrentUser.mockResolvedValue({ + ...mockUser, + kycStatus: KYCStatus.PENDING, + }); + + const response = await request(app) + .post("/api/v1/investments") + .set("Authorization", "Bearer mock-token") + .send(validPayload) + .expect(403); + + expect(response.body.error.code).toBe("KYC_NOT_APPROVED"); + }); + }); +}); diff --git a/tests/investment.service.test.ts b/tests/investment.service.test.ts new file mode 100644 index 0000000..f5b31db --- /dev/null +++ b/tests/investment.service.test.ts @@ -0,0 +1,129 @@ +import { DataSource, EntityManager, SelectQueryBuilder } from "typeorm"; +import { InvestmentService } from "../src/services/investment.service"; +import { Invoice } from "../src/models/Invoice.model"; +import { Investment } from "../src/models/Investment.model"; +import { InvoiceStatus, InvestmentStatus } from "../src/types/enums"; +import { ServiceError } from "../src/utils/service-error"; + +describe("InvestmentService", () => { + let mockDataSource: jest.Mocked; + let mockEntityManager: jest.Mocked; + let mockQueryBuilder: jest.Mocked>; + let investmentService: InvestmentService; + + beforeEach(() => { + mockQueryBuilder = { + setLock: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getOne: jest.fn(), + } as any; + + mockEntityManager = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + find: jest.fn(), + create: jest.fn(), + save: jest.fn(), + } as any; + + mockDataSource = { + transaction: jest.fn().mockImplementation((cb) => cb(mockEntityManager)), + } as any; + + investmentService = new InvestmentService(mockDataSource); + }); + + const getMockInvoice = () => ({ + id: "invoice-1", + sellerId: "seller-1", + amount: "1000.0000", + netAmount: "950.0000", + status: InvoiceStatus.PUBLISHED, + } as Invoice); + + it("should create a PENDING investment when within capacity", async () => { + const mockInvoice = getMockInvoice(); + mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); + mockEntityManager.find.mockResolvedValue([]); // No existing investments + mockEntityManager.create.mockImplementation((entity: any, data: any) => data); + mockEntityManager.save.mockImplementation((entity: any, data: any) => Promise.resolve(data)); + + const input = { + invoiceId: "invoice-1", + investorId: "investor-1", + investmentAmount: "475.0000", + }; + + const result = await investmentService.createInvestment(input); + + expect(mockQueryBuilder.setLock).toHaveBeenCalledWith("pessimistic_write"); + expect(result.status).toBe(InvestmentStatus.PENDING); + expect(result.investmentAmount).toBe("475.0000"); + // expectedReturn = 475 * (1000 / 950) = 475 * 1.0526315789 = 500 + expect(result.expectedReturn).toBe("500.0000"); + expect(mockEntityManager.save).toHaveBeenCalledTimes(1); // Only save investment + }); + + it("should transition invoice to FUNDED when fully subscribed", async () => { + const mockInvoice = getMockInvoice(); + mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); + mockEntityManager.find.mockResolvedValue([]); + mockEntityManager.create.mockImplementation((entity: any, data: any) => data); + mockEntityManager.save.mockImplementation((entity: any, data: any) => Promise.resolve(data)); + + const input = { + invoiceId: "invoice-1", + investorId: "investor-1", + investmentAmount: "950.0000", + }; + + await investmentService.createInvestment(input); + + expect(mockInvoice.status).toBe(InvoiceStatus.FUNDED); + expect(mockEntityManager.save).toHaveBeenCalledTimes(2); // Investment and Invoice + }); + + it("should reject investment if it exceeds capacity", async () => { + const mockInvoice = getMockInvoice(); + mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); + mockEntityManager.find.mockResolvedValue([ + { investmentAmount: "500.0000" } as Investment, + ]); + + const input = { + invoiceId: "invoice-1", + investorId: "investor-1", + investmentAmount: "500.0000", // 500 + 500 > 950 + }; + + await expect(investmentService.createInvestment(input)).rejects.toThrow( + new ServiceError("INSUFFICIENT_CAPACITY", "Investment amount 500 exceeds remaining capacity 450"), + ); + }); + + it("should prevent self-dealing", async () => { + const mockInvoice = getMockInvoice(); + mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); + + const input = { + invoiceId: "invoice-1", + investorId: "seller-1", // Same as invoice seller + investmentAmount: "100.0000", + }; + + await expect(investmentService.createInvestment(input)).rejects.toThrow( + new ServiceError("SELF_DEALING", "Investors cannot invest in their own invoices"), + ); + }); + + it("should reject invalid amounts", async () => { + const input = { + invoiceId: "invoice-1", + investorId: "investor-1", + investmentAmount: "-100.0000", + }; + + await expect(investmentService.createInvestment(input)).rejects.toThrow( + new ServiceError("INVALID_AMOUNT", "Investment amount must be greater than zero"), + ); + }); +}); From 64235bb41dbea6aa6e43d07c8af2ac2b306490bf Mon Sep 17 00:00:00 2001 From: DioChuks Date: Wed, 1 Apr 2026 22:04:22 +0100 Subject: [PATCH 4/4] refactor: remove unused vars & explicit any --- src/controllers/investment.controller.ts | 10 +++++----- src/controllers/invoice.controller.ts | 9 +++++++-- src/services/investment.service.ts | 1 - src/services/invoice.service.ts | 6 +++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/controllers/investment.controller.ts b/src/controllers/investment.controller.ts index a9306bd..2379bf1 100644 --- a/src/controllers/investment.controller.ts +++ b/src/controllers/investment.controller.ts @@ -1,4 +1,4 @@ -import { Request, Response } from "express"; +import { Response } from "express"; import { InvestmentService } from "../services/investment.service"; import { AuthenticatedRequest } from "../types/auth"; import { requireApprovedKYC } from "../lib/kyc"; @@ -37,12 +37,12 @@ export class InvestmentController { success: true, data: investment, }); - } catch (err: any) { - const statusCode = err.status || err.statusCode || 400; + } catch (err: unknown) { + const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 400; return res.status(statusCode).json({ error: { - code: err.code || "INTERNAL_ERROR", - message: err.message || "Internal server error", + code: (err as { code?: string }).code || "INTERNAL_ERROR", + message: (err as { message?: string }).message || "Internal server error", }, }); } diff --git a/src/controllers/invoice.controller.ts b/src/controllers/invoice.controller.ts index 5b7fbce..cbf9476 100644 --- a/src/controllers/invoice.controller.ts +++ b/src/controllers/invoice.controller.ts @@ -3,6 +3,7 @@ import type { InvoiceService } from "../services/invoice.service"; import { HttpError } from "../utils/http-error"; import { ServiceError } from "../utils/service-error"; import { AuthenticatedRequest } from "../types/auth"; +import { InvoiceStatus } from "@/types/enums"; export interface UploadDocumentRequest extends Request { params: { @@ -37,7 +38,11 @@ export interface UpdateInvoiceRequest extends AuthenticatedRequest { } export interface GetInvoicesRequest extends AuthenticatedRequest { - query: any; + query: { + page?: string; + limit?: string; + status?: string; + }; } export interface PublishInvoiceRequest extends AuthenticatedRequest { @@ -114,7 +119,7 @@ export function createInvoiceController(invoiceService: InvoiceService) { const result = await invoiceService.getInvoicesBySellerId({ sellerId: req.user.id, - status: status as any, + status: status as InvoiceStatus | undefined, skip: (page - 1) * limit, take: limit, }); diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index b31c6a0..d98b9d2 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -1,7 +1,6 @@ import { DataSource, EntityManager } from "typeorm"; import { Invoice } from "../models/Invoice.model"; import { Investment } from "../models/Investment.model"; -import { User } from "../models/User.model"; import { InvoiceStatus, InvestmentStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; import { Decimal } from "decimal.js"; diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 7439906..c94ff5c 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -7,9 +7,9 @@ import type { IPFSService, IPFSUploadResult } from "./ipfs.service"; export interface InvoiceRepositoryContract { findOne(options: { where: { id: string } }): Promise; findOneBy(options: { id?: string; invoiceNumber?: string }): Promise; - find(options: any): Promise; + find(options: { where: { sellerId: string; status?: InvoiceStatus}, skip?: number, take?: number, order?: { [key: string]: "ASC" | "DESC" } }): Promise; save(invoice: Invoice): Promise; - count(options: any): Promise; + count(options: { where: { sellerId: string; status?: InvoiceStatus } }): Promise; create(data: Partial): Invoice; } @@ -207,7 +207,7 @@ export class InvoiceService { invoices: InvoiceDTO[]; total: number; }> { - const where: any = { + const where: { sellerId: string; status?: InvoiceStatus; deletedAt: null } = { sellerId: options.sellerId, deletedAt: null, };