diff --git a/backend/controllers/paymentRecordsController.js b/backend/controllers/paymentRecordsController.js new file mode 100644 index 0000000..a8b5d20 --- /dev/null +++ b/backend/controllers/paymentRecordsController.js @@ -0,0 +1,267 @@ +const mongoose = require("mongoose"); +const coursePaymentSchema = require("../schemas/coursePaymentModel"); + +const ALLOWED_STATUSES = new Set([ + "successful", + "pending", + "failed", +]); + +const ALLOWED_SORTS = new Set([ + "newest", + "oldest", + "amount-asc", + "amount-desc", +]); + +const escapeRegex = (value = "") => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + +const parsePositiveInteger = (value, fallback, maximum) => { + const parsed = Number.parseInt(value, 10); + + if (!Number.isFinite(parsed) || parsed < 1) { + return fallback; + } + + return Math.min(parsed, maximum); +}; + +const parseDateBoundary = (value, endOfDay = false) => { + if (!value) return null; + + const parsed = new Date(value); + + if (Number.isNaN(parsed.getTime())) { + return null; + } + + if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + parsed.setHours(23, 59, 59, 999); + } + + return parsed; +}; + +const parseAmount = (value) => { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + const parsed = Number.parseFloat( + String(value || "").replace(/[^0-9.-]/g, ""), + ); + + return Number.isFinite(parsed) ? parsed : 0; +}; + +const normalizeStatus = (status) => { + const normalized = String(status || "").trim().toLowerCase(); + + if ( + ["success", "successful", "completed", "paid", "enrolled"].includes( + normalized, + ) + ) { + return "successful"; + } + + if (["failed", "declined", "rejected", "cancelled"].includes(normalized)) { + return "failed"; + } + + return "pending"; +}; + +const maskCardNumber = (cardNumber) => { + const digits = String(cardNumber || "").replace(/\D/g, ""); + + if (digits.length < 4) { + return null; + } + + return `•••• •••• •••• ${digits.slice(-4)}`; +}; + +const buildSanitizedPayment = (payment) => { + const amount = parseAmount(payment.courseId?.C_price); + const safeStatus = normalizeStatus(payment.status); + + return { + id: String(payment._id), + student: { + id: payment.userId?._id ? String(payment.userId._id) : null, + name: payment.userId?.name || null, + email: payment.userId?.email || "Unknown", + }, + course: { + id: payment.courseId?._id ? String(payment.courseId._id) : null, + title: payment.courseId?.C_title || "Deleted or unavailable course", + }, + amount, + currency: "INR", + status: safeStatus, + createdAt: payment.createdAt || null, + updatedAt: payment.updatedAt || null, + maskedCard: maskCardNumber(payment.cardDetails?.cardnumber), + }; +}; + +const getAdminPaymentsController = async (req, res) => { + try { + const page = parsePositiveInteger(req.query.page, 1, 100000); + const limit = parsePositiveInteger(req.query.limit, 10, 50); + const search = String(req.query.search || "").trim().slice(0, 120); + const status = String(req.query.status || "").trim().toLowerCase(); + const sort = String(req.query.sort || "newest").trim().toLowerCase(); + + if (status && !ALLOWED_STATUSES.has(status)) { + return res.status(400).send({ + success: false, + message: "Invalid payment status filter.", + }); + } + + if (!ALLOWED_SORTS.has(sort)) { + return res.status(400).send({ + success: false, + message: "Invalid payment sort option.", + }); + } + + const startDate = parseDateBoundary(req.query.startDate); + const endDate = parseDateBoundary(req.query.endDate, true); + + if (req.query.startDate && !startDate) { + return res.status(400).send({ + success: false, + message: "Invalid start date.", + }); + } + + if (req.query.endDate && !endDate) { + return res.status(400).send({ + success: false, + message: "Invalid end date.", + }); + } + + if (startDate && endDate && startDate > endDate) { + return res.status(400).send({ + success: false, + message: "Start date cannot be after end date.", + }); + } + + const match = {}; + + if (startDate || endDate) { + match.createdAt = {}; + if (startDate) match.createdAt.$gte = startDate; + if (endDate) match.createdAt.$lte = endDate; + } + + const records = await coursePaymentSchema + .find(match) + .select( + "_id userId courseId status createdAt updatedAt +cardDetails.cardnumber", + ) + .populate("userId", "name email") + .populate("courseId", "C_title C_price") + .lean(); + + let sanitizedPayments = records.map(buildSanitizedPayment); + + if (search) { + const safeSearch = escapeRegex(search); + const searchPattern = new RegExp(safeSearch, "i"); + + sanitizedPayments = sanitizedPayments.filter((payment) => + [ + payment.id, + payment.student.email, + payment.student.name, + payment.course.title, + ].some((field) => searchPattern.test(String(field || ""))), + ); + } + + if (status) { + sanitizedPayments = sanitizedPayments.filter( + (payment) => payment.status === status, + ); + } + + const sorters = { + newest: (a, b) => + new Date(b.createdAt || 0) - new Date(a.createdAt || 0), + oldest: (a, b) => + new Date(a.createdAt || 0) - new Date(b.createdAt || 0), + "amount-asc": (a, b) => a.amount - b.amount, + "amount-desc": (a, b) => b.amount - a.amount, + }; + + sanitizedPayments.sort(sorters[sort]); + + const summary = sanitizedPayments.reduce( + (current, payment) => { + current.totalTransactions += 1; + current[payment.status] += 1; + + if (payment.status === "successful") { + current.totalRevenue += payment.amount; + } + + return current; + }, + { + totalTransactions: 0, + successful: 0, + pending: 0, + failed: 0, + totalRevenue: 0, + }, + ); + + const totalItems = sanitizedPayments.length; + const totalPages = Math.max(1, Math.ceil(totalItems / limit)); + const safePage = Math.min(page, totalPages); + const startIndex = (safePage - 1) * limit; + const paginatedPayments = sanitizedPayments.slice( + startIndex, + startIndex + limit, + ); + + return res.status(200).send({ + success: true, + data: paginatedPayments, + summary, + pagination: { + page: safePage, + limit, + totalItems, + totalPages, + hasPreviousPage: safePage > 1, + hasNextPage: safePage < totalPages, + }, + filters: { + search, + status, + startDate: startDate?.toISOString() || null, + endDate: endDate?.toISOString() || null, + sort, + }, + }); + } catch (error) { + console.error("Unable to retrieve payment records:", error); + + return res.status(500).send({ + success: false, + message: "Unable to retrieve payment records.", + }); + } +}; + +module.exports = { + getAdminPaymentsController, +}; diff --git a/backend/routers/adminRoutes.js b/backend/routers/adminRoutes.js index f001b12..2e918d5 100644 --- a/backend/routers/adminRoutes.js +++ b/backend/routers/adminRoutes.js @@ -15,6 +15,8 @@ const { } = require("../controllers/adminController"); const { + getAdminPaymentsController, +} = require("../controllers/paymentRecordsController"); getActivityLogsController, } = require("../controllers/activityLogController"); @@ -40,6 +42,25 @@ router.get( "/payments", authMiddleware, checkRole(["admin"]), + getAdminPaymentsController, +); + +router.get( + "/getallcourses", + authMiddleware, + checkRole(["admin"]), + getAllCoursesController, +); + +router.delete( + "/deletecourse/:courseid", + authMiddleware, + checkRole(["admin"]), + deleteCourseController, +); + +router.delete( + "/deleteuser/:userid", getAllPaymentsController, ); diff --git a/docs/admin-payment-records.md b/docs/admin-payment-records.md new file mode 100644 index 0000000..de43de3 --- /dev/null +++ b/docs/admin-payment-records.md @@ -0,0 +1,82 @@ +# Admin Payment Records + +Issue: `feat: add admin payment records dashboard with safe transaction details` + +## Security-first design + +The legacy `/api/admin/payments` controller returned complete Mongoose payment +documents. Those documents contain nested mock card details, including full card +numbers and CVV values. + +The replacement controller: + +- Returns a strict, manually constructed DTO. +- Never returns CVV, expiry, password, JWT, or raw card detail objects. +- Returns only a masked card identifier when a usable number exists. +- Escapes regex search input. +- Validates filters, dates, sorting, page, and page size. +- Uses the existing admin authentication and authorization middleware. + +## Endpoint + +```http +GET /api/admin/payments +Authorization: Bearer +``` + +Query parameters: + +| Parameter | Supported values | +|---|---| +| `page` | Positive integer | +| `limit` | 1–50 | +| `search` | Transaction ID, student name/email, or course title | +| `status` | `successful`, `pending`, `failed` | +| `startDate` | ISO date or `YYYY-MM-DD` | +| `endDate` | ISO date or `YYYY-MM-DD` | +| `sort` | `newest`, `oldest`, `amount-asc`, `amount-desc` | + +## Status compatibility + +Existing payment documents default to `enrolled`. The API normalizes legacy +values as follows: + +- `enrolled`, `paid`, `completed`, `success` → `successful` +- `declined`, `rejected`, `cancelled` → `failed` +- Unknown values → `pending` + +No data migration is required. + +## Amount + +The original payment model does not contain a separate amount field. The API +uses the populated course's `C_price` value and safely parses its numeric amount. + +## Frontend + +The Payments section provides: + +- Transaction, success, attention, and mock-revenue summaries +- Search +- Status and date filters +- Newest/oldest and amount sorting +- Pagination and page size +- Loading, empty, and retry states +- Responsive desktop and mobile layouts +- A safe transaction-details dialog + +## Testing security + +Open the browser Network tab and inspect `/api/admin/payments`. + +The response must not contain: + +```text +cvvcode +expmonthyear +password +token +cardDetails +``` + +A masked value such as `•••• •••• •••• 1234` may appear as `maskedCard`. diff --git a/frontend/src/components/admin/AdminHome.jsx b/frontend/src/components/admin/AdminHome.jsx index 1a38063..bba4b22 100644 --- a/frontend/src/components/admin/AdminHome.jsx +++ b/frontend/src/components/admin/AdminHome.jsx @@ -12,6 +12,7 @@ import { tableCellClasses, } from "@mui/material"; import axiosInstance from "../common/AxiosInstance"; +import PaymentRecords from "./PaymentRecords"; import ActivityLogs from "./ActivityLogs"; const StyledTableCell = styled(TableCell)(({ theme }) => ({ @@ -77,6 +78,7 @@ const AdminHome = () => { try { const response = await axiosInstance.delete( + `api/admin/deleteuser/${userId}`, `api/user/deleteuser/${userId}`, { headers: { @@ -118,6 +120,16 @@ const AdminHome = () => { Users + + + {activeSection === "payments" ? ( + variant={ activeSection === "activity-logs" ? "contained" : "outlined" } diff --git a/frontend/src/components/admin/PaymentRecords.css b/frontend/src/components/admin/PaymentRecords.css new file mode 100644 index 0000000..ac0896f --- /dev/null +++ b/frontend/src/components/admin/PaymentRecords.css @@ -0,0 +1,546 @@ +.payments-page { + --pay-ink: #17201d; + --pay-muted: #68716d; + --pay-paper: #f5f1e8; + --pay-surface: #fffdf8; + --pay-line: rgba(23, 32, 29, 0.15); + --pay-blue: #315bd6; + --pay-green: #19865f; + --pay-orange: #d96d32; + min-height: 100%; + padding: clamp(20px, 4vw, 48px); + color: var(--pay-ink); + background: + radial-gradient(circle at 96% 0%, rgba(49, 91, 214, 0.12), transparent 28rem), + var(--pay-paper); +} + +.payments-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 24px; +} + +.payments-eyebrow { + margin: 0 0 8px; + color: var(--pay-blue); + font-size: .72rem; + font-weight: 800; + letter-spacing: .16em; +} + +.payments-header h1 { + margin: 0; + font-size: clamp(2.5rem, 6vw, 4.8rem); + font-weight: 800; + letter-spacing: -.065em; + line-height: .95; +} + +.payments-header p:last-child { + max-width: 680px; + margin: 18px 0 0; + color: var(--pay-muted); + line-height: 1.65; +} + +.payments-refresh, +.payments-primary, +.payments-secondary, +.payments-error button, +.payments-pagination button { + min-height: 42px; + padding: 0 16px; + border: 1px solid var(--pay-line); + border-radius: 999px; + font-weight: 700; + cursor: pointer; +} + +.payments-refresh, +.payments-primary { + color: white; + border-color: var(--pay-ink); + background: var(--pay-ink); +} + +.payments-secondary, +.payments-pagination button { + color: var(--pay-ink); + background: var(--pay-surface); +} + +.payments-summary-grid { + margin: 34px 0 18px; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 12px; +} + +.payments-summary-card { + min-height: 150px; + padding: 20px; + display: flex; + flex-direction: column; + border: 1px solid var(--pay-line); + border-radius: 20px; + background: var(--pay-surface); +} + +.payments-summary-card > span, +.payments-summary-card small { + color: var(--pay-muted); + font-size: .68rem; + font-weight: 800; + letter-spacing: .1em; + text-transform: uppercase; +} + +.payments-summary-card strong { + margin: auto 0 8px; + font-size: clamp(1.8rem, 4vw, 2.7rem); + letter-spacing: -.05em; +} + +.payments-summary-ink { + color: white; + background: var(--pay-ink); +} + +.payments-summary-ink > span, +.payments-summary-ink small { + color: rgba(255,255,255,.62); +} + +.payments-summary-green { + box-shadow: inset 0 4px 0 var(--pay-green); +} + +.payments-summary-orange { + box-shadow: inset 0 4px 0 var(--pay-orange); +} + +.payments-summary-blue { + box-shadow: inset 0 4px 0 var(--pay-blue); +} + +.payments-filters { + display: grid; + grid-template-columns: minmax(230px, 1.5fr) repeat(5, minmax(125px, .75fr)); + gap: 10px; + padding: 12px; + border: 1px solid var(--pay-line); + border-radius: 20px; + background: var(--pay-surface); +} + +.payments-filters label { + min-width: 0; + padding: 9px 12px; + display: flex; + flex-direction: column; + justify-content: center; + border-radius: 12px; + background: var(--pay-paper); +} + +.payments-filters label span { + margin-bottom: 4px; + color: var(--pay-muted); + font-size: .63rem; + font-weight: 800; + letter-spacing: .1em; + text-transform: uppercase; +} + +.payments-filters input, +.payments-filters select { + min-height: 28px; + width: 100%; + border: 0; + outline: 0; + color: var(--pay-ink); + background: transparent; +} + +.payments-filter-actions { + grid-column: 1 / -1; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.payments-error, +.payments-empty { + margin-top: 24px; + padding: 45px 22px; + text-align: center; + border: 1px dashed var(--pay-line); + border-radius: 20px; + background: rgba(255,253,248,.74); +} + +.payments-error { + color: #8e2727; + border-color: rgba(190,48,48,.35); + background: #fff1ef; +} + +.payments-error p, +.payments-empty p { + margin: 8px 0 18px; +} + +.payments-empty > span { + width: 54px; + height: 54px; + margin: 0 auto; + display: grid; + place-items: center; + border-radius: 50%; + color: white; + background: var(--pay-blue); + font-size: 1.5rem; + font-weight: 800; +} + +.payments-empty h2 { + margin: 14px 0 0; +} + +.payments-skeleton { + margin-top: 24px; + overflow: hidden; + border: 1px solid var(--pay-line); + border-radius: 20px; + background: var(--pay-surface); +} + +.payments-skeleton div { + height: 70px; + border-bottom: 1px solid var(--pay-line); + background: linear-gradient(90deg, transparent, rgba(49,91,214,.07), transparent); + background-size: 240% 100%; + animation: paymentShimmer 1.5s linear infinite; +} + +.payments-table-summary { + margin: 26px 2px 10px; + display: flex; + justify-content: space-between; + gap: 16px; + color: var(--pay-muted); + font-size: .82rem; +} + +.payments-table-wrapper { + overflow-x: auto; + border: 1px solid var(--pay-line); + border-radius: 20px; + background: var(--pay-surface); +} + +.payments-table { + width: 100%; + min-width: 980px; + border-collapse: collapse; +} + +.payments-table th, +.payments-table td { + padding: 16px 17px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--pay-line); +} + +.payments-table th { + color: var(--pay-muted); + background: #eee8da; + font-size: .66rem; + letter-spacing: .1em; + text-transform: uppercase; +} + +.payments-table td { + font-size: .85rem; +} + +.payments-table td strong, +.payments-table td small { + display: block; +} + +.payments-table td small { + margin-top: 3px; + color: var(--pay-muted); +} + +.payment-status { + display: inline-flex; + padding: 6px 9px; + border-radius: 999px; + font-size: .7rem; + font-weight: 800; +} + +.payment-status-successful { + color: var(--pay-green); + background: rgba(25,134,95,.1); +} + +.payment-status-pending { + color: #9a651b; + background: rgba(218,161,54,.14); +} + +.payment-status-failed { + color: #a12d2d; + background: rgba(190,48,48,.11); +} + +.payment-details-button { + min-height: 34px; + padding: 0 12px; + border: 1px solid var(--pay-line); + border-radius: 999px; + color: var(--pay-blue); + background: white; + font-weight: 700; + cursor: pointer; +} + +.payments-pagination { + margin-top: 18px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 14px; +} + +.payments-pagination button:disabled, +.payments-refresh:disabled { + cursor: not-allowed; + opacity: .45; +} + +.payment-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1000; + padding: 20px; + display: grid; + place-items: center; + background: rgba(10,16,14,.66); + backdrop-filter: blur(5px); +} + +.payment-dialog { + width: min(590px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + padding: 26px; + border-radius: 24px; + background: var(--pay-surface); + box-shadow: 0 35px 100px rgba(0,0,0,.28); +} + +.payment-dialog-header { + display: flex; + justify-content: space-between; + gap: 20px; +} + +.payment-dialog-header p { + margin: 0 0 6px; + color: var(--pay-blue); + font-size: .68rem; + font-weight: 800; + letter-spacing: .13em; +} + +.payment-dialog-header h2 { + margin: 0; + letter-spacing: -.04em; +} + +.payment-dialog-header button { + width: 38px; + height: 38px; + border: 1px solid var(--pay-line); + border-radius: 50%; + background: white; + font-size: 1.4rem; + cursor: pointer; +} + +.payment-dialog dl { + margin: 26px 0 0; +} + +.payment-dialog dl div { + padding: 13px 0; + display: grid; + grid-template-columns: 145px 1fr; + gap: 16px; + border-bottom: 1px solid var(--pay-line); +} + +.payment-dialog dt { + color: var(--pay-muted); + font-size: .72rem; + font-weight: 800; + text-transform: uppercase; +} + +.payment-dialog dd { + margin: 0; + overflow-wrap: anywhere; +} + +.payment-security-note { + margin: 22px 0 0; + padding: 14px; + border-radius: 12px; + color: #315347; + background: #e6f5ef; + font-size: .8rem; + line-height: 1.55; +} + +.sr-only { + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + position: absolute; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} + +@keyframes paymentShimmer { + to { background-position: -240% 0; } +} + +@media (max-width: 1100px) { + .payments-summary-grid { + grid-template-columns: repeat(2, 1fr); + } + + .payments-filters { + grid-template-columns: repeat(3, 1fr); + } + + .payments-search { + grid-column: span 2; + } +} + +@media (max-width: 700px) { + .payments-header { + align-items: flex-start; + flex-direction: column; + } + + .payments-filters { + grid-template-columns: 1fr 1fr; + } + + .payments-search { + grid-column: 1 / -1; + } + + .payments-table-summary { + align-items: flex-start; + flex-direction: column; + } + + .payments-table-wrapper { + overflow: visible; + border: 0; + background: transparent; + } + + .payments-table { + min-width: 0; + } + + .payments-table thead { + display: none; + } + + .payments-table, + .payments-table tbody, + .payments-table tr, + .payments-table td { + display: block; + width: 100%; + } + + .payments-table tr { + margin-bottom: 12px; + padding: 8px 14px; + border: 1px solid var(--pay-line); + border-radius: 16px; + background: var(--pay-surface); + } + + .payments-table td { + padding: 11px 0; + display: grid; + grid-template-columns: 105px 1fr; + gap: 12px; + border-bottom: 1px solid var(--pay-line); + } + + .payments-table td:last-child { + border-bottom: 0; + } + + .payments-table td::before { + content: attr(data-label); + color: var(--pay-muted); + font-size: .63rem; + font-weight: 800; + letter-spacing: .08em; + text-transform: uppercase; + } +} + +@media (max-width: 480px) { + .payments-page { + padding: 18px 14px 34px; + } + + .payments-summary-grid, + .payments-filters { + grid-template-columns: 1fr; + } + + .payments-search { + grid-column: auto; + } + + .payments-filter-actions { + align-items: stretch; + flex-direction: column; + } + + .payments-pagination { + justify-content: space-between; + } + + .payment-dialog dl div { + grid-template-columns: 1fr; + gap: 4px; + } +} + +@media (prefers-reduced-motion: reduce) { + .payments-skeleton div { + animation: none; + } +} diff --git a/frontend/src/components/admin/PaymentRecords.jsx b/frontend/src/components/admin/PaymentRecords.jsx new file mode 100644 index 0000000..b4de0be --- /dev/null +++ b/frontend/src/components/admin/PaymentRecords.jsx @@ -0,0 +1,515 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import axiosInstance from "../common/AxiosInstance"; +import "./PaymentRecords.css"; + +const initialFilters = { + search: "", + status: "", + startDate: "", + endDate: "", + sort: "newest", + limit: "10", +}; + +const formatCurrency = (amount, currency = "INR") => + new Intl.NumberFormat(undefined, { + style: "currency", + currency, + maximumFractionDigits: 2, + }).format(Number(amount || 0)); + +const formatDateTime = (value) => { + if (!value) return "Not recorded"; + + const date = new Date(value); + + if (Number.isNaN(date.getTime())) { + return "Not recorded"; + } + + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); +}; + +const formatStatus = (status) => { + if (!status) return "Unknown"; + return status.charAt(0).toUpperCase() + status.slice(1); +}; + +const PaymentRecords = () => { + const [draftFilters, setDraftFilters] = useState(initialFilters); + const [appliedFilters, setAppliedFilters] = useState(initialFilters); + const [payments, setPayments] = useState([]); + const [summary, setSummary] = useState({ + totalTransactions: 0, + successful: 0, + pending: 0, + failed: 0, + totalRevenue: 0, + }); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10, + totalItems: 0, + totalPages: 1, + hasPreviousPage: false, + hasNextPage: false, + }); + const [page, setPage] = useState(1); + const [selectedPayment, setSelectedPayment] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(""); + + const queryString = useMemo(() => { + const params = new URLSearchParams({ + page: String(page), + limit: appliedFilters.limit, + sort: appliedFilters.sort, + }); + + Object.entries(appliedFilters).forEach(([key, value]) => { + if (value && !["limit", "sort"].includes(key)) { + params.set(key, value); + } + }); + + return params.toString(); + }, [appliedFilters, page]); + + const loadPayments = useCallback( + async (isRefresh = false) => { + isRefresh ? setRefreshing(true) : setLoading(true); + setError(""); + + try { + const response = await axiosInstance.get( + `api/admin/payments?${queryString}`, + { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }, + ); + + if (!response.data.success) { + throw new Error(response.data.message); + } + + setPayments(response.data.data || []); + setSummary(response.data.summary); + setPagination(response.data.pagination); + } catch (requestError) { + setError( + requestError.response?.data?.message || + "Payment records could not be loaded. Please try again.", + ); + } finally { + setLoading(false); + setRefreshing(false); + } + }, + [queryString], + ); + + useEffect(() => { + loadPayments(); + }, [loadPayments]); + + useEffect(() => { + if (!selectedPayment) return undefined; + + const handleEscape = (event) => { + if (event.key === "Escape") { + setSelectedPayment(null); + } + }; + + document.addEventListener("keydown", handleEscape); + return () => document.removeEventListener("keydown", handleEscape); + }, [selectedPayment]); + + const handleFilterChange = (event) => { + const { name, value } = event.target; + + setDraftFilters((current) => ({ + ...current, + [name]: value, + })); + }; + + const applyFilters = (event) => { + event.preventDefault(); + + if ( + draftFilters.startDate && + draftFilters.endDate && + draftFilters.startDate > draftFilters.endDate + ) { + setError("Start date cannot be after end date."); + return; + } + + setError(""); + setPage(1); + setAppliedFilters(draftFilters); + }; + + const clearFilters = () => { + setDraftFilters(initialFilters); + setAppliedFilters(initialFilters); + setPage(1); + }; + + const startItem = + pagination.totalItems === 0 + ? 0 + : (pagination.page - 1) * pagination.limit + 1; + const endItem = Math.min( + pagination.page * pagination.limit, + pagination.totalItems, + ); + + const summaryCards = [ + { + label: "Transactions", + value: summary.totalTransactions, + hint: "Filtered records", + tone: "ink", + }, + { + label: "Successful", + value: summary.successful, + hint: "Completed mock payments", + tone: "green", + }, + { + label: "Pending / failed", + value: `${summary.pending} / ${summary.failed}`, + hint: "Needs attention", + tone: "orange", + }, + { + label: "Mock revenue", + value: formatCurrency(summary.totalRevenue), + hint: "Successful transactions only", + tone: "blue", + }, + ]; + + return ( +
+
+
+

ADMIN FINANCE

+

Payment records

+

+ Review mock course transactions through sanitized records. Full + card numbers and CVV values never appear in this interface. +

+
+ + +
+ +
+ {summaryCards.map((card) => ( +
+ {card.label} + {card.value} + {card.hint} +
+ ))} +
+ +
+ + + + + + + + + + + + +
+ + +
+
+ + {error ? ( +
+ Unable to show payment records +

{error}

+ +
+ ) : null} + + {loading ? ( +
+ Loading payment records + {Array.from({ length: 6 }).map((_, index) => ( +
+ ))} +
+ ) : payments.length === 0 && !error ? ( +
+ +

No payment records found

+

Try clearing the filters or enrolling in a paid demo course.

+
+ ) : !error ? ( + <> +
+ + Showing {startItem}{endItem}{" "} + of {pagination.totalItems} + + + Page {pagination.page} of {pagination.totalPages} + +
+ +
+ + + + + + + + + + + + + + + {payments.map((payment) => ( + + + + + + + + + + ))} + +
+ Sanitized mock payment transactions +
TransactionStudentCourseAmountStatusCreatedDetails
+ {payment.id.slice(-10)} + + + {payment.student.name || payment.student.email} + + {payment.student.name ? ( + {payment.student.email} + ) : null} + {payment.course.title} + {formatCurrency(payment.amount, payment.currency)} + + + {formatStatus(payment.status)} + + + {formatDateTime(payment.createdAt)} + + +
+
+ + + + ) : null} + + {selectedPayment ? ( +
{ + if (event.target === event.currentTarget) { + setSelectedPayment(null); + } + }} + > +
+
+
+

SAFE TRANSACTION DETAILS

+

+ {selectedPayment.course.title} +

+
+ +
+ +
+
+
Transaction ID
+
{selectedPayment.id}
+
+
+
Student
+
{selectedPayment.student.email}
+
+
+
Amount
+
+ {formatCurrency( + selectedPayment.amount, + selectedPayment.currency, + )} +
+
+
+
Status
+
{formatStatus(selectedPayment.status)}
+
+
+
Created
+
{formatDateTime(selectedPayment.createdAt)}
+
+
+
Card identifier
+
{selectedPayment.maskedCard || "Not available"}
+
+
+ +

+ Full card numbers, CVV values, expiry values, passwords, and + authentication tokens are intentionally excluded. +

+
+
+ ) : null} +
+ ); +}; + +export default PaymentRecords;