diff --git a/src/controllers/investor-risk-profile.controller.ts b/src/controllers/investor-risk-profile.controller.ts index 2ffe9a9b..c9db744c 100644 --- a/src/controllers/investor-risk-profile.controller.ts +++ b/src/controllers/investor-risk-profile.controller.ts @@ -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 { diff --git a/src/controllers/invoice-draft.controller.ts b/src/controllers/invoice-draft.controller.ts index f6b20442..77ed81f1 100644 --- a/src/controllers/invoice-draft.controller.ts +++ b/src/controllers/invoice-draft.controller.ts @@ -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 { diff --git a/src/index.ts b/src/index.ts index fe3a24fc..b02cfc2a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/models/InvestorAccreditation.model.ts b/src/models/InvestorAccreditation.model.ts index 68989bf8..fa0d1e66 100644 --- a/src/models/InvestorAccreditation.model.ts +++ b/src/models/InvestorAccreditation.model.ts @@ -23,27 +23,27 @@ export const ACCREDITATION_LIMITS: Record = { @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; } diff --git a/src/models/Invoice.model.ts b/src/models/Invoice.model.ts index 5175f9ef..c00a49ac 100644 --- a/src/models/Invoice.model.ts +++ b/src/models/Invoice.model.ts @@ -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. @@ -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; @@ -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; } /** diff --git a/src/models/SecondaryMarketPriceHistory.model.ts b/src/models/SecondaryMarketPriceHistory.model.ts index d147e845..fec2fa3a 100644 --- a/src/models/SecondaryMarketPriceHistory.model.ts +++ b/src/models/SecondaryMarketPriceHistory.model.ts @@ -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 { diff --git a/src/routes/accreditation.routes.ts b/src/routes/accreditation.routes.ts index 5136436f..0e0f1e6e 100644 --- a/src/routes/accreditation.routes.ts +++ b/src/routes/accreditation.routes.ts @@ -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"; @@ -9,11 +9,21 @@ 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); @@ -21,7 +31,7 @@ export function createAccreditationRouter({ 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); @@ -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( @@ -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( diff --git a/src/routes/investor.routes.ts b/src/routes/investor.routes.ts index 87962385..0a76623c 100644 --- a/src/routes/investor.routes.ts +++ b/src/routes/investor.routes.ts @@ -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 { @@ -72,8 +71,6 @@ export function createInvestorRouter({ // ---- Risk Profile Management (issue #595) ---- if (riskProfileService) { - const riskProfileController = createInvestorRiskProfileController(riskProfileService); - router.post( "/risk-profile", auth, diff --git a/src/routes/invoices/refund.routes.ts b/src/routes/invoices/refund.routes.ts index a3a01af0..aff8b5fb 100644 --- a/src/routes/invoices/refund.routes.ts +++ b/src/routes/invoices/refund.routes.ts @@ -12,6 +12,10 @@ export interface RefundRouterDependencies { refundService: InvoiceRefundService; } +interface RefundInvoiceParamsRequest extends AuthenticatedRequest { + params: { invoiceId: string }; +} + export function createRefundRouter({ authService, refundService, @@ -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); @@ -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); } @@ -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); diff --git a/src/routes/invoices/submit.ts b/src/routes/invoices/submit.ts index b09a6ee7..28dbedb5 100644 --- a/src/routes/invoices/submit.ts +++ b/src/routes/invoices/submit.ts @@ -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, @@ -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 = { - 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; - }; - 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, @@ -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", diff --git a/src/routes/secondary-market/price-history.routes.ts b/src/routes/secondary-market/price-history.routes.ts index f7e2cda4..15bf1a71 100644 --- a/src/routes/secondary-market/price-history.routes.ts +++ b/src/routes/secondary-market/price-history.routes.ts @@ -10,6 +10,10 @@ export interface PriceHistoryRouterDependencies { priceService: SecondaryMarketPriceService; } +interface InvoicePriceHistoryRequest extends AuthenticatedRequest { + params: { invoiceId: string }; +} + export function createPriceHistoryRouter({ authService, priceService, @@ -20,13 +24,13 @@ export function createPriceHistoryRouter({ router.get( "/invoices/:invoiceId/price-history", auth, - async (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + async (req: InvoicePriceHistoryRequest, res: Response, next: NextFunction) => { try { const { invoiceId } = req.params; const range = (req.query.range as PriceRange) || "30d"; if (!["7d", "30d", "all"].includes(range)) { - throw new HttpError("Invalid range parameter", 400); + throw new HttpError(400, "Invalid range parameter"); } const { history, currentAvg } = await priceService.getPriceHistory( diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index ae0f70ec..e5d6f4c3 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -9,6 +9,7 @@ import { Investment } from "../models/Investment.model"; import { InvestorAcknowledgement } from "../models/InvestorAcknowledgement.model"; import { InvoiceStatus, InvestmentStatus } from "../types/enums"; import { ServiceError } from "../utils/service-error"; +import { isPastDueDate } from "../utils/date.utils"; import { Decimal } from "decimal.js"; import { getCurrentTermsVersion } from "./investor-acknowledgement.service"; import { @@ -278,7 +279,7 @@ export class InvestmentService { (investment.status === InvestmentStatus.PENDING || investment.status === InvestmentStatus.CONFIRMED) && invoice?.dueDate && - new Date(invoice.dueDate) < now + isPastDueDate(invoice.dueDate, now) ) { statusDistribution.overdue += 1; } @@ -436,13 +437,16 @@ export class InvestmentService { 422 ); } - if (invoice.dueDate && new Date(invoice.dueDate) < new Date()) { + if (invoice.dueDate && isPastDueDate(invoice.dueDate)) { throw new ServiceError( "invoice_expired", "Invoice has passed its due date and is no longer accepting investments", 422 ); } + if (invoice.fundingDeadline && new Date(invoice.fundingDeadline) < new Date()) { + throw new ServiceError("funding_deadline_passed", "Funding deadline has passed", 422); + } if (invoice.sellerId === investorId) { throw new ServiceError( "SELF_DEALING", @@ -708,13 +712,18 @@ export class InvestmentService { } // 3. Reject if the invoice has passed its due date - if (invoice.dueDate && new Date(invoice.dueDate) < new Date()) { + if (invoice.dueDate && isPastDueDate(invoice.dueDate)) { throw new ServiceError( "invoice_expired", "Invoice has passed its due date and is no longer accepting investments", 422 ); } + // 3b. Reject if the funding campaign's own deadline has passed, + // independent of the (later) repayment due date. + if (invoice.fundingDeadline && new Date(invoice.fundingDeadline) < new Date()) { + throw new ServiceError("funding_deadline_passed", "Funding deadline has passed", 422); + } // 4. Prevent self-dealing if (invoice.sellerId === investorId) { diff --git a/src/services/investor-accreditation.service.ts b/src/services/investor-accreditation.service.ts index 6d452df8..a10435e8 100644 --- a/src/services/investor-accreditation.service.ts +++ b/src/services/investor-accreditation.service.ts @@ -5,7 +5,6 @@ import { ACCREDITATION_LIMITS, } from "../models/InvestorAccreditation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import Decimal from "decimal.js"; export class InvestorAccreditationService { diff --git a/src/services/invoice-refund.service.ts b/src/services/invoice-refund.service.ts index 3899cec4..87db6bd4 100644 --- a/src/services/invoice-refund.service.ts +++ b/src/services/invoice-refund.service.ts @@ -1,4 +1,4 @@ -import { Repository, LessThan } from "typeorm"; +import { Repository, LessThan, In } from "typeorm"; import { Invoice } from "../models/Invoice.model"; import { Investment } from "../models/Investment.model"; import { InvoiceStatus } from "../types/enums"; @@ -44,7 +44,7 @@ export class InvoiceRefundService { const invoice = await this.invoiceRepo.findOne({ where: { id: invoiceId } }); if (!invoice) { - throw new ServiceError("Invoice not found", 404); + throw new ServiceError("invoice_not_found", "Invoice not found", 404); } const investment = await this.investmentRepo.findOne({ @@ -55,18 +55,18 @@ export class InvoiceRefundService { }); if (!investment) { - throw new ServiceError("Investment record not found", 404); + throw new ServiceError("investment_not_found", "Investment record not found", 404); } const { canRefund, reason } = await this.canRefund(invoiceId); if (!canRefund) { - throw new ServiceError(reason || "Cannot refund invoice", 400); + throw new ServiceError("refund_not_allowed", reason || "Cannot refund invoice", 400); } logger.info("Processing refund", { invoiceId, investorWallet: walletAddress, - amount: investment.amount, + amount: investment.investmentAmount, }); await this.investmentRepo.remove(investment); @@ -84,8 +84,18 @@ export class InvoiceRefundService { async getRefundableInvoices(walletAddress: string): Promise { const now = new Date(); + // Scope to invoices this wallet actually invested in - without this + // filter every investor's refund list leaked every other investor's + // expired-but-unfunded invoices. + const investments = await this.investmentRepo.find({ + where: { investorWallet: walletAddress }, + }); + const invoiceIds = [...new Set(investments.map((investment) => investment.invoiceId))]; + if (invoiceIds.length === 0) return []; + return this.invoiceRepo.find({ where: { + id: In(invoiceIds), fundingDeadline: LessThan(now), status: InvoiceStatus.PUBLISHED, }, diff --git a/src/services/invoice.service.ts b/src/services/invoice.service.ts index 5a9244be..ddb3c9d0 100644 --- a/src/services/invoice.service.ts +++ b/src/services/invoice.service.ts @@ -76,6 +76,14 @@ export interface CreateInvoiceInput { dueDate: Date; ipfsHash?: string; riskScore?: string; + /** Extra columns captured by the public self-serve submission flow. */ + sellerWallet?: string | null; + title?: string | null; + faceValue?: string | null; + fundingTarget?: string | null; + yieldBps?: number | null; + fundingDeadline?: Date | null; + ipfsDocumentUrl?: string | null; } export interface UpdateInvoiceInput { @@ -169,6 +177,13 @@ export interface InvoiceDTO { createdAt: Date; updatedAt: Date; commitments?: CommitmentDTO[]; + sellerWallet?: string | null; + title?: string | null; + faceValue?: string | null; + fundingTarget?: string | null; + yieldBps?: number | null; + fundingDeadline?: Date | null; + ipfsDocumentUrl?: string | null; } export interface GetInvoicesOptions { @@ -305,6 +320,13 @@ export class InvoiceService { ipfsHash: input.ipfsHash?.trim() || null, riskScore: input.riskScore?.trim() || null, status: InvoiceStatus.DRAFT, + sellerWallet: input.sellerWallet ?? null, + title: input.title?.trim() || null, + faceValue: input.faceValue ?? null, + fundingTarget: input.fundingTarget ?? null, + yieldBps: input.yieldBps ?? null, + fundingDeadline: input.fundingDeadline ?? null, + ipfsDocumentUrl: input.ipfsDocumentUrl?.trim() || null, } as Partial); const saved = await this.invoiceRepository.save(invoice); @@ -1192,6 +1214,13 @@ export class InvoiceService { rejectionReason: invoice.rejectionReason ?? null, createdAt: invoice.createdAt, updatedAt: invoice.updatedAt, + sellerWallet: invoice.sellerWallet ?? null, + title: invoice.title ?? null, + faceValue: invoice.faceValue ?? null, + fundingTarget: invoice.fundingTarget ?? null, + yieldBps: invoice.yieldBps ?? null, + fundingDeadline: invoice.fundingDeadline ?? null, + ipfsDocumentUrl: invoice.ipfsDocumentUrl ?? null, }; } } diff --git a/src/services/issuer-reputation.service.ts b/src/services/issuer-reputation.service.ts index a1d5595e..910a04e8 100644 --- a/src/services/issuer-reputation.service.ts +++ b/src/services/issuer-reputation.service.ts @@ -1,7 +1,6 @@ import { DataSource, EntityManager } from "typeorm"; import { IssuerReputation, IssuerReputationHistory, ReputationTier } from "../models/IssuerReputation.model"; import { Invoice } from "../models/Invoice.model"; -import { ServiceError } from "../utils/service-error"; import { logger } from "../observability/logger"; import Decimal from "decimal.js"; @@ -39,13 +38,6 @@ const TIER_THRESHOLDS = { POOR: 0, }; -const TIER_LABELS: Record = { - [TIER_THRESHOLDS.EXCELLENT]: "excellent", - [TIER_THRESHOLDS.GOOD]: "good", - [TIER_THRESHOLDS.FAIR]: "fair", - [TIER_THRESHOLDS.POOR]: "poor", -}; - export class IssuerReputationService { constructor(private readonly dataSource: DataSource) {} @@ -145,8 +137,6 @@ export class IssuerReputationService { let totalDaysEarly = 0; let lastSettledAt: Date | null = null; - const now = new Date(); - for (const invoice of invoices) { const dueDate = new Date(invoice.dueDate); const settledDate = invoice.updatedAt; // Using updatedAt as approximation of settlement date diff --git a/src/services/secondary-market-price.service.ts b/src/services/secondary-market-price.service.ts index e65718b1..26f139cc 100644 --- a/src/services/secondary-market-price.service.ts +++ b/src/services/secondary-market-price.service.ts @@ -1,6 +1,7 @@ import { Repository, Between, LessThan } from "typeorm"; import { SecondaryMarketPriceHistory, PriceHistoryPoint } from "../models/SecondaryMarketPriceHistory.model"; -import { SecondaryMarketListing } from "../models/SecondaryMarketListing.model"; +import { SecondaryListing } from "../models/SecondaryListing.model"; +import { ListingStatus } from "../types/enums"; import Decimal from "decimal.js"; import { logger } from "../observability/logger"; @@ -15,7 +16,7 @@ const RANGE_DAYS: Record = { export class SecondaryMarketPriceService { constructor( private priceHistoryRepo: Repository, - private listingRepo: Repository + private listingRepo: Repository ) {} async recordPrice( @@ -48,13 +49,13 @@ export class SecondaryMarketPriceService { const history = this.aggregatePrices(prices); const currentListings = await this.listingRepo.find({ - where: { invoiceId, soldAt: null as any }, + where: { invoiceId, status: ListingStatus.ACTIVE }, }); let currentAvg = new Decimal(0); if (currentListings.length > 0) { const sum = currentListings.reduce( - (acc, l) => acc.plus(l.askPrice || 0), + (acc, l) => acc.plus(l.pricePerFraction || 0), new Decimal(0) ); currentAvg = sum.dividedBy(currentListings.length); diff --git a/src/utils/date.utils.ts b/src/utils/date.utils.ts new file mode 100644 index 00000000..bea3e939 --- /dev/null +++ b/src/utils/date.utils.ts @@ -0,0 +1,32 @@ +/** + * Resolves a "date" column value (e.g. "2026-10-17") to the end of that + * calendar day in UTC (23:59:59.999Z), since Postgres `date` columns carry + * no time component and TypeORM returns them as bare YYYY-MM-DD strings. + * A value that already carries a time component is returned unchanged. + */ +export function endOfDayUTC(dueDate: Date | string): Date { + if (typeof dueDate === "string" && /^\d{4}-\d{2}-\d{2}$/.test(dueDate)) { + return new Date(`${dueDate}T23:59:59.999Z`); + } + + const date = new Date(dueDate); + const isMidnightUTC = + date.getUTCHours() === 0 && + date.getUTCMinutes() === 0 && + date.getUTCSeconds() === 0 && + date.getUTCMilliseconds() === 0; + + if (isMidnightUTC) { + date.setUTCHours(23, 59, 59, 999); + } + + return date; +} + +/** + * Checks whether a due date has passed, treating a date-only value as + * expiring at the end of that day (UTC) rather than at its start. + */ +export function isPastDueDate(dueDate: Date | string, referenceDate: Date = new Date()): boolean { + return endOfDayUTC(dueDate).getTime() < referenceDate.getTime(); +} diff --git a/tests/fee-schedule.test.ts b/tests/fee-schedule.test.ts index 2c92866c..8e320383 100644 --- a/tests/fee-schedule.test.ts +++ b/tests/fee-schedule.test.ts @@ -18,8 +18,11 @@ describe("FeeScheduleService", () => { }); beforeEach(async () => { - await dataSource.getRepository(FeeSchedule).clear(); + // FeeScheduleHistory.feeSchedule FKs back to FeeSchedule, so the child + // table must be cleared first or sqlite rejects deleting a still- + // referenced parent row left over from the previous test. await dataSource.getRepository(FeeScheduleHistory).clear(); + await dataSource.getRepository(FeeSchedule).clear(); }); describe("getCurrentFeeSchedule", () => { @@ -49,7 +52,10 @@ describe("FeeScheduleService", () => { }); it("should throw error when no active schedule exists", async () => { - await expect(feeScheduleService.getCurrentFeeSchedule()).rejects.toThrow("NO_ACTIVE_FEE_SCHEDULE"); + await expect(feeScheduleService.getCurrentFeeSchedule()).rejects.toMatchObject({ + code: "NO_ACTIVE_FEE_SCHEDULE", + statusCode: 404, + }); }); }); @@ -108,7 +114,7 @@ describe("FeeScheduleService", () => { "GDADMINWALLET", "admin" ) - ).rejects.toThrow("INVALID_FEE_RATE"); + ).rejects.toMatchObject({ code: "INVALID_FEE_RATE", statusCode: 400 }); }); it("should require at least one fee to update", async () => { @@ -125,7 +131,7 @@ describe("FeeScheduleService", () => { await expect( feeScheduleService.updateFeeSchedule({}, "GDADMINWALLET", "admin") - ).rejects.toThrow("NO_CHANGES"); + ).rejects.toMatchObject({ code: "NO_CHANGES", statusCode: 400 }); }); }); diff --git a/tests/helpers/test-data-source.ts b/tests/helpers/test-data-source.ts index 7886ba03..14f93466 100644 --- a/tests/helpers/test-data-source.ts +++ b/tests/helpers/test-data-source.ts @@ -1,4 +1,4 @@ -import { DataSource } from "typeorm"; +import { DataSource, getMetadataArgsStorage } from "typeorm"; import { FeeSchedule, FeeScheduleHistory } from "../../src/models/FeeSchedule.model"; import { IssuerReputation, IssuerReputationHistory } from "../../src/models/IssuerReputation.model"; import { InvestorRiskProfile } from "../../src/models/InvestorRiskProfile.model"; @@ -6,8 +6,34 @@ import { InvoiceDraft } from "../../src/models/InvoiceDraft.model"; import { User } from "../../src/models/User.model"; import { Invoice } from "../../src/models/Invoice.model"; import { Investment } from "../../src/models/Investment.model"; +import { Transaction } from "../../src/models/Transaction.model"; +import { KYCVerification } from "../../src/models/KYCVerification.model"; +import { KycHistory } from "../../src/models/KycHistory.model"; +import { Notification } from "../../src/models/Notification.model"; +import { SecondaryListing } from "../../src/models/SecondaryListing.model"; +import { Watchlist } from "../../src/models/Watchlist.model"; + +// Postgres-only column types (enum/jsonb/timestamptz) aren't in sqlite's +// supported type list, so TypeORM's metadata validator rejects them outright. +// Other integration tests in this repo work around it the same way: rewrite +// the column metadata in place before the sqlite DataSource is built. +function patchEntityMetadataForSQLite(): void { + const columns = getMetadataArgsStorage().columns; + for (const col of columns) { + if (col.options.type === "timestamptz") { + col.options.type = "datetime" as unknown as typeof col.options.type; + } + if (col.options.type === "jsonb") { + col.options.type = "text" as unknown as typeof col.options.type; + } + if (col.options.type === "enum") { + col.options.type = "varchar" as unknown as typeof col.options.type; + } + } +} export async function createTestDataSource(): Promise { + patchEntityMetadataForSQLite(); const dataSource = new DataSource({ type: "sqlite", database: ":memory:", @@ -21,6 +47,16 @@ export async function createTestDataSource(): Promise { User, Invoice, Investment, + // Every entity User, Invoice, or Investment reference by string name in + // a relation must be registered too, or TypeORM's metadata builder + // fails to resolve the inverse side (even for tests that never touch + // these tables). + Transaction, + KYCVerification, + KycHistory, + Notification, + SecondaryListing, + Watchlist, ], synchronize: false, logging: false, diff --git a/tests/investment.service.test.ts b/tests/investment.service.test.ts index 8d93854a..524527f7 100644 --- a/tests/investment.service.test.ts +++ b/tests/investment.service.test.ts @@ -125,6 +125,27 @@ describe("InvestmentService", () => { ); }); + it("should reject investment once the funding deadline has passed, even with a future due date", async () => { + const mockInvoice = { + ...getMockInvoice(), + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + fundingDeadline: new Date(Date.now() - 1000), + } as Invoice; + mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); + mockEntityManager.find.mockResolvedValue([]); + + const input = { + invoiceId: "invoice-1", + investorId: "investor-1", + investmentAmount: "100.0000", + investorWallet: INVESTOR_WALLET, + }; + + await expect(investmentService.createInvestment(input)).rejects.toThrow( + new ServiceError("funding_deadline_passed", "Funding deadline has passed", 422) + ); + }); + it("should prevent self-dealing", async () => { const mockInvoice = getMockInvoice(); mockQueryBuilder.getOne.mockResolvedValue(mockInvoice); diff --git a/tests/invoice-submission.test.ts b/tests/invoice-submission.test.ts index a299ae4a..35d82f95 100644 --- a/tests/invoice-submission.test.ts +++ b/tests/invoice-submission.test.ts @@ -54,8 +54,48 @@ describe("POST /invoices - Invoice Submission for Admin Review (Issue #448)", () adminNotificationQueue.clear(); process.env.JWT_SECRET = "test-secret"; + // Models the real InvoiceService: createInvoice persists a DRAFT, + // submitInvoiceForReview then drives it through the DRAFT -> PENDING + // state-machine transition. Keeping both steps (rather than stubbing a + // single submitInvoice call) is what this suite guards against + // regressing to the old direct-repository-write bypass. + let lastCreated: any; mockInvoiceService = { - createInvoice: jest.fn(), + createInvoice: jest.fn(async (input: any) => { + lastCreated = { + id: "invoice-id-123", + sellerId: input.sellerId, + invoiceNumber: input.invoiceNumber, + customerName: input.customerName, + issuerName: null, + description: input.description ?? null, + amount: input.amount, + discountRate: input.discountRate, + netAmount: input.amount, + dueDate: input.dueDate, + status: InvoiceStatus.DRAFT, + ipfsHash: input.ipfsHash ?? null, + riskScore: null, + smartContractId: null, + rejectionReason: null, + createdAt: new Date(), + updatedAt: new Date(), + sellerWallet: input.sellerWallet ?? null, + title: input.title ?? null, + faceValue: input.faceValue ?? null, + fundingTarget: input.fundingTarget ?? null, + yieldBps: input.yieldBps ?? null, + fundingDeadline: input.fundingDeadline ?? null, + ipfsDocumentUrl: input.ipfsDocumentUrl ?? null, + }; + return lastCreated; + }), + submitInvoiceForReview: jest.fn(async ({ invoiceId, sellerId }: any) => { + if (!lastCreated || lastCreated.id !== invoiceId || lastCreated.sellerId !== sellerId) { + throw new Error("invoice not found"); + } + return { ...lastCreated, status: InvoiceStatus.PENDING }; + }), getInvoiceById: jest.fn(), getInvoicesBySellerId: jest.fn(), updateInvoice: jest.fn(), @@ -102,6 +142,16 @@ describe("POST /invoices - Invoice Submission for Admin Review (Issue #448)", () expect(res.body.data.fundingTarget).toBe("47500"); expect(res.body.data.faceValue).toBe("50000"); + // Regression guard for the state-machine bypass: the handler must drive + // the invoice through InvoiceService rather than writing the entity + // straight to the repository. + expect(mockInvoiceService.createInvoice).toHaveBeenCalledTimes(1); + expect(mockInvoiceService.submitInvoiceForReview).toHaveBeenCalledTimes(1); + expect(mockInvoiceService.submitInvoiceForReview).toHaveBeenCalledWith({ + invoiceId: "invoice-id-123", + sellerId, + }); + // Check admin notification queue received event const events = adminNotificationQueue.getEvents("invoice_submitted"); expect(events.length).toBe(1); diff --git a/tests/unit/invoice-invest-endpoint.test.ts b/tests/unit/invoice-invest-endpoint.test.ts index bef172bb..778457da 100644 --- a/tests/unit/invoice-invest-endpoint.test.ts +++ b/tests/unit/invoice-invest-endpoint.test.ts @@ -469,6 +469,53 @@ describe("POST /invoices/:id/invest (#465)", () => { ).rejects.toMatchObject({ code, statusCode }); }); + it("accepts an investment on a date-only due date before that day has fully elapsed", async () => { + // Postgres `date` columns come back from TypeORM as a bare "YYYY-MM-DD" + // string, which `new Date(...)` parses as that day's midnight UTC. + // Comparing against "now" directly would mark the invoice expired as + // soon as the due date arrives, hours before the day is actually over. + const todayUTC = new Date().toISOString().slice(0, 10); + const invoice = makeInvoice({ dueDate: todayUTC as unknown as Date }); + + await expect( + createService(createFakeDatabase(invoice)).investInInvoice(investInput(invoice)) + ).resolves.toBeDefined(); + }); + + it("rejects an investment once a date-only due date's day has fully elapsed", async () => { + const yesterdayUTC = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + const invoice = makeInvoice({ dueDate: yesterdayUTC as unknown as Date }); + + await expect( + createService(createFakeDatabase(invoice)).investInInvoice(investInput(invoice)) + ).rejects.toMatchObject({ code: "invoice_expired", statusCode: 422 }); + }); + + it("rejects an investment once the funding deadline has passed, even with a future due date", async () => { + // dueDate (repayment) is still a week out, but the funding campaign's + // own deadline already closed - investInInvoice only checked dueDate, + // letting investors buy into a campaign that should have been closed. + const invoice = makeInvoice({ + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + fundingDeadline: new Date(Date.now() - 1000), + }); + + await expect( + createService(createFakeDatabase(invoice)).investInInvoice(investInput(invoice)) + ).rejects.toMatchObject({ code: "funding_deadline_passed", statusCode: 422 }); + }); + + it("accepts an investment when the funding deadline is still open", async () => { + const invoice = makeInvoice({ + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + fundingDeadline: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + + await expect( + createService(createFakeDatabase(invoice)).investInInvoice(investInput(invoice)) + ).resolves.toBeDefined(); + }); + it("rejects the seller investing in their own invoice", async () => { const invoice = makeInvoice(); await expect( diff --git a/tests/unit/invoice.model.test.ts b/tests/unit/invoice.model.test.ts index 36d85d68..c6451487 100644 --- a/tests/unit/invoice.model.test.ts +++ b/tests/unit/invoice.model.test.ts @@ -246,6 +246,23 @@ describe("Invoice Model", () => { expect(Invoice.isOverdue(invoice)).toBe(false); }); + it("does not mark a date-only due date as overdue at the start of its own day", () => { + // TypeORM returns a `date` column as a bare "YYYY-MM-DD" string at runtime. + const invoice = new Invoice(); + invoice.dueDate = "2026-10-17" as unknown as Date; + const earlyMorningSameDay = new Date("2026-10-17T00:30:00.000Z"); + + expect(Invoice.isOverdue(invoice, earlyMorningSameDay)).toBe(false); + }); + + it("marks a date-only due date as overdue once its day has fully elapsed", () => { + const invoice = new Invoice(); + invoice.dueDate = "2026-10-17" as unknown as Date; + const nextDay = new Date("2026-10-18T00:00:01.000Z"); + + expect(Invoice.isOverdue(invoice, nextDay)).toBe(true); + }); + it("computes funding runway in hours accurately", () => { const invoice = new Invoice(); const reference = new Date("2026-01-01T00:00:00.000Z");