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:
@@ -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 } });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user