Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions app/api/transactions/ledger/export/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>(duplicateAgg.map((entry: { _id: string }) => entry._id))

const queryFilter: Record<string, unknown> = { ...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<Record<string, any>>

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 })
}
}
134 changes: 134 additions & 0 deletions app/api/transactions/ledger/route.ts
Original file line number Diff line number Diff line change
@@ -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<string>(duplicateAgg.map((entry: { _id: string }) => entry._id))

// Translate the derived reconciliation filter into a concrete query clause.
const queryFilter: Record<string, unknown> = { ...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<string, { count: number; amount: number }>()
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<Record<string, any>>).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 })
}
}
10 changes: 10 additions & 0 deletions app/dashboard/admin/ledger/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading"

export default function Loading() {
return (
<DashboardRouteLoading
title="Loading transaction ledger"
description="Preparing ledger entries, balances, filters, and reconciliation status."
/>
)
}
16 changes: 16 additions & 0 deletions app/dashboard/admin/ledger/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<TransactionLedger
role="admin"
title="Transaction Ledger"
description="Global wallet ledger across investors, drivers, and admins with reconciliation views."
/>
)
}
10 changes: 10 additions & 0 deletions app/dashboard/driver/ledger/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading"

export default function Loading() {
return (
<DashboardRouteLoading
title="Loading transaction ledger"
description="Preparing your wallet ledger and transaction history."
/>
)
}
55 changes: 55 additions & 0 deletions app/dashboard/driver/ledger/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DashboardShell
role="driver"
sidebarWidth="compact"
header={
<DashboardHeader
title="Transaction Ledger"
welcomeName={resolveDisplayName({
fullName: user.fullName,
name: user.name,
email: user.email,
})}
/>
}
>
<main className="min-w-0 p-4 md:p-6">
<TransactionLedger
role="driver"
description="Your repayments, wallet activity, and reconciliation status."
/>
</main>
</DashboardShell>
)
}
10 changes: 10 additions & 0 deletions app/dashboard/investor/ledger/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { DashboardRouteLoading } from "@/components/dashboard/dashboard-route-loading"

export default function Loading() {
return (
<DashboardRouteLoading
title="Loading transaction ledger"
description="Preparing your wallet ledger and transaction history."
/>
)
}
Loading
Loading