diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f41917..7bd3d05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- User dashboard: new "Payment history" page listing the user's own payments (donations excluded), with server-side pagination (25 per page), date range, method, and payment/refund filters. + ## [1.0.1] - 2026-07-23 ### Added diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 00cdc9e..6990d36 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -349,30 +349,57 @@ const getPayments = async (req, res) => { } }; -// @desc Get user payments +// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters) // @route GET /api/payments/mypayments // @access Private const getUserPayments = async (req, res) => { try { - // Users should see payments they made (donations) and payments tied to their registrations - const payments = await prisma.payment.findMany({ - where: { - OR: [ - { userId: req.user.id }, // payments made by the user (includes donations) - { registration: { userId: req.user.id } } // payments tied to the user's registrations - ] - }, - include: { - registration: { - include: { - event: true - } - }, - event: true - } - }); + const page = Math.max(1, parseInt(req.query.page) || 1); + const limit = Math.min(25, Math.max(1, parseInt(req.query.limit) || 25)); + const skip = (page - 1) * limit; - res.json(payments); + // Users should see payments they made and payments tied to their registrations, + // excluding donations. + const where = { + isDonation: false, + OR: [ + { userId: req.user.id }, + { registration: { userId: req.user.id } } + ] + }; + + if (req.query.method) { + where.method = { startsWith: String(req.query.method), mode: 'insensitive' }; + } + + if (req.query.kind === 'refund') { + where.amount = { lt: 0 }; + } else if (req.query.kind === 'payment') { + where.amount = { gte: 0 }; + } + + if (req.query.startDate || req.query.endDate) { + where.createdAt = { + ...(req.query.startDate && { gte: new Date(req.query.startDate) }), + ...(req.query.endDate && { lte: new Date(req.query.endDate) }) + }; + } + + const [payments, total] = await prisma.$transaction([ + prisma.payment.findMany({ + where, + include: { + registration: { include: { event: true } }, + event: true + }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit + }), + prisma.payment.count({ where }) + ]); + + res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); } diff --git a/backend/src/index.js b/backend/src/index.js index efe6ae1..ff44ba8 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -543,8 +543,9 @@ app.get('/docs', async (req, res) => { responses:[ { status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, ]}, - { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history', - responses:[{ status:200, desc:'Success', body:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }]}]}, + { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations)', + queryParams:{ page:'Page (default 1)', limit:'Per page (default 25, max 25)', startDate:'ISO date, filters createdAt >=', endDate:'ISO date, filters createdAt <=', method:'Filter by method prefix (cash|card|eft|voucher)', kind:'payment|refund — filters by amount sign' }, + responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', status:'succeeded', createdAt:'2025-06-01T11:00:00.000Z' }], total:1, page:1, limit:25, pages:1 }}]}, { method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', queryParams:{ page:'Page (default 1)', limit:'Per page (default 20)', eventId:'Filter by event', userId:'Filter by user', method:'Filter by method (cash|card|eft|donation)', startDate:'ISO date', endDate:'ISO date' }, responses:[{ status:200, desc:'Success', body:{ data:[{ id:'pay-uuid-...', amount:450, method:'card', user:{ name:'Jane Doe' }, registration:{ event:{ title:'Camp 2025' }}}], total:1 }}]}, diff --git a/frontend/src/app/dashboard/user/page.tsx b/frontend/src/app/dashboard/user/page.tsx index f243369..981b290 100644 --- a/frontend/src/app/dashboard/user/page.tsx +++ b/frontend/src/app/dashboard/user/page.tsx @@ -629,6 +629,10 @@ export default function UserDashboardPage() { className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" onClick={() => router.push("/dashboard/user/reset-password")} >Reset password + + + + {error &&

{error}

} + +
+
+
+ + setStartDate(e.target.value)} + /> +
+
+ + setEndDate(e.target.value)} + /> +
+
+ + +
+
+ + +
+ {(startDate || endDate || method || kind) && ( + + )} +
+ +
+ {total} payment{total !== 1 ? "s" : ""} total + {total > 0 && ` — page ${page} of ${totalPages}`} +
+ + + + {totalPages > 1 && ( +
+ Page {page} of {totalPages} +
+ + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter(p => p === 1 || p === totalPages || Math.abs(p - page) <= 1) + .reduce<(number | "…")[]>((acc, p, i, arr) => { + if (i > 0 && (p as number) - (arr[i - 1] as number) > 1) acc.push("…"); + acc.push(p); + return acc; + }, []) + .map((p, i) => + p === "…" ? ( + + ) : ( + + ) + )} + +
+
+ )} +
+ + ); +}