Switch payment method filter to a static list with an "other" bucket
Per feedback, drop the dynamic /mypayments/methods lookup (wasn't loading reliably) in favor of a static cash/card/eft/voucher/other dropdown. Server-side normalization now folds any gateway-reported method outside those four manual-entry values (apple_pay, google_pay, yoco, etc.) into "other" instead of "card", both in the returned data and in the filter query. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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: `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.
|
||||
- User dashboard payment history: `GET /api/payments/mypayments` now normalizes `method` to the fixed set cash/card/eft/voucher/other — any gateway-reported value outside that set (apple_pay, google_pay, yoco, etc.) is reported and filterable as "other" — instead of exposing raw, inconsistent gateway strings the filter dropdown didn't know about.
|
||||
|
||||
## [1.0.1] - 2026-07-23
|
||||
|
||||
|
||||
@@ -240,7 +240,6 @@ 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+ | 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/registration/:registrationId` | user+ | Payments for a registration |
|
||||
| GET | `/api/payments` | supervisor+ | All payments |
|
||||
|
||||
@@ -260,7 +260,6 @@ 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+ | Normalized method options (cash/card/eft/voucher) 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 |
|
||||
|
||||
@@ -352,12 +352,12 @@ 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".
|
||||
// that isn't one of the methods staff can manually choose is just shown/filtered as "other".
|
||||
const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
|
||||
|
||||
function normalizeUserMethod(method) {
|
||||
const m = String(method || '').toLowerCase();
|
||||
return USER_FACING_METHODS.includes(m) ? m : 'card';
|
||||
return USER_FACING_METHODS.includes(m) ? m : 'other';
|
||||
}
|
||||
|
||||
// @desc Get user payments (paginated, excludes donations, supports date range/method/kind filters)
|
||||
@@ -380,15 +380,14 @@ const getUserPayments = async (req, res) => {
|
||||
};
|
||||
|
||||
if (req.query.method) {
|
||||
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.
|
||||
const requested = String(req.query.method).toLowerCase();
|
||||
if (requested === 'other') {
|
||||
// "Other" covers every method that isn't one of the four explicit buckets —
|
||||
// e.g. gateway-reported wallet types like apple_pay, google_pay, yoco, etc.
|
||||
where.NOT = {
|
||||
OR: USER_FACING_METHODS.filter(m => m !== 'card')
|
||||
.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
OR: USER_FACING_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
|
||||
};
|
||||
} else {
|
||||
} else if (USER_FACING_METHODS.includes(requested)) {
|
||||
where.method = { equals: requested, mode: 'insensitive' };
|
||||
}
|
||||
}
|
||||
@@ -429,35 +428,6 @@ const getUserPayments = async (req, res) => {
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get the payment method options actually present in the user's own payments (normalized
|
||||
// to the user-facing set — see normalizeUserMethod), so the dashboard filter only ever
|
||||
// offers methods that exist for this user.
|
||||
// @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 }
|
||||
});
|
||||
|
||||
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) {
|
||||
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Get payment by ID
|
||||
// @route GET /api/payments/:id
|
||||
// @access Private
|
||||
@@ -1277,7 +1247,6 @@ module.exports = {
|
||||
createPayment,
|
||||
getPayments,
|
||||
getUserPayments,
|
||||
getUserPaymentMethods,
|
||||
getPaymentById,
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
|
||||
@@ -543,11 +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 (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 normalized method: cash|card|eft|voucher', kind:'payment|refund — filters by amount sign' },
|
||||
{ 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|other — any gateway-reported wallet type (apple_pay, google_pay, yoco, ...) not in that set is reported as "other"',
|
||||
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|other', 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:"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'] }}]},
|
||||
{ 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 }}]},
|
||||
|
||||
@@ -4,7 +4,6 @@ const {
|
||||
createPayment,
|
||||
getPayments,
|
||||
getUserPayments,
|
||||
getUserPaymentMethods,
|
||||
getPaymentById,
|
||||
getPaymentsByRegistration,
|
||||
getPaymentsByEvent,
|
||||
@@ -21,7 +20,6 @@ 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);
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@ interface PaymentItem {
|
||||
const formatRand = (n: number) => `R ${Math.abs(n).toFixed(2)}`;
|
||||
|
||||
// The API normalizes Payment.method to this set before it ever reaches the dashboard — any
|
||||
// gateway-reported wallet type (apple_pay, google_pay, yoco, ...) is folded into "card".
|
||||
// gateway-reported wallet type (apple_pay, google_pay, yoco, ...) is folded into "other".
|
||||
const METHOD_LABELS: Record<string, string> = {
|
||||
cash: "Cash",
|
||||
card: "Card",
|
||||
eft: "EFT",
|
||||
voucher: "Voucher",
|
||||
other: "Other",
|
||||
};
|
||||
|
||||
const formatMethod = (method: string | null | undefined) => {
|
||||
@@ -54,17 +55,6 @@ export default function UserPaymentsPage() {
|
||||
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<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!token) return;
|
||||
apiFetch<any>("/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);
|
||||
@@ -131,9 +121,11 @@ export default function UserPaymentsPage() {
|
||||
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
||||
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||
<option value="">All methods</option>
|
||||
{availableMethods.map(m => (
|
||||
<option key={m} value={m}>{formatMethod(m)}</option>
|
||||
))}
|
||||
<option value="cash">Cash</option>
|
||||
<option value="card">Card</option>
|
||||
<option value="eft">EFT</option>
|
||||
<option value="voucher">Voucher</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user