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
24 changes: 24 additions & 0 deletions src/controllers/investment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,4 +47,28 @@ export class InvestmentController {
});
}
};

getDashboard = async (req: AuthenticatedRequest, res: Response) => {
try {
const user = req.user;
if (!user) {
return res.status(401).json({ error: "Unauthorized" });
}

const dashboard = await this.investmentService.getInvestorDashboard(user.id);

return res.status(200).json({
success: true,
data: dashboard,
});
} catch (err: unknown) {
const statusCode = (err as { status?: number }).status || (err as { statusCode?: number }).statusCode || 500;
return res.status(statusCode).json({
error: {
code: (err as { code?: string }).code || "INTERNAL_ERROR",
message: (err as { message?: string }).message || "Internal server error",
},
});
}
};
}
27 changes: 27 additions & 0 deletions src/lib/invoice-validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ServiceError } from "../utils/service-error";

const MIN_LEAD_TIME_MS = 24 * 60 * 60 * 1000;

export interface PublishableInvoice {
dueDate: Date;
}

/**
* Validates that an invoice is eligible to be published.
*
* The due date must be at least 24 hours in the future at the moment of
* publishing, so investors always have a full day of runway before the
* invoice is due.
*/
export function validateInvoiceForPublish(invoice: PublishableInvoice, now: Date = new Date()): void {
const leadTimeMs = invoice.dueDate.getTime() - now.getTime();

if (leadTimeMs < MIN_LEAD_TIME_MS) {
throw new ServiceError(
"invalid_due_date",
"dueDate must be at least 24 hours in the future",
400,
{ field: "dueDate" },
);
}
}
6 changes: 6 additions & 0 deletions src/lib/kyc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,10 @@ export function requireApprovedKYC(user: { kycStatus: KYCStatus }) {
if (user.kycStatus !== KYCStatus.APPROVED) {
throw new KYCError("KYC not approved");
}
}

/** Truncates a wallet address to its first 4 and last 4 characters for safe logging. */
export function truncateWalletAddress(address: string): string {
if (address.length <= 8) return address;
return `${address.slice(0, 4)}...${address.slice(-4)}`;
}
28 changes: 28 additions & 0 deletions src/lib/stellar-format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const STROOP_DECIMALS = 7;

/**
* Converts an amount in stroops (1 XLM = 10,000,000 stroops) to a decimal
* XLM string, using integer arithmetic throughout to avoid floating point
* error on large balances.
*
* @param stroops Amount in stroops
* @param decimals Number of decimal places to display (default: 7, full stroop precision)
*/
export function stroopsToXlm(stroops: bigint, decimals: number = STROOP_DECIMALS): string {
const negative = stroops < 0n;
const abs = negative ? -stroops : stroops;

const divisor = 10n ** BigInt(STROOP_DECIMALS);
const whole = abs / divisor;
const remainderStroops = abs % divisor;

// Pad the stroop remainder out to full precision, then round/truncate to
// the requested number of decimal places.
const fullFraction = remainderStroops.toString().padStart(STROOP_DECIMALS, "0");
const fraction = decimals <= STROOP_DECIMALS
? fullFraction.slice(0, decimals)
: fullFraction.padEnd(decimals, "0");

const sign = negative ? "-" : "";
return decimals > 0 ? `${sign}${whole}.${fraction}` : `${sign}${whole}`;
}
24 changes: 21 additions & 3 deletions src/routes/admin/approve-kyc.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Request, Response } from "express";
import { DataSource } from "typeorm";
import { User } from "@/entities/User";
import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { truncateWalletAddress } from "@/lib/kyc";
import { logger } from "@/observability/logger";

interface ApproveKYCBody {
userId: string;
reviewerId: string;
}

export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>, res: Response, dataSource: DataSource) {
Expand All @@ -14,11 +17,26 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,
return res.status(401).json({ error: "Unauthorized" });
}

const { userId } = req.body;
const { userId, reviewerId } = req.body;

const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: userId });
if (!user) {
return res.status(404).json({ error: "User not found" });
}

await userRepo.update(userId, { kycStatus: KYCStatus.APPROVED });

// Logged only after the DB update succeeds, so the audit trail never
// records a decision that didn't actually persist.
const decidedAt = new Date().toISOString();
logger.info("KYC approval decision", {
wallet_address: truncateWalletAddress(user.stellarAddress),
decision: "approved",
reviewer_id: reviewerId,
decided_at: decidedAt,
});

return res.json({ success: true });
} catch (err: unknown) {
const appErr = err as { status?: number; code?: string; message?: string };
Expand All @@ -29,4 +47,4 @@ export async function approveKYC(req: Request<unknown, unknown, ApproveKYCBody>,
},
});
}
}
}
52 changes: 52 additions & 0 deletions src/routes/admin/reject-kyc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { Request, Response } from "express";
import { DataSource } from "typeorm";
import { User } from "@/models/User.model";
import { KYCStatus } from "@/types/enums";
import { truncateWalletAddress } from "@/lib/kyc";
import { logger } from "@/observability/logger";

interface RejectKYCBody {
userId: string;
reviewerId: string;
rejectionReason: string;
}

export async function rejectKYC(req: Request<unknown, unknown, RejectKYCBody>, res: Response, dataSource: DataSource) {
try {
const adminKey = req.headers["x-admin-key"];
if (adminKey !== process.env.ADMIN_API_KEY) {
return res.status(401).json({ error: "Unauthorized" });
}

const { userId, reviewerId, rejectionReason } = req.body;

const userRepo = dataSource.getRepository(User);
const user = await userRepo.findOneBy({ id: userId });
if (!user) {
return res.status(404).json({ error: "User not found" });
}

await userRepo.update(userId, { kycStatus: KYCStatus.REJECTED });

// Logged only after the DB update succeeds, so the audit trail never
// records a decision that didn't actually persist.
const decidedAt = new Date().toISOString();
logger.info("KYC rejection decision", {
wallet_address: truncateWalletAddress(user.stellarAddress),
decision: "rejected",
reviewer_id: reviewerId,
decided_at: decidedAt,
rejection_reason: rejectionReason,
});

return res.json({ success: true });
} catch (err: unknown) {
const appErr = err as { status?: number; code?: string; message?: string };
return res.status(appErr.status ?? 500).json({
error: {
code: appErr.code ?? "INTERNAL_ERROR",
message: appErr.message ?? "Internal server error",
},
});
}
}
3 changes: 3 additions & 0 deletions src/routes/investment.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,8 @@ export function createInvestmentRouter({
// POST /api/v1/investments - Create a new investment commitment
router.post("/", authMiddleware, controller.createInvestment);

// GET /api/v1/investments/dashboard - Investor portfolio aggregate
router.get("/dashboard", authMiddleware, controller.getDashboard);

return router;
}
45 changes: 45 additions & 0 deletions src/services/investment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,54 @@ export interface CreateInvestmentInput {
investmentAmount: string;
}

export interface InvestorDashboard {
totalInvested: string;
totalReturns: string;
activeInvestments: number;
}

const ACTIVE_INVESTMENT_STATUSES = [InvestmentStatus.PENDING, InvestmentStatus.CONFIRMED];

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

/**
* Aggregates an investor's portfolio across all their investments.
*
* - totalInvested sums investmentAmount across every status (a commitment
* counts once made, regardless of how it later resolves).
* - totalReturns sums actualReturn for SETTLED investments only — pending
* or confirmed investments have no realised return yet.
* - activeInvestments counts investments still in flight (PENDING/CONFIRMED).
*/
async getInvestorDashboard(investorId: string): Promise<InvestorDashboard> {
const investments = await this.dataSource.getRepository(Investment).find({
where: { investorId },
});

let totalInvested = new Decimal(0);
let totalReturns = new Decimal(0);
let activeInvestments = 0;

for (const investment of investments) {
totalInvested = totalInvested.plus(new Decimal(investment.investmentAmount));

if (investment.status === InvestmentStatus.SETTLED && investment.actualReturn !== null) {
totalReturns = totalReturns.plus(new Decimal(investment.actualReturn));
}

if (ACTIVE_INVESTMENT_STATUSES.includes(investment.status)) {
activeInvestments += 1;
}
}

return {
totalInvested: totalInvested.toFixed(4),
totalReturns: totalReturns.toFixed(4),
activeInvestments,
};
}

/**
* Creates a new investment commitment for an invoice.
* Uses a database transaction with a row-level lock on the invoice to prevent over-subscription.
Expand Down
3 changes: 3 additions & 0 deletions src/services/invoice.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { DataSource } from "typeorm";
import { Invoice } from "../models/Invoice.model";
import { InvoiceStatus } from "../types/enums";
import { ServiceError } from "../utils/service-error";
import { validateInvoiceForPublish } from "../lib/invoice-validation";
import type { IPFSService, IPFSUploadResult } from "./ipfs.service";

export interface InvoiceRepositoryContract {
Expand Down Expand Up @@ -352,6 +353,8 @@ export class InvoiceService {
);
}

validateInvoiceForPublish(invoice);

invoice.status = InvoiceStatus.PUBLISHED;
const updated = await this.invoiceRepository.save(invoice);
return this.toDTO(updated);
Expand Down
101 changes: 101 additions & 0 deletions tests/integration/investor-dashboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { DataSource, Repository } from "typeorm";
import { InvestmentService } from "../../src/services/investment.service";
import { Investment } from "../../src/models/Investment.model";
import { InvestmentStatus } from "../../src/types/enums";

describe("Investor dashboard aggregate", () => {
let mockRepository: jest.Mocked<Repository<Investment>>;
let mockDataSource: jest.Mocked<DataSource>;
let investmentService: InvestmentService;

const walletAId = "wallet-a";

function seedInvestment(overrides: Partial<Investment>): Investment {
return {
id: "investment-id",
invoiceId: "invoice-id",
investorId: walletAId,
investmentAmount: "0.0000",
expectedReturn: "0.0000",
actualReturn: null,
status: InvestmentStatus.PENDING,
transactionHash: null,
stellarOperationIndex: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
...overrides,
} as Investment;
}

beforeEach(() => {
mockRepository = {
find: jest.fn(),
} as unknown as jest.Mocked<Repository<Investment>>;

mockDataSource = {
getRepository: jest.fn().mockReturnValue(mockRepository),
} as unknown as jest.Mocked<DataSource>;

investmentService = new InvestmentService(mockDataSource);
});

it("aggregates total invested, total returns, and active count across investment states", async () => {
// Wallet A: two active investments (2000, 3000) and one settled
// investment (1000, realised return 1100).
mockRepository.find.mockResolvedValue([
seedInvestment({ id: "inv-1", investmentAmount: "2000.0000", status: InvestmentStatus.PENDING }),
seedInvestment({ id: "inv-2", investmentAmount: "3000.0000", status: InvestmentStatus.CONFIRMED }),
seedInvestment({
id: "inv-3",
investmentAmount: "1000.0000",
status: InvestmentStatus.SETTLED,
actualReturn: "1100.0000",
}),
]);

const dashboard = await investmentService.getInvestorDashboard(walletAId);

expect(mockDataSource.getRepository).toHaveBeenCalledWith(Investment);
expect(mockRepository.find).toHaveBeenCalledWith({ where: { investorId: walletAId } });

expect(dashboard.totalInvested).toBe("6000.0000");
expect(dashboard.totalReturns).toBe("1100.0000");
expect(dashboard.activeInvestments).toBe(2);
});

it("does not count pending returns towards totalReturns", async () => {
mockRepository.find.mockResolvedValue([
seedInvestment({ id: "inv-1", investmentAmount: "500.0000", status: InvestmentStatus.PENDING, expectedReturn: "550.0000" }),
]);

const dashboard = await investmentService.getInvestorDashboard(walletAId);

expect(dashboard.totalInvested).toBe("500.0000");
expect(dashboard.totalReturns).toBe("0.0000");
expect(dashboard.activeInvestments).toBe(1);
});

it("excludes cancelled investments from the active count", async () => {
mockRepository.find.mockResolvedValue([
seedInvestment({ id: "inv-1", investmentAmount: "800.0000", status: InvestmentStatus.CANCELLED }),
]);

const dashboard = await investmentService.getInvestorDashboard(walletAId);

expect(dashboard.totalInvested).toBe("800.0000");
expect(dashboard.activeInvestments).toBe(0);
});

it("returns zero for all fields when the wallet has no investments", async () => {
mockRepository.find.mockResolvedValue([]);

const dashboard = await investmentService.getInvestorDashboard("wallet-with-nothing");

expect(dashboard).toEqual({
totalInvested: "0.0000",
totalReturns: "0.0000",
activeInvestments: 0,
});
});
});
Loading
Loading