Fix supervisor payments mobile tabs, reconcile donor, report method buckets, and refund/donation accounting

- Supervisor Payments: mode tabs now wrap on mobile instead of overflowing off-screen.
- Reconciling a Yoco transaction as a donation now lets staff pick who it's from.
- Reports payment-method breakdown now buckets into Cash/Card/EFT/Other everywhere,
  folding Apple Pay, Google Pay, and Yoco-portal payments into Card.
- Refunds now net against their original method's bucket (Cashup/Finance/Profit reports,
  My Payments filtering/display) instead of vanishing or falling into "Other".
- Refund form's method dropdown mirrors the real payment methods and auto-fills from the
  payment being refunded, replacing an ambiguous generic "Refund" option.
- Fixed donation remaining/unallocated balance inflating instead of shrinking when a
  donation is refunded (assign-donation endpoint, cashup reports, donations reports,
  and the Assign Donation panel all summed refund legs with the wrong sign).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 09:08:53 +02:00
co-authored by Claude Sonnet 5
parent 70605923bf
commit 4bee1b24f7
7 changed files with 125 additions and 39 deletions
+23 -9
View File
@@ -365,8 +365,16 @@ const USER_FACING_METHODS = ['cash', 'card', 'eft', 'voucher'];
const CARD_ALIASES = ['apple_pay', 'google_pay'];
const KNOWN_METHODS = [...USER_FACING_METHODS, ...CARD_ALIASES];
function normalizeUserMethod(method) {
// Refund methods are recorded as "<method>-refund" (e.g. "card-refund") so a refund nets
// against the same bucket its original payment counted under. Strip that suffix before
// bucketing so a card refund still displays/filters as "card", not "other".
function stripRefundSuffix(method) {
const m = String(method || '').toLowerCase();
return m.endsWith('-refund') ? m.slice(0, -'-refund'.length) : m;
}
function normalizeUserMethod(method) {
const m = stripRefundSuffix(method);
if (USER_FACING_METHODS.includes(m)) return m;
if (CARD_ALIASES.includes(m)) return 'card';
return 'other';
@@ -394,18 +402,21 @@ const getUserPayments = async (req, res) => {
if (req.query.method) {
const requested = String(req.query.method).toLowerCase();
if (requested === 'card') {
// "Card" also covers card-network wallet types (apple_pay, google_pay) — same
// settlement as a card payment, no separate float to reconcile.
// "Card" also covers card-network wallet types (apple_pay, google_pay) and card
// refunds — same settlement as a card payment, no separate float to reconcile.
where.AND = [{
OR: ['card', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: ['card', 'card-refund', ...CARD_ALIASES].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
} else if (requested === 'other') {
// "Other" covers every method that isn't one of the recognized buckets above.
// "Other" covers every method (and its refund variant) that isn't one of the
// recognized buckets above.
where.NOT = {
OR: KNOWN_METHODS.map(m => ({ method: { equals: m, mode: 'insensitive' } }))
OR: KNOWN_METHODS.flatMap(m => [m, `${m}-refund`]).map(m => ({ method: { equals: m, mode: 'insensitive' } }))
};
} else if (USER_FACING_METHODS.includes(requested)) {
where.method = { equals: requested, mode: 'insensitive' };
where.AND = [{
OR: [requested, `${requested}-refund`].map(m => ({ method: { equals: m, mode: 'insensitive' } }))
}];
}
}
@@ -601,11 +612,14 @@ const assignDonationToRegistration = async (req, res) => {
// Donations are never mutated once created — their remaining balance is the original
// amount minus every leg (a Payment row with isDonation:false and originalPaymentId
// pointing back at this donation) already allocated from it.
// pointing back at this donation) already allocated from it. A refund of the donation
// itself also creates such a leg, with a negative amount — Math.abs() so a refund reduces
// the remaining balance (money that's left the building) instead of increasing it (which a
// raw signed sum would do, since subtracting a negative adds).
const existingLegs = await prisma.payment.findMany({
where: { originalPaymentId: payment.id, isDonation: false }
});
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + leg.amount, 0);
const alreadyUsed = existingLegs.reduce((sum, leg) => sum + Math.abs(leg.amount), 0);
const remainingDonation = payment.amount - alreadyUsed;
if (remainingDonation <= 0.000001) {
@@ -83,7 +83,7 @@ const reconcileYocoTransaction = async (req, res) => {
}
const { id } = req.params;
const { registrationId, eventId } = req.body || {};
const { registrationId, eventId, userId: bodyUserId } = req.body || {};
const ytx = await Yoco.findUnique({ where: { id } });
if (!ytx) return res.status(404).json({ success: false, message: 'YocoTransaction not found' });
@@ -118,7 +118,15 @@ const reconcileYocoTransaction = async (req, res) => {
let resolvedUserId = null;
if (registration?.userId) {
resolvedUserId = registration.userId;
} else if (ytx?.raw?.payload?.metadata?.userId) {
} else if (bodyUserId) {
// Staff explicitly picked the donor while reconciling (e.g. as a donation) — trust that
// over metadata guesswork, but still verify the user actually exists.
try {
const exists = await prisma.user.findUnique({ where: { id: String(bodyUserId) } });
if (exists) resolvedUserId = String(bodyUserId);
} catch {}
}
if (!resolvedUserId && ytx?.raw?.payload?.metadata?.userId) {
const metaUserId = String(ytx.raw.payload.metadata.userId);
try {
const exists = await prisma.user.findUnique({ where: { id: metaUserId } });
+12 -7
View File
@@ -103,18 +103,23 @@ async function computeEventFinancials(eventId) {
quantityByOption[optId] = (quantityByOption[optId] || 0) + t.quantity;
}
// Real inflows only — excludes donation-application legs, which would otherwise double-count
// money already counted once via the source donation (e.g. a R250 donation with R50 assigned
// to a registration must total R250 received, not R300).
const nonRefundPayments = payments.filter(p => p.amount > 0 && !isDonationLeg(p));
// Excludes donation-application legs, which would otherwise double-count money already
// counted once via the source donation (e.g. a R250 donation with R50 assigned to a
// registration must total R250 received, not R300). Refunds (negative amount) are kept in —
// a card refund must subtract from the 'card' bucket it was refunded against, not vanish from
// the per-method breakdown while still being netted out of totalRevenue below.
const realPayments = payments.filter(p => !isDonationLeg(p));
// Donations are never mutated once assigned — assignment creates a separate "leg" Payment
// row (isDonation:false, originalPaymentId -> the donation), so a donation's registrationId
// stays null forever. Its actual unallocated amount is its original amount minus every leg
// that already references it, not simply "every donation with no registrationId".
// that already references it, not simply "every donation with no registrationId". A refund
// of the donation itself also creates such a leg, with a negative amount — Math.abs() so a
// refund reduces the unallocated balance instead of inflating it (a raw signed sum would
// subtract a negative, adding the refund back on top).
const legsByDonationId = new Map();
for (const p of payments) {
if (p.originalPaymentId && !p.isDonation) {
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + p.amount);
legsByDonationId.set(p.originalPaymentId, (legsByDonationId.get(p.originalPaymentId) || 0) + Math.abs(p.amount));
}
}
const donationPayments = payments.filter(p => p.isDonation);
@@ -123,7 +128,7 @@ async function computeEventFinancials(eventId) {
const totalDonations = donationPayments.reduce((sum, p) => sum + p.amount, 0);
const paymentsByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
for (const p of realPayments) {
paymentsByMethod[bucketForMethod(p.method)] += p.amount;
}
const totalRevenue = payments.reduce((sum, p) => sum + (isDonationLeg(p) ? 0 : p.amount), 0);