Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ 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 { createSettlementRouter } from "./routes/settlement.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 type { SettlementService } from "./services/settlement.service";

import dataSource from "./config/database";

Expand Down Expand Up @@ -52,6 +54,7 @@ export interface AppDependencies {
notificationService?: NotificationService;
invoiceService?: InvoiceService;
investmentService?: InvestmentService;
settlementService?: SettlementService;
logger?: AppLogger;
metricsEnabled?: boolean;
metricsRegistry?: MetricsRegistry;
Expand All @@ -75,6 +78,7 @@ export function createApp({
notificationService,
invoiceService,
investmentService,
settlementService,
logger: appLogger = logger,
metricsEnabled = true,
metricsRegistry = new MetricsRegistry(),
Expand Down Expand Up @@ -173,6 +177,10 @@ export function createApp({
app.use("/api/v1/investments", createInvestmentRouter({ investmentService, authService }));
}

if (settlementService) {
app.use("/api/v1/settlements", createSettlementRouter({ settlementService }));
}

app.use(notFoundMiddleware);
app.use(createErrorMiddleware(appLogger));

Expand Down
43 changes: 43 additions & 0 deletions src/controllers/settlement.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Request, Response } from "express";
import { SettlementService } from "../services/settlement.service";

export class SettlementController {
constructor(private readonly settlementService: SettlementService) {}

settleInvoice = async (req: Request, res: Response) => {
try {
const invoiceId = req.params.invoiceId as string;
const { proceeds } = req.body;

if (!proceeds) {
return res.status(400).json({
error: {
code: "MISSING_FIELDS",
message: "proceeds is required",
},
});
}

const result = await this.settlementService.settleInvoice({
invoiceId,
proceeds,
});

return res.status(200).json({
success: true,
data: result,
});
} catch (err: unknown) {
const statusCode =
(err as { statusCode?: number }).statusCode ||
(err as { status?: number }).status ||
400;
return res.status(statusCode).json({
error: {
code: (err as { code?: string }).code || "INTERNAL_ERROR",
message: (err as { message?: string }).message || "Internal server error",
},
});
}
};
}
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { createNotificationService } from "./services/notification.service";
import { createInvoiceService } from "./services/invoice.service";
import { createIPFSService } from "./services/ipfs.service";
import { createInvestmentService } from "./services/investment.service";
import { createSettlementService } from "./services/settlement.service";

export async function bootstrap(): Promise<{ server: Server }> {
const config = getConfig();
Expand All @@ -24,12 +25,14 @@ export async function bootstrap(): Promise<{ server: Server }> {
const ipfsService = createIPFSService(config.ipfs, logger);
const invoiceService = createInvoiceService(dataSource, ipfsService);
const investmentService = createInvestmentService(dataSource);
const settlementService = createSettlementService(dataSource);

const app = createApp({
authService,
notificationService,
invoiceService,
investmentService,
settlementService,
config,
logger,
metricsEnabled: config.observability.metricsEnabled,
Expand Down
45 changes: 45 additions & 0 deletions src/lib/validate-invoice-for-publish.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import type { Invoice } from "@/models/Invoice.model";

export interface ValidationError {
field: string;
code: string;
message: string;
}

const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000;

/**
* Validates that an invoice meets the minimum field requirements
* required for the draft -> published lifecycle transition.
*/
export function validateInvoiceForPublish(invoice: Invoice): ValidationError[] {
const errors: ValidationError[] = [];

const faceValue = parseFloat(invoice.amount);
if (!(faceValue > 0)) {
errors.push({
field: "amount",
code: "FACE_VALUE_NOT_POSITIVE",
message: "Invoice face value must be greater than zero.",
});
}

const dueDate = new Date(invoice.dueDate);
if (Number.isNaN(dueDate.getTime()) || dueDate.getTime() - Date.now() < MIN_LEAD_TIME_MS) {
errors.push({
field: "dueDate",
code: "DUE_DATE_TOO_SOON",
message: "Invoice due date must be at least 24 hours in the future.",
});
}

if (!invoice.ipfsHash) {
errors.push({
field: "ipfsHash",
code: "MISSING_DOCUMENT",
message: "Invoice must have at least one document attached before it can be published.",
});
}

return errors;
}
5 changes: 5 additions & 0 deletions src/observability/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import winston from "winston";
export type LogMetadata = Record<string, unknown>;

export interface AppLogger {
debug(message: string, metadata?: LogMetadata): void;
info(message: string, metadata?: LogMetadata): void;
warn(message: string, metadata?: LogMetadata): void;
error(message: string, metadata?: LogMetadata): void;
Expand All @@ -12,6 +13,10 @@ export interface AppLogger {
class WinstonAppLogger implements AppLogger {
constructor(private readonly baseLogger: winston.Logger) {}

debug(message: string, metadata: LogMetadata = {}): void {
this.baseLogger.debug(message, metadata);
}

info(message: string, metadata: LogMetadata = {}): void {
this.baseLogger.info(message, metadata);
}
Expand Down
21 changes: 21 additions & 0 deletions src/routes/settlement.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Router } from "express";
import { SettlementController } from "../controllers/settlement.controller";
import type { SettlementService } from "../services/settlement.service";
import { authenticateJWT } from "../middleware/auth.middleware";

export interface SettlementRouterDependencies {
settlementService: SettlementService;
}

export function createSettlementRouter({
settlementService,
}: SettlementRouterDependencies): Router {
const router = Router();
const controller = new SettlementController(settlementService);

// POST /api/v1/settlements/:invoiceId - Settle a funded invoice and
// distribute pro-rata returns to its confirmed investors
router.post("/:invoiceId", authenticateJWT, controller.settleInvoice);

return router;
}
11 changes: 9 additions & 2 deletions src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Invoice } from "../models/Invoice.model";
import { User } from "../models/User.model";
import { InvoiceStatus, KYCStatus } from "../types/enums";
import { ServiceError } from "../utils/service-error";
import { validateInvoiceForPublish } from "../lib/invoice-validation";
import { validateInvoiceForPublish } from "../lib/validate-invoice-for-publish";
import type { IPFSService, IPFSUploadResult } from "./ipfs.service";

export interface InvoiceRepositoryContract {
Expand Down Expand Up @@ -347,7 +347,14 @@ export class InvoiceService {
);
}

validateInvoiceForPublish(invoice);
const validationErrors = validateInvoiceForPublish(invoice);
if (validationErrors.length > 0) {
throw new ServiceError(
"invoice_not_publishable",
`Invoice failed pre-publish validation: ${validationErrors.map((e) => e.message).join(" ")}`,
400,
);
}

invoice.status = InvoiceStatus.PUBLISHED;
const updated = await this.invoiceRepository.save(invoice);
Expand Down
116 changes: 116 additions & 0 deletions src/services/settlement.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { DataSource, EntityManager } from "typeorm";
import { Decimal } from "decimal.js";
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 SettleInvoiceInput {
invoiceId: string;
proceeds: string;
}

export interface InvestorSettlement {
investmentId: string;
investorId: string;
investmentAmount: string;
actualReturn: string;
}

export interface SettleInvoiceResult {
invoiceId: string;
status: InvoiceStatus.SETTLED;
proceeds: string;
settlements: InvestorSettlement[];
}

export class SettlementService {
constructor(private readonly dataSource: DataSource) {}

/**
* Settles a funded invoice by distributing proceeds to each investor
* pro-rata to their share of the invoice's face value.
*/
async settleInvoice(input: SettleInvoiceInput): Promise<SettleInvoiceResult> {
const { invoiceId, proceeds: proceedsInput } = input;

const proceeds = new Decimal(proceedsInput);
if (proceeds.isNegative() || proceeds.isZero()) {
throw new ServiceError(
"INVALID_PROCEEDS",
"Settlement proceeds 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.FUNDED) {
throw new ServiceError(
"INVALID_INVOICE_STATUS",
`Cannot settle an invoice with status ${invoice.status}`,
);
}

// 3. Find confirmed investments backing this invoice
const investments = await transactionalEntityManager.find(Investment, {
where: { invoiceId: invoice.id, status: InvestmentStatus.CONFIRMED },
});

if (investments.length === 0) {
throw new ServiceError(
"NO_CONFIRMED_INVESTMENTS",
"Invoice has no confirmed investments to settle",
);
}

// 4. Distribute proceeds pro-rata to each investor's share of the face value
const faceValue = new Decimal(invoice.amount);
const settlements: InvestorSettlement[] = [];

for (const investment of investments) {
const investmentAmount = new Decimal(investment.investmentAmount);
const actualReturn = investmentAmount
.times(proceeds)
.dividedBy(faceValue)
.toDecimalPlaces(4);

investment.actualReturn = actualReturn.toFixed(4);
investment.status = InvestmentStatus.SETTLED;
await transactionalEntityManager.save(Investment, investment);

settlements.push({
investmentId: investment.id,
investorId: investment.investorId,
investmentAmount: investment.investmentAmount,
actualReturn: investment.actualReturn,
});
}

// 5. Transition invoice to SETTLED
invoice.status = InvoiceStatus.SETTLED;
await transactionalEntityManager.save(Invoice, invoice);

return {
invoiceId: invoice.id,
status: InvoiceStatus.SETTLED as const,
proceeds: proceeds.toFixed(4),
settlements,
};
});
}
}

export function createSettlementService(dataSource: DataSource): SettlementService {
return new SettlementService(dataSource);
}
Loading
Loading