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
@@ -7,7 +7,7 @@ import { apiFetch, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal";
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "payments";
const TABS: { id: TabId; label: string }[] = [
{ id: "organisation", label: "Organisation" },
@@ -15,8 +15,19 @@ const TABS: { id: TabId; label: string }[] = [
{ id: "notifications", label: "Notifications" },
{ id: "email", label: "Email" },
{ id: "legal", label: "Legal" },
{ id: "payments", label: "Payments" },
];
// Default fee % pre-fill per Yoco plan — the "Up to R50k / Debit" rate, since debit is the
// most common consumer card type in SA and small churches likely stay in the lowest volume
// tier. Purely a UI convenience for the plan <select>; the actual % fields stay freely
// editable afterward so the admin can match their real blended rate.
const YOCO_PLAN_DEFAULTS: Record<"core" | "plus" | "pro", { inPerson: string; online: string }> = {
core: { inPerson: "2.30", online: "2.95" },
plus: { inPerson: "2.10", online: "2.75" },
pro: { inPerson: "1.95", online: "2.55" },
};
const inputCls =
"w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400";
@@ -112,6 +123,11 @@ export default function SiteSettingsPage() {
const [legalWebsiteUrl, setLegalWebsiteUrl] = useState("");
const [legalEffectiveDate, setLegalEffectiveDate] = useState("");
// ── Payments (Yoco fees) ──
const [yocoPlan, setYocoPlan] = useState<"core" | "plus" | "pro">("core");
const [feeInPersonPct, setFeeInPersonPct] = useState("");
const [feeOnlinePct, setFeeOnlinePct] = useState("");
// ── Load all settings once ────────────────────────────────────────────────
useEffect(() => {
if (!token) return;
@@ -138,6 +154,9 @@ export default function SiteSettingsPage() {
setLegalIoEmail(s.legal_io_email || "");
setLegalWebsiteUrl(s.legal_website_url || "");
setLegalEffectiveDate(s.legal_effective_date || "");
setYocoPlan((s.yoco_plan as "core" | "plus" | "pro") || "core");
setFeeInPersonPct(s.yoco_fee_in_person_pct || "");
setFeeOnlinePct(s.yoco_fee_online_pct || "");
})
.catch((e: any) => setResult({ ok: false, message: e?.message || "Failed to load settings" }))
.finally(() => setLoadingInitial(false));
@@ -239,6 +258,19 @@ export default function SiteSettingsPage() {
legal_effective_date: legalEffectiveDate.trim(),
});
const handleYocoPlanChange = (plan: "core" | "plus" | "pro") => {
setYocoPlan(plan);
const defaults = YOCO_PLAN_DEFAULTS[plan];
setFeeInPersonPct(defaults.inPerson);
setFeeOnlinePct(defaults.online);
};
const savePayments = () => save({
yoco_plan: yocoPlan,
yoco_fee_in_person_pct: feeInPersonPct.trim(),
yoco_fee_online_pct: feeOnlinePct.trim(),
});
const handleTestSmtp = async () => {
if (!token) return;
setSmtpTesting(true);
@@ -505,6 +537,44 @@ export default function SiteSettingsPage() {
<SaveBar saving={saving} onSave={saveLegal} result={result} />
</div>
)}
{/* ── Payments (Yoco fees) ─────────────────────────────────────────────── */}
{activeTab === "payments" && (
<div className="space-y-4">
<p className="text-sm text-gray-500">
Yoco deducts a processing fee from every card transaction before it reaches your bank account.
Reports use the estimate below to show a more accurate payout. Yoco&apos;s real rate varies by
monthly volume and card type, which this app doesn&apos;t track pick your plan to pre-fill a
starting value, then adjust the percentages to match your actual bank statement.
</p>
<Field label="Yoco plan">
<select
className={inputCls}
value={yocoPlan}
onChange={e => handleYocoPlanChange(e.target.value as "core" | "plus" | "pro")}
>
<option value="core">Core</option>
<option value="plus">Plus</option>
<option value="pro">Pro</option>
</select>
</Field>
<div className="grid sm:grid-cols-2 gap-4">
<Field label="In-person (card machine) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.30"
value={feeInPersonPct} onChange={e => setFeeInPersonPct(e.target.value)} />
</Field>
<Field label="Online (checkout link) fee %">
<input type="number" step="0.01" min="0" max="100" className={inputCls} placeholder="2.95"
value={feeOnlinePct} onChange={e => setFeeOnlinePct(e.target.value)} />
</Field>
</div>
<p className="text-xs text-gray-400">These fees are always absorbed by the church they're shown in reports for an accurate payout, never added to what attendees pay.</p>
<SaveBar saving={saving} onSave={savePayments} result={result} />
</div>
)}
</div>
);
}