diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c517364 --- /dev/null +++ b/TODO.md @@ -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 + + + +Next step: Start with Prisma schema update. diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index dd5d9cc..8b1997c 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -257,13 +257,31 @@ 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? @@ -271,14 +289,16 @@ model Payout { 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 { diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index fcea1ce..52b7579 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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'; @@ -71,6 +72,7 @@ import { IdempotencyInterceptor } from './common/interceptors/idempotency.interc RampModule, RelayModule, NotificationsModule, + RecipientsModule, AnalyticsModule, ], controllers: [AppController], diff --git a/apps/api/src/modules/payouts/dto/create-payout.dto.ts b/apps/api/src/modules/payouts/dto/create-payout.dto.ts index f6aa3b3..8909d5a 100644 --- a/apps/api/src/modules/payouts/dto/create-payout.dto.ts +++ b/apps/api/src/modules/payouts/dto/create-payout.dto.ts @@ -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(), }); diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 618ed05..afce9d1 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -46,7 +46,7 @@ export class PayoutsService { // ── Create single payout ────────────────────────────────────────────────── - async create( +async create( merchantId: string, dto: CreatePayoutDto, idempotencyKey?: string, @@ -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, @@ -92,7 +111,7 @@ export class PayoutsService { // ── Bulk payout ─────────────────────────────────────────────────────────── - async createBulk(merchantId: string, dto: BulkPayoutDto): Promise { +async createBulk(merchantId: string, dto: BulkPayoutDto): Promise { const batchId = randomUUID(); const results: BulkPayoutResult['payouts'] = []; let accepted = 0; @@ -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, @@ -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, diff --git a/apps/api/src/modules/recipients/dto/create-recipient.dto.ts b/apps/api/src/modules/recipients/dto/create-recipient.dto.ts new file mode 100644 index 0000000..5066f88 --- /dev/null +++ b/apps/api/src/modules/recipients/dto/create-recipient.dto.ts @@ -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; + +CreateRecipientDto.schema = CreateRecipientSchema; + diff --git a/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts b/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts new file mode 100644 index 0000000..8c45158 --- /dev/null +++ b/apps/api/src/modules/recipients/dto/recipient-filters.dto.ts @@ -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; + +RecipientFiltersDto.schema = RecipientFiltersSchema; + diff --git a/apps/api/src/modules/recipients/dto/update-recipient.dto.ts b/apps/api/src/modules/recipients/dto/update-recipient.dto.ts new file mode 100644 index 0000000..dc0e378 --- /dev/null +++ b/apps/api/src/modules/recipients/dto/update-recipient.dto.ts @@ -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; + diff --git a/apps/api/src/modules/recipients/recipients.controller.ts b/apps/api/src/modules/recipients/recipients.controller.ts new file mode 100644 index 0000000..1230e30 --- /dev/null +++ b/apps/api/src/modules/recipients/recipients.controller.ts @@ -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); + } +} diff --git a/apps/api/src/modules/recipients/recipients.module.ts b/apps/api/src/modules/recipients/recipients.module.ts new file mode 100644 index 0000000..16393ae --- /dev/null +++ b/apps/api/src/modules/recipients/recipients.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { RecipientsController } from './recipients.controller'; +import { RecipientsService } from './recipients.service'; +import { PrismaModule } from '../prisma/prisma.module'; +import { AuthModule } from '../auth/auth.module'; + +@Module({ + imports: [PrismaModule, AuthModule], + controllers: [RecipientsController], + providers: [RecipientsService], + exports: [RecipientsService], +}) +export class RecipientsModule {} + diff --git a/apps/api/src/modules/recipients/recipients.service.ts b/apps/api/src/modules/recipients/recipients.service.ts new file mode 100644 index 0000000..fecf6db --- /dev/null +++ b/apps/api/src/modules/recipients/recipients.service.ts @@ -0,0 +1,125 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { DestType, Recipient } from '@prisma/client'; +import { CreateRecipientDto } from './dto/create-recipient.dto'; +import { RecipientFiltersDto } from './dto/recipient-filters.dto'; +import { UpdateRecipientDto } from './dto/update-recipient.dto'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class RecipientsService { + constructor(private readonly prisma: PrismaService) {} + + async create(merchantId: string, dto: CreateRecipientDto): Promise { + // Check unique name per merchant + const existing = await this.prisma.recipient.findUnique({ + where: { + merchantId_name: { merchantId, name: dto.name }, + }, + }); + + if (existing) { + throw new BadRequestException('Recipient name must be unique per merchant'); + } + + const recipient = await this.prisma.recipient.create({ + data: { + merchantId, + name: dto.name, + type: dto.type, + details: dto.details as Prisma.InputJsonValue, + isDefault: dto.isDefault ?? false, + }, + }); + + return recipient; + } + + async list(merchantId: string, filters: RecipientFiltersDto) { + const where: Prisma.RecipientWhereInput = { merchantId }; + + if (filters.type) where.type = filters.type as DestType; + if (filters.search) { + where.name = { contains: filters.search, mode: 'insensitive' }; + } + if (filters.isDefault !== undefined) { + where.isDefault = filters.isDefault; + } + + const [total, recipients] = await Promise.all([ + this.prisma.recipient.count({ where }), + this.prisma.recipient.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: filters.limit ?? 50, + skip: filters.offset ?? 0, + }), + ]); + + return { + total, + limit: filters.limit ?? 50, + offset: filters.offset ?? 0, + data: recipients, + }; + } + + async getById(id: string, merchantId: string): Promise { + const recipient = await this.prisma.recipient.findFirst({ + where: { id, merchantId }, + }); + + if (!recipient) { + throw new NotFoundException('Recipient not found'); + } + + return recipient; + } + + async update(id: string, merchantId: string, dto: UpdateRecipientDto): Promise { + const recipient = await this.getById(id, merchantId); + + // Check unique name if changing name + if (dto.name && dto.name !== recipient.name) { + const nameConflict = await this.prisma.recipient.findUnique({ + where: { + merchantId_name: { merchantId, name: dto.name }, + }, + }); + if (nameConflict) { + throw new BadRequestException('Recipient name must be unique per merchant'); + } + } + + return this.prisma.recipient.update({ + where: { id }, + data: { + name: dto.name, + type: dto.type, + details: dto.details as Prisma.InputJsonValue, + isDefault: dto.isDefault ?? recipient.isDefault, + }, + }); + } + + async delete(id: string, merchantId: string): Promise { + const recipient = await this.getById(id, merchantId); + + // Prevent deletion if referenced by payouts + const payoutCount = await this.prisma.payout.count({ + where: { recipientId: id }, + }); + if (payoutCount > 0) { + throw new BadRequestException('Cannot delete recipient used in payouts'); + } + + await this.prisma.recipient.delete({ + where: { id }, + }); + } +} + diff --git a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx index db93572..ea5bb72 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -1,38 +1,43 @@ -import { Button } from "@useroutr/ui"; +"use client"; + +import { useQuery } from '@tanstack/react-query'; +import { Button } from '@useroutr/ui'; +import { Plus } from 'lucide-react'; +import { RecipientsTable } from '@/components/recipients/RecipientsTable'; +import { CreatePayoutDialog } from '@/components/payouts/CreatePayoutDialog'; +import { PayoutsTable } from '@/components/payouts/PayoutsTable'; export default function PayoutsPage() { + const payoutsQuery = useQuery({ + queryKey: ['payouts'], + queryFn: async () => { + const res = await fetch('/api/v1/payouts'); + if (!res.ok) throw new Error('Failed to fetch payouts'); + return res.json(); + }, + }); + return (

Payouts

- +
{/* Summary cards */}
{["Available balance", "Pending", "Total paid out"].map((label) => ( -
+

{label}

))}
- {/* Table placeholder */} -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
- ))} -
-
+
); } + diff --git a/apps/dashboard/src/app/(dashboard)/settings/recipients/page.tsx b/apps/dashboard/src/app/(dashboard)/settings/recipients/page.tsx new file mode 100644 index 0000000..fb8e9ed --- /dev/null +++ b/apps/dashboard/src/app/(dashboard)/settings/recipients/page.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@useroutr/ui"; +import { Plus, Search } from "lucide-react"; +import { RecipientsTable } from "@/components/recipients/RecipientsTable"; +import { CreateRecipientDialog } from "@/components/recipients/CreateRecipientDialog"; + +export default function RecipientsPage() { + const recipientsQuery = useQuery({ + queryKey: ["recipients"], + queryFn: async () => { + const res = await fetch("/api/v1/recipients"); + if (!res.ok) throw new Error("Failed to fetch recipients"); + return res.json(); + }, + }); + + return ( +
+
+

+ Recipients +

+
+ + +
+
+ + +
+ ); +} diff --git a/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx b/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx new file mode 100644 index 0000000..d287f76 --- /dev/null +++ b/apps/dashboard/src/components/payouts/CreatePayoutDialog.tsx @@ -0,0 +1,87 @@ +"use client"; + +import { useState } from 'react'; +import { Button } from '@useroutr/ui'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { RecipientSelect } from '@/components/recipients/RecipientSelect'; + +export function CreatePayoutDialog() { + const [open, setOpen] = useState(false); + const [recipientId, setRecipientId] = useState(''); + const [amount, setAmount] = useState(''); + const [currency, setCurrency] = useState('USD'); + + const handleSubmit = () => { + fetch('/api/v1/payouts', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + recipientId, + amount, + currency, + }), + }).then(() => { + setOpen(false); + // Refetch payouts + window.dispatchEvent(new CustomEvent('payouts:refetch')); + }); + }; + + return ( + <> + + + + + New Payout + + Send money to a recipient + + +
+
+ + +
+
+ + setAmount(e.target.value)} + /> +
+
+ + +
+
+ + + +
+
+ + ); +} + diff --git a/apps/dashboard/src/components/payouts/PayoutsTable.tsx b/apps/dashboard/src/components/payouts/PayoutsTable.tsx new file mode 100644 index 0000000..d310d9d --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutsTable.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { format } from 'date-fns'; + +interface Payout { + id: string; + recipientName: string; + amount: string; + currency: string; + status: string; + createdAt: string; +} + +interface PayoutsTableProps { + data: Payout[]; + isLoading: boolean; +} + +export function PayoutsTable({ data, isLoading }: PayoutsTableProps) { + if (isLoading) { + return ( +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+
+
+ ))} +
+
+ ); + } + + return ( +
+ + + + ID + Recipient + Amount + Status + Created + + + + {data.map((payout) => ( + + {payout.id.slice(-8)} + {payout.recipientName} + + {payout.amount} {payout.currency} + + + + {payout.status} + + + {format(new Date(payout.createdAt), 'MMM dd, yyyy')} + + ))} + {data.length === 0 && ( + + + No payouts yet. Create your first one! + + + )} + +
+
+ ); +} + diff --git a/apps/dashboard/src/components/recipients/CreateRecipientDialog.tsx b/apps/dashboard/src/components/recipients/CreateRecipientDialog.tsx new file mode 100644 index 0000000..662222c --- /dev/null +++ b/apps/dashboard/src/components/recipients/CreateRecipientDialog.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState, useTransition } from 'react'; +import { Button } from '@useroutr/ui'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useToast } from '@/components/ui/use-toast'; +import { DestType } from '@useroutr/types'; + +interface CreateRecipientDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CreateRecipientDialog({ open, onOpenChange }: CreateRecipientDialogProps) { + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + const [name, setName] = useState(''); + const [type, setType] = useState('BANK_ACCOUNT'); + const [details, setDetails] = useState>({}); + + const handleTypeChange = (value: DestType) => { + setType(value); + setDetails({}); + }; + + const handleSubmit = () => { + startTransition(async () => { + const res = await fetch('/api/v1/recipients', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, type, details }), + }); + + if (res.ok) { + toast({ + title: "Recipient created", + description: `${name} has been saved.`, + }); + onOpenChange(false); + setName(''); + setType('BANK_ACCOUNT'); + setDetails({}); + // Refetch recipients + window.dispatchEvent(new CustomEvent('recipients:refetch')); + } else { + toast({ + title: "Error", + description: "Failed to create recipient", + variant: "destructive", + }); + } + }); + }; + + const renderDetailsForm = () => { + switch (type) { + case 'BANK_ACCOUNT': + return ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ ); + case 'CRYPTO_WALLET': + return ( +
+
+ + +
+
+ + +
+
+ ); + // Add more type forms... + default: + return null; + } + }; + + return ( + + + + New Recipient + + Add a recipient to save for future payouts. + + +
+
+ + setName(e.target.value)} + /> +
+
+ + +
+ {renderDetailsForm()} +
+ + + +
+
+ ); +} + diff --git a/apps/dashboard/src/components/recipients/DeleteRecipientDialog.tsx b/apps/dashboard/src/components/recipients/DeleteRecipientDialog.tsx new file mode 100644 index 0000000..59e2006 --- /dev/null +++ b/apps/dashboard/src/components/recipients/DeleteRecipientDialog.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Button } from '@useroutr/ui'; +import { DialogProps } from '@/components/ui/dialog'; + +interface DeleteRecipientDialogProps extends DialogProps { + id: string; +} + +export function DeleteRecipientDialog({ id, ...props }: DeleteRecipientDialogProps) { + return ( +
Delete dialog for {id} (TODO: implement)
+ ); +} + diff --git a/apps/dashboard/src/components/recipients/EditRecipientDialog.tsx b/apps/dashboard/src/components/recipients/EditRecipientDialog.tsx new file mode 100644 index 0000000..eb886fd --- /dev/null +++ b/apps/dashboard/src/components/recipients/EditRecipientDialog.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { Button } from '@useroutr/ui'; +import { DialogProps } from '@/components/ui/dialog'; + +interface EditRecipientDialogProps extends DialogProps { + id: string; +} + +export function EditRecipientDialog({ id, ...props }: EditRecipientDialogProps) { + return ( +
Edit dialog for {id} (TODO: implement form)
+ ); +} + diff --git a/apps/dashboard/src/components/recipients/RecipientSelect.tsx b/apps/dashboard/src/components/recipients/RecipientSelect.tsx new file mode 100644 index 0000000..f76d86e --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientSelect.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useQuery } from '@tanstack/react-query'; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Button } from '@/components/ui/button'; +import { Check, ChevronsUpDown, Loader2 } from 'lucide-react'; +import { Recipient } from '@useroutr/types'; +import { cn } from '@/lib/utils'; +import { useState } from 'react'; + +export function RecipientSelect({ + value, + onValueChange, + placeholder = "Select recipient...", +}: { + value: string; + onValueChange: (value: string) => void; + placeholder?: string; +}) { + const [open, setOpen] = useState(false); + + const recipientsQuery = useQuery({ + queryKey: ['recipients'], + queryFn: async () => { + const res = await fetch('/api/v1/recipients?limit=20'); + if (!res.ok) throw new Error('Failed to fetch recipients'); + return (await res.json()) as { data: Recipient[] }; + }, + }); + + const selectedRecipient = recipientsQuery.data?.data.find(r => r.id === value); + + return ( + + + + + + + + No recipients found. + + + {recipientsQuery.isLoading ? ( + + + Loading... + + ) : ( + recipientsQuery.data?.data.map((recipient) => ( + { + onValueChange(recipient.id === value ? '' : recipient.id); + setOpen(false); + }} + > + +
+ {recipient.name} + + {recipient.type.replace('_', ' ').toUpperCase()} + +
+
+ )) + )} +
+
+
+
+
+ ); +} + diff --git a/apps/dashboard/src/components/recipients/RecipientsTable.tsx b/apps/dashboard/src/components/recipients/RecipientsTable.tsx new file mode 100644 index 0000000..f33e5aa --- /dev/null +++ b/apps/dashboard/src/components/recipients/RecipientsTable.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useState } from 'react'; +import { Button } from '@useroutr/ui'; +import { MoreHorizontal, Trash2, Edit3 } from 'lucide-react'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Badge } from '@/components/ui/badge'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { DestType } from '@useroutr/types'; +import { EditRecipientDialog } from './EditRecipientDialog'; +import { DeleteRecipientDialog } from './DeleteRecipientDialog'; + +interface Recipient { + id: string; + name: string; + type: DestType; + isDefault: boolean; + createdAt: string; +} + +interface RecipientsTableProps { + data: Recipient[]; + total: number; + isLoading: boolean; +} + +export function RecipientsTable({ data, total, isLoading }: RecipientsTableProps) { + const [editId, setEditId] = useState(null); + const [deleteId, setDeleteId] = useState(null); + const [selected, setSelected] = useState([]); + + if (isLoading) { + return ( +
+
+
+
+
+ ); + } + + return ( +
+
+
+ +
+
+ {total} recipients +
+
+ +
+ + + + + Name + Type + Details + Default + Created + + + + {data.map((recipient) => ( + + + s.id === recipient.id)} + onChange={() => setSelected((prev) => + prev.some((s) => s.id === recipient.id) + ? prev.filter((s) => s.id !== recipient.id) + : [...prev, recipient] + )} + className="h-4 w-4 rounded border-gray-300" + /> + + {recipient.name} + + + {recipient.type.replace('_', ' ').toUpperCase()} + + + + {recipient.type === 'BANK_ACCOUNT' && '**** **** 1234'} + {recipient.type === 'CRYPTO_WALLET' && '0x...abc'} + {recipient.type === 'MOBILE_MONEY' && '+1 (555) 123-4567'} + {recipient.type === 'STELLAR' && 'G...'} + + + {recipient.isDefault && Default} + + + {new Date(recipient.createdAt).toLocaleDateString()} + + + + + + + + setEditId(recipient.id)}> + + Edit + + setDeleteId(recipient.id)}> + + Delete + + + + + + ))} + {data.length === 0 && ( + + + No recipients. + + + )} + +
+
+ + {editId && ( + setEditId(null)} + /> + )} + {deleteId && ( + setDeleteId(null)} + /> + )} +
+ ); +} + diff --git a/packages/types/src/payout.types.ts b/packages/types/src/payout.types.ts index fd556ee..14a84e3 100644 --- a/packages/types/src/payout.types.ts +++ b/packages/types/src/payout.types.ts @@ -1,6 +1,18 @@ +export interface Recipient { + id: string; + merchantId: string; + name: string; + type: 'BANK_ACCOUNT' | 'MOBILE_MONEY' | 'CRYPTO_WALLET' | 'STELLAR'; + details: Record; + isDefault: boolean; + createdAt: string; + updatedAt: string; +} + export interface Payout { id: string; merchantId?: string; + recipientId?: string; // new recipientName?: string; destination?: Record; amount: bigint | number | string;