diff --git a/backend/src/middleware/payout-security.ts b/backend/src/middleware/payout-security.ts new file mode 100644 index 00000000..d12ef50f --- /dev/null +++ b/backend/src/middleware/payout-security.ts @@ -0,0 +1,347 @@ +/** + * Issue #1161: Strengthen payment security (iteration 6) + * + * Enhanced security middleware for payout operations + * Implements additional validation, fraud detection, and audit logging + */ + +import type { Request, Response, NextFunction } from 'express'; +import { createHash, randomBytes } from 'node:crypto'; + +export interface SecurityConfig { + /** Maximum payout amount before additional verification */ + maxAmountWithoutReview: number; + /** Enable velocity checks */ + enableVelocityChecks: boolean; + /** Maximum payouts per merchant per day */ + maxPayoutsPerDay: number; + /** Maximum total payout amount per merchant per day */ + maxDailyAmount: number; + /** Enable IP geolocation checks */ + enableGeoChecks: boolean; + /** Require 2FA for large payouts */ + require2FAThreshold: number; +} + +export const DEFAULT_SECURITY_CONFIG: SecurityConfig = { + maxAmountWithoutReview: 10000, + enableVelocityChecks: true, + maxPayoutsPerDay: 100, + maxDailyAmount: 100000, + enableGeoChecks: true, + require2FAThreshold: 25000, +}; + +interface PayoutAttempt { + merchantId: string; + amount: number; + timestamp: Date; + ipAddress: string; + userAgent: string; +} + +interface SecurityViolation { + type: 'velocity' | 'amount' | 'geo' | 'pattern' | '2fa'; + severity: 'low' | 'medium' | 'high'; + message: string; + details?: Record; +} + +/** + * In-memory store for payout attempts (would use Redis in production) + */ +class PayoutAttemptStore { + private attempts: Map = new Map(); + + add(attempt: PayoutAttempt): void { + const key = `${attempt.merchantId}:${this.getDayKey(attempt.timestamp)}`; + const existing = this.attempts.get(key) || []; + existing.push(attempt); + this.attempts.set(key, existing); + } + + getForMerchantToday(merchantId: string): PayoutAttempt[] { + const key = `${merchantId}:${this.getDayKey(new Date())}`; + return this.attempts.get(key) || []; + } + + getTotalAmountToday(merchantId: string): number { + const attempts = this.getForMerchantToday(merchantId); + return attempts.reduce((sum, attempt) => sum + attempt.amount, 0); + } + + getCountToday(merchantId: string): number { + return this.getForMerchantToday(merchantId).length; + } + + private getDayKey(date: Date): string { + return date.toISOString().split('T')[0]; + } + + cleanup(): void { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const cutoffKey = this.getDayKey(yesterday); + + for (const [key] of this.attempts) { + const dayKey = key.split(':')[1]; + if (dayKey < cutoffKey) { + this.attempts.delete(key); + } + } + } +} + +const attemptStore = new PayoutAttemptStore(); + +/** + * Security middleware for payout requests + */ +export function payoutSecurityMiddleware(config: SecurityConfig = DEFAULT_SECURITY_CONFIG) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const { merchantId, amount, asset } = req.body; + const ipAddress = (req.headers['x-forwarded-for'] as string) || req.socket.remoteAddress || 'unknown'; + const userAgent = req.headers['user-agent'] || 'unknown'; + + // Record attempt + const attempt: PayoutAttempt = { + merchantId, + amount: Number(amount), + timestamp: new Date(), + ipAddress, + userAgent, + }; + attemptStore.add(attempt); + + // Run security checks + const violations = await runSecurityChecks(attempt, config, req); + + if (violations.length > 0) { + // Check for high severity violations + const highSeverity = violations.filter((v) => v.severity === 'high'); + + if (highSeverity.length > 0) { + // Block request + res.status(403).json({ + error: { + code: 'SECURITY_VIOLATION', + message: 'Payout request blocked due to security concerns', + violations: highSeverity.map((v) => ({ + type: v.type, + message: v.message, + })), + }, + }); + return; + } + + // Medium/low severity: flag for review + req.body._securityFlags = violations; + req.body._requiresReview = true; + } + + next(); + } catch (error) { + next(error); + } + }; +} + +/** + * Run all security checks + */ +async function runSecurityChecks( + attempt: PayoutAttempt, + config: SecurityConfig, + req: Request +): Promise { + const violations: SecurityViolation[] = []; + + // 1. Amount threshold check + if (attempt.amount > config.maxAmountWithoutReview) { + violations.push({ + type: 'amount', + severity: 'medium', + message: `Payout amount ${attempt.amount} exceeds review threshold`, + details: { amount: attempt.amount, threshold: config.maxAmountWithoutReview }, + }); + } + + // 2. Velocity checks + if (config.enableVelocityChecks) { + const todayCount = attemptStore.getCountToday(attempt.merchantId); + const todayAmount = attemptStore.getTotalAmountToday(attempt.merchantId); + + if (todayCount >= config.maxPayoutsPerDay) { + violations.push({ + type: 'velocity', + severity: 'high', + message: 'Daily payout count limit exceeded', + details: { count: todayCount, limit: config.maxPayoutsPerDay }, + }); + } + + if (todayAmount >= config.maxDailyAmount) { + violations.push({ + type: 'velocity', + severity: 'high', + message: 'Daily payout amount limit exceeded', + details: { amount: todayAmount, limit: config.maxDailyAmount }, + }); + } + } + + // 3. 2FA check + if (attempt.amount >= config.require2FAThreshold) { + const has2FA = req.headers['x-2fa-token'] !== undefined; + if (!has2FA) { + violations.push({ + type: '2fa', + severity: 'high', + message: '2FA required for large payouts', + details: { amount: attempt.amount, threshold: config.require2FAThreshold }, + }); + } + } + + // 4. Pattern detection (simple example) + const recentAttempts = attemptStore.getForMerchantToday(attempt.merchantId); + const rapidAttempts = recentAttempts.filter( + (a) => a.timestamp.getTime() > Date.now() - 60000 // Last minute + ); + + if (rapidAttempts.length > 5) { + violations.push({ + type: 'pattern', + severity: 'medium', + message: 'Unusual payout frequency detected', + details: { recentCount: rapidAttempts.length }, + }); + } + + // 5. Geo checks (simplified - would use real IP geolocation service) + if (config.enableGeoChecks) { + const isSuspiciousIP = await checkIPReputation(attempt.ipAddress); + if (isSuspiciousIP) { + violations.push({ + type: 'geo', + severity: 'medium', + message: 'Request from suspicious IP address', + details: { ip: attempt.ipAddress }, + }); + } + } + + return violations; +} + +/** + * Check IP reputation (placeholder - would integrate with actual service) + */ +async function checkIPReputation(ipAddress: string): Promise { + // Placeholder: In production, integrate with IP reputation services + // like MaxMind, IPQualityScore, etc. + const suspiciousIPs = new Set(['0.0.0.0', '127.0.0.1']); + return suspiciousIPs.has(ipAddress); +} + +/** + * Generate secure idempotency key + */ +export function generateSecureIdempotencyKey(merchantId: string, nonce?: string): string { + const data = `${merchantId}:${nonce || randomBytes(16).toString('hex')}:${Date.now()}`; + return createHash('sha256').update(data).digest('hex'); +} + +/** + * Verify request signature (webhook/API signature verification) + */ +export function verifyRequestSignature( + payload: string, + signature: string, + secret: string +): boolean { + const expectedSignature = createHash('sha256') + .update(payload + secret) + .digest('hex'); + + return signature === expectedSignature; +} + +/** + * Rate limiting by merchant ID + */ +export function merchantRateLimiter( + windowMs: number = 60000, + maxRequests: number = 10 +) { + const requests = new Map(); + + return (req: Request, res: Response, next: NextFunction): void => { + const merchantId = req.body.merchantId || req.params.merchantId; + if (!merchantId) { + next(); + return; + } + + const now = Date.now(); + const windowStart = now - windowMs; + + let timestamps = requests.get(merchantId) || []; + timestamps = timestamps.filter((ts) => ts > windowStart); + + if (timestamps.length >= maxRequests) { + res.status(429).json({ + error: { + code: 'RATE_LIMIT_EXCEEDED', + message: `Too many requests. Maximum ${maxRequests} per ${windowMs / 1000} seconds`, + retryAfter: Math.ceil((timestamps[0] + windowMs - now) / 1000), + }, + }); + return; + } + + timestamps.push(now); + requests.set(merchantId, timestamps); + next(); + }; +} + +/** + * Audit logger for security events + */ +export class SecurityAuditLogger { + private logs: Array<{ + timestamp: Date; + event: string; + merchantId: string; + details: Record; + }> = []; + + log(event: string, merchantId: string, details: Record): void { + this.logs.push({ + timestamp: new Date(), + event, + merchantId, + details, + }); + + // In production, send to logging service (e.g., CloudWatch, Datadog) + console.log('[SECURITY AUDIT]', { event, merchantId, details }); + } + + getLogs(merchantId?: string): Array { + if (merchantId) { + return this.logs.filter((log) => log.merchantId === merchantId); + } + return this.logs; + } +} + +export const securityAuditLogger = new SecurityAuditLogger(); + +// Cleanup old attempts daily +setInterval(() => { + attemptStore.cleanup(); +}, 24 * 60 * 60 * 1000); diff --git a/backend/src/services/settlement/sar-bank-transfer-rail.ts b/backend/src/services/settlement/sar-bank-transfer-rail.ts new file mode 100644 index 00000000..8a68823d --- /dev/null +++ b/backend/src/services/settlement/sar-bank-transfer-rail.ts @@ -0,0 +1,222 @@ +/** + * Issue #1016: Add SAR/bank-transfer settlement rail + * + * SAR (Saudi Riyal) bank transfer settlement rail implementation + * Conforms to the PayoutExecutor interface for seamless integration + */ + +import type { PayoutExecutionRequest, PayoutExecutionResult } from '../payouts/payout-engine.js'; +import { randomUUID } from 'node:crypto'; + +export interface SARBankAccount { + accountNumber: string; + iban: string; + bankCode: string; + bankName: string; + accountHolderName: string; + swiftCode?: string; +} + +export interface SARTransferConfig { + /** API endpoint for the SAR banking provider */ + apiEndpoint: string; + /** API key for authentication */ + apiKey: string; + /** Timeout for transfer requests in milliseconds */ + timeoutMs?: number; + /** Enable sandbox mode for testing */ + sandboxMode?: boolean; +} + +export interface SARTransferRequest { + idempotencyKey: string; + amount: number; + currency: 'SAR'; + recipient: SARBankAccount; + reference: string; + merchantId: string; +} + +export interface SARTransferResponse { + transactionId: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + completedAt?: Date; + errorCode?: string; + errorMessage?: string; +} + +const DEFAULT_TIMEOUT_MS = 30000; + +/** + * SAR Bank Transfer Settlement Rail + * Implements PayoutExecutor interface for bank transfers in Saudi Riyals + */ +export class SARBankTransferRail { + private config: Required; + private pendingTransfers: Map; + + constructor(config: SARTransferConfig) { + this.config = { + ...config, + timeoutMs: config.timeoutMs ?? DEFAULT_TIMEOUT_MS, + sandboxMode: config.sandboxMode ?? false, + }; + this.pendingTransfers = new Map(); + } + + /** + * Execute a payout via SAR bank transfer + * Conforms to PayoutExecutor interface + */ + async execute(request: PayoutExecutionRequest): Promise { + // Validate currency (SAR only for this rail) + if (request.asset !== 'SAR') { + throw new Error(`SAR bank transfer rail only supports SAR currency, got ${request.asset}`); + } + + // Check for existing transfer with same idempotency key (deduplication) + const existing = this.pendingTransfers.get(request.idempotencyKey); + if (existing && existing.status === 'completed') { + return { + transactionHash: existing.transactionId, + settledAt: existing.completedAt, + }; + } + + // Prepare transfer request + const transferRequest: SARTransferRequest = { + idempotencyKey: request.idempotencyKey, + amount: request.amount, + currency: 'SAR', + recipient: await this.resolveBankAccount(request.merchantId), + reference: `Payout-${request.batchId}`, + merchantId: request.merchantId, + }; + + // Execute transfer + const response = await this.executeTransfer(transferRequest); + + // Store in pending transfers + this.pendingTransfers.set(request.idempotencyKey, response); + + // Return result + return { + transactionHash: response.transactionId, + settledAt: response.completedAt, + }; + } + + /** + * Execute the actual bank transfer via provider API + */ + private async executeTransfer(request: SARTransferRequest): Promise { + if (this.config.sandboxMode) { + // Sandbox mode: simulate successful transfer + return this.simulateSandboxTransfer(request); + } + + // Production mode: call actual banking API + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs); + + try { + const response = await fetch(`${this.config.apiEndpoint}/transfers`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.config.apiKey}`, + 'Idempotency-Key': request.idempotencyKey, + }, + body: JSON.stringify({ + amount: request.amount, + currency: request.currency, + recipient: request.recipient, + reference: request.reference, + merchantId: request.merchantId, + }), + signal: controller.signal, + }); + + clearTimeout(timeout); + + if (!response.ok) { + throw new Error(`Transfer failed: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + + return { + transactionId: data.transactionId, + status: data.status, + completedAt: data.completedAt ? new Date(data.completedAt) : undefined, + errorCode: data.errorCode, + errorMessage: data.errorMessage, + }; + } catch (error) { + clearTimeout(timeout); + + if (error instanceof Error && error.name === 'AbortError') { + throw new Error(`Transfer request timed out after ${this.config.timeoutMs}ms`); + } + + throw error; + } + } + + /** + * Simulate a sandbox transfer for testing + */ + private simulateSandboxTransfer(request: SARTransferRequest): SARTransferResponse { + const transactionId = `sandbox_sar_${randomUUID()}`; + + return { + transactionId, + status: 'completed', + completedAt: new Date(), + }; + } + + /** + * Resolve merchant's bank account details + * In production, this would query from database + */ + private async resolveBankAccount(merchantId: string): Promise { + // TODO: Query actual bank account from merchant settings + // For now, return a placeholder + return { + accountNumber: '1234567890', + iban: `SA${merchantId.slice(0, 22).padEnd(22, '0')}`, + bankCode: '80', + bankName: 'Saudi National Bank', + accountHolderName: 'Merchant Account', + swiftCode: 'NCBKSAJE', + }; + } + + /** + * Check status of a transfer + */ + async getTransferStatus(idempotencyKey: string): Promise { + return this.pendingTransfers.get(idempotencyKey) ?? null; + } + + /** + * List all pending transfers + */ + getPendingTransfers(): SARTransferResponse[] { + return Array.from(this.pendingTransfers.values()).filter( + (transfer) => transfer.status === 'pending' || transfer.status === 'processing' + ); + } +} + +/** + * Factory function to create a SAR bank transfer executor + */ +export function createSARBankTransferExecutor(config: SARTransferConfig) { + const rail = new SARBankTransferRail(config); + + return async (request: PayoutExecutionRequest): Promise => { + return rail.execute(request); + }; +} diff --git a/backend/src/utils/timezone-handler.ts b/backend/src/utils/timezone-handler.ts new file mode 100644 index 00000000..0c83d954 --- /dev/null +++ b/backend/src/utils/timezone-handler.ts @@ -0,0 +1,199 @@ +/** + * Issue #1022: Fix timezone handling in scheduled payouts + * + * Utility for normalizing all scheduled payout times to UTC + * Handles DST boundaries and provides local time conversion for UI display + */ + +/** + * Normalize any date/time to UTC + * All schedules are stored in UTC internally + */ +export function normalizeToUTC(date: Date): Date { + return new Date(date.toISOString()); +} + +/** + * Convert UTC timestamp to local time for display + * @param utcDate - Date in UTC + * @param timezone - IANA timezone identifier (e.g., 'America/New_York', 'Asia/Dubai') + */ +export function convertUTCToLocal(utcDate: Date, timezone: string): Date { + const utcTime = utcDate.getTime(); + const localString = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(utcTime); + + return new Date(localString); +} + +/** + * Convert local time to UTC for storage + * @param localDate - Date in local timezone + * @param timezone - IANA timezone identifier + */ +export function convertLocalToUTC(localDate: Date, timezone: string): Date { + // Create date string in the specified timezone + const localString = localDate.toLocaleString('en-US', { timeZone: timezone }); + const localTime = new Date(localString); + + // Calculate offset and adjust to UTC + const offset = localTime.getTime() - localDate.getTime(); + return new Date(localDate.getTime() - offset); +} + +/** + * Calculate next execution time in UTC for a scheduled payout + * @param scheduleType - daily, weekly, or monthly + * @param lastExecutionUTC - Last execution time in UTC (or now if first run) + * @param preferredHourUTC - Preferred hour of day in UTC (0-23) + */ +export function calculateNextExecutionUTC( + scheduleType: 'daily' | 'weekly' | 'monthly', + lastExecutionUTC: Date = new Date(), + preferredHourUTC: number = 0 +): Date { + const next = new Date(lastExecutionUTC); + next.setUTCHours(preferredHourUTC, 0, 0, 0); + + switch (scheduleType) { + case 'daily': + next.setUTCDate(next.getUTCDate() + 1); + break; + case 'weekly': + next.setUTCDate(next.getUTCDate() + 7); + break; + case 'monthly': + next.setUTCMonth(next.getUTCMonth() + 1); + break; + } + + return next; +} + +/** + * Check if a date is during a DST transition + * @param date - Date to check + * @param timezone - IANA timezone identifier + */ +export function isDSTTransition(date: Date, timezone: string): boolean { + const before = new Date(date.getTime() - 24 * 60 * 60 * 1000); + const after = new Date(date.getTime() + 24 * 60 * 60 * 1000); + + const formatOptions: Intl.DateTimeFormatOptions = { + timeZone: timezone, + timeZoneName: 'short', + }; + + const beforeTZ = new Intl.DateTimeFormat('en-US', formatOptions).format(before); + const dateTZ = new Intl.DateTimeFormat('en-US', formatOptions).format(date); + const afterTZ = new Intl.DateTimeFormat('en-US', formatOptions).format(after); + + // Extract timezone abbreviations + const extractTZ = (str: string) => str.split(' ').pop() || ''; + + return extractTZ(beforeTZ) !== extractTZ(dateTZ) || extractTZ(dateTZ) !== extractTZ(afterTZ); +} + +/** + * Get timezone offset in minutes for a specific date + * Accounts for DST + */ +export function getTimezoneOffset(date: Date, timezone: string): number { + const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); + const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + + return (tzDate.getTime() - utcDate.getTime()) / (1000 * 60); +} + +/** + * Format UTC date for display in a specific timezone + * @param utcDate - Date in UTC + * @param timezone - IANA timezone identifier + * @param includeTimezone - Whether to include timezone abbreviation + */ +export function formatForDisplay( + utcDate: Date, + timezone: string, + includeTimezone: boolean = true +): string { + const options: Intl.DateTimeFormatOptions = { + timeZone: timezone, + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }; + + if (includeTimezone) { + options.timeZoneName = 'short'; + } + + return new Intl.DateTimeFormat('en-US', options).format(utcDate); +} + +/** + * Validate IANA timezone identifier + */ +export function isValidTimezone(timezone: string): boolean { + try { + Intl.DateTimeFormat(undefined, { timeZone: timezone }); + return true; + } catch { + return false; + } +} + +/** + * Get common timezones for dropdown/selection + */ +export const COMMON_TIMEZONES = [ + { value: 'UTC', label: 'UTC' }, + { value: 'America/New_York', label: 'Eastern Time (US)' }, + { value: 'America/Chicago', label: 'Central Time (US)' }, + { value: 'America/Denver', label: 'Mountain Time (US)' }, + { value: 'America/Los_Angeles', label: 'Pacific Time (US)' }, + { value: 'Europe/London', label: 'London' }, + { value: 'Europe/Paris', label: 'Paris' }, + { value: 'Europe/Berlin', label: 'Berlin' }, + { value: 'Asia/Dubai', label: 'Dubai' }, + { value: 'Asia/Tokyo', label: 'Tokyo' }, + { value: 'Asia/Shanghai', label: 'Shanghai' }, + { value: 'Asia/Singapore', label: 'Singapore' }, + { value: 'Australia/Sydney', label: 'Sydney' }, +] as const; + +/** + * Schedule configuration with timezone support + */ +export interface ScheduleConfig { + scheduleType: 'daily' | 'weekly' | 'monthly'; + /** Preferred execution hour in merchant's local timezone (0-23) */ + preferredHourLocal: number; + /** Merchant's timezone (IANA identifier) */ + timezone: string; +} + +/** + * Convert schedule config to UTC-based next execution time + */ +export function scheduleToNextExecutionUTC(config: ScheduleConfig, lastExecution?: Date): Date { + // Convert preferred hour from local to UTC + const now = lastExecution || new Date(); + const localDate = new Date(now); + localDate.setHours(config.preferredHourLocal, 0, 0, 0); + + const utcDate = convertLocalToUTC(localDate, config.timezone); + const preferredHourUTC = utcDate.getUTCHours(); + + return calculateNextExecutionUTC(config.scheduleType, now, preferredHourUTC); +} diff --git a/docs/api/payout-api.yaml b/docs/api/payout-api.yaml new file mode 100644 index 00000000..d41812c7 --- /dev/null +++ b/docs/api/payout-api.yaml @@ -0,0 +1,501 @@ +openapi: 3.0.3 +info: + title: AgenticPay Payout API + description: | + Payout API for AgenticPay - handles scheduled and on-demand payouts to merchants. + + ## Issue #1015: Document the payout API with OpenAPI examples + + This specification provides comprehensive documentation for the payout endpoints, + including request/response schemas and examples for both success and error cases. + version: 1.0.0 + contact: + name: AgenticPay API Support + email: api-support@agenticpay.com +servers: + - url: https://api.agenticpay.com/v1 + description: Production server + - url: https://sandbox.agenticpay.com/v1 + description: Sandbox server +tags: + - name: Payouts + description: Payout management operations + - name: Schedules + description: Payout schedule configuration + +paths: + /payouts: + post: + summary: Create a payout + description: Create a new payout for a merchant + operationId: createPayout + tags: + - Payouts + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePayoutRequest' + examples: + basic: + summary: Basic payout + value: + merchantId: "merch_abc123" + amount: 1000.50 + asset: "USDC" + recipientAddress: "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37" + large-payout: + summary: Large payout requiring approval + value: + merchantId: "merch_xyz789" + amount: 50000.00 + asset: "USDC" + recipientAddress: "GDQP2KPQGKIHYJGXNUIYOMHARUARCA7DJT5FO2FFOOKY3B2WSQHG4W37" + metadata: + reason: "Monthly settlement" + responses: + '201': + description: Payout created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/PayoutResponse' + examples: + success: + summary: Successful payout creation + value: + id: "payout_1234567890" + idempotencyKey: "idem_abc123xyz" + merchantId: "merch_abc123" + status: "pending" + amount: 1000.50 + asset: "USDC" + requiresApproval: false + createdAt: "2026-10-17T22:30:00Z" + updatedAt: "2026-10-17T22:30:00Z" + approval-required: + summary: Payout requiring approval + value: + id: "payout_9876543210" + idempotencyKey: "idem_xyz789abc" + merchantId: "merch_xyz789" + status: "pending" + amount: 50000.00 + asset: "USDC" + requiresApproval: true + createdAt: "2026-10-17T22:30:00Z" + updatedAt: "2026-10-17T22:30:00Z" + '400': + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + invalid-amount: + summary: Invalid amount + value: + error: + code: "INVALID_AMOUNT" + message: "Amount must be greater than zero" + details: + field: "amount" + value: -100 + unsupported-asset: + summary: Unsupported asset + value: + error: + code: "UNSUPPORTED_ASSET" + message: "Asset BTC is not supported for payouts" + details: + supportedAssets: ["USDC", "USDT", "XLM", "EURC", "SAR"] + '401': + $ref: '#/components/responses/UnauthorizedError' + '429': + $ref: '#/components/responses/RateLimitError' + + get: + summary: List payouts + description: Retrieve a list of payouts for the authenticated merchant + operationId: listPayouts + tags: + - Payouts + security: + - bearerAuth: [] + parameters: + - name: merchantId + in: query + description: Filter by merchant ID + schema: + type: string + - name: status + in: query + description: Filter by payout status + schema: + type: string + enum: [pending, executing, completed, failed] + - name: limit + in: query + description: Number of results to return + schema: + type: integer + default: 20 + minimum: 1 + maximum: 100 + - name: offset + in: query + description: Number of results to skip + schema: + type: integer + default: 0 + minimum: 0 + responses: + '200': + description: List of payouts + content: + application/json: + schema: + $ref: '#/components/schemas/PayoutListResponse' + examples: + success: + summary: Successful list response + value: + data: + - id: "payout_1234567890" + merchantId: "merch_abc123" + status: "completed" + amount: 1000.50 + asset: "USDC" + createdAt: "2026-10-17T22:30:00Z" + settledAt: "2026-10-17T22:35:00Z" + - id: "payout_0987654321" + merchantId: "merch_abc123" + status: "pending" + amount: 2500.00 + asset: "USDC" + createdAt: "2026-10-17T23:00:00Z" + pagination: + limit: 20 + offset: 0 + total: 2 + '401': + $ref: '#/components/responses/UnauthorizedError' + + /payouts/{payoutId}: + get: + summary: Get payout details + description: Retrieve detailed information about a specific payout + operationId: getPayout + tags: + - Payouts + security: + - bearerAuth: [] + parameters: + - name: payoutId + in: path + required: true + description: The payout ID + schema: + type: string + responses: + '200': + description: Payout details + content: + application/json: + schema: + $ref: '#/components/schemas/PayoutResponse' + '404': + description: Payout not found + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + not-found: + value: + error: + code: "PAYOUT_NOT_FOUND" + message: "Payout with ID payout_invalid123 not found" + + /payouts/{payoutId}/approve: + post: + summary: Approve a payout + description: Approve a payout that requires manual approval + operationId: approvePayout + tags: + - Payouts + security: + - bearerAuth: [] + parameters: + - name: payoutId + in: path + required: true + description: The payout ID + schema: + type: string + responses: + '200': + description: Payout approved successfully + content: + application/json: + schema: + $ref: '#/components/schemas/PayoutResponse' + examples: + success: + value: + id: "payout_9876543210" + merchantId: "merch_xyz789" + status: "executing" + amount: 50000.00 + asset: "USDC" + requiresApproval: true + approvedBy: "user_approver123" + approvedAt: "2026-10-17T23:00:00Z" + '403': + description: Insufficient permissions + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + forbidden: + value: + error: + code: "INSUFFICIENT_PERMISSIONS" + message: "User does not have approval permissions" + + /payout-schedules: + post: + summary: Create payout schedule + description: Configure a recurring payout schedule for a merchant + operationId: createPayoutSchedule + tags: + - Schedules + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateScheduleRequest' + examples: + daily: + summary: Daily payout schedule + value: + merchantId: "merch_abc123" + scheduleType: "daily" + preferredAsset: "USDC" + autoApproveLimit: 5000 + threshold: + summary: Threshold-based schedule + value: + merchantId: "merch_xyz789" + scheduleType: "threshold" + thresholdAmount: 10000 + preferredAsset: "USDT" + autoApproveLimit: 8000 + responses: + '201': + description: Schedule created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/ScheduleResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + CreatePayoutRequest: + type: object + required: + - merchantId + - amount + - asset + properties: + merchantId: + type: string + description: Merchant identifier + amount: + type: number + format: double + minimum: 0.01 + description: Payout amount + asset: + type: string + enum: [USDC, USDT, XLM, EURC, SAR] + description: Asset/currency code + recipientAddress: + type: string + description: Recipient blockchain address or bank account + metadata: + type: object + additionalProperties: true + description: Optional metadata + + PayoutResponse: + type: object + properties: + id: + type: string + description: Unique payout identifier + idempotencyKey: + type: string + description: Idempotency key for deduplication + merchantId: + type: string + description: Merchant identifier + status: + type: string + enum: [pending, executing, completed, failed] + description: Current payout status + amount: + type: number + format: double + description: Payout amount + asset: + type: string + description: Asset/currency code + requiresApproval: + type: boolean + description: Whether manual approval is required + transactionHash: + type: string + description: Blockchain transaction hash (when settled) + errorMessage: + type: string + description: Error message if failed + approvedBy: + type: string + description: User ID who approved (if applicable) + approvedAt: + type: string + format: date-time + description: Approval timestamp + settledAt: + type: string + format: date-time + description: Settlement timestamp + createdAt: + type: string + format: date-time + description: Creation timestamp + updatedAt: + type: string + format: date-time + description: Last update timestamp + + PayoutListResponse: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/PayoutResponse' + pagination: + type: object + properties: + limit: + type: integer + offset: + type: integer + total: + type: integer + + CreateScheduleRequest: + type: object + required: + - merchantId + - scheduleType + - preferredAsset + properties: + merchantId: + type: string + scheduleType: + type: string + enum: [daily, weekly, monthly, threshold] + thresholdAmount: + type: number + format: double + description: Required for threshold schedules + preferredAsset: + type: string + enum: [USDC, USDT, XLM, EURC, SAR] + autoApproveLimit: + type: number + format: double + description: Payouts below this amount are auto-approved + + ScheduleResponse: + type: object + properties: + id: + type: string + merchantId: + type: string + scheduleType: + type: string + thresholdAmount: + type: number + format: double + preferredAsset: + type: string + autoApproveLimit: + type: number + createdAt: + type: string + format: date-time + + ErrorResponse: + type: object + properties: + error: + type: object + properties: + code: + type: string + description: Error code + message: + type: string + description: Human-readable error message + details: + type: object + additionalProperties: true + description: Additional error details + + responses: + UnauthorizedError: + description: Authentication required + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unauthorized: + value: + error: + code: "UNAUTHORIZED" + message: "Authentication required. Please provide a valid Bearer token." + + RateLimitError: + description: Rate limit exceeded + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + rate-limit: + value: + error: + code: "RATE_LIMIT_EXCEEDED" + message: "Too many requests. Please retry after 60 seconds." + details: + retryAfter: 60