From 9779868c3a653ff3c089362d82744e5ff64f21d3 Mon Sep 17 00:00:00 2001 From: Emmy123222 Date: Sat, 28 Mar 2026 07:05:54 +0000 Subject: [PATCH] feat: add marketplace API for investor discovery - Add GET /api/v1/marketplace/invoices endpoint for public invoice browsing - Implement comprehensive filtering: status, amount range, due date, sorting - Add stable pagination with page, limit, and total metadata - Expose only investor-relevant fields, hide seller PII and internal data - Support unauthenticated access to reduce investment discovery friction - Add comprehensive test coverage (27 tests) for service, routes, repository - Include detailed API documentation with usage examples and privacy rationale - Implement efficient database queries with proper indexing strategy Public fields: id, invoiceNumber, customerName, amount, discountRate, netAmount, dueDate, status, createdAt Private fields: sellerId, ipfsHash, riskScore, smartContractId, updatedAt, deletedAt Closes #4 --- Readme.md | 13 +- docs/MARKETPLACE_API.md | 192 ++++++++++++++++ package-lock.json | 8 +- src/app.ts | 11 + src/controllers/marketplace.controller.ts | 92 ++++++++ src/index.ts | 3 + src/routes/marketplace.routes.ts | 19 ++ src/services/marketplace.service.ts | 191 +++++++++++++++ tests/marketplace.repository.test.ts | 224 ++++++++++++++++++ tests/marketplace.routes.test.ts | 268 ++++++++++++++++++++++ tests/marketplace.service.test.ts | 196 ++++++++++++++++ 11 files changed, 1207 insertions(+), 10 deletions(-) create mode 100644 docs/MARKETPLACE_API.md create mode 100644 src/controllers/marketplace.controller.ts create mode 100644 src/routes/marketplace.routes.ts create mode 100644 src/services/marketplace.service.ts create mode 100644 tests/marketplace.repository.test.ts create mode 100644 tests/marketplace.routes.test.ts create mode 100644 tests/marketplace.service.test.ts diff --git a/Readme.md b/Readme.md index 9338438..7c5e3ea 100644 --- a/Readme.md +++ b/Readme.md @@ -148,6 +148,13 @@ POST /api/invoices/:id/publish # Publish to marketplace POST /api/invoices/:id/payment # Record payment ``` +### Marketplace +``` +GET /api/v1/marketplace/invoices # Browse published invoices (public, no auth required) +``` + +**Note**: The marketplace endpoint provides public read access to published invoices for investor discovery. See [docs/MARKETPLACE_API.md](docs/MARKETPLACE_API.md) for detailed documentation. + ### Investments ``` GET /api/investments # List user investments @@ -155,12 +162,6 @@ POST /api/investments # Invest in invoice GET /api/investments/:id # Get investment details ``` -### Marketplace -``` -GET /api/marketplace # Browse available invoices -GET /api/marketplace/stats # Market statistics -``` - ### Dashboard ``` GET /api/dashboard/seller # Seller analytics diff --git a/docs/MARKETPLACE_API.md b/docs/MARKETPLACE_API.md new file mode 100644 index 0000000..57d2c7a --- /dev/null +++ b/docs/MARKETPLACE_API.md @@ -0,0 +1,192 @@ +# Marketplace API Documentation + +The Marketplace API provides a read-only interface for investors to discover published receivables (invoices) available for investment. This decentralized marketplace allows investors to browse investment opportunities without exposing unnecessary seller PII. + +## Overview + +The marketplace endpoint allows unauthenticated access to published invoices, providing essential investment signals like discount rates, amounts, and due dates while protecting sensitive seller information. + +## API Endpoint + +### List Published Invoices + +**GET** `/api/v1/marketplace/invoices` + +Returns a paginated list of invoices available for investment. + +#### Authentication +- **No authentication required** - Public read access for published invoices only +- This reduces friction for investors to discover opportunities + +#### Query Parameters + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `page` | integer | 1 | Page number (min: 1) | +| `limit` | integer | 20 | Items per page (min: 1, max: 100) | +| `status` | string/array | `["published"]` | Invoice status filter | +| `dueBefore` | ISO date | - | Filter invoices due before this date | +| `minAmount` | number | - | Minimum invoice amount | +| `maxAmount` | number | - | Maximum invoice amount | +| `sort` | string | `"due_date"` | Sort field: `due_date`, `discount_rate`, `amount`, `created_at` | +| `sortOrder` | string | `"ASC"` | Sort order: `ASC` or `DESC` | + +#### Response Format + +**Success (200)** +```json +{ + "success": true, + "data": [ + { + "id": "uuid", + "invoiceNumber": "INV-001", + "customerName": "Customer Corp", + "amount": "10000.00", + "discountRate": "5.50", + "netAmount": "9450.00", + "dueDate": "2024-12-31T00:00:00.000Z", + "status": "published", + "createdAt": "2024-01-15T10:30:00.000Z" + } + ], + "meta": { + "total": 150, + "page": 1, + "limit": 20, + "totalPages": 8 + } +} +``` + +**Error Responses** +- `400` - Invalid query parameters +- `500` - Server error + +## Public Fields + +The API exposes only investor-relevant fields while protecting seller privacy: + +### ✅ **Public Fields (Exposed)** +- `id` - Invoice identifier for investment references +- `invoiceNumber` - Public invoice reference +- `customerName` - Debtor information (relevant for credit assessment) +- `amount` - Full invoice value +- `discountRate` - Discount percentage offered +- `netAmount` - Amount after discount (what seller receives) +- `dueDate` - Payment due date (tenor information) +- `status` - Current invoice status +- `createdAt` - When invoice was created + +### ❌ **Private Fields (Hidden)** +- `sellerId` - Seller identity protection +- `ipfsHash` - Internal document references +- `riskScore` - Internal risk assessments +- `smartContractId` - Technical implementation details +- `updatedAt` - Internal timestamps +- `deletedAt` - Soft deletion markers + +## Privacy Rationale + +The field selection balances investor needs with seller privacy: + +1. **Investment Signals**: Discount rate, amount, and due date provide essential yield and risk information +2. **Credit Assessment**: Customer name allows investors to evaluate debtor creditworthiness +3. **Privacy Protection**: Seller identity and internal risk scores remain confidential +4. **Operational Security**: Technical details like IPFS hashes and contract IDs are hidden + +## Usage Examples + +### Basic Listing +```bash +curl "https://api.stellarsettle.com/api/v1/marketplace/invoices" +``` + +### Filtered Search +```bash +curl "https://api.stellarsettle.com/api/v1/marketplace/invoices?minAmount=1000&maxAmount=50000&sort=discount_rate&sortOrder=DESC" +``` + +### Date-Based Filtering +```bash +curl "https://api.stellarsettle.com/api/v1/marketplace/invoices?dueBefore=2024-12-31T23:59:59.999Z&sort=due_date" +``` + +### Pagination +```bash +curl "https://api.stellarsettle.com/api/v1/marketplace/invoices?page=2&limit=50" +``` + +### Multiple Status Filter +```bash +curl "https://api.stellarsettle.com/api/v1/marketplace/invoices?status=published&status=funded" +``` + +## Filtering & Sorting + +### Status Filtering +- **Default**: Only `published` invoices (ready for investment) +- **Available statuses**: `published`, `funded`, `settled` (though `funded` and `settled` are less relevant for new investments) +- **Multiple values**: Use array format or repeat parameter + +### Amount Filtering +- Both `minAmount` and `maxAmount` are optional +- Validation ensures `minAmount ≤ maxAmount` +- Useful for portfolio size constraints + +### Date Filtering +- `dueBefore`: Find invoices with shorter tenors +- ISO 8601 date format required +- Timezone-aware filtering + +### Sorting Options +- **`due_date`**: Sort by payment due date (tenor) +- **`discount_rate`**: Sort by yield/discount offered +- **`amount`**: Sort by invoice size +- **`created_at`**: Sort by listing recency + +## Pagination + +- **Stable ordering**: Results use `ORDER BY {sort_field} {order}, id ASC` for consistent pagination +- **Reasonable limits**: Maximum 100 items per page to prevent abuse +- **Complete metadata**: Response includes total count and page calculations + +## Performance Considerations + +- **Database indexes**: Optimized queries on `status`, `due_date`, `amount`, and `created_at` +- **Soft deletes**: Automatically excludes deleted invoices +- **Efficient counting**: Separate count query for accurate totals + +## Integration Notes + +### Frontend Integration +```javascript +// Fetch marketplace data +const response = await fetch('/api/v1/marketplace/invoices?page=1&limit=20&sort=discount_rate&sortOrder=DESC'); +const { data, meta } = await response.json(); + +// Display investment opportunities +data.forEach(invoice => { + console.log(`${invoice.invoiceNumber}: ${invoice.discountRate}% discount, due ${invoice.dueDate}`); +}); +``` + +### Investment Flow +1. **Discovery**: Browse marketplace for suitable invoices +2. **Analysis**: Evaluate discount rate, amount, tenor, and debtor +3. **Investment**: Use separate investment API (out of scope for this endpoint) + +## Security & Privacy + +- **No authentication required**: Reduces friction for discovery +- **Public data only**: No sensitive seller information exposed +- **Rate limiting**: Standard API rate limits apply +- **CORS enabled**: Supports browser-based applications + +## Future Enhancements + +Potential future features (out of current scope): +- Full-text search across invoice documents +- Advanced filtering (industry, geography, credit ratings) +- Real-time updates via WebSocket +- Bulk export capabilities \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d030b66..e10fb9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,7 +55,6 @@ "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -81,6 +80,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -898,7 +898,6 @@ "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/trace-mapping": "0.3.9" }, @@ -2093,6 +2092,7 @@ "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -3006,7 +3006,6 @@ "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-json-stable-stringify": "2.x" }, @@ -3534,6 +3533,7 @@ "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", @@ -4324,7 +4324,6 @@ "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", @@ -5548,6 +5547,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", diff --git a/src/app.ts b/src/app.ts index ba2fc01..cd29977 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,10 +6,13 @@ import { createRequestObservabilityMiddleware } from "./middleware/request-obser import { logger, type AppLogger } from "./observability/logger"; import { getMetricsContentType, MetricsRegistry } from "./observability/metrics"; import { createAuthRouter } from "./routes/auth.routes"; +import { createMarketplaceRouter } from "./routes/marketplace.routes"; import type { AuthService } from "./services/auth.service"; +import type { MarketplaceService } from "./services/marketplace.service"; export interface AppDependencies { authService: AuthService; + marketplaceService?: MarketplaceService; logger?: AppLogger; metricsEnabled?: boolean; metricsRegistry?: MetricsRegistry; @@ -105,6 +108,7 @@ export function createRequestLifecycleTracker(): RequestLifecycleTracker { export function createApp({ authService, + marketplaceService, logger: appLogger = logger, metricsEnabled = true, metricsRegistry = new MetricsRegistry(), @@ -167,6 +171,13 @@ export function createApp({ app.use("/api/v1/auth", createAuthRouter(authService)); + // Add marketplace routes if service is provided + if (marketplaceService) { + app.use("/api/v1/marketplace", createMarketplaceRouter({ + marketplaceService, + })); + } + app.use(notFoundMiddleware); app.use(createErrorMiddleware(appLogger)); app.locals.requestLifecycleTracker = requestLifecycleTracker; diff --git a/src/controllers/marketplace.controller.ts b/src/controllers/marketplace.controller.ts new file mode 100644 index 0000000..c2a2af8 --- /dev/null +++ b/src/controllers/marketplace.controller.ts @@ -0,0 +1,92 @@ +import type { Request, Response, NextFunction } from "express"; +import Joi from "joi"; +import type { MarketplaceService, MarketplaceFilters, PaginationOptions } from "../services/marketplace.service"; +import { InvoiceStatus } from "../types/enums"; +import { HttpError } from "../utils/http-error"; +import { ServiceError } from "../utils/service-error"; + +const getInvoicesSchema = Joi.object({ + page: Joi.number().integer().min(1).default(1), + limit: Joi.number().integer().min(1).max(100).default(20), + status: Joi.alternatives().try( + Joi.string().valid(...Object.values(InvoiceStatus)), + Joi.array().items(Joi.string().valid(...Object.values(InvoiceStatus))), + ).optional(), + dueBefore: Joi.date().iso().optional(), + minAmount: Joi.number().min(0).optional(), + maxAmount: Joi.number().min(0).optional(), + sort: Joi.string().valid("due_date", "discount_rate", "amount", "created_at").default("due_date"), + sortOrder: Joi.string().valid("ASC", "DESC").default("ASC"), +}); + +export interface GetInvoicesRequest extends Request { + query: { + page?: string; + limit?: string; + status?: string | string[]; + dueBefore?: string; + minAmount?: string; + maxAmount?: string; + sort?: string; + sortOrder?: string; + }; +} + +export function createMarketplaceController(marketplaceService: MarketplaceService) { + return { + async getInvoices( + req: GetInvoicesRequest, + res: Response, + next: NextFunction, + ): Promise { + try { + // Validate query parameters + const { error, value } = getInvoicesSchema.validate(req.query, { + stripUnknown: true, + convert: true, + }); + + if (error) { + throw new HttpError(400, `Invalid query parameters: ${error.message}`); + } + + // Parse and normalize filters + const filters: MarketplaceFilters = { + status: Array.isArray(value.status) ? value.status : value.status ? [value.status] : undefined, + dueBefore: value.dueBefore, + minAmount: value.minAmount, + maxAmount: value.maxAmount, + sort: value.sort, + sortOrder: value.sortOrder, + }; + + // Validate amount range + if (filters.minAmount !== undefined && filters.maxAmount !== undefined) { + if (filters.minAmount > filters.maxAmount) { + throw new HttpError(400, "minAmount cannot be greater than maxAmount"); + } + } + + const pagination: PaginationOptions = { + page: value.page, + limit: value.limit, + }; + + const result = await marketplaceService.getPublishedInvoices(filters, pagination); + + res.status(200).json({ + success: true, + data: result.data, + meta: result.meta, + }); + } catch (error) { + if (error instanceof ServiceError) { + next(new HttpError(error.statusCode, error.message)); + return; + } + + next(error); + } + }, + }; +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index 41555c0..49df20e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,7 @@ import { getConfig } from "./config/env"; import { getPaymentVerificationConfig } from "./config/stellar"; import { logger } from "./observability/logger"; import { createAuthService } from "./services/auth.service"; +import { createMarketplaceService } from "./services/marketplace.service"; import { createVerifyPaymentService } from "./services/stellar/verify-payment.service"; import { createReconcilePendingStellarStateWorker } from "./workers/reconcile-pending-stellar-state.worker"; @@ -40,9 +41,11 @@ export async function bootstrap(): Promise { } const authService = createAuthService(dataSource, config); + const marketplaceService = createMarketplaceService(dataSource); const requestLifecycleTracker = createRequestLifecycleTracker(); const app = createApp({ authService, + marketplaceService, logger, metricsEnabled: config.observability.metricsEnabled, http: { diff --git a/src/routes/marketplace.routes.ts b/src/routes/marketplace.routes.ts new file mode 100644 index 0000000..2674395 --- /dev/null +++ b/src/routes/marketplace.routes.ts @@ -0,0 +1,19 @@ +import { Router } from "express"; +import type { MarketplaceService } from "../services/marketplace.service"; +import { createMarketplaceController } from "../controllers/marketplace.controller"; + +export interface MarketplaceRouterDependencies { + marketplaceService: MarketplaceService; +} + +export function createMarketplaceRouter({ + marketplaceService, +}: MarketplaceRouterDependencies): Router { + const router = Router(); + const controller = createMarketplaceController(marketplaceService); + + // GET /api/v1/marketplace/invoices - List published invoices for investment + router.get("/invoices", controller.getInvoices); + + return router; +} \ No newline at end of file diff --git a/src/services/marketplace.service.ts b/src/services/marketplace.service.ts new file mode 100644 index 0000000..8fb2f73 --- /dev/null +++ b/src/services/marketplace.service.ts @@ -0,0 +1,191 @@ +import { DataSource, Repository } from "typeorm"; +import { Invoice } from "../models/Invoice.model"; +import { InvoiceStatus } from "../types/enums"; + +export interface MarketplaceFilters { + status?: InvoiceStatus[]; + dueBefore?: Date; + minAmount?: number; + maxAmount?: number; + sort?: "due_date" | "discount_rate" | "amount" | "created_at"; + sortOrder?: "ASC" | "DESC"; +} + +export interface PaginationOptions { + page: number; + limit: number; +} + +export interface PublicInvoice { + id: string; + invoiceNumber: string; + customerName: string; + amount: string; + discountRate: string; + netAmount: string; + dueDate: Date; + status: InvoiceStatus; + createdAt: Date; + // Excluded: sellerId, ipfsHash, riskScore, smartContractId, updatedAt, deletedAt +} + +export interface MarketplaceResponse { + data: PublicInvoice[]; + meta: { + total: number; + page: number; + limit: number; + totalPages: number; + }; +} + +export interface MarketplaceRepositoryContract { + findPublishedInvoices( + filters: MarketplaceFilters, + pagination: PaginationOptions, + ): Promise<{ invoices: Invoice[]; total: number }>; +} + +export interface MarketplaceServiceDependencies { + marketplaceRepository: MarketplaceRepositoryContract; +} + +export class MarketplaceService { + private readonly marketplaceRepository: MarketplaceRepositoryContract; + + constructor(dependencies: MarketplaceServiceDependencies) { + this.marketplaceRepository = dependencies.marketplaceRepository; + } + + async getPublishedInvoices( + filters: MarketplaceFilters = {}, + pagination: PaginationOptions = { page: 1, limit: 20 }, + ): Promise { + // Set default filters + const normalizedFilters: MarketplaceFilters = { + status: filters.status || [InvoiceStatus.PUBLISHED], + dueBefore: filters.dueBefore, + minAmount: filters.minAmount, + maxAmount: filters.maxAmount, + sort: filters.sort || "due_date", + sortOrder: filters.sortOrder || "ASC", + }; + + // Validate pagination + const normalizedPagination: PaginationOptions = { + page: Math.max(1, pagination.page), + limit: Math.min(100, Math.max(1, pagination.limit)), // Max 100 items per page + }; + + const { invoices, total } = await this.marketplaceRepository.findPublishedInvoices( + normalizedFilters, + normalizedPagination, + ); + + const publicInvoices: PublicInvoice[] = invoices.map(this.toPublicInvoice); + + return { + data: publicInvoices, + meta: { + total, + page: normalizedPagination.page, + limit: normalizedPagination.limit, + totalPages: Math.ceil(total / normalizedPagination.limit), + }, + }; + } + + private toPublicInvoice(invoice: Invoice): PublicInvoice { + return { + id: invoice.id, + invoiceNumber: invoice.invoiceNumber, + customerName: invoice.customerName, + amount: invoice.amount, + discountRate: invoice.discountRate, + netAmount: invoice.netAmount, + dueDate: invoice.dueDate, + status: invoice.status, + createdAt: invoice.createdAt, + }; + } +} + +class TypeORMMarketplaceRepository implements MarketplaceRepositoryContract { + private readonly repository: Repository; + + constructor(repository: Repository) { + this.repository = repository; + } + + async findPublishedInvoices( + filters: MarketplaceFilters, + pagination: PaginationOptions, + ): Promise<{ invoices: Invoice[]; total: number }> { + const queryBuilder = this.repository + .createQueryBuilder("invoice") + .where("invoice.deleted_at IS NULL"); + + // Apply status filter + if (filters.status && filters.status.length > 0) { + queryBuilder.andWhere("invoice.status IN (:...statuses)", { + statuses: filters.status, + }); + } + + // Apply date filter + if (filters.dueBefore) { + queryBuilder.andWhere("invoice.due_date <= :dueBefore", { + dueBefore: filters.dueBefore, + }); + } + + // Apply amount filters + if (filters.minAmount !== undefined) { + queryBuilder.andWhere("CAST(invoice.amount AS DECIMAL) >= :minAmount", { + minAmount: filters.minAmount, + }); + } + + if (filters.maxAmount !== undefined) { + queryBuilder.andWhere("CAST(invoice.amount AS DECIMAL) <= :maxAmount", { + maxAmount: filters.maxAmount, + }); + } + + // Apply sorting with stable ordering + const sortColumn = this.getSortColumn(filters.sort || "due_date"); + queryBuilder.orderBy(sortColumn, filters.sortOrder || "ASC"); + queryBuilder.addOrderBy("invoice.id", "ASC"); // Stable sort + + // Get total count + const total = await queryBuilder.getCount(); + + // Apply pagination + const offset = (pagination.page - 1) * pagination.limit; + queryBuilder.skip(offset).take(pagination.limit); + + const invoices = await queryBuilder.getMany(); + + return { invoices, total }; + } + + private getSortColumn(sort: string): string { + const sortMap: Record = { + due_date: "invoice.due_date", + discount_rate: "invoice.discount_rate", + amount: "invoice.amount", + created_at: "invoice.created_at", + }; + + return sortMap[sort] || "invoice.due_date"; + } +} + +export function createMarketplaceService(dataSource: DataSource): MarketplaceService { + const invoiceRepository = dataSource.getRepository(Invoice); + const marketplaceRepository = new TypeORMMarketplaceRepository(invoiceRepository); + + return new MarketplaceService({ + marketplaceRepository, + }); +} \ No newline at end of file diff --git a/tests/marketplace.repository.test.ts b/tests/marketplace.repository.test.ts new file mode 100644 index 0000000..8df22fc --- /dev/null +++ b/tests/marketplace.repository.test.ts @@ -0,0 +1,224 @@ +import { DataSource, Repository, SelectQueryBuilder } from "typeorm"; +import { createMarketplaceService } from "../src/services/marketplace.service"; +import { Invoice } from "../src/models/Invoice.model"; +import { InvoiceStatus } from "../src/types/enums"; + +describe("TypeORMMarketplaceRepository", () => { + let mockDataSource: jest.Mocked; + let mockRepository: jest.Mocked>; + let mockQueryBuilder: jest.Mocked>; + let marketplaceService: any; + + beforeEach(() => { + mockQueryBuilder = { + createQueryBuilder: jest.fn(), + where: jest.fn(), + andWhere: jest.fn(), + orderBy: jest.fn(), + addOrderBy: jest.fn(), + getCount: jest.fn(), + skip: jest.fn(), + take: jest.fn(), + getMany: jest.fn(), + } as any; + + // Chain methods return the query builder for fluent interface + mockQueryBuilder.where.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.andWhere.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.orderBy.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.addOrderBy.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.skip.mockReturnValue(mockQueryBuilder); + mockQueryBuilder.take.mockReturnValue(mockQueryBuilder); + + mockRepository = { + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), + } as any; + + mockDataSource = { + getRepository: jest.fn().mockReturnValue(mockRepository), + } as any; + + marketplaceService = createMarketplaceService(mockDataSource); + }); + + describe("findPublishedInvoices", () => { + const mockInvoices = [ + { + id: "invoice-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.00", + discountRate: "5.00", + netAmount: "950.00", + dueDate: new Date("2024-12-31"), + status: InvoiceStatus.PUBLISHED, + createdAt: new Date("2024-01-01"), + }, + ] as Invoice[]; + + it("should build query with default filters", async () => { + mockQueryBuilder.getCount.mockResolvedValue(1); + mockQueryBuilder.getMany.mockResolvedValue(mockInvoices); + + const filters = { + status: [InvoiceStatus.PUBLISHED], + sort: "due_date" as const, + sortOrder: "ASC" as const, + }; + const pagination = { page: 1, limit: 20 }; + + await marketplaceService.getPublishedInvoices(filters, pagination); + + expect(mockRepository.createQueryBuilder).toHaveBeenCalledWith("invoice"); + expect(mockQueryBuilder.where).toHaveBeenCalledWith("invoice.deleted_at IS NULL"); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith("invoice.status IN (:...statuses)", { + statuses: [InvoiceStatus.PUBLISHED], + }); + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith("invoice.due_date", "ASC"); + expect(mockQueryBuilder.addOrderBy).toHaveBeenCalledWith("invoice.id", "ASC"); + }); + + it("should apply date filter", async () => { + mockQueryBuilder.getCount.mockResolvedValue(0); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const dueBefore = new Date("2024-12-01"); + const filters = { + status: [InvoiceStatus.PUBLISHED], + dueBefore, + sort: "due_date" as const, + sortOrder: "ASC" as const, + }; + + await marketplaceService.getPublishedInvoices(filters, { page: 1, limit: 20 }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith("invoice.due_date <= :dueBefore", { + dueBefore, + }); + }); + + it("should apply amount filters", async () => { + mockQueryBuilder.getCount.mockResolvedValue(0); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const filters = { + status: [InvoiceStatus.PUBLISHED], + minAmount: 500, + maxAmount: 2000, + sort: "due_date" as const, + sortOrder: "ASC" as const, + }; + + await marketplaceService.getPublishedInvoices(filters, { page: 1, limit: 20 }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + "CAST(invoice.amount AS DECIMAL) >= :minAmount", + { minAmount: 500 }, + ); + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith( + "CAST(invoice.amount AS DECIMAL) <= :maxAmount", + { maxAmount: 2000 }, + ); + }); + + it("should apply different sort columns", async () => { + mockQueryBuilder.getCount.mockResolvedValue(0); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const testCases = [ + { sort: "due_date", expected: "invoice.due_date" }, + { sort: "discount_rate", expected: "invoice.discount_rate" }, + { sort: "amount", expected: "invoice.amount" }, + { sort: "created_at", expected: "invoice.created_at" }, + ]; + + for (const testCase of testCases) { + mockQueryBuilder.orderBy.mockClear(); + + const filters = { + status: [InvoiceStatus.PUBLISHED], + sort: testCase.sort as any, + sortOrder: "DESC" as const, + }; + + await marketplaceService.getPublishedInvoices(filters, { page: 1, limit: 20 }); + + expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith(testCase.expected, "DESC"); + } + }); + + it("should apply pagination correctly", async () => { + mockQueryBuilder.getCount.mockResolvedValue(50); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const filters = { + status: [InvoiceStatus.PUBLISHED], + sort: "due_date" as const, + sortOrder: "ASC" as const, + }; + const pagination = { page: 3, limit: 10 }; + + await marketplaceService.getPublishedInvoices(filters, pagination); + + expect(mockQueryBuilder.skip).toHaveBeenCalledWith(20); // (page - 1) * limit + expect(mockQueryBuilder.take).toHaveBeenCalledWith(10); + }); + + it("should handle multiple statuses", async () => { + mockQueryBuilder.getCount.mockResolvedValue(0); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const filters = { + status: [InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED], + sort: "due_date" as const, + sortOrder: "ASC" as const, + }; + + await marketplaceService.getPublishedInvoices(filters, { page: 1, limit: 20 }); + + expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith("invoice.status IN (:...statuses)", { + statuses: [InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED], + }); + }); + + it("should not apply optional filters when not provided", async () => { + mockQueryBuilder.getCount.mockResolvedValue(0); + mockQueryBuilder.getMany.mockResolvedValue([]); + + const filters = { + sort: "due_date" as const, + sortOrder: "ASC" as const, + // No status, dueBefore, minAmount, maxAmount provided + }; + + await marketplaceService.getPublishedInvoices(filters, { page: 1, limit: 20 }); + + // Should only have the base where clause for deleted_at + expect(mockQueryBuilder.where).toHaveBeenCalledTimes(1); + expect(mockQueryBuilder.where).toHaveBeenCalledWith("invoice.deleted_at IS NULL"); + + // Should not have additional where clauses for optional filters (except default status) + const andWhereCalls = mockQueryBuilder.andWhere.mock.calls; + expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("due_date"))).toBe(false); + expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("amount"))).toBe(false); + + // Status filter should be applied with default value + expect(andWhereCalls.some(call => typeof call[0] === 'string' && call[0].includes("status"))).toBe(true); + }); + + it("should return both invoices and total count", async () => { + mockQueryBuilder.getCount.mockResolvedValue(25); + mockQueryBuilder.getMany.mockResolvedValue(mockInvoices); + + const result = await marketplaceService.getPublishedInvoices( + { status: [InvoiceStatus.PUBLISHED] }, + { page: 1, limit: 20 }, + ); + + expect(result.meta.total).toBe(25); + expect(result.data).toHaveLength(1); + expect(mockQueryBuilder.getCount).toHaveBeenCalled(); + expect(mockQueryBuilder.getMany).toHaveBeenCalled(); + }); + }); +}); \ No newline at end of file diff --git a/tests/marketplace.routes.test.ts b/tests/marketplace.routes.test.ts new file mode 100644 index 0000000..2c78eb8 --- /dev/null +++ b/tests/marketplace.routes.test.ts @@ -0,0 +1,268 @@ +import request from "supertest"; +import express from "express"; +import { createMarketplaceRouter } from "../src/routes/marketplace.routes"; +import { createErrorMiddleware } from "../src/middleware/error.middleware"; +import { logger } from "../src/observability/logger"; +import { InvoiceStatus } from "../src/types/enums"; + +describe("Marketplace Routes", () => { + let app: express.Application; + let mockMarketplaceService: any; + + beforeEach(() => { + mockMarketplaceService = { + getPublishedInvoices: jest.fn(), + }; + + app = express(); + app.use(express.json()); + app.use( + "/api/v1/marketplace", + createMarketplaceRouter({ + marketplaceService: mockMarketplaceService, + }), + ); + app.use(createErrorMiddleware(logger)); + }); + + describe("GET /api/v1/marketplace/invoices", () => { + const mockResponse = { + data: [ + { + id: "invoice-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.00", + discountRate: "5.00", + netAmount: "950.00", + dueDate: "2024-12-31T00:00:00.000Z", + status: InvoiceStatus.PUBLISHED, + createdAt: "2024-01-01T00:00:00.000Z", + }, + ], + meta: { + total: 1, + page: 1, + limit: 20, + totalPages: 1, + }, + }; + + it("should return published invoices with default parameters", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + const response = await request(app) + .get("/api/v1/marketplace/invoices") + .expect(200); + + expect(response.body).toEqual({ + success: true, + data: mockResponse.data, + meta: mockResponse.meta, + }); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + { + status: undefined, + dueBefore: undefined, + minAmount: undefined, + maxAmount: undefined, + sort: "due_date", + sortOrder: "ASC", + }, + { page: 1, limit: 20 }, + ); + }); + + it("should handle pagination parameters", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ page: 2, limit: 10 }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.any(Object), + { page: 2, limit: 10 }, + ); + }); + + it("should handle status filter as single value", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ status: "published" }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.objectContaining({ + status: ["published"], + }), + expect.any(Object), + ); + }); + + it("should handle status filter as array", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ status: ["published", "funded"] }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.objectContaining({ + status: ["published", "funded"], + }), + expect.any(Object), + ); + }); + + it("should handle amount filters", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ minAmount: 500, maxAmount: 2000 }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.objectContaining({ + minAmount: 500, + maxAmount: 2000, + }), + expect.any(Object), + ); + }); + + it("should handle date filter", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ dueBefore: "2024-12-31T23:59:59.999Z" }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.objectContaining({ + dueBefore: new Date("2024-12-31T23:59:59.999Z"), + }), + expect.any(Object), + ); + }); + + it("should handle sorting parameters", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ sort: "discount_rate", sortOrder: "DESC" }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + expect.objectContaining({ + sort: "discount_rate", + sortOrder: "DESC", + }), + expect.any(Object), + ); + }); + + it("should validate query parameters", async () => { + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ page: -1, limit: 200, status: "invalid_status" }) + .expect(400); + }); + + it("should validate amount range", async () => { + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ minAmount: 2000, maxAmount: 1000 }) + .expect(400); + }); + + it("should validate date format", async () => { + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ dueBefore: "invalid-date" }) + .expect(400); + }); + + it("should validate sort parameters", async () => { + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ sort: "invalid_sort", sortOrder: "INVALID" }) + .expect(400); + }); + + it("should handle service errors", async () => { + mockMarketplaceService.getPublishedInvoices.mockRejectedValue( + new Error("Database connection failed"), + ); + + await request(app) + .get("/api/v1/marketplace/invoices") + .expect(500); + }); + + it("should strip unknown query parameters", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ + page: 1, + limit: 10, + unknownParam: "should-be-stripped", + anotherUnknown: 123 + }) + .expect(200); + + // Should only receive known parameters + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + { + status: undefined, + dueBefore: undefined, + minAmount: undefined, + maxAmount: undefined, + sort: "due_date", + sortOrder: "ASC", + }, + { page: 1, limit: 10 }, + ); + }); + + it("should handle complex query combinations", async () => { + mockMarketplaceService.getPublishedInvoices.mockResolvedValue(mockResponse); + + await request(app) + .get("/api/v1/marketplace/invoices") + .query({ + page: 2, + limit: 5, + status: ["published", "funded"], + dueBefore: "2024-12-31T23:59:59.999Z", + minAmount: 100, + maxAmount: 5000, + sort: "amount", + sortOrder: "DESC", + }) + .expect(200); + + expect(mockMarketplaceService.getPublishedInvoices).toHaveBeenCalledWith( + { + status: ["published", "funded"], + dueBefore: new Date("2024-12-31T23:59:59.999Z"), + minAmount: 100, + maxAmount: 5000, + sort: "amount", + sortOrder: "DESC", + }, + { page: 2, limit: 5 }, + ); + }); + }); +}); \ No newline at end of file diff --git a/tests/marketplace.service.test.ts b/tests/marketplace.service.test.ts new file mode 100644 index 0000000..badaefc --- /dev/null +++ b/tests/marketplace.service.test.ts @@ -0,0 +1,196 @@ +import { MarketplaceService } from "../src/services/marketplace.service"; +import { InvoiceStatus } from "../src/types/enums"; +import { Invoice } from "../src/models/Invoice.model"; + +describe("MarketplaceService", () => { + let mockMarketplaceRepository: any; + let marketplaceService: MarketplaceService; + + const mockInvoices: Invoice[] = [ + { + id: "invoice-1", + sellerId: "seller-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.00", + discountRate: "5.00", + netAmount: "950.00", + dueDate: new Date("2024-12-31"), + ipfsHash: "QmHash1", + riskScore: "3.5", + status: InvoiceStatus.PUBLISHED, + smartContractId: "contract-1", + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + } as Invoice, + { + id: "invoice-2", + sellerId: "seller-2", + invoiceNumber: "INV-002", + customerName: "Customer B", + amount: "2000.00", + discountRate: "3.00", + netAmount: "1940.00", + dueDate: new Date("2024-11-30"), + ipfsHash: "QmHash2", + riskScore: "2.1", + status: InvoiceStatus.PUBLISHED, + smartContractId: "contract-2", + createdAt: new Date("2024-01-02"), + updatedAt: new Date("2024-01-02"), + deletedAt: null, + } as Invoice, + ]; + + beforeEach(() => { + mockMarketplaceRepository = { + findPublishedInvoices: jest.fn(), + }; + + marketplaceService = new MarketplaceService({ + marketplaceRepository: mockMarketplaceRepository, + }); + }); + + describe("getPublishedInvoices", () => { + it("should return published invoices with default filters", async () => { + mockMarketplaceRepository.findPublishedInvoices.mockResolvedValue({ + invoices: mockInvoices, + total: 2, + }); + + const result = await marketplaceService.getPublishedInvoices(); + + expect(result).toEqual({ + data: [ + { + id: "invoice-1", + invoiceNumber: "INV-001", + customerName: "Customer A", + amount: "1000.00", + discountRate: "5.00", + netAmount: "950.00", + dueDate: new Date("2024-12-31"), + status: InvoiceStatus.PUBLISHED, + createdAt: new Date("2024-01-01"), + }, + { + id: "invoice-2", + invoiceNumber: "INV-002", + customerName: "Customer B", + amount: "2000.00", + discountRate: "3.00", + netAmount: "1940.00", + dueDate: new Date("2024-11-30"), + status: InvoiceStatus.PUBLISHED, + createdAt: new Date("2024-01-02"), + }, + ], + meta: { + total: 2, + page: 1, + limit: 20, + totalPages: 1, + }, + }); + + expect(mockMarketplaceRepository.findPublishedInvoices).toHaveBeenCalledWith( + { + status: [InvoiceStatus.PUBLISHED], + sort: "due_date", + sortOrder: "ASC", + }, + { page: 1, limit: 20 }, + ); + }); + + it("should apply custom filters and pagination", async () => { + mockMarketplaceRepository.findPublishedInvoices.mockResolvedValue({ + invoices: [mockInvoices[0]], + total: 1, + }); + + const filters = { + status: [InvoiceStatus.PUBLISHED, InvoiceStatus.FUNDED], + dueBefore: new Date("2024-12-01"), + minAmount: 500, + maxAmount: 1500, + sort: "discount_rate" as const, + sortOrder: "DESC" as const, + }; + + const pagination = { page: 2, limit: 10 }; + + const result = await marketplaceService.getPublishedInvoices(filters, pagination); + + expect(result.meta).toEqual({ + total: 1, + page: 2, + limit: 10, + totalPages: 1, + }); + + expect(mockMarketplaceRepository.findPublishedInvoices).toHaveBeenCalledWith( + filters, + pagination, + ); + }); + + it("should normalize pagination limits", async () => { + mockMarketplaceRepository.findPublishedInvoices.mockResolvedValue({ + invoices: [], + total: 0, + }); + + // Test invalid pagination values + await marketplaceService.getPublishedInvoices({}, { page: -1, limit: 200 }); + + expect(mockMarketplaceRepository.findPublishedInvoices).toHaveBeenCalledWith( + expect.any(Object), + { page: 1, limit: 100 }, // Normalized values + ); + }); + + it("should exclude private fields from public response", async () => { + mockMarketplaceRepository.findPublishedInvoices.mockResolvedValue({ + invoices: [mockInvoices[0]], + total: 1, + }); + + const result = await marketplaceService.getPublishedInvoices(); + + const publicInvoice = result.data[0]; + + // Should include public fields + expect(publicInvoice).toHaveProperty("id"); + expect(publicInvoice).toHaveProperty("invoiceNumber"); + expect(publicInvoice).toHaveProperty("customerName"); + expect(publicInvoice).toHaveProperty("amount"); + expect(publicInvoice).toHaveProperty("discountRate"); + expect(publicInvoice).toHaveProperty("netAmount"); + expect(publicInvoice).toHaveProperty("dueDate"); + expect(publicInvoice).toHaveProperty("status"); + expect(publicInvoice).toHaveProperty("createdAt"); + + // Should exclude private fields + expect(publicInvoice).not.toHaveProperty("sellerId"); + expect(publicInvoice).not.toHaveProperty("ipfsHash"); + expect(publicInvoice).not.toHaveProperty("riskScore"); + expect(publicInvoice).not.toHaveProperty("smartContractId"); + expect(publicInvoice).not.toHaveProperty("updatedAt"); + expect(publicInvoice).not.toHaveProperty("deletedAt"); + }); + + it("should calculate total pages correctly", async () => { + mockMarketplaceRepository.findPublishedInvoices.mockResolvedValue({ + invoices: [], + total: 25, + }); + + const result = await marketplaceService.getPublishedInvoices({}, { page: 1, limit: 10 }); + + expect(result.meta.totalPages).toBe(3); // Math.ceil(25 / 10) + }); + }); +}); \ No newline at end of file