Payment provider interface: Yoco behind it, Site settings → Payments
- src/payments: provider registry and PaymentProvider interface (createCheckout, verifyWebhook/parseWebhook, getTransaction, refund, listTransactions as capabilities), Yoco as the first provider. All four direct Yoco checkout calls go through payments.createCheckout. - Webhook split into the provider part (signature, parsing) and shared processing; same URL (/api/webhooks/yoco), same behaviour. - YocoTransaction renamed in place to ProviderTransaction (+ provider, unique per provider + externalId); Payment.provider added and backfilled to "yoco". Payment.method stays the wallet type so cashups are unchanged. - Site settings → Payments: Enabled toggle and order per provider; Yoco's settings shown while it's enabled. R15 minimum for all online payments. - New endpoint names (/api/payments/checkout, /api/provider-transactions); the old /yoco-checkout and /yoco-transactions paths still work. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,13 +11,13 @@ import { ColorPickerField } from "@/components/admin/ColorPickerField";
|
||||
import { BrandingPreviewPanel } from "@/components/admin/BrandingPreviewPanel";
|
||||
import { BackupsTab } from "@/components/admin/BackupsTab";
|
||||
import { JobsTab } from "@/components/admin/JobsTab";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
import { PaymentsTab } from "@/components/admin/PaymentsTab";
|
||||
import { extractDominantColors } from "@/lib/extractColors";
|
||||
import { mapsSearchUrl } from "@/lib/maps";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "yoco" | "backups" | "jobs";
|
||||
type TabId = "organisation" | "branding" | "notifications" | "email" | "legal" | "whatsapp" | "payments" | "backups" | "jobs";
|
||||
|
||||
const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "organisation", label: "Organisation", icon: Building2 },
|
||||
@@ -26,7 +26,7 @@ const TABS: { id: TabId; label: string; icon: LucideIcon }[] = [
|
||||
{ id: "email", label: "Email", icon: Mail },
|
||||
{ id: "legal", label: "Legal", icon: Scale },
|
||||
{ id: "whatsapp", label: "WhatsApp", icon: MessageCircle },
|
||||
{ id: "yoco", label: "Yoco", icon: CreditCard },
|
||||
{ id: "payments", label: "Payments", icon: CreditCard },
|
||||
{ id: "backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ id: "jobs", label: "Background jobs", icon: Timer },
|
||||
];
|
||||
@@ -97,7 +97,9 @@ function SiteSettingsPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const { reload: reloadSettings } = useSiteSettings();
|
||||
|
||||
const initialTab = TABS.some(t => t.id === searchParams.get("tab")) ? (searchParams.get("tab") as TabId) : "organisation";
|
||||
// ?tab=yoco is the Payments tab's older name.
|
||||
const tabParam = searchParams.get("tab") === "yoco" ? "payments" : searchParams.get("tab");
|
||||
const initialTab = TABS.some(t => t.id === tabParam) ? (tabParam as TabId) : "organisation";
|
||||
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
|
||||
const [loadingInitial, setLoadingInitial] = useState(true);
|
||||
|
||||
@@ -703,7 +705,7 @@ function SiteSettingsPageInner() {
|
||||
{activeTab === "whatsapp" && <WhatsAppTab active={activeTab === "whatsapp"} />}
|
||||
|
||||
{/* ── Yoco ──────────────────────────────────────────────────────────── */}
|
||||
{activeTab === "yoco" && <YocoTab active={activeTab === "yoco"} />}
|
||||
{activeTab === "payments" && <PaymentsTab active={activeTab === "payments"} />}
|
||||
|
||||
{/* ── Backups ───────────────────────────────────────────────────────── */}
|
||||
{activeTab === "backups" && <BackupsTab active={activeTab === "backups"} />}
|
||||
|
||||
@@ -35,16 +35,18 @@ function refundMethodForOriginal(method: string | null | undefined): string {
|
||||
|
||||
type Outstanding = Record<string, { totalDue: number; totalPaid: number; outstanding: number }>;
|
||||
|
||||
// An unreconciled Yoco transaction (GET /api/yoco-transactions/unreconciled). `raw` is the
|
||||
// webhook as Yoco sent it; its checkout metadata says who/what the payment was for.
|
||||
type YocoTx = {
|
||||
// An unreconciled payment provider transaction (GET /api/provider-transactions/unreconciled).
|
||||
// `metadata` is the checkout metadata: who/what the payment was for.
|
||||
type ProviderTx = {
|
||||
id: string;
|
||||
provider: string;
|
||||
providerLabel: string;
|
||||
amount: number;
|
||||
checkoutId?: string | null;
|
||||
externalId?: string | null;
|
||||
methodType?: string | null;
|
||||
createdDate?: string | null;
|
||||
raw?: { payload?: { metadata?: { userId?: string; eventId?: string } } } | null;
|
||||
metadata?: { userId?: string; eventId?: string } | null;
|
||||
};
|
||||
|
||||
function RegistrationOptions({ regs, regOutstanding }: {
|
||||
@@ -278,7 +280,7 @@ function PaymentsContent() {
|
||||
if (!amt || amt <= 0) { setError("Enter a valid amount"); return; }
|
||||
try {
|
||||
setLinkGenerating(true);
|
||||
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/yoco-checkout", {
|
||||
const res = await apiFetch<{ redirectUrl?: string; checkoutId?: string; amount?: number; priceUpdated?: boolean; message?: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
@@ -319,7 +321,7 @@ function PaymentsContent() {
|
||||
setError(null); setInfo(null);
|
||||
try {
|
||||
setLinkSending(channel);
|
||||
await apiFetch("/api/payments/yoco-checkout/send", {
|
||||
await apiFetch("/api/payments/checkout/send", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: { registrationId: linkResult.registrationId, redirectUrl: linkResult.redirectUrl, channel },
|
||||
@@ -421,34 +423,34 @@ function PaymentsContent() {
|
||||
|
||||
const [mode, setMode] = useState<'payment'|'refund'|'donation'|'reconcile'|'link'>("payment");
|
||||
|
||||
// Yoco unreconciled list and actions
|
||||
const [yocoLoading, setYocoLoading] = useState(false);
|
||||
const [yocoTxs, setYocoTxs] = useState<YocoTx[]>([]);
|
||||
const loadYocoUnreconciled = useCallback(async () => {
|
||||
// Unreconciled online payments (from payment providers) and actions
|
||||
const [txLoading, setTxLoading] = useState(false);
|
||||
const [providerTxs, setProviderTxs] = useState<ProviderTx[]>([]);
|
||||
const loadUnreconciled = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
setYocoLoading(true);
|
||||
const res = await apiFetch<{ data?: YocoTx[] } | YocoTx[]>("/api/yoco-transactions/unreconciled", { authToken: token });
|
||||
setTxLoading(true);
|
||||
const res = await apiFetch<{ data?: ProviderTx[] } | ProviderTx[]>("/api/provider-transactions/unreconciled", { authToken: token });
|
||||
const items = Array.isArray(res) ? res : (Array.isArray(res?.data) ? res.data : []);
|
||||
setYocoTxs(items);
|
||||
setProviderTxs(items);
|
||||
} catch {
|
||||
// ignore silently, shown on demand via error state if needed
|
||||
} finally {
|
||||
setYocoLoading(false);
|
||||
setTxLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
useEffect(() => { loadYocoUnreconciled(); }, [loadYocoUnreconciled]);
|
||||
useEffect(() => { loadUnreconciled(); }, [loadUnreconciled]);
|
||||
|
||||
// Inline reconcile form state per-row
|
||||
const [reconcileForm, setReconcileForm] = useState<{ txId: string | null; type: 'reg' | 'don' | null; userId: string; registrationId: string; eventId: string; submitting: boolean }>({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
|
||||
const openReconcileToRegistration = (tx: YocoTx) => {
|
||||
const metaUser = tx?.raw?.payload?.metadata?.userId ? String(tx.raw.payload.metadata.userId) : '';
|
||||
const openReconcileToRegistration = (tx: ProviderTx) => {
|
||||
const metaUser = tx.metadata?.userId ? String(tx.metadata.userId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'reg', userId: metaUser, registrationId: '', eventId: '', submitting: false });
|
||||
};
|
||||
|
||||
const openReconcileAsDonation = (tx: YocoTx) => {
|
||||
const metaEvent = tx?.raw?.payload?.metadata?.eventId ? String(tx.raw.payload.metadata.eventId) : '';
|
||||
const openReconcileAsDonation = (tx: ProviderTx) => {
|
||||
const metaEvent = tx.metadata?.eventId ? String(tx.metadata.eventId) : '';
|
||||
setReconcileForm({ txId: tx.id, type: 'don', userId: '', registrationId: '', eventId: metaEvent, submitting: false });
|
||||
};
|
||||
|
||||
@@ -467,7 +469,7 @@ function PaymentsContent() {
|
||||
setReconcileForm(prev => ({ ...prev, submitting: true }));
|
||||
if (type === 'reg') {
|
||||
if (!registrationId) { setError('Please select a registration'); return; }
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { registrationId }
|
||||
@@ -475,7 +477,7 @@ function PaymentsContent() {
|
||||
setInfo('Reconciled to registration');
|
||||
} else {
|
||||
if (!eventId) { setError('Please select an event'); return; }
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(txId)}/reconcile`, {
|
||||
method: 'POST',
|
||||
authToken: token,
|
||||
body: { eventId, userId: userId || undefined }
|
||||
@@ -483,7 +485,7 @@ function PaymentsContent() {
|
||||
setInfo('Reconciled as donation');
|
||||
}
|
||||
setReconcileForm({ txId: null, type: null, userId: '', registrationId: '', eventId: '', submitting: false });
|
||||
await Promise.all([loadYocoUnreconciled(), loadPayments(), loadRegistrations()]);
|
||||
await Promise.all([loadUnreconciled(), loadPayments(), loadRegistrations()]);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to reconcile');
|
||||
} finally {
|
||||
@@ -547,21 +549,22 @@ function PaymentsContent() {
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Yoco reconciliation panel */}
|
||||
{/* Online payment reconciliation panel */}
|
||||
{mode === 'reconcile' && (
|
||||
<div className="border rounded-xl p-4 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="text-lg font-semibold">Unreconciled Yoco Payments</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadYocoUnreconciled} disabled={yocoLoading}>{yocoLoading ? 'Refreshing…' : 'Refresh'}</button>
|
||||
<div className="text-lg font-semibold">Unreconciled Online Payments</div>
|
||||
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={loadUnreconciled} disabled={txLoading}>{txLoading ? 'Refreshing…' : 'Refresh'}</button>
|
||||
</div>
|
||||
{yocoTxs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No unreconciled Yoco transactions.</div>
|
||||
{providerTxs.length === 0 ? (
|
||||
<div className="text-sm text-gray-500">No unreconciled online payments.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto w-full">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left border-b">
|
||||
<th className="py-2 pr-3">Created</th>
|
||||
<th className="py-2 pr-3">Provider</th>
|
||||
<th className="py-2 pr-3">External ID</th>
|
||||
<th className="py-2 pr-3">Amount</th>
|
||||
<th className="py-2 pr-3">Checkout</th>
|
||||
@@ -570,10 +573,11 @@ function PaymentsContent() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{yocoTxs.map(tx => (
|
||||
{providerTxs.map(tx => (
|
||||
<React.Fragment key={tx.id}>
|
||||
<tr className="border-b hover:bg-gray-50">
|
||||
<td className="py-2 pr-3 whitespace-nowrap">{tx.createdDate ? new Date(tx.createdDate).toLocaleString() : '-'}</td>
|
||||
<td className="py-2 pr-3">{tx.providerLabel}</td>
|
||||
<td className="py-2 pr-3 font-mono text-xs break-all max-w-[10rem]">{tx.externalId}</td>
|
||||
<td className="py-2 pr-3">R {(Number(tx.amount || 0)/100).toFixed(2)}</td>
|
||||
<td className="py-2 pr-3 break-all max-w-[8rem]">{tx.checkoutId || '-'}</td>
|
||||
@@ -586,9 +590,9 @@ function PaymentsContent() {
|
||||
const ok = window.confirm('Ignore this transaction? It will be marked as ignored and reconciled.');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await apiFetch(`/api/yoco-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
await apiFetch(`/api/provider-transactions/${encodeURIComponent(tx.id)}/ignore`, { method: 'POST', authToken: token });
|
||||
setInfo('Transaction ignored');
|
||||
await loadYocoUnreconciled();
|
||||
await loadUnreconciled();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || 'Failed to ignore');
|
||||
}
|
||||
@@ -597,7 +601,7 @@ function PaymentsContent() {
|
||||
</tr>
|
||||
{reconcileForm.txId === tx.id && (
|
||||
<tr className="bg-gray-50">
|
||||
<td colSpan={6} className="p-3">
|
||||
<td colSpan={7} className="p-3">
|
||||
{reconcileForm.type === 'reg' ? (
|
||||
<div className="grid sm:grid-cols-3 gap-3 items-end">
|
||||
<div>
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function DonatePage() {
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
setLoading(true);
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/yoco-checkout", {
|
||||
const res = await apiFetch<{ redirectUrl: string }>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
body: {
|
||||
eventId,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ArrowDown, ArrowUp, Wallet } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useDismissingState } from "@/hooks/useDismissingState";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { YocoTab } from "@/components/admin/YocoTab";
|
||||
|
||||
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
|
||||
|
||||
/**
|
||||
* Site settings → Payments: which online payment providers people can use, in what order
|
||||
* (the first one is the default), and below that the settings of each enabled provider.
|
||||
*/
|
||||
export function PaymentsTab({ active }: { active: boolean }) {
|
||||
const { token } = useAuth();
|
||||
const [providers, setProviders] = useState<ProviderSetting[] | null>(null);
|
||||
const [minimum, setMinimum] = useState(15);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useDismissingState<{ type: "ok" | "err"; text: string } | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await apiFetch<{ providers: ProviderSetting[]; minimumAmount: number }>("/api/payments/providers/settings", { authToken: token });
|
||||
setProviders(res.providers);
|
||||
setMinimum(res.minimumAmount);
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Failed to load payment providers" });
|
||||
}
|
||||
}, [token, setMessage]);
|
||||
|
||||
useEffect(() => { if (active) load(); }, [active, load]);
|
||||
|
||||
const save = async (next: ProviderSetting[]) => {
|
||||
if (!token) return;
|
||||
setProviders(next);
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await apiFetch<{ message: string; providers: ProviderSetting[] }>("/api/payments/providers/settings", {
|
||||
method: "PUT", authToken: token, body: { providers: next.map(p => ({ id: p.id, enabled: p.enabled })) },
|
||||
});
|
||||
setProviders(res.providers);
|
||||
setMessage({ type: "ok", text: res.message });
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Could not save" });
|
||||
load();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const move = (i: number, by: number) => {
|
||||
if (!providers) return;
|
||||
const next = [...providers];
|
||||
const [p] = next.splice(i, 1);
|
||||
next.splice(i + by, 0, p);
|
||||
save(next);
|
||||
};
|
||||
const toggle = (i: number) => providers && save(providers.map((p, j) => (j === i ? { ...p, enabled: !p.enabled } : p)));
|
||||
|
||||
const usable = (providers || []).filter(p => p.enabled && p.configured);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-lg bg-brand-50 flex items-center justify-center shrink-0">
|
||||
<Wallet className="w-4 h-4 text-brand-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Payments</h2>
|
||||
<p className="text-xs text-gray-500">Which online payment providers people can use. The first one in the list is used by default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm p-2.5 rounded-lg ${message.type === "ok" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!providers ? (
|
||||
<p className="text-sm text-gray-400">Loading…</p>
|
||||
) : (
|
||||
<div className="border rounded-lg divide-y" role="list" aria-label="Payment providers">
|
||||
{providers.map((p, i) => (
|
||||
<div key={p.id} role="listitem" className="flex items-center gap-3 px-3 py-2.5">
|
||||
<label className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<input type="checkbox" checked={p.enabled} disabled={saving} onChange={() => toggle(i)} aria-label={`Enable ${p.label}`} />
|
||||
<span className="text-sm font-medium text-gray-900">{p.label}</span>
|
||||
{i === 0 && p.enabled && <span className="text-[11px] px-1.5 py-0.5 rounded bg-brand-50 text-brand-700">Default</span>}
|
||||
{p.enabled && !p.configured && <span className="text-[11px] px-1.5 py-0.5 rounded bg-amber-100 text-amber-800">Not set up</span>}
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<button type="button" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30" disabled={saving || i === 0} onClick={() => move(i, -1)} aria-label={`Move ${p.label} up`}><ArrowUp className="w-4 h-4" /></button>
|
||||
<button type="button" className="p-1 rounded hover:bg-gray-100 disabled:opacity-30" disabled={saving || i === providers.length - 1} onClick={() => move(i, 1)} aria-label={`Move ${p.label} down`}><ArrowDown className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{providers && usable.length === 0 && (
|
||||
<p className="text-sm text-amber-800 bg-amber-50 border border-amber-200 rounded-lg p-2.5">
|
||||
Online payments are off: no provider is both enabled and set up. People can still pay at the door.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-gray-500">
|
||||
Online payments must be at least R{minimum}. Payments are recorded at the full amount paid; provider fees aren't deducted or added to the payer's total — deal with them at cashup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Each provider's own settings, only while it's enabled */}
|
||||
{providers?.some(p => p.id === "yoco" && p.enabled) && (
|
||||
<div className="border-t pt-5">
|
||||
<YocoTab active={active} onChanged={load} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,8 @@ function Card({ icon: Icon, title, subtitle, children }: {
|
||||
);
|
||||
}
|
||||
|
||||
export function YocoTab({ active }: { active: boolean }) {
|
||||
// onChanged: called after a save, so the Payments tab can refresh whether Yoco is set up.
|
||||
export function YocoTab({ active, onChanged }: { active: boolean; onChanged?: () => void }) {
|
||||
const { token } = useAuth();
|
||||
const [cfg, setCfg] = useState<YocoConfig | null>(null);
|
||||
const [hooks, setHooks] = useState<YocoWebhook[]>([]);
|
||||
@@ -131,6 +132,7 @@ export function YocoTab({ active }: { active: boolean }) {
|
||||
const text = await fn();
|
||||
setMessage({ type: "ok", text });
|
||||
await load();
|
||||
onChanged?.();
|
||||
} catch (e) {
|
||||
setMessage({ type: "err", text: errorMessage(e) || "Something went wrong" });
|
||||
} finally {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React from "react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play } from "lucide-react";
|
||||
import { Building2, Palette, Bell, Mail, Scale, MessageCircle, LayoutDashboard, Timer, Play, CreditCard, ArrowUpDown, KeyRound } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
/** Help content for Site Settings (/dashboard/admin/settings). */
|
||||
export const siteSettingsHelpContent: HelpContent = {
|
||||
title: "Site Settings help",
|
||||
subtitle: "Configure your organisation, branding, email, WhatsApp, and legal pages.",
|
||||
subtitle: "Configure your organisation, branding, email, WhatsApp, payments, and legal pages.",
|
||||
quickLinks: [
|
||||
{ label: "Back to Admin dashboard", href: "/dashboard/admin", icon: LayoutDashboard },
|
||||
],
|
||||
@@ -96,6 +96,27 @@ export const siteSettingsHelpContent: HelpContent = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "payments",
|
||||
label: "Payments",
|
||||
icon: CreditCard,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p>Online payments: which providers people can pay with, and each provider's settings.</p>
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={ArrowUpDown} title="Providers and order" tone="brand">
|
||||
Tick a provider to let people pay with it, and use the arrows to set the order. The first provider that's ticked and set up is the default. If none is, online payments are off and people pay at the door.
|
||||
</GuideItem>
|
||||
<GuideItem icon={KeyRound} title="Yoco" tone="green">
|
||||
Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full.
|
||||
</GuideItem>
|
||||
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
|
||||
Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren't deducted or added to the payer's total — deal with them at cashup.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "jobs",
|
||||
label: "Background jobs",
|
||||
|
||||
@@ -25,10 +25,10 @@ export const supervisorPaymentsHelpContent: HelpContent = {
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
Match unreconciled Yoco card transactions to a registration, log them as a donation, or ignore them.
|
||||
Match unreconciled online payments (from Yoco, or another payment provider) to a registration, log them as a donation, or ignore them.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Link2} title="Payment Link" tone="violet">
|
||||
Generate a Yoco checkout link for a user to pay online — copy it or send by email/WhatsApp. Generating a new link invalidates the previous one.
|
||||
Generate an online payment link for a user — copy it or send by email/WhatsApp. It uses the default payment provider (Site settings → Payments). Online payments must be at least R15. Generating a new link invalidates the previous one.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -198,13 +198,13 @@ export function fetchAllPayments(token: string, params: Record<string, string> =
|
||||
return fetchAllPages<Payment>("/api/payments", token, params);
|
||||
}
|
||||
|
||||
// Creates a Yoco checkout for a registration's full remaining outstanding balance.
|
||||
// Creates an online checkout (default payment provider) for a registration's full remaining balance.
|
||||
// Omitting `amount` makes the backend charge the full remainder rather than a partial amount —
|
||||
// partial-amount checkouts are only ever created from the supervisor payments dashboard.
|
||||
export type CheckoutResponse = { redirectUrl?: string; priceUpdated?: boolean; newTotal?: number; message?: string };
|
||||
|
||||
export function createFullPaymentCheckout(token: string, registrationId: string, donationAmount?: number): Promise<CheckoutResponse> {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/yoco-checkout", {
|
||||
return apiFetch<CheckoutResponse>("/api/payments/checkout", {
|
||||
method: "POST",
|
||||
authToken: token,
|
||||
body: {
|
||||
|
||||
Reference in New Issue
Block a user