From ebaa0c0fd2b45b8836be2c49a82f348a0e91fe63 Mon Sep 17 00:00:00 2001 From: Danitello123 Date: Wed, 26 Aug 2026 22:58:59 +0100 Subject: [PATCH 1/2] feat: add structured log for failed KYC review decisions --- src/lib/kyc-status-log.ts | 32 +++++++++++++++++++++++++++++ src/routes/admin/approve-kyc.ts | 28 ++++++++++++++++++++++++- src/routes/admin/reject-kyc.ts | 28 ++++++++++++++++++++++++- src/routes/admin/revoke-kyc.ts | 36 ++++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src/lib/kyc-status-log.ts b/src/lib/kyc-status-log.ts index 475e176..2952443 100644 --- a/src/lib/kyc-status-log.ts +++ b/src/lib/kyc-status-log.ts @@ -5,6 +5,9 @@ import { truncateWalletAddress } from "./kyc"; /** The administrative action that produced a KYC status change. */ export type KYCStatusChangeAction = "approve" | "reject" | "revoke"; +/** The stable category of a KYC review failure. */ +export type KYCReviewFailureCategory = "unauthorized" | "user_not_found" | "invalid_state" | "database_error"; + export interface KYCStatusChangeInput { /** Wallet address of the user whose KYC status changed. */ wallet: string; @@ -51,3 +54,32 @@ export function logKYCStatusChange(logger: AppLogger, input: KYCStatusChangeInpu ...(input.reason ? { reason: input.reason } : {}), }); } + +export interface KYCReviewFailureInput { + /** Internal id of the user being reviewed, if known. */ + userId?: string; + /** Internal id of the reviewing admin, if known. */ + reviewerId?: string; + /** Which admin action failed. */ + action: KYCStatusChangeAction; + /** The stable category of the failure. */ + failureCategory: KYCReviewFailureCategory; + /** A correlation identifier for the request, if available. */ + correlationId?: string; + /** Any error message or code. */ + errorDetails?: string; +} + +/** + * Audit log for failed KYC review decisions. + */ +export function logKYCReviewFailure(logger: AppLogger, input: KYCReviewFailureInput): void { + logger.error("KYC review failed", { + ...(input.userId ? { user_id: input.userId } : {}), + ...(input.reviewerId ? { reviewer_id: input.reviewerId } : {}), + action: input.action, + failure_category: input.failureCategory, + ...(input.correlationId ? { correlation_id: input.correlationId } : {}), + ...(input.errorDetails ? { error_details: input.errorDetails } : {}), + }); +} diff --git a/src/routes/admin/approve-kyc.ts b/src/routes/admin/approve-kyc.ts index 2b9f3af..543dc90 100644 --- a/src/routes/admin/approve-kyc.ts +++ b/src/routes/admin/approve-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface ApproveKYCBody { @@ -13,7 +13,16 @@ interface ApproveKYCBody { export async function approveKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "approve", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -22,6 +31,13 @@ export async function approveKYC(req: Request, const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "approve", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } @@ -45,6 +61,16 @@ export async function approveKYC(req: Request, return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "approve", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: { diff --git a/src/routes/admin/reject-kyc.ts b/src/routes/admin/reject-kyc.ts index 75ac4a1..5abe1af 100644 --- a/src/routes/admin/reject-kyc.ts +++ b/src/routes/admin/reject-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface RejectKYCBody { @@ -14,7 +14,16 @@ interface RejectKYCBody { export async function rejectKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "reject", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -23,6 +32,13 @@ export async function rejectKYC(req: Request, r const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "reject", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } @@ -47,6 +63,16 @@ export async function rejectKYC(req: Request, r return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "reject", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: { diff --git a/src/routes/admin/revoke-kyc.ts b/src/routes/admin/revoke-kyc.ts index 84716ad..17ecfff 100644 --- a/src/routes/admin/revoke-kyc.ts +++ b/src/routes/admin/revoke-kyc.ts @@ -2,7 +2,7 @@ import { Request, Response } from "express"; import { DataSource } from "typeorm"; import { User } from "@/models/User.model"; import { KYCStatus } from "@/types/enums"; -import { logKYCStatusChange } from "@/lib/kyc-status-log"; +import { logKYCStatusChange, logKYCReviewFailure } from "@/lib/kyc-status-log"; import { logger } from "@/observability/logger"; interface RevokeKYCBody { @@ -23,7 +23,16 @@ interface RevokeKYCBody { export async function revokeKYC(req: Request, res: Response, dataSource: DataSource) { try { const adminKey = req.headers["x-admin-key"]; + const correlationId = req.headers["x-request-id"] as string | undefined; + if (adminKey !== process.env.ADMIN_API_KEY) { + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "revoke", + failureCategory: "unauthorized", + correlationId, + }); return res.status(401).json({ error: "Unauthorized" }); } @@ -32,11 +41,26 @@ export async function revokeKYC(req: Request, r const userRepo = dataSource.getRepository(User); const user = await userRepo.findOneBy({ id: userId }); if (!user) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "revoke", + failureCategory: "user_not_found", + correlationId, + }); return res.status(404).json({ error: "User not found" }); } const previousStatus = user.kycStatus; if (previousStatus !== KYCStatus.APPROVED) { + logKYCReviewFailure(logger, { + userId, + reviewerId, + action: "revoke", + failureCategory: "invalid_state", + correlationId, + errorDetails: `Cannot revoke KYC for a user whose status is ${previousStatus}.`, + }); return res.status(409).json({ error: { code: "KYC_NOT_APPROVED", @@ -63,6 +87,16 @@ export async function revokeKYC(req: Request, r return res.json({ success: true }); } catch (err: unknown) { + const correlationId = req.headers["x-request-id"] as string | undefined; + logKYCReviewFailure(logger, { + userId: req.body?.userId, + reviewerId: req.body?.reviewerId, + action: "revoke", + failureCategory: "database_error", + correlationId, + errorDetails: err instanceof Error ? err.message : String(err), + }); + const appErr = err as { status?: number; code?: string; message?: string }; return res.status(appErr.status ?? 500).json({ error: { From f6eb309c58c556d86262ca76724422257d778e45 Mon Sep 17 00:00:00 2001 From: Danitello123 Date: Wed, 26 Aug 2026 23:08:32 +0100 Subject: [PATCH 2/2] test: add unit tests for platform-fee boundary values --- .../utils/discount-calculator.utils.test.ts | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/tests/unit/utils/discount-calculator.utils.test.ts b/tests/unit/utils/discount-calculator.utils.test.ts index 542ea43..002642d 100644 --- a/tests/unit/utils/discount-calculator.utils.test.ts +++ b/tests/unit/utils/discount-calculator.utils.test.ts @@ -2,6 +2,7 @@ import { calculateInvoiceTerms, calculateTenureDays, } from "../../../src/utils/discount-calculator.utils"; +import { Decimal } from "decimal.js"; describe("discount-calculator.utils", () => { describe("calculateTenureDays", () => { @@ -94,5 +95,108 @@ describe("discount-calculator.utils", () => { }), ).toThrow("Discount BPS must be between 0 and 10,000"); }); + + describe("Platform fee calculation boundaries", () => { + const dueDate = new Date("2026-05-01T00:00:00Z"); + const referenceDate = new Date("2026-04-01T00:00:00Z"); + + it("should handle minimum supported payment amount and zero fee", () => { + const result = calculateInvoiceTerms({ + faceValue: "0.0001", // Smallest non-zero amount for 4 decimal places + dueDate, + referenceDate, + discountBps: 0, + platformFeeBps: 0, + }); + expect(result.platformFee).toBe("0.0000"); + expect(result.advanceAmount).toBe("0.0001"); + expect(result.investorReturn).toBe("0.0000"); + + const reconciled = new Decimal(result.advanceAmount) + .plus(result.investorReturn) + .plus(result.platformFee); + expect(reconciled.equals("0.0001")).toBe(true); + }); + + it("should handle largest supported payment amount with near-maximum fee", () => { + const largeAmount = "999999999999999.9999"; + const result = calculateInvoiceTerms({ + faceValue: largeAmount, + dueDate, + referenceDate, + discountBps: 0, + platformFeeBps: 9999, // 99.99% maximum valid fee since 100% throws + }); + + // 99.99% of 999999999999999.9999 + // = 999899999999999.99990001 + + const expectedFee = new Decimal(largeAmount).times(9999).dividedBy(10000).toFixed(4); + const expectedAdvance = new Decimal(largeAmount).minus(expectedFee).toFixed(4); + + expect(result.platformFee).toBe(expectedFee); + expect(result.investorReturn).toBe("0.0000"); + expect(result.advanceAmount).toBe(expectedAdvance); + + const reconciled = new Decimal(result.advanceAmount) + .plus(result.investorReturn) + .plus(result.platformFee); + expect(reconciled.equals(largeAmount)).toBe(true); + }); + + it("should handle minimum platform fee (1 bps)", () => { + const result = calculateInvoiceTerms({ + faceValue: "10000.0000", + dueDate, + referenceDate, + discountBps: 100, // 1% + platformFeeBps: 1, // 0.01% + }); + expect(result.platformFee).toBe("1.0000"); + expect(result.investorReturn).toBe("100.0000"); + expect(result.advanceAmount).toBe("9899.0000"); + + const reconciled = new Decimal(result.advanceAmount) + .plus(result.investorReturn) + .plus(result.platformFee); + expect(reconciled.equals("10000.0000")).toBe(true); + }); + + it("should reject invalid negative platform fee", () => { + expect(() => + calculateInvoiceTerms({ + faceValue: "1000.0000", + dueDate, + referenceDate, + discountBps: 100, + platformFeeBps: -1, + }), + ).toThrow("Platform fee BPS must be between 0 and 10,000"); + }); + + it("should reject platform fee > 10,000", () => { + expect(() => + calculateInvoiceTerms({ + faceValue: "1000.0000", + dueDate, + referenceDate, + discountBps: 100, + platformFeeBps: 10001, + }), + ).toThrow("Platform fee BPS must be between 0 and 10,000"); + }); + + it("should reject total fee and discount >= 10,000", () => { + expect(() => + calculateInvoiceTerms({ + faceValue: "1000.0000", + dueDate, + referenceDate, + discountBps: 5000, + platformFeeBps: 5000, + }), + ).toThrow("Total fee and discount deductions cannot exceed 100% of face value"); + }); + }); }); });