+
@@ -114,9 +120,9 @@ export function Locations() {
Default Accounts
{mpesaAcct ? (
-
{mpesaAcct.name}
+
{mpesaAcct.name}
) : (
-
No M-PESA wallet
+
No wallet
)}
{cashAcct ? (
{cashAcct.name}
@@ -129,7 +135,7 @@ export function Locations() {
Accounts ({locAccounts.length})
{locAccounts.map(a => (
-
+
{a.name} · {a.currentBalance}
))}
diff --git a/src/pages/Mpesa.tsx b/src/pages/Mpesa.tsx
index 55df11d..7e99c24 100644
--- a/src/pages/Mpesa.tsx
+++ b/src/pages/Mpesa.tsx
@@ -48,7 +48,8 @@ export function Mpesa() {
const { data: categories, refetch: refetchCategories } = trpc.expenses.categories.useQuery();
const { data: suppliers } = trpc.suppliers.list.useQuery();
const { data: accounts } = trpc.accounts.list.useQuery();
- const mpesaAccounts = accounts?.filter(a => a.type === "mpesa" && a.isActive && !a.deletedAt) ?? [];
+ const walletTypes = ["mpesa", "wallet"];
+ const mpesaAccounts = accounts?.filter(a => walletTypes.includes(a.type) && a.isActive && !a.deletedAt) ?? [];
// Always pass the date parameters to ensure proper filtering
const { data: transactions, refetch } = trpc.mpesa.list.useQuery({
@@ -341,11 +342,11 @@ export function Mpesa() {
-
-
Select the M-PESA wallet/account that received this topup. The system will credit this account.
+
+
Select the wallet account that received this topup. The system will credit this account.
+
+
+
+
Create, edit, and switch between businesses
+
+
navigate("/businesses")} className="bg-[#2E7D32]">
+
+ Open Businesses
+
+
+
+
+
+
+
+
+
+ Multi-Currency
+
+
+
+
+
+
+
Allow transacting in multiple currencies with live exchange rates
+
+
toggle("multiCurrency")} disabled={!canManage} />
+
+
+
+
+
+
+
+
+ Branches & Locations
+
+
+
+
+ Create and manage business branches, set default wallets and cash accounts, and view all linked accounts per location.
+
+ navigate("/locations")} className="bg-[#C73E1D]">
+
+ Manage Branches
+
>
@@ -390,6 +484,199 @@ export function Settings() {
>
)}
+ {tab === "wallets" && (
+ <>
+
+
+
Wallet Management
+
Provider configuration, exchange rates, and currency management
+
+
refetchHealth()} className="gap-2">
+ Refresh
+
+
+
+
+ {(["providers", "rates", "currencies"] as const).map((t) => (
+ setWalletSubTab(t)} className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${walletSubTab === t ? "bg-[#C73E1D] text-white" : "bg-[#F5EDE6] text-[#2D2A26] hover:bg-[#E8E0D8]"}`}>
+ {t.charAt(0).toUpperCase() + t.slice(1)}
+
+ ))}
+
+
+ {walletSubTab === "providers" && (
+ <>
+
+ {health?.map((h: any) => (
+
+
+
+
+ {PROVIDER_ICONS[h.provider] ||
}
+
+
+
{h.displayName}
+
{h.provider}
+
+
+
+
{h.features?.initiatePayment ? "API" : "SMS"}
+
+
+
+
Currencies: {(h.supportedCurrencies || []).join(", ")}
+
Last txn: {h.lastTransactionDate || "No transactions"}
+
+
+ {Object.entries(h.features || {}).filter(([, v]) => v).map(([k]) => (
+ {k}
+ ))}
+
+
+
+ ))}
+ {(!health || health.length === 0) && (
+
No providers registered
+ )}
+
+
+
+ Available Providers
+
+
+ {providers?.map((p: any) => (
+
+
+
{p.displayName || p.name} ({p.code})
+
{p.supportedCurrencies} · {p.isActive ? "Active" : "Inactive"}
+
+
+ ))}
+ {(!providers || providers.length === 0) && (
+
No providers available
+ )}
+
+
+
+ >
+ )}
+
+ {walletSubTab === "rates" && (
+
+
+
+
Exchange Rates
+
+ syncRates.mutate()} disabled={syncRates.isPending} className="gap-1">
+
+ {syncRates.isPending ? "Syncing..." : "Sync from Frankfurter"}
+
+
+
+
+
+
+
Manual Rate Override
+
+
+
+
+
+
+
+
+
+
+
+
+ setRateForm(f => ({ ...f, rate: e.target.value }))} placeholder="e.g. 153.25" className="text-sm" />
+
+
+ {manualUpdateRate.isPending ? "Saving..." : "Save Rate"}
+
+
+
+
+
+
+ | From |
+ To |
+ Rate |
+ Source |
+ Last Updated |
+
+
+
+ {rates?.map((r: any) => (
+
+ | {r.fromCurrency} |
+ {r.toCurrency} |
+ {r.rate} |
+ {r.source || "Manual"} |
+ {r.updatedAt ? new Date(r.updatedAt).toLocaleDateString() : "-"} |
+
+ ))}
+ {(!rates || rates.length === 0) && (
+
+ | No exchange rates configured |
+
+ )}
+
+
+
+
+ )}
+
+ {walletSubTab === "currencies" && (
+
+ Supported Currencies
+
+
+
+
+ | Code |
+ Name |
+ Symbol |
+ Decimals |
+ Status |
+
+
+
+ {currencies?.map((c: any) => (
+
+ | {c.code} |
+ {c.name} |
+ {c.symbol} |
+ {c.decimalPlaces} |
+
+ toggleCurrency.mutate({ code: c.code, isActive: !c.isActive })}
+ disabled={c.isDefault}
+ className={`inline-flex items-center gap-1 text-xs rounded-full px-2 py-0.5 border ${
+ c.isActive
+ ? "border-[#2E7D32] bg-[#2E7D32]/10 text-[#2E7D32]"
+ : "border-[#8D8A87] bg-transparent text-[#8D8A87]"
+ } ${c.isDefault ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:opacity-80"}`}
+ >
+ {c.isActive ? : }
+ {c.isActive ? "Active" : "Inactive"}
+
+ {c.isDefault && (default)}
+ |
+
+ ))}
+
+
+
+
+ )}
+ >
+ )}
+
{tab === "integrations" && (
<>
{/* API Keys */}
@@ -499,6 +786,41 @@ export function Settings() {
+
+ {/* Currency Exchanges */}
+
+ Currency Exchanges
+
+
+
+
+
+
Frankfurter
+
https://api.frankfurter.dev
+
+
Active
+
+
+ Frankfurter is a free, open-source exchange rate API. You can self-host it and change the endpoint later.
+
+
+ API URL
+ https://api.frankfurter.dev/v2/rates
+
+
+ syncRates.mutate()}
+ disabled={syncRates.isPending}
+ className="gap-1 bg-[#C73E1D]"
+ >
+
+ {syncRates.isPending ? "Syncing..." : "Sync Now"}
+
+
+
+
+
>
)}
diff --git a/src/pages/Wallet.tsx b/src/pages/Wallet.tsx
new file mode 100644
index 0000000..3c021a3
--- /dev/null
+++ b/src/pages/Wallet.tsx
@@ -0,0 +1,671 @@
+// ABOUTME: Multi-provider mobile wallet dashboard showing all configured wallet providers and their transactions.
+// ABOUTME: Uses the unified wallet API (trpc.wallet.*) for provider-agnostic wallet management.
+import { useState, useEffect } from "react";
+import { Layout } from "@/components/Layout";
+import { trpc } from "@/providers/trpc";
+import { formatKES, formatDate, getLocalDateString } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Smartphone, Upload, ArrowUpRight, ArrowDownRight, Tag, Receipt, Wallet as WalletIcon, Landmark, Plus, Link2, BookOpen } from "lucide-react";
+
+export function Wallet() {
+ const [tab, setTab] = useState<"overview" | "transactions" | "ledger" | "import">("overview");
+ const [selectedProvider, setSelectedProvider] = useState
("");
+ const [dateFrom, setDateFrom] = useState(() => {
+ const d = new Date(); d.setMonth(d.getMonth() - 1); return d.toISOString().split("T")[0];
+ });
+ const [dateTo, setDateTo] = useState(() => new Date().toISOString().split("T")[0]);
+ const [smsText, setSmsText] = useState("");
+ const [selectedLocation, setSelectedLocation] = useState("");
+ const [smsProvider, setSmsProvider] = useState("mpesa");
+ const [parsedPreview, setParsedPreview] = useState([]);
+ const [isPreviewing, setIsPreviewing] = useState(false);
+ const [tagTxnId, setTagTxnId] = useState(null);
+ const [linkTxnId, setLinkTxnId] = useState(null);
+ const [expenseForm, setExpenseForm] = useState({ locationId: "", categoryId: "", description: "", supplierId: "" });
+ const [linkForm, setLinkForm] = useState({ sourceAccountId: "", destinationAccountId: "" });
+ const [ledgerOpen, setLedgerOpen] = useState(false);
+ const [selectedWallet, setSelectedWallet] = useState("");
+ const [ledgerForm, setLedgerForm] = useState({ locationId: "", accountId: "", ledgerDate: getLocalDateString(), openingBalance: "", closingBalance: "", notes: "", provider: "mpesa" });
+
+ const utils = trpc.useUtils();
+ const { data: locations } = trpc.locations.list.useQuery();
+ const { data: providers } = trpc.wallet.providers.list.useQuery();
+ const { data: accounts } = trpc.accounts.list.useQuery();
+ const { data: categories, refetch: refetchCategories } = trpc.expenses.categories.useQuery();
+ const { data: suppliers } = trpc.suppliers.list.useQuery();
+ const { data: feeAnalysis } = trpc.dashboard.feeAnalysis.useQuery({});
+ const { data: transactions, refetch } = trpc.wallet.transactions.list.useQuery({
+ provider: selectedProvider || undefined,
+ dateFrom, dateTo,
+ });
+ const { data: stats } = trpc.wallet.transactions.stats.useQuery({
+ provider: selectedProvider || undefined,
+ dateFrom, dateTo,
+ });
+ const { data: ledgers, refetch: refetchLedger } = trpc.wallet.dailyLedger.list.useQuery({
+ provider: selectedProvider || undefined,
+ dateFrom, dateTo,
+ });
+
+ const importSms = trpc.wallet.transactions.importSms.useMutation({
+ onSuccess: () => { setSmsText(""); setParsedPreview([]); refetch(); utils.wallet.transactions.stats.invalidate(); },
+ });
+
+ const createExpenseFromTxn = trpc.wallet.transactions.createExpenseFromTxn.useMutation({
+ onSuccess: () => { setTagTxnId(null); refetch(); utils.expenses.list.invalidate(); utils.suppliers.list.invalidate(); },
+ });
+
+ const linkTopup = trpc.wallet.transactions.linkTopupToAccount.useMutation({
+ onSuccess: () => { setLinkTxnId(null); setLinkForm({ sourceAccountId: "", destinationAccountId: "" }); refetch(); utils.accounts.list.invalidate(); },
+ });
+
+ const createLedger = trpc.wallet.dailyLedger.create.useMutation({
+ onSuccess: () => {
+ setLedgerOpen(false);
+ setLedgerForm({ locationId: "", accountId: "", ledgerDate: getLocalDateString(), openingBalance: "", closingBalance: "", notes: "", provider: "mpesa" });
+ refetchLedger();
+ }
+ });
+
+ const previewSmsQuery = trpc.wallet.transactions.previewSms.useQuery(
+ { locationId: parseInt(selectedLocation), provider: smsProvider, smsText },
+ { enabled: false },
+ );
+
+ useEffect(() => {
+ if (tagTxnId !== null) {
+ refetchCategories();
+ }
+ }, [tagTxnId, refetchCategories]);
+
+ useEffect(() => { setParsedPreview([]); }, [smsText, smsProvider, selectedLocation]);
+
+ const handlePreviewSms = async () => {
+ if (!selectedLocation || !smsText.trim()) return;
+ setIsPreviewing(true);
+ try {
+ const result = await previewSmsQuery.refetch();
+ if (result.data) { setParsedPreview(result.data); }
+ } catch {
+ setParsedPreview([]);
+ }
+ setIsPreviewing(false);
+ };
+
+ const handleImportSms = () => {
+ if (!selectedLocation || !smsText.trim()) return;
+ importSms.mutate({ locationId: parseInt(selectedLocation), provider: smsProvider, smsText });
+ };
+
+ const providerColors: Record = {
+ mpesa: "bg-green-600",
+ airtel: "bg-red-600",
+ sasapay: "bg-blue-600",
+ };
+
+ const providerIcons: Record = {
+ mpesa: "M-PESA",
+ airtel: "Airtel",
+ sasapay: "Sasapay",
+ };
+
+ const totalIn = parseFloat(stats?.summary?.totalIn ?? "0");
+ const totalOut = parseFloat(stats?.summary?.totalOut ?? "0");
+ const totalFees = parseFloat(stats?.summary?.totalFees ?? "0");
+ const netFlow = totalIn - totalOut - totalFees;
+
+ const walletTypes = ["mpesa", "wallet"];
+ const walletAccounts = accounts?.filter(a => walletTypes.includes(a.type) && a.isActive && !a.deletedAt) ?? [];
+ const bankAccounts = accounts?.filter(a => a.type === "bank_account" && !a.deletedAt) ?? [];
+
+ // Ledger calculations (mirror Mpesa page pattern)
+ const ledgerTxns = selectedWallet ? transactions?.filter((t: any) => {
+ const wallet = walletAccounts.find(a => a.id.toString() === selectedWallet);
+ return wallet && t.locationId === wallet.locationId;
+ }) : transactions;
+ const rangeTxns = ledgerTxns ?? [];
+ const ledgerTotalIn = rangeTxns.filter((t: any) => parseFloat(t.amount) > 0).reduce((s: number, t: any) => s + parseFloat(t.amount), 0);
+ const ledgerTotalOut = rangeTxns.filter((t: any) => parseFloat(t.amount) < 0).reduce((s: number, t: any) => s + Math.abs(parseFloat(t.amount)), 0);
+ const ledgerTotalFees = rangeTxns.reduce((s: number, t: any) => s + parseFloat(t.txnFee || "0"), 0);
+
+ const handleLedgerSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ createLedger.mutate({
+ locationId: +ledgerForm.locationId,
+ provider: ledgerForm.provider,
+ accountId: +ledgerForm.accountId,
+ ledgerDate: ledgerForm.ledgerDate,
+ openingBalance: ledgerForm.openingBalance,
+ notes: ledgerForm.notes,
+ });
+ };
+
+ const smsImportSection = (fullPage?: boolean) => (
+
+
+
+
+ {locations && locations.length === 0 && (
+
No locations found for your current business. Please create a location first in Settings.
+ )}
+
Transactions will be imported to the selected location and will only be visible when viewing this business.
+
+
+
+
+
+
+
+
Paste multiple SMS messages. Each line should contain one transaction.
+
+
+
+ {isPreviewing ? "Parsing..." : "Preview"}
+
+
+
+ {importSms.isPending ? "Importing..." : `Import ${parsedPreview.length > 0 ? parsedPreview.length + ' Records' : 'SMS'}`}
+
+
+ {importSms.data && (
+
+
+ {importSms.data.success ? `Imported ${importSms.data.imported} transactions (${importSms.data.skipped} skipped)` : 'Import failed'}
+
+ {importSms.data.errors?.length > 0 && (
+
+ {importSms.data.errors.slice(0, 5).map((e: string, i: number) => - {e}
)}
+
+ )}
+
+ )}
+ {parsedPreview.length > 0 && (
+
+
Preview: {parsedPreview.length} transactions
+
+ {parsedPreview.slice(0, 10).map((p: any, i: number) => (
+
+ {p.txnId} · {p.partyName || "-"}
+ {p.currency || "KES"} {p.amount} · {p.txnType}
+
+ ))}
+ {parsedPreview.length > 10 &&
... and {parsedPreview.length - 10} more
}
+
+
+ )}
+
+ );
+
+ return (
+
+
+
+
+
Mobile Wallet
+
Multi-provider wallet aggregation
+
+
+
+
+
+
+
+ {["overview", "transactions", "ledger", "import"].map((t) => (
+ setTab(t as any)} className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${tab === t ? "bg-[#C73E1D] text-white" : "bg-[#F5EDE6] text-[#2D2A26] hover:bg-[#E8E0D8]"}`}>
+ {t === "overview" ? "Overview" : t === "transactions" ? "Transactions" : t === "ledger" ? "Daily Ledger" : "Import"}
+
+ ))}
+
+
+
+
+
+
+
+
+
+ setDateFrom(e.target.value)} className="rounded-lg border border-[#E8E0D8] px-3 py-1.5 text-sm" />
+
+
+
+ setDateTo(e.target.value)} className="rounded-lg border border-[#E8E0D8] px-3 py-1.5 text-sm" />
+
+
+
+ {tab === "overview" && (
+ <>
+
+
+
+ {formatKES(totalIn.toFixed(2))}
+ {stats?.summary?.countIn ?? 0} transactions
+
+
+
+ {formatKES(totalOut.toFixed(2))}
+ {stats?.summary?.countOut ?? 0} transactions
+
+
+ Fees
+ {formatKES(totalFees.toFixed(2))}
+ Transaction costs
+
+
+ Net Flow
+ = 0 ? "text-[#2E7D32]" : "text-[#D32F2F]"}`}>{formatKES(netFlow.toFixed(2))}
+ {selectedProvider || "All providers"}
+
+
+
+ {/* Transaction Fee Analysis - from Mpesa page */}
+ {feeAnalysis && (
+
+ Transaction Fee Analysis
+
+
+ {feeAnalysis.feesByType?.map((ft: { txnType: string; totalFees: string; count: number }) => (
+
+
{ft.txnType}
+
{formatKES(ft.totalFees)}
+
{ft.count} txns
+
+ ))}
+
+ {feeAnalysis.topRecipients && feeAnalysis.topRecipients.length > 0 && (
+
+
Top Recipients by Fees
+
+ {feeAnalysis.topRecipients.slice(0, 5).map((r: { partyName: string | null; totalFees: string; count: number }) => (
+
+ {r.partyName}
+ {formatKES(r.totalFees)} ({r.count} txns)
+
+ ))}
+
+
+ )}
+
+
+ )}
+
+
+
+ Fees by Type
+
+ {stats?.feesByType?.length ? stats.feesByType.map((ft: any) => (
+
+ {ft.txnType.replace(/_/g, " ")}
+ {formatKES(ft.totalFees)} ({ft.count} txns)
+
+ )) : No fees recorded
}
+
+
+
+ Top Recipients
+
+ {stats?.topRecipients?.length ? stats.topRecipients.map((r: any) => (
+
+ {r.partyName}
+ {formatKES(r.totalAmount)} ({r.count})
+
+ )) : No outbound transactions
}
+
+
+
+
+
+ Available Providers
+
+
+ {providers?.map((p: any) => (
+
+
+ {providerIcons[p.code] || p.displayName || p.name}
+
+
{p.supportedCurrencies || "KES"}
+
{p.isActive ? "Active" : "Inactive"}
+
+ ))}
+ {(!providers || providers.length === 0) &&
No providers configured
}
+
+
+
+ >
+ )}
+
+ {tab === "transactions" && (
+
+
+
+
+
+
+ | Date |
+ ID |
+ Provider |
+ Type |
+ Party |
+ Amount |
+ Fee |
+ Status |
+ Action |
+
+
+
+ {transactions?.map((txn: any) => {
+ const amt = Math.abs(parseFloat(txn.amount));
+ const isOut = txn.direction === "out" || parseFloat(txn.amount) < 0;
+ return (
+
+ | {formatDate(txn.txnDate)} {txn.txnTime} |
+ {txn.providerTxnId || txn.txnId} |
+
+
+ {providerIcons[txn.provider] || txn.provider || "M-PESA"}
+
+ |
+
+ {txn.txnType.replace(/_/g, " ")}
+ |
+ {txn.partyName || txn.description || "-"} |
+
+ {isOut ? "-" : "+"}{formatKES(amt.toFixed(2))}
+ |
+ {formatKES(txn.txnFee || "0")} |
+
+
+ {txn.isLinked ? "Linked" : "Unlinked"}
+
+ |
+
+
+ {/* Link topup to bank account */}
+ {!txn.isLinked && txn.txnType === "topup" && (
+
+ )}
+ {/* Tag expense */}
+ {!txn.isLinked && isOut && (
+
+ )}
+
+ |
+
+ );
+ })}
+ {(!transactions || transactions.length === 0) && (
+ |
+
+ No transactions found for the selected date range.
+ Import SMS messages or adjust your date filter.
+ |
+ )}
+
+
+
+
+
+ )}
+
+ {tab === "import" && (
+
+ Bulk SMS Import
+
+
+ This bulk import view is designed for processing large volumes of SMS transactions.
+ Paste multiple transaction messages below, preview them, and import in a single batch.
+
+ {smsImportSection(true)}
+
+
+ )}
+
+ {tab === "ledger" && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {formatKES(ledgerTotalIn.toFixed(2))}
+
+
+
+ {formatKES(ledgerTotalOut.toFixed(2))}
+
+
+ Fees
+ {formatKES(ledgerTotalFees.toFixed(2))}
+
+
+ Net Change
+ {formatKES((ledgerTotalIn - ledgerTotalOut - ledgerTotalFees).toFixed(2))}
+
+
+
+
+ Ledger Entries
+
+
+
+
+
+ | Date |
+ Provider |
+ Opening |
+ Inflow |
+ Outflow |
+ Fees |
+ Closing |
+ Txns |
+
+
+
+ {ledgers?.map((l: any) => (
+
+ | {formatDate(l.ledgerDate)} |
+
+
+ {providerIcons[l.provider] || l.provider || "M-PESA"}
+
+ |
+ {formatKES(l.openingBalance || "0")} |
+ {formatKES(l.totalInflow || l.totalTopups || "0")} |
+ {formatKES(l.totalOutflow || l.totalExpenditures || "0")} |
+ {formatKES(l.totalFees || "0")} |
+ {formatKES(l.closingBalance || "0")} |
+ {l.transactionCount ?? 0} |
+
+ ))}
+ {(!ledgers || ledgers.length === 0) && (
+ |
+
+ No ledger entries found
+ |
+ )}
+
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/src/pages/WalletAdmin.tsx b/src/pages/WalletAdmin.tsx
new file mode 100644
index 0000000..a087a11
--- /dev/null
+++ b/src/pages/WalletAdmin.tsx
@@ -0,0 +1,275 @@
+// ABOUTME: Admin dashboard for wallet provider monitoring, configuration, exchange rate management, and health checks.
+import { useState } from "react";
+import { Layout } from "@/components/Layout";
+import { trpc } from "@/providers/trpc";
+import { formatKES } from "@/lib/utils";
+import { formatCurrency } from "@/lib/currency";
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { toast } from "sonner";
+import { RefreshCw, Activity, Settings, TrendingUp, Smartphone, Wallet, DollarSign, AlertCircle, CheckCircle2 } from "lucide-react";
+
+const PROVIDER_COLORS: Record = {
+ mpesa: "bg-[#25B266]",
+ airtel_money: "bg-[#E30613]",
+ sasapay: "bg-[#00A651]",
+};
+
+const PROVIDER_ICONS: Record = {
+ mpesa: ,
+ airtel_money: ,
+ sasapay: ,
+};
+
+export function WalletAdmin() {
+ const [tab, setTab] = useState<"providers" | "rates" | "currencies">("providers");
+ const [rateDialogOpen, setRateDialogOpen] = useState(false);
+ const [rateForm, setRateForm] = useState({ fromCurrency: "USD", toCurrency: "KES", rate: "" });
+ const utils = trpc.useUtils();
+
+ const { data: health, refetch: refetchHealth } = trpc.walletManagement.providers.health.useQuery({});
+ const { data: providers } = trpc.walletManagement.providers.list.useQuery();
+ const { data: rates, refetch: refetchRates } = trpc.walletManagement.rates.latest.useQuery({});
+ const { data: currencies } = trpc.walletManagement.currencies.list.useQuery();
+ const createCurrency = trpc.walletManagement.currencies.create.useMutation({ onSuccess: () => currencies.refetch() });
+ const toggleCurrency = trpc.walletManagement.currencies.toggle.useMutation({ onSuccess: () => currencies.refetch() });
+
+ const syncRates = trpc.walletManagement.rates.sync.useMutation({
+ onSuccess: (res) => {
+ if (res.success) {
+ toast.success("Exchange rates synced successfully");
+ refetchRates();
+ } else {
+ toast.error(`Sync failed: ${res.error}`);
+ }
+ },
+ });
+
+ const manualUpdateRate = trpc.walletManagement.rates.manualUpdate.useMutation({
+ onSuccess: () => {
+ toast.success("Rate updated successfully");
+ refetchRates();
+ setRateDialogOpen(false);
+ setRateForm({ fromCurrency: "USD", toCurrency: "KES", rate: "" });
+ },
+ onError: (err) => { toast.error(err.message); },
+ });
+
+ const configureProvider = trpc.walletManagement.providers.configure.useMutation({
+ onSuccess: () => {
+ toast.success("Provider configured");
+ refetchHealth();
+ },
+ onError: (err) => { toast.error(err.message); },
+ });
+
+ return (
+
+
+
+
+
Wallet Management
+
Provider configuration, exchange rates, and system monitoring
+
+
refetchHealth()} className="gap-2">
+ Refresh
+
+
+
+
+ {(["providers", "rates", "currencies"] as const).map((t) => (
+ setTab(t)} className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${tab === t ? "bg-[#C73E1D] text-white" : "bg-[#F5EDE6] text-[#2D2A26] hover:bg-[#E8E0D8]"}`}>
+ {t.charAt(0).toUpperCase() + t.slice(1)}
+
+ ))}
+
+
+ {tab === "providers" && (
+ <>
+
+ {health?.map((h: any) => (
+
+
+
+
+ {PROVIDER_ICONS[h.provider] ||
}
+
+
+
{h.displayName}
+
{h.provider}
+
+
+
+
{h.features?.initiatePayment ? "API" : "SMS"}
+
+
+
+
Currencies: {(h.supportedCurrencies || []).join(", ")}
+
Last txn: {h.lastTransactionDate || "No transactions"}
+
+
+ {Object.entries(h.features || {}).filter(([, v]) => v).map(([k]) => (
+ {k}
+ ))}
+
+
+
+ ))}
+ {(!health || health.length === 0) && (
+
No providers registered
+ )}
+
+
+
+ Available Providers
+
+
+ {providers?.map((p: any) => (
+
+
+
{p.displayName || p.name} ({p.code})
+
{p.supportedCurrencies} · {p.isActive ? "Active" : "Inactive"}
+
+
+ {}} className="text-xs">Configure
+ utils.walletManagement.providers.testConnection.mutate({ provider: p.code }).catch(() => {}) } className="gap-1 text-xs">
+ Test
+
+
+
+ ))}
+ {(!providers || providers.length === 0) &&
No providers found
}
+
+
+
+ >
+ )}
+
+ {tab === "rates" && (
+ <>
+
+
Exchange Rates
+
+
syncRates.mutate()} loading={syncRates.isPending} className="gap-1 bg-[#C73E1D]">
+ Sync Rates
+
+
+
+
+
+
+
+
+
+
+ | From |
+ To |
+ Rate |
+ Source |
+ Last Updated |
+
+
+
+ {rates?.map((r: any) => (
+
+ | {r.fromCurrency} |
+ {r.toCurrency} |
+ {r.rate} |
+ {r.source || "manual"} |
+ {r.validFrom ? new Date(r.validFrom).toLocaleDateString() : "-"} |
+
+ ))}
+ {(!rates || rates.length === 0) && (
+ | No exchange rates configured |
+ )}
+
+
+
+
+ >
+ )}
+
+ {tab === "currencies" && (
+
+
+
+
+
+ | Code |
+ Name |
+ Symbol |
+ Decimals |
+ Status |
+
+
+
+ {currencies?.map((c: any) => (
+
+ | {c.code} |
+ {c.name} |
+ {c.symbol} |
+ {c.decimalPlaces} |
+
+ toggleCurrency.mutate({ code: c.code, isActive: !c.isActive })}
+ disabled={c.isDefault}
+ className={`inline-flex items-center gap-1 text-xs rounded-full px-2 py-0.5 border ${
+ c.isActive
+ ? "border-[#2E7D32] bg-[#2E7D32]/10 text-[#2E7D32]"
+ : "border-[#8D8A87] bg-transparent text-[#8D8A87]"
+ } ${c.isDefault ? "opacity-50 cursor-not-allowed" : "cursor-pointer hover:opacity-80"}`}
+ >
+ {c.isActive ? : }
+ {c.isActive ? "Active" : "Inactive"}
+
+ {c.isDefault && (default)}
+ |
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}