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
1 change: 0 additions & 1 deletion src/controllers/investor-risk-profile.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { InvestorRiskProfileService, RiskProfileInput } from "../services/investor-risk-profile.service";
import { AppError } from "../utils/http-error";
import { logger } from "../observability/logger";
import type { AuthenticatedRequest } from "../types/auth";

export class InvestorRiskProfileController {
Expand Down
3 changes: 1 addition & 2 deletions src/controllers/invoice-draft.controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { Response, NextFunction } from "express";
import { InvoiceDraftService, DraftData } from "../services/invoice-draft.service";
import { AppError } from "../utils/http-error";
import { logger } from "../observability/logger";
import type { AuthenticatedRequest } from "../types/auth";

export class InvoiceDraftController {
Expand Down
23 changes: 21 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,10 +262,29 @@ export async function bootstrap(): Promise<{
// ---- Start settlement worker cron (hourly) ----
settlementWorker.start("0 * * * *");

// All deployed Soroban contracts whose events feed the indexer. The
// read-model projections (creator key, ACL, curve migration, atomic swap,
// royalty, dividend) dispatch by event topic rather than contract id, so
// their events are only ever seen if the contract that emits them is
// actually polled here.
const sorobanEventContractIds = Array.from(
new Set(
[
config.sorobanEscrow.contractId,
sorobanConfig.escrowContractId,
sorobanConfig.paymentDistributorContractId,
sorobanConfig.subscriptionContractId,
sorobanConfig.tokenContractId,
].filter((id): id is string => Boolean(id))
)
);

const eventIndexer =
config.sorobanIndexer.enabled && config.sorobanEscrow.contractId && config.sorobanEscrow.rpcUrl
config.sorobanIndexer.enabled &&
sorobanEventContractIds.length > 0 &&
config.sorobanEscrow.rpcUrl
? new EventIndexerService({
contractIds: [config.sorobanEscrow.contractId],
contractIds: sorobanEventContractIds,
rpcUrl: config.sorobanEscrow.rpcUrl,
dataSource,
logger,
Expand Down
14 changes: 7 additions & 7 deletions src/models/InvestorAccreditation.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,27 +23,27 @@ export const ACCREDITATION_LIMITS: Record<AccreditationTier, number> = {
@Index("idx_investor_accreditation_wallet", ["walletAddress"], { unique: true })
export class InvestorAccreditation {
@PrimaryGeneratedColumn("uuid")
id: string;
id!: string;

@Column({ type: "varchar", length: 256, unique: true })
walletAddress: string;
walletAddress!: string;

@Column({
type: "enum",
enum: AccreditationTier,
default: AccreditationTier.UNACCREDITED,
})
tier: AccreditationTier;
tier!: AccreditationTier;

@Column({ type: "timestamp", nullable: true })
verificationDate: Date | null;
verificationDate!: Date | null;

@Column({ type: "varchar", length: 255, nullable: true })
verificationSource: string | null;
verificationSource!: string | null;

@CreateDateColumn()
createdAt: Date;
createdAt!: Date;

@UpdateDateColumn()
updatedAt: Date;
updatedAt!: Date;
}
6 changes: 3 additions & 3 deletions src/models/Invoice.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import Decimal from "decimal.js";
import { InvoiceStatus } from "../types/enums";
import { logger } from "../observability/logger";
import { AppError } from "../utils/http-error";
import { isPastDueDate } from "../utils/date.utils";

/**
* Frozen state transition map optimized for performance.
Expand Down Expand Up @@ -553,8 +554,7 @@ export class Invoice {
if (!invoice.dueDate) return false;

try {
const dueTime = new Date(invoice.dueDate).getTime();
return !isNaN(dueTime) && dueTime < referenceDate.getTime();
return isPastDueDate(invoice.dueDate, referenceDate);
} catch {
logger.warn("Invalid due date when checking overdue status", { invoiceId: invoice.id });
return false;
Expand Down Expand Up @@ -614,7 +614,7 @@ export class Invoice {
* Evaluates if the invoice due date has passed.
*/
isExpired(referenceDate: Date = new Date()): boolean {
return this.dueDate ? new Date(this.dueDate).getTime() < referenceDate.getTime() : false;
return this.dueDate ? isPastDueDate(this.dueDate, referenceDate) : false;
}

/**
Expand Down
12 changes: 6 additions & 6 deletions src/models/SecondaryMarketPriceHistory.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,23 @@ import { Invoice } from "./Invoice.model";
@Index("idx_price_history_invoice_date_range", ["invoiceId", "createdAt"])
export class SecondaryMarketPriceHistory {
@PrimaryGeneratedColumn("uuid")
id: string;
id!: string;

@Column({ type: "uuid" })
invoiceId: string;
invoiceId!: string;

@ManyToOne(() => Invoice)
@JoinColumn({ name: "invoiceId" })
invoice: Invoice;
invoice!: Invoice;

@Column({ type: "decimal", precision: 18, scale: 8 })
askPrice: Decimal;
askPrice!: Decimal;

@Column({ type: "integer", default: 1 })
listingCount: number;
listingCount!: number;

@CreateDateColumn()
createdAt: Date;
createdAt!: Date;
}

export interface PriceHistoryPoint {
Expand Down
22 changes: 16 additions & 6 deletions src/routes/accreditation.routes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Router, type Response, type NextFunction } from "express";
import { createAuthMiddleware, requireInvestor, requireAdmin } from "../middleware/auth.middleware";
import { createAuthMiddleware, requireAdmin } from "../middleware/auth.middleware";
import type { AuthService } from "../services/auth.service";
import type { InvestorAccreditationService } from "../services/investor-accreditation.service";
import type { AuthenticatedRequest } from "../types/auth";
Expand All @@ -9,19 +9,29 @@ import { AccreditationTier } from "../models/InvestorAccreditation.model";
export interface AccreditationRouterDependencies {
authService: AuthService;
accreditationService: InvestorAccreditationService;
adminWallets?: string[];
}

interface WalletParamsRequest extends AuthenticatedRequest {
params: { wallet: string };
}

interface WalletEligibilityParamsRequest extends AuthenticatedRequest {
params: { wallet: string; invoiceId: string };
}

export function createAccreditationRouter({
authService,
accreditationService,
adminWallets = [],
}: AccreditationRouterDependencies): Router {
const router = Router();
const auth = createAuthMiddleware(authService);

router.get(
"/:wallet",
auth,
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
async (req: WalletParamsRequest, res: Response, next: NextFunction) => {
try {
const { wallet } = req.params;
const accreditation = await accreditationService.getAccreditation(wallet);
Expand All @@ -41,14 +51,14 @@ export function createAccreditationRouter({
router.patch(
"/:wallet",
auth,
requireAdmin(),
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
requireAdmin(adminWallets),
async (req: WalletParamsRequest, res: Response, next: NextFunction) => {
try {
const { wallet } = req.params;
const { tier, verificationSource } = req.body;

if (!Object.values(AccreditationTier).includes(tier)) {
throw new HttpError("Invalid tier", 400);
throw new HttpError(400, "Invalid tier");
}

const accreditation = await accreditationService.updateAccreditation(
Expand All @@ -72,7 +82,7 @@ export function createAccreditationRouter({
router.get(
"/:wallet/eligibility/:invoiceId",
auth,
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
async (req: WalletEligibilityParamsRequest, res: Response, next: NextFunction) => {
try {
const { wallet, invoiceId } = req.params;
const eligibility = await accreditationService.checkEligibility(
Expand Down
3 changes: 0 additions & 3 deletions src/routes/investor.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import type { InvestorAcknowledgementService } from "../services/investor-acknow
import type { AuthenticatedRequest } from "../types/auth";
import { ServiceError } from "../utils/service-error";
import { HttpError, PublicAppError } from "../utils/http-error";
import { createInvestorRiskProfileController } from "@/controllers/investor-risk-profile.controller";
import type { InvestorRiskProfileService } from "@/services/investor-risk-profile.service";

export interface InvestorRouterDependencies {
Expand Down Expand Up @@ -72,8 +71,6 @@ export function createInvestorRouter({

// ---- Risk Profile Management (issue #595) ----
if (riskProfileService) {
const riskProfileController = createInvestorRiskProfileController(riskProfileService);

router.post(
"/risk-profile",
auth,
Expand Down
18 changes: 11 additions & 7 deletions src/routes/invoices/refund.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface RefundRouterDependencies {
refundService: InvoiceRefundService;
}

interface RefundInvoiceParamsRequest extends AuthenticatedRequest {
params: { invoiceId: string };
}

export function createRefundRouter({
authService,
refundService,
Expand All @@ -23,18 +27,18 @@ export function createRefundRouter({
"/:invoiceId/refund",
auth,
requireInvestor(),
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
async (req: RefundInvoiceParamsRequest, res: Response, next: NextFunction) => {
try {
const { invoiceId } = req.params;
const wallet = req.user?.wallet;
const wallet = req.user?.stellarAddress;

if (!wallet) {
throw new HttpError("Wallet not found in request", 401);
throw new HttpError(401, "Wallet not found in request");
}

const { canRefund, reason } = await refundService.canRefund(invoiceId);
if (!canRefund) {
throw new HttpError(reason || "Cannot refund invoice", 400);
throw new HttpError(400, reason || "Cannot refund invoice");
}

await refundService.refundInvestment(invoiceId, wallet);
Expand All @@ -47,7 +51,7 @@ export function createRefundRouter({
});
} catch (error) {
if (error instanceof ServiceError) {
return next(new HttpError(error.message, error.statusCode));
return next(new HttpError(error.statusCode, error.message));
}
next(error);
}
Expand All @@ -60,10 +64,10 @@ export function createRefundRouter({
requireInvestor(),
async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
try {
const wallet = req.user?.wallet;
const wallet = req.user?.stellarAddress;

if (!wallet) {
throw new HttpError("Wallet not found in request", 401);
throw new HttpError(401, "Wallet not found in request");
}

const refundableInvoices = await refundService.getRefundableInvoices(wallet);
Expand Down
68 changes: 26 additions & 42 deletions src/routes/invoices/submit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ import crypto from "crypto";
import { z } from "zod";
import { submitInvoiceSchema } from "@/lib/invoice-submission.schema";
import { adminNotificationQueue } from "@/services/admin-notification-queue.service";
import { InvoiceStatus, UserType } from "@/types/enums";
import { UserType } from "@/types/enums";
import type { AuthenticatedRequest } from "@/types/auth";
import type { InvoiceService } from "@/services/invoice.service";
import dataSource from "@/config/database";
import { Invoice } from "@/models/Invoice.model";
import { ServiceError } from "@/utils/service-error";

export async function submitInvoice(
req: Request | AuthenticatedRequest,
Expand Down Expand Up @@ -60,57 +59,42 @@ export async function submitInvoice(

const sellerWallet = user.stellarAddress;
const sellerId = user.id;
const invoiceId = crypto.randomUUID();
const invoiceNumber = `INV-${Date.now().toString().slice(-6)}-${crypto.randomBytes(3).toString("hex").toUpperCase()}`;

let savedInvoice: Partial<Invoice> = {
id: invoiceId,
if (!invoiceService) {
throw new ServiceError("invoice_service_unavailable", "Invoice service is unavailable", 500);
}

// Creates the invoice as a draft, then drives it through the same
// InvoiceStateMachine transition (draft -> pending) that admin-review
// submissions use, so status history and notification side effects fire
// exactly once, instead of writing the entity straight to the repository.
const created = await invoiceService.createInvoice({
sellerId,
sellerWallet,
invoiceNumber,
customerName: title,
title,
description,
amount: faceValue.toString(),
faceValue: faceValue.toString(),
discountRate: (yieldBps / 100).toFixed(2),
yieldBps,
fundingTarget: fundingTarget.toString(),
netAmount: fundingTarget.toString(),
dueDate: fundingDeadline,
fundingDeadline,
ipfsHash: ipfsDocumentUrl,
sellerWallet,
title,
faceValue: faceValue.toString(),
fundingTarget: fundingTarget.toString(),
yieldBps,
fundingDeadline,
ipfsDocumentUrl,
status: InvoiceStatus.PENDING,
rejectionReason: null,
createdAt: new Date(),
updatedAt: new Date(),
};
});

const invSvc = invoiceService as unknown as {
submitInvoice?: (params: unknown) => Promise<Invoice>;
};
if (invSvc && typeof invSvc.submitInvoice === "function") {
savedInvoice = await invSvc.submitInvoice({
sellerId,
sellerWallet,
title,
description,
faceValue,
fundingTarget,
yieldBps,
fundingDeadline,
ipfsDocumentUrl,
});
} else if (dataSource.isInitialized) {
const repo = dataSource.getRepository(Invoice);
const invoiceEntity = repo.create(savedInvoice as Invoice);
savedInvoice = await repo.save(invoiceEntity);
}
const submitted = await invoiceService.submitInvoiceForReview({
invoiceId: created.id,
sellerId,
});

// Emit invoice_submitted event to admin notification queue
await adminNotificationQueue.emitEvent("invoice_submitted", {
invoiceId: savedInvoice.id || invoiceId,
invoiceId: submitted.id,
sellerId,
sellerWallet,
title,
Expand All @@ -125,11 +109,11 @@ export async function submitInvoice(

return res.status(201).json({
success: true,
data: savedInvoice,
data: submitted,
});
} catch (err: unknown) {
const appErr = err as { status?: number; code?: string; message?: string };
return res.status(appErr.status ?? 500).json({
const appErr = err as { status?: number; statusCode?: number; code?: string; message?: string };
return res.status(appErr.statusCode ?? appErr.status ?? 500).json({
error: {
code: appErr.code ?? "INTERNAL_ERROR",
message: appErr.message ?? "Internal server error",
Expand Down
Loading
Loading