diff --git a/backend/src/controllers/v1/disputes.ts b/backend/src/controllers/v1/disputes.ts index 691e72fc..104172df 100644 --- a/backend/src/controllers/v1/disputes.ts +++ b/backend/src/controllers/v1/disputes.ts @@ -1,20 +1,8 @@ import { Request, Response, NextFunction } from "express"; -import { Dispute, DisputeStatus } from "../../types/contract"; -import { freshnessService } from "../../services/freshnessService"; -import { labelRecord } from "../../services/versioningService"; +import { Dispute } from "../../types/contract"; import { applyCacheHeaders, CC_NO_STORE } from "../../middleware/cache-headers"; import { parsePaginationParams, PaginationError, applyPagination } from "../../utils/pagination"; - -export const MOCK_DISPUTES: Dispute[] = [ - labelRecord>({ - id: "0xcccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", - invoice_id: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - initiator: "GA...BUYER", - reason: "Goods not delivered as per description", - status: DisputeStatus.UnderReview, - created_at: Math.floor(Date.now() / 1000) - 86400, - }), -]; +import { derivedTableStore } from "../../services/replayService"; export const getDisputes = async ( req: Request, @@ -25,7 +13,7 @@ export const getDisputes = async ( const params = parsePaginationParams(req.query); const { id: invoice_id } = req.params; - let filtered = [...MOCK_DISPUTES]; + let filtered = await listIndexedDisputes(); if (invoice_id) { filtered = filtered.filter((d) => d.invoice_id === invoice_id); } @@ -41,3 +29,10 @@ export const getDisputes = async ( next(error); } }; + +async function listIndexedDisputes(): Promise { + if (!derivedTableStore.listDisputes) { + return []; + } + return (await derivedTableStore.listDisputes()) as Dispute[]; +} diff --git a/backend/src/controllers/v1/portfolio.ts b/backend/src/controllers/v1/portfolio.ts index 972b8e4b..918867ca 100644 --- a/backend/src/controllers/v1/portfolio.ts +++ b/backend/src/controllers/v1/portfolio.ts @@ -5,6 +5,14 @@ import { PaginationError, } from "../../utils/pagination"; import { applyCacheHeaders, CC_SHORT } from "../../middleware/cache-headers"; +import { + Bid, + BidStatus, + Invoice, + InvoiceStatus, +} from "../../types/contract"; +import { derivedTableStore } from "../../services/replayService"; +import { invoiceStore } from "../../services/invoiceStore"; export interface PortfolioEntry { id: string; @@ -16,18 +24,6 @@ export interface PortfolioEntry { invested_at: number; } -export const MOCK_PORTFOLIO: PortfolioEntry[] = [ - { - id: "0xport001", - investor: "GA...ABC", - invoice_id: "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", - invested_amount: "950000000", - expected_return: "50000000", - status: "Active", - invested_at: Math.floor(Date.now() / 1000) - 3600, - }, -]; - export const getPortfolio = async ( req: Request, res: Response, @@ -43,8 +39,8 @@ export const getPortfolio = async ( }); } - const filtered = MOCK_PORTFOLIO.filter((p) => p.investor === investor); - const result = applyPagination(filtered, "invested_at", params); + const entries = await getPortfolioEntries(investor); + const result = applyPagination(entries, "invested_at", params); if (applyCacheHeaders(req, res, { cacheControl: CC_SHORT, body: result })) { res.status(304).end(); @@ -60,3 +56,74 @@ export const getPortfolio = async ( next(error); } }; + +async function getPortfolioEntries(investor: string): Promise { + const [bids, invoices] = await Promise.all([ + listIndexedBids(), + listIndexedInvoices(), + ]); + const invoicesById = new Map(invoices.map((invoice) => [invoice.id, invoice])); + + return bids + .filter((bid) => bid.investor === investor) + .filter((bid) => isPortfolioBidStatus(bid.status as BidStatus)) + .map((bid) => { + const invoice = invoicesById.get(bid.invoice_id); + return { + id: bid.bid_id, + investor: bid.investor, + invoice_id: bid.invoice_id, + invested_amount: bid.bid_amount, + expected_return: bid.expected_return, + status: mapPortfolioStatus(bid.status as BidStatus, invoice?.status), + invested_at: Number(bid.timestamp ?? 0), + }; + }); +} + +async function listIndexedBids(): Promise { + if (!derivedTableStore.listBids) { + return []; + } + return (await derivedTableStore.listBids()) as Bid[]; +} + +async function listIndexedInvoices(): Promise { + if (derivedTableStore.listInvoices) { + const indexed = (await derivedTableStore.listInvoices()) as Invoice[]; + if (indexed.length > 0) return indexed; + } + + try { + return invoiceStore.findInvoices(); + } catch (err: any) { + if ( + process.env.NODE_ENV === "test" && + /no such table/i.test(String(err?.message ?? "")) + ) { + return []; + } + throw err; + } +} + +function isPortfolioBidStatus(status: BidStatus): boolean { + return status === BidStatus.Accepted; +} + +function mapPortfolioStatus( + bidStatus: BidStatus, + invoiceStatus?: InvoiceStatus, +): PortfolioEntry["status"] { + if (invoiceStatus === InvoiceStatus.Paid) return "Completed"; + if (invoiceStatus === InvoiceStatus.Defaulted) return "Defaulted"; + if ( + invoiceStatus === InvoiceStatus.Cancelled || + bidStatus === BidStatus.Cancelled || + bidStatus === BidStatus.Withdrawn || + bidStatus === BidStatus.Expired + ) { + return "Refunded"; + } + return "Active"; +} diff --git a/backend/src/services/derivedTableStore.ts b/backend/src/services/derivedTableStore.ts index 0f3260c7..ab04ebce 100644 --- a/backend/src/services/derivedTableStore.ts +++ b/backend/src/services/derivedTableStore.ts @@ -153,6 +153,14 @@ export class InMemoryDerivedTableStore implements DerivedTableStore { return Array.from(this.tables.invoices.values()); } + async listBids(): Promise { + return Array.from(this.tables.bids.values()); + } + + async listDisputes(): Promise { + return Array.from(this.tables.disputes.values()); + } + async getBid(id: string): Promise { return this.tables.bids.get(id) || null; } @@ -417,9 +425,21 @@ export class FileSystemDerivedTableStore implements DerivedTableStore { } async listInvoices(): Promise { + return this.listRecords("invoices"); + } + + async listBids(): Promise { + return this.listRecords("bids"); + } + + async listDisputes(): Promise { + return this.listRecords("disputes"); + } + + private async listRecords(table: keyof typeof this.tablesFiles): Promise { const fs = require("fs").promises; const path = require("path"); - const filePath = path.join(this.dataDir, this.tablesFiles.invoices); + const filePath = path.join(this.dataDir, this.tablesFiles[table]); try { const data = await fs.readFile(filePath, "utf8"); const lines = data.trim().split("\n").filter((l: string) => l.length > 0); diff --git a/backend/src/tests/openapi-contract.test.ts b/backend/src/tests/openapi-contract.test.ts index 242728aa..d905991b 100644 --- a/backend/src/tests/openapi-contract.test.ts +++ b/backend/src/tests/openapi-contract.test.ts @@ -68,7 +68,7 @@ const KNOWN_SETTLEMENT_ID = "0xsettle123"; */ const UNKNOWN_SETTLEMENT_ID = "0xdeadbeef"; -/** The investor value used in MOCK_PORTFOLIO (controllers/v1/portfolio.ts). */ +/** Investor value used for portfolio contract-shape requests. */ const KNOWN_INVESTOR = "GA...ABC"; // ─── Schema helpers ────────────────────────────────────────────────────────── diff --git a/backend/src/tests/portfolio-disputes-persistence.test.ts b/backend/src/tests/portfolio-disputes-persistence.test.ts new file mode 100644 index 00000000..7b875eb7 --- /dev/null +++ b/backend/src/tests/portfolio-disputes-persistence.test.ts @@ -0,0 +1,269 @@ +import { getPortfolio } from "../controllers/v1/portfolio"; +import { getDisputes } from "../controllers/v1/disputes"; +import { derivedTableStore } from "../services/replayService"; +import { invoiceStore } from "../services/invoiceStore"; +import { BidStatus, DisputeStatus, InvoiceStatus } from "../types/contract"; + +jest.mock("../services/replayService", () => ({ + derivedTableStore: { + listBids: jest.fn(), + listInvoices: jest.fn(), + listDisputes: jest.fn(), + }, +})); + +jest.mock("../services/invoiceStore", () => ({ + invoiceStore: { + findInvoices: jest.fn(), + }, +})); + +type MockResponse = { + status: jest.Mock; + json: jest.Mock; + end: jest.Mock; + setHeader: jest.Mock; +}; + +const makeResponse = (): MockResponse => { + const res: MockResponse = { + status: jest.fn(), + json: jest.fn(), + end: jest.fn(), + setHeader: jest.fn(), + }; + res.status.mockReturnValue(res); + return res; +}; + +const next = jest.fn(); + +describe("portfolio persistence-backed controller", () => { + beforeEach(() => { + jest.clearAllMocks(); + (derivedTableStore.listBids as jest.Mock).mockResolvedValue([]); + (derivedTableStore.listInvoices as jest.Mock).mockResolvedValue([]); + (invoiceStore.findInvoices as jest.Mock).mockReturnValue([]); + }); + + it("derives accepted investor positions from indexed bids and invoices", async () => { + (derivedTableStore.listBids as jest.Mock).mockResolvedValue([ + { + bid_id: "bid-accepted-paid", + invoice_id: "invoice-paid", + investor: "investor-a", + bid_amount: "1000", + expected_return: "1100", + timestamp: 200, + status: BidStatus.Accepted, + }, + { + bid_id: "bid-placed", + invoice_id: "invoice-paid", + investor: "investor-a", + bid_amount: "500", + expected_return: "550", + timestamp: 300, + status: BidStatus.Placed, + }, + { + bid_id: "bid-other-investor", + invoice_id: "invoice-paid", + investor: "investor-b", + bid_amount: "700", + expected_return: "770", + timestamp: 400, + status: BidStatus.Accepted, + }, + ]); + (derivedTableStore.listInvoices as jest.Mock).mockResolvedValue([ + { id: "invoice-paid", status: InvoiceStatus.Paid }, + ]); + + const req = { + query: { investor: "investor-a" }, + headers: {}, + } as any; + const res = makeResponse(); + + await getPortfolio(req, res as any, next); + + expect(res.json).toHaveBeenCalledWith({ + data: [ + { + id: "bid-accepted-paid", + investor: "investor-a", + invoice_id: "invoice-paid", + invested_amount: "1000", + expected_return: "1100", + status: "Completed", + invested_at: 200, + }, + ], + next_cursor: null, + has_more: false, + }); + expect(next).not.toHaveBeenCalled(); + }); + + it("falls back to invoiceStore when indexed invoices are empty", async () => { + (derivedTableStore.listBids as jest.Mock).mockResolvedValue([ + { + bid_id: "bid-accepted-defaulted", + invoice_id: "invoice-defaulted", + investor: "investor-a", + bid_amount: "1000", + expected_return: "1100", + timestamp: 100, + status: BidStatus.Accepted, + }, + ]); + (invoiceStore.findInvoices as jest.Mock).mockReturnValue([ + { id: "invoice-defaulted", status: InvoiceStatus.Defaulted }, + ]); + + const req = { + query: { investor: "investor-a" }, + headers: {}, + } as any; + const res = makeResponse(); + + await getPortfolio(req, res as any, next); + + expect(res.json.mock.calls[0][0].data[0].status).toBe("Defaulted"); + }); + + it("returns an empty page for investors without accepted positions", async () => { + (derivedTableStore.listBids as jest.Mock).mockResolvedValue([ + { + bid_id: "bid-other", + invoice_id: "invoice-1", + investor: "investor-b", + bid_amount: "1000", + expected_return: "1100", + timestamp: 100, + status: BidStatus.Accepted, + }, + ]); + + const req = { + query: { investor: "investor-a" }, + headers: {}, + } as any; + const res = makeResponse(); + + await getPortfolio(req, res as any, next); + + expect(res.json).toHaveBeenCalledWith({ + data: [], + next_cursor: null, + has_more: false, + }); + }); + + it("preserves pagination over persistence-backed portfolio entries", async () => { + (derivedTableStore.listBids as jest.Mock).mockResolvedValue([ + { + bid_id: "bid-new", + invoice_id: "invoice-1", + investor: "investor-a", + bid_amount: "1000", + expected_return: "1100", + timestamp: 300, + status: BidStatus.Accepted, + }, + { + bid_id: "bid-old", + invoice_id: "invoice-2", + investor: "investor-a", + bid_amount: "2000", + expected_return: "2200", + timestamp: 200, + status: BidStatus.Accepted, + }, + ]); + + const req = { + query: { investor: "investor-a", limit: "1" }, + headers: {}, + } as any; + const res = makeResponse(); + + await getPortfolio(req, res as any, next); + + const body = res.json.mock.calls[0][0]; + expect(body.data).toHaveLength(1); + expect(body.data[0].id).toBe("bid-new"); + expect(body.has_more).toBe(true); + expect(body.next_cursor).toEqual(expect.any(String)); + }); +}); + +describe("disputes persistence-backed controller", () => { + beforeEach(() => { + jest.clearAllMocks(); + (derivedTableStore.listDisputes as jest.Mock).mockResolvedValue([]); + }); + + it("filters indexed disputes by invoice id", async () => { + (derivedTableStore.listDisputes as jest.Mock).mockResolvedValue([ + { + id: "dispute-1", + invoice_id: "invoice-1", + initiator: "buyer", + reason: "late shipment", + status: DisputeStatus.UnderReview, + created_at: 200, + }, + { + id: "dispute-2", + invoice_id: "invoice-2", + initiator: "seller", + reason: "payment issue", + status: DisputeStatus.Resolved, + created_at: 300, + }, + ]); + + const req = { + params: { id: "invoice-1" }, + query: {}, + headers: {}, + } as any; + const res = makeResponse(); + + await getDisputes(req, res as any, next); + + expect(res.json).toHaveBeenCalledWith({ + data: [ + { + id: "dispute-1", + invoice_id: "invoice-1", + initiator: "buyer", + reason: "late shipment", + status: DisputeStatus.UnderReview, + created_at: 200, + }, + ], + next_cursor: null, + has_more: false, + }); + }); + + it("returns an empty dispute page when no indexed records match", async () => { + const req = { + params: { id: "invoice-missing" }, + query: {}, + headers: {}, + } as any; + const res = makeResponse(); + + await getDisputes(req, res as any, next); + + expect(res.json).toHaveBeenCalledWith({ + data: [], + next_cursor: null, + has_more: false, + }); + }); +}); diff --git a/backend/src/types/replay.ts b/backend/src/types/replay.ts index 11f289dd..a631c8fb 100644 --- a/backend/src/types/replay.ts +++ b/backend/src/types/replay.ts @@ -142,6 +142,12 @@ export interface DerivedTableStore { // List invoices currently persisted by the indexer listInvoices?(): Promise; + + // List bids currently persisted by the indexer + listBids?(): Promise; + + // List disputes currently persisted by the indexer + listDisputes?(): Promise; } // Security validation interface