diff --git a/app/api/transactions/ledger/export/route.ts b/app/api/transactions/ledger/export/route.ts new file mode 100644 index 0000000..745e2ff --- /dev/null +++ b/app/api/transactions/ledger/export/route.ts @@ -0,0 +1,132 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { normalizeUserRole, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { parseSearchParams } from "@/lib/api/validation" +import dbConnect from "@/lib/dbConnect" +import { + buildLedgerFilter, + normalizeLedgerEntry, + type LedgerActor, + type LedgerQueryParams, +} from "@/lib/ledger/ledger" +import Transaction from "@/models/Transaction" +import "@/models/User" + +const EXPORT_LIMIT = 5000 + +const querySchema = z.object({ + search: z.string().trim().max(120).default(""), + type: z.string().trim().max(40).default(""), + status: z.string().trim().max(20).default(""), + method: z.string().trim().max(40).default(""), + reconciliation: z.enum(["reconciled", "pending", "failed", "duplicate", ""]).default(""), + from: z.string().trim().max(40).default(""), + to: z.string().trim().max(40).default(""), + userType: z.string().trim().max(20).default(""), + userId: z.string().trim().max(40).default(""), +}) + +function csvEscape(value: unknown): string { + const raw = value == null ? "" : String(value) + if (raw.includes(",") || raw.includes("\"") || raw.includes("\n")) { + return `"${raw.replace(/"/g, "\"\"")}"` + } + return raw +} + +export async function GET(request: Request) { + try { + const authContext = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) + if ("response" in authContext) return authContext.response + + const parsed = parseSearchParams(request, querySchema) + if ("response" in parsed) return parsed.response + + const role = normalizeUserRole(authContext.user.role) + if (!role) { + return NextResponse.json({ message: "Access denied" }, { status: 403 }) + } + + await dbConnect() + + const params = { ...parsed.data, page: 1, pageSize: EXPORT_LIMIT } as LedgerQueryParams + const actor: LedgerActor = { id: authContext.user._id.toString(), role } + const isAdmin = role === "admin" + + const baseFilter = buildLedgerFilter(params, actor) + + const duplicateAgg = await Transaction.aggregate([ + { $match: { ...baseFilter, gatewayReference: { $nin: [null, ""] } } }, + { $group: { _id: "$gatewayReference", count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } }, + { $project: { _id: 1 } }, + ]) + const duplicateReferences = new Set(duplicateAgg.map((entry: { _id: string }) => entry._id)) + + const queryFilter: Record = { ...baseFilter } + if (params.reconciliation === "failed") { + queryFilter.status = "Failed" + } else if (params.reconciliation === "pending") { + queryFilter.status = "Pending" + } else if (params.reconciliation === "duplicate") { + queryFilter.gatewayReference = { $in: Array.from(duplicateReferences) } + } else if (params.reconciliation === "reconciled") { + queryFilter.status = "Completed" + queryFilter.gatewayReference = { $nin: Array.from(duplicateReferences) } + } + + const pageQuery = Transaction.find(queryFilter).sort({ timestamp: -1 }).limit(EXPORT_LIMIT) + if (isAdmin) { + pageQuery.populate({ path: "userId", select: "name fullName email role" }) + } + const transactions = (await pageQuery.lean()) as Array> + + const headers = [ + "Date", + "Type", + "Direction", + "Amount", + "Currency", + "Status", + "Reconciliation", + "Method", + "Reference", + "Description", + ...(isAdmin ? ["User", "User Email", "User Type"] : []), + ] + + const lines = [headers.map(csvEscape).join(",")] + for (const tx of transactions) { + const entry = normalizeLedgerEntry(tx, duplicateReferences) + const row = [ + entry.timestamp, + entry.type, + entry.direction, + entry.amount, + entry.currency, + entry.status, + entry.reconciliation, + entry.method ?? "", + entry.reference ?? "", + entry.description, + ...(isAdmin ? [entry.userName ?? "", entry.userEmail ?? "", entry.userType] : []), + ] + lines.push(row.map(csvEscape).join(",")) + } + + const csv = lines.join("\n") + const filename = `transaction-ledger-${new Date().toISOString().slice(0, 10)}.csv` + + return new NextResponse(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }) + } catch (error) { + console.error("LEDGER_EXPORT_ERROR", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/api/transactions/ledger/route.ts b/app/api/transactions/ledger/route.ts new file mode 100644 index 0000000..27e477f --- /dev/null +++ b/app/api/transactions/ledger/route.ts @@ -0,0 +1,134 @@ +import { NextResponse } from "next/server" +import { z } from "zod" + +import { finalizeAuthenticatedResponse, normalizeUserRole, requireAuthenticatedUser } from "@/lib/api/route-guard" +import { parseSearchParams } from "@/lib/api/validation" +import dbConnect from "@/lib/dbConnect" +import { + buildLedgerFilter, + normalizeLedgerEntry, + type LedgerActor, + type LedgerQueryParams, +} from "@/lib/ledger/ledger" +import Transaction from "@/models/Transaction" +// Ensure the referenced model is registered for populate(). +import "@/models/User" + +const querySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).default(20), + search: z.string().trim().max(120).default(""), + type: z.string().trim().max(40).default(""), + status: z.string().trim().max(20).default(""), + method: z.string().trim().max(40).default(""), + reconciliation: z.enum(["reconciled", "pending", "failed", "duplicate", ""]).default(""), + from: z.string().trim().max(40).default(""), + to: z.string().trim().max(40).default(""), + userType: z.string().trim().max(20).default(""), + userId: z.string().trim().max(40).default(""), +}) + +export async function GET(request: Request) { + try { + const authContext = await requireAuthenticatedUser(request, ["admin", "driver", "investor"]) + if ("response" in authContext) return authContext.response + + const parsed = parseSearchParams(request, querySchema) + if ("response" in parsed) return parsed.response + + const role = normalizeUserRole(authContext.user.role) + if (!role) { + return NextResponse.json({ message: "Access denied" }, { status: 403 }) + } + + await dbConnect() + + const params = parsed.data as LedgerQueryParams + const actor: LedgerActor = { id: authContext.user._id.toString(), role } + const isAdmin = role === "admin" + + const baseFilter = buildLedgerFilter(params, actor) + + // Detect duplicate provider references within the filtered scope so the + // dashboard can flag potential double-postings. + const duplicateAgg = await Transaction.aggregate([ + { $match: { ...baseFilter, gatewayReference: { $nin: [null, ""] } } }, + { $group: { _id: "$gatewayReference", count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } }, + { $project: { _id: 1 } }, + ]) + const duplicateReferences = new Set(duplicateAgg.map((entry: { _id: string }) => entry._id)) + + // Translate the derived reconciliation filter into a concrete query clause. + const queryFilter: Record = { ...baseFilter } + if (params.reconciliation === "failed") { + queryFilter.status = "Failed" + } else if (params.reconciliation === "pending") { + queryFilter.status = "Pending" + } else if (params.reconciliation === "duplicate") { + queryFilter.gatewayReference = { $in: Array.from(duplicateReferences) } + } else if (params.reconciliation === "reconciled") { + queryFilter.status = "Completed" + queryFilter.gatewayReference = { $nin: Array.from(duplicateReferences) } + } + + const pageQuery = Transaction.find(queryFilter) + .sort({ timestamp: -1 }) + .skip((params.page - 1) * params.pageSize) + .limit(params.pageSize) + if (isAdmin) { + pageQuery.populate({ path: "userId", select: "name fullName email role" }) + } + + const [statusAgg, total, transactions] = await Promise.all([ + Transaction.aggregate([ + { $match: baseFilter }, + { $group: { _id: "$status", count: { $sum: 1 }, amount: { $sum: "$amount" } } }, + ]), + Transaction.countDocuments(queryFilter), + pageQuery.lean(), + ]) + + const statusMap = new Map() + for (const entry of statusAgg as Array<{ _id: string; count: number; amount: number }>) { + statusMap.set(entry._id, { count: entry.count, amount: entry.amount }) + } + + const completed = statusMap.get("Completed") ?? { count: 0, amount: 0 } + const pending = statusMap.get("Pending") ?? { count: 0, amount: 0 } + const failed = statusMap.get("Failed") ?? { count: 0, amount: 0 } + const totalCount = completed.count + pending.count + failed.count + + const entries = (transactions as Array>).map((tx) => + normalizeLedgerEntry(tx, duplicateReferences), + ) + + const response = NextResponse.json({ + success: true, + scope: isAdmin ? "global" : "self", + transactions: entries, + pagination: { + page: params.page, + pageSize: params.pageSize, + total, + totalPages: Math.max(1, Math.ceil(total / params.pageSize)), + }, + summary: { + totalCount, + totalAmount: completed.amount + pending.amount + failed.amount, + completedCount: completed.count, + completedAmount: completed.amount, + pendingCount: pending.count, + pendingAmount: pending.amount, + failedCount: failed.count, + failedAmount: failed.amount, + duplicateCount: duplicateReferences.size, + }, + }) + + return finalizeAuthenticatedResponse(response, authContext) + } catch (error) { + console.error("LEDGER_GET_ERROR", error) + return NextResponse.json({ error: "Internal server error" }, { status: 500 }) + } +} diff --git a/app/dashboard/admin/ledger/loading.tsx b/app/dashboard/admin/ledger/loading.tsx new file mode 100644 index 0000000..e54266b --- /dev/null +++ b/app/dashboard/admin/ledger/loading.tsx @@ -0,0 +1,10 @@ +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" + +export default function Loading() { + return ( + + ) +} diff --git a/app/dashboard/admin/ledger/page.tsx b/app/dashboard/admin/ledger/page.tsx new file mode 100644 index 0000000..3ae9579 --- /dev/null +++ b/app/dashboard/admin/ledger/page.tsx @@ -0,0 +1,16 @@ +import { TransactionLedger } from "@/components/dashboard/ledger/transaction-ledger" +import { requireAdminAccess } from "@/src/server/admin/require-admin" + +export const dynamic = "force-dynamic" + +export default async function AdminLedgerPage() { + await requireAdminAccess() + + return ( + + ) +} diff --git a/app/dashboard/driver/ledger/loading.tsx b/app/dashboard/driver/ledger/loading.tsx new file mode 100644 index 0000000..64b6eb4 --- /dev/null +++ b/app/dashboard/driver/ledger/loading.tsx @@ -0,0 +1,10 @@ +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" + +export default function Loading() { + return ( + + ) +} diff --git a/app/dashboard/driver/ledger/page.tsx b/app/dashboard/driver/ledger/page.tsx new file mode 100644 index 0000000..b09d69c --- /dev/null +++ b/app/dashboard/driver/ledger/page.tsx @@ -0,0 +1,55 @@ +import { redirect } from "next/navigation" + +import { DashboardShell } from "@/components/dashboard/dashboard-shell" +import { DashboardHeader } from "@/components/dashboard/investor-overview/dashboard-header" +import { TransactionLedger } from "@/components/dashboard/ledger/transaction-ledger" +import dbConnect from "@/lib/dbConnect" +import { getSessionFromCookies } from "@/lib/auth/session" +import User from "@/models/User" + +export const dynamic = "force-dynamic" + +function resolveDisplayName(user: { fullName?: string; name?: string; email?: string | null }) { + if (user.fullName && user.fullName.trim()) return user.fullName.trim() + if (user.name && user.name.trim()) return user.name.trim() + if (user.email) return user.email.split("@")[0] + return "Driver" +} + +export default async function DriverLedgerPage() { + const session = await getSessionFromCookies() + if (!session?.userId) { + redirect("/signin") + } + + await dbConnect() + const user = await User.findById(session.userId).select("name fullName email role") + + if (!user || user.role !== "driver") { + redirect("/signin") + } + + return ( + + } + > +
+ +
+
+ ) +} diff --git a/app/dashboard/investor/ledger/loading.tsx b/app/dashboard/investor/ledger/loading.tsx new file mode 100644 index 0000000..64b6eb4 --- /dev/null +++ b/app/dashboard/investor/ledger/loading.tsx @@ -0,0 +1,10 @@ +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" + +export default function Loading() { + return ( + + ) +} diff --git a/app/dashboard/investor/ledger/page.tsx b/app/dashboard/investor/ledger/page.tsx new file mode 100644 index 0000000..c6966a1 --- /dev/null +++ b/app/dashboard/investor/ledger/page.tsx @@ -0,0 +1,54 @@ +"use client" + +import { useRouter } from "next/navigation" + +import { DashboardShell } from "@/components/dashboard/dashboard-shell" +import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading" +import { Header } from "@/components/dashboard/header" +import { TransactionLedger } from "@/components/dashboard/ledger/transaction-ledger" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuth } from "@/hooks/use-auth" + +export default function InvestorLedgerPage() { + const router = useRouter() + const { user: authUser, loading: authLoading } = useAuth() + + if (authLoading) { + return ( + + ) + } + + if (!authUser || authUser.role !== "investor") { + return ( +
+ + + Access denied + You need an investor account to access this page. + + + + + +
+ ) + } + + return ( + }> +
+ +
+
+ ) +} diff --git a/components/dashboard/ledger/transaction-ledger.tsx b/components/dashboard/ledger/transaction-ledger.tsx new file mode 100644 index 0000000..03e4725 --- /dev/null +++ b/components/dashboard/ledger/transaction-ledger.tsx @@ -0,0 +1,633 @@ +"use client" + +import { useCallback, useEffect, useMemo, useState } from "react" +import { + AlertTriangle, + ArrowDownLeft, + ArrowUpRight, + Download, + Loader2, + RefreshCw, + Search, + SlidersHorizontal, +} from "lucide-react" + +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { formatNaira } from "@/lib/currency" +import { cn } from "@/lib/utils" + +type LedgerRole = "admin" | "investor" | "driver" + +interface LedgerEntry { + id: string + userId: string + userType: string + userName: string | null + userEmail: string | null + type: string + direction: "credit" | "debit" + amount: number + amountOriginal: number | null + currency: string + originalCurrency: string | null + exchangeRate: number | null + method: string | null + reference: string | null + description: string + status: string + reconciliation: "reconciled" | "pending" | "failed" | "duplicate" + relatedId: string | null + metadata: Record | null + timestamp: string +} + +interface LedgerSummary { + totalCount: number + totalAmount: number + completedCount: number + completedAmount: number + pendingCount: number + pendingAmount: number + failedCount: number + failedAmount: number + duplicateCount: number +} + +interface LedgerResponse { + success: boolean + scope: "global" | "self" + transactions: LedgerEntry[] + pagination: { page: number; pageSize: number; total: number; totalPages: number } + summary: LedgerSummary +} + +interface TransactionLedgerProps { + role: LedgerRole + title?: string + description?: string +} + +const TYPE_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "All types" }, + { value: "deposit", label: "Deposit" }, + { value: "wallet_funding", label: "Wallet Funding" }, + { value: "wallet_debit", label: "Wallet Debit" }, + { value: "withdrawal", label: "Withdrawal" }, + { value: "investment", label: "Investment" }, + { value: "pool_investment", label: "Pool Investment" }, + { value: "return", label: "Return / Payout" }, + { value: "repayment", label: "Repayment" }, + { value: "loan_disbursement", label: "Loan Disbursement" }, + { value: "down_payment", label: "Down Payment" }, +] + +const STATUS_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "All statuses" }, + { value: "Completed", label: "Completed" }, + { value: "Pending", label: "Pending" }, + { value: "Failed", label: "Failed" }, +] + +const METHOD_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "All providers" }, + { value: "paystack", label: "Paystack" }, + { value: "gateway", label: "Gateway" }, + { value: "wallet", label: "Wallet" }, + { value: "internal_wallet", label: "Internal Wallet" }, + { value: "privy", label: "Privy" }, + { value: "system", label: "System" }, +] + +const RECONCILIATION_OPTIONS: Array<{ value: string; label: string }> = [ + { value: "", label: "All reconciliation" }, + { value: "reconciled", label: "Reconciled" }, + { value: "pending", label: "Pending" }, + { value: "failed", label: "Failed" }, + { value: "duplicate", label: "Duplicate" }, +] + +const TYPE_LABELS: Record = Object.fromEntries( + TYPE_OPTIONS.filter((option) => option.value).map((option) => [option.value, option.label]), +) + +const METHOD_LABELS: Record = Object.fromEntries( + METHOD_OPTIONS.filter((option) => option.value).map((option) => [option.value, option.label]), +) + +const PAGE_SIZE = 20 + +const selectClassName = + "h-9 rounded-md border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring" + +function formatType(type: string) { + return TYPE_LABELS[type] ?? type.replace(/_/g, " ") +} + +function formatMethod(method: string | null) { + if (!method) return "—" + return METHOD_LABELS[method] ?? method +} + +function formatDate(value: string) { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + return date.toLocaleString("en-NG", { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) +} + +function statusVariant(status: string): "green" | "yellow" | "red" | "outline" { + if (status === "Completed") return "green" + if (status === "Pending") return "yellow" + if (status === "Failed") return "red" + return "outline" +} + +function reconciliationVariant(value: string): "green" | "yellow" | "red" | "purple" | "outline" { + if (value === "reconciled") return "green" + if (value === "pending") return "yellow" + if (value === "failed") return "red" + if (value === "duplicate") return "purple" + return "outline" +} + +export function TransactionLedger({ role, title, description }: TransactionLedgerProps) { + const isAdmin = role === "admin" + + const [search, setSearch] = useState("") + const [debouncedSearch, setDebouncedSearch] = useState("") + const [type, setType] = useState("") + const [status, setStatus] = useState("") + const [method, setMethod] = useState("") + const [reconciliation, setReconciliation] = useState("") + const [from, setFrom] = useState("") + const [to, setTo] = useState("") + const [page, setPage] = useState(1) + + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [selected, setSelected] = useState(null) + + useEffect(() => { + const handle = setTimeout(() => setDebouncedSearch(search.trim()), 350) + return () => clearTimeout(handle) + }, [search]) + + // Reset to first page whenever a filter changes. + useEffect(() => { + setPage(1) + }, [debouncedSearch, type, status, method, reconciliation, from, to]) + + const queryString = useMemo(() => { + const params = new URLSearchParams() + params.set("page", String(page)) + params.set("pageSize", String(PAGE_SIZE)) + if (debouncedSearch) params.set("search", debouncedSearch) + if (type) params.set("type", type) + if (status) params.set("status", status) + if (method) params.set("method", method) + if (reconciliation) params.set("reconciliation", reconciliation) + if (from) params.set("from", from) + if (to) params.set("to", to) + return params.toString() + }, [page, debouncedSearch, type, status, method, reconciliation, from, to]) + + const fetchLedger = useCallback(async () => { + setLoading(true) + setError(null) + try { + const response = await fetch(`/api/transactions/ledger?${queryString}`) + if (!response.ok) { + throw new Error(`Request failed with status ${response.status}`) + } + const payload = (await response.json()) as LedgerResponse + setData(payload) + } catch (fetchError) { + console.error("LEDGER_FETCH_ERROR", fetchError) + setError("We couldn't load the transaction ledger. Please try again.") + } finally { + setLoading(false) + } + }, [queryString]) + + useEffect(() => { + void fetchLedger() + }, [fetchLedger]) + + const exportHref = useMemo(() => { + const params = new URLSearchParams() + if (debouncedSearch) params.set("search", debouncedSearch) + if (type) params.set("type", type) + if (status) params.set("status", status) + if (method) params.set("method", method) + if (reconciliation) params.set("reconciliation", reconciliation) + if (from) params.set("from", from) + if (to) params.set("to", to) + const query = params.toString() + return `/api/transactions/ledger/export${query ? `?${query}` : ""}` + }, [debouncedSearch, type, status, method, reconciliation, from, to]) + + const hasActiveFilters = Boolean( + debouncedSearch || type || status || method || reconciliation || from || to, + ) + + const clearFilters = () => { + setSearch("") + setType("") + setStatus("") + setMethod("") + setReconciliation("") + setFrom("") + setTo("") + } + + const summary = data?.summary + const pagination = data?.pagination + const entries = data?.transactions ?? [] + + const summaryCards = [ + { + label: "Total Transactions", + value: summary ? summary.totalCount.toLocaleString() : "—", + sub: summary ? `${formatNaira(summary.totalAmount)} total volume` : "", + }, + { + label: "Completed", + value: summary ? formatNaira(summary.completedAmount) : "—", + sub: summary ? `${summary.completedCount.toLocaleString()} transactions` : "", + }, + { + label: "Pending", + value: summary ? formatNaira(summary.pendingAmount) : "—", + sub: summary ? `${summary.pendingCount.toLocaleString()} transactions` : "", + }, + { + label: "Failed", + value: summary ? formatNaira(summary.failedAmount) : "—", + sub: summary ? `${summary.failedCount.toLocaleString()} transactions` : "", + }, + ] + + return ( +
+
+
+

+ {title ?? "Transaction Ledger"} +

+

+ {description ?? + "Every balance movement with funding, repayments, payouts, and reconciliation status."} +

+
+
+ + +
+
+ + {/* Balance summary cards */} +
+ {summaryCards.map((card) => ( + + + {card.label} + + + {loading && !data ? ( + + ) : ( + <> +

{card.value}

+ {card.sub ?

{card.sub}

: null} + + )} +
+
+ ))} +
+ + {isAdmin && summary && summary.duplicateCount > 0 ? ( +
+ + + {summary.duplicateCount} duplicated provider reference + {summary.duplicateCount === 1 ? "" : "s"} detected. Filter by{" "} + Duplicate to review potential double-postings. + +
+ ) : null} + + {/* Filters */} + + + + + Filters + + + +
+ + setSearch(event.target.value)} + placeholder="Search by description, reference, or related ID" + className="pl-9" + /> +
+
+ + + + +
+ setFrom(event.target.value)} + /> + setTo(event.target.value)} + /> +
+
+ {hasActiveFilters ? ( +
+ +
+ ) : null} +
+
+ + {/* Ledger table */} + + + {error ? ( +
+ +

{error}

+ +
+ ) : loading && !data ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ) : entries.length === 0 ? ( +
+

No transactions found

+

+ {hasActiveFilters + ? "Try adjusting or clearing your filters." + : "Ledger entries will appear here as wallet activity happens."} +

+
+ ) : ( +
+ + + + Date + {isAdmin ? User : null} + Type + Amount + Status + Reconciliation + Provider + Reference + + + + {entries.map((entry) => ( + setSelected(entry)} + > + + {formatDate(entry.timestamp)} + + {isAdmin ? ( + +
+ {entry.userName || "Unknown"} +
+
+ {entry.userEmail || entry.userType} +
+
+ ) : null} + +
+ {entry.direction === "credit" ? ( + + ) : ( + + )} + {formatType(entry.type)} +
+
+ + {entry.direction === "credit" ? "+" : "-"} + {formatNaira(entry.amount)} + + + {entry.status} + + + + {entry.reconciliation} + + + {formatMethod(entry.method)} + + {entry.reference || "—"} + +
+ ))} +
+
+
+ )} +
+
+ + {/* Pagination */} + {pagination && pagination.total > 0 ? ( +
+

+ Showing {(pagination.page - 1) * pagination.pageSize + 1}– + {Math.min(pagination.page * pagination.pageSize, pagination.total)} of{" "} + {pagination.total.toLocaleString()} +

+
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+
+ ) : null} + + {/* Detail drawer */} + (!open ? setSelected(null) : undefined)}> + + {selected ? ( + <> + + {formatType(selected.type)} + Transaction details and reconciliation status. + +
+
+

Amount

+

+ {selected.direction === "credit" ? "+" : "-"} + {formatNaira(selected.amount)} +

+
+ {selected.status} + + {selected.reconciliation} + + + {selected.direction} + +
+
+ + + + + + {selected.relatedId ? : null} + {selected.amountOriginal != null ? ( + + ) : null} + {selected.exchangeRate != null ? ( + + ) : null} + {isAdmin ? ( + + ) : null} + +
+ + ) : null} +
+
+
+ ) +} + +function DetailRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+ {label} + {value} +
+ ) +} diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index 4ef0c4c..cf7a833 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -83,7 +83,10 @@ const SIDEBAR_SECTIONS: Record = { id: "investor-finances", label: "Finances", defaultExpanded: true, - items: [{ label: "My Wallet", href: "/dashboard/investor/wallet", icon: Wallet }], + items: [ + { label: "My Wallet", href: "/dashboard/investor/wallet", icon: Wallet }, + { label: "Transaction Ledger", href: "/dashboard/investor/ledger", icon: Receipt }, + ], }, { id: "investor-account", @@ -110,6 +113,7 @@ const SIDEBAR_SECTIONS: Record = { { label: "My Vehicle / Contract", href: "/dashboard/driver/contract", icon: Calendar }, { label: "Make Payment", href: "/dashboard/driver/repayment", icon: Wallet }, { label: "Payment History", href: "/dashboard/driver/payments", icon: Receipt }, + { label: "Transaction Ledger", href: "/dashboard/driver/ledger", icon: Receipt }, ], }, { @@ -146,6 +150,7 @@ const SIDEBAR_SECTIONS: Record = { defaultExpanded: true, items: [ { label: "Reports", href: "/dashboard/admin/reports", icon: FileText }, + { label: "Transaction Ledger", href: "/dashboard/admin/ledger", icon: Receipt }, { label: "Issues", href: "/dashboard/admin/issues", icon: ShieldAlert }, { label: "Governance", href: "/dashboard/admin/governance", icon: Vote }, ], diff --git a/lib/ledger/ledger.ts b/lib/ledger/ledger.ts new file mode 100644 index 0000000..a110188 --- /dev/null +++ b/lib/ledger/ledger.ts @@ -0,0 +1,202 @@ +/** + * Shared helpers for the wallet transaction ledger dashboard. + * + * These utilities are used by both the ledger list API and the CSV export API + * so that filtering, ledger direction, and reconciliation classification stay + * consistent between the table view and exported files. + */ + +export const LEDGER_TRANSACTION_TYPES = [ + "investment", + "loan_disbursement", + "repayment", + "deposit", + "withdrawal", + "return", + "pool_investment", + "wallet_funding", + "wallet_debit", + "down_payment", +] as const + +export type LedgerTransactionType = (typeof LEDGER_TRANSACTION_TYPES)[number] + +export const LEDGER_STATUSES = ["Pending", "Completed", "Failed"] as const +export type LedgerStatus = (typeof LEDGER_STATUSES)[number] + +export const LEDGER_METHODS = [ + "wallet", + "internal_wallet", + "gateway", + "paystack", + "privy", + "system", +] as const +export type LedgerMethod = (typeof LEDGER_METHODS)[number] + +export const LEDGER_USER_TYPES = ["driver", "investor", "admin"] as const + +/** + * Reconciliation state is derived from a transaction rather than stored, so the + * dashboard can surface failed, pending, duplicated, and unreconciled records + * without a schema migration. + */ +export type ReconciliationStatus = "reconciled" | "pending" | "failed" | "duplicate" + +/** Transaction types that increase the user's wallet balance. */ +const CREDIT_TYPES = new Set([ + "deposit", + "wallet_funding", + "return", + "loan_disbursement", +]) + +export type LedgerDirection = "credit" | "debit" + +export function getLedgerDirection(type: string): LedgerDirection { + return CREDIT_TYPES.has(type as LedgerTransactionType) ? "credit" : "debit" +} + +export function getReconciliationStatus( + status: string, + reference: string | null | undefined, + duplicateReferences: Set, +): ReconciliationStatus { + if (status === "Failed") return "failed" + if (status === "Pending") return "pending" + if (reference && duplicateReferences.has(reference)) return "duplicate" + return "reconciled" +} + +export interface LedgerQueryParams { + page: number + pageSize: number + search: string + type: string + status: string + method: string + reconciliation: string + from: string + to: string + userType: string + userId: string +} + +export interface LedgerActor { + id: string + role: "admin" | "driver" | "investor" +} + +/** + * Builds a MongoDB filter for the ledger. Non-admins are always scoped to their + * own records; admins may optionally scope by userId/userType. + */ +export function buildLedgerFilter(params: LedgerQueryParams, actor: LedgerActor): Record { + const filter: Record = {} + + if (actor.role === "admin") { + if (params.userId) filter.userId = params.userId + if (LEDGER_USER_TYPES.includes(params.userType as (typeof LEDGER_USER_TYPES)[number])) { + filter.userType = params.userType + } + } else { + filter.userId = actor.id + } + + if (LEDGER_TRANSACTION_TYPES.includes(params.type as LedgerTransactionType)) { + filter.type = params.type + } + + if (LEDGER_STATUSES.includes(params.status as LedgerStatus)) { + filter.status = params.status + } + + if (LEDGER_METHODS.includes(params.method as LedgerMethod)) { + filter.method = params.method + } + + const timestamp: Record = {} + if (params.from) { + const fromDate = new Date(params.from) + if (!Number.isNaN(fromDate.getTime())) timestamp.$gte = fromDate + } + if (params.to) { + const toDate = new Date(params.to) + if (!Number.isNaN(toDate.getTime())) { + toDate.setHours(23, 59, 59, 999) + timestamp.$lte = toDate + } + } + if (Object.keys(timestamp).length > 0) { + filter.timestamp = timestamp + } + + if (params.search) { + const escaped = params.search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + const regex = new RegExp(escaped, "i") + filter.$or = [{ description: regex }, { gatewayReference: regex }, { relatedId: regex }] + } + + return filter +} + +export interface NormalizedLedgerEntry { + id: string + userId: string + userType: string + userName: string | null + userEmail: string | null + type: string + direction: LedgerDirection + amount: number + amountOriginal: number | null + currency: string + originalCurrency: string | null + exchangeRate: number | null + method: string | null + reference: string | null + description: string + status: string + reconciliation: ReconciliationStatus + relatedId: string | null + metadata: Record | null + timestamp: string +} + +export function normalizeLedgerEntry( + tx: Record, + duplicateReferences: Set, +): NormalizedLedgerEntry { + const reference = tx.gatewayReference ?? null + const status = tx.status ?? "Completed" + + // userId may be a raw ObjectId or a populated user document. + const userRef = tx.userId + const isPopulatedUser = userRef && typeof userRef === "object" && "_id" in userRef + const userId = isPopulatedUser ? userRef._id.toString() : userRef ? userRef.toString() : "" + const userName = isPopulatedUser ? userRef.fullName ?? userRef.name ?? null : null + const userEmail = isPopulatedUser ? userRef.email ?? null : null + + return { + id: tx._id.toString(), + userId, + userType: tx.userType ?? "", + userName, + userEmail, + type: tx.type, + direction: getLedgerDirection(tx.type), + amount: Number(tx.amount ?? 0), + amountOriginal: tx.amountOriginal != null ? Number(tx.amountOriginal) : null, + currency: tx.currency ?? "NGN", + originalCurrency: tx.originalCurrency ?? null, + exchangeRate: tx.exchangeRate != null ? Number(tx.exchangeRate) : null, + method: tx.method ?? null, + reference, + description: tx.description ?? "", + status, + reconciliation: getReconciliationStatus(status, reference, duplicateReferences), + relatedId: tx.relatedId ?? null, + metadata: (tx.metadata as Record | undefined) ?? null, + timestamp: tx.timestamp ? new Date(tx.timestamp).toISOString() : new Date().toISOString(), + } +}