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..2f8e2e7 100644 --- a/apps/dashboard/src/app/(dashboard)/payouts/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/payouts/page.tsx @@ -1,38 +1,589 @@ -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 { 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/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/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/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__/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__/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..9ad280c --- /dev/null +++ b/apps/dashboard/src/components/payouts/index.ts @@ -0,0 +1,7 @@ +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"; 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/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/lib/utils.ts b/apps/dashboard/src/lib/utils.ts index 6f37769..2f5ba4e 100644 --- a/apps/dashboard/src/lib/utils.ts +++ b/apps/dashboard/src/lib/utils.ts @@ -1,11 +1,42 @@ // Re-export cn from shared UI package export { cn } from "@useroutr/ui"; +// Stablecoins / crypto assets aren't in ISO 4217, so Intl.NumberFormat with +// `style: "currency"` throws. Format the number portion only and append the +// asset code as a suffix. +const NON_ISO_CURRENCIES = new Set([ + "USDC", + "USDT", + "DAI", + "XLM", + "BTC", + "ETH", +]); + export function formatCurrency(amount: number, currency = "USD") { - return new Intl.NumberFormat("en-US", { - style: "currency", - currency, - }).format(amount); + const upper = currency?.toUpperCase() ?? "USD"; + + if (NON_ISO_CURRENCIES.has(upper)) { + const formatted = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + return `${formatted} ${upper}`; + } + + try { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency: upper, + }).format(amount); + } catch { + // Unknown ISO code — fall back to plain number + suffix. + const formatted = new Intl.NumberFormat("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); + return `${formatted} ${upper}`; + } } export function truncateAddress(address: string, chars = 6) { 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 {};