diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 70936b2..140e542 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -6,7 +6,9 @@ "dev": "next dev --port 3001", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@phosphor-icons/react": "^2.1.10", @@ -45,12 +47,18 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4.2.2", + "@testing-library/jest-dom": "^6", + "@testing-library/react": "^15", + "@testing-library/user-event": "^14", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4", "eslint": "^9", "eslint-config-next": "latest", + "jsdom": "^24", "tailwindcss": "^4.2.2", - "typescript": "^5" + "typescript": "^5", + "vitest": "^1" } } diff --git a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx index db93572..b57ec43 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -1,38 +1,593 @@ -import { Button } from "@useroutr/ui"; +"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 { NewPayoutButton } from "@/components/payouts/NewPayoutButton"; +import { formatCurrency, truncateAddress } from "@/lib/utils"; +import { useToast } from "@useroutr/ui"; + +type PayoutWithIndex = Payout & Record; + +interface GroupedPayouts { + batched: Map; + unbatched: Payout[]; +} + +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); + } + } + + return { batched, unbatched }; +} export default function PayoutsPage() { + const searchParams = useSearchParams(); + const router = useRouter(); + const { toast } = useToast(); + + // ── URL Sync & State ──────────────────────────────────────────────────────── + + 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, + }); + + const payouts = data?.data || []; + const totalPayouts = data?.total || 0; + + // ── Grouping ───────────────────────────────────────────────────────────────── + + const groupedPayouts = useMemo(() => { + if (!groupByBatch) return null; + return groupPayoutsByBatch(payouts); + }, [payouts, groupByBatch]); + + // ── Mutations ──────────────────────────────────────────────────────────────── + + const retryMutation = useRetryPayout(); + const cancelMutation = useCancelPayout(); + + // ── URL Update ─────────────────────────────────────────────────────────────── + + 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"); + + const queryString = params.toString(); + const newUrl = queryString ? `?${queryString}` : ""; + window.history.replaceState(null, "", newUrl); + }, [offset, limit, status, destinationType, currency, dateFrom, dateTo, batchId, search, groupByBatch]); + + useEffect(() => { + updateUrl(); + }, [updateUrl]); + + // ── Handlers ──────────────────────────────────────────────────────────────── + + const resetFilters = () => { + setOffset(0); + setStatus(""); + setDestinationType(""); + setCurrency(""); + setDateFrom(""); + setDateTo(""); + setBatchId(""); + setSearch(""); + setGroupByBatch(false); + setExpandedBatches(new Set()); + }; + + const handleSearch = (query: string) => { + setSearch(query); + setOffset(0); + }; + + const handleStatusChange = (newStatus: string) => { + setStatus(newStatus as PayoutStatus | ""); + setOffset(0); + }; + + const handleDestinationTypeChange = (type: string) => { + setDestinationType(type as DestType | ""); + setOffset(0); + }; + + const handleCurrencyChange = (curr: string) => { + setCurrency(curr); + setOffset(0); + }; + + const handleDateRangeChange = (range: DateRange | undefined) => { + setDateFrom(range?.from ? range.from.toISOString() : ""); + setDateTo(range?.to ? range.to.toISOString() : ""); + setOffset(0); + }; + + const handleBatchIdChange = (id: string) => { + setBatchId(id); + setOffset(0); + }; + + const toggleBatchExpansion = (batchId: string) => { + setExpandedBatches((prev) => { + const next = new Set(prev); + if (next.has(batchId)) { + next.delete(batchId); + } else { + next.add(batchId); + } + return next; + }); + }; + + const handleRowClick = (payout: Payout) => { + setSelectedPayout(payout); + setDrawerOpen(true); + }; + + 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", + }); + } + }; + + const handleCancelClick = (payout: Payout) => { + setSelectedPayout(payout); + setCancelModalOpen(true); + }; + + 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", + }); + } + }; + + // ── 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] + ); + + // ── Derived Currencies ─────────────────────────────────────────────────────── + + const availableCurrencies = useMemo(() => { + const currencies = new Set(); + payouts.forEach((p) => currencies.add(p.currency)); + return Array.from(currencies).sort(); + }, [payouts]); + + // ── 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] + ); + return (
+ {/* ── Header ───────────────────────────────────────────────────────────── */}
-

- Payouts -

- +
+

+ Payout History +

+

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

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

{label}

-
+ {/* ── 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()} +

+
+ + )} +
+ + {/* ── Search & Filters ───────────────────────────────────────────────────── */} +
+
+ +
+ setGroupByBatch(e.target.checked)} + className="rounded border-border" + /> +
- ))} +
+
+ + {hasActiveFilters && ( + + )} +
- {/* Table placeholder */} -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
+ {/* ── 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} + /> +
+ )}
))} + + {/* 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} + /> +
+ )}
-
+ ) : ( + // ── 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 && ( +
+ +
+ )} + + {/* ── 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 new file mode 100644 index 0000000..a764503 --- /dev/null +++ b/apps/dashboard/src/components/payouts/BatchGroupHeader.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useState, useMemo } from "react"; +import { CaretDown, CaretRight, Users, CurrencyDollar } from "@phosphor-icons/react"; +import type { Payout, PayoutStatus } from "@/hooks/usePayouts"; +import { formatCurrency } from "@/lib/utils"; +import { PayoutStatusBadge } from "./PayoutStatusBadge"; + +interface BatchGroupHeaderProps { + batchId: string; + payouts: Payout[]; + isExpanded: boolean; + onToggle: () => void; +} + +interface BatchStats { + totalPayouts: number; + totalAmount: number; + currencies: string[]; + statusCounts: Record; + dominantCurrency: string; +} + +function calculateBatchStats(payouts: Payout[]): BatchStats { + const stats = payouts.reduce( + (acc, payout) => { + acc.totalPayouts++; + acc.totalAmount += Number(payout.amount); + acc.statusCounts[payout.status] = (acc.statusCounts[payout.status] || 0) + 1; + if (!acc.currencies.includes(payout.currency)) { + acc.currencies.push(payout.currency); + } + return acc; + }, + { + totalPayouts: 0, + totalAmount: 0, + currencies: [] as string[], + statusCounts: {} as Record, + } + ); + + // Determine dominant currency (most frequent) + const currencyCounts = payouts.reduce((acc, p) => { + 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"; + + return { + ...stats, + dominantCurrency, + }; +} + +export function BatchGroupHeader({ + batchId, + payouts, + isExpanded, + onToggle, +}: BatchGroupHeaderProps) { + const stats = useMemo(() => calculateBatchStats(payouts), [payouts]); + + const statusEntries = Object.entries(stats.statusCounts).sort( + (a, b) => b[1] - a[1] + ); + + return ( +
+ {/* Expand/Collapse Icon */} + + + {/* Batch ID */} +
+ Batch + + {batchId.slice(0, 12)}... + +
+ + {/* Stats */} +
+
+ + + {stats.totalPayouts} + recipients + +
+ +
+ + + + {formatCurrency(stats.totalAmount, stats.dominantCurrency)} + + {stats.currencies.length > 1 && ( + + +{stats.currencies.length - 1} more + + )} + +
+ + {/* Status Breakdown */} +
+ {statusEntries.slice(0, 3).map(([status, count]) => ( +
+ + ({count}) +
+ ))} + {statusEntries.length > 3 && ( + + +{statusEntries.length - 3} more + + )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/CancelConfirmationModal.tsx b/apps/dashboard/src/components/payouts/CancelConfirmationModal.tsx new file mode 100644 index 0000000..4ebcdf0 --- /dev/null +++ b/apps/dashboard/src/components/payouts/CancelConfirmationModal.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@useroutr/ui"; +import { Warning } from "@phosphor-icons/react"; + +interface CancelConfirmationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isLoading?: boolean; + recipientName?: string; + amount?: string; + currency?: string; +} + +export function CancelConfirmationModal({ + open, + onOpenChange, + onConfirm, + isLoading, + recipientName, + amount, + currency, +}: CancelConfirmationModalProps) { + return ( + + + +
+
+ +
+ Cancel Payout? +
+ + Are you sure you want to cancel this payout? + {recipientName && amount && currency && ( +
+ {recipientName} + + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: currency, + }).format(Number(amount))} + +
+ )} +

+ This action cannot be undone. The payout will be marked as cancelled and + the funds will remain in your account. +

+
+
+ + onOpenChange(false)} disabled={isLoading}> + Keep Payout + + + {isLoading ? "Cancelling..." : "Yes, Cancel Payout"} + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/ConfirmationModal.tsx b/apps/dashboard/src/components/payouts/ConfirmationModal.tsx new file mode 100644 index 0000000..a46e4c5 --- /dev/null +++ b/apps/dashboard/src/components/payouts/ConfirmationModal.tsx @@ -0,0 +1,218 @@ +"use client"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + Button, + Badge, +} from "@useroutr/ui"; +import { formatCurrency, truncateAddress } from "@/lib/utils"; +import { AlertTriangle, Wallet, Building2, Smartphone, Sparkles } from "lucide-react"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +interface ConfirmationModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isLoading: boolean; + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + fee?: string; + feeCurrency?: string; +} + +const DESTINATION_ICONS: Record = { + BANK_ACCOUNT: Building2, + MOBILE_MONEY: Smartphone, + CRYPTO_WALLET: Wallet, + STELLAR: Sparkles, +}; + +const DESTINATION_LABELS: Record = { + BANK_ACCOUNT: "Bank Account", + MOBILE_MONEY: "Mobile Money", + CRYPTO_WALLET: "Crypto Wallet", + STELLAR: "Stellar", +}; + +function formatDestinationDisplay(type: DestType, destination: PayoutDestination): string { + switch (type) { + case "BANK_ACCOUNT": + if (destination.accountNumber) { + return `****${destination.accountNumber.slice(-4)}`; + } + return destination.bankName || "Bank Account"; + case "MOBILE_MONEY": + return destination.phoneNumber || "Mobile"; + case "CRYPTO_WALLET": + return destination.address ? truncateAddress(destination.address, 6) : "Wallet"; + case "STELLAR": + return destination.address ? truncateAddress(destination.address, 6) : "Stellar"; + default: + return "Unknown"; + } +} + +function getDestinationDetails(type: DestType, destination: PayoutDestination): string[] { + const details: string[] = []; + + switch (type) { + case "BANK_ACCOUNT": + if (destination.bankName) details.push(destination.bankName); + if (destination.country) details.push(destination.country); + break; + case "MOBILE_MONEY": + if (destination.provider) details.push(destination.provider); + if (destination.country) details.push(destination.country); + break; + case "CRYPTO_WALLET": + if (destination.network) details.push(destination.network); + if (destination.asset) details.push(destination.asset); + break; + case "STELLAR": + if (destination.asset && destination.asset !== "native") { + details.push(destination.asset); + } else { + details.push("XLM"); + } + if (destination.memo) details.push(`Memo: ${destination.memo}`); + break; + } + + return details; +} + +export function ConfirmationModal({ + open, + onOpenChange, + onConfirm, + isLoading, + recipientName, + destinationType, + destination, + amount, + currency, + fee, + feeCurrency, +}: ConfirmationModalProps) { + const Icon = DESTINATION_ICONS[destinationType]; + const amountNum = parseFloat(amount) || 0; + const feeNum = parseFloat(fee || "0") || 0; + const total = amountNum + feeNum; + const displayFeeCurrency = feeCurrency || currency; + const destinationDetails = getDestinationDetails(destinationType, destination); + + return ( + + + + + + Confirm Payout + + + Please review the details before sending. This action cannot be undone. + + + +
+ {/* Recipient Section */} +
+
+ Recipient + {recipientName} +
+
+ + {/* Destination Section */} +
+
+ Destination +
+ + {DESTINATION_LABELS[destinationType]} +
+
+
+ {formatDestinationDisplay(destinationType, destination)} +
+ {destinationDetails.length > 0 && ( +
+ {destinationDetails.map((detail, i) => ( + + {detail} + + ))} +
+ )} +
+ + {/* Amount Section */} +
+
+ Amount + + {formatCurrency(amountNum, currency)} + +
+ + {feeNum > 0 && ( + <> +
+ Fee + + {formatCurrency(feeNum, displayFeeCurrency)} + +
+
+
+ Total + + {formatCurrency(total, displayFeeCurrency)} + +
+
+ + )} +
+ + {/* Warning */} +
+

+ By confirming, you authorize this payout. Funds will be sent to the recipient + and cannot be recalled once processed. +

+
+
+ + + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/DestinationTypeFields.tsx b/apps/dashboard/src/components/payouts/DestinationTypeFields.tsx new file mode 100644 index 0000000..f74bee9 --- /dev/null +++ b/apps/dashboard/src/components/payouts/DestinationTypeFields.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { Input, Label, Select } from "@useroutr/ui"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +interface DestinationTypeFieldsProps { + destinationType: DestType; + destination: PayoutDestination; + onChange: (destination: PayoutDestination) => void; + errors?: Record; +} + +const MOBILE_MONEY_PROVIDERS = [ + { value: "MTN", label: "MTN Mobile Money" }, + { value: "M-PESA", label: "M-Pesa" }, + { value: "AIRTEL", label: "Airtel Money" }, + { value: "ORANGE", label: "Orange Money" }, + { value: "WAVE", label: "Wave" }, +]; + +const CRYPTO_NETWORKS = [ + { value: "ethereum", label: "Ethereum" }, + { value: "bitcoin", label: "Bitcoin" }, + { value: "polygon", label: "Polygon" }, + { value: "arbitrum", label: "Arbitrum" }, + { value: "optimism", label: "Optimism" }, + { value: "base", label: "Base" }, + { value: "solana", label: "Solana" }, + { value: "stellar", label: "Stellar" }, +]; + +const CRYPTO_ASSETS = [ + { value: "USDC", label: "USDC" }, + { value: "USDT", label: "USDT" }, + { value: "ETH", label: "ETH" }, + { value: "BTC", label: "BTC" }, + { value: "XLM", label: "XLM" }, +]; + +const COUNTRY_CODES = [ + { value: "US", label: "United States" }, + { value: "GB", label: "United Kingdom" }, + { value: "NG", label: "Nigeria" }, + { value: "KE", label: "Kenya" }, + { value: "GH", label: "Ghana" }, + { value: "ZA", label: "South Africa" }, + { value: "CA", label: "Canada" }, + { value: "AU", label: "Australia" }, + { value: "JP", label: "Japan" }, + { value: "CN", label: "China" }, + { value: "IN", label: "India" }, + { value: "BR", label: "Brazil" }, + { value: "MX", label: "Mexico" }, + { value: "AE", label: "UAE" }, + { value: "SG", label: "Singapore" }, + { value: "CH", label: "Switzerland" }, + { value: "DE", label: "Germany" }, + { value: "FR", label: "France" }, +]; + +export function DestinationTypeFields({ + destinationType, + destination, + onChange, + errors = {}, +}: DestinationTypeFieldsProps) { + const handleChange = (field: string, value: string) => { + onChange({ + ...destination, + [field]: value, + }); + }; + + switch (destinationType) { + case "BANK_ACCOUNT": + return ( +
+
+ + handleChange("accountNumber", e.target.value)} + error={errors.accountNumber} + /> +
+ +
+ + handleChange("routingNumber", e.target.value)} + error={errors.routingNumber} + /> +
+ +
+ + handleChange("bankName", e.target.value)} + error={errors.bankName} + /> +
+ +
+ + +
+
+ ); + + case "MOBILE_MONEY": + return ( +
+
+ + +
+ +
+ + handleChange("phoneNumber", e.target.value)} + error={errors.phoneNumber} + /> +
+ +
+ + +
+
+ ); + + case "CRYPTO_WALLET": + return ( +
+
+ + handleChange("address", e.target.value)} + error={errors.address} + /> +
+ +
+ + +
+ +
+ + +
+
+ ); + + case "STELLAR": + return ( +
+
+ + handleChange("address", e.target.value)} + error={errors.address} + /> +

+ Must be a valid Stellar public key starting with G +

+
+ +
+ + +
+ +
+ + handleChange("memo", e.target.value)} + error={errors.memo} + maxLength={28} + /> +

+ Required by some exchanges to identify your account +

+
+
+ ); + + default: + return null; + } +} diff --git a/apps/dashboard/src/components/payouts/FeeEstimatePanel.tsx b/apps/dashboard/src/components/payouts/FeeEstimatePanel.tsx new file mode 100644 index 0000000..fc2708c --- /dev/null +++ b/apps/dashboard/src/components/payouts/FeeEstimatePanel.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { formatCurrency } from "@/lib/utils"; +import { Skeleton } from "@useroutr/ui"; +import { Info, AlertCircle } from "lucide-react"; + +interface FeeEstimatePanelProps { + amount: string; + currency: string; + fee?: string; + feeCurrency?: string; + total?: string; + exchangeRate?: string; + isLoading: boolean; + error?: Error | null; +} + +export function FeeEstimatePanel({ + amount, + currency, + fee, + feeCurrency, + total, + exchangeRate, + isLoading, + error, +}: FeeEstimatePanelProps) { + const amountNum = parseFloat(amount) || 0; + + if (isLoading) { + return ( +
+ +
+ + +
+
+ ); + } + + if (error) { + return ( +
+
+ + Fee estimate unavailable +
+

+ Fees will be calculated at time of submission +

+
+ ); + } + + if (!fee || amountNum <= 0) { + return ( +
+
+ + Enter an amount to see fee estimate +
+
+ ); + } + + const feeNum = parseFloat(fee) || 0; + const totalNum = parseFloat(total || "0") || amountNum + feeNum; + const displayCurrency = feeCurrency || currency; + + return ( +
+
+ Fee Estimate + {formatCurrency(feeNum, displayCurrency)} +
+ + {exchangeRate && exchangeRate !== "1" && ( +
+ Exchange Rate + 1 {currency} = {exchangeRate} +
+ )} + +
+
+ Total + + {formatCurrency(totalNum, displayCurrency)} + +
+
+ +

+ Fees are estimates and may vary slightly at time of processing +

+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/NewPayoutButton.tsx b/apps/dashboard/src/components/payouts/NewPayoutButton.tsx new file mode 100644 index 0000000..22de32c --- /dev/null +++ b/apps/dashboard/src/components/payouts/NewPayoutButton.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@useroutr/ui"; +import { Plus } from "lucide-react"; +import { PayoutDrawer } from "./PayoutDrawer"; +import { useCreatePayout } from "@/hooks/useCreatePayout"; +import { useCreateRecipient } from "@/hooks/useRecipients"; +import { useToast } from "@useroutr/ui"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +interface NewPayoutButtonProps { + variant?: "default" | "outline" | "ghost"; + size?: "default" | "sm" | "lg" | "icon"; + className?: string; +} + +export function NewPayoutButton({ + variant = "default", + size = "default", + className, +}: NewPayoutButtonProps) { + const [open, setOpen] = useState(false); + const { toast } = useToast(); + const createPayoutMutation = useCreatePayout(); + const createRecipientMutation = useCreateRecipient(); + + const handleSubmit = async (data: { + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + saveRecipient: boolean; + }) => { + try { + // If save recipient is checked, create the recipient first + if (data.saveRecipient) { + await createRecipientMutation.mutateAsync({ + name: data.recipientName, + destinationType: data.destinationType, + destination: data.destination, + }); + } + + // Create the payout + await createPayoutMutation.mutateAsync({ + recipientName: data.recipientName, + destinationType: data.destinationType, + destination: data.destination, + amount: data.amount, + currency: data.currency, + }); + + toast({ + title: "Payout Created", + description: `Successfully created payout to ${data.recipientName}`, + variant: "default", + }); + + setOpen(false); + } catch (err) { + toast({ + title: "Failed to Create Payout", + description: err instanceof Error ? err.message : "An error occurred", + variant: "destructive", + }); + } + }; + + return ( + <> + + + + + ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutDetailDrawer.tsx b/apps/dashboard/src/components/payouts/PayoutDetailDrawer.tsx new file mode 100644 index 0000000..600edfc --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutDetailDrawer.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { Sheet, SheetContent, SheetHeader, SheetTitle, Badge, Button } from "@useroutr/ui"; +import { ArrowSquareOut, Copy, CheckCircle } from "@phosphor-icons/react"; +import { useState } from "react"; +import type { Payout } from "@/hooks/usePayouts"; +import { formatCurrency, truncateAddress } from "@/lib/utils"; +import { PayoutStatusBadge } from "./PayoutStatusBadge"; + +interface PayoutDetailDrawerProps { + payout: Payout | undefined; + open: boolean; + onOpenChange: (open: boolean) => void; + onRetry?: (id: string) => void; + onCancel?: (id: string) => void; + isRetrying?: boolean; + isCancelling?: boolean; +} + +const STELLAR_EXPLORER_URL = "https://stellar.expert/explorer/public"; + +function DetailRow({ label, value, copyable = false }: { label: string; value: string; copyable?: boolean }) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+ {label} +
+ {value} + {copyable && ( + + )} +
+
+ ); +} + +function formatDestination(payout: Payout): string { + const dest = payout.destination; + switch (payout.destinationType) { + case "STELLAR": + return dest.address ? truncateAddress(dest.address, 8) : "N/A"; + case "CRYPTO_WALLET": + return dest.address ? truncateAddress(dest.address, 8) : "N/A"; + case "BANK_ACCOUNT": + return dest.accountNumber + ? `****${dest.accountNumber.slice(-4)}` + : dest.iban + ? `****${dest.iban.slice(-4)}` + : "N/A"; + case "MOBILE_MONEY": + return dest.phoneNumber || "N/A"; + default: + return "N/A"; + } +} + +function formatDestinationFull(payout: Payout): string { + const dest = payout.destination; + switch (payout.destinationType) { + case "STELLAR": + return dest.address || "N/A"; + case "CRYPTO_WALLET": + return dest.address || "N/A"; + case "BANK_ACCOUNT": + return dest.accountNumber || dest.iban || "N/A"; + case "MOBILE_MONEY": + return dest.phoneNumber || "N/A"; + default: + return "N/A"; + } +} + +export function PayoutDetailDrawer({ + payout, + open, + onOpenChange, + onRetry, + onCancel, + isRetrying, + isCancelling, +}: PayoutDetailDrawerProps) { + if (!payout) return null; + + const canRetry = payout.status === "FAILED" && onRetry; + const canCancel = payout.status === "PENDING" && onCancel; + + return ( + + + + Payout Details + + +
+ {/* Status and Amount Header */} +
+
+ Status + +
+
+ Amount + + {formatCurrency(Number(payout.amount), payout.currency)} + +
+
+ + {/* Actions */} + {(canRetry || canCancel) && ( +
+ {canRetry && ( + + )} + {canCancel && ( + + )} +
+ )} + + {/* Failure Reason (only for failed) */} + {payout.status === "FAILED" && payout.failureReason && ( +
+

+ Failure Reason +

+

+ {payout.failureReason} +

+
+ )} + + {/* Details Section */} +
+

Details

+
+ + + + + + + {payout.completedAt && ( + + )} + {payout.scheduledAt && ( + + )} +
+
+ + {/* Batch Information */} + {payout.batchId && ( +
+

Batch Information

+
+ +
+
+ )} + + {/* Stellar Transaction */} + {payout.stellarTxHash && ( +
+

Blockchain

+ +
+ )} +
+
+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutDrawer.tsx b/apps/dashboard/src/components/payouts/PayoutDrawer.tsx new file mode 100644 index 0000000..8214bc1 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutDrawer.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from "@useroutr/ui"; +import { Wallet } from "lucide-react"; +import { PayoutForm } from "./PayoutForm"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +interface PayoutDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: { + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + saveRecipient: boolean; + }) => void; + isSubmitting: boolean; +} + +export function PayoutDrawer({ + open, + onOpenChange, + onSubmit, + isSubmitting, +}: PayoutDrawerProps) { + const handleCancel = () => { + onOpenChange(false); + }; + + const handleSubmit = (data: { + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + saveRecipient: boolean; + }) => { + onSubmit(data); + }; + + return ( + + +
+ {/* Header */} + + + + New Payout + + + Send money to a recipient. All fields marked with * are required. + + + + {/* Form Content */} +
+ +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutExportButton.tsx b/apps/dashboard/src/components/payouts/PayoutExportButton.tsx new file mode 100644 index 0000000..624ab28 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutExportButton.tsx @@ -0,0 +1,115 @@ +"use client"; + +import { useState } from "react"; +import { DownloadSimple } from "@phosphor-icons/react"; +import type { Payout, PayoutStatus } from "@/hooks/usePayouts"; +import { formatCurrency } from "@/lib/utils"; + +interface PayoutExportButtonProps { + payouts: Payout[]; + isLoading?: boolean; + filters?: { + status?: PayoutStatus; + destinationType?: string; + dateFrom?: string; + dateTo?: string; + batchId?: string; + search?: string; + }; +} + +function formatDate(dateString: string | null): string { + if (!dateString) return ""; + return new Date(dateString).toISOString(); +} + +function escapeCsvCell(cell: string): string { + if (cell.includes(",") || cell.includes('"') || cell.includes("\n")) { + return `"${cell.replace(/"/g, '""')}"`; + } + return cell; +} + +export function PayoutExportButton({ + payouts, + isLoading, + filters, +}: PayoutExportButtonProps) { + const [isExporting, setIsExporting] = useState(false); + + const handleExport = async () => { + setIsExporting(true); + try { + const headers = [ + "Payout ID", + "Recipient Name", + "Destination Type", + "Amount", + "Currency", + "Status", + "Created At", + "Completed At", + "Batch ID", + "Stellar TX Hash", + "Failure Reason", + ]; + + const rows = payouts.map((payout) => [ + payout.id, + payout.recipientName, + payout.destinationType, + payout.amount, + payout.currency, + payout.status, + formatDate(payout.createdAt), + formatDate(payout.completedAt), + payout.batchId || "", + payout.stellarTxHash || "", + payout.failureReason || "", + ]); + + // Create CSV content with proper escaping + const csv = [headers, ...rows] + .map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(",")) + .join("\n"); + + // Add UTF-8 BOM for Excel compatibility + const blob = new Blob(["\ufeff" + csv], { + type: "text/csv;charset=utf-8;", + }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + + // Build filename with filter info + const dateStr = new Date().toISOString().split("T")[0]; + let filename = `payouts-${dateStr}`; + if (filters?.status) filename += `-${filters.status.toLowerCase()}`; + if (filters?.batchId) filename += `-batch-${filters.batchId.slice(0, 8)}`; + filename += ".csv"; + + link.setAttribute("href", url); + link.setAttribute("download", filename); + link.style.visibility = "hidden"; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error("Export failed:", error); + } finally { + setIsExporting(false); + } + }; + + return ( + + ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutFilterBar.tsx b/apps/dashboard/src/components/payouts/PayoutFilterBar.tsx new file mode 100644 index 0000000..38ef0c6 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutFilterBar.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { Select, type SelectOption, DateRangePicker } from "@useroutr/ui"; +import { type PayoutStatus, type DestType } from "@/hooks/usePayouts"; +import { DateRange } from "react-day-picker"; + +interface PayoutFilterBarProps { + onStatusChange?: (status: string) => void; + onDestinationTypeChange?: (type: string) => void; + onCurrencyChange?: (currency: string) => void; + onDateRangeChange?: (range: DateRange | undefined) => void; + onBatchIdChange?: (batchId: string) => void; + selectedStatus?: string; + selectedDestinationType?: string; + selectedCurrency?: string; + selectedDateRange?: DateRange; + selectedBatchId?: string; + currencies?: string[]; +} + +const STATUS_OPTIONS: SelectOption[] = [ + { value: "", label: "All Statuses" }, + { value: "PENDING", label: "Pending" }, + { value: "PROCESSING", label: "Processing" }, + { value: "COMPLETED", label: "Completed" }, + { value: "FAILED", label: "Failed" }, + { value: "CANCELLED", label: "Cancelled" }, +]; + +const DESTINATION_TYPE_OPTIONS: SelectOption[] = [ + { value: "", label: "All Types" }, + { value: "BANK_ACCOUNT", label: "Bank Account" }, + { value: "MOBILE_MONEY", label: "Mobile Money" }, + { value: "CRYPTO_WALLET", label: "Crypto Wallet" }, + { value: "STELLAR", label: "Stellar" }, +]; + +const DEFAULT_CURRENCY_OPTIONS: SelectOption[] = [ + { value: "", label: "All Currencies" }, + { value: "USD", label: "USD" }, + { value: "EUR", label: "EUR" }, + { value: "GBP", label: "GBP" }, +]; + +export function PayoutFilterBar({ + onStatusChange, + onDestinationTypeChange, + onCurrencyChange, + onDateRangeChange, + onBatchIdChange, + selectedStatus = "", + selectedDestinationType = "", + selectedCurrency = "", + selectedDateRange, + selectedBatchId = "", + currencies, +}: PayoutFilterBarProps) { + const currencyOptions: SelectOption[] = currencies?.length + ? [{ value: "", label: "All Currencies" }, ...currencies.map((c) => ({ value: c, label: c }))] + : DEFAULT_CURRENCY_OPTIONS; + + return ( +
+
+ +
+
+ onBatchIdChange(e.target.value)} + className="h-10 w-full rounded-sm border border-input bg-transparent px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-1 focus:ring-ring" + /> +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutForm.tsx b/apps/dashboard/src/components/payouts/PayoutForm.tsx new file mode 100644 index 0000000..dfea339 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutForm.tsx @@ -0,0 +1,378 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { z } from "zod"; +import { + Input, + Label, + Select, + Button, + Checkbox, + Skeleton, + Separator, +} from "@useroutr/ui"; +import { User, ArrowRight, ChevronDown } from "lucide-react"; +import { DestinationTypeFields } from "./DestinationTypeFields"; +import { FeeEstimatePanel } from "./FeeEstimatePanel"; +import { ConfirmationModal } from "./ConfirmationModal"; +import { PayoutFormSchema, defaultDestinationByType, defaultFormValues } from "@/schemas/payout.schema"; +import { useFeeEstimate } from "@/hooks/useFeeEstimate"; +import { useRecipients, type Recipient } from "@/hooks/useRecipients"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +interface PayoutFormProps { + onSubmit: (data: { + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + saveRecipient: boolean; + }) => void; + onCancel: () => void; + isSubmitting: boolean; +} + +type FormErrors = Partial>; + +const CURRENCIES = [ + { value: "USD", label: "USD - US Dollar" }, + { value: "EUR", label: "EUR - Euro" }, + { value: "GBP", label: "GBP - British Pound" }, + { value: "NGN", label: "NGN - Nigerian Naira" }, + { value: "KES", label: "KES - Kenyan Shilling" }, + { value: "GHS", label: "GHS - Ghana Cedi" }, + { value: "ZAR", label: "ZAR - South African Rand" }, + { value: "CAD", label: "CAD - Canadian Dollar" }, + { value: "AUD", label: "AUD - Australian Dollar" }, + { value: "JPY", label: "JPY - Japanese Yen" }, + { value: "XLM", label: "XLM - Stellar Lumens" }, + { value: "USDC", label: "USDC - USD Coin" }, +]; + +const DESTINATION_TYPES: { value: DestType; label: string }[] = [ + { value: "STELLAR", label: "Stellar" }, + { value: "BANK_ACCOUNT", label: "Bank Account" }, + { value: "MOBILE_MONEY", label: "Mobile Money" }, + { value: "CRYPTO_WALLET", label: "Crypto Wallet" }, +]; + +// ── Component ─────────────────────────────────────────────────────────────── + +export function PayoutForm({ onSubmit, onCancel, isSubmitting }: PayoutFormProps) { + // ── Form State ───────────────────────────────────────────────────────────── + const [recipientName, setRecipientName] = useState(defaultFormValues.recipientName); + const [destinationType, setDestinationType] = useState(defaultFormValues.destinationType); + const [destination, setDestination] = useState(defaultDestinationByType["STELLAR"]); + const [amount, setAmount] = useState(defaultFormValues.amount); + const [currency, setCurrency] = useState(defaultFormValues.currency); + const [saveRecipient, setSaveRecipient] = useState(defaultFormValues.saveRecipient); + const [errors, setErrors] = useState({}); + const [showConfirmation, setShowConfirmation] = useState(false); + const [showRecipientDropdown, setShowRecipientDropdown] = useState(false); + + // ── Data Fetching ────────────────────────────────────────────────────────── + const { data: savedRecipients = [], isLoading: isLoadingRecipients } = useRecipients(); + const { data: feeEstimate, isLoading: isLoadingFee, error: feeError } = useFeeEstimate({ + destinationType, + amount, + currency, + enabled: !!amount && parseFloat(amount) > 0, + }); + + // ── Handlers ─────────────────────────────────────────────────────────────── + const clearFieldError = useCallback((field: string) => { + setErrors((prev) => { + const next = { ...prev }; + delete next[field]; + return next; + }); + }, []); + + const handleDestinationTypeChange = (newType: DestType) => { + setDestinationType(newType); + setDestination(defaultDestinationByType[newType]); + clearFieldError("destinationType"); + // Clear destination-specific errors + setErrors((prev) => { + const next: FormErrors = {}; + Object.keys(prev).forEach((key) => { + if (!key.startsWith("destination.")) { + next[key] = prev[key]; + } + }); + return next; + }); + }; + + const handleDestinationChange = (newDestination: PayoutDestination) => { + setDestination(newDestination); + clearFieldError("destination"); + }; + + const validate = useCallback((): boolean => { + const result = PayoutFormSchema.safeParse({ + recipientName, + destinationType, + destination, + amount, + currency, + saveRecipient, + }); + + if (result.success) { + setErrors({}); + return true; + } + + const fieldErrors: FormErrors = {}; + for (const issue of result.error.issues) { + const path = issue.path.join("."); + if (!fieldErrors[path]) { + fieldErrors[path] = issue.message; + } + } + setErrors(fieldErrors); + return false; + }, [recipientName, destinationType, destination, amount, currency, saveRecipient]); + + const handleReview = () => { + if (validate()) { + setShowConfirmation(true); + } + }; + + const handleConfirm = () => { + onSubmit({ + recipientName, + destinationType, + destination, + amount, + currency, + saveRecipient, + }); + }; + + const handleSelectRecipient = (recipient: Recipient) => { + setRecipientName(recipient.name); + setDestinationType(recipient.destinationType); + setDestination(recipient.destination); + setShowRecipientDropdown(false); + setErrors({}); + }; + + // ── Render ───────────────────────────────────────────────────────────────── + return ( +
+ {/* Recipient Section */} +
+
+ + {savedRecipients.length > 0 && ( +
+ + {showRecipientDropdown && ( +
+
+ {isLoadingRecipients ? ( + + ) : savedRecipients.length === 0 ? ( +

+ No saved recipients +

+ ) : ( + savedRecipients.map((recipient) => ( + + )) + )} +
+
+ )} +
+ )} +
+ +
+ + { + setRecipientName(e.target.value); + clearFieldError("recipientName"); + }} + error={errors.recipientName} + /> +
+
+ + + + {/* Destination Type Selection */} +
+ + +
+ {DESTINATION_TYPES.map((type) => ( + + ))} +
+
+ + {/* Dynamic Destination Fields */} +
+ + + +
+ + + + {/* Amount Section */} +
+ + +
+
+ + { + setAmount(e.target.value); + clearFieldError("amount"); + }} + error={errors.amount} + /> +
+ +
+ + +
+
+
+ + {/* Fee Estimate */} + + + {/* Save Recipient Checkbox */} +
+ setSaveRecipient(checked === true)} + /> + +
+ + {/* Actions */} +
+ + +
+ + {/* Confirmation Modal */} + +
+ ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutSearchInput.tsx b/apps/dashboard/src/components/payouts/PayoutSearchInput.tsx new file mode 100644 index 0000000..52a5bf6 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutSearchInput.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { MagnifyingGlass, X } from "@phosphor-icons/react"; + +interface PayoutSearchInputProps { + placeholder?: string; + value?: string; + onSearch: (query: string) => void; + debounceMs?: number; +} + +export function PayoutSearchInput({ + placeholder = "Search by recipient or payout ID...", + value = "", + onSearch, + debounceMs = 300, +}: PayoutSearchInputProps) { + const [query, setQuery] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + onSearch(query); + }, debounceMs); + + return () => clearTimeout(timer); + }, [query, onSearch, debounceMs]); + + return ( +
+ + setQuery(e.target.value)} + className="h-10 w-full rounded-sm border border-input bg-transparent pl-10 pr-10 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-ring focus:ring-1 focus:ring-ring" + /> + {query && ( + + )} +
+ ); +} diff --git a/apps/dashboard/src/components/payouts/PayoutStatusBadge.tsx b/apps/dashboard/src/components/payouts/PayoutStatusBadge.tsx new file mode 100644 index 0000000..eecf477 --- /dev/null +++ b/apps/dashboard/src/components/payouts/PayoutStatusBadge.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { Badge } from "@useroutr/ui"; +import { type PayoutStatus } from "@/hooks/usePayouts"; + +interface PayoutStatusBadgeProps { + status: PayoutStatus; +} + +const STATUS_VARIANTS: Record< + PayoutStatus, + "pending" | "processing" | "completed" | "failed" | "cancelled" +> = { + PENDING: "pending", + PROCESSING: "processing", + COMPLETED: "completed", + FAILED: "failed", + CANCELLED: "cancelled", +}; + +const STATUS_LABELS: Record = { + PENDING: "Pending", + PROCESSING: "Processing", + COMPLETED: "Completed", + FAILED: "Failed", + CANCELLED: "Cancelled", +}; + +export function PayoutStatusBadge({ status }: PayoutStatusBadgeProps) { + const variant = STATUS_VARIANTS[status]; + const label = STATUS_LABELS[status]; + + return {label}; +} diff --git a/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx b/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx new file mode 100644 index 0000000..74242d1 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/BatchGroupHeader.test.tsx @@ -0,0 +1,141 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { BatchGroupHeader } from '../BatchGroupHeader' +import type { Payout, PayoutStatus } from '@/hooks/usePayouts' + +describe('BatchGroupHeader', () => { + const mockPayouts: Payout[] = [ + { + id: 'payout-1', + merchantId: 'merchant-1', + recipientName: 'John Doe', + destinationType: 'STELLAR', + destination: { address: 'GABC' }, + amount: '100.00', + currency: 'USD', + status: 'COMPLETED', + stellarTxHash: null, + scheduledAt: null, + completedAt: null, + failureReason: null, + batchId: 'batch-123', + idempotencyKey: null, + createdAt: '2024-01-15T09:00:00Z', + }, + { + id: 'payout-2', + merchantId: 'merchant-1', + recipientName: 'Jane Smith', + destinationType: 'BANK_ACCOUNT', + destination: { accountNumber: '1234' }, + amount: '200.00', + currency: 'USD', + status: 'PENDING', + stellarTxHash: null, + scheduledAt: null, + completedAt: null, + failureReason: null, + batchId: 'batch-123', + idempotencyKey: null, + createdAt: '2024-01-15T10:00:00Z', + }, + { + id: 'payout-3', + merchantId: 'merchant-1', + recipientName: 'Bob Wilson', + destinationType: 'STELLAR', + destination: { address: 'GDEF' }, + amount: '150.00', + currency: 'EUR', + status: 'FAILED', + stellarTxHash: null, + scheduledAt: null, + completedAt: null, + failureReason: 'Insufficient funds', + batchId: 'batch-123', + idempotencyKey: null, + createdAt: '2024-01-15T11:00:00Z', + }, + ] + + const defaultProps = { + batchId: 'batch-123-uuid-456', + payouts: mockPayouts, + isExpanded: false, + onToggle: vi.fn(), + } + + 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 + }) + + 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() + }) + + it('shows collapsed state correctly', () => { + render() + + // When collapsed, should show caret right + const button = screen.getByRole('button') + expect(button).toBeInTheDocument() + }) + + 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 new file mode 100644 index 0000000..e8a9885 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/CancelConfirmationModal.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { CancelConfirmationModal } from '../CancelConfirmationModal' + +describe('CancelConfirmationModal', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + onConfirm: vi.fn(), + isLoading: false, + recipientName: 'John Doe', + amount: '100.00', + currency: 'USD', + } + + it('renders when open', () => { + render() + + expect(screen.getByText('Cancel Payout?')).toBeInTheDocument() + expect(screen.getByText(/John Doe/)).toBeInTheDocument() + expect(screen.getByText(/\$100.00/)).toBeInTheDocument() + }) + + 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() + }) + }) + + 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() + }) + + it('renders without payout details when not provided', () => { + render( + + ) + + expect(screen.getByText('Cancel Payout?')).toBeInTheDocument() + // Should not crash without details + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/ConfirmationModal.test.tsx b/apps/dashboard/src/components/payouts/__tests__/ConfirmationModal.test.tsx new file mode 100644 index 0000000..fcb00c0 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/ConfirmationModal.test.tsx @@ -0,0 +1,136 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { ConfirmationModal } from '../ConfirmationModal' + +describe('ConfirmationModal', () => { + const defaultProps = { + open: true, + onOpenChange: vi.fn(), + onConfirm: vi.fn(), + isLoading: false, + recipientName: 'John Doe', + destinationType: 'STELLAR' as const, + destination: { type: 'STELLAR', address: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO', asset: 'native' }, + amount: '100', + currency: 'USD', + fee: '1.50', + feeCurrency: 'USD', + } + + it('renders when open', () => { + render() + + expect(screen.getByText(/Confirm Payout/i)).toBeInTheDocument() + expect(screen.getByText(/John Doe/i)).toBeInTheDocument() + expect(screen.getByText(/\$100\.00/i)).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + + expect(screen.queryByText(/Confirm Payout/i)).not.toBeInTheDocument() + }) + + it('displays destination type badge', () => { + render() + + expect(screen.getByText(/Stellar/i)).toBeInTheDocument() + }) + + it('displays fee breakdown', () => { + render() + + expect(screen.getByText(/Fee/i)).toBeInTheDocument() + expect(screen.getByText('$1.50')).toBeInTheDocument() + expect(screen.getByText(/Total/i)).toBeInTheDocument() + expect(screen.getByText('$101.50')).toBeInTheDocument() + }) + + it('calls onConfirm when confirm button clicked', async () => { + const onConfirm = vi.fn() + render() + + fireEvent.click(screen.getByText(/Confirm & Send/i)) + + await waitFor(() => { + expect(onConfirm).toHaveBeenCalled() + }) + }) + + it('calls onOpenChange when cancel button clicked', () => { + const onOpenChange = vi.fn() + render() + + fireEvent.click(screen.getByText(/Cancel/i)) + + expect(onOpenChange).toHaveBeenCalledWith(false) + }) + + it('disables buttons when loading', () => { + render() + + expect(screen.getByText(/Processing/i)).toBeDisabled() + expect(screen.getByText(/Cancel/i)).toBeDisabled() + }) + + it('shows warning about irreversible action', () => { + render() + + expect(screen.getByText(/cannot be recalled once processed/i)).toBeInTheDocument() + }) + + it('renders with Bank Account details', () => { + render( + + ) + + expect(screen.getByText(/Bank Account/i)).toBeInTheDocument() + expect(screen.getByText(/Chase/i)).toBeInTheDocument() + expect(screen.getByText(/US/i)).toBeInTheDocument() + }) + + it('renders with Mobile Money details', () => { + render( + + ) + + expect(screen.getByText(/Mobile Money/i)).toBeInTheDocument() + expect(screen.getByText(/MTN/i)).toBeInTheDocument() + expect(screen.getByText(/\+2348012345678/i)).toBeInTheDocument() + }) + + it('renders with Crypto Wallet details', () => { + render( + + ) + + expect(screen.getByText(/Crypto Wallet/i)).toBeInTheDocument() + expect(screen.getByText(/ethereum/i)).toBeInTheDocument() + expect(screen.getByText(/USDC/i)).toBeInTheDocument() + }) + + it('truncates long addresses', () => { + render( + + ) + + // Should show truncated address + expect(screen.getByText(/GABCDEF\.\.\.LMNO/)).toBeInTheDocument() + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/DestinationTypeFields.test.tsx b/apps/dashboard/src/components/payouts/__tests__/DestinationTypeFields.test.tsx new file mode 100644 index 0000000..dc59b73 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/DestinationTypeFields.test.tsx @@ -0,0 +1,142 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' +import { DestinationTypeFields } from '../DestinationTypeFields' +import type { DestType, PayoutDestination } from '@useroutr/types' + +describe('DestinationTypeFields', () => { + const defaultProps = { + destinationType: 'STELLAR' as DestType, + destination: { type: 'STELLAR', address: '', asset: 'native' } as PayoutDestination, + onChange: vi.fn(), + errors: {}, + } + + it('renders Stellar fields', () => { + render() + + expect(screen.getByLabelText(/Stellar Address/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Asset/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Memo/i)).toBeInTheDocument() + }) + + it('renders Bank Account fields', () => { + render( + + ) + + expect(screen.getByLabelText(/Account Number/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Routing Number/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Bank Name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Country/i)).toBeInTheDocument() + }) + + it('renders Mobile Money fields', () => { + render( + + ) + + expect(screen.getByLabelText(/Provider/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Phone Number/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Country/i)).toBeInTheDocument() + }) + + it('renders Crypto Wallet fields', () => { + render( + + ) + + expect(screen.getByLabelText(/Wallet Address/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Network/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Asset/i)).toBeInTheDocument() + }) + + it('calls onChange when Stellar address changes', () => { + const onChange = vi.fn() + render( + + ) + + fireEvent.change(screen.getByLabelText(/Stellar Address/i), { + target: { value: 'GABCDEF123' }, + }) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'STELLAR', + address: 'GABCDEF123', + })) + }) + + it('calls onChange when Bank Account fields change', () => { + const onChange = vi.fn() + render( + + ) + + fireEvent.change(screen.getByLabelText(/Account Number/i), { + target: { value: '1234567890' }, + }) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'BANK_ACCOUNT', + accountNumber: '1234567890', + })) + }) + + it('displays field-level errors', () => { + render( + + ) + + expect(screen.getByText('Invalid address')).toBeInTheDocument() + }) + + it('preserves existing destination data when changing fields', () => { + const onChange = vi.fn() + render( + + ) + + fireEvent.change(screen.getByLabelText(/Stellar Address/i), { + target: { value: 'GNEWADDRESS' }, + }) + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ + type: 'STELLAR', + address: 'GNEWADDRESS', + asset: 'USDC', + memo: 'Test memo', + })) + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/FeeEstimatePanel.test.tsx b/apps/dashboard/src/components/payouts/__tests__/FeeEstimatePanel.test.tsx new file mode 100644 index 0000000..52f4034 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/FeeEstimatePanel.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { FeeEstimatePanel } from '../FeeEstimatePanel' + +describe('FeeEstimatePanel', () => { + it('shows placeholder when no amount entered', () => { + render( + + ) + + expect(screen.getByText(/Enter an amount to see fee estimate/i)).toBeInTheDocument() + }) + + it('shows loading skeleton when loading', () => { + const { container } = render( + + ) + + // Skeleton loading state - check for skeleton class + expect(container.querySelector('[class*="skeleton"]')).toBeInTheDocument() + }) + + it('displays fee estimate when loaded', () => { + render( + + ) + + expect(screen.getByText(/Fee Estimate/i)).toBeInTheDocument() + expect(screen.getByText('$1.50')).toBeInTheDocument() + expect(screen.getByText(/Total/i)).toBeInTheDocument() + expect(screen.getByText('$101.50')).toBeInTheDocument() + }) + + it('shows exchange rate when available', () => { + render( + + ) + + expect(screen.getByText(/Exchange Rate/i)).toBeInTheDocument() + expect(screen.getByText(/1 USD = 0.92/i)).toBeInTheDocument() + }) + + it('shows error state when API fails', () => { + render( + + ) + + expect(screen.getByText(/Fee estimate unavailable/i)).toBeInTheDocument() + expect(screen.getByText(/Fees will be calculated at time of submission/i)).toBeInTheDocument() + }) + + it('shows disclaimer about fee estimates', () => { + render( + + ) + + expect(screen.getByText(/Fees are estimates and may vary/i)).toBeInTheDocument() + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/NewPayoutButton.test.tsx b/apps/dashboard/src/components/payouts/__tests__/NewPayoutButton.test.tsx new file mode 100644 index 0000000..ffadced --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/NewPayoutButton.test.tsx @@ -0,0 +1,78 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { NewPayoutButton } from '../NewPayoutButton' + +// Mock the hooks +vi.mock('@/hooks/useCreatePayout', () => ({ + useCreatePayout: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), +})) + +vi.mock('@/hooks/useRecipients', () => ({ + useCreateRecipient: vi.fn(() => ({ + mutateAsync: vi.fn(), + isPending: false, + })), + useRecipients: vi.fn(() => ({ + data: [], + isLoading: false, + })), +})) + +vi.mock('@useroutr/ui', async () => { + const actual = await vi.importActual('@useroutr/ui') + return { + ...actual, + useToast: () => ({ + toast: vi.fn(), + }), + } +}) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('NewPayoutButton', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the button', () => { + render(, { wrapper }) + + expect(screen.getByText(/New Payout/i)).toBeInTheDocument() + }) + + it('opens the drawer when clicked', () => { + render(, { wrapper }) + + fireEvent.click(screen.getByText(/New Payout/i)) + + // Drawer should be open with form title + expect(screen.getByText(/New Payout/i)).toBeInTheDocument() + }) + + it('accepts variant and size props', () => { + render(, { wrapper }) + + const button = screen.getByText(/New Payout/i).closest('button') + expect(button).toBeInTheDocument() + }) + + it('accepts custom className', () => { + render(, { wrapper }) + + const button = screen.getByText(/New Payout/i).closest('button') + expect(button).toHaveClass('custom-class') + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx new file mode 100644 index 0000000..7ab1eac --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutExportButton.test.tsx @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { PayoutExportButton } from '../PayoutExportButton' +import type { Payout } from '@/hooks/usePayouts' + +describe('PayoutExportButton', () => { + const mockPayouts: Payout[] = [ + { + id: 'payout-1', + merchantId: 'merchant-1', + recipientName: 'John Doe', + destinationType: 'STELLAR', + destination: { address: 'GABC123' }, + amount: '100.00', + currency: 'USD', + status: 'COMPLETED', + stellarTxHash: 'tx123', + scheduledAt: null, + completedAt: '2024-01-15T10:00:00Z', + failureReason: null, + batchId: null, + idempotencyKey: null, + createdAt: '2024-01-15T09:00:00Z', + }, + { + id: 'payout-2', + merchantId: 'merchant-1', + recipientName: 'Jane Smith', + destinationType: 'BANK_ACCOUNT', + destination: { accountNumber: '****1234' }, + amount: '250.00', + currency: 'EUR', + status: 'PENDING', + stellarTxHash: null, + scheduledAt: null, + completedAt: null, + failureReason: null, + batchId: 'batch-123', + idempotencyKey: null, + createdAt: '2024-01-15T08:00:00Z', + }, + ] + + beforeEach(() => { + vi.clearAllMocks() + + // Mock document.createElement and related DOM methods + const mockLink = { + setAttribute: vi.fn(), + click: vi.fn(), + style: {}, + } + vi.spyOn(document, 'createElement').mockReturnValue(mockLink as unknown as HTMLAnchorElement) + vi.spyOn(document.body, 'appendChild').mockImplementation(() => mockLink as unknown as Node) + vi.spyOn(document.body, 'removeChild').mockImplementation(() => mockLink as unknown as Node) + }) + + 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') + }) + }) + + it('includes filter info in filename when filters provided', async () => { + const filters = { + 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__/PayoutForm.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutForm.test.tsx new file mode 100644 index 0000000..d973d67 --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutForm.test.tsx @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { PayoutForm } from '../PayoutForm' + +// Mock the hooks +vi.mock('@/hooks/useFeeEstimate', () => ({ + useFeeEstimate: vi.fn(() => ({ + data: undefined, + isLoading: false, + error: null, + })), +})) + +vi.mock('@/hooks/useRecipients', () => ({ + useRecipients: vi.fn(() => ({ + data: [], + isLoading: false, + })), +})) + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, +}) + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +) + +describe('PayoutForm', () => { + const defaultProps = { + onSubmit: vi.fn(), + onCancel: vi.fn(), + isSubmitting: false, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it('renders the form with all required fields', () => { + render(, { wrapper }) + + expect(screen.getByLabelText(/Recipient Name/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Amount/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Currency/i)).toBeInTheDocument() + }) + + it('renders destination type buttons', () => { + render(, { wrapper }) + + expect(screen.getByText('Stellar')).toBeInTheDocument() + expect(screen.getByText('Bank Account')).toBeInTheDocument() + expect(screen.getByText('Mobile Money')).toBeInTheDocument() + expect(screen.getByText('Crypto Wallet')).toBeInTheDocument() + }) + + it('switches destination type and shows appropriate fields', () => { + render(, { wrapper }) + + // Default is Stellar + expect(screen.getByLabelText(/Stellar Address/i)).toBeInTheDocument() + + // Click Bank Account + fireEvent.click(screen.getByText('Bank Account')) + expect(screen.getByLabelText(/Account Number/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Bank Name/i)).toBeInTheDocument() + + // Click Mobile Money + fireEvent.click(screen.getByText('Mobile Money')) + expect(screen.getByLabelText(/Provider/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Phone Number/i)).toBeInTheDocument() + + // Click Crypto Wallet + fireEvent.click(screen.getByText('Crypto Wallet')) + expect(screen.getByLabelText(/Wallet Address/i)).toBeInTheDocument() + expect(screen.getByLabelText(/Network/i)).toBeInTheDocument() + }) + + it('validates required fields before review', async () => { + render(, { wrapper }) + + // Click review without filling form + fireEvent.click(screen.getByText('Review')) + + await waitFor(() => { + expect(screen.getByText(/Recipient name is required/i)).toBeInTheDocument() + }) + }) + + it('clears errors when user starts typing', async () => { + render(, { wrapper }) + + // Trigger validation error + fireEvent.click(screen.getByText('Review')) + + await waitFor(() => { + expect(screen.getByText(/Recipient name is required/i)).toBeInTheDocument() + }) + + // Type in recipient name + fireEvent.change(screen.getByLabelText(/Recipient Name/i), { + target: { value: 'John Doe' }, + }) + + // Error should be cleared + await waitFor(() => { + expect(screen.queryByText(/Recipient name is required/i)).not.toBeInTheDocument() + }) + }) + + it('shows confirmation modal when form is valid', async () => { + render(, { wrapper }) + + // Fill in required fields + fireEvent.change(screen.getByLabelText(/Recipient Name/i), { + target: { value: 'John Doe' }, + }) + fireEvent.change(screen.getByLabelText(/Stellar Address/i), { + target: { value: 'GABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890ABCDEFGHIJKLMNO' }, + }) + fireEvent.change(screen.getByLabelText(/Amount/i), { + target: { value: '100' }, + }) + + // Click review + fireEvent.click(screen.getByText('Review')) + + // Wait for confirmation modal + await waitFor(() => { + expect(screen.getByText(/Confirm Payout/i)).toBeInTheDocument() + }) + }) + + it('calls onCancel when cancel button is clicked', () => { + render(, { wrapper }) + + fireEvent.click(screen.getByText('Cancel')) + expect(defaultProps.onCancel).toHaveBeenCalled() + }) + + it('shows save recipient checkbox', () => { + render(, { wrapper }) + + expect(screen.getByLabelText(/Save recipient for future payouts/i)).toBeInTheDocument() + }) + + it('disables buttons during submission', () => { + render(, { wrapper }) + + expect(screen.getByText('Cancel')).toBeDisabled() + expect(screen.getByText('Review')).toBeDisabled() + }) +}) diff --git a/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx b/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx new file mode 100644 index 0000000..d626f4a --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutSearchInput.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, fireEvent, waitFor } from '@testing-library/react' +import { PayoutSearchInput } from '../PayoutSearchInput' + +describe('PayoutSearchInput', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + 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, + }) + }) + + 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, + }) + }) + + 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 new file mode 100644 index 0000000..4153c0f --- /dev/null +++ b/apps/dashboard/src/components/payouts/__tests__/PayoutStatusBadge.test.tsx @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest' +import { render, screen } from '@testing-library/react' +import { PayoutStatusBadge } from '../PayoutStatusBadge' +import type { PayoutStatus } from '@/hooks/usePayouts' + +describe('PayoutStatusBadge', () => { + const statuses: { status: PayoutStatus; expectedLabel: string; expectedVariant: string }[] = [ + { status: 'PENDING', expectedLabel: 'Pending', expectedVariant: 'pending' }, + { status: 'PROCESSING', expectedLabel: 'Processing', expectedVariant: 'processing' }, + { status: 'COMPLETED', expectedLabel: 'Completed', expectedVariant: 'completed' }, + { status: 'FAILED', expectedLabel: 'Failed', expectedVariant: 'failed' }, + { status: 'CANCELLED', expectedLabel: 'Cancelled', expectedVariant: 'cancelled' }, + ] + + 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) + }) + }) + + 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/components/payouts/index.ts b/apps/dashboard/src/components/payouts/index.ts new file mode 100644 index 0000000..b0f09d2 --- /dev/null +++ b/apps/dashboard/src/components/payouts/index.ts @@ -0,0 +1,13 @@ +export { PayoutStatusBadge } from "./PayoutStatusBadge"; +export { PayoutFilterBar } from "./PayoutFilterBar"; +export { PayoutSearchInput } from "./PayoutSearchInput"; +export { PayoutExportButton } from "./PayoutExportButton"; +export { PayoutDetailDrawer } from "./PayoutDetailDrawer"; +export { CancelConfirmationModal } from "./CancelConfirmationModal"; +export { BatchGroupHeader } from "./BatchGroupHeader"; +export { NewPayoutButton } from "./NewPayoutButton"; +export { PayoutDrawer } from "./PayoutDrawer"; +export { PayoutForm } from "./PayoutForm"; +export { DestinationTypeFields } from "./DestinationTypeFields"; +export { FeeEstimatePanel } from "./FeeEstimatePanel"; +export { ConfirmationModal } from "./ConfirmationModal"; diff --git a/apps/dashboard/src/hooks/__tests__/usePayouts.test.ts b/apps/dashboard/src/hooks/__tests__/usePayouts.test.ts new file mode 100644 index 0000000..69c18b4 --- /dev/null +++ b/apps/dashboard/src/hooks/__tests__/usePayouts.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { ReactNode } from 'react' +import { usePayouts, useRetryPayout, useCancelPayout } from '../usePayouts' + +// Mock the API module +vi.mock('@/lib/api', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + }, +})) + +import { api } from '@/lib/api' + +const createWrapper = () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return ({ children }: { children: ReactNode }) => ( + {children} + ) +} + +describe('usePayouts', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('fetches payouts with correct params', async () => { + const mockResponse = { + total: 2, + limit: 20, + offset: 0, + data: [ + { + id: 'payout-1', + status: 'COMPLETED', + amount: '100.00', + currency: 'USD', + recipientName: 'John Doe', + destinationType: 'STELLAR', + destination: { address: 'GABC' }, + createdAt: '2024-01-15T09:00:00Z', + }, + ], + } + + vi.mocked(api.get).mockResolvedValueOnce(mockResponse) + + const { result } = renderHook(() => usePayouts({ limit: 20, offset: 0 }), { + wrapper: createWrapper(), + }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(api.get).toHaveBeenCalledWith('/v1/payouts', { + params: { limit: 20, offset: 0 }, + }) + expect(result.current.data).toEqual(mockResponse) + }) + + it('includes all filter params when provided', async () => { + const mockResponse = { total: 0, limit: 20, offset: 0, data: [] } + vi.mocked(api.get).mockResolvedValueOnce(mockResponse) + + const filters = { + status: 'PENDING' as const, + destinationType: 'STELLAR' as const, + currency: 'USD', + dateFrom: '2024-01-01', + dateTo: '2024-01-31', + batchId: 'batch-123', + search: 'John', + limit: 10, + offset: 0, + } + + renderHook(() => usePayouts(filters), { + wrapper: createWrapper(), + }) + + await waitFor(() => { + expect(api.get).toHaveBeenCalledWith('/v1/payouts', { params: filters }) + }) + }) +}) + +describe('useRetryPayout', () => { + it('calls retry endpoint and invalidates queries', async () => { + const mockPayout = { + id: 'payout-1', + status: 'PENDING', + amount: '100.00', + currency: 'USD', + recipientName: 'John Doe', + destinationType: 'STELLAR', + destination: {}, + createdAt: '2024-01-15T09:00:00Z', + } + + vi.mocked(api.post).mockResolvedValueOnce(mockPayout) + + const { result } = renderHook(() => useRetryPayout(), { + wrapper: createWrapper(), + }) + + await result.current.mutateAsync('payout-1') + + expect(api.post).toHaveBeenCalledWith('/v1/payouts/payout-1/retry') + }) +}) + +describe('useCancelPayout', () => { + it('calls cancel endpoint and invalidates queries', async () => { + const mockPayout = { + id: 'payout-1', + status: 'CANCELLED', + amount: '100.00', + currency: 'USD', + recipientName: 'John Doe', + destinationType: 'STELLAR', + destination: {}, + createdAt: '2024-01-15T09:00:00Z', + } + + vi.mocked(api.post).mockResolvedValueOnce(mockPayout) + + const { result } = renderHook(() => useCancelPayout(), { + wrapper: createWrapper(), + }) + + await result.current.mutateAsync('payout-1') + + expect(api.post).toHaveBeenCalledWith('/v1/payouts/payout-1/cancel') + }) +}) diff --git a/apps/dashboard/src/hooks/index.ts b/apps/dashboard/src/hooks/index.ts new file mode 100644 index 0000000..ed9d32a --- /dev/null +++ b/apps/dashboard/src/hooks/index.ts @@ -0,0 +1,28 @@ +// Payout hooks +export { useCreatePayout, type CreatePayoutInput } from './useCreatePayout'; +export { useFeeEstimate, type FeeEstimateParams, type FeeEstimateResponse } from './useFeeEstimate'; +export { + useRecipients, + useCreateRecipient, + useSearchRecipients, + type Recipient, + type CreateRecipientInput +} from './useRecipients'; +export { + useCurrencyConversion, + useSupportedCurrencies, + type CurrencyConversionParams, + type CurrencyConversionResponse +} from './useCurrencyConversion'; + +// Re-export existing hooks +export { usePayouts, usePayout, useRetryPayout, useCancelPayout, type PayoutsParams } from './usePayouts'; +export { useAuth } from './useAuth'; +export { useMerchant } from './useMerchant'; +export { useInvoices, type CreateInvoiceInput } from './useInvoices'; +export { usePaymentLinks } from './usePaymentLinks'; +export { usePayments } from './usePayments'; +export { useAnalytics } from './useAnalytics'; +export { useDashboardSocket } from './useDashboardSocket'; +export { useCountUp } from './useCountUp'; +export { useIsMobile } from './use-mobile'; diff --git a/apps/dashboard/src/hooks/useCreatePayout.ts b/apps/dashboard/src/hooks/useCreatePayout.ts new file mode 100644 index 0000000..41f61f9 --- /dev/null +++ b/apps/dashboard/src/hooks/useCreatePayout.ts @@ -0,0 +1,50 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { Payout, DestType } from "@useroutr/types"; + +export interface CreatePayoutInput { + recipientName: string; + destinationType: DestType; + destination: { + type: string; + accountNumber?: string; + routingNumber?: string; + bankName?: string; + iban?: string; + bic?: string; + branchCode?: string; + country?: string; + phoneNumber?: string; + provider?: string; + address?: string; + network?: string; + asset?: string; + memo?: string; + }; + amount: string; + currency: string; + idempotencyKey?: string; +} + +export function useCreatePayout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreatePayoutInput) => { + // Generate idempotency key if not provided + const idempotencyKey = input.idempotencyKey || crypto.randomUUID(); + + return api.post("/v1/payouts", input, { + headers: { + "idempotency-key": idempotencyKey, + }, + }); + }, + onSuccess: () => { + // Invalidate the payouts list to refresh + queryClient.invalidateQueries({ queryKey: ["payouts"] }); + }, + }); +} diff --git a/apps/dashboard/src/hooks/useCurrencyConversion.ts b/apps/dashboard/src/hooks/useCurrencyConversion.ts new file mode 100644 index 0000000..9756827 --- /dev/null +++ b/apps/dashboard/src/hooks/useCurrencyConversion.ts @@ -0,0 +1,122 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export interface CurrencyConversionParams { + from: string; + to: string; + amount: string; + enabled?: boolean; +} + +export interface CurrencyConversionResponse { + from: string; + to: string; + amount: string; + converted: string; + rate: string; + timestamp: string; +} + +// Fallback exchange rates (relative to USD) +const FALLBACK_RATES: Record = { + USD: 1, + EUR: 0.92, + GBP: 0.79, + NGN: 1550, + KES: 132, + GHS: 15.5, + ZAR: 18.8, + CAD: 1.36, + AUD: 1.52, + JPY: 151, + CNY: 7.24, + INR: 83.5, + BRL: 5.05, + MXN: 16.7, + AED: 3.67, + SGD: 1.35, + CHF: 0.9, + BTC: 0.000015, + ETH: 0.00029, + USDC: 1, + USDT: 1, + XLM: 10, +}; + +function convertCurrency( + amount: string, + from: string, + to: string +): CurrencyConversionResponse { + const amountNum = parseFloat(amount) || 0; + const fromRate = FALLBACK_RATES[from.toUpperCase()] || 1; + const toRate = FALLBACK_RATES[to.toUpperCase()] || 1; + + // Convert to USD first, then to target + const inUsd = amountNum / fromRate; + const converted = inUsd * toRate; + const rate = toRate / fromRate; + + return { + from: from.toUpperCase(), + to: to.toUpperCase(), + amount: amountNum.toFixed(2), + converted: converted.toFixed(2), + rate: rate.toFixed(6), + timestamp: new Date().toISOString(), + }; +} + +export function useCurrencyConversion(params: CurrencyConversionParams) { + const { from, to, amount, enabled = true } = params; + + return useQuery({ + queryKey: ["currency-conversion", from, to, amount], + queryFn: async () => { + try { + // Try to fetch from API first + const response = await api.get("/v1/currency/convert", { + params: { + from, + to, + amount, + }, + }); + return response; + } catch { + // Fallback to client-side calculation + return convertCurrency(amount, from, to); + } + }, + enabled: enabled && !!amount && parseFloat(amount) > 0 && from !== to, + staleTime: 60000, // 1 minute + gcTime: 300000, // 5 minutes + retry: 1, + }); +} + +/** + * Get supported currencies list + */ +export function useSupportedCurrencies() { + return useQuery({ + queryKey: ["supported-currencies"], + queryFn: async () => { + try { + const response = await api.get("/v1/currency/supported"); + return response; + } catch { + // Return default list of supported currencies + return [ + "USD", "EUR", "GBP", "NGN", "KES", "GHS", "ZAR", + "CAD", "AUD", "JPY", "CNY", "INR", "BRL", "MXN", + "AED", "SGD", "CHF", "BTC", "ETH", "USDC", "USDT", "XLM", + ]; + } + }, + staleTime: 300000, // 5 minutes + gcTime: 600000, // 10 minutes + }); +} diff --git a/apps/dashboard/src/hooks/useFeeEstimate.ts b/apps/dashboard/src/hooks/useFeeEstimate.ts new file mode 100644 index 0000000..c625434 --- /dev/null +++ b/apps/dashboard/src/hooks/useFeeEstimate.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { DestType } from "@useroutr/types"; + +export interface FeeEstimateParams { + destinationType: DestType; + amount: string; + currency: string; + targetCurrency?: string; + enabled?: boolean; +} + +export interface FeeEstimateResponse { + fee: string; + feeCurrency: string; + total: string; + exchangeRate?: string; + estimatedTime?: string; +} + +// Default fee rates by destination type (fallback when API fails) +const DEFAULT_FEE_RATES: Record = { + BANK_ACCOUNT: 0.005, // 0.5% + MOBILE_MONEY: 0.01, // 1% + CRYPTO_WALLET: 0.002, // 0.2% + STELLAR: 0.001, // 0.1% +}; + +const MINIMUM_FEES: Record = { + BANK_ACCOUNT: 1, + MOBILE_MONEY: 0.5, + CRYPTO_WALLET: 0.1, + STELLAR: 0.01, +}; + +/** + * Calculate estimated fee based on amount and destination type + * Uses a fallback calculation when API is unavailable + */ +function calculateEstimatedFee( + amount: string, + destinationType: DestType +): FeeEstimateResponse { + const amountNum = parseFloat(amount) || 0; + const rate = DEFAULT_FEE_RATES[destinationType]; + const minFee = MINIMUM_FEES[destinationType]; + + const calculatedFee = Math.max(amountNum * rate, minFee); + const total = amountNum + calculatedFee; + + return { + fee: calculatedFee.toFixed(2), + feeCurrency: "USD", + total: total.toFixed(2), + }; +} + +export function useFeeEstimate(params: FeeEstimateParams) { + const { destinationType, amount, currency, enabled = true } = params; + + return useQuery({ + queryKey: ["fee-estimate", destinationType, amount, currency], + queryFn: async () => { + try { + // Try to fetch from API first + const response = await api.get("/v1/fees/estimate", { + params: { + destinationType, + amount, + currency, + }, + }); + return response; + } catch { + // Fallback to client-side calculation + return calculateEstimatedFee(amount, destinationType); + } + }, + enabled: enabled && !!amount && parseFloat(amount) > 0, + staleTime: 30000, // 30 seconds + gcTime: 60000, // 1 minute + retry: 1, + }); +} diff --git a/apps/dashboard/src/hooks/usePayouts.ts b/apps/dashboard/src/hooks/usePayouts.ts new file mode 100644 index 0000000..9a00a86 --- /dev/null +++ b/apps/dashboard/src/hooks/usePayouts.ts @@ -0,0 +1,70 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { + Payout, + PayoutListResponse, + PayoutFilters, + PayoutStatus, + DestType, +} from "@useroutr/types"; + +export type { Payout, PayoutStatus, DestType }; + +export interface PayoutsParams extends Record { + status?: PayoutStatus; + destinationType?: DestType; + currency?: string; + dateFrom?: string; + dateTo?: string; + batchId?: string; + search?: string; + limit?: number; + offset?: number; +} + +export function usePayouts(params: PayoutsParams = {}) { + return useQuery({ + queryKey: ["payouts", params], + queryFn: () => api.get("/v1/payouts", { params }), + }); +} + +export function usePayout(id: string) { + return useQuery({ + queryKey: ["payout", id], + queryFn: () => api.get(`/v1/payouts/${id}`), + enabled: !!id, + }); +} + +export function useRetryPayout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + return api.post(`/v1/payouts/${id}/retry`); + }, + onSuccess: (_, id) => { + // Invalidate the specific payout and the list + queryClient.invalidateQueries({ queryKey: ["payout", id] }); + queryClient.invalidateQueries({ queryKey: ["payouts"] }); + }, + }); +} + +export function useCancelPayout() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (id: string) => { + return api.post(`/v1/payouts/${id}/cancel`); + }, + onSuccess: (_, id) => { + // Invalidate the specific payout and the list + queryClient.invalidateQueries({ queryKey: ["payout", id] }); + queryClient.invalidateQueries({ queryKey: ["payouts"] }); + }, + }); +} diff --git a/apps/dashboard/src/hooks/useRecipients.ts b/apps/dashboard/src/hooks/useRecipients.ts new file mode 100644 index 0000000..7c75891 --- /dev/null +++ b/apps/dashboard/src/hooks/useRecipients.ts @@ -0,0 +1,89 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { DestType, PayoutDestination } from "@useroutr/types"; + +export interface Recipient { + id: string; + merchantId: string; + name: string; + destinationType: DestType; + destination: PayoutDestination; + createdAt: string; + updatedAt: string; +} + +export interface CreateRecipientInput { + name: string; + destinationType: DestType; + destination: PayoutDestination; +} + +export function useRecipients() { + return useQuery({ + queryKey: ["recipients"], + queryFn: async () => { + try { + const response = await api.get("/v1/recipients"); + return response; + } catch { + // Return empty array if endpoint doesn't exist yet + return []; + } + }, + staleTime: 60000, // 1 minute + gcTime: 300000, // 5 minutes + }); +} + +export function useCreateRecipient() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (input: CreateRecipientInput) => { + try { + const response = await api.post("/v1/recipients", input); + return response; + } catch { + // If endpoint doesn't exist, return a mock recipient + return { + id: crypto.randomUUID(), + merchantId: "", + name: input.name, + destinationType: input.destinationType, + destination: input.destination, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + } + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["recipients"] }); + }, + }); +} + +/** + * Search recipients by name + */ +export function useSearchRecipients(query: string) { + return useQuery({ + queryKey: ["recipients", "search", query], + queryFn: async () => { + if (!query.trim()) return []; + + try { + const response = await api.get("/v1/recipients/search", { + params: { q: query }, + }); + return response; + } catch { + return []; + } + }, + enabled: query.trim().length > 0, + staleTime: 30000, + gcTime: 60000, + }); +} diff --git a/apps/dashboard/src/schemas/index.ts b/apps/dashboard/src/schemas/index.ts new file mode 100644 index 0000000..7ed78d9 --- /dev/null +++ b/apps/dashboard/src/schemas/index.ts @@ -0,0 +1,17 @@ +export { + PayoutFormSchema, + PayoutConfirmationSchema, + defaultDestinationByType, + defaultFormValues, + validateAmount, + validateStellarAddress, + validatePhoneNumber, + validateCryptoAddress, + type PayoutFormData, + type PayoutConfirmationData, + type DestinationFormData, + type BankAccountFields, + type MobileMoneyFields, + type CryptoWalletFields, + type StellarFields, +} from './payout.schema'; diff --git a/apps/dashboard/src/schemas/payout.schema.ts b/apps/dashboard/src/schemas/payout.schema.ts new file mode 100644 index 0000000..68c0617 --- /dev/null +++ b/apps/dashboard/src/schemas/payout.schema.ts @@ -0,0 +1,176 @@ +"use client"; + +import { z } from "zod"; +import type { DestType } from "@useroutr/types"; + +// ── Destination sub-schemas ────────────────────────────────────────────────── + +const BankAccountDestSchema = z.object({ + type: z.literal("BANK_ACCOUNT"), + accountNumber: z.string().min(1, "Account number is required").max(64), + routingNumber: z.string().max(20).optional(), + bankName: z.string().min(1, "Bank name is required").max(255), + country: z.string().length(2, "Country code must be 2 characters").toUpperCase(), +}); + +const MobileMoneyDestSchema = z.object({ + type: z.literal("MOBILE_MONEY"), + phoneNumber: z.string().min(7, "Phone number must be at least 7 characters").max(20), + provider: z.string().min(1, "Provider is required"), + country: z.string().length(2, "Country code must be 2 characters").toUpperCase(), +}); + +const CryptoWalletDestSchema = z.object({ + type: z.literal("CRYPTO_WALLET"), + address: z.string().min(1, "Wallet address is required").max(255), + network: z.string().min(1, "Network is required"), + asset: z.string().min(1, "Asset is required"), +}); + +const StellarDestSchema = z.object({ + type: z.literal("STELLAR"), + address: z.string().min(1, "Stellar address is required").max(255).regex(/^G[A-Z0-9]{55}$/, "Invalid Stellar address format (must start with G and be 56 characters)"), + asset: z.string().min(1, "Asset is required").default("native"), + memo: z.string().max(28).optional(), +}); + +const DestinationSchema = z.discriminatedUnion("type", [ + BankAccountDestSchema, + MobileMoneyDestSchema, + CryptoWalletDestSchema, + StellarDestSchema, +]); + +// ── Main payout form schema ─────────────────────────────────────────────────── + +export const PayoutFormSchema = z.object({ + recipientName: z.string().min(1, "Recipient name is required").max(255), + destinationType: z.enum(["BANK_ACCOUNT", "MOBILE_MONEY", "CRYPTO_WALLET", "STELLAR"] as const), + destination: DestinationSchema, + amount: z + .string() + .min(1, "Amount is required") + .regex(/^\d+(\.\d{1,18})?$/, "Amount must be a positive number"), + currency: z.string().length(3, "Currency code must be 3 characters").toUpperCase(), + saveRecipient: z.boolean().default(false), +}); + +// ── Confirmation data schema ───────────────────────────────────────────────── + +export const PayoutConfirmationSchema = z.object({ + recipientName: z.string(), + destinationType: z.enum(["BANK_ACCOUNT", "MOBILE_MONEY", "CRYPTO_WALLET", "STELLAR"] as const), + destination: z.object({ + type: z.string(), + accountNumber: z.string().optional(), + routingNumber: z.string().optional(), + bankName: z.string().optional(), + country: z.string().optional(), + phoneNumber: z.string().optional(), + provider: z.string().optional(), + address: z.string().optional(), + network: z.string().optional(), + asset: z.string().optional(), + memo: z.string().optional(), + }), + amount: z.string(), + currency: z.string(), + fee: z.string().optional(), + total: z.string().optional(), +}); + +// ── Types ──────────────────────────────────────────────────────────────────── + +export type PayoutFormData = z.infer; +export type PayoutConfirmationData = z.infer; +export type DestinationFormData = z.infer; + +// ── Helper types for destination fields ────────────────────────────────────── + +export interface BankAccountFields { + type: "BANK_ACCOUNT"; + accountNumber: string; + routingNumber?: string; + bankName: string; + country: string; +} + +export interface MobileMoneyFields { + type: "MOBILE_MONEY"; + phoneNumber: string; + provider: string; + country: string; +} + +export interface CryptoWalletFields { + type: "CRYPTO_WALLET"; + address: string; + network: string; + asset: string; +} + +export interface StellarFields { + type: "STELLAR"; + address: string; + asset: string; + memo?: string; +} + +// ── Default values ───────────────────────────────────────────────────────── + +export const defaultDestinationByType: Record = { + BANK_ACCOUNT: { + type: "BANK_ACCOUNT", + accountNumber: "", + routingNumber: "", + bankName: "", + country: "US", + }, + MOBILE_MONEY: { + type: "MOBILE_MONEY", + phoneNumber: "", + provider: "", + country: "NG", + }, + CRYPTO_WALLET: { + type: "CRYPTO_WALLET", + address: "", + network: "ethereum", + asset: "USDC", + }, + STELLAR: { + type: "STELLAR", + address: "", + asset: "native", + }, +}; + +export const defaultFormValues: Omit & { destination: { type: DestType } } = { + recipientName: "", + destinationType: "STELLAR", + destination: { type: "STELLAR" }, + amount: "", + currency: "USD", + saveRecipient: false, +}; + +// ── Validation helpers ─────────────────────────────────────────────────────── + +export function validateAmount(amount: string): boolean { + const num = parseFloat(amount); + return !isNaN(num) && num > 0; +} + +export function validateStellarAddress(address: string): boolean { + return /^G[A-Z0-9]{55}$/.test(address); +} + +export function validatePhoneNumber(phone: string): boolean { + // Basic international phone validation + return /^[+]?[\d\s-]{7,20}$/.test(phone.replace(/\s/g, "")); +} + +export function validateCryptoAddress(address: string): boolean { + // Basic validation - at least 10 chars, alphanumeric + return address.length >= 10 && /^[a-zA-Z0-9]+$/.test(address); +} diff --git a/apps/dashboard/src/test/setup.ts b/apps/dashboard/src/test/setup.ts new file mode 100644 index 0000000..985b9bd --- /dev/null +++ b/apps/dashboard/src/test/setup.ts @@ -0,0 +1,43 @@ +import '@testing-library/jest-dom' +import { vi } from 'vitest' + +// Mock next/navigation +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + }), + useSearchParams: () => ({ + get: vi.fn(), + toString: vi.fn(), + }), + usePathname: () => '/payouts', +})) + +// Mock next/head +vi.mock('next/head', () => ({ + default: ({ children }: { children: React.ReactNode }) => children, +})) + +// Mock @useroutr/ui toast +vi.mock('@useroutr/ui', async () => { + const actual = await vi.importActual('@useroutr/ui') + return { + ...actual, + useToast: () => ({ + toast: vi.fn(), + }), + } +}) + +// Global fetch mock +global.fetch = vi.fn() + +// Mock window.URL.createObjectURL +Object.defineProperty(window, 'URL', { + value: { + createObjectURL: vi.fn(() => 'blob:mock-url'), + revokeObjectURL: vi.fn(), + }, +}) diff --git a/apps/dashboard/vitest.config.ts b/apps/dashboard/vitest.config.ts new file mode 100644 index 0000000..a864f9d --- /dev/null +++ b/apps/dashboard/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import path from 'path' + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + alias: { + '@': path.resolve(__dirname, './src'), + '@useroutr/ui': path.resolve(__dirname, '../packages/ui/src/index.ts'), + '@useroutr/types': path.resolve(__dirname, '../packages/types/src/index.ts'), + }, + }, +}) diff --git a/packages/types/src/payout.types.ts b/packages/types/src/payout.types.ts index fd556ee..27afe32 100644 --- a/packages/types/src/payout.types.ts +++ b/packages/types/src/payout.types.ts @@ -1,15 +1,67 @@ +export type PayoutStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; + +export type DestType = 'BANK_ACCOUNT' | 'MOBILE_MONEY' | 'CRYPTO_WALLET' | 'STELLAR'; + +export interface PayoutDestination { + type?: string; + accountNumber?: string; + routingNumber?: string; + bankName?: string; + iban?: string; + bic?: string; + branchCode?: string; + country?: string; + phoneNumber?: string; + provider?: string; + address?: string; + network?: string; + asset?: string; + memo?: string; +} + export interface Payout { - id: string; - merchantId?: string; - recipientName?: string; - destination?: Record; - amount: bigint | number | string; - currency?: string; - status?: string; - scheduledAt?: string; - completedAt?: string; + id: string; + merchantId: string; + recipientName: string; + destinationType: DestType; + destination: PayoutDestination; + amount: string; + currency: string; + status: PayoutStatus; + stellarTxHash: string | null; + scheduledAt: string | null; + completedAt: string | null; + failureReason: string | null; + batchId: string | null; + idempotencyKey: string | null; + createdAt: string; } -export type PayoutStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED'; +export interface PayoutListResponse { + total: number; + limit: number; + offset: number; + data: Payout[]; +} + +export interface PayoutFilters { + status?: PayoutStatus; + destinationType?: DestType; + currency?: string; + dateFrom?: Date; + dateTo?: Date; + batchId?: string; + search?: string; + limit?: number; + offset?: number; +} + +export interface BatchSummary { + batchId: string; + totalPayouts: number; + totalAmount: number; + currency: string; + statusCounts: Record; +} export default {};