Skip to content
Open
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
32 changes: 32 additions & 0 deletions src/lib/kyc-status-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 } : {}),
});
}
28 changes: 27 additions & 1 deletion src/routes/admin/approve-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -13,7 +13,16 @@ interface ApproveKYCBody {
export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>, 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" });
}

Expand All @@ -22,6 +31,13 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,
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" });
}

Expand All @@ -45,6 +61,16 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,

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: {
Expand Down
28 changes: 27 additions & 1 deletion src/routes/admin/reject-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,7 +14,16 @@ interface RejectKYCBody {
export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, 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" });
}

Expand All @@ -23,6 +32,13 @@ export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, 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" });
}

Expand All @@ -47,6 +63,16 @@ export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, 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: {
Expand Down
36 changes: 35 additions & 1 deletion src/routes/admin/revoke-kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -23,7 +23,16 @@ interface RevokeKYCBody {
export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, 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" });
}

Expand All @@ -32,11 +41,26 @@ export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, 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",
Expand All @@ -63,6 +87,16 @@ export async function revokeKYC(req: Request<unknown, unknown, RevokeKYCBody>, 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: {
Expand Down
104 changes: 104 additions & 0 deletions tests/unit/utils/discount-calculator.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
calculateInvoiceTerms,
calculateTenureDays,
} from "../../../src/utils/discount-calculator.utils";
import { Decimal } from "decimal.js";

describe("discount-calculator.utils", () => {
describe("calculateTenureDays", () => {
Expand Down Expand Up @@ -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");
});
});
});
});