From 59194349d29fea5dd6b051584b201385361d69c8 Mon Sep 17 00:00:00 2001 From: joshua Date: Fri, 24 Jul 2026 08:55:58 +0200 Subject: [PATCH] Populate payment method filter from real data instead of a guessed list The previous fix hardcoded apple_pay/google_pay as extra filter options, but Payment.method is free-text set by whatever the gateway reports, so guessing at literal values was fragile and still didn't surface them for this user. Add GET /api/payments/mypayments/methods returning the distinct method values actually present in the user's payments, and have the dashboard filter build its options from that instead. Also switch the method filter from a startsWith match to an exact match, since the values now come straight from the same column being filtered. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- backend/API_DOCUMENTATION.md | 1 + backend/README.md | 1 + backend/src/controllers/paymentController.js | 30 ++++++++++++++++++- backend/src/index.js | 4 ++- backend/src/routes/paymentRoutes.js | 2 ++ .../src/app/dashboard/user/payments/page.tsx | 24 ++++++++++----- 7 files changed, 53 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a326bbb..0bb3716 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,7 @@ and this project follows [Semantic Versioning](https://semver.org/). ### Fixed - Cashup/reports: payments tagged with a digital wallet method (e.g. `apple_pay`, `google_pay` from online checkouts) are now bucketed as "card" for reconciliation instead of silently falling into "other". -- User dashboard payment history: the method filter and display now recognize Apple Pay / Google Pay instead of only cash/card/EFT/voucher, and unrecognized method values are shown with a readable label instead of a raw snake_case/camelCase string. +- User dashboard payment history: the method filter no longer offers a hardcoded, guessed set of values (which silently excluded real gateway-reported methods and matched them inconsistently). It now queries the distinct methods actually present in the user's payments (new `GET /api/payments/mypayments/methods` endpoint) and filters on an exact match, so every real method — including wallet types like Apple Pay/Google Pay — is filterable and displayed with a readable label instead of a raw string. ## [1.0.1] - 2026-07-23 diff --git a/backend/API_DOCUMENTATION.md b/backend/API_DOCUMENTATION.md index 89f23af..dffd171 100644 --- a/backend/API_DOCUMENTATION.md +++ b/backend/API_DOCUMENTATION.md @@ -240,6 +240,7 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`: | POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) | | POST | `/api/payments/yoco-checkout` | user+ | Initiate Yoco checkout; re-evaluates early-bird pricing first — returns `{ priceUpdated: true, newTotal }` if prices changed | | GET | `/api/payments/mypayments` | user+ | Own payments | +| GET | `/api/payments/mypayments/methods` | user+ | Distinct `method` values present in the user's own payments | | GET | `/api/payments/:id` | user+ | Payment by ID | | GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration | | GET | `/api/payments` | supervisor+ | All payments | diff --git a/backend/README.md b/backend/README.md index ab865aa..218aa75 100644 --- a/backend/README.md +++ b/backend/README.md @@ -260,6 +260,7 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass | POST | `/` | supervisor+ | Record manual payment | | POST | `/yoco-checkout` | user+ | Initiate Yoco checkout | | GET | `/mypayments` | user+ | Own payments | +| GET | `/mypayments/methods` | user+ | Distinct `method` values present in the user's own payments | | GET | `/:id` | user+ | Payment by ID | | GET | `/registration/:registrationId` | user+ | Payments for a registration | | GET | `/` | supervisor+ | All payments | diff --git a/backend/src/controllers/paymentController.js b/backend/src/controllers/paymentController.js index 6990d36..d46a886 100644 --- a/backend/src/controllers/paymentController.js +++ b/backend/src/controllers/paymentController.js @@ -369,7 +369,7 @@ const getUserPayments = async (req, res) => { }; if (req.query.method) { - where.method = { startsWith: String(req.query.method), mode: 'insensitive' }; + where.method = { equals: String(req.query.method), mode: 'insensitive' }; } if (req.query.kind === 'refund') { @@ -405,6 +405,33 @@ const getUserPayments = async (req, res) => { } }; +// @desc Get the distinct payment methods actually present in the user's own payments, +// so the dashboard filter never has to guess at gateway-reported values +// (e.g. apple_pay, google_pay) — it only ever offers what's really there. +// @route GET /api/payments/mypayments/methods +// @access Private +const getUserPaymentMethods = async (req, res) => { + try { + const rows = await prisma.payment.findMany({ + where: { + isDonation: false, + method: { not: null }, + OR: [ + { userId: req.user.id }, + { registration: { userId: req.user.id } } + ] + }, + distinct: ['method'], + select: { method: true }, + orderBy: { method: 'asc' } + }); + + res.json({ methods: rows.map(r => r.method).filter(Boolean) }); + } catch (error) { + res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); + } +}; + // @desc Get payment by ID // @route GET /api/payments/:id // @access Private @@ -1224,6 +1251,7 @@ module.exports = { createPayment, getPayments, getUserPayments, + getUserPaymentMethods, getPaymentById, getPaymentsByRegistration, getPaymentsByEvent, diff --git a/backend/src/index.js b/backend/src/index.js index ff44ba8..097ef02 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -544,8 +544,10 @@ app.get('/docs', async (req, res) => { { 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 (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' }, + 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 exact method value (see /mypayments/methods for the actual set)', 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/mypayments/methods', auth:'user+', desc:"Distinct Payment.method values present in the user's own payments (method is free-text — gateways can report values like apple_pay/google_pay beyond the manual-entry set)", + responses:[{ status:200, desc:'Success', body:{ methods:['card', 'cash', 'apple_pay'] }}]}, { 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/backend/src/routes/paymentRoutes.js b/backend/src/routes/paymentRoutes.js index 1774b47..1677f6e 100644 --- a/backend/src/routes/paymentRoutes.js +++ b/backend/src/routes/paymentRoutes.js @@ -4,6 +4,7 @@ const { createPayment, getPayments, getUserPayments, + getUserPaymentMethods, getPaymentById, getPaymentsByRegistration, getPaymentsByEvent, @@ -20,6 +21,7 @@ router.post('/', protect, supervisor, createPayment); router.post('/yoco-checkout', protect, createYocoCheckout); router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink); router.get('/mypayments', protect, getUserPayments); +router.get('/mypayments/methods', protect, getUserPaymentMethods); router.get('/:id', protect, getPaymentById); router.get('/registration/:registrationId', protect, getPaymentsByRegistration); diff --git a/frontend/src/app/dashboard/user/payments/page.tsx b/frontend/src/app/dashboard/user/payments/page.tsx index 83b3fbd..72502a2 100644 --- a/frontend/src/app/dashboard/user/payments/page.tsx +++ b/frontend/src/app/dashboard/user/payments/page.tsx @@ -57,9 +57,20 @@ export default function UserPaymentsPage() { // Filters const [startDate, setStartDate] = useState(""); const [endDate, setEndDate] = useState(""); - const [method, setMethod] = useState<"" | "cash" | "card" | "eft" | "voucher" | "apple_pay" | "google_pay">(""); + const [method, setMethod] = useState(""); const [kind, setKind] = useState<"" | "payment" | "refund">(""); + // Method is free-text (gateways can report values like apple_pay/google_pay beyond the + // manual-entry set), so the filter options come from what's actually in the user's payments + // rather than a hardcoded guess. + const [availableMethods, setAvailableMethods] = useState([]); + useEffect(() => { + if (!token) return; + apiFetch("/api/payments/mypayments/methods", { authToken: token }) + .then(res => setAvailableMethods(Array.isArray(res?.methods) ? res.methods : [])) + .catch(() => {}); + }, [token]); + const buildQuery = useCallback((p: number) => { const qs = new URLSearchParams({ page: String(p), limit: "25" }); if (startDate) qs.set("startDate", startDate); @@ -124,14 +135,11 @@ export default function UserPaymentsPage() {
- setMethod(e.target.value)}> - - - - - - + {availableMethods.map(m => ( + + ))}