Normalize user-facing payment method to cash/card/eft/voucher

Per feedback, stop trying to surface every gateway-reported wallet
type (apple_pay, google_pay, yoco, ...) as its own filter/display
value on the user payment history page. Both /mypayments and
/mypayments/methods now fold anything outside the four manual-entry
methods into "card", both in the returned data and in the filter
query, so Apple Pay/Google Pay payments show up under Card.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-24 09:02:59 +02:00
co-authored by Claude Sonnet 5
parent 59194349d2
commit 7f074e43a6
6 changed files with 44 additions and 24 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Fixed ### 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". - 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 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. - User dashboard payment history: `GET /api/payments/mypayments` and its new `/methods` endpoint now normalize `method` to cash/card/eft/voucher — any other gateway-reported value (apple_pay, google_pay, yoco, etc.) is reported and filterable as "card" — instead of exposing raw, inconsistent gateway strings the filter dropdown didn't know about.
## [1.0.1] - 2026-07-23 ## [1.0.1] - 2026-07-23
+1 -1
View File
@@ -240,7 +240,7 @@ For unauthenticated (guest) registration on events with `requiresAuth: false`:
| POST | `/api/payments` | supervisor+ | Record manual payment (cash/EFT) | | 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 | | 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` | user+ | Own payments |
| GET | `/api/payments/mypayments/methods` | user+ | Distinct `method` values present in the user's own payments | | GET | `/api/payments/mypayments/methods` | user+ | Normalized method options (cash/card/eft/voucher) present in the user's own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID | | GET | `/api/payments/:id` | user+ | Payment by ID |
| GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration | | GET | `/api/payments/registration/:registrationId` | user+ | Payments for a registration |
| GET | `/api/payments` | supervisor+ | All payments | | GET | `/api/payments` | supervisor+ | All payments |
+1 -1
View File
@@ -260,7 +260,7 @@ Full interactive API docs available at `GET /docs` (requires admin JWT — pass
| POST | `/` | supervisor+ | Record manual payment | | POST | `/` | supervisor+ | Record manual payment |
| POST | `/yoco-checkout` | user+ | Initiate Yoco checkout | | POST | `/yoco-checkout` | user+ | Initiate Yoco checkout |
| GET | `/mypayments` | user+ | Own payments | | GET | `/mypayments` | user+ | Own payments |
| GET | `/mypayments/methods` | user+ | Distinct `method` values present in the user's own payments | | GET | `/mypayments/methods` | user+ | Normalized method options (cash/card/eft/voucher) present in the user's own payments |
| GET | `/:id` | user+ | Payment by ID | | GET | `/:id` | user+ | Payment by ID |
| GET | `/registration/:registrationId` | user+ | Payments for a registration | | GET | `/registration/:registrationId` | user+ | Payments for a registration |
| GET | `/` | supervisor+ | All payments | | GET | `/` | supervisor+ | All payments |
+34 -8
View File
@@ -349,6 +349,17 @@ const getPayments = async (req, res) => {
} }
}; };
// Payment.method is free-text — online checkouts get tagged with whatever wallet type the
// gateway reports (apple_pay, google_pay, yoco, ...), not just the manual-entry methods below.
// For the user-facing dashboard we don't want to expose every raw gateway string, so anything
// that isn't one of the methods staff can manually choose is just shown/filtered as "card".
const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
function normalizeUserMethod(method) {
const m = String(method || '').toLowerCase();
return USER_FACING_METHODS.includes(m) ? m : 'card';
}
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters) // @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
// @route GET /api/payments/mypayments // @route GET /api/payments/mypayments
// @access Private // @access Private
@@ -369,7 +380,17 @@ const getUserPayments = async (req, res) => {
}; };
if (req.query.method) { if (req.query.method) {
where.method = { equals: String(req.query.method), mode: 'insensitive' }; const requested = normalizeUserMethod(req.query.method);
if (requested === 'card') {
// "Card" also covers every gateway-reported method that isn't one of the other
// explicit buckets (apple_pay, google_pay, yoco, etc.) — match anything NOT in those.
where.NOT = {
OR: USER_FACING_METHODS.filter(m => m !== 'card')
.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else {
where.method = { equals: requested, mode: 'insensitive' };
}
} }
if (req.query.kind === 'refund') { if (req.query.kind === 'refund') {
@@ -399,15 +420,18 @@ const getUserPayments = async (req, res) => {
prisma.payment.count({ where }) prisma.payment.count({ where })
]); ]);
res.json({ data: payments, total, page, limit, pages: Math.ceil(total / limit) }); // Normalize the displayed method so the dashboard never shows a raw gateway string.
const normalizedPayments = payments.map(p => ({ ...p, method: normalizeUserMethod(p.method) }));
res.json({ data: normalizedPayments, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
} }
}; };
// @desc Get the distinct payment methods actually present in the user's own payments, // @desc Get the payment method options actually present in the user's own payments (normalized
// so the dashboard filter never has to guess at gateway-reported values // to the user-facing set — see normalizeUserMethod), so the dashboard filter only ever
// (e.g. apple_pay, google_pay) — it only ever offers what's really there. // offers methods that exist for this user.
// @route GET /api/payments/mypayments/methods // @route GET /api/payments/mypayments/methods
// @access Private // @access Private
const getUserPaymentMethods = async (req, res) => { const getUserPaymentMethods = async (req, res) => {
@@ -422,11 +446,13 @@ const getUserPaymentMethods = async (req, res) => {
] ]
}, },
distinct: ['method'], distinct: ['method'],
select: { method: true }, select: { method: true }
orderBy: { method: 'asc' }
}); });
res.json({ methods: rows.map(r => r.method).filter(Boolean) }); const present = new Set(rows.map(r => normalizeUserMethod(r.method)));
const methods = USER_FACING_METHODS.filter(m => present.has(m));
res.json({ methods });
} catch (error) { } catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
} }
+4 -4
View File
@@ -543,11 +543,11 @@ app.get('/docs', async (req, res) => {
responses:[ responses:[
{ status:201, desc:'Recorded', body:{ id:'pay-uuid-...', amount:450, method:'cash', status:'succeeded', createdAt:'2025-06-10T09:00:00.000Z' }}, { 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)', { method:'GET', path:'/api/payments/mypayments', auth:'user+', desc:'Get own payment history (paginated, excludes donations). Returned method is normalized to cash|card|eft|voucher — any gateway-reported wallet type (apple_pay, google_pay, yoco, ...) is reported as "card"',
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' }, 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 normalized method: 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 }}]}, 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)", { method:'GET', path:'/api/payments/mypayments/methods', auth:'user+', desc:"Normalized method options (subset of cash|card|eft|voucher) actually present in the user's own payments",
responses:[{ status:200, desc:'Success', body:{ methods:['card', 'cash', 'apple_pay'] }}]}, responses:[{ status:200, desc:'Success', body:{ methods:['card', 'cash'] }}]},
{ method:'GET', path:'/api/payments', auth:'supervisor+', desc:'List all payments', { 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' }, 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 }}]}, 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 }}]},
@@ -18,24 +18,18 @@ interface PaymentItem {
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`; const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
// Payment.method is free-text — online payments arrive tagged with whatever wallet type the // The API normalizes Payment.method to this set before it ever reaches the dashboard — any
// gateway reports (e.g. apple_pay, google_pay), not just the manual-entry methods below. // gateway-reported wallet type (apple_pay, google_pay, yoco, ...) is folded into "card".
const METHOD_LABELS: Record<string, string> = { const METHOD_LABELS: Record<string, string> = {
cash: "Cash", cash: "Cash",
card: "Card", card: "Card",
eft: "EFT", eft: "EFT",
voucher: "Voucher", voucher: "Voucher",
apple_pay: "Apple Pay",
google_pay: "Google Pay",
}; };
const formatMethod = (method: string | null | undefined) => { const formatMethod = (method: string | null | undefined) => {
if (!method) return "Payment"; if (!method) return "Payment";
if (METHOD_LABELS[method]) return METHOD_LABELS[method]; return METHOD_LABELS[method] || method;
return method
.replace(/[_-]+/g, " ")
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/\b\w/g, c => c.toUpperCase());
}; };
export default function UserPaymentsPage() { export default function UserPaymentsPage() {