diff --git a/apps/api/prisma/migrations/20260405000000_add_payout_idempotency_key/migration.sql b/apps/api/prisma/migrations/20260405000000_add_payout_idempotency_key/migration.sql new file mode 100644 index 0000000..65495b0 --- /dev/null +++ b/apps/api/prisma/migrations/20260405000000_add_payout_idempotency_key/migration.sql @@ -0,0 +1,9 @@ +-- AlterTable: add idempotencyKey and missing indexes to Payout +ALTER TABLE "Payout" ADD COLUMN "idempotencyKey" TEXT; +CREATE UNIQUE INDEX "Payout_idempotencyKey_key" ON "Payout"("idempotencyKey"); + +-- Add indexes +CREATE INDEX IF NOT EXISTS "Payout_merchantId_idx" ON "Payout"("merchantId"); +CREATE INDEX IF NOT EXISTS "Payout_status_idx" ON "Payout"("status"); +CREATE INDEX IF NOT EXISTS "Payout_batchId_idx" ON "Payout"("batchId"); +CREATE INDEX IF NOT EXISTS "Payout_createdAt_idx" ON "Payout"("createdAt"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 8452509..dd5d9cc 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -271,8 +271,14 @@ model Payout { completedAt DateTime? failureReason String? batchId String? + idempotencyKey String? @unique createdAt DateTime @default(now()) merchant Merchant @relation(fields: [merchantId], references: [id]) + + @@index([merchantId]) + @@index([status]) + @@index([batchId]) + @@index([createdAt]) } enum DestType { diff --git a/apps/api/src/modules/payouts/dto/create-payout.dto.ts b/apps/api/src/modules/payouts/dto/create-payout.dto.ts new file mode 100644 index 0000000..f6aa3b3 --- /dev/null +++ b/apps/api/src/modules/payouts/dto/create-payout.dto.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; + +// ── Destination sub-schemas ──────────────────────────────────────────────────── + +const BankAccountDestSchema = z.object({ + type: z.literal('BANK_ACCOUNT'), + accountNumber: z.string().min(1).max(64), + routingNumber: z.string().max(20).optional(), + bankName: z.string().max(255).optional(), + iban: z.string().max(34).optional(), + bic: z.string().max(11).optional(), + branchCode: z.string().max(20).optional(), + country: z.string().length(2).toUpperCase(), +}); + +const MobileMoneyDestSchema = z.object({ + type: z.literal('MOBILE_MONEY'), + phoneNumber: z.string().min(7).max(20), + provider: z.string().min(1).max(100), + country: z.string().length(2).toUpperCase(), +}); + +const CryptoWalletDestSchema = z.object({ + type: z.literal('CRYPTO_WALLET'), + address: z.string().min(1).max(255), + network: z.string().min(1).max(50), + asset: z.string().min(1).max(50), +}); + +const StellarDestSchema = z.object({ + type: z.literal('STELLAR'), + address: z.string().min(1).max(255), + asset: z.string().min(1).max(50).default('native'), + memo: z.string().max(28).optional(), +}); + +const DestinationSchema = z.discriminatedUnion('type', [ + BankAccountDestSchema, + MobileMoneyDestSchema, + CryptoWalletDestSchema, + StellarDestSchema, +]); + +// ── Create single payout ─────────────────────────────────────────────────────── + +export const CreatePayoutSchema = z.object({ + recipientName: z.string().min(1).max(255), + destinationType: z.enum(['BANK_ACCOUNT', 'MOBILE_MONEY', 'CRYPTO_WALLET', 'STELLAR']), + destination: DestinationSchema, + amount: z + .string() + .regex(/^\d+(\.\d{1,18})?$/, 'amount must be a positive decimal string') + .refine((v) => parseFloat(v) > 0, { message: 'amount must be greater than 0' }), + currency: z.string().length(3).toUpperCase(), + scheduledAt: z.coerce.date().optional(), +}); + +export type CreatePayoutDto = z.infer; + +// ── Bulk payout ──────────────────────────────────────────────────────────────── + +export const BulkPayoutSchema = z.object({ + payouts: z + .array(CreatePayoutSchema) + .min(1, 'At least one payout is required') + .max(10_000, 'Maximum 10,000 recipients per bulk payout'), +}); + +export type BulkPayoutDto = z.infer; diff --git a/apps/api/src/modules/payouts/dto/payout-filters.dto.ts b/apps/api/src/modules/payouts/dto/payout-filters.dto.ts new file mode 100644 index 0000000..fa90412 --- /dev/null +++ b/apps/api/src/modules/payouts/dto/payout-filters.dto.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const PayoutFiltersSchema = z.object({ + status: z.enum(['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED']).optional(), + destinationType: z.enum(['BANK_ACCOUNT', 'MOBILE_MONEY', 'CRYPTO_WALLET', 'STELLAR']).optional(), + currency: z.string().length(3).toUpperCase().optional(), + dateFrom: z.coerce.date().optional(), + dateTo: z.coerce.date().optional(), + batchId: z.string().optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +export type PayoutFiltersDto = z.infer; diff --git a/apps/api/src/modules/payouts/payouts.controller.ts b/apps/api/src/modules/payouts/payouts.controller.ts new file mode 100644 index 0000000..df7a7bb --- /dev/null +++ b/apps/api/src/modules/payouts/payouts.controller.ts @@ -0,0 +1,85 @@ +import { + Body, + Controller, + Get, + Headers, + HttpCode, + HttpStatus, + Param, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { PayoutsService } from './payouts.service'; +import { CreatePayoutSchema, CreatePayoutDto, BulkPayoutSchema, BulkPayoutDto } from './dto/create-payout.dto'; +import { PayoutFiltersSchema, PayoutFiltersDto } from './dto/payout-filters.dto'; +import { CombinedAuthGuard } from '../../common/guards/combined-auth.guard'; +import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; +import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe'; +import { CurrentMerchant } from '../merchant/decorators/current-merchant.decorator'; + +@Controller('v1/payouts') +@UseGuards(CombinedAuthGuard) +export class PayoutsController { + constructor(private readonly payoutsService: PayoutsService) {} + + // ── POST /v1/payouts ────────────────────────────────────────────────────── + @Post() + @HttpCode(HttpStatus.CREATED) + async create( + @CurrentMerchant('id') merchantId: string, + @Body(new ZodValidationPipe(CreatePayoutSchema)) dto: CreatePayoutDto, + @Headers('idempotency-key') idempotencyKey?: string, + ) { + return this.payoutsService.create(merchantId, dto, idempotencyKey); + } + + // ── POST /v1/payouts/bulk ───────────────────────────────────────────────── + @Post('bulk') + @HttpCode(HttpStatus.CREATED) + async createBulk( + @CurrentMerchant('id') merchantId: string, + @Body(new ZodValidationPipe(BulkPayoutSchema)) dto: BulkPayoutDto, + ) { + return this.payoutsService.createBulk(merchantId, dto); + } + + // ── GET /v1/payouts ─────────────────────────────────────────────────────── + @Get() + @UseGuards(JwtAuthGuard) + async list( + @CurrentMerchant('id') merchantId: string, + @Query(new ZodValidationPipe(PayoutFiltersSchema)) filters: PayoutFiltersDto, + ) { + return this.payoutsService.list(merchantId, filters); + } + + // ── GET /v1/payouts/:id ─────────────────────────────────────────────────── + @Get(':id') + async getOne( + @CurrentMerchant('id') merchantId: string, + @Param('id') id: string, + ) { + return this.payoutsService.getById(id, merchantId); + } + + // ── POST /v1/payouts/:id/cancel ─────────────────────────────────────────── + @Post(':id/cancel') + @UseGuards(JwtAuthGuard) + async cancel( + @CurrentMerchant('id') merchantId: string, + @Param('id') id: string, + ) { + return this.payoutsService.cancel(id, merchantId); + } + + // ── POST /v1/payouts/:id/retry ──────────────────────────────────────────── + @Post(':id/retry') + @UseGuards(JwtAuthGuard) + async retry( + @CurrentMerchant('id') merchantId: string, + @Param('id') id: string, + ) { + return this.payoutsService.retry(id, merchantId); + } +} diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 4b0e14b..581fcc7 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -1,4 +1,15 @@ import { Module } from '@nestjs/common'; +import { PayoutsController } from './payouts.controller'; +import { PayoutsService } from './payouts.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { WebhooksModule } from '../webhooks/webhooks.module'; +import { StellarModule } from '../stellar/stellar.module'; +import { AuthModule } from '../auth/auth.module'; -@Module({}) +@Module({ + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule], + providers: [PayoutsService], + controllers: [PayoutsController], + exports: [PayoutsService], +}) export class PayoutsModule {} diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts new file mode 100644 index 0000000..618ed05 --- /dev/null +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -0,0 +1,352 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { DestType, Payout, PayoutStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { WebhooksService } from '../webhooks/webhooks.service'; +import { StellarService } from '../stellar/stellar.service'; +import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; +import { PayoutFiltersDto } from './dto/payout-filters.dto'; +import { randomUUID } from 'crypto'; + +// ── Types ───────────────────────────────────────────────────────────────────── + +export interface BulkPayoutResult { + batchId: string; + total: number; + accepted: number; + rejected: number; + payouts: Array<{ index: number; payoutId?: string; error?: string }>; +} + +type AssetObject = { native?: boolean; code?: string; issuer?: string }; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function assetToString(asset: AssetObject): string { + if (asset.native) return 'native'; + return `${asset.code}:${asset.issuer}`; +} + +// ── Service ─────────────────────────────────────────────────────────────────── + +@Injectable() +export class PayoutsService { + private readonly logger = new Logger(PayoutsService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly webhooks: WebhooksService, + private readonly stellar: StellarService, + ) {} + + // ── Create single payout ────────────────────────────────────────────────── + + async create( + merchantId: string, + dto: CreatePayoutDto, + idempotencyKey?: string, + ): Promise { + // Deduplicate via idempotency key + if (idempotencyKey) { + const existing = await this.prisma.payout.findUnique({ + where: { idempotencyKey }, + }); + if (existing) { + if (existing.merchantId !== merchantId) { + throw new ConflictException('Idempotency key already used by another merchant'); + } + return existing; + } + } + + const payout = await this.prisma.payout.create({ + data: { + merchantId, + recipientName: dto.recipientName, + destinationType: dto.destinationType as DestType, + destination: dto.destination as Prisma.InputJsonValue, + amount: dto.amount, + currency: dto.currency, + status: PayoutStatus.PENDING, + scheduledAt: dto.scheduledAt ?? null, + idempotencyKey: idempotencyKey ?? null, + }, + }); + + this.webhooks + .dispatch(merchantId, 'payout.initiated', this.webhookPayload(payout) as Prisma.InputJsonValue) + .catch(() => undefined); + + // Process immediately unless scheduled for the future + if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { + this.processPayout(payout).catch(() => undefined); + } + + return payout; + } + + // ── Bulk payout ─────────────────────────────────────────────────────────── + + async createBulk(merchantId: string, dto: BulkPayoutDto): Promise { + const batchId = randomUUID(); + const results: BulkPayoutResult['payouts'] = []; + let accepted = 0; + let rejected = 0; + + for (let i = 0; i < dto.payouts.length; i++) { + const item = dto.payouts[i]; + try { + const payout = await this.prisma.payout.create({ + data: { + merchantId, + recipientName: item.recipientName, + destinationType: item.destinationType as DestType, + destination: item.destination as Prisma.InputJsonValue, + amount: item.amount, + currency: item.currency, + status: PayoutStatus.PENDING, + scheduledAt: item.scheduledAt ?? null, + batchId, + }, + }); + results.push({ index: i, payoutId: payout.id }); + accepted++; + + if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { + this.processPayout(payout).catch(() => undefined); + } + } catch (err) { + rejected++; + results.push({ + index: i, + error: err instanceof Error ? err.message : 'Failed to create payout', + }); + } + } + + this.webhooks + .dispatch(merchantId, 'payout.initiated', { + batchId, + total: dto.payouts.length, + accepted, + rejected, + } as Prisma.InputJsonValue) + .catch(() => undefined); + + return { batchId, total: dto.payouts.length, accepted, rejected, payouts: results }; + } + + // ── List ────────────────────────────────────────────────────────────────── + + async list(merchantId: string, filters: PayoutFiltersDto) { + const where: Prisma.PayoutWhereInput = { merchantId }; + + if (filters.status) where.status = filters.status as PayoutStatus; + if (filters.destinationType) where.destinationType = filters.destinationType as DestType; + if (filters.currency) where.currency = filters.currency; + if (filters.batchId) where.batchId = filters.batchId; + if (filters.dateFrom || filters.dateTo) { + where.createdAt = {}; + if (filters.dateFrom) where.createdAt.gte = filters.dateFrom; + if (filters.dateTo) where.createdAt.lte = filters.dateTo; + } + + const [total, payouts] = await Promise.all([ + this.prisma.payout.count({ where }), + this.prisma.payout.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: filters.limit, + skip: filters.offset, + }), + ]); + + return { + total, + limit: filters.limit, + offset: filters.offset, + data: payouts.map((p) => this.formatResponse(p)), + }; + } + + // ── Get by ID ───────────────────────────────────────────────────────────── + + async getById(id: string, merchantId: string): Promise { + const payout = await this.prisma.payout.findUnique({ where: { id } }); + if (!payout || payout.merchantId !== merchantId) { + throw new NotFoundException('Payout not found'); + } + return payout; + } + + // ── Cancel ──────────────────────────────────────────────────────────────── + + async cancel(id: string, merchantId: string): Promise { + const payout = await this.getById(id, merchantId); + + if (payout.status !== PayoutStatus.PENDING) { + throw new BadRequestException( + `Cannot cancel a payout in ${payout.status} status — only PENDING payouts can be cancelled`, + ); + } + + return this.prisma.payout.update({ + where: { id }, + data: { status: PayoutStatus.CANCELLED }, + }); + } + + // ── Retry ───────────────────────────────────────────────────────────────── + + async retry(id: string, merchantId: string): Promise { + const payout = await this.getById(id, merchantId); + + if (payout.status !== PayoutStatus.FAILED) { + throw new BadRequestException( + `Cannot retry a payout in ${payout.status} status — only FAILED payouts can be retried`, + ); + } + + const reset = await this.prisma.payout.update({ + where: { id }, + data: { status: PayoutStatus.PENDING, failureReason: null }, + }); + + this.webhooks + .dispatch(merchantId, 'payout.initiated', this.webhookPayload(reset) as Prisma.InputJsonValue) + .catch(() => undefined); + + this.processPayout(reset).catch(() => undefined); + + return reset; + } + + // ── Internal processing ─────────────────────────────────────────────────── + + private async processPayout(payout: Payout): Promise { + await this.prisma.payout.update({ + where: { id: payout.id }, + data: { status: PayoutStatus.PROCESSING }, + }); + + try { + const destination = payout.destination as Record; + + const isStellarPayout = + payout.destinationType === DestType.STELLAR || + (payout.destinationType === DestType.CRYPTO_WALLET && + typeof destination.network === 'string' && + destination.network.toLowerCase() === 'stellar'); + + if (isStellarPayout) { + await this.processStellarPayout(payout, destination); + } + // BANK_ACCOUNT and MOBILE_MONEY remain PROCESSING until external system + // delivers and calls back to update the status. + } catch (err) { + const failureReason = err instanceof Error ? err.message : 'Processing failed'; + const failed = await this.prisma.payout.update({ + where: { id: payout.id }, + data: { status: PayoutStatus.FAILED, failureReason }, + }); + this.webhooks + .dispatch(payout.merchantId, 'payout.failed', { + ...this.webhookPayload(failed), + failureReason, + } as Prisma.InputJsonValue) + .catch(() => undefined); + this.logger.error(`Payout ${payout.id} failed: ${failureReason}`); + } + } + + private async processStellarPayout( + payout: Payout, + destination: Record, + ): Promise { + const destAddress = String(destination.address); + const destAsset = typeof destination.asset === 'string' ? destination.asset : 'native'; + const sourceAsset = 'native'; + const sourceAmount = payout.amount.toString(); + + const { paths } = await this.stellar.findStrictSendPaths({ + sourceAsset, + sourceAmount, + destinationAssets: [destAsset], + destinationAccount: destAddress, + }); + + const bestPath = paths[0]; + // Convert asset objects to the 'native' | 'CODE:issuer' strings parseAsset expects + const pathStrings: string[] = (bestPath?.path ?? []).map((a) => + assetToString(a as AssetObject), + ); + const destMin = ( + parseFloat(bestPath?.destinationAmount ?? sourceAmount) * 0.99 + ).toFixed(7); + + const txHash = await this.stellar.executePathPayment({ + sourceAsset, + sourceAmount, + destinationAccount: destAddress, + destinationAsset: destAsset, + destinationMinAmount: destMin, + path: pathStrings, + }); + + const completed = await this.prisma.payout.update({ + where: { id: payout.id }, + data: { + status: PayoutStatus.COMPLETED, + completedAt: new Date(), + stellarTxHash: txHash, + }, + }); + + this.webhooks + .dispatch(payout.merchantId, 'payout.completed', { + ...this.webhookPayload(completed), + stellarTxHash: txHash, + } as Prisma.InputJsonValue) + .catch(() => undefined); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private formatResponse(payout: Payout) { + return { + id: payout.id, + merchantId: payout.merchantId, + recipientName: payout.recipientName, + destinationType: payout.destinationType, + destination: payout.destination, + amount: payout.amount.toString(), + currency: payout.currency, + status: payout.status, + stellarTxHash: payout.stellarTxHash, + scheduledAt: payout.scheduledAt, + completedAt: payout.completedAt, + failureReason: payout.failureReason, + batchId: payout.batchId, + createdAt: payout.createdAt, + }; + } + + private webhookPayload(payout: Payout): Record { + return { + payoutId: payout.id, + recipientName: payout.recipientName, + amount: payout.amount.toString(), + currency: payout.currency, + destinationType: payout.destinationType, + status: payout.status, + ...(payout.batchId && { batchId: payout.batchId }), + ...(payout.stellarTxHash && { stellarTxHash: payout.stellarTxHash }), + createdAt: payout.createdAt.toISOString(), + }; + } +}