diff --git a/backend/src/index.ts b/backend/src/index.ts index 87e32d2c..e502fd32 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -106,6 +106,7 @@ import { rateLimitAnalyticsRouter } from './routes/rate-limit-analytics.js'; import { startScheduledRotation, stopScheduledRotation } from './config/credential-rotation.js'; import { subscriptionsRouter } from './routes/subscriptions.js'; import { workspacesRouter } from './routes/workspaces.js'; +import { marketplaceEscrowRouter } from './routes/marketplace-escrow.js'; import { teamsRouter } from './routes/teams.js'; import { merchantAuditRouter } from './routes/merchant-audit.js'; import { installmentsRouter } from './routes/installments.js'; @@ -264,6 +265,7 @@ apiV1Router.use('/escrow', escrowRouter); apiV1Router.use('/disputes', disputesRouter); apiV1Router.use('/subscriptions', subscriptionsRouter); apiV1Router.use('/workspaces', workspacesRouter); +apiV1Router.use('/marketplace-escrow', marketplaceEscrowRouter); apiV1Router.use('/teams', teamsRouter); apiV1Router.use('/merchant-audit', merchantAuditRouter); apiV1Router.use('/installments', installmentsRouter); diff --git a/backend/src/routes/marketplace-escrow.ts b/backend/src/routes/marketplace-escrow.ts new file mode 100644 index 00000000..177c3893 --- /dev/null +++ b/backend/src/routes/marketplace-escrow.ts @@ -0,0 +1,196 @@ +import { Router, Request, Response } from 'express'; +import { z } from 'zod'; +import { AppError, asyncHandler } from '../middleware/errorHandler.js'; +import { validate } from '../middleware/validate.js'; +import { marketplaceEscrowService } from '../services/marketplace-escrow.js'; + +export const marketplaceEscrowRouter = Router(); + +const milestoneSchema = z.object({ + id: z.string().min(1).optional(), + name: z.string().min(1).max(120), + amount: z.number().positive(), +}); + +const createOrderSchema = z.object({ + marketplaceId: z.string().min(1), + externalOrderId: z.string().min(1), + buyerId: z.string().min(1), + sellerId: z.string().min(1), + amount: z.number().positive(), + currency: z.string().length(3).optional(), + platformFeePercent: z.number().min(0).max(100).optional(), + inspectionPeriodMs: z.number().int().nonnegative().optional(), + milestones: z.array(milestoneSchema).min(1).optional(), +}); + +const shipSchema = z.object({ + trackingInfo: z.string().max(200).optional(), +}); + +const disputeSchema = z.object({ + raisedBy: z.string().min(1), + reason: z.string().min(1).max(500), +}); + +const resolveSchema = z.object({ + resolution: z.enum(['buyer', 'seller', 'split']), + buyerPercent: z.number().min(0).max(100).optional(), + approvedBy: z.string().min(1), +}); + +const refundSchema = z.object({ + reason: z.string().min(1).max(500), +}); + +const releaseSchema = z.object({ + approvedBy: z.string().min(1).optional(), +}); + +/** + * Preserve service-level status codes (404/409/403) when they surface as errors. + */ +const serviceHandler = + (handler: (req: Request, res: Response) => unknown | Promise) => + asyncHandler(async (req, res) => { + try { + await handler(req, res); + } catch (err) { + const serviceError = err as { statusCode?: number; code?: string; message?: string }; + if (typeof serviceError?.statusCode === 'number') { + throw new AppError(serviceError.statusCode, serviceError.message ?? 'Escrow error', serviceError.code); + } + throw err; + } + }); + +marketplaceEscrowRouter.post( + '/orders', + validate(createOrderSchema), + serviceHandler((req: Request, res: Response) => { + res.status(201).json({ data: marketplaceEscrowService.createOrder(req.body) }); + }), +); + +marketplaceEscrowRouter.get( + '/orders', + serviceHandler((req: Request, res: Response) => { + const { marketplaceId, buyerId, sellerId, status, limit, offset } = req.query; + res.json( + marketplaceEscrowService.listOrders({ + marketplaceId: marketplaceId as string | undefined, + buyerId: buyerId as string | undefined, + sellerId: sellerId as string | undefined, + status: status as never, + limit: limit ? Number(limit) : undefined, + offset: offset ? Number(offset) : undefined, + }), + ); + }), +); + +marketplaceEscrowRouter.get( + '/settlements/:marketplaceId', + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.getSettlementSummary(String(req.params.marketplaceId)) }); + }), +); + +marketplaceEscrowRouter.get( + '/orders/:id', + serviceHandler((req: Request, res: Response) => { + const order = marketplaceEscrowService.getOrder(String(req.params.id)); + if (!order) throw new AppError(404, 'Marketplace order not found', 'NOT_FOUND'); + res.json({ data: order }); + }), +); + +marketplaceEscrowRouter.get( + '/orders/:id/payout', + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.getPayoutBreakdown(String(req.params.id)) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/fund', + serviceHandler((req: Request, res: Response) => { + const { txHash } = req.body ?? {}; + res.json({ data: marketplaceEscrowService.fundOrder(String(req.params.id), txHash) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/ship', + validate(shipSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.markShipped(String(req.params.id), req.body.trackingInfo) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/deliver', + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.markDelivered(String(req.params.id)) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/release', + validate(releaseSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.release(String(req.params.id), req.body.approvedBy) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/milestones/:milestoneId/release', + validate(releaseSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ + data: marketplaceEscrowService.releaseMilestone( + String(req.params.id), + String(req.params.milestoneId), + req.body.approvedBy, + ), + }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/auto-release', + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.autoRelease(String(req.params.id)) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/dispute', + validate(disputeSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.raiseDispute(String(req.params.id), req.body) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/resolve', + validate(resolveSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.resolveDispute(String(req.params.id), req.body) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/refund', + validate(refundSchema), + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.refund(String(req.params.id), req.body.reason) }); + }), +); + +marketplaceEscrowRouter.post( + '/orders/:id/cancel', + serviceHandler((req: Request, res: Response) => { + res.json({ data: marketplaceEscrowService.cancelOrder(String(req.params.id)) }); + }), +); diff --git a/backend/src/services/__tests__/marketplace-escrow.test.ts b/backend/src/services/__tests__/marketplace-escrow.test.ts new file mode 100644 index 00000000..2c8f9e45 --- /dev/null +++ b/backend/src/services/__tests__/marketplace-escrow.test.ts @@ -0,0 +1,332 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { MarketplaceEscrowService } from '../marketplace-escrow.js'; + +describe('MarketplaceEscrowService — marketplace escrow workflows (#915)', () => { + let service: MarketplaceEscrowService; + let now: number; + + const baseOrder = { + marketplaceId: 'mp_1', + externalOrderId: 'order-1001', + buyerId: 'buyer_1', + sellerId: 'seller_1', + amount: 200, + platformFeePercent: 10, + }; + + beforeEach(() => { + now = new Date('2026-03-01T00:00:00.000Z').getTime(); + service = new MarketplaceEscrowService(() => now); + }); + + const expectError = (fn: () => unknown, statusCode: number, message?: RegExp) => { + try { + fn(); + throw new Error('Expected function to throw'); + } catch (err) { + const error = err as Error & { statusCode?: number }; + expect(error.statusCode).toBe(statusCode); + if (message) expect(error.message).toMatch(message); + } + }; + + const fundedOrder = (overrides: Record = {}) => { + const order = service.createOrder({ ...baseOrder, ...overrides }); + service.fundOrder(order.id, 'tx_abc'); + return order; + }; + + describe('order creation', () => { + it('creates an order with platform fee and escrow status', () => { + const order = service.createOrder(baseOrder); + expect(order.status).toBe('created'); + expect(order.platformFee).toBe(20); + expect(order.sellerPayout).toBe(0); + expect(order.currency).toBe('USD'); + expect(order.events[0].type).toBe('order.created'); + }); + + it('rejects an order where buyer equals seller', () => { + expectError( + () => service.createOrder({ ...baseOrder, sellerId: 'buyer_1' }), + 400, + /must be different/, + ); + }); + + it('rejects a non-positive amount', () => { + expectError(() => service.createOrder({ ...baseOrder, amount: 0 }), 400, /greater than 0/); + }); + + it('rejects an out-of-range platform fee', () => { + expectError( + () => service.createOrder({ ...baseOrder, platformFeePercent: 150 }), + 400, + /between 0 and 100/, + ); + }); + + it('validates that milestones sum to the order amount', () => { + expectError( + () => + service.createOrder({ + ...baseOrder, + milestones: [ + { name: 'Design', amount: 50 }, + { name: 'Build', amount: 100 }, + ], + }), + 400, + /must sum to the order amount/, + ); + }); + }); + + describe('happy path lifecycle', () => { + it('runs created → funded → shipped → delivered → released', () => { + const order = fundedOrder(); + expect(service.getOrder(order.id)!.status).toBe('funded'); + + expect(service.markShipped(order.id, 'TRACK123').status).toBe('shipped'); + const delivered = service.markDelivered(order.id); + expect(delivered.status).toBe('delivered'); + expect(delivered.trackingInfo).toBe('TRACK123'); + expect(delivered.autoReleaseAt).toBeTruthy(); + + const released = service.release(order.id, 'buyer_1'); + expect(released.status).toBe('released'); + expect(released.sellerPayout).toBe(180); + }); + + it('records an audit trail of lifecycle events', () => { + const order = fundedOrder(); + service.markShipped(order.id); + service.markDelivered(order.id); + service.release(order.id); + + const types = service.getOrder(order.id)!.events.map((e) => e.type); + expect(types).toEqual([ + 'order.created', + 'order.funded', + 'order.shipped', + 'order.delivered', + 'order.released', + ]); + }); + }); + + describe('state machine guards', () => { + it('cannot fund twice', () => { + const order = fundedOrder(); + expectError(() => service.fundOrder(order.id, 'tx_2'), 409, /Cannot fund/); + }); + + it('cannot ship before funding', () => { + const order = service.createOrder(baseOrder); + expectError(() => service.markShipped(order.id), 409, /Cannot ship/); + }); + + it('cannot deliver before shipping', () => { + const order = fundedOrder(); + expectError(() => service.markDelivered(order.id), 409, /Cannot deliver/); + }); + + it('cannot release a disputed order', () => { + const order = fundedOrder(); + service.raiseDispute(order.id, { raisedBy: 'buyer_1', reason: 'damaged' }); + expectError(() => service.release(order.id), 409, /Cannot release/); + }); + + it('reports an unknown order', () => { + expectError(() => service.release('ghost'), 404, /Marketplace order not found/); + }); + }); + + describe('auto-release', () => { + it('releases once the inspection window elapses', () => { + const order = fundedOrder({ inspectionPeriodMs: 1000 }); + service.markShipped(order.id); + service.markDelivered(order.id); + + expectError(() => service.autoRelease(order.id), 400, /has not elapsed/); + + now += 1000; + const released = service.autoRelease(order.id); + expect(released.status).toBe('released'); + expect(released.sellerPayout).toBe(180); + }); + }); + + describe('milestones', () => { + const milestoneOrder = () => + fundedOrder({ + milestones: [ + { id: 'ms_1', name: 'Design', amount: 80 }, + { id: 'ms_2', name: 'Build', amount: 120 }, + ], + }); + + it('releases individual milestones and settles when all are out', () => { + const order = milestoneOrder(); + + const partial = service.releaseMilestone(order.id, 'ms_1'); + expect(partial.status).toBe('partially_released'); + expect(service.getPayoutBreakdown(order.id).releasedMilestoneAmount).toBe(80); + + const complete = service.releaseMilestone(order.id, 'ms_2'); + expect(complete.status).toBe('released'); + expect(complete.sellerPayout).toBe(180); + }); + + it('rejects an unknown milestone', () => { + const order = milestoneOrder(); + expectError(() => service.releaseMilestone(order.id, 'ms_missing'), 404, /Milestone not found/); + }); + + it('rejects releasing the same milestone twice', () => { + const order = milestoneOrder(); + service.releaseMilestone(order.id, 'ms_1'); + expectError(() => service.releaseMilestone(order.id, 'ms_1'), 409, /already been released/); + }); + + it('rejects milestones on an order without them', () => { + const order = fundedOrder(); + expectError(() => service.releaseMilestone(order.id, 'ms_1'), 400, /no milestones/); + }); + }); + + describe('disputes and buyer protection', () => { + it('refunds the buyer when resolved in their favour', () => { + const order = fundedOrder(); + service.raiseDispute(order.id, { raisedBy: 'buyer_1', reason: 'never arrived' }); + + const resolved = service.resolveDispute(order.id, { resolution: 'buyer', approvedBy: 'arbiter' }); + expect(resolved.status).toBe('refunded'); + expect(resolved.refundedAmount).toBe(200); + expect(resolved.sellerPayout).toBe(0); + expect(resolved.platformFee).toBe(0); + }); + + it('pays the seller when resolved in their favour', () => { + const order = fundedOrder(); + service.raiseDispute(order.id, { raisedBy: 'seller_1', reason: 'delivered as agreed' }); + + const resolved = service.resolveDispute(order.id, { resolution: 'seller', approvedBy: 'arbiter' }); + expect(resolved.status).toBe('released'); + expect(resolved.sellerPayout).toBe(180); + expect(resolved.platformFee).toBe(20); + }); + + it('splits funds between buyer and seller', () => { + const order = fundedOrder(); + service.raiseDispute(order.id, { raisedBy: 'buyer_1', reason: 'partial damage' }); + + const resolved = service.resolveDispute(order.id, { + resolution: 'split', + buyerPercent: 25, + approvedBy: 'arbiter', + }); + + // distributable = 180, buyer 25% = 45, seller = 135 + expect(resolved.status).toBe('partially_released'); + expect(resolved.refundedAmount).toBe(45); + expect(resolved.sellerPayout).toBe(135); + }); + + it('rejects a swap-resolve of an order without a dispute', () => { + const order = fundedOrder(); + expectError( + () => service.resolveDispute(order.id, { resolution: 'buyer', approvedBy: 'arbiter' }), + 409, + /Cannot resolve/, + ); + }); + + it('blocks third parties from raising a dispute', () => { + const order = fundedOrder(); + expectError( + () => service.raiseDispute(order.id, { raisedBy: 'random_user', reason: 'nope' }), + 403, + /Only the buyer or seller/, + ); + }); + + it('validates the split percentage', () => { + const order = fundedOrder(); + service.raiseDispute(order.id, { raisedBy: 'buyer_1', reason: 'x' }); + expectError( + () => service.resolveDispute(order.id, { resolution: 'split', buyerPercent: 120, approvedBy: 'a' }), + 400, + /between 0 and 100/, + ); + }); + }); + + describe('refunds and cancellation', () => { + it('refunds a funded order', () => { + const order = fundedOrder(); + const refunded = service.refund(order.id, 'out of stock'); + expect(refunded.status).toBe('refunded'); + expect(refunded.refundedAmount).toBe(200); + expect(refunded.platformFee).toBe(0); + }); + + it('requires a refund reason', () => { + const order = fundedOrder(); + expectError(() => service.refund(order.id, ''), 400, /reason is required/); + }); + + it('cancels an unfunded order only', () => { + const order = service.createOrder(baseOrder); + expect(service.cancelOrder(order.id).status).toBe('cancelled'); + + const funded = fundedOrder(); + expectError(() => service.cancelOrder(funded.id), 409, /Cannot cancel/); + }); + }); + + describe('reporting', () => { + it('lists orders with filters and pagination', () => { + const first = fundedOrder(); + fundedOrder({ externalOrderId: 'order-1002', buyerId: 'buyer_2' }); + service.release(first.id); + + expect(service.listOrders({ marketplaceId: 'mp_1' }).total).toBe(2); + expect(service.listOrders({ status: 'released' }).total).toBe(1); + expect(service.listOrders({ buyerId: 'buyer_2' }).total).toBe(1); + expect(service.listOrders({ limit: 1 }).orders).toHaveLength(1); + }); + + it('summarizes marketplace settlements', () => { + const released = fundedOrder(); + service.release(released.id); + + const refunded = fundedOrder({ externalOrderId: 'order-1003' }); + service.refund(refunded.id, 'damaged'); + + const summary = service.getSettlementSummary('mp_1'); + expect(summary.orderCount).toBe(2); + expect(summary.escrowedVolume).toBe(200); + expect(summary.platformFeesCollected).toBe(20); + expect(summary.sellerPayouts).toBe(180); + expect(summary.buyerRefunds).toBe(200); + }); + + it('reports the payout breakdown', () => { + const order = fundedOrder(); + service.release(order.id); + expect(service.getPayoutBreakdown(order.id)).toMatchObject({ + amount: 200, + platformFee: 20, + sellerPayout: 180, + status: 'released', + }); + }); + + it('clears state between tests', () => { + fundedOrder(); + service.resetForTests(); + expect(service.listOrders().total).toBe(0); + }); + }); +}); diff --git a/backend/src/services/marketplace-escrow.ts b/backend/src/services/marketplace-escrow.ts new file mode 100644 index 00000000..e564b455 --- /dev/null +++ b/backend/src/services/marketplace-escrow.ts @@ -0,0 +1,473 @@ +/** + * marketplace-escrow.ts — Issue #915 + * + * Escrow payment workflows for marketplaces. + * + * A marketplace holds the buyer's funds until delivery is confirmed, then + * settles the order by paying the seller and taking a platform commission. + * The workflow supports: + * - order lifecycle: created → funded → shipped → delivered → released; + * - a buyer inspection window after delivery, after which funds + * auto-release to the seller; + * - optional milestone-based partial releases for large orders; + * - buyer-protection disputes resolved to the buyer, the seller, or split; + * - refunds and cancellation of unfunded orders; + * - payout/settlement reporting per marketplace. + */ + +import { randomUUID } from 'node:crypto'; +import { BaseService } from './BaseService.js'; + +export type MarketplaceOrderStatus = + | 'created' + | 'funded' + | 'shipped' + | 'delivered' + | 'released' + | 'partially_released' + | 'refunded' + | 'disputed' + | 'cancelled'; + +export type MilestoneStatus = 'pending' | 'released'; + +export interface Milestone { + id: string; + name: string; + amount: number; + status: MilestoneStatus; + releasedAt?: string; +} + +export interface OrderEvent { + type: string; + at: string; + details?: Record; +} + +export interface EscrowDispute { + raisedBy: string; + reason: string; + raisedAt: string; + resolution?: 'buyer' | 'seller' | 'split'; + buyerPercent?: number; + resolvedAt?: string; +} + +export interface MarketplaceOrder { + id: string; + marketplaceId: string; + externalOrderId: string; + buyerId: string; + sellerId: string; + amount: number; + currency: string; + platformFeePercent: number; + platformFee: number; + sellerPayout: number; + refundedAmount: number; + status: MarketplaceOrderStatus; + milestones?: Milestone[]; + inspectionPeriodMs: number; + fundingTxHash?: string; + trackingInfo?: string; + deliveredAt?: string; + autoReleaseAt?: string; + releasedAt?: string; + refundedAt?: string; + cancelledAt?: string; + dispute?: EscrowDispute; + events: OrderEvent[]; + createdAt: string; + updatedAt: string; +} + +export interface PayoutBreakdown { + orderId: string; + amount: number; + currency: string; + platformFee: number; + sellerPayout: number; + buyerRefund: number; + releasedMilestoneAmount: number; + status: MarketplaceOrderStatus; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_INSPECTION_PERIOD_MS = 7 * DAY_MS; + +export class MarketplaceEscrowService extends BaseService { + private orders = new Map(); + + constructor(private readonly now: () => number = Date.now) { + super(); + } + + // ----------------------------------------------------------- order creation + + createOrder(input: { + marketplaceId: string; + externalOrderId: string; + buyerId: string; + sellerId: string; + amount: number; + currency?: string; + platformFeePercent?: number; + inspectionPeriodMs?: number; + milestones?: { id?: string; name: string; amount: number }[]; + }): MarketplaceOrder { + this.validate(!!input.marketplaceId, 'marketplaceId is required'); + this.validate(!!input.externalOrderId, 'externalOrderId is required'); + this.validate(!!input.buyerId && !!input.sellerId, 'buyerId and sellerId are required'); + this.validate(input.buyerId !== input.sellerId, 'Buyer and seller must be different'); + this.validate(input.amount > 0, 'Amount must be greater than 0'); + + const platformFeePercent = input.platformFeePercent ?? 10; + this.validate( + platformFeePercent >= 0 && platformFeePercent <= 100, + 'platformFeePercent must be between 0 and 100', + ); + + const amount = this.round(input.amount); + const milestones = input.milestones?.map((m) => { + this.validate(m.amount > 0, 'Milestone amount must be greater than 0'); + return { + id: m.id ?? `ms_${randomUUID()}`, + name: m.name, + amount: this.round(m.amount), + status: 'pending' as MilestoneStatus, + }; + }); + + if (milestones) { + this.validate(milestones.length > 0, 'milestones cannot be empty'); + const total = this.round(milestones.reduce((sum, m) => sum + m.amount, 0)); + this.validate(total === amount, 'Milestone amounts must sum to the order amount'); + } + + const now = this.now(); + const order: MarketplaceOrder = { + id: `mko_${randomUUID()}`, + marketplaceId: input.marketplaceId, + externalOrderId: input.externalOrderId, + buyerId: input.buyerId, + sellerId: input.sellerId, + amount, + currency: (input.currency ?? 'USD').toUpperCase(), + platformFeePercent, + platformFee: this.round((amount * platformFeePercent) / 100), + sellerPayout: 0, + refundedAmount: 0, + status: 'created', + milestones, + inspectionPeriodMs: input.inspectionPeriodMs ?? DEFAULT_INSPECTION_PERIOD_MS, + events: [{ type: 'order.created', at: new Date(now).toISOString() }], + createdAt: new Date(now).toISOString(), + updatedAt: new Date(now).toISOString(), + }; + + this.orders.set(order.id, order); + return order; + } + + getOrder(id: string): MarketplaceOrder | undefined { + return this.orders.get(id); + } + + listOrders(filter: { + marketplaceId?: string; + buyerId?: string; + sellerId?: string; + status?: MarketplaceOrderStatus; + limit?: number; + offset?: number; + } = {}): { orders: MarketplaceOrder[]; total: number } { + const all = Array.from(this.orders.values()) + .filter((o) => (!filter.marketplaceId || o.marketplaceId === filter.marketplaceId)) + .filter((o) => (!filter.buyerId || o.buyerId === filter.buyerId)) + .filter((o) => (!filter.sellerId || o.sellerId === filter.sellerId)) + .filter((o) => (!filter.status || o.status === filter.status)) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + + const offset = filter.offset ?? 0; + const limit = Math.min(filter.limit ?? 50, 100); + return { orders: all.slice(offset, offset + limit), total: all.length }; + } + + // ---------------------------------------------------------------- lifecycle + + fundOrder(id: string, txHash: string): MarketplaceOrder { + this.validate(!!txHash, 'txHash is required'); + const order = this.requireOrder(id); + this.assertStatus(order, ['created'], 'fund'); + + order.status = 'funded'; + order.fundingTxHash = txHash; + this.touch(order, 'order.funded', { txHash }); + return order; + } + + markShipped(id: string, trackingInfo?: string): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['funded'], 'ship'); + + order.status = 'shipped'; + order.trackingInfo = trackingInfo; + this.touch(order, 'order.shipped', trackingInfo ? { trackingInfo } : undefined); + return order; + } + + markDelivered(id: string): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['shipped'], 'deliver'); + + const deliveredAt = this.now(); + order.status = 'delivered'; + order.deliveredAt = new Date(deliveredAt).toISOString(); + order.autoReleaseAt = new Date(deliveredAt + order.inspectionPeriodMs).toISOString(); + this.touch(order, 'order.delivered', { autoReleaseAt: order.autoReleaseAt }); + return order; + } + + /** Release escrowed funds to the seller (minus the platform commission). */ + release(id: string, approvedBy = 'buyer'): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['funded', 'shipped', 'delivered'], 'release'); + + order.status = 'released'; + order.sellerPayout = this.round(order.amount - order.platformFee); + order.releasedAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.released', { approvedBy, sellerPayout: order.sellerPayout }); + return order; + } + + /** Release a single milestone, settling the order once every milestone is out. */ + releaseMilestone(id: string, milestoneId: string, approvedBy = 'buyer'): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus( + order, + ['funded', 'shipped', 'delivered', 'partially_released'], + 'release milestones for', + ); + this.validate(!!order.milestones?.length, 'Order has no milestones'); + this.validate(order.dispute === undefined, 'Cannot release milestones while a dispute is open'); + + const milestone = order.milestones!.find((m) => m.id === milestoneId); + if (!milestone) this.notFound('Milestone', milestoneId); + if (milestone.status === 'released') this.conflict('Milestone has already been released'); + + milestone.status = 'released'; + milestone.releasedAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.milestone_released', { milestoneId, amount: milestone.amount, approvedBy }); + + const allReleased = order.milestones!.every((m) => m.status === 'released'); + if (allReleased) { + order.status = 'released'; + order.sellerPayout = this.round(order.amount - order.platformFee); + order.releasedAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.released', { sellerPayout: order.sellerPayout }); + } else { + order.status = 'partially_released'; + } + + return order; + } + + /** Auto-release once the buyer inspection window has elapsed. */ + autoRelease(id: string): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['delivered'], 'auto-release'); + const autoReleaseAt = order.autoReleaseAt; + if (!autoReleaseAt) this.conflict('Order has no auto-release schedule'); + this.validate( + this.now() >= new Date(autoReleaseAt).getTime(), + 'Inspection window has not elapsed yet', + ); + + order.status = 'released'; + order.sellerPayout = this.round(order.amount - order.platformFee); + order.releasedAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.auto_released', { sellerPayout: order.sellerPayout }); + return order; + } + + // ---------------------------------------------------------------- protection + + raiseDispute(id: string, input: { raisedBy: string; reason: string }): MarketplaceOrder { + this.validate(!!input.raisedBy, 'raisedBy is required'); + this.validate(!!input.reason, 'reason is required'); + + const order = this.requireOrder(id); + this.assertStatus(order, ['funded', 'shipped', 'delivered', 'partially_released'], 'dispute'); + if (![order.buyerId, order.sellerId].includes(input.raisedBy)) { + this.forbidden('Only the buyer or seller can raise a dispute'); + } + + order.status = 'disputed'; + order.dispute = { + raisedBy: input.raisedBy, + reason: input.reason, + raisedAt: new Date(this.now()).toISOString(), + }; + this.touch(order, 'order.disputed', { raisedBy: input.raisedBy, reason: input.reason }); + return order; + } + + resolveDispute( + id: string, + input: { resolution: 'buyer' | 'seller' | 'split'; buyerPercent?: number; approvedBy: string }, + ): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['disputed'], 'resolve'); + this.validate(!!input.approvedBy, 'approvedBy is required'); + + const resolution = input.resolution; + let platformFee = order.platformFee; + let buyerRefund = 0; + let sellerPayout = 0; + + if (resolution === 'buyer') { + platformFee = 0; + buyerRefund = order.amount; + } else if (resolution === 'seller') { + sellerPayout = this.round(order.amount - order.platformFee); + } else { + const buyerPercent = input.buyerPercent ?? 50; + this.validate( + buyerPercent >= 0 && buyerPercent <= 100, + 'buyerPercent must be between 0 and 100', + ); + // Commission applies only to the portion that reaches the seller. + const distributable = this.round(order.amount - order.platformFee); + buyerRefund = this.round((distributable * buyerPercent) / 100); + sellerPayout = this.round(distributable - buyerRefund); + } + + order.platformFee = platformFee; + order.refundedAmount = buyerRefund; + order.sellerPayout = sellerPayout; + order.dispute = { + ...order.dispute!, + resolution, + buyerPercent: resolution === 'split' ? input.buyerPercent ?? 50 : undefined, + resolvedAt: new Date(this.now()).toISOString(), + }; + + if (resolution === 'buyer') { + order.status = 'refunded'; + order.refundedAt = new Date(this.now()).toISOString(); + } else if (resolution === 'split') { + order.status = 'partially_released'; + } else { + order.status = 'released'; + } + order.releasedAt = new Date(this.now()).toISOString(); + + this.touch(order, 'order.dispute_resolved', { resolution, buyerRefund, sellerPayout }); + return order; + } + + refund(id: string, reason: string): MarketplaceOrder { + this.validate(!!reason, 'Refund reason is required'); + const order = this.requireOrder(id); + this.assertStatus(order, ['funded', 'shipped', 'delivered'], 'refund'); + + order.status = 'refunded'; + order.platformFee = 0; + order.sellerPayout = 0; + order.refundedAmount = order.amount; + order.refundedAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.refunded', { reason }); + return order; + } + + cancelOrder(id: string): MarketplaceOrder { + const order = this.requireOrder(id); + this.assertStatus(order, ['created'], 'cancel'); + + order.status = 'cancelled'; + order.cancelledAt = new Date(this.now()).toISOString(); + this.touch(order, 'order.cancelled'); + return order; + } + + // ---------------------------------------------------------------- reporting + + getPayoutBreakdown(id: string): PayoutBreakdown { + const order = this.requireOrder(id); + const releasedMilestoneAmount = this.round( + (order.milestones ?? []) + .filter((m) => m.status === 'released') + .reduce((sum, m) => sum + m.amount, 0), + ); + + return { + orderId: order.id, + amount: order.amount, + currency: order.currency, + platformFee: order.platformFee, + sellerPayout: order.sellerPayout, + buyerRefund: order.refundedAmount, + releasedMilestoneAmount, + status: order.status, + }; + } + + getSettlementSummary(marketplaceId: string): { + marketplaceId: string; + orderCount: number; + escrowedVolume: number; + platformFeesCollected: number; + sellerPayouts: number; + buyerRefunds: number; + currency: string; + } { + const orders = this.listOrders({ marketplaceId, limit: 100 }).orders; + const settled = orders.filter((o) => ['released', 'partially_released'].includes(o.status)); + const refunded = orders.filter((o) => o.status === 'refunded'); + + return { + marketplaceId, + orderCount: orders.length, + escrowedVolume: this.round(settled.reduce((sum, o) => sum + o.amount, 0)), + platformFeesCollected: this.round(settled.reduce((sum, o) => sum + o.platformFee, 0)), + sellerPayouts: this.round(settled.reduce((sum, o) => sum + o.sellerPayout, 0)), + buyerRefunds: this.round( + refunded.reduce((sum, o) => sum + o.refundedAmount, 0) + + settled.reduce((sum, o) => sum + o.refundedAmount, 0), + ), + currency: orders[0]?.currency ?? 'USD', + }; + } + + resetForTests(): void { + this.orders.clear(); + } + + // --------------------------------------------------------------- internals + + private requireOrder(id: string): MarketplaceOrder { + const order = this.orders.get(id); + if (!order) this.notFound('Marketplace order', id); + return order; + } + + private assertStatus(order: MarketplaceOrder, allowed: MarketplaceOrderStatus[], action: string): void { + if (!allowed.includes(order.status)) { + this.conflict(`Cannot ${action} an order in status ${order.status}`); + } + } + + private touch(order: MarketplaceOrder, type: string, details?: Record): void { + const at = new Date(this.now()).toISOString(); + order.events.push({ type, at, details }); + order.updatedAt = at; + this.orders.set(order.id, order); + } + + private round(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; + } +} + +export const marketplaceEscrowService = new MarketplaceEscrowService(); diff --git a/docs/MARKETPLACE_ESCROW.md b/docs/MARKETPLACE_ESCROW.md new file mode 100644 index 00000000..d4bb53f7 --- /dev/null +++ b/docs/MARKETPLACE_ESCROW.md @@ -0,0 +1,48 @@ +# Marketplace Escrow Workflows + +Issue: [#915](https://github.com/Smartdevs17/agenticpay/issues/915) + +Hold a buyer's funds until delivery is confirmed, then settle the order by +paying the seller and taking a platform commission. + +## Backend + +Service: `backend/src/services/marketplace-escrow.ts` +Routes: `backend/src/routes/marketplace-escrow.ts` (mounted at `/api/v1/marketplace-escrow`) + +| Method | Path | Purpose | +| --- | --- | --- | +| `POST` | `/orders` | Create an escrowed order (optional milestones) | +| `GET` | `/orders` | List orders (`marketplaceId`, `buyerId`, `sellerId`, `status`) | +| `GET` | `/orders/:id` | Read an order (includes the event audit trail) | +| `GET` | `/orders/:id/payout` | Payout breakdown for an order | +| `POST` | `/orders/:id/fund` | Fund the order with a transaction hash | +| `POST` | `/orders/:id/ship` | Record shipment / tracking | +| `POST` | `/orders/:id/deliver` | Confirm delivery and start the inspection window | +| `POST` | `/orders/:id/release` | Release funds to the seller | +| `POST` | `/orders/:id/milestones/:milestoneId/release` | Release one milestone | +| `POST` | `/orders/:id/auto-release` | Release after the inspection window | +| `POST` | `/orders/:id/dispute` | Raise a buyer-protection dispute | +| `POST` | `/orders/:id/resolve` | Resolve a dispute (buyer / seller / split) | +| `POST` | `/orders/:id/refund` | Refund the buyer | +| `POST` | `/orders/:id/cancel` | Cancel an unfunded order | +| `GET` | `/settlements/:marketplaceId` | Marketplace settlement summary | + +### Lifecycle + +`created → funded → shipped → delivered → released` + +- After `deliver`, funds auto-release to the seller once + `inspectionPeriodMs` (default 7 days) elapses. +- Orders with milestones settle each milestone individually + (`partially_released`) and become `released` once every milestone is out. + Milestone amounts must sum to the order amount. +- The platform commission (`platformFeePercent`, default 10%) is deducted from + the seller payout on release. +- A dispute can be raised by the buyer or seller; resolution to `buyer` + refunds the full amount, `seller` releases it, and `split` refunds + `buyerPercent` of the distributable amount to the buyer. + +## Tests + +`backend/src/services/__tests__/marketplace-escrow.test.ts`