From 72ddc8db51dfbcead3d94ef0abf436bdc6fa00d0 Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Sat, 17 Oct 2026 17:34:06 +0100 Subject: [PATCH 1/5] fix: compare invoice due dates against end-of-day, not midnight 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 that directly against "now" in investInInvoice, createInvestment, and the investor analytics overdue calc closed invoices for investment at the start of their due date instead of at the end of it. Add src/utils/date.utils.ts (isPastDueDate/endOfDayUTC) and use it consistently across investment.service.ts and Invoice.model.ts's isOverdue/isExpired, which shared the same bug. Co-Authored-By: Claude Sonnet 5 --- src/models/Invoice.model.ts | 6 ++-- src/services/investment.service.ts | 7 +++-- src/utils/date.utils.ts | 32 ++++++++++++++++++++++ tests/unit/invoice-invest-endpoint.test.ts | 22 +++++++++++++++ tests/unit/invoice.model.test.ts | 17 ++++++++++++ 5 files changed, 78 insertions(+), 6 deletions(-) create mode 100644 src/utils/date.utils.ts 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/services/investment.service.ts b/src/services/investment.service.ts index ae0f70ec..75ce671b 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,7 +437,7 @@ 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", @@ -708,7 +709,7 @@ 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", 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/unit/invoice-invest-endpoint.test.ts b/tests/unit/invoice-invest-endpoint.test.ts index bef172bb..a67f49cf 100644 --- a/tests/unit/invoice-invest-endpoint.test.ts +++ b/tests/unit/invoice-invest-endpoint.test.ts @@ -469,6 +469,28 @@ 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 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"); From ab4a040216e37d560a0c8581db07feeabca6e070 Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Sat, 17 Oct 2026 17:40:28 +0100 Subject: [PATCH 2/5] fix: route invoice submission through InvoiceService state machine POST /invoices (self-serve submission) checked for an InvoiceService. submitInvoice method that doesn't exist, so it always fell through to a fallback that wrote the entity straight to the repository. That bypassed InvoiceStateMachine entirely: no transition validation, no invoice_status_history row, and none of the transition side effects ran. submit.ts now calls InvoiceService.createInvoice (drafts the invoice) followed by submitInvoiceForReview (drives the draft -> pending transition through the state machine, with its guard, history and notification side effects). Extend CreateInvoiceInput/InvoiceDTO with the sellerWallet/title/faceValue/fundingTarget/yieldBps/fundingDeadline/ ipfsDocumentUrl columns the self-serve flow captures, which createInvoice previously had no way to persist. Also fix the error handler to read ServiceError's statusCode (it was reading a nonexistent `status` field, so every service error surfaced as a 500). Co-Authored-By: Claude Sonnet 5 --- src/routes/invoices/submit.ts | 68 ++++++++++++-------------------- src/services/invoice.service.ts | 29 ++++++++++++++ tests/invoice-submission.test.ts | 52 +++++++++++++++++++++++- 3 files changed, 106 insertions(+), 43 deletions(-) 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/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/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); From 2e764419b4ace0304b07d4c2387d22492c3493db Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Sat, 17 Oct 2026 17:45:43 +0100 Subject: [PATCH 3/5] fix: reject investments after an invoice's funding deadline passes investInInvoice and createInvestment only checked invoice.dueDate (the repayment date) to decide whether an invoice could still accept investment, ignoring invoice.fundingDeadline (the funding campaign's own cutoff). A long-tenor invoice could have its funding deadline pass while dueDate was still months out, letting investors keep buying into a closed campaign. Add a fundingDeadline check alongside the existing dueDate check in both methods, throwing ServiceError("funding_deadline_passed", ..., 422) when it has passed. fundingDeadline is a `timestamptz` column (unlike the `date`-typed dueDate), so a plain Date comparison is correct here. Co-Authored-By: Claude Sonnet 5 # Conflicts: # tests/unit/invoice-invest-endpoint.test.ts --- src/services/investment.service.ts | 8 +++++++ tests/investment.service.test.ts | 21 ++++++++++++++++++ tests/unit/invoice-invest-endpoint.test.ts | 25 ++++++++++++++++++++++ 3 files changed, 54 insertions(+) diff --git a/src/services/investment.service.ts b/src/services/investment.service.ts index 75ce671b..e5d6f4c3 100644 --- a/src/services/investment.service.ts +++ b/src/services/investment.service.ts @@ -444,6 +444,9 @@ export class InvestmentService { 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", @@ -716,6 +719,11 @@ export class InvestmentService { 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/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/unit/invoice-invest-endpoint.test.ts b/tests/unit/invoice-invest-endpoint.test.ts index a67f49cf..778457da 100644 --- a/tests/unit/invoice-invest-endpoint.test.ts +++ b/tests/unit/invoice-invest-endpoint.test.ts @@ -491,6 +491,31 @@ describe("POST /invoices/:id/invest (#465)", () => { ).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( From f323c8f297c48e87b44f052a2874e175c9c7b956 Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Sat, 17 Oct 2026 17:49:44 +0100 Subject: [PATCH 4/5] fix: index all deployed Soroban contracts, not just the escrow contract EventIndexerService was constructed with contractIds: [config.sorobanEscrow.contractId] only. The read-model projections wired onto its eventBus (creator key, ACL, curve migration, atomic swap, royalty, dividend) dispatch by event topic rather than contract id, so they only ever see an event if the contract that emits it is actually polled - and none of them are emitted by the escrow contract alone. In production this meant those projections never updated. Build contractIds from every configured Soroban contract address (escrow, payment distributor, subscription, token) via the sorobanConfig already loaded earlier in this function, deduplicated, and gate the indexer on at least one of them being present. Co-Authored-By: Claude Sonnet 5 --- src/index.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) 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, From 4cc9ffaed0d91ab1bf3ea5290ee09f2523d919fc Mon Sep 17 00:00:00 2001 From: The-Big-Danny Date: Sat, 17 Oct 2026 18:33:52 +0100 Subject: [PATCH 5/5] fix: resolve pre-existing build/lint/test failures blocking CI PR #653's 4 CI checks (Build, Lint, Type Check, Test) were failing, but verification showed dev itself is red on the identical errors at the same commit - unrelated to the 4 invoice/investment fixes in this PR. Fixing them here so the PR can go green rather than leaving dev broken. Type Check / Build: - InvestorAccreditation.model.ts, SecondaryMarketPriceHistory.model.ts: add missing `!` definite-assignment on TypeORM columns. - secondary-market-price.service.ts: fix the entity/field references to point at the real SecondaryListing model (askPrice/soldAt never existed; the actual fields are pricePerFraction/status). - accreditation.routes.ts, refund.routes.ts, price-history.routes.ts: Express 5's ParamsDictionary types a route param as `string | string[]` to support wildcard segments, so narrow req.params to the route's own shape per this repo's existing PublishInvoiceRequest convention; fix HttpError's swapped (message, status) call sites to (status, message); fix req.user?.wallet (no such field) to req.user?.stellarAddress; requireAdmin() missing its adminWallets argument. - invoice-refund.service.ts: ServiceError(message, status) swapped to (code, message, status); Investment has no `amount` field, only investmentAmount. Also fixed getRefundableInvoices, which returned every expired invoice platform-wide regardless of the wallet argument it was given - a real cross-investor data leak, not just a type error. Lint: removed 9 unused imports/vars across 6 files (investor-risk-profile .controller.ts, invoice-draft.controller.ts, investor.routes.ts, investor-accreditation.service.ts, invoice-refund.service.ts, issuer-reputation.service.ts, including a dead TIER_LABELS map that duplicated computeTier's logic). Test: tests/fee-schedule.test.ts's DataSource never initialized - User's relations reference Transaction/KYCVerification/KycHistory/ Notification/SecondaryListing/Watchlist by string name, none of which were registered in tests/helpers/test-data-source.ts, so TypeORM's metadata builder couldn't resolve the inverse side. Registering them surfaced a second, pre-existing issue: FeeSchedule's enum/timestamptz columns aren't valid sqlite types, worked around the same way nine other integration tests in this repo already do (rewriting column metadata before the DataSource is built). That in turn surfaced two more pre-existing test bugs once they could finally run: beforeEach cleared the FK parent (FeeSchedule) before the child (FeeScheduleHistory), and three assertions checked error.message for a ServiceError's `code` instead of its `message`. Co-Authored-By: Claude Sonnet 5 --- .../investor-risk-profile.controller.ts | 1 - src/controllers/invoice-draft.controller.ts | 3 +- src/models/InvestorAccreditation.model.ts | 14 +++---- .../SecondaryMarketPriceHistory.model.ts | 12 +++--- src/routes/accreditation.routes.ts | 22 ++++++++--- src/routes/investor.routes.ts | 3 -- src/routes/invoices/refund.routes.ts | 18 +++++---- .../secondary-market/price-history.routes.ts | 8 +++- .../investor-accreditation.service.ts | 1 - src/services/invoice-refund.service.ts | 20 +++++++--- src/services/issuer-reputation.service.ts | 10 ----- .../secondary-market-price.service.ts | 9 +++-- tests/fee-schedule.test.ts | 14 +++++-- tests/helpers/test-data-source.ts | 38 ++++++++++++++++++- 14 files changed, 114 insertions(+), 59 deletions(-) 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/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/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/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/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/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/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,