Skip to content
Open
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
50 changes: 50 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Recipient Management System - TODO

Status: In Progress

## Steps from Approved Plan (Step-by-step breakdown)

### 1. Database & Prisma (Prisma model + migration)

- [x] Add Recipient model to apps/api/prisma/schema.prisma
- [x] Update Payout model (add recipientId optional FK)
- [x] Run prisma generate && prisma db push/migrate
- [ ] Update seed.ts with sample recipients

### 2. API Layer - Recipients Module (New CRUD)

- [x] Create apps/api/src/modules/recipients/recipients.module.ts
- [x] Create recipients.controller.ts (POST/GET/PATCH/DELETE /v1/recipients)
- [x] Create recipients.service.ts (CRUD logic)
- [x] Create dto/create-recipient.dto.ts (validate like payout DTO)
- [x] Create dto/recipient-filters.dto.ts (for list pagination/search)

### 3. API Updates - Payout Integration

- [x] Edit payouts.service.ts: create() support recipientId lookup/copy
- [x] Edit payouts.dto/create-payout.dto.ts: add optional recipientId field
- [x] Edit packages/types/src/payout.types.ts: Add Recipient interface

### 4. Dashboard UI - Recipients Management

- [x] Create apps/dashboard/src/app/(dashboard)/settings/recipients/page.tsx (list page)
- [x] Create components/recipients/RecipientsTable.tsx, AddRecipientDialog.tsx, EditRecipientDialog.tsx
- [x] Add search/filter/pagination

### 5. Dashboard UI - Payout Integration

- [x] Create/update apps/dashboard/src/app/(dashboard)/payouts/page.tsx
- [x] Create components/recipients/RecipientAutocomplete.tsx (select for payout form)
- [x] Integrate autocomplete: prefill form from selected recipient

### 6. Testing & Polish

- [ ] Manual test: CRUD recipients → select in payout → verify
- [ ] Add sample data via seed
- [ ] Update README/docs if needed

## Completed Steps

<!-- Updated after each completion -->

Next step: Start with Prisma schema update.
30 changes: 25 additions & 5 deletions apps/api/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -257,28 +257,48 @@ enum InvoiceStatus {

// ── Payouts ───────────────────────────────────────────────────

model Recipient {
id String @id @default(cuid())
merchantId String
name String
type DestType
details Json // validated per type: {accountNumber, routingNumber?, ...}
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
merchant Merchant @relation(fields: [merchantId], references: [id], onDelete: Cascade)
payouts Payout[]

@@unique([merchantId, name])
@@index([merchantId])
@@index([type])
}

model Payout {
id String @id @default(cuid())
id String @id @default(cuid())
merchantId String
recipientId String? // optional ref to saved Recipient
recipientName String
destinationType DestType
destination Json // {type, account, routing, ...}
amount Decimal @db.Decimal(36, 18)
amount Decimal @db.Decimal(36, 18)
currency String
status PayoutStatus @default(PENDING)
stellarTxHash String?
scheduledAt DateTime?
completedAt DateTime?
failureReason String?
batchId String?
idempotencyKey String? @unique
createdAt DateTime @default(now())
merchant Merchant @relation(fields: [merchantId], references: [id])
idempotencyKey String? @unique
createdAt DateTime @default(now())
merchant Merchant @relation(fields: [merchantId], references: [id])
recipient Recipient? @relation(fields: [recipientId], references: [id])

@@index([merchantId])
@@index([status])
@@index([batchId])
@@index([createdAt])
@@index([recipientId])
}

enum DestType {
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { RelayModule } from './modules/relay/relay.module';
import { NotificationsModule } from './modules/notifications/notifications.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { PrismaModule } from './modules/prisma/prisma.module';
import { RecipientsModule } from './modules/recipients/recipients.module';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { IdempotencyInterceptor } from './common/interceptors/idempotency.interceptor';
Expand Down Expand Up @@ -71,6 +72,7 @@ import { IdempotencyInterceptor } from './common/interceptors/idempotency.interc
RampModule,
RelayModule,
NotificationsModule,
RecipientsModule,
AnalyticsModule,
],
controllers: [AppController],
Expand Down
12 changes: 10 additions & 2 deletions apps/api/src/modules/payouts/dto/create-payout.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,21 @@ const DestinationSchema = z.discriminatedUnion('type', [
// ── Create single payout ───────────────────────────────────────────────────────

export const CreatePayoutSchema = z.object({
recipientId: z.string().optional(),
recipientName: z.string().min(1).max(255),
destinationType: z.enum(['BANK_ACCOUNT', 'MOBILE_MONEY', 'CRYPTO_WALLET', 'STELLAR']),
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' }),
.refine((v) => parseFloat(v) > 0, {
message: 'amount must be greater than 0',
}),
currency: z.string().length(3).toUpperCase(),
scheduledAt: z.coerce.date().optional(),
});
Expand Down
57 changes: 48 additions & 9 deletions apps/api/src/modules/payouts/payouts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class PayoutsService {

// ── Create single payout ──────────────────────────────────────────────────

async create(
async create(
merchantId: string,
dto: CreatePayoutDto,
idempotencyKey?: string,
Expand All @@ -64,12 +64,31 @@ export class PayoutsService {
}
}

let finalDto = { ...dto };

// If recipientId provided, lookup and merge details
if (dto.recipientId) {
const recipient = await this.prisma.recipient.findFirst({
where: { id: dto.recipientId, merchantId },
});
if (!recipient) {
throw new NotFoundException('Recipient not found');
}
finalDto = {
...dto,
recipientName: recipient.name,
destinationType: recipient.type,
destination: recipient.details as Prisma.InputJsonValue,
};
}

const payout = await this.prisma.payout.create({
data: {
merchantId,
recipientName: dto.recipientName,
destinationType: dto.destinationType as DestType,
destination: dto.destination as Prisma.InputJsonValue,
recipientId: dto.recipientId ?? null,
recipientName: finalDto.recipientName,
destinationType: finalDto.destinationType as DestType,
destination: finalDto.destination as Prisma.InputJsonValue,
amount: dto.amount,
currency: dto.currency,
status: PayoutStatus.PENDING,
Expand All @@ -92,7 +111,7 @@ export class PayoutsService {

// ── Bulk payout ───────────────────────────────────────────────────────────

async createBulk(merchantId: string, dto: BulkPayoutDto): Promise<BulkPayoutResult> {
async createBulk(merchantId: string, dto: BulkPayoutDto): Promise<BulkPayoutResult> {
const batchId = randomUUID();
const results: BulkPayoutResult['payouts'] = [];
let accepted = 0;
Expand All @@ -101,12 +120,31 @@ export class PayoutsService {
for (let i = 0; i < dto.payouts.length; i++) {
const item = dto.payouts[i];
try {
let finalItem = { ...item };

// If recipientId provided, lookup and merge details
if (item.recipientId) {
const recipient = await this.prisma.recipient.findFirst({
where: { id: item.recipientId, merchantId },
});
if (!recipient) {
throw new NotFoundException(`Recipient ${item.recipientId} not found`);
}
finalItem = {
...item,
recipientName: recipient.name,
destinationType: recipient.type,
destination: recipient.details as Prisma.InputJsonValue,
};
}

const payout = await this.prisma.payout.create({
data: {
merchantId,
recipientName: item.recipientName,
destinationType: item.destinationType as DestType,
destination: item.destination as Prisma.InputJsonValue,
recipientId: item.recipientId ?? null,
recipientName: finalItem.recipientName,
destinationType: finalItem.destinationType as DestType,
destination: finalItem.destination as Prisma.InputJsonValue,
amount: item.amount,
currency: item.currency,
status: PayoutStatus.PENDING,
Expand Down Expand Up @@ -317,10 +355,11 @@ export class PayoutsService {

// ── Helpers ───────────────────────────────────────────────────────────────

private formatResponse(payout: Payout) {
private formatResponse(payout: Payout) {
return {
id: payout.id,
merchantId: payout.merchantId,
recipientId: payout.recipientId,
recipientName: payout.recipientName,
destinationType: payout.destinationType,
destination: payout.destination,
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/modules/recipients/dto/create-recipient.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { z } from 'zod';
import { DestType } from '@prisma/client';

// Reuse payout DTO validation patterns
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,
]);

export const CreateRecipientSchema = z.object({
name: z.string().min(1).max(255),
type: z.nativeEnum(DestType),
details: DestinationSchema,
isDefault: z.boolean().optional(),
});

export type CreateRecipientDto = z.infer<typeof CreateRecipientSchema>;

CreateRecipientDto.schema = CreateRecipientSchema;

15 changes: 15 additions & 0 deletions apps/api/src/modules/recipients/dto/recipient-filters.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { z } from 'zod';
import { DestType } from '@prisma/client';

export const RecipientFiltersSchema = z.object({
search: z.string().optional(),
type: z.nativeEnum(DestType).optional(),
isDefault: z.boolean().optional(),
limit: z.coerce.number().min(1).max(100).default(50),
offset: z.coerce.number().min(0).default(0),
});

export type RecipientFiltersDto = z.infer<typeof RecipientFiltersSchema>;

RecipientFiltersDto.schema = RecipientFiltersSchema;

7 changes: 7 additions & 0 deletions apps/api/src/modules/recipients/dto/update-recipient.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { z } from 'zod';
import { CreateRecipientDto } from './create-recipient.dto';

export const UpdateRecipientSchema = CreateRecipientDto.schema.partial();

export type UpdateRecipientDto = z.infer<typeof UpdateRecipientSchema>;

73 changes: 73 additions & 0 deletions apps/api/src/modules/recipients/recipients.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { RecipientsService } from './recipients.service';
import { CreateRecipientDto } from './dto/create-recipient.dto';
import { RecipientFiltersDto } from './dto/recipient-filters.dto';
import { UpdateRecipientDto } from './dto/update-recipient.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/recipients')
@UseGuards(CombinedAuthGuard)
export class RecipientsController {
constructor(private readonly recipientsService: RecipientsService) {}

@Post()
@HttpCode(HttpStatus.CREATED)
async create(
@CurrentMerchant('id') merchantId: string,
@Body(new ZodValidationPipe(CreateRecipientDto.schema))
dto: CreateRecipientDto,
) {
return this.recipientsService.create(merchantId, dto);
}

@Get()
@UseGuards(JwtAuthGuard)
async list(
@CurrentMerchant('id') merchantId: string,
@Query(new ZodValidationPipe(RecipientFiltersDto.schema))
filters: RecipientFiltersDto,
) {
return this.recipientsService.list(merchantId, filters);
}

@Get(':id')
async getOne(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
) {
return this.recipientsService.getById(id, merchantId);
}

@Patch(':id')
async update(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
@Body() dto: UpdateRecipientDto,
) {
return this.recipientsService.update(id, merchantId, dto);
}

@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(
@CurrentMerchant('id') merchantId: string,
@Param('id') id: string,
) {
return this.recipientsService.delete(id, merchantId);
}
}
Loading