Add Yoco processing fee tracking to payments, settings, and reports

Estimates and snapshots the Yoco card-processing fee (always church-borne)
on every card payment, lets the admin configure in-person/online fee % per
plan in Settings -> Payments, and surfaces the fee alongside gross revenue
across the Finance/Profit reports, Master Orders, Revenue Detailed, and the
full cashup reconciliation flow (close-out screen, Cashup report, and audit
trail) so payout figures match what actually lands in the bank.
This commit is contained in:
2026-07-28 11:23:46 +02:00
parent 1815f78c85
commit 1f88ce5e0e
15 changed files with 311 additions and 64 deletions
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Payment" ADD COLUMN "feeAmount" DOUBLE PRECISION,
ADD COLUMN "feeChannel" TEXT,
ADD COLUMN "feePayer" TEXT,
ADD COLUMN "feeRate" DOUBLE PRECISION;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Event" ADD COLUMN "feePayerOnline" TEXT;
@@ -0,0 +1,12 @@
/*
Warnings:
- You are about to drop the column `feePayerOnline` on the `Event` table. All the data in the column will be lost.
- You are about to drop the column `feePayer` on the `Payment` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Event" DROP COLUMN "feePayerOnline";
-- AlterTable
ALTER TABLE "Payment" DROP COLUMN "feePayer";
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventCashupLine" ADD COLUMN "feeAmount" DOUBLE PRECISION;
+4
View File
@@ -221,6 +221,9 @@ model Payment {
externalId String? @unique
status String?
originalPaymentId String?
feeAmount Float?
feeRate Float?
feeChannel String?
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@ -461,6 +464,7 @@ model EventCashupLine {
cashup EventCashup @relation(fields: [cashupId], references: [id], onDelete: Cascade)
method String
expectedAmount Float @default(0)
feeAmount Float?
actualAmount Float?
variance Float?
notes String?
+5 -2
View File
@@ -53,7 +53,9 @@ const closeEvent = async (req, res) => {
? lines
.filter(l => l && ALL_METHODS.includes(l.method))
.map(l => {
const expected = financials.expectedCashByMethod[l.method] || 0;
// expectedInBankByMethod nets out Yoco fees (only ever nonzero for 'card') so the
// variance reconciles against what should actually land in the bank, not the gross figure.
const expected = financials.expectedInBankByMethod[l.method] || 0;
const denominations = l.method === 'cash' && Array.isArray(l.denominations)
? l.denominations
.map(d => ({ value: parseFloat(d.value), count: parseInt(d.count, 10) || 0 }))
@@ -66,6 +68,7 @@ const closeEvent = async (req, res) => {
id: uuidv4(),
method: l.method,
expectedAmount: expected,
feeAmount: financials.feesByMethod[l.method] || 0,
actualAmount: actual,
variance: actual !== null ? actual - expected : null,
notes: l.notes || null,
@@ -77,7 +80,7 @@ const closeEvent = async (req, res) => {
const totalActualRevenue = isFullCashup
? cashupLines.reduce((sum, l) => sum + (l.actualAmount !== null ? l.actualAmount : 0), 0)
: null;
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedCashByMethod[m], 0);
const totalExpectedRevenue = ALL_METHODS.reduce((sum, m) => sum + financials.expectedInBankByMethod[m], 0);
const cashup = await prisma.eventCashup.create({
data: {
+18 -5
View File
@@ -5,7 +5,8 @@ const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('
const axios = require('axios');
const { emailTickets } = require('./ticketController');
const { safeErrorMessage } = require('../utils/errorUtils');
const { assertEventOpen } = require('../utils/cashupUtils');
const { assertEventOpen, bucketForMethod } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Create a new payment
// @route POST /api/payments
@@ -58,6 +59,14 @@ const createPayment = async (req, res) => {
throw new Error('Payment method is required');
}
// Snapshot the Yoco fee estimate for this payment's method/amount at creation time —
// never recomputed later, so historical reports stay stable if settings change (see
// priceSnapshot in utils/pricing.js for the same philosophy). In-person is always
// church-borne: this app doesn't control the amount charged on a physical card machine.
const feeConfig = await getYocoFeeConfig();
const feeBucket = bucketForMethod(method);
const feeFor = (amt) => computeFeeSnapshot({ grossAmount: amt, channel: 'in_person', bucket: feeBucket, config: feeConfig });
// Validate required parameters based on isDonation flag
if (isDonation && !eventId) {
res.status(400);
@@ -160,7 +169,8 @@ const createPayment = async (req, res) => {
registrationId: null,
eventId: registration.eventId,
isDonation: true,
createdAt: paidAtDate || undefined
createdAt: paidAtDate || undefined,
...feeFor(requestedAmount)
},
include: {
user: { select: { id: true, name: true, email: true } },
@@ -178,7 +188,8 @@ const createPayment = async (req, res) => {
registrationId,
eventId: registrationEventId || eventId || null,
isDonation: false,
createdAt: paidAtDate || undefined
createdAt: paidAtDate || undefined,
...feeFor(applyAmount)
},
include: {
user: { select: { id: true, name: true, email: true } },
@@ -199,7 +210,8 @@ const createPayment = async (req, res) => {
eventId: registration.eventId,
isDonation: true,
originalPaymentId: payment.id,
createdAt: paidAtDate || undefined
createdAt: paidAtDate || undefined,
...feeFor(excess)
}
});
}
@@ -215,7 +227,8 @@ const createPayment = async (req, res) => {
registrationId: registrationId || null,
eventId: registrationEventId || eventId || null,
isDonation: isDonation || false,
createdAt: paidAtDate || undefined
createdAt: paidAtDate || undefined,
...feeFor(parseFloat(amount))
},
include: {
user: { select: { id: true, name: true, email: true } },
+11 -2
View File
@@ -4,6 +4,8 @@ const { v4: uuidv4 } = require('uuid');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { emailTickets } = require('./ticketController');
const { computeRegistrationTotalDue, refreshPricingForRegistration } = require('../utils/pricing');
const { bucketForMethod } = require('../utils/cashupUtils');
const { getYocoFeeConfig, computeFeeSnapshot } = require('../utils/yocoFees');
// @desc Handle Yoco webhook events
// @route POST /api/webhooks/yoco
@@ -313,18 +315,25 @@ const handlePaymentSucceeded = async (webhookData) => {
} catch (e) { /* ignore — payment will fail below if still null */ }
}
// Snapshot the Yoco fee for this payment at creation time, using current settings — never
// recomputed later, so historical reports stay stable if settings change.
const grossAmount = amount / 100;
const feeConfig = await getYocoFeeConfig();
const feeSnapshot = computeFeeSnapshot({ grossAmount, channel: 'online', bucket: bucketForMethod(method.type), config: feeConfig });
// Create payment record
const payment = await prisma.payment.create({
data: {
id: uuidv4(), // Generate a UUID for the payment
amount: amount / 100, // Convert cents to your currency unit
amount: grossAmount, // Convert cents to your currency unit
method: method.type, // The type of payment from the webhook
status: status === 'succeeded' ? 'completed' : status,
externalId: yocoPaymentId,
registrationId: registration?.id || null,
userId: resolvedUserId,
eventId: registration?.eventId || metadata?.eventId || null,
isDonation: !registration?.id
isDonation: !registration?.id,
...feeSnapshot
},
include: {
user: {
+23 -1
View File
@@ -104,6 +104,15 @@ async function computeEventFinancials(eventId) {
}
const totalRevenue = payments.reduce((sum, p) => sum + p.amount, 0);
// Yoco processing fees, snapshotted per-payment at creation time (see utils/yocoFees.js).
// Always absorbed by the church — never passed on to the buyer.
const feesByMethod = emptyByMethod();
for (const p of nonRefundPayments) {
if (p.feeAmount == null) continue;
feesByMethod[bucketForMethod(p.method)] += p.feeAmount;
}
const totalFees = ALL_METHODS.reduce((s, m) => s + feesByMethod[m], 0);
// Costs, with computed totals and attribution to a payment method's float (if tagged)
const costBreakdown = costs.map(c => {
const total = c.costType === 'per_item'
@@ -126,6 +135,13 @@ async function computeEventFinancials(eventId) {
const expectedCashByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedCashByMethod[m] = paymentsByMethod[m] - costsByMethod[m];
// What should actually land in the bank per method — same as expectedCashByMethod, minus
// Yoco's cut (only ever nonzero for 'card'). This is a reconciliation-only figure: it must
// NOT feed effectiveGrossIncomeByMethod/netProfit below, since totalFees is already subtracted
// there once via netProfitAfterFees — folding it into expectedCashByMethod too would double-count.
const expectedInBankByMethod = emptyByMethod();
for (const m of ALL_METHODS) expectedInBankByMethod[m] = expectedCashByMethod[m] - feesByMethod[m];
// Most recent reconciliation on record (if any) — the source of "actual" truth
const latestReconciled = history.find(h => h.action === 'closed' || h.action === 'quick_closed') || null;
const reconciled = latestReconciled ? {
@@ -139,7 +155,8 @@ async function computeEventFinancials(eventId) {
for (const m of ALL_METHODS) {
const line = latestReconciled.lines.find(l => l.method === m);
out[m] = {
expected: line ? line.expectedAmount : expectedCashByMethod[m],
expected: line ? line.expectedAmount : expectedInBankByMethod[m],
fee: line && line.feeAmount != null ? line.feeAmount : feesByMethod[m],
actual: line && line.actualAmount != null ? line.actualAmount : null,
variance: line && line.variance != null ? line.variance : null,
notes: line ? line.notes : null,
@@ -161,6 +178,7 @@ async function computeEventFinancials(eventId) {
}
const effectiveTotalRevenue = ALL_METHODS.reduce((s, m) => s + effectiveGrossIncomeByMethod[m], 0);
const netProfit = effectiveTotalRevenue - totalCosts;
const netProfitAfterFees = netProfit - totalFees;
// What was actually sold, by ticket type — for the Finance report's income-stream breakdown
const salesByOptionMap = {};
@@ -183,10 +201,14 @@ async function computeEventFinancials(eventId) {
untaggedCostsTotal,
totalCosts,
expectedCashByMethod,
expectedInBankByMethod,
reconciled,
effectiveGrossIncomeByMethod,
effectiveTotalRevenue,
netProfit,
feesByMethod,
totalFees,
netProfitAfterFees,
unallocatedDonations,
unallocatedDonationsTotal,
totalDonations,
+35
View File
@@ -0,0 +1,35 @@
/**
* Yoco processing fee estimation.
*
* Yoco's real rate depends on plan, channel (in-person card machine vs. online checkout),
* monthly volume tier, and card type — none of which this app tracks. Instead the admin
* configures one flat, editable % per channel (Settings → Payments), pre-filled from a
* representative default when they pick a plan. The church always absorbs this fee — it's
* shown in reports so payout figures are accurate, never passed on to the buyer.
*/
const { getSetting } = require('./settingsCache');
async function getYocoFeeConfig() {
const [inPersonPct, onlinePct] = await Promise.all([
getSetting('yoco_fee_in_person_pct', '0'),
getSetting('yoco_fee_online_pct', '0'),
]);
return {
inPersonRate: parseFloat(inPersonPct) || 0, // percent, e.g. 2.30
onlineRate: parseFloat(onlinePct) || 0,
};
}
// Snapshot to store on a Payment row at creation time. Only 'card' payments are fee-eligible —
// cash/eft/voucher never incur a Yoco fee.
function computeFeeSnapshot({ grossAmount, channel, bucket, config }) {
if (bucket !== 'card') {
return { feeAmount: null, feeRate: null, feeChannel: null };
}
const rate = channel === 'online' ? config.onlineRate : config.inPersonRate;
const feeAmount = Math.round(grossAmount * (rate / 100) * 100) / 100;
return { feeAmount, feeRate: rate, feeChannel: channel };
}
module.exports = { getYocoFeeConfig, computeFeeSnapshot };