Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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");
6 changes: 6 additions & 0 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
69 changes: 69 additions & 0 deletions apps/api/src/modules/payouts/dto/create-payout.dto.ts
Original file line number Diff line number Diff line change
@@ -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<typeof CreatePayoutSchema>;

// ── 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<typeof BulkPayoutSchema>;
14 changes: 14 additions & 0 deletions apps/api/src/modules/payouts/dto/payout-filters.dto.ts
Original file line number Diff line number Diff line change
@@ -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<typeof PayoutFiltersSchema>;
85 changes: 85 additions & 0 deletions apps/api/src/modules/payouts/payouts.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
13 changes: 12 additions & 1 deletion apps/api/src/modules/payouts/payouts.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading