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 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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 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
|
## [1.0.1] - 2026-07-23
|
||||||
|
|
||||||
|
|||||||
@@ -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` | 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/: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 |
|
||||||
|
|||||||
@@ -260,6 +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 | `/: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 |
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ const getUserPayments = async (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (req.query.method) {
|
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') {
|
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
|
// @desc Get payment by ID
|
||||||
// @route GET /api/payments/:id
|
// @route GET /api/payments/:id
|
||||||
// @access Private
|
// @access Private
|
||||||
@@ -1224,6 +1251,7 @@ module.exports = {
|
|||||||
createPayment,
|
createPayment,
|
||||||
getPayments,
|
getPayments,
|
||||||
getUserPayments,
|
getUserPayments,
|
||||||
|
getUserPaymentMethods,
|
||||||
getPaymentById,
|
getPaymentById,
|
||||||
getPaymentsByRegistration,
|
getPaymentsByRegistration,
|
||||||
getPaymentsByEvent,
|
getPaymentsByEvent,
|
||||||
|
|||||||
@@ -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' }},
|
{ 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)',
|
||||||
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 }}]},
|
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',
|
{ 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 }}]},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const {
|
|||||||
createPayment,
|
createPayment,
|
||||||
getPayments,
|
getPayments,
|
||||||
getUserPayments,
|
getUserPayments,
|
||||||
|
getUserPaymentMethods,
|
||||||
getPaymentById,
|
getPaymentById,
|
||||||
getPaymentsByRegistration,
|
getPaymentsByRegistration,
|
||||||
getPaymentsByEvent,
|
getPaymentsByEvent,
|
||||||
@@ -20,6 +21,7 @@ router.post('/', protect, supervisor, createPayment);
|
|||||||
router.post('/yoco-checkout', protect, createYocoCheckout);
|
router.post('/yoco-checkout', protect, createYocoCheckout);
|
||||||
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
router.post('/yoco-checkout/send', protect, supervisor, sendPaymentLink);
|
||||||
router.get('/mypayments', protect, getUserPayments);
|
router.get('/mypayments', protect, getUserPayments);
|
||||||
|
router.get('/mypayments/methods', protect, getUserPaymentMethods);
|
||||||
router.get('/:id', protect, getPaymentById);
|
router.get('/:id', protect, getPaymentById);
|
||||||
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
router.get('/registration/:registrationId', protect, getPaymentsByRegistration);
|
||||||
|
|
||||||
|
|||||||
@@ -57,9 +57,20 @@ export default function UserPaymentsPage() {
|
|||||||
// Filters
|
// Filters
|
||||||
const [startDate, setStartDate] = useState("");
|
const [startDate, setStartDate] = useState("");
|
||||||
const [endDate, setEndDate] = 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">("");
|
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 buildQuery = useCallback((p: number) => {
|
||||||
const qs = new URLSearchParams({ page: String(p), limit: "25" });
|
const qs = new URLSearchParams({ page: String(p), limit: "25" });
|
||||||
if (startDate) qs.set("startDate", startDate);
|
if (startDate) qs.set("startDate", startDate);
|
||||||
@@ -124,14 +135,11 @@ export default function UserPaymentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-gray-600 mb-1">Method</label>
|
<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 as any)}>
|
<select className="border rounded px-2 py-1.5 text-sm" value={method} onChange={e => setMethod(e.target.value)}>
|
||||||
<option value="">All methods</option>
|
<option value="">All methods</option>
|
||||||
<option value="cash">Cash</option>
|
{availableMethods.map(m => (
|
||||||
<option value="card">Card</option>
|
<option key={m} value={m}>{formatMethod(m)}</option>
|
||||||
<option value="eft">EFT</option>
|
))}
|
||||||
<option value="voucher">Voucher</option>
|
|
||||||
<option value="apple_pay">Apple Pay</option>
|
|
||||||
<option value="google_pay">Google Pay</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user