diff --git a/src/app.ts b/src/app.ts index 02fb044..0b96ee4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -7,13 +7,19 @@ import { logger, type AppLogger } from "./observability/logger"; import { getMetricsContentType, MetricsRegistry } from "./observability/metrics"; import { createAuthRouter } from "./routes/auth.routes"; import { createInvoiceRouter } from "./routes/invoice.routes"; +import { createSettlementRouter } from "./routes/settlement.routes"; +import { createDashboardRouter } from "./routes/dashboard.routes"; import type { AuthService } from "./services/auth.service"; import type { InvoiceService } from "./services/invoice.service"; +import type { SettlementService } from "./services/settlement.service"; +import type { DashboardService } from "./services/dashboard.service"; import type { AppConfig } from "./config/env"; export interface AppDependencies { authService: AuthService; invoiceService?: InvoiceService; + settlementService?: SettlementService; + dashboardService?: DashboardService; logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; @@ -111,6 +117,8 @@ export function createRequestLifecycleTracker(): RequestLifecycleTracker { export function createApp({ authService, invoiceService, + settlementService, + dashboardService, logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), @@ -182,6 +190,22 @@ export function createApp({ })); } + // Add settlement routes if service is provided + if (settlementService) { + app.use("/api/v1/settlement", createSettlementRouter({ + settlementService, + authService, + })); + } + + // Add dashboard routes if service is provided + if (dashboardService) { + app.use("/api/v1/dashboard", createDashboardRouter({ + dashboardService, + authService, + })); + } + app.use(notFoundMiddleware); app.use(createErrorMiddleware(appLogger)); app.locals.requestLifecycleTracker = requestLifecycleTracker; diff --git a/src/controllers/dashboard.controller.ts b/src/controllers/dashboard.controller.ts new file mode 100644 index 0000000..c203eaf --- /dev/null +++ b/src/controllers/dashboard.controller.ts @@ -0,0 +1,71 @@ +import type { Request, Response, NextFunction } from "express"; +import type { DashboardService } from "../services/dashboard.service"; +import { HttpError } from "../utils/http-error"; +import { ServiceError } from "../utils/service-error"; +import { UserType } from "../types/enums"; + +export function createDashboardController(dashboardService: DashboardService) { + return { + async getSellerDashboard( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + // Only sellers can access seller dashboard + if (req.user.userType !== UserType.SELLER && req.user.userType !== UserType.BOTH) { + throw new HttpError(403, "Only sellers can access seller dashboard"); + } + + const metrics = await dashboardService.getSellerDashboard(req.user.id); + + res.status(200).json({ + success: true, + data: metrics, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + + async getInvestorDashboard( + req: Request, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + // Only investors can access investor dashboard + if (req.user.userType !== UserType.INVESTOR && req.user.userType !== UserType.BOTH) { + throw new HttpError(403, "Only investors can access investor dashboard"); + } + + const metrics = await dashboardService.getInvestorDashboard(req.user.id); + + res.status(200).json({ + success: true, + data: metrics, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + }; +} diff --git a/src/controllers/settlement.controller.ts b/src/controllers/settlement.controller.ts new file mode 100644 index 0000000..824d866 --- /dev/null +++ b/src/controllers/settlement.controller.ts @@ -0,0 +1,78 @@ +import type { Request, Response, NextFunction } from "express"; +import Joi from "joi"; +import type { SettlementService } from "../services/settlement.service"; +import { HttpError } from "../utils/http-error"; +import { ServiceError } from "../utils/service-error"; +import { UserType } from "../types/enums"; + +const settleInvoiceSchema = Joi.object({ + paidAmount: Joi.string().regex(/^\d+(\.\d{1,4})?$/).required(), + stellarTxHash: Joi.string().length(64).optional(), + settledAt: Joi.date().iso().optional(), +}); + +export interface SettleInvoiceRequest extends Request { + params: { + id: string; + }; + body: { + paidAmount: string; + stellarTxHash?: string; + settledAt?: string; + }; +} + +export function createSettlementController(settlementService: SettlementService) { + return { + async settleInvoice( + req: SettleInvoiceRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + if (!req.user) { + throw new HttpError(401, "Authentication required"); + } + + // MVP: Only sellers can settle their own invoices + if (req.user.userType !== UserType.SELLER && req.user.userType !== UserType.BOTH) { + throw new HttpError(403, "Only sellers can settle invoices"); + } + + const { error, value } = settleInvoiceSchema.validate(req.body, { + abortEarly: false, + stripUnknown: true, + }); + + if (error) { + throw new HttpError( + 400, + "Request validation failed.", + error.details.map((detail) => detail.message), + ); + } + + const { id: invoiceId } = req.params; + + const result = await settlementService.settleInvoice({ + invoiceId, + paidAmount: value.paidAmount, + stellarTxHash: value.stellarTxHash, + settledAt: value.settledAt ? new Date(value.settledAt) : undefined, + }); + + res.status(200).json({ + success: true, + data: result, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 1164f77..3f096e0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,8 @@ import { logger } from "./observability/logger"; import { createAuthService } from "./services/auth.service"; import { createIPFSService } from "./services/ipfs.service"; import { createInvoiceService } from "./services/invoice.service"; +import { createSettlementService } from "./services/settlement.service"; +import { createDashboardService } from "./services/dashboard.service"; import { createVerifyPaymentService } from "./services/stellar/verify-payment.service"; import { createReconcilePendingStellarStateWorker } from "./workers/reconcile-pending-stellar-state.worker"; @@ -44,10 +46,14 @@ export async function bootstrap(): Promise { const authService = createAuthService(dataSource, config); const ipfsService = createIPFSService(config.ipfs); const invoiceService = createInvoiceService(dataSource, ipfsService); + const settlementService = createSettlementService(dataSource); + const dashboardService = createDashboardService(dataSource); const requestLifecycleTracker = createRequestLifecycleTracker(); const app = createApp({ authService, invoiceService, + settlementService, + dashboardService, logger, metricsEnabled: config.observability.metricsEnabled, http: { @@ -72,11 +78,11 @@ export async function bootstrap(): Promise { const reconciliationWorker = config.reconciliation.enabled ? createReconcilePendingStellarStateWorker( - dataSource, - createVerifyPaymentService(dataSource, getPaymentVerificationConfig()), - config.reconciliation, - logger, - ) + dataSource, + createVerifyPaymentService(dataSource, getPaymentVerificationConfig()), + config.reconciliation, + logger, + ) : null; reconciliationWorker?.start(); diff --git a/src/routes/dashboard.routes.ts b/src/routes/dashboard.routes.ts new file mode 100644 index 0000000..6dab979 --- /dev/null +++ b/src/routes/dashboard.routes.ts @@ -0,0 +1,27 @@ +import { Router } from "express"; +import type { DashboardService } from "../services/dashboard.service"; +import { createDashboardController } from "../controllers/dashboard.controller"; +import { createAuthMiddleware } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; + +export interface DashboardRouterDependencies { + dashboardService: DashboardService; + authService: AuthService; +} + +export function createDashboardRouter({ + dashboardService, + authService, +}: DashboardRouterDependencies): Router { + const router = Router(); + const controller = createDashboardController(dashboardService); + const authMiddleware = createAuthMiddleware(authService); + + // GET /api/v1/dashboard/seller - Get seller dashboard metrics + router.get("/seller", authMiddleware, controller.getSellerDashboard); + + // GET /api/v1/dashboard/investor - Get investor dashboard metrics + router.get("/investor", authMiddleware, controller.getInvestorDashboard); + + return router; +} diff --git a/src/routes/settlement.routes.ts b/src/routes/settlement.routes.ts new file mode 100644 index 0000000..4e8f4ed --- /dev/null +++ b/src/routes/settlement.routes.ts @@ -0,0 +1,24 @@ +import { Router } from "express"; +import type { SettlementService } from "../services/settlement.service"; +import { createSettlementController } from "../controllers/settlement.controller"; +import { createAuthMiddleware } from "../middleware/auth.middleware"; +import type { AuthService } from "../services/auth.service"; + +export interface SettlementRouterDependencies { + settlementService: SettlementService; + authService: AuthService; +} + +export function createSettlementRouter({ + settlementService, + authService, +}: SettlementRouterDependencies): Router { + const router = Router(); + const controller = createSettlementController(settlementService); + const authMiddleware = createAuthMiddleware(authService); + + // POST /api/v1/settlement/:id - Settle an invoice + router.post("/:id", authMiddleware, controller.settleInvoice); + + return router; +} diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts new file mode 100644 index 0000000..9743705 --- /dev/null +++ b/src/services/dashboard.service.ts @@ -0,0 +1,204 @@ +import { DataSource, Repository, IsNull } from "typeorm"; +import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; +import { InvoiceStatus, InvestmentStatus } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; + +export interface SellerDashboardMetrics { + totalListings: number; + listingsByStatus: Record; + totalFundedVolume: string; + upcomingDueDates: Array<{ + invoiceId: string; + invoiceNumber: string; + dueDate: Date; + amount: string; + status: InvoiceStatus; + }>; +} + +export interface InvestorDashboardMetrics { + activeInvestments: number; + investmentsByStatus: Record; + expectedReturnSum: string; + actualReturnSum: string; + upcomingMaturities: Array<{ + investmentId: string; + invoiceNumber: string; + invoiceDueDate: Date; + investmentAmount: string; + expectedReturn: string; + status: InvestmentStatus; + }>; +} + +interface DashboardRepositoryContract { + getSellerMetrics(sellerId: string): Promise; + getInvestorMetrics(investorId: string): Promise; +} + +interface DashboardServiceDependencies { + dashboardRepository: DashboardRepositoryContract; +} + +export class DashboardService { + constructor(private readonly dependencies: DashboardServiceDependencies) { } + + async getSellerDashboard(sellerId: string): Promise { + if (!sellerId) { + throw new ServiceError("invalid_seller_id", "Seller ID is required.", 400); + } + + return this.dependencies.dashboardRepository.getSellerMetrics(sellerId); + } + + async getInvestorDashboard(investorId: string): Promise { + if (!investorId) { + throw new ServiceError("invalid_investor_id", "Investor ID is required.", 400); + } + + return this.dependencies.dashboardRepository.getInvestorMetrics(investorId); + } +} + +class TypeOrmDashboardRepository implements DashboardRepositoryContract { + constructor( + private readonly invoiceRepository: Repository, + private readonly investmentRepository: Repository, + ) { } + + async getSellerMetrics(sellerId: string): Promise { + // Get all invoices for seller (excluding soft deletes) + const invoices = await this.invoiceRepository.find({ + where: { sellerId, deletedAt: IsNull() }, + order: { dueDate: "ASC" }, + }); + + // Count by status + const listingsByStatus: Record = { + [InvoiceStatus.DRAFT]: 0, + [InvoiceStatus.PENDING]: 0, + [InvoiceStatus.PUBLISHED]: 0, + [InvoiceStatus.FUNDED]: 0, + [InvoiceStatus.SETTLED]: 0, + [InvoiceStatus.CANCELLED]: 0, + }; + + let totalFundedVolume = BigInt(0); + + for (const invoice of invoices) { + listingsByStatus[invoice.status] += 1; + + if (invoice.status === InvoiceStatus.FUNDED || invoice.status === InvoiceStatus.SETTLED) { + totalFundedVolume += BigInt(this.normalizeAmount(invoice.netAmount)); + } + } + + // Get upcoming due dates (next 30 days, funded or settled only) + const now = new Date(); + const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + + const upcomingDueDates = invoices + .filter( + (inv) => + (inv.status === InvoiceStatus.FUNDED || inv.status === InvoiceStatus.SETTLED) && + inv.dueDate >= now && + inv.dueDate <= thirtyDaysFromNow, + ) + .map((inv) => ({ + invoiceId: inv.id, + invoiceNumber: inv.invoiceNumber, + dueDate: inv.dueDate, + amount: inv.netAmount, + status: inv.status, + })); + + return { + totalListings: invoices.length, + listingsByStatus, + totalFundedVolume: this.denormalizeAmount(totalFundedVolume), + upcomingDueDates, + }; + } + + async getInvestorMetrics(investorId: string): Promise { + // Get all investments for investor (excluding soft deletes) + const investments = await this.investmentRepository.find({ + where: { investorId, deletedAt: IsNull() }, + relations: ["invoice"], + order: { createdAt: "ASC" }, + }); + + // Count by status + const investmentsByStatus: Record = { + [InvestmentStatus.PENDING]: 0, + [InvestmentStatus.CONFIRMED]: 0, + [InvestmentStatus.SETTLED]: 0, + [InvestmentStatus.CANCELLED]: 0, + }; + + let expectedReturnSum = BigInt(0); + let actualReturnSum = BigInt(0); + + for (const investment of investments) { + investmentsByStatus[investment.status] += 1; + expectedReturnSum += BigInt(this.normalizeAmount(investment.expectedReturn)); + + if (investment.actualReturn) { + actualReturnSum += BigInt(this.normalizeAmount(investment.actualReturn)); + } + } + + // Get upcoming maturities (next 30 days, confirmed or settled only) + const now = new Date(); + const thirtyDaysFromNow = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); + + const upcomingMaturities = investments + .filter( + (inv) => + (inv.status === InvestmentStatus.CONFIRMED || inv.status === InvestmentStatus.SETTLED) && + inv.invoice && + inv.invoice.dueDate >= now && + inv.invoice.dueDate <= thirtyDaysFromNow, + ) + .map((inv) => ({ + investmentId: inv.id, + invoiceNumber: inv.invoice!.invoiceNumber, + invoiceDueDate: inv.invoice!.dueDate, + investmentAmount: inv.investmentAmount, + expectedReturn: inv.expectedReturn, + status: inv.status, + })); + + return { + activeInvestments: investments.length, + investmentsByStatus, + expectedReturnSum: this.denormalizeAmount(expectedReturnSum), + actualReturnSum: this.denormalizeAmount(actualReturnSum), + upcomingMaturities, + }; + } + + private normalizeAmount(amount: string): string { + const normalized = amount.trim(); + const [whole, fraction = ""] = normalized.split("."); + const paddedFraction = `${fraction}${"0".repeat(4)}`.slice(0, 4); + return `${whole}${paddedFraction}`; + } + + private denormalizeAmount(value: bigint): string { + const str = value.toString().padStart(5, "0"); + const whole = str.slice(0, -4); + const fraction = str.slice(-4); + return `${whole}.${fraction}`; + } +} + +export function createDashboardService(dataSource: DataSource): DashboardService { + const invoiceRepository = dataSource.getRepository(Invoice); + const investmentRepository = dataSource.getRepository(Investment); + + return new DashboardService({ + dashboardRepository: new TypeOrmDashboardRepository(invoiceRepository, investmentRepository), + }); +} diff --git a/src/services/settlement.service.ts b/src/services/settlement.service.ts new file mode 100644 index 0000000..0779c89 --- /dev/null +++ b/src/services/settlement.service.ts @@ -0,0 +1,227 @@ +import { DataSource } from "typeorm"; +import { Invoice } from "../models/Invoice.model"; +import { Investment } from "../models/Investment.model"; +import { Transaction } from "../models/Transaction.model"; +import { InvoiceStatus, InvestmentStatus, TransactionStatus, TransactionType } from "../types/enums"; +import { ServiceError } from "../utils/service-error"; + +export interface SettlementInput { + invoiceId: string; + paidAmount: string; + stellarTxHash?: string; + settledAt?: Date; +} + +export interface SettlementResult { + invoiceId: string; + invoiceStatus: InvoiceStatus; + investmentsSettled: number; + totalDistributed: string; + transactionId: string; +} + +interface SettlementUnitOfWork { + findInvoiceByIdForUpdate(invoiceId: string): Promise; + findInvestmentsByInvoiceIdForUpdate(invoiceId: string): Promise; + saveInvoice(invoice: Invoice): Promise; + saveInvestment(investment: Investment): Promise; + saveTransaction(transaction: Transaction): Promise; + createTransaction(input: Partial): Transaction; +} + +interface SettlementTransactionRunner { + runInTransaction(callback: (unitOfWork: SettlementUnitOfWork) => Promise): Promise; +} + +interface SettlementServiceDependencies { + transactionRunner: SettlementTransactionRunner; +} + +/** + * Settlement Service - MVP Bridge + * + * This service implements settlement that transitions invoices from funded → settled + * and investments from confirmed → settled with actual_return populated. + * + * Pro-rata distribution formula: + * For each investor: actual_return = (paidAmount * investmentAmount) / totalInvestedAmount + * + * This is an MVP implementation until on-chain settlement via Soroban events is fully wired. + * Authorization: Currently accepts authenticated admin/seller endpoints (to be enforced at controller level). + */ +export class SettlementService { + constructor(private readonly dependencies: SettlementServiceDependencies) { } + + async settleInvoice(input: SettlementInput): Promise { + return this.dependencies.transactionRunner.runInTransaction(async (unitOfWork) => { + const invoice = await unitOfWork.findInvoiceByIdForUpdate(input.invoiceId); + + if (!invoice) { + throw new ServiceError("invoice_not_found", "Invoice not found.", 404); + } + + // Validate invoice state transition: funded → settled + if (invoice.status !== InvoiceStatus.FUNDED) { + throw new ServiceError( + "invalid_invoice_state", + `Invoice must be in FUNDED status to settle. Current status: ${invoice.status}`, + 400, + ); + } + + const investments = await unitOfWork.findInvestmentsByInvoiceIdForUpdate(invoice.id); + + // Validate all investments are in confirmed state + const unconfirmedInvestments = investments.filter( + (inv) => inv.status !== InvestmentStatus.CONFIRMED, + ); + + if (unconfirmedInvestments.length > 0) { + throw new ServiceError( + "invalid_investment_state", + `All investments must be CONFIRMED to settle. Found ${unconfirmedInvestments.length} non-confirmed investments.`, + 400, + ); + } + + if (investments.length === 0) { + throw new ServiceError( + "no_investments", + "Cannot settle invoice with no confirmed investments.", + 400, + ); + } + + // Calculate pro-rata distribution + const paidAmount = BigInt(this.normalizeAmount(input.paidAmount)); + const totalInvested = investments.reduce( + (sum, inv) => sum + BigInt(this.normalizeAmount(inv.investmentAmount)), + BigInt(0), + ); + + if (totalInvested === BigInt(0)) { + throw new ServiceError( + "invalid_total_invested", + "Total invested amount cannot be zero.", + 400, + ); + } + + // Distribute returns pro-rata + let totalDistributed = BigInt(0); + const settledInvestments: Investment[] = []; + + for (let i = 0; i < investments.length; i += 1) { + const investment = investments[i]; + const investmentBigInt = BigInt(this.normalizeAmount(investment.investmentAmount)); + + // Calculate return: (paidAmount * investmentAmount) / totalInvested + let actualReturn = (paidAmount * investmentBigInt) / totalInvested; + + // Handle rounding: last investor gets remainder to ensure exact distribution + if (i === investments.length - 1) { + actualReturn = paidAmount - totalDistributed; + } + + totalDistributed += actualReturn; + + investment.status = InvestmentStatus.SETTLED; + investment.actualReturn = this.denormalizeAmount(actualReturn); + + const savedInvestment = await unitOfWork.saveInvestment(investment); + settledInvestments.push(savedInvestment); + } + + // Update invoice status + invoice.status = InvoiceStatus.SETTLED; + const savedInvoice = await unitOfWork.saveInvoice(invoice); + + // Create settlement transaction record for audit trail + const settlementTransaction = unitOfWork.createTransaction({ + userId: invoice.sellerId, + invoiceId: invoice.id, + type: TransactionType.PAYMENT, + amount: this.denormalizeAmount(paidAmount), + status: TransactionStatus.COMPLETED, + stellarTxHash: input.stellarTxHash ?? null, + timestamp: input.settledAt ?? new Date(), + }); + + const savedTransaction = await unitOfWork.saveTransaction(settlementTransaction); + + return { + invoiceId: savedInvoice.id, + invoiceStatus: savedInvoice.status, + investmentsSettled: settledInvestments.length, + totalDistributed: this.denormalizeAmount(totalDistributed), + transactionId: savedTransaction.id, + }; + }); + } + + /** + * Normalize decimal amount to BigInt (scale 4 decimal places) + * Example: "1000.5000" → 10005000n + */ + private normalizeAmount(amount: string): string { + const normalized = amount.trim(); + + if (!/^\d+(\.\d{1,4})?$/.test(normalized)) { + throw new ServiceError( + "invalid_amount_format", + `Invalid amount format: ${amount}. Expected decimal with up to 4 decimal places.`, + 400, + ); + } + + const [whole, fraction = ""] = normalized.split("."); + const paddedFraction = `${fraction}${"0".repeat(4)}`.slice(0, 4); + + return `${whole}${paddedFraction}`; + } + + /** + * Denormalize BigInt back to decimal string (scale 4 decimal places) + * Example: 10005000n → "1000.5000" + */ + private denormalizeAmount(value: bigint): string { + const str = value.toString().padStart(5, "0"); + const whole = str.slice(0, -4); + const fraction = str.slice(-4); + + return `${whole}.${fraction}`; + } +} + +class TypeOrmSettlementTransactionRunner implements SettlementTransactionRunner { + constructor(private readonly dataSource: DataSource) { } + + runInTransaction(callback: (unitOfWork: SettlementUnitOfWork) => Promise): Promise { + return this.dataSource.transaction(async (manager) => + callback({ + findInvoiceByIdForUpdate: (invoiceId: string) => + manager.getRepository(Invoice).findOne({ + where: { id: invoiceId }, + }), + findInvestmentsByInvoiceIdForUpdate: (invoiceId: string) => + manager.getRepository(Investment).find({ + where: { invoiceId }, + }), + saveInvoice: (invoice: Invoice) => + manager.getRepository(Invoice).save(invoice), + saveInvestment: (investment: Investment) => + manager.getRepository(Investment).save(investment), + saveTransaction: (transaction: Transaction) => + manager.getRepository(Transaction).save(transaction), + createTransaction: (input: Partial) => + manager.getRepository(Transaction).create(input), + }), + ); + } +} + +export function createSettlementService(dataSource: DataSource): SettlementService { + return new SettlementService({ + transactionRunner: new TypeOrmSettlementTransactionRunner(dataSource), + }); +} diff --git a/tests/dashboard.service.test.ts b/tests/dashboard.service.test.ts new file mode 100644 index 0000000..78930d1 --- /dev/null +++ b/tests/dashboard.service.test.ts @@ -0,0 +1,249 @@ +import { DashboardService } from "../src/services/dashboard.service"; +import { ServiceError } from "../src/utils/service-error"; +import { Invoice } from "../src/models/Invoice.model"; +import { Investment } from "../src/models/Investment.model"; +import { InvoiceStatus, InvestmentStatus } from "../src/types/enums"; + +describe("DashboardService", () => { + let mockDashboardRepository: any; + let dashboardService: DashboardService; + + beforeEach(() => { + mockDashboardRepository = { + getSellerMetrics: jest.fn(), + getInvestorMetrics: jest.fn(), + }; + + dashboardService = new DashboardService({ + dashboardRepository: mockDashboardRepository, + }); + }); + + describe("getSellerDashboard", () => { + it("should return seller metrics with status counts", async () => { + const mockMetrics = { + totalListings: 5, + listingsByStatus: { + [InvoiceStatus.DRAFT]: 1, + [InvoiceStatus.PENDING]: 1, + [InvoiceStatus.PUBLISHED]: 1, + [InvoiceStatus.FUNDED]: 1, + [InvoiceStatus.SETTLED]: 1, + [InvoiceStatus.CANCELLED]: 0, + }, + totalFundedVolume: "2000.0000", + upcomingDueDates: [ + { + invoiceId: "inv-1", + invoiceNumber: "INV-001", + dueDate: new Date("2024-12-31"), + amount: "1000.0000", + status: InvoiceStatus.FUNDED, + }, + ], + }; + + mockDashboardRepository.getSellerMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getSellerDashboard("seller-123"); + + expect(result).toEqual(mockMetrics); + expect(mockDashboardRepository.getSellerMetrics).toHaveBeenCalledWith("seller-123"); + }); + + it("should throw error when seller ID is missing", async () => { + await expect(dashboardService.getSellerDashboard("")).rejects.toThrow(ServiceError); + + await expect(dashboardService.getSellerDashboard("")).rejects.toMatchObject({ + code: "invalid_seller_id", + statusCode: 400, + }); + }); + + it("should calculate total funded volume correctly", async () => { + const mockMetrics = { + totalListings: 3, + listingsByStatus: { + [InvoiceStatus.DRAFT]: 0, + [InvoiceStatus.PENDING]: 0, + [InvoiceStatus.PUBLISHED]: 0, + [InvoiceStatus.FUNDED]: 2, + [InvoiceStatus.SETTLED]: 1, + [InvoiceStatus.CANCELLED]: 0, + }, + totalFundedVolume: "3000.0000", // 1000 + 1500 + 500 + upcomingDueDates: [], + }; + + mockDashboardRepository.getSellerMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getSellerDashboard("seller-123"); + + expect(result.totalFundedVolume).toBe("3000.0000"); + }); + + it("should filter upcoming due dates within 30 days", async () => { + const now = new Date(); + const in15Days = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); + const in45Days = new Date(now.getTime() + 45 * 24 * 60 * 60 * 1000); + + const mockMetrics = { + totalListings: 2, + listingsByStatus: { + [InvoiceStatus.DRAFT]: 0, + [InvoiceStatus.PENDING]: 0, + [InvoiceStatus.PUBLISHED]: 0, + [InvoiceStatus.FUNDED]: 2, + [InvoiceStatus.SETTLED]: 0, + [InvoiceStatus.CANCELLED]: 0, + }, + totalFundedVolume: "2000.0000", + upcomingDueDates: [ + { + invoiceId: "inv-1", + invoiceNumber: "INV-001", + dueDate: in15Days, + amount: "1000.0000", + status: InvoiceStatus.FUNDED, + }, + ], + }; + + mockDashboardRepository.getSellerMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getSellerDashboard("seller-123"); + + expect(result.upcomingDueDates).toHaveLength(1); + expect(result.upcomingDueDates[0].dueDate).toEqual(in15Days); + }); + }); + + describe("getInvestorDashboard", () => { + it("should return investor metrics with investment counts", async () => { + const mockMetrics = { + activeInvestments: 3, + investmentsByStatus: { + [InvestmentStatus.PENDING]: 0, + [InvestmentStatus.CONFIRMED]: 2, + [InvestmentStatus.SETTLED]: 1, + [InvestmentStatus.CANCELLED]: 0, + }, + expectedReturnSum: "300.0000", + actualReturnSum: "100.0000", + upcomingMaturities: [ + { + investmentId: "inv-1", + invoiceNumber: "INV-001", + invoiceDueDate: new Date("2024-12-31"), + investmentAmount: "500.0000", + expectedReturn: "50.0000", + status: InvestmentStatus.CONFIRMED, + }, + ], + }; + + mockDashboardRepository.getInvestorMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getInvestorDashboard("investor-123"); + + expect(result).toEqual(mockMetrics); + expect(mockDashboardRepository.getInvestorMetrics).toHaveBeenCalledWith("investor-123"); + }); + + it("should throw error when investor ID is missing", async () => { + await expect(dashboardService.getInvestorDashboard("")).rejects.toThrow(ServiceError); + + await expect(dashboardService.getInvestorDashboard("")).rejects.toMatchObject({ + code: "invalid_investor_id", + statusCode: 400, + }); + }); + + it("should calculate expected and actual return sums", async () => { + const mockMetrics = { + activeInvestments: 2, + investmentsByStatus: { + [InvestmentStatus.PENDING]: 0, + [InvestmentStatus.CONFIRMED]: 1, + [InvestmentStatus.SETTLED]: 1, + [InvestmentStatus.CANCELLED]: 0, + }, + expectedReturnSum: "200.0000", // 100 + 100 + actualReturnSum: "150.0000", // 75 + 75 + upcomingMaturities: [], + }; + + mockDashboardRepository.getInvestorMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getInvestorDashboard("investor-123"); + + expect(result.expectedReturnSum).toBe("200.0000"); + expect(result.actualReturnSum).toBe("150.0000"); + }); + + it("should include only confirmed and settled investments in upcoming maturities", async () => { + const now = new Date(); + const in15Days = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000); + + const mockMetrics = { + activeInvestments: 3, + investmentsByStatus: { + [InvestmentStatus.PENDING]: 1, + [InvestmentStatus.CONFIRMED]: 1, + [InvestmentStatus.SETTLED]: 1, + [InvestmentStatus.CANCELLED]: 0, + }, + expectedReturnSum: "300.0000", + actualReturnSum: "100.0000", + upcomingMaturities: [ + { + investmentId: "inv-2", + invoiceNumber: "INV-002", + invoiceDueDate: in15Days, + investmentAmount: "500.0000", + expectedReturn: "50.0000", + status: InvestmentStatus.CONFIRMED, + }, + { + investmentId: "inv-3", + invoiceNumber: "INV-003", + invoiceDueDate: in15Days, + investmentAmount: "500.0000", + expectedReturn: "50.0000", + status: InvestmentStatus.SETTLED, + }, + ], + }; + + mockDashboardRepository.getInvestorMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getInvestorDashboard("investor-123"); + + expect(result.upcomingMaturities).toHaveLength(2); + expect(result.upcomingMaturities.every((m) => m.status !== InvestmentStatus.PENDING)).toBe( + true, + ); + }); + + it("should handle zero actual returns for pending investments", async () => { + const mockMetrics = { + activeInvestments: 1, + investmentsByStatus: { + [InvestmentStatus.PENDING]: 1, + [InvestmentStatus.CONFIRMED]: 0, + [InvestmentStatus.SETTLED]: 0, + [InvestmentStatus.CANCELLED]: 0, + }, + expectedReturnSum: "100.0000", + actualReturnSum: "0.0000", + upcomingMaturities: [], + }; + + mockDashboardRepository.getInvestorMetrics.mockResolvedValue(mockMetrics); + + const result = await dashboardService.getInvestorDashboard("investor-123"); + + expect(result.actualReturnSum).toBe("0.0000"); + }); + }); +}); diff --git a/tests/settlement.service.test.ts b/tests/settlement.service.test.ts new file mode 100644 index 0000000..fa227c9 --- /dev/null +++ b/tests/settlement.service.test.ts @@ -0,0 +1,279 @@ +import { SettlementService } from "../src/services/settlement.service"; +import { ServiceError } from "../src/utils/service-error"; +import { Invoice } from "../src/models/Invoice.model"; +import { Investment } from "../src/models/Investment.model"; +import { Transaction } from "../src/models/Transaction.model"; +import { InvoiceStatus, InvestmentStatus, TransactionStatus, TransactionType } from "../src/types/enums"; + +describe("SettlementService", () => { + let mockTransactionRunner: any; + let settlementService: SettlementService; + + beforeEach(() => { + mockTransactionRunner = { + runInTransaction: jest.fn(), + }; + + settlementService = new SettlementService({ + transactionRunner: mockTransactionRunner, + }); + }); + + describe("settleInvoice", () => { + const mockInvoice = { + id: "invoice-123", + sellerId: "seller-456", + invoiceNumber: "INV-001", + customerName: "Test Customer", + amount: "1000.0000", + discountRate: "5.00", + netAmount: "950.0000", + dueDate: new Date("2024-12-31"), + ipfsHash: null, + riskScore: null, + status: InvoiceStatus.FUNDED, + smartContractId: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Invoice; + + const mockInvestments = [ + { + id: "investment-1", + invoiceId: "invoice-123", + investorId: "investor-1", + investmentAmount: "500.0000", + expectedReturn: "50.0000", + actualReturn: null, + status: InvestmentStatus.CONFIRMED, + transactionHash: null, + stellarOperationIndex: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Investment, + { + id: "investment-2", + invoiceId: "invoice-123", + investorId: "investor-2", + investmentAmount: "500.0000", + expectedReturn: "50.0000", + actualReturn: null, + status: InvestmentStatus.CONFIRMED, + transactionHash: null, + stellarOperationIndex: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + } as Investment, + ]; + + it("should successfully settle invoice with pro-rata distribution", async () => { + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(mockInvoice), + findInvestmentsByInvoiceIdForUpdate: jest.fn().mockResolvedValue(mockInvestments), + saveInvoice: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveInvestment: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveTransaction: jest.fn().mockImplementation((tx) => Promise.resolve({ ...tx, id: "tx-123" })), + createTransaction: jest.fn().mockImplementation((input) => input), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + const result = await settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + stellarTxHash: "abc123def456", + }); + + expect(result.invoiceId).toBe("invoice-123"); + expect(result.invoiceStatus).toBe(InvoiceStatus.SETTLED); + expect(result.investmentsSettled).toBe(2); + expect(result.totalDistributed).toBe("1000.0000"); + expect(result.transactionId).toBe("tx-123"); + + // Verify pro-rata distribution: each investor gets 500 (50% of 1000) + const savedInvestments = unitOfWork.saveInvestment.mock.calls; + expect(savedInvestments[0][0].actualReturn).toBe("500.0000"); + expect(savedInvestments[1][0].actualReturn).toBe("500.0000"); + }); + + it("should handle rounding correctly with odd distribution", async () => { + const investments = [ + { + ...mockInvestments[0], + investmentAmount: "333.3333", + }, + { + ...mockInvestments[1], + investmentAmount: "666.6667", + }, + ]; + + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(mockInvoice), + findInvestmentsByInvoiceIdForUpdate: jest.fn().mockResolvedValue(investments), + saveInvoice: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveInvestment: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveTransaction: jest.fn().mockImplementation((tx) => Promise.resolve({ ...tx, id: "tx-123" })), + createTransaction: jest.fn().mockImplementation((input) => input), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + const result = await settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + }); + + expect(result.totalDistributed).toBe("1000.0000"); + + // Last investor should get remainder to ensure exact distribution + const savedInvestments = unitOfWork.saveInvestment.mock.calls; + const firstReturn = BigInt(savedInvestments[0][0].actualReturn.replace(".", "")); + const secondReturn = BigInt(savedInvestments[1][0].actualReturn.replace(".", "")); + const total = firstReturn + secondReturn; + + expect(total).toBe(BigInt(10000000)); // 1000.0000 normalized + }); + + it("should throw error when invoice not found", async () => { + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(null), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await expect( + settlementService.settleInvoice({ + invoiceId: "nonexistent", + paidAmount: "1000.0000", + }), + ).rejects.toThrow(ServiceError); + + await expect( + settlementService.settleInvoice({ + invoiceId: "nonexistent", + paidAmount: "1000.0000", + }), + ).rejects.toMatchObject({ + code: "invoice_not_found", + statusCode: 404, + }); + }); + + it("should throw error when invoice is not in FUNDED status", async () => { + const draftInvoice = { ...mockInvoice, status: InvoiceStatus.DRAFT }; + + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(draftInvoice), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await expect( + settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + }), + ).rejects.toMatchObject({ + code: "invalid_invoice_state", + statusCode: 400, + }); + }); + + it("should throw error when investments are not all CONFIRMED", async () => { + const mixedInvestments = [ + mockInvestments[0], + { ...mockInvestments[1], status: InvestmentStatus.PENDING }, + ]; + + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(mockInvoice), + findInvestmentsByInvoiceIdForUpdate: jest.fn().mockResolvedValue(mixedInvestments), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await expect( + settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + }), + ).rejects.toMatchObject({ + code: "invalid_investment_state", + statusCode: 400, + }); + }); + + it("should throw error when no investments exist", async () => { + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(mockInvoice), + findInvestmentsByInvoiceIdForUpdate: jest.fn().mockResolvedValue([]), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await expect( + settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + }), + ).rejects.toMatchObject({ + code: "no_investments", + statusCode: 400, + }); + }); + + it("should be idempotent - calling settlement twice should not double-pay", async () => { + const settledInvoice = { ...mockInvoice, status: InvoiceStatus.SETTLED }; + const settledInvestments = mockInvestments.map((inv) => ({ + ...inv, + status: InvestmentStatus.SETTLED, + actualReturn: "500.0000", + })); + + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(settledInvoice), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await expect( + settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + }), + ).rejects.toMatchObject({ + code: "invalid_invoice_state", + statusCode: 400, + }); + }); + + it("should create settlement transaction record for audit trail", async () => { + const unitOfWork = { + findInvoiceByIdForUpdate: jest.fn().mockResolvedValue(mockInvoice), + findInvestmentsByInvoiceIdForUpdate: jest.fn().mockResolvedValue(mockInvestments), + saveInvoice: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveInvestment: jest.fn().mockImplementation((inv) => Promise.resolve(inv)), + saveTransaction: jest.fn().mockImplementation((tx) => Promise.resolve({ ...tx, id: "tx-123" })), + createTransaction: jest.fn().mockImplementation((input) => input), + }; + + mockTransactionRunner.runInTransaction.mockImplementation((cb) => cb(unitOfWork)); + + await settlementService.settleInvoice({ + invoiceId: "invoice-123", + paidAmount: "1000.0000", + stellarTxHash: "abc123def456", + }); + + const createTransactionCall = unitOfWork.createTransaction.mock.calls[0][0]; + expect(createTransactionCall.type).toBe(TransactionType.PAYMENT); + expect(createTransactionCall.status).toBe(TransactionStatus.COMPLETED); + expect(createTransactionCall.stellarTxHash).toBe("abc123def456"); + expect(createTransactionCall.amount).toBe("1000.0000"); + }); + }); +});