diff --git a/apps/api/src/modules/payouts/payouts.module.ts b/apps/api/src/modules/payouts/payouts.module.ts index 581fcc7..b3be4b6 100644 --- a/apps/api/src/modules/payouts/payouts.module.ts +++ b/apps/api/src/modules/payouts/payouts.module.ts @@ -5,9 +5,10 @@ import { PrismaModule } from '../prisma/prisma.module'; import { WebhooksModule } from '../webhooks/webhooks.module'; import { StellarModule } from '../stellar/stellar.module'; import { AuthModule } from '../auth/auth.module'; +import { EventsModule } from '../events/events.module'; @Module({ - imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule], + imports: [PrismaModule, WebhooksModule, StellarModule, AuthModule, EventsModule], providers: [PayoutsService], controllers: [PayoutsController], exports: [PayoutsService], diff --git a/apps/api/src/modules/payouts/payouts.service.ts b/apps/api/src/modules/payouts/payouts.service.ts index 618ed05..20bc66b 100644 --- a/apps/api/src/modules/payouts/payouts.service.ts +++ b/apps/api/src/modules/payouts/payouts.service.ts @@ -9,6 +9,7 @@ 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 { EventsService } from '../events/events/events.service'; import { CreatePayoutDto, BulkPayoutDto } from './dto/create-payout.dto'; import { PayoutFiltersDto } from './dto/payout-filters.dto'; import { randomUUID } from 'crypto'; @@ -42,6 +43,7 @@ export class PayoutsService { private readonly prisma: PrismaService, private readonly webhooks: WebhooksService, private readonly stellar: StellarService, + private readonly events: EventsService, ) {} // ── Create single payout ────────────────────────────────────────────────── @@ -81,6 +83,7 @@ export class PayoutsService { this.webhooks .dispatch(merchantId, 'payout.initiated', this.webhookPayload(payout) as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(payout); // Process immediately unless scheduled for the future if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { @@ -116,6 +119,7 @@ export class PayoutsService { }); results.push({ index: i, payoutId: payout.id }); accepted++; + this.emitPayoutStatus(payout); if (!payout.scheduledAt || payout.scheduledAt <= new Date()) { this.processPayout(payout).catch(() => undefined); @@ -220,6 +224,7 @@ export class PayoutsService { this.webhooks .dispatch(merchantId, 'payout.initiated', this.webhookPayload(reset) as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(reset); this.processPayout(reset).catch(() => undefined); @@ -229,10 +234,11 @@ export class PayoutsService { // ── Internal processing ─────────────────────────────────────────────────── private async processPayout(payout: Payout): Promise { - await this.prisma.payout.update({ + const processing = await this.prisma.payout.update({ where: { id: payout.id }, data: { status: PayoutStatus.PROCESSING }, }); + this.emitPayoutStatus(processing); try { const destination = payout.destination as Record; @@ -260,6 +266,7 @@ export class PayoutsService { failureReason, } as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(failed); this.logger.error(`Payout ${payout.id} failed: ${failureReason}`); } } @@ -313,6 +320,7 @@ export class PayoutsService { stellarTxHash: txHash, } as Prisma.InputJsonValue) .catch(() => undefined); + this.emitPayoutStatus(completed); } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -349,4 +357,14 @@ export class PayoutsService { createdAt: payout.createdAt.toISOString(), }; } + + private emitPayoutStatus(payout: Payout): void { + this.events.emitPayoutStatus(payout.merchantId, payout.id, payout.status, { + amount: payout.amount.toString(), + currency: payout.currency, + stellarTxHash: payout.stellarTxHash ?? undefined, + failureReason: payout.failureReason ?? undefined, + updatedAt: new Date(), + }); + } } diff --git a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx index 2f8e2e7..67e6c7e 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -1,589 +1,848 @@ "use client"; -import { useState, useCallback, useMemo, useEffect } from "react"; -import { useSearchParams, useRouter } from "next/navigation"; -import { usePayouts, useRetryPayout, useCancelPayout, type Payout, type PayoutStatus, type DestType } from "@/hooks/usePayouts"; -import { DataTable, type Column, Pagination, Button, EmptyState, Skeleton } from "@useroutr/ui"; -import { DateRange } from "react-day-picker"; -import { Receipt, ArrowCounterClockwise, Prohibit } from "@phosphor-icons/react"; - -import { PayoutStatusBadge } from "@/components/payouts/PayoutStatusBadge"; -import { PayoutSearchInput } from "@/components/payouts/PayoutSearchInput"; -import { PayoutFilterBar } from "@/components/payouts/PayoutFilterBar"; -import { PayoutDetailDrawer } from "@/components/payouts/PayoutDetailDrawer"; -import { PayoutExportButton } from "@/components/payouts/PayoutExportButton"; -import { CancelConfirmationModal } from "@/components/payouts/CancelConfirmationModal"; -import { BatchGroupHeader } from "@/components/payouts/BatchGroupHeader"; -import { formatCurrency, truncateAddress } from "@/lib/utils"; -import { useToast } from "@useroutr/ui"; - -type PayoutWithIndex = Payout & Record; - -interface GroupedPayouts { - batched: Map; - unbatched: Payout[]; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + AlertCircle, + CheckCircle2, + Download, + FileSpreadsheet, + Pencil, + RefreshCcw, + RotateCcw, + ShieldCheck, + UploadCloud, +} from "lucide-react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@useroutr/ui"; +import { api } from "@/lib/api"; +import { cn } from "@/lib/utils"; +import { useDashboardSocket } from "@/hooks/useDashboardSocket"; + +const REQUIRED_HEADERS = [ + "recipient_name", + "destination_type", + "account_details", + "amount", + "currency", +] as const; + +const SUPPORTED_BANK_COUNTRIES = ["US", "GB", "NG", "KE", "GH", "ZA"]; +const SUPPORTED_MOBILE_COUNTRIES = ["NG", "KE", "GH", "ZA", "UG", "TZ", "RW"]; +const TWO_FACTOR_ENABLED = true; +const FEE_RATE = 0.012; + +type DestinationType = "BANK_ACCOUNT" | "MOBILE_MONEY" | "CRYPTO_WALLET" | "STELLAR"; +type ValidationStatus = "valid" | "error"; +type PayoutStatus = "PENDING" | "PROCESSING" | "COMPLETED" | "FAILED" | "CANCELLED"; + +interface EditableRow { + id: string; + rowNumber: number; + recipient_name: string; + destination_type: string; + account_details: string; + amount: string; + currency: string; + status: ValidationStatus; + errors: string[]; } -function groupPayoutsByBatch(payouts: Payout[]): GroupedPayouts { - const batched = new Map(); - const unbatched: Payout[] = []; - - for (const payout of payouts) { - if (payout.batchId) { - const existing = batched.get(payout.batchId) || []; - existing.push(payout); - batched.set(payout.batchId, existing); - } else { - unbatched.push(payout); - } - } +interface PayoutPayload { + recipientName: string; + destinationType: DestinationType; + destination: Record; + amount: string; + currency: string; +} - return { batched, unbatched }; +interface BulkPayoutResult { + batchId: string; + total: number; + accepted: number; + rejected: number; + payouts: Array<{ index: number; payoutId?: string; error?: string }>; } -export default function PayoutsPage() { - const searchParams = useSearchParams(); - const router = useRouter(); - const { toast } = useToast(); +interface PayoutListItem { + id: string; + recipientName: string; + destinationType: DestinationType; + amount: string; + currency: string; + status: PayoutStatus; + failureReason?: string | null; + batchId?: string | null; +} - // ── URL Sync & State ──────────────────────────────────────────────────────── +interface PayoutsResponse { + data: PayoutListItem[]; + total: number; +} - const [offset, setOffset] = useState(Number(searchParams.get("offset")) || 0); - const [limit, setLimit] = useState(Number(searchParams.get("limit")) || 20); - const [status, setStatus] = useState( - (searchParams.get("status") as PayoutStatus) || "" - ); - const [destinationType, setDestinationType] = useState( - (searchParams.get("destinationType") as DestType) || "" - ); - const [currency, setCurrency] = useState(searchParams.get("currency") || ""); - const [dateFrom, setDateFrom] = useState(searchParams.get("dateFrom") || ""); - const [dateTo, setDateTo] = useState(searchParams.get("dateTo") || ""); - const [batchId, setBatchId] = useState(searchParams.get("batchId") || ""); - const [search, setSearch] = useState(searchParams.get("search") || ""); - const [groupByBatch, setGroupByBatch] = useState( - searchParams.get("groupByBatch") === "true" - ); - const [expandedBatches, setExpandedBatches] = useState>(new Set()); - - // ── Drawer & Modal State ───────────────────────────────────────────────────── - - const [selectedPayout, setSelectedPayout] = useState(); - const [drawerOpen, setDrawerOpen] = useState(false); - const [cancelModalOpen, setCancelModalOpen] = useState(false); - - // ── Data Fetching ─────────────────────────────────────────────────────────── - - const { data, isLoading, isFetching, error } = usePayouts({ - offset, - limit, - status: status || undefined, - destinationType: destinationType || undefined, - currency: currency || undefined, - dateFrom: dateFrom || undefined, - dateTo: dateTo || undefined, - batchId: batchId || undefined, - search: search || undefined, - }); +interface ProgressRecipient { + rowId: string; + payoutId?: string; + recipientName: string; + amount: string; + currency: string; + status: PayoutStatus; + failureReason?: string; +} - const payouts = data?.data || []; - const totalPayouts = data?.total || 0; +interface SocketEnvelope { + event?: string; + data?: { + payoutId?: string; + status?: PayoutStatus; + failureReason?: string; + }; +} - // ── Grouping ───────────────────────────────────────────────────────────────── +const templateCsv = `${REQUIRED_HEADERS.join(",")} +Amina Bello,BANK_ACCOUNT,"accountNumber=1234567890;bankName=Kuda;country=NG",2500,NGN +Kwame Mensah,MOBILE_MONEY,"phoneNumber=+233241234567;provider=MTN;country=GH",450,GHS +Nova Labs,STELLAR,"address=GBZXN7PIRZGNMHGAQXUKYOOSHLJXBS5M4NUJ2S7NGW3MY6RIM7E6WYOG;asset=native",75,USD`; + +function parseCsv(text: string): string[][] { + const rows: string[][] = []; + let current = ""; + let row: string[] = []; + let inQuotes = false; + + for (let i = 0; i < text.length; i++) { + const char = text[i]; + const next = text[i + 1]; + + if (char === '"' && inQuotes && next === '"') { + current += '"'; + i++; + continue; + } - const groupedPayouts = useMemo(() => { - if (!groupByBatch) return null; - return groupPayoutsByBatch(payouts); - }, [payouts, groupByBatch]); + if (char === '"') { + inQuotes = !inQuotes; + continue; + } - // ── Mutations ──────────────────────────────────────────────────────────────── + if (char === "," && !inQuotes) { + row.push(current.trim()); + current = ""; + continue; + } - const retryMutation = useRetryPayout(); - const cancelMutation = useCancelPayout(); + if ((char === "\n" || char === "\r") && !inQuotes) { + if (char === "\r" && next === "\n") i++; + row.push(current.trim()); + if (row.some(Boolean)) rows.push(row); + row = []; + current = ""; + continue; + } - // ── URL Update ─────────────────────────────────────────────────────────────── + current += char; + } - const updateUrl = useCallback(() => { - const params = new URLSearchParams(); - if (offset > 0) params.set("offset", String(offset)); - if (limit !== 20) params.set("limit", String(limit)); - if (status) params.set("status", status); - if (destinationType) params.set("destinationType", destinationType); - if (currency) params.set("currency", currency); - if (dateFrom) params.set("dateFrom", dateFrom); - if (dateTo) params.set("dateTo", dateTo); - if (batchId) params.set("batchId", batchId); - if (search) params.set("search", search); - if (groupByBatch) params.set("groupByBatch", "true"); + row.push(current.trim()); + if (row.some(Boolean)) rows.push(row); + return rows; +} - const queryString = params.toString(); - const newUrl = queryString ? `?${queryString}` : ""; - window.history.replaceState(null, "", newUrl); - }, [offset, limit, status, destinationType, currency, dateFrom, dateTo, batchId, search, groupByBatch]); +function parseAccountDetails(value: string): Record { + const trimmed = value.trim(); + if (!trimmed) return {}; + + try { + const parsed = JSON.parse(trimmed) as Record; + return Object.fromEntries( + Object.entries(parsed).map(([key, item]) => [key, String(item)]) + ); + } catch { + return Object.fromEntries( + trimmed + .split(";") + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + const [key, ...rest] = part.split("="); + return [key.trim(), rest.join("=").trim()]; + }) + .filter(([key, item]) => key && item) + ); + } +} - useEffect(() => { - updateUrl(); - }, [updateUrl]); - - // ── Handlers ──────────────────────────────────────────────────────────────── - - const resetFilters = () => { - setOffset(0); - setStatus(""); - setDestinationType(""); - setCurrency(""); - setDateFrom(""); - setDateTo(""); - setBatchId(""); - setSearch(""); - setGroupByBatch(false); - setExpandedBatches(new Set()); - }; +function normalizeDestinationType(value: string): DestinationType | null { + const normalized = value.trim().toUpperCase().replace(/[\s-]+/g, "_"); + if (["BANK_ACCOUNT", "MOBILE_MONEY", "CRYPTO_WALLET", "STELLAR"].includes(normalized)) { + return normalized as DestinationType; + } + return null; +} - const handleSearch = (query: string) => { - setSearch(query); - setOffset(0); - }; +function validateRow(row: Omit): EditableRow { + const errors: string[] = []; + const destinationType = normalizeDestinationType(row.destination_type); + const details = parseAccountDetails(row.account_details); + const amount = Number(row.amount); + const currency = row.currency.trim().toUpperCase(); + + if (!row.recipient_name.trim()) errors.push("Recipient name is required"); + if (!destinationType) errors.push("Unsupported destination type"); + if (!Number.isFinite(amount) || amount <= 0) errors.push("Amount must be greater than 0"); + if (!/^[A-Z]{3}$/.test(currency)) errors.push("Currency must be a 3-letter code"); + + if (destinationType === "BANK_ACCOUNT") { + const country = details.country?.toUpperCase(); + if (!country || !SUPPORTED_BANK_COUNTRIES.includes(country)) { + errors.push("Unsupported country for bank payout"); + } + if (!/^\d{6,20}$/.test(details.accountNumber ?? "")) { + errors.push("Invalid account number"); + } + } - const handleStatusChange = (newStatus: string) => { - setStatus(newStatus as PayoutStatus | ""); - setOffset(0); - }; + if (destinationType === "MOBILE_MONEY") { + const country = details.country?.toUpperCase(); + if (!country || !SUPPORTED_MOBILE_COUNTRIES.includes(country)) { + errors.push("Unsupported country for mobile money"); + } + if (!/^\+?\d{7,15}$/.test(details.phoneNumber ?? "")) { + errors.push("Invalid mobile money phone number"); + } + if (!details.provider) errors.push("Mobile money provider is required"); + } - const handleDestinationTypeChange = (type: string) => { - setDestinationType(type as DestType | ""); - setOffset(0); - }; + if (destinationType === "CRYPTO_WALLET") { + if (!details.address || details.address.length < 12) errors.push("Invalid wallet address"); + if (!details.network) errors.push("Wallet network is required"); + } - const handleCurrencyChange = (curr: string) => { - setCurrency(curr); - setOffset(0); - }; + if (destinationType === "STELLAR") { + if (!/^G[A-Z2-7]{55}$/.test(details.address ?? "")) { + errors.push("Invalid Stellar account"); + } + } - const handleDateRangeChange = (range: DateRange | undefined) => { - setDateFrom(range?.from ? range.from.toISOString() : ""); - setDateTo(range?.to ? range.to.toISOString() : ""); - setOffset(0); + return { + ...row, + destination_type: destinationType ?? row.destination_type, + currency, + status: errors.length ? "error" : "valid", + errors, }; +} - const handleBatchIdChange = (id: string) => { - setBatchId(id); - setOffset(0); +function rowToPayout(row: EditableRow): PayoutPayload { + const destinationType = normalizeDestinationType(row.destination_type) as DestinationType; + const details = parseAccountDetails(row.account_details); + + return { + recipientName: row.recipient_name.trim(), + destinationType, + destination: { + ...details, + type: destinationType, + ...(destinationType === "BANK_ACCOUNT" || destinationType === "MOBILE_MONEY" + ? { country: details.country.toUpperCase() } + : {}), + ...(destinationType === "STELLAR" && !details.asset ? { asset: "native" } : {}), + ...(destinationType === "CRYPTO_WALLET" && !details.asset + ? { asset: row.currency.trim().toUpperCase() } + : {}), + }, + amount: Number(row.amount).toFixed(2), + currency: row.currency.trim().toUpperCase(), }; +} - const toggleBatchExpansion = (batchId: string) => { - setExpandedBatches((prev) => { - const next = new Set(prev); - if (next.has(batchId)) { - next.delete(batchId); - } else { - next.add(batchId); - } - return next; - }); - }; +function formatMoney(amount: number, currency = "USD") { + return new Intl.NumberFormat("en", { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(amount); +} - const handleRowClick = (payout: Payout) => { - setSelectedPayout(payout); - setDrawerOpen(true); - }; +function formatTotals(totals: Record) { + const entries = Object.entries(totals).filter(([, amount]) => amount > 0); + if (!entries.length) return formatMoney(0, "USD"); + return entries.map(([currency, amount]) => formatMoney(amount, currency)).join(" + "); +} - const handleRetry = async (id: string) => { - try { - await retryMutation.mutateAsync(id); - toast({ - title: "Payout Retried", - description: "The payout has been queued for retry.", - variant: "default", - }); - } catch (err) { - toast({ - title: "Retry Failed", - description: err instanceof Error ? err.message : "Failed to retry payout", - variant: "destructive", - }); - } - }; +function statusClass(status: PayoutStatus) { + if (status === "COMPLETED") return "bg-emerald-100 text-emerald-700"; + if (status === "FAILED") return "bg-red-100 text-red-700"; + if (status === "PROCESSING") return "bg-blue-100 text-blue-700"; + if (status === "CANCELLED") return "bg-zinc-100 text-zinc-600"; + return "bg-amber-100 text-amber-700"; +} - const handleCancelClick = (payout: Payout) => { - setSelectedPayout(payout); - setCancelModalOpen(true); - }; +export default function PayoutsPage() { + const fileInputRef = useRef(null); + const [activeTab, setActiveTab] = useState<"single" | "bulk">("bulk"); + const [rows, setRows] = useState([]); + const [fileName, setFileName] = useState(""); + const [uploadError, setUploadError] = useState(""); + const [editingId, setEditingId] = useState(null); + const [twoFactorCode, setTwoFactorCode] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(""); + const [progress, setProgress] = useState([]); + + const { connected, subscribe } = useDashboardSocket(); + + const { data: payouts } = useQuery({ + queryKey: ["payouts", "recent"], + queryFn: () => api.get("/payouts", { params: { limit: 100, offset: 0 } }), + }); - const handleCancelConfirm = async () => { - if (!selectedPayout) return; - try { - await cancelMutation.mutateAsync(selectedPayout.id); - setCancelModalOpen(false); - toast({ - title: "Payout Cancelled", - description: "The payout has been cancelled successfully.", - variant: "default", - }); - } catch (err) { - toast({ - title: "Cancel Failed", - description: err instanceof Error ? err.message : "Failed to cancel payout", - variant: "destructive", + const validRows = rows.filter((row) => row.status === "valid"); + const invalidRows = rows.filter((row) => row.status === "error"); + const totalsByCurrency = validRows.reduce>((totals, row) => { + const currency = row.currency || "USD"; + totals[currency] = (totals[currency] ?? 0) + Number(row.amount || 0); + return totals; + }, {}); + const feesByCurrency = Object.fromEntries( + Object.entries(totalsByCurrency).map(([currency, amount]) => [currency, amount * FEE_RATE]) + ); + const debitByCurrency = Object.fromEntries( + Object.entries(totalsByCurrency).map(([currency, amount]) => [ + currency, + amount + (feesByCurrency[currency] ?? 0), + ]) + ); + const completedCount = progress.filter((item) => item.status === "COMPLETED").length; + const finishedCount = progress.filter((item) => ["COMPLETED", "FAILED"].includes(item.status)).length; + const progressPercent = progress.length ? Math.round((finishedCount / progress.length) * 100) : 0; + + const summaryCards = useMemo(() => { + const data = payouts?.data ?? []; + const pending = data.filter((item) => item.status === "PENDING" || item.status === "PROCESSING").length; + const paidOut = data + .filter((item) => item.status === "COMPLETED") + .reduce((sum, item) => sum + Number(item.amount), 0); + + return [ + { label: "Available balance", value: formatMoney(128_450, "USD") }, + { label: "Pending", value: String(pending) }, + { label: "Total paid out", value: formatMoney(paidOut || 0, "USD") }, + ]; + }, [payouts]); + + useEffect(() => { + return subscribe("message", (payload) => { + const envelope = payload as SocketEnvelope; + if (envelope.event !== "payout:status" || !envelope.data?.payoutId || !envelope.data.status) return; + + setProgress((current) => + current.map((recipient) => + recipient.payoutId === envelope.data?.payoutId + ? { + ...recipient, + status: envelope.data.status, + failureReason: envelope.data.failureReason ?? recipient.failureReason, + } + : recipient + ) + ); + }); + }, [subscribe]); + + function loadCsv(text: string, name: string) { + setUploadError(""); + setSubmitError(""); + setProgress([]); + + const parsed = parseCsv(text); + const [headers, ...body] = parsed; + const normalizedHeaders = headers?.map((header) => header.trim().toLowerCase()) ?? []; + const missing = REQUIRED_HEADERS.filter((header) => !normalizedHeaders.includes(header)); + + if (!headers?.length || missing.length) { + setRows([]); + setUploadError(`Missing columns: ${missing.join(", ")}`); + return; + } + + const nextRows = body.map((values, index) => { + const raw = Object.fromEntries( + REQUIRED_HEADERS.map((header) => [ + header, + values[normalizedHeaders.indexOf(header)] ?? "", + ]) + ) as Record<(typeof REQUIRED_HEADERS)[number], string>; + + return validateRow({ + id: `${Date.now()}-${index}`, + rowNumber: index + 2, + recipient_name: raw.recipient_name, + destination_type: raw.destination_type, + account_details: raw.account_details, + amount: raw.amount, + currency: raw.currency, }); + }); + + setRows(nextRows); + setFileName(name); + } + + async function handleFile(file: File) { + if (!file.name.toLowerCase().endsWith(".csv")) { + setUploadError("Upload a CSV file"); + return; } - }; + loadCsv(await file.text(), file.name); + } - // ── Table Columns ─────────────────────────────────────────────────────────── - - const columns: Column[] = useMemo( - () => [ - { - key: "id", - header: "Payout ID", - sortable: false, - render: (payout) => ( - - {payout.id.slice(0, 12)}... - - ), - }, - { - key: "recipientName", - header: "Recipient", - sortable: false, - render: (payout) => ( -
-
- - {payout.recipientName.charAt(0).toUpperCase()} - -
- {payout.recipientName} -
- ), - }, - { - key: "amount", - header: "Amount", - sortable: false, - render: (payout) => ( - - {formatCurrency(Number(payout.amount), payout.currency)} - - ), - }, - { - key: "currency", - header: "Currency", - sortable: false, - render: (payout) => ( - {payout.currency} - ), - }, - { - key: "destinationType", - header: "Type", - sortable: false, - render: (payout) => ( - - {payout.destinationType.replace("_", " ")} - - ), - }, - { - key: "status", - header: "Status", - sortable: false, - render: (payout) => , - }, - { - key: "createdAt", - header: "Date", - sortable: false, - render: (payout) => ( - - {new Date(payout.createdAt).toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - })} - - ), - }, - { - key: "actions", - header: "", - sortable: false, - render: (payout) => ( -
- {payout.status === "FAILED" && ( - - )} - {payout.status === "PENDING" && ( - - )} -
- ), - }, - ], - [retryMutation.isPending, cancelMutation.isPending] - ); + function updateRow(id: string, field: (typeof REQUIRED_HEADERS)[number], value: string) { + setRows((current) => + current.map((row) => { + if (row.id !== id) return row; + return validateRow({ + id: row.id, + rowNumber: row.rowNumber, + recipient_name: field === "recipient_name" ? value : row.recipient_name, + destination_type: field === "destination_type" ? value : row.destination_type, + account_details: field === "account_details" ? value : row.account_details, + amount: field === "amount" ? value : row.amount, + currency: field === "currency" ? value : row.currency, + }); + }) + ); + } - // ── Derived Currencies ─────────────────────────────────────────────────────── + function downloadTemplate() { + const blob = new Blob([templateCsv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "useroutr-bulk-payout-template.csv"; + link.click(); + URL.revokeObjectURL(url); + } - const availableCurrencies = useMemo(() => { - const currencies = new Set(); - payouts.forEach((p) => currencies.add(p.currency)); - return Array.from(currencies).sort(); - }, [payouts]); + async function submitBulkPayout() { + setSubmitError(""); + if (!validRows.length || invalidRows.length) return; + if (TWO_FACTOR_ENABLED && !/^\d{6}$/.test(twoFactorCode)) { + setSubmitError("Enter the 6-digit 2FA code to confirm this payout"); + return; + } - // ── Render ─────────────────────────────────────────────────────────────────── - - const hasActiveFilters = - status || destinationType || currency || dateFrom || dateTo || batchId || search; - - const filters = useMemo( - () => ({ - status: status || undefined, - destinationType: destinationType || undefined, - dateFrom: dateFrom || undefined, - dateTo: dateTo || undefined, - batchId: batchId || undefined, - search: search || undefined, - }), - [status, destinationType, dateFrom, dateTo, batchId, search] - ); + setSubmitting(true); + try { + const result = await api.post("/payouts/bulk", { + payouts: validRows.map(rowToPayout), + }); + + setProgress( + validRows.map((row, index) => { + const item = result.payouts.find((payout) => payout.index === index); + return { + rowId: row.id, + payoutId: item?.payoutId, + recipientName: row.recipient_name, + amount: row.amount, + currency: row.currency, + status: item?.error ? "FAILED" : "PENDING", + failureReason: item?.error, + }; + }) + ); + } catch (error) { + setSubmitError(error instanceof Error ? error.message : "Bulk payout failed"); + } finally { + setSubmitting(false); + } + } + + async function retryRecipient(recipient: ProgressRecipient) { + if (!recipient.payoutId) return; + setProgress((current) => + current.map((item) => + item.rowId === recipient.rowId + ? { ...item, status: "PENDING", failureReason: undefined } + : item + ) + ); + try { + await api.post(`/payouts/${recipient.payoutId}/retry`); + } catch (error) { + setProgress((current) => + current.map((item) => + item.rowId === recipient.rowId + ? { + ...item, + status: "FAILED", + failureReason: error instanceof Error ? error.message : "Retry failed", + } + : item + ) + ); + } + } return (
- {/* ── Header ───────────────────────────────────────────────────────────── */} -
+
-

- Payout History -

-
- {isLoading ? ( - - ) : ( - `${totalPayouts.toLocaleString()} total payouts` - )} -
+

Payouts

+

+ Create and monitor recipient disbursements. +

+
+
+ {(["single", "bulk"] as const).map((tab) => ( + + ))}
-
- {/* ── Summary Cards ──────────────────────────────────────────────────────── */} -
- {isLoading ? ( - <> - - - - - - ) : ( - <> -
-

Total Payouts

-

- {totalPayouts.toLocaleString()} -

-
-
-

Pending

-

- {payouts.filter((p) => p.status === "PENDING").length.toLocaleString()} -

-
-
-

Completed

-

- {payouts.filter((p) => p.status === "COMPLETED").length.toLocaleString()} -

-
-
-

Failed

-

- {payouts.filter((p) => p.status === "FAILED").length.toLocaleString()} -

-
- - )} +
+ {summaryCards.map((card) => ( +
+

{card.label}

+

{card.value}

+
+ ))}
- {/* ── Search & Filters ───────────────────────────────────────────────────── */} -
-
- -
- setGroupByBatch(e.target.checked)} - className="rounded border-border" - /> -
-
- - {hasActiveFilters && ( - - )} -
-
- - {/* ── Table ──────────────────────────────────────────────────────────────── */} - {error ? ( -
-

Failed to load payouts

-

- {error instanceof Error ? error.message : "Unknown error"} -

- +
- ) : groupByBatch && groupedPayouts ? ( - // ── Batch Grouped View ───────────────────────────────────────────────── -
- {/* Batched Payouts */} - {Array.from(groupedPayouts.batched.entries()).map(([batchId, batchPayouts]) => ( -
- toggleBatchExpansion(batchId)} - /> - {expandedBatches.has(batchId) && ( -
- - columns={columns} - data={batchPayouts as PayoutWithIndex[]} - isLoading={false} - emptyMessage="No payouts in this batch" - onRowClick={handleRowClick} - /> -
+ ) : ( +
+
{ + event.preventDefault(); + const file = event.dataTransfer.files[0]; + if (file) void handleFile(file); + }} + onDragOver={(event) => event.preventDefault()} + className="rounded-lg border border-dashed border-border bg-card p-8 text-center shadow-sm" + > + { + const file = event.target.files?.[0]; + if (file) void handleFile(file); + }} + /> +
+ +
+

+ {fileName || "Drop CSV here or choose a file"} +

+

+ recipient_name, destination_type, account_details, amount, currency +

+
+ + + {!!rows.length && ( + )}
- ))} + {uploadError && ( +

{uploadError}

+ )} +
- {/* Unbatched Payouts */} - {groupedPayouts.unbatched.length > 0 && ( -
-

- Individual Payouts ({groupedPayouts.unbatched.length}) -

- - columns={columns} - data={groupedPayouts.unbatched as PayoutWithIndex[]} - isLoading={isLoading} - loadingRows={limit} - emptyMessage="No payouts found" - onRowClick={handleRowClick} - /> -
+ {!!rows.length && ( + <> +
+
+
+

CSV validation

+

+ {validRows.length} valid, {invalidRows.length} need fixes +

+
+
+ + + Valid + + + + Error + +
+
+ +
+ + + + + {REQUIRED_HEADERS.map((header) => ( + + ))} + + + + + {rows.map((row) => { + const editing = editingId === row.id; + return ( + + + {REQUIRED_HEADERS.map((field) => ( + + ))} + + + + ); + })} + +
Row + {header} + Errors +
{row.rowNumber} + {editing ? ( + updateRow(row.id, field, event.target.value)} + className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring" + /> + ) : ( + + {row[field]} + + )} + + {row.errors.length ? ( +
+ {row.errors.map((error) => ( +

+ {error} +

+ ))} +
+ ) : ( + Ready + )} +
+ +
+
+
+ +
+
+

Confirmation summary

+
+
+

Total amount

+

+ {formatTotals(totalsByCurrency)} +

+
+
+

Recipients

+

{validRows.length}

+
+
+

Estimated fees

+

+ {formatTotals(feesByCurrency)} +

+
+
+

Settlement time

+

1-2 days

+
+
+ + {TWO_FACTOR_ENABLED && ( +
+ + + setTwoFactorCode(event.target.value.replace(/\D/g, "").slice(0, 6))} + inputMode="numeric" + placeholder="000000" + className="h-10 w-full rounded-md border border-input bg-background px-3 text-sm tracking-[0.2em] outline-none focus:ring-2 focus:ring-ring sm:ml-auto sm:w-36" + /> +
+ )} + + {submitError &&

{submitError}

} + +
+ + + Live {connected ? "connected" : "connecting"} + +
+
+ +
+

Batch readiness

+
+
+ Valid rows + {validRows.length} +
+
+ Fixable rows + {invalidRows.length} +
+
+ Total debit + + {formatTotals(debitByCurrency)} + +
+
+
+
+ )} -
- ) : ( - // ── Flat Table View ─────────────────────────────────────────────────── - - columns={columns} - data={payouts as PayoutWithIndex[]} - isLoading={isLoading} - loadingRows={limit} - emptyMessage={hasActiveFilters ? "No payouts match your filters" : "No payouts found"} - emptyIcon={} - onRowClick={handleRowClick} - /> - )} - {/* ── Empty State with Filter Clear ──────────────────────────────────────── */} - {!isLoading && payouts.length === 0 && hasActiveFilters && ( -
- + {!!progress.length && ( +
+
+
+
+

Progress tracker

+

+ {completedCount} of {progress.length} completed +

+
+
+
+
+
+
+ +
+ {progress.map((recipient) => ( +
+
+

{recipient.recipientName}

+

+ {recipient.amount} {recipient.currency} +

+ {recipient.failureReason && ( +

+ {recipient.failureReason} +

+ )} +
+ + {recipient.status} + +

+ {recipient.status === "PENDING" && "Pending"} + {recipient.status === "PROCESSING" && "Processing"} + {recipient.status === "COMPLETED" && "Completed"} + {recipient.status === "FAILED" && "Failed"} + {recipient.status === "CANCELLED" && "Cancelled"} +

+ {recipient.status === "FAILED" && ( + + )} +
+ ))} +
+
+ )}
)} - - {/* ── Pagination ─────────────────────────────────────────────────────────── */} - {!groupByBatch && ( - setOffset((page - 1) * limit)} - onPageSizeChange={(size) => { - setLimit(size); - setOffset(0); - }} - pageSizeOptions={[10, 20, 50, 100]} - /> - )} - - {/* ── Detail Drawer ────────────────────────────────────────────────────────── */} - - - {/* ── Cancel Confirmation Modal ────────────────────────────────────────────── */} -
); } - diff --git a/apps/dashboard/src/components/payouts/BatchGroupHeader.tsx b/apps/dashboard/src/components/payouts/BatchGroupHeader.tsx index a764503..39aa053 100644 --- a/apps/dashboard/src/components/payouts/BatchGroupHeader.tsx +++ b/apps/dashboard/src/components/payouts/BatchGroupHeader.tsx @@ -45,7 +45,7 @@ function calculateBatchStats(payouts: Payout[]): BatchStats { acc[p.currency] = (acc[p.currency] || 0) + 1; return acc; }, {} as Record); - + const dominantCurrency = Object.entries(currencyCounts).sort( (a, b) => b[1] - a[1] )[0]?.[0] || "USD"; diff --git a/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx b/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx index 74242d1..aa5530c 100644 --- a/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx +++ b/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx @@ -67,34 +67,34 @@ describe('BatchGroupHeader', () => { it('renders batch summary information', () => { render() - + expect(screen.getByText('Batch')).toBeInTheDocument() expect(screen.getByText(/batch-123/)).toBeInTheDocument() }) it('displays total payout count', () => { render() - + expect(screen.getByText('3')).toBeInTheDocument() expect(screen.getByText('recipients')).toBeInTheDocument() }) it('displays total amount with dominant currency', () => { render() - + // Total amount is 450 USD (dominant) + 150 EUR expect(screen.getByText('$450.00')).toBeInTheDocument() }) it('shows additional currencies count when multiple currencies exist', () => { render() - + expect(screen.getByText('+1 more')).toBeInTheDocument() }) it('displays status breakdown', () => { render() - + // Should show status counts expect(screen.getByText('(1)')).toBeInTheDocument() // Each status appears }) @@ -102,16 +102,16 @@ describe('BatchGroupHeader', () => { it('calls onToggle when clicked', () => { const onToggle = vi.fn() render() - + const header = screen.getByText('Batch').closest('div')?.parentElement fireEvent.click(header!) - + expect(onToggle).toHaveBeenCalled() }) it('shows expanded state correctly', () => { render() - + // When expanded, should show caret down const button = screen.getByRole('button') expect(button).toBeInTheDocument() @@ -119,7 +119,7 @@ describe('BatchGroupHeader', () => { it('shows collapsed state correctly', () => { render() - + // When collapsed, should show caret right const button = screen.getByRole('button') expect(button).toBeInTheDocument() @@ -128,13 +128,13 @@ describe('BatchGroupHeader', () => { it('handles single currency correctly', () => { const singleCurrencyPayouts = mockPayouts.map(p => ({ ...p, currency: 'USD' })) render() - + expect(screen.queryByText(/more/)).not.toBeInTheDocument() }) it('handles empty payouts gracefully', () => { render() - + expect(screen.getByText('Batch')).toBeInTheDocument() expect(screen.getByText('0')).toBeInTheDocument() }) diff --git a/apps/dashboard/src/components/payouts/__tests__/CancelConfirmationModal.test.tsx b/apps/dashboard/src/components/payouts/__tests__/CancelConfirmationModal.test.tsx index e8a9885..a82de25 100644 --- a/apps/dashboard/src/components/payouts/__tests__/CancelConfirmationModal.test.tsx +++ b/apps/dashboard/src/components/payouts/__tests__/CancelConfirmationModal.test.tsx @@ -15,7 +15,7 @@ describe('CancelConfirmationModal', () => { it('renders when open', () => { render() - + expect(screen.getByText('Cancel Payout?')).toBeInTheDocument() expect(screen.getByText(/John Doe/)).toBeInTheDocument() expect(screen.getByText(/\$100.00/)).toBeInTheDocument() @@ -23,17 +23,17 @@ describe('CancelConfirmationModal', () => { it('does not render when closed', () => { render() - + expect(screen.queryByText('Cancel Payout?')).not.toBeInTheDocument() }) it('calls onConfirm when confirm button clicked', async () => { const onConfirm = vi.fn() render() - + const confirmButton = screen.getByText('Yes, Cancel Payout') fireEvent.click(confirmButton) - + await waitFor(() => { expect(onConfirm).toHaveBeenCalled() }) @@ -42,26 +42,26 @@ describe('CancelConfirmationModal', () => { it('calls onOpenChange when cancel button clicked', () => { const onOpenChange = vi.fn() render() - + const cancelButton = screen.getByText('Keep Payout') fireEvent.click(cancelButton) - + expect(onOpenChange).toHaveBeenCalledWith(false) }) it('disables buttons when loading', () => { render() - + const confirmButton = screen.getByText('Cancelling...') const cancelButton = screen.getByText('Keep Payout') - + expect(confirmButton).toBeDisabled() expect(cancelButton).toBeDisabled() }) it('shows warning icon and appropriate messaging', () => { render() - + expect(screen.getByText(/Are you sure/)).toBeInTheDocument() expect(screen.getByText(/cannot be undone/)).toBeInTheDocument() }) @@ -75,7 +75,7 @@ describe('CancelConfirmationModal', () => { currency={undefined} /> ) - + expect(screen.getByText('Cancel Payout?')).toBeInTheDocument() // Should not crash without details }) diff --git a/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx index 7ab1eac..93bd347 100644 --- a/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx @@ -43,7 +43,7 @@ describe('PayoutExportButton', () => { beforeEach(() => { vi.clearAllMocks() - + // Mock document.createElement and related DOM methods const mockLink = { setAttribute: vi.fn(), @@ -57,30 +57,30 @@ describe('PayoutExportButton', () => { it('renders export button', () => { render() - + expect(screen.getByText('Export CSV')).toBeInTheDocument() }) it('is disabled when loading', () => { render() - + const button = screen.getByText('Export CSV').closest('button') expect(button).toBeDisabled() }) it('is disabled when no payouts', () => { render() - + const button = screen.getByText('Export CSV').closest('button') expect(button).toBeDisabled() }) it('triggers CSV download when clicked', async () => { render() - + const button = screen.getByText('Export CSV') fireEvent.click(button) - + await waitFor(() => { expect(document.createElement).toHaveBeenCalledWith('a') }) @@ -91,12 +91,12 @@ describe('PayoutExportButton', () => { status: 'COMPLETED' as const, batchId: 'batch-12345', } - + render() - + const button = screen.getByText('Export CSV') fireEvent.click(button) - + await waitFor(() => { const createElementCalls = vi.mocked(document.createElement).mock.calls expect(createElementCalls.length).toBeGreaterThan(0) diff --git a/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx index d626f4a..77c2291 100644 --- a/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx @@ -9,26 +9,26 @@ describe('PayoutSearchInput', () => { it('renders with default placeholder', () => { render() - + expect(screen.getByPlaceholderText('Search by recipient or payout ID...')).toBeInTheDocument() }) it('renders with custom placeholder', () => { render() - + expect(screen.getByPlaceholderText('Custom placeholder')).toBeInTheDocument() }) it('calls onSearch after debounce when typing', async () => { const onSearch = vi.fn() render() - + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test query' } }) - + // Should not be called immediately expect(onSearch).not.toHaveBeenCalled() - + // Should be called after debounce await waitFor(() => expect(onSearch).toHaveBeenCalledWith('test query'), { timeout: 200, @@ -38,18 +38,18 @@ describe('PayoutSearchInput', () => { it('clears search when X button is clicked', async () => { const onSearch = vi.fn() render() - + const input = screen.getByRole('textbox') fireEvent.change(input, { target: { value: 'test query' } }) - + // Wait for X button to appear await waitFor(() => { expect(screen.getByRole('button')).toBeInTheDocument() }) - + const clearButton = screen.getByRole('button') fireEvent.click(clearButton) - + expect(input).toHaveValue('') await waitFor(() => expect(onSearch).toHaveBeenLastCalledWith(''), { timeout: 200, @@ -58,7 +58,7 @@ describe('PayoutSearchInput', () => { it('initializes with provided value', () => { render() - + const input = screen.getByRole('textbox') expect(input).toHaveValue('initial value') }) diff --git a/apps/dashboard/src/components/payouts/__tests__/PayoutStatusBadge.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutStatusBadge.test.tsx index 4153c0f..ed23c8e 100644 --- a/apps/dashboard/src/components/payouts/__tests__/PayoutStatusBadge.test.tsx +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutStatusBadge.test.tsx @@ -15,7 +15,7 @@ describe('PayoutStatusBadge', () => { statuses.forEach(({ status, expectedLabel, expectedVariant }) => { it(`renders ${status} status with correct label and styling`, () => { render() - + const badge = screen.getByText(expectedLabel) expect(badge).toBeInTheDocument() expect(badge.parentElement).toHaveClass(expectedVariant) @@ -25,7 +25,7 @@ describe('PayoutStatusBadge', () => { it('matches all status mappings exactly', () => { // Ensure all statuses are mapped const allStatuses: PayoutStatus[] = ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'CANCELLED'] - + allStatuses.forEach((status) => { const { container } = render() expect(container.querySelector('span')).toBeInTheDocument() diff --git a/apps/dashboard/src/hooks/useDashboardSocket.ts b/apps/dashboard/src/hooks/useDashboardSocket.ts index 05f9149..b718770 100644 --- a/apps/dashboard/src/hooks/useDashboardSocket.ts +++ b/apps/dashboard/src/hooks/useDashboardSocket.ts @@ -1,7 +1,8 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { io, type Socket } from "socket.io-client"; +import { getToken } from "@/lib/auth"; const SOCKET_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3000"; @@ -10,9 +11,11 @@ export function useDashboardSocket() { const [connected, setConnected] = useState(false); useEffect(() => { + const token = getToken(); const socket = io(SOCKET_URL, { transports: ["websocket"], autoConnect: true, + query: token ? { type: "merchant", token: `Bearer ${token}` } : undefined, }); socket.on("connect", () => setConnected(true)); @@ -25,12 +28,12 @@ export function useDashboardSocket() { }; }, []); - const subscribe = (event: string, callback: (...args: unknown[]) => void) => { + const subscribe = useCallback((event: string, callback: (...args: unknown[]) => void) => { socketRef.current?.on(event, callback); return () => { socketRef.current?.off(event, callback); }; - }; + }, []); return { connected, subscribe, socket: socketRef.current }; }