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:
@@ -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's real rate varies by
|
||||
monthly volume and card type, which this app doesn'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user