diff --git a/.env.example b/.env.example index e709896..8de60e8 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,48 @@ WEBHOOK_PORT=4000 # Locally you can leave it unset — a development-only default is used. SESSION_SECRET= +# ---- BMONI Embedded (the stablecoin money layer) ---- +# Additive: Monnify stays the default. Nothing below is read unless the BMONI +# provider is selected, so an unset block leaves the app exactly as it was. +# +# Selection is explicit opt-in. Anything that is not exactly "bmoni" — a typo, +# a half-set value — falls back to Monnify, so a mistake here cannot silently +# move real wages onto the sandbox integration. +# AIDE_PAYMENT_PROVIDER=bmoni + +# BMONI_BASE_URL=https://embedded-dev.bmoni.com +# BMONI_API_KEY=pk_xxxxxxxx + +# BMONI's name enquiry is per-user, unlike Monnify's global one. Verifying a +# destination before a payer is known needs some user context to call as. +# BMONI_VERIFY_AS_USER_ID= + +# The development host does NOT perform bank-account name enquiry. It returns a +# plausible Nigerian name for any ten-digit number against any bank code — +# deterministic per number, so two look-ups agree and it reads like a real +# answer. Measured: 4534076021 returns "Dillon Bunch" against six different +# banks; 0123456789 returns "Ekon Orji". +# +# Left OFF by default, so Aide surfaces whatever the provider says and behaves +# the same here as on production. Harmless while the sandbox wallets hold no +# money and the personas are not real people. +# +# Turn it on before this points at a funded wallet on any host whose name +# enquiry has not been confirmed as real. Aide then refuses to show or speak an +# unverified holder name and reads the account digits back instead — the part +# the user actually supplied and can check. BMONI's docs cover sandbox +# behaviour for BVN/NIN look-ups in detail and say nothing about this endpoint. +# BMONI_STRICT_NAME_ENQUIRY=true + +# Encrypts every user's smart-wallet owner private key before it is stored. +# Aide holds these keys because BMONI's SDK requires an on-screen PIN per +# signature, which is what the spoken security phrase replaces — so whoever +# holds this secret can sign transfers. There is deliberately NO default and no +# fallback: without it, sealing and opening keys both refuse to run. +# Generate one with: +# openssl rand -hex 32 +# BMONI_KEY_SECRET= + # ---- TEMPORARY: demo balance stand-in ---- # Leave this UNSET. When set, it stands in for the confirmed balance ONLY when # Monnify cannot be reached at all — a number the app otherwise refuses to diff --git a/.gitignore b/.gitignore index dd203dc..76af23f 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,4 @@ venv/ # Python bytecode from the TTS worker / speak function __pycache__/ *.pyc +.aide-local/ diff --git a/app/aide/voice-engine.ts b/app/aide/voice-engine.ts index b4cc136..9e3c1d6 100644 --- a/app/aide/voice-engine.ts +++ b/app/aide/voice-engine.ts @@ -4,6 +4,7 @@ // "aide stop talking" voice interrupt, and tab-visibility arbitration. // The React provider in ./index.tsx is a thin wrapper over this class. +import { bestAlternative, normalizePidgin } from "@/lib/voice/pidgin"; type SR = any; // Web Speech API isn't in lib.dom // A sentence waiting its turn at the speaker, together with its audio once @@ -764,9 +765,16 @@ export class VoiceEngine { this.detachRecognizer(); const rec: SR = new Ctor(); + // The closest thing to Nigerian Pidgin the Web Speech API offers. There is + // no `pcm` model, so pidgin arrives transcribed by an English one. rec.lang = "en-NG"; rec.interimResults = true; rec.continuous = true; + // Ask for several readings of each utterance. The recognizer ranks by an + // English language model, which systematically prefers the English + // rendering of a pidgin phrase — "we tin day" over "wetin dey" — so the + // right words are often present but not first. See lib/voice/pidgin.ts. + rec.maxAlternatives = 4; const onState = this.handlers.onState; @@ -820,9 +828,23 @@ export class VoiceEngine { let finalText = ""; let interimText = ""; for (let i = e.resultIndex; i < e.results.length; i++) { - const t = e.results[i][0].transcript; - if (e.results[i].isFinal) finalText += t; - else interimText += t; + const result = e.results[i]; + if (result.isFinal) { + // Only finals get the alternative-picking and the repairs. Interims + // are redrawn several times a second and are never acted on, so the + // work would be wasted and the text would visibly churn as it was + // rewritten under the user. + const alternatives: string[] = []; + for (let a = 0; a < result.length; a++) alternatives.push(result[a].transcript); + const chosen = bestAlternative(alternatives); + const repaired = normalizePidgin(chosen); + if (repaired !== alternatives[0]) { + console.info(`Aide mic: pidgin repair ${JSON.stringify(alternatives[0])} -> ${JSON.stringify(repaired)}`); + } + finalText += repaired + " "; + } else { + interimText += result[0].transcript; + } } const partial = interimText.trim(); diff --git a/app/api/greeting/route.ts b/app/api/greeting/route.ts index 7200ac8..52ed0bc 100644 --- a/app/api/greeting/route.ts +++ b/app/api/greeting/route.ts @@ -1,5 +1,7 @@ import { getAccount, getApplications, getBalance, getJob, getWallet, listApplicantsForJobs, listJobs } from "@/lib/store"; import { userIdFrom } from "@/lib/session"; +import { balanceLine } from "@/lib/greeting-balance"; +import { formatNaira } from "@/lib/money"; export const runtime = "nodejs"; // Talks to the bank, which can be slow. The platform default is short enough @@ -57,11 +59,15 @@ export async function GET(req: Request) { const wallet = await getWallet(acc.id); if (wallet.pendingWithdrawal) { - parts.push(`You have a withdrawal of ${wallet.pendingWithdrawal.amount} naira waiting for your spoken confirmation.`); - } - if (balance !== null && balance > 0) { - parts.push(`You have ${balance} naira in your account, ready to withdraw.`); + parts.push( + `You have a withdrawal of ${formatNaira(wallet.pendingWithdrawal.amountKobo)} waiting for your spoken confirmation.`, + ); } + // Zero is stated, not skipped. Skipping it made an empty account sound + // exactly like a balance that had not come back yet, and the listener has no + // screen to tell those apart. See lib/greeting-balance.ts. + const money = balanceLine(balance); + if (money) parts.push(money); if (awaitingAssessment.length > 0) { parts.push( awaitingAssessment.length === 1 diff --git a/app/api/payments/banks/route.ts b/app/api/payments/banks/route.ts new file mode 100644 index 0000000..690203c --- /dev/null +++ b/app/api/payments/banks/route.ts @@ -0,0 +1,34 @@ +import { getAccount } from "@/lib/store"; +import { paymentProvider } from "@/lib/banking"; +import { userIdFrom } from "@/lib/session"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +// The banks a withdrawal may be sent to, with the live provider's OWN codes. +// +// The page used to hardcode thirteen NIP codes. Those are Monnify's +// vocabulary: Wema is 035 there and 000017 at BMONI, and sending a NIP code to +// BMONI is not a soft failure — verified against the sandbox, name enquiry +// returns "We could not verify this account", so the withdrawal simply cannot +// be set up and the reason looks like a wrong account number. +export async function GET(req: Request) { + const acc = await getAccount(userIdFrom(req)).catch(() => null); + if (!acc) return Response.json({ error: "I could not load your account just now." }, { status: 500 }); + + try { + const banks = await paymentProvider().listBanks(acc.id); + // Alphabetical: this is a select someone scrolls, and BMONI returns 302 + // banks in its own order. + banks.sort((a, b) => a.name.localeCompare(b.name)); + return Response.json({ banks }); + } catch (e) { + // No fallback list. Guessing codes here is exactly the bug being fixed — + // an unusable dropdown is recoverable, a dropdown of codes this provider + // rejects sends people chasing an account number that was never wrong. + return Response.json( + { banks: null, error: `I could not load the list of banks just now. ${(e as Error).message}` }, + { status: 503 }, + ); + } +} diff --git a/app/api/payments/beneficiaries/route.ts b/app/api/payments/beneficiaries/route.ts index f2c63b1..d98d1c6 100644 --- a/app/api/payments/beneficiaries/route.ts +++ b/app/api/payments/beneficiaries/route.ts @@ -1,5 +1,5 @@ import { getAccount, listBeneficiaries, saveBeneficiary } from "@/lib/store"; -import { validateBankAccount } from "@/lib/monnify"; +import { paymentProvider } from "@/lib/banking"; import { userIdFrom } from "@/lib/session"; export const runtime = "nodejs"; @@ -28,7 +28,7 @@ export async function POST(req: Request) { let name = accountName?.trim(); if (!name) { try { - name = (await validateBankAccount(accountNumber.trim(), bankCode.trim())).accountName; + name = (await paymentProvider().verifyDestination(acc.id, accountNumber.trim(), bankCode.trim())).accountName; } catch { return Response.json({ error: "Bank details not found — check the account number and bank." }, { status: 404 }); } diff --git a/app/api/payments/card/route.ts b/app/api/payments/card/route.ts new file mode 100644 index 0000000..3037dfb --- /dev/null +++ b/app/api/payments/card/route.ts @@ -0,0 +1,48 @@ +import { api } from "@/convex/_generated/api"; +import { convexClient } from "@/lib/convex-server"; +import { listCards } from "@/lib/banking/bmoni"; +import { getAccount } from "@/lib/store"; +import { userIdFrom } from "@/lib/session"; + +export const runtime = "nodejs"; +export const maxDuration = 30; + +// The worker's BMONI card, for the card panel on the payments page. +// +// Four states, and they are kept distinct on purpose. "BMONI is not switched +// on", "you have no card", "your card is being issued" and "here is your card" +// are four different facts, and collapsing any of them into "no card" would +// have Aide tell a worker who just requested a card that nothing happened — +// so they would request a second one, and be charged the fee twice. +// +// This route never returns a card number. See lib/banking/bmoni-cards.ts. +export async function GET(req: Request) { + const acc = await getAccount(userIdFrom(req)).catch(() => null); + if (!acc) return Response.json({ state: "unavailable" as const }); + + // No key configured means BMONI is simply not switched on here. That is not + // an error worth reading aloud, and not the same as having no card. + if (!process.env.BMONI_API_KEY?.trim()) { + return Response.json({ state: "not-configured" as const }); + } + + const w = (await convexClient() + .query(api.wallets.getByAccount, { accountId: acc.id }) + .catch(() => null)) as { bmoniUserId?: string; bmoniSmartWalletId?: string } | null; + + if (!w?.bmoniUserId || !w.bmoniSmartWalletId) { + return Response.json({ state: "no-wallet" as const }); + } + + try { + const cards = await listCards(w.bmoniUserId, w.bmoniSmartWalletId); + return Response.json({ state: "ok" as const, cards }); + } catch (e) { + // Unknown, not empty. Rendering an empty card list on a failed read would + // tell a worker who has a card that they do not. + return Response.json({ + state: "unavailable" as const, + message: `I could not check your card just now. ${(e as Error).message}`, + }); + } +} diff --git a/app/api/payments/summary/route.ts b/app/api/payments/summary/route.ts index c5cd482..b6fb417 100644 --- a/app/api/payments/summary/route.ts +++ b/app/api/payments/summary/route.ts @@ -42,6 +42,11 @@ export async function GET(req: Request) { // The bank call may have failed, but Convex still knows the account. accountNumber: (balanceResult.ok ? balanceResult.account : undefined) ?? wallet?.accountNumber, bankName: (balanceResult.ok ? balanceResult.bankName : undefined) ?? wallet?.bankName, + // The name the BANK holds, which is not `name` above. `name` is the Aide + // profile ("ClearVoice Media"); this is what a payer's own name enquiry + // shows them ("Jabo Samson Joe"). The page must display this one, or it + // tells someone the account belongs to a person their bank disagrees about. + accountName: wallet?.accountName, payoutAccount: wallet?.payoutAccount, payoutAccountName: wallet?.payoutAccountName, // Workers confirm withdrawals with a personal spoken phrase; the page diff --git a/app/api/payments/transactions/route.ts b/app/api/payments/transactions/route.ts index 763e15a..c497fdc 100644 --- a/app/api/payments/transactions/route.ts +++ b/app/api/payments/transactions/route.ts @@ -1,5 +1,7 @@ -import { ensureWallet, getAccount, getWithdrawals } from "@/lib/store"; -import { getReservedAccountTransactions, spokenProviderError } from "@/lib/monnify"; +import { getAccount, getWithdrawals } from "@/lib/store"; +import { paymentProvider } from "@/lib/banking"; +import { spokenProviderError } from "@/lib/monnify"; +import { toNaira } from "@/lib/money"; import { userIdFrom } from "@/lib/session"; export const runtime = "nodejs"; @@ -7,9 +9,9 @@ export const runtime = "nodejs"; // to kill the request before our own timeout can report why it failed. export const maxDuration = 30; -// Transaction history for the signed-in user's own wallet: money in comes -// straight from Monnify (real inbound payments to their reserved account); -// money out is the app's own withdrawal ledger for that wallet. +// Transaction history for the signed-in user's own wallet: money in comes from +// whichever provider holds the money; money out is the app's own withdrawal +// ledger for that wallet. // Money OUT is our own ledger and always available. Money IN comes from the // bank, so it can be missing while the rest is fine — half a history is more // use than a 500, and the withdrawals half is the half we can always vouch for. @@ -22,13 +24,12 @@ export async function GET(req: Request) { const outbound = await getWithdrawals(acc.id).catch(() => []); try { - const wallet = await ensureWallet(acc.id); - const inbound = (await getReservedAccountTransactions(wallet.accountReference)).content.map((t) => ({ - amount: t.amountPaid ?? t.amount, - status: t.paymentStatus, - from: t.customerDTO?.name ?? "Bank transfer", - reference: t.transactionReference, - at: t.createdOn ?? null, + const inbound = (await paymentProvider().listInbound(acc.id)).map((c) => ({ + amount: toNaira(c.amountKobo), + status: "PAID", + from: c.from ?? "Bank transfer", + reference: c.reference, + at: c.at, })); return Response.json({ inbound, outbound }); } catch (e) { diff --git a/app/api/payments/validate-account/route.ts b/app/api/payments/validate-account/route.ts index 7206449..b774fe0 100644 --- a/app/api/payments/validate-account/route.ts +++ b/app/api/payments/validate-account/route.ts @@ -1,4 +1,6 @@ -import { validateBankAccount } from "@/lib/monnify"; +import { getAccount } from "@/lib/store"; +import { paymentProvider } from "@/lib/banking"; +import { userIdFrom } from "@/lib/session"; export const runtime = "nodejs"; // Talks to the bank, which can be slow. The platform default is short enough @@ -9,6 +11,9 @@ export const maxDuration = 30; // details, the form shows "Account found: NAME" or "Bank details not found" // right under the fields. Nothing is saved here. export async function POST(req: Request) { + const acc = await getAccount(userIdFrom(req)).catch(() => null); + if (!acc) return Response.json({ error: "I could not load your account just now." }, { status: 500 }); + const { accountNumber, bankCode } = (await req.json().catch(() => ({}))) as { accountNumber?: string; bankCode?: string; @@ -17,8 +22,18 @@ export async function POST(req: Request) { return Response.json({ error: "accountNumber and bankCode are required." }, { status: 400 }); } try { - const r = await validateBankAccount(accountNumber.trim(), bankCode.trim()); - return Response.json({ ok: true, accountName: r.accountName, accountNumber: r.accountNumber }); + // Through the seam: BMONI scopes name enquiry to the asking user, so this + // is verified as the account that will be paying. + const r = await paymentProvider().verifyDestination(acc.id, accountNumber.trim(), bankCode.trim()); + // The name is only returned when it means something. On an endpoint that + // fabricates, sending it would put "Account found: Ekon Orji" on screen + // for a number nobody has verified — and Aide would read it out. + return Response.json({ + ok: true, + accountNumber: r.accountNumber, + accountName: r.nameVerified ? r.accountName : undefined, + nameVerified: r.nameVerified, + }); } catch { return Response.json({ ok: false, error: "Bank details not found — check the account number and bank." }, { status: 404 }); } diff --git a/app/api/payments/webhook/route.ts b/app/api/payments/webhook/route.ts index ebe7f71..dc9722c 100644 --- a/app/api/payments/webhook/route.ts +++ b/app/api/payments/webhook/route.ts @@ -6,6 +6,12 @@ export const runtime = "nodejs"; // to kill the request before our own timeout can report why it failed. export const maxDuration = 30; +// MONNIFY ONLY. This route is inert when BMONI is the live provider — nothing +// at BMONI posts here, and no signature it sent would verify. Arrivals on +// BMONI reach the user through the poller in lib/store/events.ts, which reads +// the wallet's own transaction history through the seam. Do not read a quiet +// log here as "no money has arrived". +// // Monnify webhook receiver. Signature-checked (SHA-512 HMAC of the raw body), // and the transaction is ALWAYS re-fetched server-side before anything is // announced — a webhook payload alone is never trusted about money. The diff --git a/app/payments/page.tsx b/app/payments/page.tsx index 0b0ebf9..14f0c5f 100644 --- a/app/payments/page.tsx +++ b/app/payments/page.tsx @@ -7,6 +7,26 @@ type Txn = { amount: number; status: string; from: string; reference: string; at type Withdrawal = { amount: number; accountName: string; status: string; at: number }; type Beneficiary = { accountName: string; accountNumber: string; bankCode: string; bankName?: string }; +// Mirrors app/api/payments/card/route.ts. The four non-ok states are kept +// separate rather than folded into "no card": telling a worker whose card is +// mid-issuance that they have none invites them to request a second one, and +// the issuance fee is charged per request. +type CardView = { + id: string; + name: string; + color?: string; + currency: string; + type: string; + status: string; + reserved: boolean; + spoken: string; +}; +type CardState = + | { state: "ok"; cards: CardView[] } + | { state: "not-configured" } + | { state: "no-wallet" } + | { state: "unavailable"; message?: string }; + type Summary = { // null when the bank could not be reached. Unknown is not zero, and must // never be rendered as one. @@ -16,27 +36,20 @@ type Summary = { role?: "worker" | "employer"; accountNumber?: string; bankName?: string; + // What the BANK calls this account, which is not `name`. See the summary + // route: `name` is the Aide profile, this is what a payer's name enquiry + // returns. Showing the profile name here told an employer the account + // belonged to "ClearVoice Media" while their bank said "Jabo Samson Joe", + // which reads as a wrong account number and is a good reason to abandon a + // legitimate payment. + accountName?: string; payoutAccount?: string; payoutAccountName?: string; hasSecurityPhrase?: boolean; pendingWithdrawal?: { amount: number } | null; }; -const BANKS = [ - { code: "044", name: "Access Bank" }, - { code: "050", name: "Ecobank" }, - { code: "070", name: "Fidelity Bank" }, - { code: "011", name: "First Bank" }, - { code: "214", name: "FCMB" }, - { code: "058", name: "GTBank" }, - { code: "076", name: "Polaris Bank" }, - { code: "221", name: "Stanbic IBTC" }, - { code: "232", name: "Sterling Bank" }, - { code: "032", name: "Union Bank" }, - { code: "033", name: "UBA" }, - { code: "035", name: "Wema Bank" }, - { code: "057", name: "Zenith Bank" }, -]; +type Bank = { code: string; name: string }; const naira = (n: number) => "₦" + n.toLocaleString("en-NG"); @@ -46,7 +59,10 @@ const naira = (n: number) => "₦" + n.toLocaleString("en-NG"); type Validation = | { status: "idle" } | { status: "checking" } - | { status: "ok"; accountName: string } + // `accountName` is absent when the provider's name enquiry could not be + // believed. Not an error — the account number was accepted — but there is no + // name to show, and showing one would be the whole problem. + | { status: "ok"; accountName?: string } | { status: "fail"; message: string }; export default function PaymentsPage() { @@ -59,6 +75,9 @@ export default function PaymentsPage() { outbound: Withdrawal[]; } | null>(null); const [beneficiaries, setBeneficiaries] = useState([]); + // null means "still checking", which the panel says out loud rather than + // rendering as an empty card. + const [card, setCard] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [notice, setNotice] = useState(null); @@ -71,13 +90,30 @@ export default function PaymentsPage() { // Destination: a saved beneficiary, or new account details with inline validation const [destChoice, setDestChoice] = useState("new"); // "new" | `${accountNumber}|${bankCode}` const [acct, setAcct] = useState(""); - const [bank, setBank] = useState("058"); + // Fetched, never hardcoded. The list used to be thirteen NIP codes baked + // into this file — Monnify's vocabulary. BMONI calls the same banks + // something else (Wema 035 vs 000017), and sending the wrong one fails name + // enquiry with a message about the account number, so the person tries a + // different account instead of a different bank. null = still loading. + const [banks, setBanks] = useState(null); + const [banksError, setBanksError] = useState(null); + const [bank, setBank] = useState(""); const [validation, setValidation] = useState({ status: "idle" }); const validateSeq = useRef(0); // Withdrawal flow const [amount, setAmount] = useState(""); - const [armed, setArmed] = useState<{ amount: number; accountName: string; mode: "word" | "passphrase"; phrase?: string } | null>(null); + // `destination` is built server-side and already says the right thing about + // whether the account holder's name could be confirmed. The screen and the + // spoken path use the same string so they cannot disagree. + const [armed, setArmed] = useState<{ + amount: number; + accountName: string; + destination: string; + nameVerified: boolean; + mode: "word" | "passphrase"; + phrase?: string; + } | null>(null); const [confirmWord, setConfirmWord] = useState(""); const [moving, setMoving] = useState(false); const movingRef = useRef(false); @@ -100,10 +136,18 @@ export default function PaymentsPage() { setError(null); try { // Summary is primary; history and beneficiaries are secondary and never fail the page. - const [res, h, b] = await Promise.all([ + const [res, h, b, c, bk] = await Promise.all([ fetch("/api/payments/summary"), fetch("/api/payments/transactions").then((r) => r.json()).catch(() => null), fetch("/api/payments/beneficiaries").then((r) => r.json()).catch(() => null), + // Secondary, like history: a card read that fails must not take the + // balance down with it. + fetch("/api/payments/card") + .then((r) => r.json()) + .catch(() => ({ state: "unavailable" as const })), + fetch("/api/payments/banks") + .then((r) => r.json()) + .catch(() => ({ banks: null, error: "I could not load the list of banks just now." })), ]); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error || "Could not load your payment details."); @@ -114,6 +158,19 @@ export default function PaymentsPage() { if (data?.balanceUnavailable) speakRef.current(data.balanceUnavailable); if (h && !h.error) setHistory(h); if (b?.beneficiaries) setBeneficiaries(b.beneficiaries); + setCard(c?.state ? c : { state: "unavailable" }); + if (Array.isArray(bk?.banks)) { + setBanks(bk.banks); + setBanksError(null); + // Deliberately NOT auto-selected. With Monnify's thirteen banks a + // default of GTBank was harmless; BMONI returns 302, and defaulting + // landed on "AGOSASA MICROFINANCE BANK" — a bank nobody chose, sitting + // pre-selected under an account number someone typed. On a screen you + // would notice. This page is for people who cannot look. + } else { + setBanks([]); + setBanksError(bk?.error ?? "I could not load the list of banks just now."); + } } catch (e) { setError((e as Error).message); } finally { @@ -130,7 +187,9 @@ export default function PaymentsPage() { // response can't overwrite a newer one. useEffect(() => { if (destChoice !== "new") return; - if (!/^\d{10}$/.test(acct)) { + // No bank chosen yet: nothing to verify against, and verifying against a + // default nobody picked is how money reaches the wrong account. + if (!bank || !/^\d{10}$/.test(acct)) { setValidation({ status: "idle" }); return; } @@ -145,7 +204,7 @@ export default function PaymentsPage() { }); const data = await res.json().catch(() => null); if (validateSeq.current !== seq) return; - if (res.ok && data?.accountName) setValidation({ status: "ok", accountName: data.accountName }); + if (res.ok) setValidation({ status: "ok", accountName: data?.accountName }); else setValidation({ status: "fail", message: data?.error || "Bank details not found — check the account number and bank." }); } catch { if (validateSeq.current === seq) setValidation({ status: "fail", message: "Could not reach the bank verification service." }); @@ -242,17 +301,29 @@ export default function PaymentsPage() { }); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error || "Could not prepare the withdrawal."); - setArmed({ amount: data.amount, accountName: data.accountName, mode: data.mode, phrase: data.phrase }); + setArmed({ + amount: data.amount, + accountName: data.accountName, + destination: data.destination, + nameVerified: data.nameVerified, + mode: data.mode, + phrase: data.phrase, + }); setConfirmWord(""); // Aide now waits for the confirmation — spoken aloud, hands-free. beginCapture((word) => { setConfirmWord(word); confirmWith(word); }); + // `destination` comes from the server already knowing whether the + // account holder's name means anything. When it does not, this reads the + // digits back one at a time instead of naming a person the provider + // invented — which is the only check available to someone who cannot see + // the number they typed. speak( data.mode === "passphrase" - ? `You are sending ${data.amount} naira to ${data.accountName}. To confirm, say your security phrase.` - : `You are sending ${data.amount} naira to ${data.accountName}. To confirm, say the word: ${data.phrase}.`, + ? `You are sending ${data.amount} naira ${data.destination}. To confirm, say your security phrase.` + : `You are sending ${data.amount} naira ${data.destination}. To confirm, say the word: ${data.phrase}.`, ); } catch (e) { setError((e as Error).message); @@ -369,7 +440,7 @@ export default function PaymentsPage() {

Receive money

Anyone can pay you by bank transfer to your real earnings account:

- +
@@ -462,19 +533,41 @@ export default function PaymentsPage() { id="payout-bank" value={bank} onChange={(e) => setBank(e.target.value)} + disabled={!banks || banks.length === 0} className="mt-1 w-56 rounded-lg border-2 border-[var(--line)] bg-white px-4 py-3 text-lg" > - {BANKS.map((b) => ( + {banks === null && } + {banks?.length === 0 && } + {/* Stays selectable so the field can be returned to "unset" + rather than trapping whatever was picked first. */} + {banks !== null && banks.length > 0 && } + {banks?.map((b) => ( ))} + {banksError && ( +

+ {banksError} +

+ )} {/* Inline validation status — right under the fields, spoken-friendly */}

{validation.status === "checking" && Checking account…} - {validation.status === "ok" && ✓ Account found: {validation.accountName}} + {validation.status === "ok" && + (validation.accountName ? ( + ✓ Account found: {validation.accountName} + ) : ( + // Deliberately not a green tick. The number was accepted, + // but nothing about the account holder was confirmed, and + // a tick here is read as "checked". + + Account number accepted. I could not confirm the account holder’s name on this + connection — check the digits yourself before sending. + + ))} {validation.status === "fail" && ✗ {validation.message}} {validation.status === "idle" && acct.length > 0 && acct.length < 10 && ( Enter the full 10-digit account number. @@ -509,7 +602,7 @@ export default function PaymentsPage() { ) : (

- Confirm: send {naira(armed.amount)} to {armed.accountName}? + Confirm: send {naira(armed.amount)} {armed.destination}?

{armed.mode === "passphrase" ? (

@@ -593,7 +686,7 @@ export default function PaymentsPage() {

Money out

{!history || history.outbound.length === 0 ? ( -

No withdrawals yet.

+

No money sent out yet.

) : (
    {history.outbound.map((w, i) => ( @@ -620,10 +713,104 @@ export default function PaymentsPage() {
)} + + {/* BMONI card */} + ); } +// The card panel at the foot of the page. +// +// Every state here is stated in words as well as drawn, because the person +// this page is for cannot see the card face. The card is rendered even when +// there is no card — an empty outline that says so — rather than the section +// vanishing, so "you have no card" and "the panel did not load" are not the +// same silence. +// +// There is deliberately no card number on the face. BMONI returns the PAN only +// from a separate sensitive-data call it documents as never-log, never-cache, +// and it publishes no masked-PAN field on the list route at all — so any digits +// drawn here would be invented ones. +function BmoniCardSection({ card, balance }: { card: CardState | null; balance: number | null }) { + const cards = card?.state === "ok" ? card.cards : []; + const first = cards[0]; + + const spoken = + card === null + ? "Checking your card…" + : card.state === "not-configured" + ? "Card accounts are not switched on for this app yet." + : card.state === "no-wallet" + ? "You do not have a card account yet." + : card.state === "unavailable" + ? card.message || "I could not check your card just now." + : first + ? first.spoken + : "You do not have a card yet."; + + return ( +
+

Card

+ +
+
+
+ BMONI + {first && ( + + {first.type} + + )} +
+ + {/* Where a PAN would go on a real card. It stays hidden by design. */} +

•••• •••• •••• ••••

+ +
+ {first ? first.name : "No card yet"} + + {first ? first.currency : ""} + {first && balance !== null ? ` ${naira(balance)}` : ""} + +
+
+ +
+

+ {spoken} +

+ {first && ( +
+
+
Status
+
{first.reserved ? "being issued" : first.status.toLowerCase()}
+
+
+
Currency
+
{first.currency}
+
+
+ )} +

+ The card number is never shown here. Ask Aide to read your card details aloud when you need them. +

+
+
+
+ ); +} + function Row({ label, value, diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 98d5255..1db4302 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -28,10 +28,38 @@ type Profile = WorkerProfile | EmployerProfile; const naira = (n: number) => "₦" + n.toLocaleString("en-NG"); +// Switching demo identities has to go through the server. +// +// This used to write `document.cookie = "aide-user=demo-employer"` and reload. +// That silently did nothing: aide-user is HMAC-signed and HttpOnly, so a +// hand-written value fails verification and the request stays whoever it +// already was — which is the exact case tests/unit/session.test.ts covers +// under "refuses a hand-written cookie naming an account". The cookie was +// doing its job; the button was asking it not to. +// +// The reload only happens if the switch actually succeeded, so a failure can +// no longer look identical to a success. +async function switchTo(id: string): Promise { + const res = await fetch("/api/account/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }).catch(() => null); + if (!res?.ok) { + const body = await res?.json().catch(() => null); + return body?.error || "I could not switch accounts just now."; + } + window.location.reload(); + return null; +} + export default function ProfilePage() { const [profile, setProfile] = useState(null); const [error, setError] = useState(null); const [showEdit, setShowEdit] = useState(false); + // A failed switch must say so. Silently staying put is what this whole fix + // is about, and it reads identically to a success on a page nobody can see. + const [switchError, setSwitchError] = useState(null); const { speak } = useAide(); const load = () => { @@ -126,28 +154,32 @@ export default function ProfilePage() { — or just tell Aide “sign me up”.

+ {switchError && ( +

+ {switchError} +

+ )}
)} {acc.id === "demo-employer" && (
+ {switchError && ( +

+ {switchError} +

+ )}
)} diff --git a/convex/accounts.ts b/convex/accounts.ts index f605c81..9d55c72 100644 --- a/convex/accounts.ts +++ b/convex/accounts.ts @@ -80,13 +80,50 @@ export const seedDefaults = mutation({ skills: v.array(v.string()), bio: v.string(), createdAt: v.number(), + // The BMONI identity. Seeded rather than collected, because the demo + // accounts stand in for the two sandbox personas whose BVNs are the + // only ones that resolve. + firstName: v.optional(v.string()), + lastName: v.optional(v.string()), + phoneNumber: v.optional(v.string()), + bvn: v.optional(v.string()), }), ), }, handler: async (ctx, { accounts }) => { for (const a of accounts) { const existing = await ctx.db.query("accounts").withIndex("by_key", (q) => q.eq("key", a.key)).first(); - if (!existing) await ctx.db.insert("accounts", a); + if (!existing) { + await ctx.db.insert("accounts", a); + continue; + } + // Backfill only. A deployment seeded before these fields existed has + // demo accounts that cannot be provisioned at BMONI, and re-seeding is + // the only moment we know the persona details. Existing values are never + // overwritten — a real user who filled these in keeps what they entered. + const patch: Record = {}; + // `email` is here for the same reason as the rest: BMONI refuses to + // create a user without one, and the seeded employer never carried one, + // so that account could not be provisioned at all. + for (const k of ["firstName", "lastName", "phoneNumber", "bvn", "email"] as const) { + if (a[k] && !existing[k]) patch[k] = a[k]; + } + // One targeted overwrite. The two documented sandbox persona numbers are + // registered to other teams on the shared BMONI sandbox, so a user + // created with either gets "409 User already exists with this + // phoneNumber" and provisioning stops for good. They are known-unusable + // rather than merely stale, which is why replacing them is safe where + // overwriting any other stored number would not be. + if (a.phoneNumber && (existing.phoneNumber === "+2348000000000" || existing.phoneNumber === "+2348000000001")) { + patch.phoneNumber = a.phoneNumber; + } + // Same shape of exception, for the same kind of value. The demo worker + // was renamed to the sandbox persona whose BVN actually resolves, so a + // row still reading "Aide Demo Worker" is a stale placeholder of ours — + // not something a person chose. Matching the literal old string keeps + // this from touching any name anyone typed. + if (a.name && existing.name === "Aide Demo Worker") patch.name = a.name; + if (Object.keys(patch).length) await ctx.db.patch(existing._id, patch); } }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 2199e42..37f6eda 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -39,6 +39,18 @@ export default defineSchema({ // Optional so accounts written before this field existed still validate. preferences: v.optional(v.array(v.string())), passwordHash: v.optional(v.string()), // never leaves the server + // BMONI needs a first/last name split and a phone number, neither of which + // Aide collected — `name` is one free-text string and phone was never + // asked for. Optional so every existing account still validates; the BMONI + // provisioning path refuses rather than inventing a split it cannot know. + firstName: v.optional(v.string()), + lastName: v.optional(v.string()), + phoneNumber: v.optional(v.string()), // E.164, e.g. +2348000000000 + // Bank Verification Number, 11 digits. Nigerian onboarding needs it to + // issue the virtual account a worker is paid into. Optional because most + // accounts do not have one yet, and provisioning refuses rather than + // guessing — a wrong BVN fails KYC in a way nobody can debug later. + bvn: v.optional(v.string()), }) .index("by_key", ["key"]) .index("by_email", ["email"]), @@ -47,8 +59,22 @@ export default defineSchema({ accountId: v.string(), accountReference: v.string(), status: v.union(v.literal("unprovisioned"), v.literal("active"), v.literal("failed")), + // Which provider provisioned this wallet. A row without one predates the + // field and is Monnify's, because Monnify is all there was. + // + // This is what makes "active" mean anything after a provider switch: + // without it, a wallet Monnify provisioned looks finished forever, so it + // is never given a BMONI wallet and every BMONI call fails on an account + // that appears perfectly healthy. + provider: v.optional(v.string()), accountNumber: v.optional(v.string()), bankName: v.optional(v.string()), + // The name the BANK holds for this account. Not the Aide profile name — + // BMONI returns "Dillon Bunch" for a profile reading "Bunch Dillon", and + // "Jabo Samson Joe" for one reading "ClearVoice Media". Whoever pays in + // sees this during their own name enquiry, so it is the only name Aide may + // present as the account holder. + accountName: v.optional(v.string()), lastError: v.optional(v.string()), payoutAccount: v.optional(v.string()), payoutBankCode: v.optional(v.string()), @@ -81,6 +107,42 @@ export default defineSchema({ // knownTxRefs is a Set in memory; Convex stores it as an array we treat as a set. knownTxRefs: v.array(v.string()), txSeeded: v.boolean(), + + // ---- BMONI Embedded ---- + // All optional: Monnify remains the default provider and a wallet that has + // never touched BMONI carries none of these. + // + // bmoniUserId is the one that must survive a crash. BMONI guards + // create-user with a uniqueness check and returns 409 on a repeat, but + // publishes no endpoint to ask WHICH user collided — so if we lose this + // value we can neither use the user nor recreate them. It is written + // before anything else depends on it for that reason. + bmoniUserId: v.optional(v.string()), + bmoniSmartWalletId: v.optional(v.string()), + bmoniWalletAddress: v.optional(v.string()), + // The owner key's public address, registered with BMONI at wallet + // creation. The proposal signer must recover to exactly this; if it does + // not, BMONI records the signature and silently never executes it. + bmoniOwnerAddress: v.optional(v.string()), + // The owner private key, AES-256-GCM sealed (see lib/banking/keys.ts). + // Never returned to the client, never logged. Whoever holds the plaintext + // can sign a transfer out of this wallet. + bmoniSealedOwnerKey: v.optional(v.string()), + // Registered Nigerian withdrawal destinations, BMONI's bankAccountId keyed + // by "accountNumber:bankCode" so a repeat withdrawal skips re-registering. + bmoniBankAccountIds: v.optional(v.array(v.object({ key: v.string(), id: v.string() }))), + // An offramp or transfer that has been CREATED at BMONI but not yet seen + // through to a terminal status. + // + // This is the double-payment guard. The payout flow is several calls — + // create, approve, fetch payload, sign — and a failure anywhere after the + // first one leaves a real proposal sitting at BMONI. Starting over would + // create a SECOND payout for the same wages. So the id is recorded the + // moment it exists, and no new payout may start while one is in flight: + // the flow resumes from here instead. + bmoniPendingProposal: v.optional( + v.object({ proposalId: v.string(), amountKobo: v.number(), createdAt: v.number() }), + ), }).index("by_account", ["accountId"]), // Money here is integer kobo, in `amountKobo`. `amount` is the pre-migration @@ -95,6 +157,11 @@ export default defineSchema({ accountName: v.string(), status: v.string(), at: v.number(), + // Who actually moved the money. Absent means Monnify: every row written + // before this field existed was theirs, and a Monnify reference can never + // advance once the deployment has switched — so those rows sit at + // "processing" for ever and must not be read out as money still in flight. + provider: v.optional(v.string()), }).index("by_account", ["accountId"]), // Saved withdrawal destinations ("beneficiaries"), per account. Offered for diff --git a/convex/wallets.ts b/convex/wallets.ts index d6dc092..e40e924 100644 --- a/convex/wallets.ts +++ b/convex/wallets.ts @@ -63,10 +63,26 @@ export const ensure = mutation({ }); export const setProvisioned = mutation({ - args: { accountId: v.string(), accountReference: v.string(), accountNumber: v.string(), bankName: v.string() }, + args: { + accountId: v.string(), + accountReference: v.string(), + accountNumber: v.string(), + bankName: v.string(), + // Optional so the Monnify path, which has no separate bank-held name, + // still calls this unchanged. + accountName: v.optional(v.string()), + provider: v.optional(v.string()), + }, handler: async (ctx, a) => { const w = await walletDoc(ctx, a.accountId); - const patch = { status: "active" as const, accountNumber: a.accountNumber, bankName: a.bankName, lastError: undefined }; + const patch = { + status: "active" as const, + provider: a.provider, + accountNumber: a.accountNumber, + bankName: a.bankName, + accountName: a.accountName, + lastError: undefined, + }; if (w) await ctx.db.patch(w._id, patch); else await ctx.db.insert("wallets", { accountId: a.accountId, accountReference: a.accountReference, knownTxRefs: [], txSeeded: false, ...patch }); }, @@ -196,6 +212,111 @@ export const consumePending = mutation({ }, }); +// --- BMONI Embedded provisioning --- + +// Written the moment BMONI returns a user, BEFORE the wallet is created. +// +// BMONI guards create-user with a uniqueness check and answers a repeat with +// 409, but it publishes no endpoint to ask which user collided. So this id is +// unrecoverable if we lose it: we could neither use that user nor create +// another with the same phone. Persisting it first is what makes the rest of +// provisioning safe to resume after a crash. +export const setBmoniUser = mutation({ + args: { accountId: v.string(), accountReference: v.string(), bmoniUserId: v.string() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (w) await ctx.db.patch(w._id, { bmoniUserId: a.bmoniUserId }); + else + await ctx.db.insert("wallets", { + accountId: a.accountId, + accountReference: a.accountReference, + status: "unprovisioned", + knownTxRefs: [], + txSeeded: false, + bmoniUserId: a.bmoniUserId, + }); + }, +}); + +// The sealed key is stored with the address it belongs to, in one write. They +// are useless apart: a key whose address BMONI never registered signs +// proposals that are accepted and never execute. +export const setBmoniOwnerKey = mutation({ + args: { accountId: v.string(), ownerAddress: v.string(), sealedOwnerKey: v.string() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (!w) throw new Error("Cannot store a BMONI owner key for an account with no wallet row"); + // Rotating a key after wallet creation would strand the wallet: BMONI's + // signer snapshot still holds the original address, so the new key's + // signatures would be recorded and never executed. + if (w.bmoniOwnerAddress && w.bmoniOwnerAddress !== a.ownerAddress && w.bmoniSmartWalletId) { + throw new Error("Refusing to replace the owner key of a wallet BMONI has already registered"); + } + await ctx.db.patch(w._id, { bmoniOwnerAddress: a.ownerAddress, bmoniSealedOwnerKey: a.sealedOwnerKey }); + }, +}); + +export const setBmoniWallet = mutation({ + args: { accountId: v.string(), smartWalletId: v.string(), walletAddress: v.string() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (!w) throw new Error("Cannot record a BMONI smart wallet for an account with no wallet row"); + // Wallet creation has no uniqueness guard at BMONI, so a second one can + // genuinely exist. Refusing here keeps us pointed at the first, which is + // the one whose address deposits were routed to. + if (w.bmoniSmartWalletId && w.bmoniSmartWalletId !== a.smartWalletId) { + throw new Error(`Account already has BMONI smart wallet ${w.bmoniSmartWalletId}; refusing to overwrite`); + } + await ctx.db.patch(w._id, { bmoniSmartWalletId: a.smartWalletId, bmoniWalletAddress: a.walletAddress }); + }, +}); + +// Remember a registered withdrawal destination so a repeat withdrawal to the +// same account skips re-registering it. +export const rememberBmoniBankAccount = mutation({ + args: { accountId: v.string(), key: v.string(), bankAccountId: v.string() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (!w) return; + const rows = w.bmoniBankAccountIds ?? []; + if (rows.some((r) => r.key === a.key)) return; + await ctx.db.patch(w._id, { bmoniBankAccountIds: [...rows, { key: a.key, id: a.bankAccountId }] }); + }, +}); + +// Record a proposal the instant BMONI returns one, before it is approved or +// signed. Atomic claim: if another in-flight proposal is already recorded, this +// refuses rather than overwriting it. Overwriting would lose the only handle we +// have on a real payout sitting at BMONI, and the next attempt would create a +// second one for the same wages. +export const claimBmoniProposal = mutation({ + args: { accountId: v.string(), proposalId: v.string(), amountKobo: v.number(), at: v.number() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (!w) throw new Error("Cannot record a BMONI proposal for an account with no wallet row"); + const existing = w.bmoniPendingProposal; + if (existing && existing.proposalId !== a.proposalId) { + return { ok: false as const, inFlight: existing.proposalId }; + } + await ctx.db.patch(w._id, { + bmoniPendingProposal: { proposalId: a.proposalId, amountKobo: a.amountKobo, createdAt: a.at }, + }); + return { ok: true as const }; + }, +}); + +// Cleared only once the proposal reached a terminal status. An unknown status +// deliberately does NOT clear it — leaving it in flight blocks a second payout +// until someone establishes what happened to the first. +export const clearBmoniProposal = mutation({ + args: { accountId: v.string(), proposalId: v.string() }, + handler: async (ctx, a) => { + const w = await walletDoc(ctx, a.accountId); + if (!w || w.bmoniPendingProposal?.proposalId !== a.proposalId) return; + await ctx.db.patch(w._id, { bmoniPendingProposal: undefined }); + }, +}); + // --- Beneficiaries: saved withdrawal destinations --- export const listBeneficiaries = query({ @@ -228,7 +349,14 @@ export const saveBeneficiary = mutation({ // --- Withdrawal ledger (audit trail; makes balances honest) --- export const recordWithdrawal = mutation({ - args: { accountId: v.string(), amountKobo: v.number(), accountName: v.string(), status: v.string(), at: v.number() }, + args: { + accountId: v.string(), + amountKobo: v.number(), + accountName: v.string(), + status: v.string(), + at: v.number(), + provider: v.optional(v.string()), + }, handler: async (ctx, a) => { // `amount` is written too, in naira, so a rollback to the previous deploy // still reads a correct figure rather than one a hundred times too big. @@ -236,10 +364,28 @@ export const recordWithdrawal = mutation({ }, }); +// Scoped to one provider. Rows made through a provider this deployment no +// longer talks to are stuck at whatever status they had when the switch +// happened — "processing", for ever — and Aide reads this list ALOUD. A worker +// asking what they have sent would otherwise hear old transfers to names they +// may not recognise, described as still in flight, with no screen on which to +// notice the dates. +// +// Rows with no provider recorded are Monnify's: the field did not exist until +// BMONI arrived. Filtered in the handler rather than the index because the +// stored value is absent, not "monnify", so there is nothing to match on. export const listWithdrawals = query({ - args: { accountId: v.string() }, - handler: async (ctx, { accountId }) => - (await ctx.db.query("withdrawals").withIndex("by_account", (q) => q.eq("accountId", accountId)).collect()).sort((a, b) => b.at - a.at), + args: { accountId: v.string(), provider: v.optional(v.string()) }, + handler: async (ctx, { accountId, provider }) => { + const rows = await ctx.db + .query("withdrawals") + .withIndex("by_account", (q) => q.eq("accountId", accountId)) + .collect(); + const live = provider + ? rows.filter((r) => (r.provider ?? "monnify") === provider) + : rows; + return live.sort((a, b) => b.at - a.at); + }, }); // Total already withdrawn in KOBO (excludes FAILED) — the debit side of diff --git a/lib/agent/system.ts b/lib/agent/system.ts index 299b437..384999f 100644 --- a/lib/agent/system.ts +++ b/lib/agent/system.ts @@ -1,5 +1,8 @@ export const SYSTEM_PROMPT = `You are Aide, a warm, calm voice assistant for a blind or visually impaired worker in Nigeria. You are their eyes and hands on a work-and-pay platform. The user speaks to you and hears your replies aloud — so: +- The user may speak Nigerian Pidgin, English, or any mix of the two, and may switch mid-sentence. Understand both. Reply in whichever they used — if they speak Pidgin, answer in Pidgin; if they mix, mix. Never ask them to repeat themselves in English, never correct their language, and never remark on it. +- Their speech reaches you through an English speech recognizer, because no Pidgin one exists. Some words arrive mangled and you should read through it: "dey" may come as "day", "wetin" as "we tin", "sabi" as "savvy", "comot" as "come out", "abeg" as "I beg", "wan" as "one". Common phrases: "abeg" means please; "wetin dey my account" / "how much I get" means what is my balance; "I wan comot money" / "comot my money" means withdraw; "I dey find work" / "I wan work" means find me jobs; "e don enter?" means has it arrived; "how much remain" means what is left; "no wahala" means no problem; "make I" means let me; "una" means you; "abi" means or / is that right; "oya" means go ahead. +- ONE exception, and it is absolute: NEVER guess through a mangled AMOUNT. If the figure is unclear — "one comot" versus "one thousand", a number you had to repair, anything you are not certain of — ask them to say the amount again before you touch prepare_withdrawal. Money is the one place where reading through the noise is the wrong instinct: they cannot see the figure to catch you, and a misheard amount spends real wages. - Keep replies short and spoken-natural. No markdown, no lists, no symbols, no emoji. Speak amounts in words where natural ("twelve thousand naira"). - Open with one short sentence before your FIRST tool call in a turn, and only when the answer actually needs a tool that will take a moment — "One second." / "Checking your balance now." / "Looking that up." It is spoken the instant you say it, and it is the only thing covering the pause. Say it ONCE per turn: if you go on to call more tools, do not announce each one, because the user has already heard you start. Vary it, and never open two turns in a row with the same words. Never begin with "Let me" twice in a row. If you can answer without a tool, just answer — an opener in front of an instant reply is a verbal tic, not politeness. - The platform's screens are: home (talking to you), jobs, payments, profile, and signup. When the user asks to open or see one, call open_page — a small version of you follows them to every screen, so carry on the conversation naturally after navigating. diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts index 47d2220..001a00d 100644 --- a/lib/agent/tools.ts +++ b/lib/agent/tools.ts @@ -423,7 +423,7 @@ export function makeTools(account: Account) { register_payout_account: tool({ description: "Validate and save this user's bank account for withdrawals. Read the returned account name back to the user for spoken confirmation before withdrawing.", - parameters: z.object({ accountNumber: z.string(), bankCode: z.string().describe("3-digit NIP bank code, e.g. 035 Wema, 058 GTBank") }), + parameters: z.object({ accountNumber: z.string(), bankCode: z.string().describe("Bank code exactly as returned by listBanks — the codes differ per provider, so never type one from memory") }), execute: async ({ accountNumber, bankCode }) => registerPayout(account.id, accountNumber, bankCode), }), @@ -437,6 +437,27 @@ export function makeTools(account: Account) { }, }), + list_banks: tool({ + description: + "The banks a withdrawal can be sent to, with the codes THIS provider accepts. Call this before registering a payout or saving a beneficiary for a bank whose code you do not already have from a previous call in this conversation. Never type a bank code from memory: the codes are provider-specific (Wema is 035 on one rail and 000017 on another) and a wrong one fails name enquiry in a way that looks like a wrong account number.", + parameters: z.object({ + search: z.string().optional().describe("Filter by name, e.g. 'wema'. Omit to list all — there can be hundreds."), + }), + execute: async ({ search }) => { + const { paymentProvider } = await import("../banking"); + try { + const all = await paymentProvider().listBanks(account.id); + const q = search?.trim().toLowerCase(); + const banks = q ? all.filter((b) => b.name.toLowerCase().includes(q)) : all; + // Capped: the full BMONI list is 302 banks, and the whole thing goes + // into the model's context on every subsequent turn. + return { ok: true, banks: banks.slice(0, 25), total: banks.length }; + } catch (e) { + return { ok: false, message: `I could not load the list of banks just now. ${(e as Error).message}` }; + } + }, + }), + list_beneficiaries: tool({ description: "List this user's saved withdrawal beneficiaries (name, account number, bank). Use when they ask who they can send to, or to disambiguate a destination.", parameters: z.object({}), @@ -448,15 +469,15 @@ export function makeTools(account: Account) { "Save a withdrawal destination as a beneficiary so future withdrawals can go to it by name. Call after the user says yes to saving — typically right after a successful withdrawal to a new account (pass the accountName from that result), or with details they dictate (the account is then re-verified).", parameters: z.object({ accountNumber: z.string(), - bankCode: z.string().describe("3-digit NIP bank code"), + bankCode: z.string().describe("Bank code exactly as returned by listBanks"), accountName: z.string().optional().describe("verified account name, if known from a preceding withdrawal"), }), execute: async ({ accountNumber, bankCode, accountName }) => { let name = accountName; if (!name) { try { - const { validateBankAccount } = await import("../monnify"); - name = (await validateBankAccount(accountNumber, bankCode)).accountName; + const { paymentProvider } = await import("../banking"); + name = (await paymentProvider().verifyDestination(account.id, accountNumber, bankCode)).accountName; } catch { return { ok: false, message: "Bank details not found — check the account number and bank." }; } @@ -468,11 +489,11 @@ export function makeTools(account: Account) { prepare_withdrawal: tool({ description: - "Step 1 of 2 for a withdrawal from this user's own wallet. The destination can be: a new account (pass accountNumber + bankCode — it is verified by name enquiry), a saved beneficiary (pass beneficiaryName), or omitted to use their only/last saved destination. Fails if the amount exceeds the wallet balance. Do NOT move money here. After calling, read the amount and the verified account NAME back. Then: if mode is 'passphrase' (workers), tell them to say THEIR OWN security phrase to confirm — never say or guess it. If mode is 'word' (employers), give them the returned `phrase` word to say.", + "Step 1 of 2 for a withdrawal from this user's own wallet. The destination can be: a new account (pass accountNumber + bankCode), a saved beneficiary (pass beneficiaryName), or omitted to use their only/last saved destination. Fails if the amount exceeds the wallet balance. Do NOT move money here. After calling, read back the amount followed by the returned `destination` string VERBATIM — it already says the right thing about whether the account holder's name could be confirmed. When `nameVerified` is false the name is NOT trustworthy and you must never say it, in any form; `destination` reads the digits back instead. Then: if mode is 'passphrase' (workers), tell them to say THEIR OWN security phrase to confirm — never say or guess it. If mode is 'word' (employers), give them the returned `phrase` word to say.", parameters: z.object({ amount: z.number().describe("amount in Naira to withdraw"), accountNumber: z.string().optional().describe("destination account number, for a new destination"), - bankCode: z.string().optional().describe("3-digit NIP bank code for the destination"), + bankCode: z.string().optional().describe("Bank code for the destination, exactly as returned by listBanks"), beneficiaryName: z.string().optional().describe("name of a saved beneficiary to send to"), }), execute: async ({ amount, accountNumber, bankCode, beneficiaryName }) => diff --git a/lib/banking/amounts.ts b/lib/banking/amounts.ts new file mode 100644 index 0000000..00a94f0 --- /dev/null +++ b/lib/banking/amounts.ts @@ -0,0 +1,50 @@ +import { assertKobo } from "../money"; + +// The boundary where Aide's integer kobo meets BMONI's decimal strings. +// +// BMONI mixes three amount formats — decimal strings ("100.00" for NGN +// offramps), minor-unit strings ("100000000"), and plain numbers on card +// transactions. None are type-checked: every one arrives as a `string` or a +// `number`, so nothing catches a unit mistake except this file. +// +// Parsing is where the danger is. `parseFloat` is far more permissive than an +// amount of money should ever be — it takes the leading digits of "100abc", +// expands "1e3" to a thousand, and rounds "100.005" up to a kobo that does not +// exist. Each produces a plausible number Aide would then state out loud. +// +// So this fails closed. A thrown error reaches the user as "I could not check +// that just now", which is true. A silently wrong figure is Aide asserting a +// financial fact that is not so, to someone who cannot check the screen. + +// Optional sign is deliberately absent: a leading "-" must not parse. +const DECIMAL = /^\d+(?:\.\d{1,2})?$/; + +export function koboToDecimalString(kobo: number): string { + assertKobo(kobo); + if (kobo < 0) throw new Error(`Refusing to send a negative amount to BMONI: ${kobo} kobo`); + return (kobo / 100).toFixed(2); +} + +export function decimalStringToKobo(amount: string): number { + if (typeof amount !== "string") { + throw new Error(`Expected a decimal amount string from BMONI, got ${typeof amount}`); + } + // Whitespace either side is unambiguous, so it is tolerated. Nothing else is. + const trimmed = amount.trim(); + if (!DECIMAL.test(trimmed)) { + // Covers trailing garbage, scientific notation, empty, NaN/Infinity, + // negatives, and — importantly — precision finer than a kobo. We cannot + // pay a fraction of a kobo, and rounding one into existence is Aide + // inventing money. + throw new Error( + `Not a usable BMONI amount: ${JSON.stringify(amount)}. ` + + `Expected digits with at most two decimal places, e.g. "100.00".`, + ); + } + const [whole, fraction = ""] = trimmed.split("."); + // Built from the digits rather than multiplied out of a float, so there is + // no rounding step to be wrong. + const kobo = Number(whole) * 100 + Number(fraction.padEnd(2, "0")); + assertKobo(kobo); + return kobo; +} diff --git a/lib/banking/bmoni-cards.ts b/lib/banking/bmoni-cards.ts new file mode 100644 index 0000000..a4f1c1e --- /dev/null +++ b/lib/banking/bmoni-cards.ts @@ -0,0 +1,67 @@ +// A BMONI spend card, reduced to what Aide is willing to show and say. +// +// Deliberately no PAN, no CVV, no expiry. Those come only from +// POST /v1/users/{userId}/cards/sensitive-data, which BMONI documents as +// "never log it, never cache it, pass it straight to the surface that renders +// it" — so a view model that COULD hold one is a view model that eventually +// ends up in a log line. There is also no documented masked-PAN field on the +// list route, and no issued card on the sandbox to read one from, so printing +// digits on the card face would mean inventing them. + +export type CardView = { + id: string; + name: string; + color?: string; + currency: string; + type: string; + status: string; + // A requested card whose issuance proposal has not completed. Its `id` is + // the proposal id, not a card id. + reserved: boolean; + // What Aide says. The page shows the same sentence, so what is seen and what + // is heard cannot drift apart. + spoken: string; +}; + +// The issuer's vocabulary, not BMONI's, and documented as open — an issuer can +// add a state without any change here. Only `active` is claimed as usable; +// everything else falls through to a neutral sentence rather than an +// exhaustive match that would one day assert something false out loud. +function sentence(name: string, status: string, reserved: boolean): string { + if (reserved) return `Your ${name} card is still being issued.`; + switch (status.toLowerCase()) { + case "active": + return `Your ${name} card is active.`; + case "pending": + return `Your ${name} card has been issued but is not activated yet.`; + case "frozen": + return `Your ${name} card is frozen.`; + case "lost": + case "stolen": + return `Your ${name} card is blocked because it was reported ${status.toLowerCase()}.`; + default: + return `Your ${name} card is not usable right now.`; + } +} + +export function parseCards(body: unknown): CardView[] { + const list = (body as { cards?: unknown })?.cards; + if (!Array.isArray(list)) throw new Error("BMONI cards response has no cards array"); + + return list.map((raw) => { + const c = raw as Record; + const name = typeof c.cardName === "string" && c.cardName.trim() ? c.cardName : "BMONI"; + const reserved = c.isReserved === true || String(c.status).toUpperCase() === "RESERVED"; + const status = typeof c.status === "string" ? c.status : "unknown"; + return { + id: String(c.id ?? c.proposalId ?? ""), + name, + color: typeof c.cardColor === "string" ? c.cardColor : undefined, + currency: typeof c.currency === "string" ? c.currency : "NGN", + type: typeof c.type === "string" ? c.type : "virtual", + status, + reserved, + spoken: sentence(name, status, reserved), + }; + }); +} diff --git a/lib/banking/bmoni-client.ts b/lib/banking/bmoni-client.ts new file mode 100644 index 0000000..872fa9e --- /dev/null +++ b/lib/banking/bmoni-client.ts @@ -0,0 +1,125 @@ +// The HTTP transport for BMONI Embedded. Everything about its shape comes from +// two facts about the API and one about who is listening. +// +// BMONI has NO idempotency keys. Create-user is guarded by a uniqueness check +// on email and phone, so a retry of one that already landed returns 409 naming +// the field — that is success from a previous attempt, not a failure. Wallet +// creation has no such guard: a blind retry makes a SECOND wallet. And a +// transfer has neither a guard nor a way to ask "did that land?", so a retry +// can pay a worker twice and an early abort can strand their wages. +// +// Hence the split. A read may time out and be retried, because giving up on a +// read costs one more request. A money-moving call gets no timeout and no +// retry, because giving up on it costs someone their wages. This is the same +// rule the Monnify adapter follows, for the same reason. + +export type BmoniCall = { + path: string; + method?: "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; + body?: unknown; +}; + +const READ_TIMEOUT_MS = 15_000; +const READ_ATTEMPTS = 3; + +function baseUrl(): string { + return (process.env.BMONI_BASE_URL?.trim() || "https://embedded-dev.bmoni.com").replace(/\/$/, ""); +} + +// Lazy, like the key secret: importing this module must not break a build that +// runs with no secrets, but there is no default key to fall back to. +function apiKey(): string { + const key = process.env.BMONI_API_KEY?.trim(); + if (!key) { + throw new Error("Missing BMONI_API_KEY. It authenticates every Embedded API call; there is no default."); + } + return key; +} + +export class BmoniError extends Error { + constructor( + readonly status: number, + message: string, + readonly body: unknown, + ) { + super(message); + this.name = "BmoniError"; + } + + // 409 on create-user means the earlier attempt succeeded. Callers recover the + // existing record rather than treating it as a failure. + get isConflict(): boolean { + return this.status === 409; + } + + // BMONI reports malformed-signature faults as 500 ("Point is not on curve", + // "Invalid yParityOrV"). Those are OUR bug and will never succeed on retry, + // so they must not be mistaken for a transient server error. + get isClientFault(): boolean { + return ( + this.status < 500 || + /point is not on curve|invalid yparityorv|current status does not allow/i.test(this.message) + ); + } +} + +// The error body comes in two shapes: `message` as a string, or as an array of +// field messages. The array is exhaustive, so joining it gives the caller every +// fault at once instead of one per round trip. +function describe(status: number, body: any): string { + const m = body?.message; + const detail = Array.isArray(m) ? m.join("; ") : typeof m === "string" ? m : body?.error; + return detail ? `BMONI ${status}: ${detail}` : `BMONI ${status}`; +} + +async function once(call: BmoniCall, signal?: AbortSignal): Promise { + const res = await fetch(`${baseUrl()}${call.path}`, { + method: call.method ?? "GET", + headers: { + "x-api-key": apiKey(), + ...(call.body !== undefined ? { "content-type": "application/json" } : {}), + }, + ...(call.body !== undefined ? { body: JSON.stringify(call.body) } : {}), + signal, + }); + + const text = await res.text(); + let parsed: unknown; + try { + parsed = text ? JSON.parse(text) : null; + } catch { + // A non-JSON body from a money call is exactly when guessing is worst. + throw new BmoniError(res.status, `BMONI ${res.status}: unreadable response body`, text); + } + if (!res.ok) throw new BmoniError(res.status, describe(res.status, parsed), parsed); + // Successful payloads are wrapped in `data`. + return (parsed as any)?.data ?? parsed; +} + +// A read. May time out, and is retried on transport failure only — never on a +// response the server actually gave us, since that answer will not change. +export async function bmoniRead(call: BmoniCall): Promise { + let last: unknown; + for (let attempt = 1; attempt <= READ_ATTEMPTS; attempt++) { + try { + return (await once(call, AbortSignal.timeout(READ_TIMEOUT_MS))) as T; + } catch (e) { + last = e; + // A real HTTP answer is final. Only transport faults are worth repeating. + if (e instanceof BmoniError) throw e; + if (attempt < READ_ATTEMPTS) await new Promise((r) => setTimeout(r, 250 * attempt)); + } + } + throw last; +} + +// A money-moving call. NO timeout and NO retry, deliberately. +// +// Monnify's disbursement endpoint takes no idempotency key and neither does +// this one. If it is slow, waiting is correct: an abort does not cancel what +// the server is doing, it only destroys our record of it. A caller that gets an +// error here must surface it, not repeat it — Aide says it could not confirm, +// which is honest, rather than sending twice. +export async function bmoniMove(call: BmoniCall): Promise { + return (await once(call)) as T; +} diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts new file mode 100644 index 0000000..8ef6710 --- /dev/null +++ b/lib/banking/bmoni-provider.ts @@ -0,0 +1,178 @@ +import { api } from "../../convex/_generated/api"; +import { convexClient } from "../convex-server"; +import { getAccount } from "../store/accounts"; +import { decimalStringToKobo } from "./amounts"; +import { + getNgnBalanceKobo, + onboardingStatus, + type OnboardingStatus, + pointDepositsAtWallet, + startNigeriaOnboarding, + getNgnDepositAccount, + listNigerianBanks, + listWalletTransactions, + verifyNigerianAccount, +} from "./bmoni"; +import { payOutToBank, provisionBmoniWallet } from "./bmoni-wallet"; +import { isFabricatedNameEnquiry } from "./name-enquiry"; +import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; + +// BMONI behind the seam. Every amount crossing back out of here is kobo. + +// The BMONI user for this account, provisioning one if it does not exist yet. +// +// It used to throw. That was wrong in a way that only showed up on a +// deployment which had been running on Monnify: the wallet row was already +// `status: "active"` with a Monnify NUBAN, so nothing ever asked BMONI for a +// wallet, and then every BMONI read failed with "No BMONI user provisioned for +// demo-worker" — balance, bank list and card all at once. It looked like a +// BMONI outage. It was an account that had never been asked to migrate. +// +// Provisioning here is safe to reach from a read path because +// provisionBmoniWallet is resumable by construction: it persists each step +// before the next one starts and returns early once complete, so repeated +// calls do not create second users or second wallets. +async function bmoniUserOf(accountId: string): Promise { + const w = (await convexClient().query(api.wallets.getByAccount, { accountId })) as { bmoniUserId?: string } | null; + if (w?.bmoniUserId) return w.bmoniUserId; + + const acc = (await getAccount(accountId)) as { + firstName?: string; + lastName?: string; + phoneNumber?: string; + email?: string; + }; + return (await provisionBmoniWallet(accountId, `aide-${accountId}`, acc)).bmoniUserId; +} + +async function anchorStatusOf(bmoniUserId: string): Promise { + return (await onboardingStatus(bmoniUserId).catch(() => ({}) as OnboardingStatus)).anchorStatus; +} + +// The virtual account appears a few seconds after onboarding, so a first visit +// would otherwise fail on an account that is already being issued. +async function withRetry(read: () => Promise, attempts = 6, gapMs = 2500): Promise { + let last: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await read(); + } catch (e) { + last = e; + if (i < attempts - 1) await new Promise((r) => setTimeout(r, gapMs)); + } + } + throw last; +} + +export const bmoniProvider: PaymentProvider = { + name: "bmoni", + + async ensureWallet(accountId) { + const acc = (await getAccount(accountId)) as { + firstName?: string; + lastName?: string; + phoneNumber?: string; + email?: string; + bvn?: string; + }; + const { bmoniUserId, smartWalletId, walletAddress } = await provisionBmoniWallet( + accountId, + `aide-${accountId}`, + acc, + ); + + // The wallet exists but nothing can be paid INTO it until the Nigerian + // anchor rail is onboarded — that is the call which issues the virtual + // account. Skipping it left the user with a smart wallet and no NUBAN, so + // the deposit list held only BMONI's pooled house account and the parser + // (correctly) refused to hand that out as theirs. + // + // Idempotent in practice: start-nigeria on an already-active rail returns + // the same workflow rather than issuing a second account. + if (acc.bvn?.trim() && (await anchorStatusOf(bmoniUserId)) !== "active") { + await startNigeriaOnboarding({ + userId: bmoniUserId, + bvn: acc.bvn.trim(), + ngnWalletAddress: walletAddress, + smartWalletId, + }); + } + + // The NUBAN a worker actually gives out. Read separately: the wallet is an + // address, the deposit account is the bank-facing side of it. + // + // Issued asynchronously — for a few seconds after onboarding the list + // holds only the pooled account, and parseNgnDepositAccount throws rather + // than returning it. Retried briefly so a first visit does not have to + // fail before the account that is already on its way shows up. + const deposit = await withRetry(() => getNgnDepositAccount(bmoniUserId)); + + // Route deposits at this wallet. Without it the virtual account exists but + // credits land nowhere Aide can see. + await pointDepositsAtWallet(bmoniUserId, smartWalletId, deposit.id).catch((e) => { + // Already linked is not a failure; anything else is worth knowing about + // without taking the account number down with it. + console.warn(`[bmoni] could not link deposits for ${accountId}: ${(e as Error).message}`); + }); + + return { accountNumber: deposit.accountNumber, bankName: deposit.bankName, accountName: deposit.accountName }; + }, + + // BMONI holds the wallet, so the balance is simply what it reports. Nothing + // is subtracted here: withdrawals have already left the wallet on BMONI's + // side, and taking Aide's withdrawal ledger off again would double-count + // every payout and understate what the worker actually has. + async getBalanceKobo(accountId) { + return await getNgnBalanceKobo(await bmoniUserOf(accountId)); + }, + + async listInbound(accountId): Promise { + const w = (await convexClient().query(api.wallets.getByAccount, { accountId })) as { + bmoniUserId?: string; + bmoniSmartWalletId?: string; + } | null; + if (!w?.bmoniUserId || !w.bmoniSmartWalletId) { + // Throwing, not returning []. No wallet means the history is unknown, + // and an empty list here would be read out as "no payments received". + throw new Error(`No BMONI wallet provisioned for ${accountId}`); + } + return await listWalletTransactions(w.bmoniUserId, w.bmoniSmartWalletId); + }, + + async listBanks(accountId) { + return await listNigerianBanks(await bmoniUserOf(accountId)); + }, + + // Verified as the account doing the asking, rather than as some configured + // stand-in user. BMONI scopes name enquiry per user, and borrowing another + // user's context to ask about a stranger's account is both wrong and + // unnecessary now that every account has its own BMONI user. + async verifyDestination(accountId, accountNumber, bankCode): Promise { + const r = await verifyNigerianAccount(await bmoniUserOf(accountId), accountNumber, bankCode); + return { + accountNumber, + bankCode, + accountName: r.accountName, + // False on the development host, which fabricates. The name still + // travels — it is needed to register the destination with BMONI — but + // callers must not show or say it as a confirmation. + nameVerified: !isFabricatedNameEnquiry("bmoni", process.env.BMONI_BASE_URL), + }; + }, + + async payOut(args): Promise { + const { proposalId, outcome } = await payOutToBank({ + accountId: args.accountId, + amountKobo: args.amountKobo, + accountNumber: args.accountNumber, + bankCode: args.bankCode, + bankName: args.bankName, + }); + return { state: outcome.state, reference: proposalId, mayAnnounceArrival: outcome.mayAnnounceArrival }; + }, +}; + +// Exported for the balance path: BMONI reports balances as decimal strings. +export function balanceToKobo(amount: string): number { + return decimalStringToKobo(amount); +} diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts new file mode 100644 index 0000000..0ba558c --- /dev/null +++ b/lib/banking/bmoni-shapes.ts @@ -0,0 +1,236 @@ +import { toKobo } from "../money"; + +// The seam between BMONI's JSON and anything Aide is willing to say out loud. +// +// Every shape here was captured from the live sandbox rather than read off the +// documentation, because three of them did not match the documented client +// types — and a field-name mismatch on this API is silent. It is not a 400 and +// not a type error; the property is simply `undefined`, gets persisted, and +// surfaces much later as a 404 on a path containing the word "undefined". +// +// So these parsers throw. None of them defaults, none of them coerces, none of +// them returns a zero it did not read. A parser that guessed would produce a +// confident wrong number, and the person it is read aloud to has no screen to +// check it against. + +type Json = Record; + +function obj(v: unknown): Json { + if (!v || typeof v !== "object" || Array.isArray(v)) { + throw new Error(`BMONI returned ${Array.isArray(v) ? "an array" : typeof v} where an object was expected`); + } + return v as Json; +} + +function str(v: unknown, field: string): string { + if (typeof v !== "string" || !v.trim()) throw new Error(`BMONI response is missing ${field}`); + return v; +} + +// ---- POST /v1/users --------------------------------------------------------- + +// The user comes back wrapped, and carries TWO uuids: `id` (the partner-side +// record) and `bmoniUserId`. Every other endpoint paths on `bmoniUserId`; +// `id` gives "User not found". They are indistinguishable by shape, so this +// picks by name and never by position. +export function parseCreatedUser(body: unknown): { bmoniUserId: string } { + const user = obj(obj(body).user); + return { bmoniUserId: str(user.bmoniUserId, "user.bmoniUserId") }; +} + +// ---- POST …/smart-wallets/create-managed ------------------------------------ + +// Returns `id` and `walletAddress` — not `smartWalletId` and `address`. +// Also: a wallet requested as CNGN comes back with currency NGN. +export function parseCreatedWallet(body: unknown): { smartWalletId: string; address: string; currency: string } { + const w = obj(body); + return { + smartWalletId: str(w.id, "id"), + address: str(w.walletAddress, "walletAddress"), + currency: str(w.currency, "currency"), + }; +} + +// ---- GET …/smart-wallets/account/balances ----------------------------------- + +// `balance` is a decimal STRING, and each entry carries its own `error` inside +// an HTTP 200: BMONI can answer successfully while admitting it could not +// price that particular wallet. +export function parseNgnBalanceKobo(body: unknown): number { + const list = obj(body).balances; + if (!Array.isArray(list)) throw new Error("BMONI balances response has no balances array"); + + const ngn = list.map(obj).find((b) => b.currency === "NGN"); + if (!ngn) { + // Not the same fact as "empty". Wallets here can be USD, and a user with + // no naira wallet has an unknown naira balance, not a zero one. + const seen = list.map((b) => obj(b).currency).join(", ") || "none"; + throw new Error(`No NGN smart wallet in BMONI balances (currencies present: ${seen})`); + } + if (ngn.error) throw new Error(`BMONI could not price the NGN wallet: ${String(ngn.error)}`); + + const raw = str(ngn.balance, "balances[].balance"); + const naira = Number(raw); + if (!Number.isFinite(naira) || naira < 0) throw new Error(`BMONI reported an unusable NGN balance: ${raw}`); + return toKobo(naira); +} + +// ---- the ngnWalletIndex start-nigeria demands ------------------------------- + +// POST …/onboarding/start-nigeria requires THREE fields — bvn, ngnWalletAddress +// and ngnWalletIndex — and answers 400 listing all of them when any is missing. +// The client sent two, so the rail never started, so no virtual account was +// ever issued, so the deposit list held only BMONI's pooled house account. +// +// No response anywhere carries the index: not the wallet, not the balances +// entry, not the user record. It has to be derived, and the derivation is +// deliberately narrow. Aide provisions exactly one smart wallet per user, so +// when BMONI lists exactly one and it is ours, 0 is the only value that can be +// right — verified live, the anchor rail went not_started → active. +// +// With more than one wallet, position in the array is a guess: BMONI never says +// whether its index counts every wallet or only the NGN ones. Guessing wrong +// points a worker's incoming wages at a wallet Aide does not read, and the +// money lands somewhere real, so nothing anywhere reports an error. So this +// throws instead. +export function soleNgnWalletIndex(body: unknown, smartWalletId: string): number { + const list = obj(body).balances; + if (!Array.isArray(list)) throw new Error("BMONI balances response has no balances array"); + + const i = list.map(obj).findIndex((b) => b.smartWalletId === smartWalletId); + if (i < 0) { + throw new Error( + `BMONI does not list smart wallet ${smartWalletId} for this user, so its onboarding index cannot be derived`, + ); + } + if (list.length > 1) { + throw new Error( + `BMONI lists ${list.length} smart wallets for this user. Which one its ngnWalletIndex counts is ` + + `undocumented, and onboarding the wrong index routes incoming wages to a wallet Aide cannot read. ` + + `Refusing to guess.`, + ); + } + return i; +} + +// ---- GET …/bank-accounts/deposit-accounts/NGN ------------------------------- + +// Bkey's own house account, returned to users who have no virtual account of +// their own yet. It is a real, payable NUBAN, which is exactly what makes it +// dangerous: nothing about the response says "this is not yours". +const POOLED_ID = /^pooled-/i; +const POOLED_NAME = /bkey/i; + +export function parseNgnDepositAccount(body: unknown): { id: string; accountNumber: string; bankName: string; accountName: string } { + const list = obj(body).accounts; + if (!Array.isArray(list) || list.length === 0) { + throw new Error("BMONI returned no naira deposit account for this user"); + } + // Filtered, not indexed. Verified against the sandbox: once the virtual + // account is issued the worker's own account comes FIRST and the pooled one + // second, but in the seconds before issuance completes the pooled account is + // the only entry — so `accounts[0]` is sometimes the right answer and + // sometimes the pooled house account, with nothing in the response to say + // which. Position cannot be trusted; the pooled one is excluded by identity. + const own = list.map(obj).filter((a) => { + const id = typeof a.id === "string" ? a.id : ""; + const name = typeof a.accountName === "string" ? a.accountName : ""; + return !POOLED_ID.test(id) && !POOLED_NAME.test(name); + }); + + if (own.length === 0) { + // Refusing here is the whole point. Handing the pooled account back would + // have Aide read a working account number aloud as the worker's own; an + // employer would pay into a shared pool with no reference tying it to + // anyone, and the money would be genuinely unrecoverable by this app. + throw new Error( + "BMONI lists only its pooled house account for this user, not one of their own. The per-user " + + "virtual account is issued by Nigerian onboarding and appears a few seconds later — wait and re-read.", + ); + } + const a = own[0]; + return { + // Needed to point deposits at the wallet; onramp/vba/nigeria takes this id. + id: str(a.id, "accounts[].id"), + accountNumber: str(a.accountNumber, "accounts[].accountNumber"), + bankName: str(a.bankName, "accounts[].bankName"), + // The name the BANK holds for this account, which is NOT the Aide profile + // name — "Dillon Bunch" against a profile reading "Bunch Dillon", and + // "Jabo Samson Joe" against "ClearVoice Media". Whoever pays in will see + // this one during their own name enquiry, so it is the only one Aide may + // read out as the account holder. + accountName: str(a.accountName, "accounts[].accountName"), + }; +} + +// ---- GET …/bank-accounts/nigerian-banks ------------------------------------- + +// Wrapped in `banks`, with bankName/bankCode. The codes are NIBSS institution +// codes (Wema 000017), NOT the 3-digit NIP codes the payments page and Monnify +// use (Wema 035). The two lists are not interchangeable. +export function parseBanks(body: unknown): Array<{ name: string; code: string }> { + const list = obj(body).banks; + if (!Array.isArray(list)) throw new Error("BMONI nigerian-banks response has no banks array"); + return list.map(obj).map((b) => ({ name: str(b.bankName, "banks[].bankName"), code: str(b.bankCode, "banks[].bankCode") })); +} + +// ---- GET …/smart-wallets/{smartWalletId}/transactions ----------------------- + +// The wallet's own history. The envelope is verified — `transactions` plus +// page/perPage/total/pageCount/hasNextPage/hasPreviousPage — but no wallet on +// the shared sandbox has a transaction yet, so the ITEM field names below come +// from the SDK's EmbeddedWalletTransaction model, not from observed JSON. +// +// Hence: throw on an item that cannot be read, never skip it. A skipped row is +// a payment a worker is never told about, and they have no screen on which to +// notice the list is short. +export type WalletCredit = { amountKobo: number; reference: string; from?: string; at: number }; + +// Aide announces money as HAVING ARRIVED. Only a completed credit has. +const ARRIVED = "completed"; + +export function parseWalletTransactions(body: unknown): WalletCredit[] { + const list = obj(body).transactions; + if (!Array.isArray(list)) throw new Error("BMONI transactions response has no transactions array"); + + const credits: WalletCredit[] = []; + for (const raw of list) { + const t = obj(raw); + if (String(t.direction).toLowerCase() !== "incoming") continue; + if (String(t.status).toLowerCase() !== ARRIVED) continue; + + const amount = typeof t.amount === "string" ? Number(t.amount) : t.amount; + if (typeof amount !== "number" || !Number.isFinite(amount)) { + throw new Error(`BMONI credit ${String(t.id)} carries no readable amount`); + } + const at = typeof t.createdAt === "number" ? t.createdAt : Date.parse(String(t.createdAt)); + credits.push({ + amountKobo: toKobo(amount), + reference: str(t.id, "transactions[].id"), + from: typeof t.counterpartyName === "string" ? t.counterpartyName : undefined, + at: Number.isFinite(at) ? at : Date.now(), + }); + } + return credits; +} + +// ---- POST …/bank-accounts/verify-nigerian-account --------------------------- + +// Name enquiry. The holder is `accountName`, NOT `accountHolderName` as the +// client first assumed — another silent undefined, and this one would have had +// Aide read back an empty account holder at the exact moment a worker is being +// asked to approve where their wages go. +export function parseVerifiedAccount(body: unknown): { + accountNumber: string; + accountName: string; + bankName: string; + bankCode: string; +} { + const a = obj(body); + return { + accountNumber: str(a.accountNumber, "accountNumber"), + accountName: str(a.accountName, "accountName"), + bankName: str(a.bankName, "bankName"), + bankCode: str(a.bankCode, "bankCode"), + }; +} diff --git a/lib/banking/bmoni-wallet.ts b/lib/banking/bmoni-wallet.ts new file mode 100644 index 0000000..530b7b8 --- /dev/null +++ b/lib/banking/bmoni-wallet.ts @@ -0,0 +1,346 @@ +import { api } from "../../convex/_generated/api"; +import { convexClient } from "../convex-server"; +import { generateOwnerKey, openPrivateKey, sealPrivateKey } from "./keys"; +import { signOwnerProof, signProposalDigest } from "./signing"; +import { readProposalOutcome, type ProposalOutcome } from "./proposal-status"; +import { + approveProposal, + createBmoniUser, + listBmoniUsers, + createNgnOfframp, + createSmartWallet, + getProposal, + getSignPayload, + listBalances, + pointDepositsAtWallet, + registerWithdrawalAccount, + requestOwnerProofChallenge, + startNigeriaOnboarding, + submitProposalSignature, + verifyNigerianAccount, + BmoniError, +} from "./bmoni"; + +// Provisioning and payout, orchestrated so that any step can fail and the flow +// can be picked up again without repeating something that already happened. +// +// That resumability is the whole design. BMONI has no idempotency keys: a +// repeated create makes a second wallet, and a repeated payout pays a worker's +// wages twice. So every step records its result before the next one starts, +// and every entry point reads that record first. + +type WalletRow = { + accountId: string; + bmoniUserId?: string; + bmoniSmartWalletId?: string; + bmoniWalletAddress?: string; + bmoniOwnerAddress?: string; + bmoniSealedOwnerKey?: string; + bmoniBankAccountIds?: Array<{ key: string; id: string }>; + bmoniPendingProposal?: { proposalId: string; amountKobo: number; createdAt: number }; +}; + +async function row(accountId: string): Promise { + return (await convexClient().query(api.wallets.getByAccount, { accountId })) as WalletRow | null; +} + +// BMONI wants a first and last name; Aide only ever collected one free-text +// `name`. Splitting it is a guess, and a guess about someone's legal name is +// one that fails KYC in a way nobody can debug later. So the account must carry +// the split explicitly, and this refuses when it does not. +function requireIdentity(acc: { firstName?: string; lastName?: string; phoneNumber?: string; email?: string }): { + firstName: string; + lastName: string; + phoneNumber: string; + email: string; +} { + const missing = (["firstName", "lastName", "phoneNumber", "email"] as const).filter((k) => !acc[k]?.trim()); + if (missing.length) { + throw new Error( + `Cannot provision a BMONI wallet without ${missing.join(", ")}. ` + + `BMONI needs a first/last name split and an E.164 phone; Aide stores one free-text name, ` + + `and splitting it would be a guess about someone's legal identity.`, + ); + } + return { + firstName: acc.firstName!.trim(), + lastName: acc.lastName!.trim(), + phoneNumber: acc.phoneNumber!.trim(), + email: acc.email!.trim(), + }; +} + +// ---- Provisioning ----------------------------------------------------------- + +export type ProvisionResult = { bmoniUserId: string; smartWalletId: string; walletAddress: string }; + +export async function provisionBmoniWallet( + accountId: string, + accountReference: string, + account: { firstName?: string; lastName?: string; phoneNumber?: string; email?: string }, +): Promise { + const existing = await row(accountId); + + // Already fully provisioned — nothing to do, and certainly nothing to retry. + if (existing?.bmoniUserId && existing.bmoniSmartWalletId && existing.bmoniWalletAddress) { + return { + bmoniUserId: existing.bmoniUserId, + smartWalletId: existing.bmoniSmartWalletId, + walletAddress: existing.bmoniWalletAddress, + }; + } + + const identity = requireIdentity(account); + + // Step 1 — the user. Persisted BEFORE anything depends on it, because a 409 + // on a repeat names the field that collided but gives no way to ask WHICH + // user it was. Losing this id means the phone number is spent and the user + // is unreachable. + let bmoniUserId = existing?.bmoniUserId; + if (!bmoniUserId) { + try { + bmoniUserId = (await createBmoniUser(identity)).bmoniUserId; + } catch (e) { + if (e instanceof BmoniError && e.isConflict) { + // Recoverable: a previous attempt created this user and we lost the + // id. GET /v1/users can find it again. + // + // Matched on the EXACT email and phone that were submitted, and only + // when exactly one row matches both. The sandbox list is shared with + // every other team, and a loose match here would bind this worker's + // wages to a stranger's wallet — which is worse than not recovering. + const candidates = (await listBmoniUsers().catch(() => [])).filter( + (u) => u.email?.toLowerCase() === identity.email.toLowerCase() && u.phoneNumber === identity.phoneNumber, + ); + if (candidates.length !== 1 || !candidates[0].bmoniUserId) { + throw new Error( + `BMONI already holds a user for this email or phone (${e.message}), and looking it up returned ` + + `${candidates.length} matches rather than exactly one. Refusing to guess which user is ` + + `${accountId} — binding the wrong one would send their wages to a stranger.`, + ); + } + bmoniUserId = candidates[0].bmoniUserId; + } else { + throw e; + } + } + await convexClient().mutation(api.wallets.setBmoniUser, { accountId, accountReference, bmoniUserId }); + } + + // Step 2 — the owner key. Generated and sealed before the wallet exists, so + // the address we register is one we can still sign with afterwards. Reused if + // already present: rotating it would strand the wallet, because BMONI's + // signer snapshot keeps the address registered at creation. + let ownerAddress = existing?.bmoniOwnerAddress; + let sealed = existing?.bmoniSealedOwnerKey; + if (!ownerAddress || !sealed) { + const key = generateOwnerKey(); + ownerAddress = key.address; + sealed = sealPrivateKey(key.privateKey); + await convexClient().mutation(api.wallets.setBmoniOwnerKey, { + accountId, + ownerAddress, + sealedOwnerKey: sealed, + }); + } + + // Step 3 — wallet creation has NO uniqueness guard, so a blind retry makes a + // second wallet. If one may already exist, read balances first and refuse + // rather than guessing. + if (existing?.bmoniUserId && !existing.bmoniSmartWalletId) { + const outcome = await listBalances(bmoniUserId).then( + (body) => ({ ok: true as const, body }), + (e: Error) => ({ ok: false as const, message: e.message }), + ); + if (walletAlreadyExists(outcome)) { + throw new Error( + `BMONI already reports a wallet for ${accountId} but none is recorded locally. Creating another ` + + `would leave two wallets and deposits routed to the wrong one. Recover the smartWalletId manually.`, + ); + } + } + + const challenge = await requestOwnerProofChallenge(bmoniUserId, ownerAddress); + // Owner proof signs the challenge TEXT, with the EIP-191 prefix — the + // opposite of the proposal signature below. + const ownerProofSignature = await signOwnerProof(openPrivateKey(sealed), challenge.message); + + const wallet = await createSmartWallet({ + userId: bmoniUserId, + userOwnerAddress: ownerAddress, + ownerProofChallengeId: challenge.challengeId, + ownerProofSignature, + }); + + await convexClient().mutation(api.wallets.setBmoniWallet, { + accountId, + smartWalletId: wallet.smartWalletId, + walletAddress: wallet.address, + }); + + return { bmoniUserId, smartWalletId: wallet.smartWalletId, walletAddress: wallet.address }; +} + +// BMONI's way of saying "this user has no wallet yet" — an HTTP 400 from the +// balances endpoint, which is the normal state of a user about to be given +// their first one. +const NO_WALLET_YET = /no embedded smart wallet group found/i; + +// Whether a wallet already exists, from either a balances response or the +// error that came back instead. +// +// Deliberately asymmetric. `false` unlocks wallet creation, which has no +// uniqueness guard at BMONI — a wrong `false` makes a SECOND wallet and +// deposits then route to whichever one we did not record. So `false` is +// returned ONLY on positive evidence: an empty balance list, or the specific +// 400 that names the absent wallet group. Anything else — a timeout, a 500, a +// body in a shape we do not recognise — is "cannot tell", and cannot-tell +// resolves to "yes, one exists" so the caller stops and a human looks. +export function walletAlreadyExists( + outcome: { ok: true; body: unknown } | { ok: false; message: string }, +): boolean { + if (!outcome.ok) return !NO_WALLET_YET.test(outcome.message); + const body = outcome.body as { wallets?: unknown; balances?: unknown } | null; + if (!body) return true; + const list = Array.isArray(body) ? body : (body.wallets ?? body.balances); + if (!Array.isArray(list)) return true; + return list.length > 0; +} + +// Activate the NGN rail and point incoming bank transfers at the wallet. +export async function activateNigeriaRail(args: { + bmoniUserId: string; + smartWalletId: string; + walletAddress: string; + bvn: string; + // The virtual account to route deposits into. It only exists once the + // Nigerian anchor rail has finished onboarding, which is asynchronous — so + // the caller supplies it rather than this function inventing one. Pointing + // deposits at a guessed account sends a worker's wages somewhere they cannot + // be recovered from, and nothing would say so out loud. + bankAccountId: string; +}): Promise { + await startNigeriaOnboarding({ + userId: args.bmoniUserId, + bvn: args.bvn, + ngnWalletAddress: args.walletAddress, + smartWalletId: args.smartWalletId, + }); + await pointDepositsAtWallet(args.bmoniUserId, args.smartWalletId, args.bankAccountId); +} + +// ---- Paying out ------------------------------------------------------------- + +export type PayoutResult = { proposalId: string; outcome: ProposalOutcome }; + +// Withdraw to a Nigerian bank account. +// +// The dangerous window is between creating the proposal and signing it: a real +// payout exists at BMONI, and starting over would create a second one. So the +// proposal id is claimed in Convex the instant it exists, and a claim that +// finds another proposal already in flight refuses instead of overwriting. +export async function payOutToBank(args: { + accountId: string; + amountKobo: number; + accountNumber: string; + bankCode: string; + bankName: string; +}): Promise { + const w = await row(args.accountId); + if (!w?.bmoniUserId || !w.bmoniSmartWalletId || !w.bmoniSealedOwnerKey) { + throw new Error(`No provisioned BMONI wallet for ${args.accountId}`); + } + + // Resume rather than restart: an in-flight proposal is a payout that already + // exists. Creating another would pay the same wages twice. + if (w.bmoniPendingProposal) { + return await seeProposalThrough( + args.accountId, + w.bmoniUserId, + w.bmoniSealedOwnerKey, + w.bmoniPendingProposal.proposalId, + ); + } + + // Name enquiry first — it yields the exact holder name registration needs. + const destKey = `${args.accountNumber}:${args.bankCode}`; + let bankAccountId = w.bmoniBankAccountIds?.find((r) => r.key === destKey)?.id; + if (!bankAccountId) { + const verified = await verifyNigerianAccount(w.bmoniUserId, args.accountNumber, args.bankCode); + // Get-or-create at BMONI, so this is safe to repeat. + const registered = await registerWithdrawalAccount({ + userId: w.bmoniUserId, + accountNumber: args.accountNumber, + bankCode: args.bankCode, + bankName: args.bankName, + accountHolderName: verified.accountName, + }); + bankAccountId = registered.id; + await convexClient().mutation(api.wallets.rememberBmoniBankAccount, { + accountId: args.accountId, + key: destKey, + bankAccountId, + }); + } + + // From here a real payout can exist at BMONI. + const proposal = await createNgnOfframp({ + userId: w.bmoniUserId, + smartWalletId: w.bmoniSmartWalletId, + bankAccountId, + amountKobo: args.amountKobo, + }); + + const claim = (await convexClient().mutation(api.wallets.claimBmoniProposal, { + accountId: args.accountId, + proposalId: proposal.proposalId, + amountKobo: args.amountKobo, + at: Date.now(), + })) as { ok: boolean; inFlight?: string }; + if (!claim.ok) { + throw new Error( + `Created BMONI proposal ${proposal.proposalId} but ${claim.inFlight} was already in flight for ` + + `${args.accountId}. Two payouts now exist — resolve both before withdrawing again.`, + ); + } + + return await seeProposalThrough(args.accountId, w.bmoniUserId, w.bmoniSealedOwnerKey, proposal.proposalId); +} + +// Approve, sign and read back a proposal that already exists. Safe to re-enter: +// approving twice is harmless, and the sign payload is always fetched fresh +// because it carries a deadline. +async function seeProposalThrough( + accountId: string, + bmoniUserId: string, + sealedOwnerKey: string, + proposalId: string, +): Promise { + const current = await getProposal(bmoniUserId, proposalId); + + if (current.status === "PENDING_APPROVALS") { + await approveProposal(bmoniUserId, proposalId); + } + + const after = await getProposal(bmoniUserId, proposalId); + if (after.status === "PENDING_SIGNATURES") { + // Fetched immediately before signing — the payload carries a deadline, and + // a stale one is rejected after the withdrawal was confirmed out loud. + const payload = await getSignPayload(bmoniUserId, proposalId); + // Raw 32-byte digest, NO EIP-191 prefix. The opposite of the owner proof. + const signature = signProposalDigest(openPrivateKey(sealedOwnerKey), payload.hashToSign); + await submitProposalSignature(bmoniUserId, proposalId, signature); + } + + const final = await getProposal(bmoniUserId, proposalId); + const outcome = readProposalOutcome(final.status); + + // Terminal states release the guard. An unknown status deliberately does + // NOT: leaving it in flight blocks another payout until a human establishes + // what happened to this one. + if (outcome.state === "completed" || outcome.state === "failed") { + await convexClient().mutation(api.wallets.clearBmoniProposal, { accountId, proposalId }); + } + + return { proposalId, outcome }; +} diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts new file mode 100644 index 0000000..7eab374 --- /dev/null +++ b/lib/banking/bmoni.ts @@ -0,0 +1,314 @@ +import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; +import { koboToDecimalString } from "./amounts"; +import { parseCards } from "./bmoni-cards"; +import { parseVerifiedAccount, parseWalletTransactions } from "./bmoni-shapes"; +import { + parseBanks, + parseCreatedUser, + parseCreatedWallet, + parseNgnBalanceKobo, + parseNgnDepositAccount, + soleNgnWalletIndex, +} from "./bmoni-shapes"; + +// The BMONI Embedded operations Aide uses, in lifecycle order: +// +// user → smart wallet → KYC → rail → fund → move money +// +// The wallet comes BEFORE KYC, which is the opposite of most providers and the +// thing most likely to trip someone reading this later: `start-nigeria` needs +// an `ngnWalletAddress`, so the wallet has to exist first. +// +// Everything that can move money goes through bmoniMove — one attempt, no +// timeout, no retry. Everything else is a read. + +export type BmoniUser = { bmoniUserId: string }; +export type SmartWallet = { smartWalletId: string; address: string; currency: string }; +export type OwnerProofChallenge = { challengeId: string; message: string }; +export type SignPayload = { hashToSign: string; deadline?: number }; +export type Proposal = { proposalId: string; status: string }; + +// ---- 1. User ---------------------------------------------------------------- + +export type NewUser = { firstName: string; lastName: string; email: string; phoneNumber: string }; + +// A 409 means a previous attempt already created this user, guarded on email +// and phone. It IS recoverable: GET /v1/users lists every partner user with +// their bmoniUserId, email and phone, so the id can be found again. (BMONI's +// own docs name a `findUserByEmail` that does not exist; the list route is +// what actually works.) +// +// The id is still persisted at creation rather than relied on being +// re-findable, because the list is shared across every team on the sandbox and +// matching the wrong row would bind a worker's wages to a stranger's wallet. +export async function createBmoniUser(u: NewUser): Promise { + return parseCreatedUser(await bmoniRead({ path: "/v1/users", method: "POST", body: u })); +} + +// ---- 2. Smart wallet -------------------------------------------------------- + +export async function requestOwnerProofChallenge( + userId: string, + userOwnerAddress: string, + currency = "CNGN", +): Promise { + return await bmoniRead({ + path: `/v1/users/${userId}/smart-wallets/owner-proof-challenges`, + method: "POST", + body: { currency, userOwnerAddress }, + }); +} + +// Wallet creation has NO uniqueness guard: a blind retry produces a SECOND +// wallet. So this is a move, not a read — one attempt only. If it fails without +// a clear answer, the caller must read balances to see whether the first +// attempt landed before ever trying again. +export async function createSmartWallet(args: { + userId: string; + userOwnerAddress: string; + ownerProofChallengeId: string; + ownerProofSignature: string; + currency?: string; +}): Promise { + return parseCreatedWallet( + await bmoniMove({ + path: `/v1/users/${args.userId}/smart-wallets/create-managed`, + method: "POST", + body: { + currency: args.currency ?? "CNGN", + userOwnerAddress: args.userOwnerAddress, + ownerProofChallengeId: args.ownerProofChallengeId, + ownerProofSignature: args.ownerProofSignature, + }, + }), + ); +} + +// The read-before-retry that makes a failed wallet create safe to recover from. +export async function listBalances(userId: string): Promise { + return await bmoniRead({ path: `/v1/users/${userId}/smart-wallets/account/balances` }); +} + +// The naira figure, in whole kobo. Throws rather than reporting a zero it did +// not read — see lib/banking/bmoni-shapes.ts. +export async function getNgnBalanceKobo(userId: string): Promise { + return parseNgnBalanceKobo(await listBalances(userId)); +} + +// ---- 3. KYC / rail ---------------------------------------------------------- + +export async function startNigeriaOnboarding(args: { + userId: string; + bvn: string; + ngnWalletAddress: string; + smartWalletId: string; +}): Promise { + // BMONI rejects a BVN that is not exactly 11 digits, but only after a round + // trip. Checking here keeps the error next to the field that caused it. + const bvn = args.bvn.trim(); + if (!/^\d{11}$/.test(bvn)) throw new Error(`BVN must be exactly 11 digits, got ${JSON.stringify(args.bvn)}`); + + // Read rather than accepted from the caller: no caller has any way to know + // this number, and the one that invented it would invent it wrong. See + // soleNgnWalletIndex for why it refuses rather than picking. + const ngnWalletIndex = soleNgnWalletIndex(await listBalances(args.userId), args.smartWalletId); + + return await bmoniRead({ + path: `/v1/users/${args.userId}/onboarding/start-nigeria`, + method: "POST", + body: { bvn, ngnWalletAddress: args.ngnWalletAddress, ngnWalletIndex }, + }); +} + +// There is no single `status`. The live response is one field per rail — +// anchorStatus, bridgeStatus, moneriumStatus, paytrieStatus, etherfuseStatus — +// each independently "not_started" or beyond. Nigerian onboarding is the +// anchor rail; the others belong to regions Aide does not use. +export type OnboardingStatus = { + anchorStatus?: string; + bridgeStatus?: string; + moneriumStatus?: string; + paytrieStatus?: string; + etherfuseStatus?: string; +}; + +export async function onboardingStatus(userId: string): Promise { + return await bmoniRead({ path: `/v1/users/${userId}/onboarding/status` }); +} + +// ---- 4. Deposits (money in) ------------------------------------------------- + +// VERIFIED against the sandbox: this requires a `bankAccountId` UUID in the +// body. Calling it with none answers 400 ["bankAccountId must be a UUID"]. +// +// Which id, and where it comes from, is still unknown. Before onboarding +// completes the only account BMONI lists is the pooled house one, whose id is +// the literal string "pooled-vba-1" — not a UUID, so it cannot be the answer. +// The per-user virtual account that would have a UUID does not exist until the +// Nigerian anchor rail is onboarded, which needs one of the two sandbox BVN +// personas. So this is left un-guessed on purpose: sending an arbitrary id +// here would point a worker's incoming wages at somebody else's account. +export async function pointDepositsAtWallet( + userId: string, + smartWalletId: string, + bankAccountId: string, +): Promise { + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(bankAccountId)) { + throw new Error(`onramp/vba/nigeria needs a bankAccountId UUID, got ${JSON.stringify(bankAccountId)}`); + } + return await bmoniRead({ + path: `/v1/users/${userId}/smart-wallets/${smartWalletId}/onramp/vba/nigeria`, + method: "POST", + body: { bankAccountId }, + }); +} + +// The account number a worker actually gives out to be paid into. +export async function getNgnDepositAccount(userId: string): Promise<{ id: string; accountNumber: string; bankName: string; accountName: string }> { + return parseNgnDepositAccount(await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/deposit-accounts/NGN` })); +} + +// ---- 5. Withdrawal destinations -------------------------------------------- + +export async function listNigerianBanks(userId: string): Promise> { + return parseBanks(await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/nigerian-banks` })); +} + +// Name enquiry. A 404 means no account matches — surface it and let the user +// correct the number rather than pushing on, exactly as with Monnify. +export async function verifyNigerianAccount( + userId: string, + accountNumber: string, + bankCode: string, +): Promise<{ accountNumber: string; accountName: string; bankName: string; bankCode: string }> { + return parseVerifiedAccount( + await bmoniRead({ + path: `/v1/users/${userId}/bank-accounts/verify-nigerian-account`, + method: "POST", + body: { accountNumber, bankCode }, + }), + ); +} + +// Get-or-create: calling again with the same account returns the existing +// record rather than duplicating it, so this is safe to repeat. +export async function registerWithdrawalAccount(args: { + userId: string; + accountNumber: string; + bankCode: string; + bankName: string; + accountHolderName: string; +}): Promise<{ id: string }> { + return await bmoniRead({ + path: `/v1/users/${args.userId}/bank-accounts/withdrawal-accounts/nigeria`, + method: "POST", + body: { + accountNumber: args.accountNumber, + bankCode: args.bankCode, + bankName: args.bankName, + accountHolderName: args.accountHolderName, + }, + }); +} + +// ---- 6. Moving money -------------------------------------------------------- + +// Withdraw to a Nigerian bank. Returns a PROPOSAL — nothing has moved yet. +export async function createNgnOfframp(args: { + userId: string; + smartWalletId: string; + bankAccountId: string; + amountKobo: number; +}): Promise { + return await bmoniMove({ + path: `/v1/users/${args.userId}/smart-wallets/${args.smartWalletId}/offramp/nigeria`, + method: "POST", + body: { bankAccountId: args.bankAccountId, fromAmount: koboToDecimalString(args.amountKobo) }, + }); +} + +// Worker-to-worker / employer-to-worker transfer. Also only a proposal. +export async function createTransferProposal(args: { + userId: string; + smartWalletId: string; + toAddress: string; + amountKobo: number; + description?: string; + currency?: string; +}): Promise { + return await bmoniMove({ + path: `/v1/users/${args.userId}/smart-wallets/${args.smartWalletId}/proposals`, + method: "POST", + body: { + proposal: { + type: "TRANSFER", + toAddress: args.toAddress, + amount: koboToDecimalString(args.amountKobo), + currency: args.currency ?? "CNGN", + ...(args.description ? { description: args.description } : {}), + }, + }, + }); +} + +export async function approveProposal(userId: string, proposalId: string): Promise { + return await bmoniMove({ + path: `/v1/users/${userId}/smart-wallets/proposals/${proposalId}/approve`, + method: "POST", + }); +} + +// Fetch immediately before signing. The payload carries a deadline and must not +// be cached across a user session — a stale one fails with "Signature deadline +// exceeded", which for Aide means a withdrawal that was confirmed out loud and +// then quietly did not happen. +export async function getSignPayload(userId: string, proposalId: string): Promise { + return await bmoniRead({ + path: `/v1/users/${userId}/smart-wallets/proposals/${proposalId}/sign-payload`, + }); +} + +export async function submitProposalSignature( + userId: string, + proposalId: string, + signature: string, +): Promise { + return await bmoniMove({ + path: `/v1/users/${userId}/smart-wallets/proposals/${proposalId}/sign`, + method: "POST", + body: { signature }, + }); +} + +export async function getProposal(userId: string, proposalId: string): Promise { + return await bmoniRead({ path: `/v1/users/${userId}/smart-wallets/proposals/${proposalId}` }); +} + +export { BmoniError }; + +// ---- 7. Cards --------------------------------------------------------------- + +// Cards are LISTED on the smart-wallet path but CREATED on the user path — the +// asymmetry is real and cost an afternoon: POST to the wallet path is a 404. +// Every partner user, with the bmoniUserId every other endpoint paths on. +// Used to recover from a create-user conflict. +export async function listBmoniUsers(): Promise< + Array<{ bmoniUserId: string; email?: string; phoneNumber?: string }> +> { + const body = (await bmoniRead({ path: "/v1/users" })) as { users?: unknown }; + if (!Array.isArray(body?.users)) throw new Error("BMONI /v1/users response has no users array"); + return body.users as Array<{ bmoniUserId: string; email?: string; phoneNumber?: string }>; +} + +export async function listCards(userId: string, smartWalletId: string) { + return parseCards(await bmoniRead({ path: `/v1/users/${userId}/smart-wallets/${smartWalletId}/cards` })); +} + +// ---- 8. Wallet history ------------------------------------------------------ + +// Money in and out of the wallet itself. Paged; the first page is the newest. +export async function listWalletTransactions(userId: string, smartWalletId: string) { + return parseWalletTransactions( + await bmoniRead({ path: `/v1/users/${userId}/smart-wallets/${smartWalletId}/transactions` }), + ); +} diff --git a/lib/banking/index.ts b/lib/banking/index.ts new file mode 100644 index 0000000..19561d4 --- /dev/null +++ b/lib/banking/index.ts @@ -0,0 +1,17 @@ +import { bmoniProvider } from "./bmoni-provider"; +import { monnifyProvider } from "./monnify-provider"; +import { selectedProvider, type PaymentProvider } from "./provider"; + +// The one place that decides who is holding the money. +// +// Monnify is the default and stays the default. Selecting BMONI is setting +// AIDE_PAYMENT_PROVIDER=bmoni; putting it back is unsetting it. Neither +// requires a deploy of different code, which is the property that makes this +// integration reversible in the way it was promised to be. + +export function paymentProvider(): PaymentProvider { + return selectedProvider() === "bmoni" ? bmoniProvider : monnifyProvider; +} + +export { selectedProvider }; +export type { PaymentProvider }; diff --git a/lib/banking/keys.ts b/lib/banking/keys.ts new file mode 100644 index 0000000..9d64d25 --- /dev/null +++ b/lib/banking/keys.ts @@ -0,0 +1,68 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; +import { ethers } from "ethers"; + +// Per-user secp256k1 owner keys for BMONI smart wallets. +// +// Aide holds these keys itself. BMONI's own SDK would hold them in the Android +// Keystore or iOS Secure Enclave, but it also puts a 4-to-6 digit PIN on screen +// for every signature — the exact interaction the spoken security phrase exists +// to replace, and one a blind user cannot complete. Aide is a web app besides. +// +// That trade is deliberate, and the cost is that this key is ours to protect: +// whoever holds it can sign a transfer out of a worker's wallet. So it is +// encrypted before it goes near Convex, with AES-256-GCM — authenticated, so a +// tampered row fails loudly instead of decrypting to a *different* key. A +// different key would sign proposals that recover to an address BMONI does not +// recognise, which it accepts and records and never executes: the silent +// failure again, one layer down. + +export type OwnerKey = { privateKey: string; address: string }; + +const IV_BYTES = 12; // GCM standard nonce length +const TAG_BYTES = 16; + +// Read lazily. Importing this module must not break `npm run build`, which +// runs with no secrets by design — but a missing secret must never fall back +// to a default key, so this throws at use rather than defaulting. +function masterKey(): Buffer { + const raw = process.env.BMONI_KEY_SECRET?.trim(); + if (!raw) { + throw new Error( + "Missing BMONI_KEY_SECRET. It encrypts every user's wallet owner key; there is deliberately no default.", + ); + } + if (!/^[0-9a-fA-F]{64}$/.test(raw)) { + throw new Error("BMONI_KEY_SECRET must be 32 bytes as 64 hex characters (openssl rand -hex 32)."); + } + return Buffer.from(raw, "hex"); +} + +export function generateOwnerKey(): OwnerKey { + const w = ethers.Wallet.createRandom(); + return { privateKey: w.privateKey, address: w.address }; +} + +// Layout: iv ‖ authTag ‖ ciphertext, base64. The random iv is what makes two +// seals of the same key differ — equal ciphertexts would reveal that two users +// share a key, and let a stolen row be replayed onto another. +export function sealPrivateKey(privateKey: string): string { + const iv = randomBytes(IV_BYTES); + const cipher = createCipheriv("aes-256-gcm", masterKey(), iv); + const body = Buffer.concat([cipher.update(privateKey, "utf8"), cipher.final()]); + return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64"); +} + +export function openPrivateKey(sealed: string): string { + const raw = Buffer.from(sealed, "base64"); + if (raw.length <= IV_BYTES + TAG_BYTES) { + throw new Error("Sealed owner key is truncated or not a sealed value at all."); + } + const decipher = createDecipheriv("aes-256-gcm", masterKey(), raw.subarray(0, IV_BYTES)); + decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES)); + // final() throws if the tag does not verify — wrong secret, or tampering. + const out = Buffer.concat([decipher.update(raw.subarray(IV_BYTES + TAG_BYTES)), decipher.final()]).toString("utf8"); + if (!/^0x[0-9a-fA-F]{64}$/.test(out)) { + throw new Error("Sealed value did not decrypt to a private key."); + } + return out; +} diff --git a/lib/banking/monnify-provider.ts b/lib/banking/monnify-provider.ts new file mode 100644 index 0000000..20fe840 --- /dev/null +++ b/lib/banking/monnify-provider.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import { api } from "../../convex/_generated/api"; +import { convexClient } from "../convex-server"; +import { getReservedAccountTransactions, singleTransfer, validateBankAccount } from "../monnify"; +import { ensureWallet as ensureMonnifyWallet, getWallet } from "../store/payments"; +import { sumKobo, toKobo, toNaira } from "../money"; +import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; + +// Monnify behind the same seam. This wraps the existing, working code rather +// than replacing it — `lib/monnify.ts` and `lib/store/payments.ts` are +// untouched, so selecting BMONI and selecting it back are both config changes. +// +// Monnify speaks naira floats. Conversion happens here and nowhere above. + +// Monnify's own status vocabulary. Anything outside it is unknown, never +// completed — the same rule as the BMONI proposal mapping, for the same reason. +const SUCCEEDED = new Set(["SUCCESS", "COMPLETED"]); +const IN_FLIGHT = new Set(["PENDING", "PENDING_AUTHORIZATION", "PROCESSING"]); +const FAILED = new Set(["FAILED", "REVERSED", "EXPIRED"]); + +// The NIP codes the payments page used to hardcode. They live here now so the +// UI can stop knowing them — they are Monnify's vocabulary, not Aide's, and +// BMONI's codes for the same banks are different. +const MONNIFY_BANKS = [ + { code: "044", name: "Access Bank" }, + { code: "050", name: "Ecobank" }, + { code: "070", name: "Fidelity Bank" }, + { code: "011", name: "First Bank" }, + { code: "214", name: "FCMB" }, + { code: "058", name: "GTBank" }, + { code: "076", name: "Polaris Bank" }, + { code: "221", name: "Stanbic IBTC" }, + { code: "232", name: "Sterling Bank" }, + { code: "032", name: "Union Bank" }, + { code: "033", name: "UBA" }, + { code: "035", name: "Wema Bank" }, + { code: "057", name: "Zenith Bank" }, +]; + +export const monnifyProvider: PaymentProvider = { + name: "monnify", + + async ensureWallet(accountId) { + const w = await ensureMonnifyWallet(accountId); + return { accountNumber: w.accountNumber, bankName: w.bankName }; + }, + + async listInbound(accountId): Promise { + const wallet = await getWallet(accountId); + const { content } = await getReservedAccountTransactions(wallet.accountReference); + return content + .filter((t) => t.paymentStatus === "PAID") + .map((t) => ({ + // Naira in, kobo out. The one conversion this adapter exists to own. + amountKobo: toKobo(t.amountPaid ?? t.amount), + reference: t.transactionReference, + from: t.customerDTO?.name, + at: typeof t.createdOn === "number" ? t.createdOn : t.createdOn ? Date.parse(t.createdOn) : Date.now(), + })); + }, + + // Monnify has no balance for a reserved account, so one is derived: what + // landed on the NUBAN, minus what Aide has already sent out. The withdrawal + // ledger is Aide's own — Monnify does not deduct payouts from the reserved + // account's transaction list — so it has to be subtracted here. + async getBalanceKobo(accountId) { + const [inbound, withdrawnKobo] = await Promise.all([ + this.listInbound(accountId), + convexClient().query(api.wallets.withdrawnTotal, { accountId }) as Promise, + ]); + return Math.max(0, sumKobo(inbound.map((c) => c.amountKobo)) - withdrawnKobo); + }, + + // Monnify's list is global, so the account is not consulted. Kept in the + // signature because BMONI's is not. + async listBanks() { + return MONNIFY_BANKS.map((b) => ({ ...b })); + }, + + async verifyDestination(_accountId, accountNumber, bankCode): Promise { + const r = await validateBankAccount(accountNumber, bankCode); + // Monnify's sandbox rejects accounts that do not exist, so its answer is a + // real one on both environments. + return { accountNumber: r.accountNumber, accountName: r.accountName, bankCode, nameVerified: true }; + }, + + async payOut(args): Promise { + // singleTransfer already carries the no-retry, no-early-abort rule. + const r = await singleTransfer({ + amount: toNaira(args.amountKobo), + // Same shape the existing withdrawal path uses, so transfers made + // through the seam are recognisable alongside the ones that are not. + reference: `aide-wd-${randomUUID().slice(0, 8)}`, + narration: "Aide withdrawal", + destinationAccountNumber: args.accountNumber, + destinationBankCode: args.bankCode, + destinationAccountName: args.accountName, + }); + const raw = String((r as { status?: string }).status ?? ""); + const state = SUCCEEDED.has(raw) ? "completed" : IN_FLIGHT.has(raw) ? "pending" : FAILED.has(raw) ? "failed" : "unknown"; + return { + state, + reference: (r as { reference?: string }).reference ?? "", + mayAnnounceArrival: state === "completed", + }; + }, +}; diff --git a/lib/banking/name-enquiry.ts b/lib/banking/name-enquiry.ts new file mode 100644 index 0000000..ff386ae --- /dev/null +++ b/lib/banking/name-enquiry.ts @@ -0,0 +1,70 @@ +import type { ProviderName } from "./provider"; + +// Whether a name enquiry can be believed. +// +// KNOWN, MEASURED, AND DELIBERATELY NOT GUARDED AGAINST BY DEFAULT. +// +// BMONI's development host does not perform name enquiry. It returns a +// plausible Nigerian name for any ten-digit number against any bank code, +// deterministic per number, so two look-ups agree and it reads exactly like a +// real answer. Measured 2026-09-04 against embedded-dev.bmoni.com: +// +// 4534076021 against six different banks -> "Dillon Bunch" every time +// 0123456789 / GTBANK -> "Ekon Orji" +// 0000000000 / GTBANK -> "Amarachi Nwosu" +// +// The decision is to surface the provider's answer as-is rather than to +// second-guess the sandbox, so Aide behaves on the dev host exactly as it will +// on production. Aide is therefore capable of speaking a fabricated account +// holder's name on this host. On the sandbox that costs nothing — the wallets +// hold no money and the personas are not real people. +// +// It stops being free the moment this runs against a funded wallet on a host +// whose name enquiry has not been confirmed as real. BMONI's docs are explicit +// about sandbox behaviour for BVN/NIN look-ups (only two personas resolve, +// everything else fails) and say nothing at all about this endpoint, so +// "production is real" is an assumption nobody has checked. +// +// Set BMONI_STRICT_NAME_ENQUIRY=true to refuse to present an unverified name. +// The confirmation then reads the account digits back instead, which is the +// part the user actually supplied and can check. +const BMONI_PRODUCTION = /^https?:\/\/embedded\.bmoni\.com\/?$/i; + +export function isFabricatedNameEnquiry(provider: ProviderName, baseUrl: string | undefined): boolean { + // Off unless asked for. Opt-in, so nothing changes for anyone who has not + // read the note above and decided. + if (process.env.BMONI_STRICT_NAME_ENQUIRY?.trim().toLowerCase() !== "true") return false; + // Monnify's sandbox does real name enquiry — it rejects accounts that do not + // exist — so even under strict mode this is a fact about BMONI's dev host. + if (provider !== "bmoni") return false; + if (!baseUrl?.trim()) return true; + return !BMONI_PRODUCTION.test(baseUrl.trim()); +} + +// Spoken as separate digits. "3463455722" reaches a screen reader as one +// enormous number — "three billion, four hundred and sixty-three million…" — +// which nobody can check against the number they meant to type. One digit at a +// time is what makes a wrong one audible. +export function spellDigits(accountNumber: string): string { + return accountNumber.replace(/\D/g, "").split("").join(" "); +} + +// The destination, as Aide says it in the breath before money moves. +export function destinationConfirmation(dest: { + accountName: string; + accountNumber: string; + nameVerified: boolean; +}): string { + // The digits are spelled out even alongside a verified name. That is an + // accessibility fix rather than a trust one: a screen reader turns + // "3463455722" into "three billion, four hundred and sixty-three million…", + // which cannot be checked against the number someone meant to type. + if (dest.nameVerified) return `to ${dest.accountName}, account ${spellDigits(dest.accountNumber)}`; + // The unverified name is deliberately not spoken at all. Said aloud in any + // form — even hedged — it is the thing the listener will remember as + // confirmation. + return ( + `to account ${spellDigits(dest.accountNumber)}. ` + + `I could not confirm the account holder's name on this connection, so please check those digits yourself` + ); +} diff --git a/lib/banking/proposal-status.ts b/lib/banking/proposal-status.ts new file mode 100644 index 0000000..9ab6c07 --- /dev/null +++ b/lib/banking/proposal-status.ts @@ -0,0 +1,48 @@ +// What Aide is allowed to SAY about a transfer, given what BMONI reported. +// +// This is ARCHITECTURE.md's rule at its sharpest: the model never decides a +// financial fact, it only narrates what a tool returned this turn. So the tool +// must be certain. There are three honest answers — done, still going, or +// failed — and anything that does not clearly mean one of those resolves to "I +// do not know". +// +// The asymmetry is deliberate. Calling a completed transfer "still going" +// costs a worker one more question. Calling an unknown one "completed" tells +// someone who cannot see the screen that their wages arrived when they did +// not, and they have no way to discover otherwise until they try to spend it. +// +// Mapping is exact and case-sensitive on purpose. A status that merely looks +// like success — "SUCCESS", "SETTLED", "completed" — is a status we have not +// seen documented, and guessing that it means the same thing is the guess this +// module exists to prevent. + +export type ProposalState = "completed" | "pending" | "failed" | "unknown"; + +export type ProposalOutcome = { + state: ProposalState; + // The status exactly as BMONI sent it. An unknown state is an operational + // event someone has to chase, and losing the string makes it undiagnosable. + raw: string; + // The only gate that should decide whether Aide says the money arrived. + mayAnnounceArrival: boolean; +}; + +// Every status BMONI documents for a proposal. Adding to this list is a +// deliberate act, which is the point — a new status arriving from the provider +// should surface as "unknown" and be looked at, not silently absorbed. +const COMPLETED = new Set(["COMPLETED"]); +const PENDING = new Set(["PENDING_APPROVALS", "PENDING_SIGNATURES", "EXECUTING"]); +const FAILED = new Set(["FAILED"]); + +export function readProposalOutcome(status: string): ProposalOutcome { + // Anything that is not a string is a malformed payload, not a status. + const raw = typeof status === "string" ? status : ""; + const state: ProposalState = COMPLETED.has(raw) + ? "completed" + : PENDING.has(raw) + ? "pending" + : FAILED.has(raw) + ? "failed" + : "unknown"; + return { state, raw, mayAnnounceArrival: state === "completed" }; +} diff --git a/lib/banking/provider.ts b/lib/banking/provider.ts new file mode 100644 index 0000000..a84e9d6 --- /dev/null +++ b/lib/banking/provider.ts @@ -0,0 +1,117 @@ +import { toKobo } from "../money"; + +// The seam between Aide and whoever is actually holding the money. +// +// Additive by design. Monnify remains the default and `lib/monnify.ts` is +// untouched, so selecting BMONI is a config change and selecting it back is +// the same change in reverse. That matters more than usual here: this is a +// hackathon integration against a sandbox, and the ability to put it back +// should not depend on a revert landing cleanly. +// +// The interface is deliberately narrow — four things the store layer actually +// needs — rather than the union of both providers' APIs. A seam wide enough to +// express everything both sides can do is not a seam. +// +// AMOUNTS ACROSS THIS BOUNDARY ARE KOBO. Monnify speaks naira and BMONI speaks +// decimal strings, and each adapter converts at its own edge. Nothing in the +// middle handles a naira float, because the one bug this seam could introduce +// is a unit mix-up that no type would catch: both are `number`. + +export type ProviderName = "monnify" | "bmoni"; + +// A destination account the provider answered about. +// +// `nameVerified` is false when the endpoint that produced `accountName` does +// not actually perform name enquiry — BMONI's development host returns a +// plausible name for any number against any bank. When it is false the name +// must not be presented, or spoken, as confirmation of anything. See +// lib/banking/name-enquiry.ts. +export type VerifiedAccount = { + accountNumber: string; + accountName: string; + bankCode: string; + nameVerified: boolean; +}; + +// What a payout attempt is allowed to report. Mirrors proposal-status: there is +// no "probably worked". An unknown answer must not become a spoken +// confirmation. +export type PayoutOutcome = { + state: "completed" | "pending" | "failed" | "unknown"; + reference: string; + mayAnnounceArrival: boolean; +}; + +export type InboundCredit = { amountKobo: number; reference: string; from?: string; at: number }; + +export interface PaymentProvider { + readonly name: ProviderName; + + // Make sure the account can receive money, and return where it lands. + // + // `accountName` is the name the BANK holds, which is not the Aide profile + // name. Whoever pays in sees it during their own name enquiry, so it is the + // only name Aide may present as the account holder. + ensureWallet(accountId: string): Promise<{ accountNumber?: string; bankName?: string; accountName?: string }>; + + // Confirmed money IN, in kobo. The credit side of the balance. + // + // An empty list means no money has arrived, and callers may say so. A + // provider that cannot answer must throw rather than return [] — "nobody has + // paid you" is a claim about the worker's employer, and it must never be + // made on the strength of a failed read. + listInbound(accountId: string): Promise; + + // The spendable balance, in kobo. + // + // Separate from listInbound because the two providers know it in genuinely + // different ways. Monnify has no balance for a reserved account, so its + // adapter derives one: confirmed credits minus Aide's own withdrawal ledger. + // BMONI holds the wallet, so it simply reports the figure and Aide must not + // second-guess it by subtracting a ledger the provider has already applied. + // + // Throwing is correct when the figure is unknown. There is no sentinel: a + // zero returned for "could not check" is the one wrong answer that sounds + // exactly like a true one when read aloud. + getBalanceKobo(accountId: string): Promise; + + // The banks this provider will pay out to, with ITS OWN codes. + // + // The two providers do not share a code list and the difference is silent. + // Wema is 035 to Monnify (NIP) and 000017 to BMONI (NIBSS); sending a NIP + // code to BMONI fails name enquiry outright, verified against the sandbox. + // So the list is fetched, never hardcoded in the UI. + // + // Takes an accountId because BMONI scopes this per user, unlike Monnify's + // global list. + listBanks(accountId: string): Promise>; + + // Name enquiry. Throws if the account does not resolve — never guesses a + // name. Scoped to the account doing the asking, for the same reason. + verifyDestination(accountId: string, accountNumber: string, bankCode: string): Promise; + + // Money OUT. Implementations must not retry and must not abort early. + payOut(args: { + accountId: string; + amountKobo: number; + accountNumber: string; + bankCode: string; + bankName: string; + accountName: string; + }): Promise; +} + +// Which provider is live. BMONI now, and by default — it holds the wallets, +// the naira balance and the account numbers workers give out. +// +// Monnify is still reachable by setting AIDE_PAYMENT_PROVIDER=monnify. It stays +// only as a way back if the sandbox goes down mid-demo; nothing routes to it +// unless it is asked for by name. +export function selectedProvider(): ProviderName { + return process.env.AIDE_PAYMENT_PROVIDER?.trim().toLowerCase() === "monnify" ? "monnify" : "bmoni"; +} + +// Monnify reports naira as a float; everything above this line is kobo. +export function monnifyNairaToKobo(naira: number): number { + return toKobo(naira); +} diff --git a/lib/banking/signing.ts b/lib/banking/signing.ts new file mode 100644 index 0000000..0ca401a --- /dev/null +++ b/lib/banking/signing.ts @@ -0,0 +1,59 @@ +import { ethers } from "ethers"; + +// BMONI takes two signatures over the life of a wallet, made with OPPOSITE +// methods, and mixing them up is the failure mode the whole module guards. +// +// owner proof (wallet creation) — challenge TEXT, WITH the EIP-191 prefix +// proposal (moving money) — raw 32-byte DIGEST, WITHOUT it +// +// Signing a proposal the message way produces a signature that is structurally +// valid, decodes cleanly, carries a correct v byte, and is accepted and +// recorded by BMONI. It just recovers to a different address, never matches the +// proposal's signer snapshot, and the transfer silently never executes. +// +// That silence is why this matters here more than in most integrations. Aide +// would have every reason to tell a worker their wages were on the way, and no +// way to discover otherwise. A transfer that fails loudly costs a retry. +// +// BMONI has no endpoint that validates a signature without submitting it, so +// the published test vector in tests/money/bmoni-signing.test.ts is the only +// way to prove the toolchain before real money depends on it. + +// A signature is 65 bytes: r (32) ‖ s (32) ‖ v (1), hex, 0x-prefixed. +const SIGNATURE_SHAPE = /^0x[0-9a-fA-F]{130}$/; + +// Ethers already emits v as 27/28 and serializes r‖s‖v in order, so this is a +// tripwire rather than a fixup: if a future library change starts emitting +// yParity 0/1, BMONI answers "Invalid yParityOrV" from behind a network call +// and a proposal id. Better to fail here, with the bytes in hand. +function assertWellFormed(signature: string): string { + if (!SIGNATURE_SHAPE.test(signature)) { + throw new Error(`Malformed signature: expected 65 bytes as hex, got ${signature}`); + } + const v = parseInt(signature.slice(-2), 16); + if (v !== 27 && v !== 28) { + throw new Error(`Signature v byte is ${v}; BMONI expects the Ethereum convention of 27 or 28`); + } + return signature; +} + +// Wallet creation, step 3: prove control of the owner address by signing the +// challenge text. signMessage applies the EIP-191 prefix, which is what this +// step wants — the opposite of signProposalDigest below. +export async function signOwnerProof(privateKey: string, challengeMessage: string): Promise { + return assertWellFormed(await new ethers.Wallet(privateKey).signMessage(challengeMessage)); +} + +// Moving money, step 11: sign the `hashToSign` from GET …/sign-payload as a +// raw digest. signingKey.sign, NOT signMessage — no EIP-191 prefix. +// +// Synchronous, and deliberately not dressed up as async: there is nothing to +// await, and a Promise here would suggest a round trip that does not happen. +export function signProposalDigest(privateKey: string, hashToSign: string): string { + if (!/^0x[0-9a-fA-F]{64}$/.test(hashToSign)) { + // Not a 32-byte digest. Signing it anyway would produce a valid-looking + // signature over the wrong thing, which is the silent failure again. + throw new Error(`Expected a 32-byte hex digest to sign, got ${hashToSign}`); + } + return assertWellFormed(new ethers.Wallet(privateKey).signingKey.sign(hashToSign).serialized); +} diff --git a/lib/greeting-balance.ts b/lib/greeting-balance.ts new file mode 100644 index 0000000..674e2f5 --- /dev/null +++ b/lib/greeting-balance.ts @@ -0,0 +1,21 @@ +import { formatNaira, toKobo } from "./money"; + +// The money line in Aide's opening breath, as a pure function so what it says +// can be asserted without standing up a route. +// +// A balance of zero used to be skipped, which made it sound exactly like a +// balance that had not come back yet. For someone who cannot glance at the +// screen those are the same event, and only one of them means "stop waiting, +// nothing arrived". Zero is now stated. +// +// null still says nothing, and that is deliberate rather than an oversight: +// the greeting only waits 2.5s for the figure so Aide can start speaking +// promptly, so null usually means "slow", not "broken". Announcing a failure +// every slow morning would worry people about something routine, and asking +// again costs one sentence. +export function balanceLine(balance: number | null): string | null { + if (balance === null) return null; + const kobo = toKobo(balance); + if (kobo === 0) return `Your balance is ${formatNaira(0)} right now.`; + return `You have ${formatNaira(kobo)} in your account, ready to withdraw.`; +} diff --git a/lib/payments.ts b/lib/payments.ts index bc156a0..1ccf7b2 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -1,6 +1,6 @@ -import { randomUUID } from "node:crypto"; import * as store from "./store"; -import { singleTransfer, validateBankAccount } from "./monnify"; +import { paymentProvider } from "./banking"; +import { toKobo } from "./money"; import { spokenClientError } from "./spoken-error"; // Shared payment actions used by both the agent tools and the /payments page @@ -14,7 +14,7 @@ export async function registerPayout( bankCode: string, ): Promise<{ ok: true; accountName: string } | { ok: false; message: string }> { try { - const r = await validateBankAccount(accountNumber, bankCode); + const r = await paymentProvider().verifyDestination(accountId, accountNumber, bankCode); await store.setPayout(accountId, accountNumber, bankCode, r.accountName); return { ok: true, accountName: r.accountName }; } catch (e) { @@ -25,7 +25,8 @@ export async function registerPayout( } // Step 2 of a withdrawal: verify the spoken confirm word, then run the real -// Monnify transfer. Money only moves if the phrase matched within its TTL. +// transfer through whichever provider is live. Money only moves if the phrase +// matched within its TTL. export async function confirmWithdrawal(accountId: string, spokenPhrase: string): Promise< | { ok: true; @@ -41,24 +42,45 @@ export async function confirmWithdrawal(accountId: string, spokenPhrase: string) > { const check = await store.verifyWithdrawal(accountId, spokenPhrase); if (!check.ok) return check; + const provider = paymentProvider(); + + // BMONI registers a withdrawal destination by name as well as code, and the + // armed withdrawal only stored the code. Resolved from the provider's own + // list so the spelling is theirs — a name we invented would be rejected at + // registration, after the worker has already spoken their security phrase. + const bankName = (await provider.listBanks(accountId).catch(() => [])).find( + (b) => b.code === check.bankCode, + )?.name; + try { - const r = await singleTransfer({ - amount: check.amount, - reference: `aide-wd-${randomUUID().slice(0, 8)}`, - narration: "Aide withdrawal", - destinationAccountNumber: check.account, - destinationBankCode: check.bankCode, - destinationAccountName: check.accountName, + // One attempt, no retry, no early abort — the rule lives inside each + // adapter. Kobo across the seam; `check.amount` is naira. + const r = await provider.payOut({ + accountId, + amountKobo: toKobo(check.amount), + accountNumber: check.account, + bankCode: check.bankCode, + bankName: bankName ?? "", + accountName: check.accountName, }); - const pending = r.status === "PENDING_AUTHORIZATION"; - await store.recordWithdrawal(accountId, { amount: check.amount, accountName: check.accountName, status: r.status }); + + // An unknown outcome is never a completion. `pending` is what stops Aide + // saying the money has arrived, so anything that is not an outright + // success has to set it — including "unknown", where the transfer may well + // have gone through and we simply cannot say so yet. + const pending = r.state !== "completed"; + const status = r.state === "completed" ? "SUCCESS" : r.state === "failed" ? "FAILED" : "PENDING"; + if (r.state === "failed") { + return { ok: false, message: "The transfer did not go through. Your money is still in your account." }; + } + await store.recordWithdrawal(accountId, { amount: check.amount, accountName: check.accountName, status }); const known = (await store.listBeneficiaries(accountId)).some( (b) => b.accountNumber === check.account && b.bankCode === check.bankCode, ); return { ok: true, - status: r.status, + status, pending, amount: check.amount, message: pending ? "Withdrawal initiated and is being processed." : "Withdrawal completed.", diff --git a/lib/store/accounts.ts b/lib/store/accounts.ts index c4cce3e..60f2f7f 100644 --- a/lib/store/accounts.ts +++ b/lib/store/accounts.ts @@ -18,6 +18,10 @@ type ConvexAccount = { bio: string; preferences?: string[]; createdAt: number; + firstName?: string; + lastName?: string; + phoneNumber?: string; + bvn?: string; }; function toAccount(a: ConvexAccount): Account { @@ -31,6 +35,10 @@ function toAccount(a: ConvexAccount): Account { bio: a.bio, preferences: a.preferences ?? [], createdAt: a.createdAt, + firstName: a.firstName, + lastName: a.lastName, + phoneNumber: a.phoneNumber, + bvn: a.bvn, }; } @@ -48,8 +56,13 @@ const FALLBACK_WORKER: Account = { }; // The only shape of an account that may be serialized to the browser. -export function publicAccount(a: Account): Omit & { authenticated: boolean } { - const { passwordHash, ...rest } = a; +export function publicAccount(a: Account): Omit & { authenticated: boolean } { + // The BVN is stripped alongside the password hash, and for the same class of + // reason. It is not a profile detail: it is the identifier every Nigerian + // bank uses to tie a person to their accounts, it cannot be reissued if it + // leaks, and it is enough on its own to begin an identity fraud. Nothing in + // the browser needs it — it exists so BMONI can run KYC, server-side. + const { passwordHash, bvn: _bvn, ...rest } = a; return { ...rest, authenticated: !!passwordHash }; } diff --git a/lib/store/events.ts b/lib/store/events.ts index 41154b0..f9f16ce 100644 --- a/lib/store/events.ts +++ b/lib/store/events.ts @@ -1,6 +1,7 @@ -import { getReservedAccountTransactions } from "../monnify"; +import { paymentProvider } from "../banking"; +import { toNaira } from "../money"; import { state, type AideEvent } from "./state"; -import { cacheWalletBalance, listActiveWallets } from "./payments"; +import { cacheBalanceKobo, listActiveWallets } from "./payments"; import { publishConvexEvent } from "../convex-server"; // Live events: confirmed payments announced the moment they land, unprompted. @@ -60,21 +61,35 @@ export function ensurePolling(): void { let reachedProvider = false; for (const wallet of watched) { try { - const { content } = await getReservedAccountTransactions(wallet.accountReference); + // Through the seam. This poller is how Aide gets to say "₦5,000 just + // landed from Adebayo" — under BMONI it was polling Monnify's + // reserved-account transactions, an endpoint with nothing in it, so + // wages would have arrived in silence. Silence is the one thing a + // blind user cannot distinguish from a crash. + const provider = paymentProvider(); + const credits = await provider.listInbound(wallet.accountId); reachedProvider = true; - const paid = content.filter((t) => t.paymentStatus === "PAID"); - await cacheWalletBalance(wallet.accountId, paid.reduce((s, t) => s + t.amount, 0)); - for (const t of paid) { - const parsed = typeof t.createdOn === "number" ? t.createdOn : t.createdOn ? Date.parse(t.createdOn) : Date.now(); + + // The balance from the provider rather than a sum of the credits: for + // BMONI the wallet balance already nets off withdrawals, and summing + // credits would ignore every payout the worker has made. + await provider + .getBalanceKobo(wallet.accountId) + .then((kobo) => cacheBalanceKobo(wallet.accountId, kobo)) + .catch(() => { + /* the announcement matters more than the cache; getBalance will ask again */ + }); + + for (const c of credits) { publishEvent( wallet.accountId, { type: "payment", - amount: t.amountPaid ?? t.amount, - from: t.customerDTO?.name ?? "a bank transfer", - reference: t.transactionReference, + amount: toNaira(c.amountKobo), + from: c.from ?? "a bank transfer", + reference: c.reference, }, - Number.isNaN(parsed) ? Date.now() : parsed, + Number.isFinite(c.at) ? c.at : Date.now(), ); } } catch (e) { diff --git a/lib/store/payments.ts b/lib/store/payments.ts index b78d7fd..2b56639 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -1,15 +1,20 @@ import { createHash } from "node:crypto"; -import { createReservedAccount, getReservedAccount, getReservedAccountTransactions, validateBankAccount } from "../monnify"; +import { createReservedAccount, getReservedAccount } from "../monnify"; +import { paymentProvider, selectedProvider } from "../banking"; +import { destinationConfirmation, isFabricatedNameEnquiry } from "../banking/name-enquiry"; import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { getAccount } from "./accounts"; import { type Beneficiary, type Wallet, type WithdrawalRecord } from "./state"; import { toKobo, toNaira } from "../money"; -// Per-account Monnify wallets, now backed by Convex so balances, payout -// destinations, and armed withdrawals are shared across serverless instances. -// available = confirmed inbound transfers to this wallet's NUBAN − this -// wallet's withdrawals (the Convex ledger). +// Per-account wallets, backed by Convex so balances, payout destinations, and +// armed withdrawals are shared across serverless instances. +// +// The money itself lives with whichever provider is selected — BMONI by +// default. The balance and the receiving account number both come from +// `paymentProvider()` rather than from any one provider's SDK, so this file no +// longer knows which rail it is on. const CONFIRM_WORDS = ["mango", "sunrise", "guitar", "river", "orange", "candle", "harvest", "compass"]; const PENDING_TTL_MS = 5 * 60 * 1000; @@ -45,8 +50,10 @@ type WalletDoc = { accountId: string; accountReference: string; status: "unprovisioned" | "active" | "failed"; + provider?: string; accountNumber?: string; bankName?: string; + accountName?: string; lastError?: string; payoutAccount?: string; payoutBankCode?: string; @@ -84,8 +91,10 @@ function toWallet(d: WalletDoc): Wallet { accountId: d.accountId, accountReference: d.accountReference, status: d.status, + provider: d.provider, accountNumber: d.accountNumber, bankName: d.bankName, + accountName: d.accountName, lastError: d.lastError, payoutAccount: d.payoutAccount, payoutBankCode: d.payoutBankCode, @@ -123,7 +132,47 @@ export function ensureWallet(accountId: string): Promise { const p = (async () => { const wallet = await getWallet(accountId); - if (wallet.status === "active") return wallet; + // Active is not enough — it has to be active for the provider that is + // LIVE. A wallet Monnify provisioned satisfies `status === "active"` + // forever, so returning here left switched deployments permanently + // half-migrated: the old NUBAN still on screen, no BMONI user ever + // created, and every BMONI call failing on an account that looked healthy. + // + // A row with no recorded provider is Monnify's; that is all there was when + // those rows were written. + if (wallet.status === "active" && (wallet.provider ?? "monnify") === selectedProvider()) return wallet; + + // BMONI provisions through its own multi-step lifecycle (user → wallet → + // onboarding → virtual account), so the seam owns it. Only Monnify's + // reserved-account creation is inline below, because the seam's Monnify + // adapter calls back into this very function. + if (selectedProvider() !== "monnify") { + const ref = wallet.accountReference; + const { accountNumber, bankName, accountName } = await paymentProvider().ensureWallet(accountId); + if (!accountNumber || !bankName) { + // No account number means nothing to give an employer. Recording it as + // active would have Aide read out a blank where a NUBAN should be. + throw new Error(`${selectedProvider()} returned no receiving account for ${accountId}`); + } + await convexClient().mutation(api.wallets.setProvisioned, { + accountId, + accountReference: ref, + accountNumber, + bankName, + accountName, + provider: selectedProvider(), + }); + return { + ...wallet, + status: "active" as const, + provider: selectedProvider(), + accountNumber, + bankName, + accountName, + lastError: undefined, + }; + } + const acc = await getAccount(accountId); const ref = wallet.accountReference; let reserved; @@ -141,8 +190,8 @@ export function ensureWallet(accountId: string): Promise { } const accountNumber = reserved.accounts[0].accountNumber; const bankName = reserved.accounts[0].bankName; - await convexClient().mutation(api.wallets.setProvisioned, { accountId, accountReference: ref, accountNumber, bankName }); - return { ...wallet, status: "active" as const, accountNumber, bankName, lastError: undefined }; + await convexClient().mutation(api.wallets.setProvisioned, { accountId, accountReference: ref, accountNumber, bankName, provider: "monnify" }); + return { ...wallet, status: "active" as const, provider: "monnify", accountNumber, bankName, lastError: undefined }; })(); inFlight.set( @@ -186,6 +235,13 @@ export async function cacheWalletBalance(accountId: string, inboundTotal: number balanceCache.set(accountId, { value: await availableFrom(accountId, inboundTotal), at: Date.now() }); } +// The provider's own figure, already spendable — nothing is derived from it. +// Used by the arrival poller, which has just read the balance anyway and +// should not make the next getBalance() ask again. +export function cacheBalanceKobo(accountId: string, kobo: number): void { + balanceCache.set(accountId, { value: toNaira(kobo), at: Date.now() }); +} + // TEMPORARY, and deliberately awkward to leave switched on. // // AIDE_DEMO_BALANCE stands in for the real figure ONLY when the bank cannot be @@ -212,9 +268,8 @@ export async function getBalance(accountId: string): Promise<{ balance: number; if (cached && Date.now() - cached.at < BALANCE_TTL_MS && w.accountNumber) { return { balance: cached.value, account: w.accountNumber, bankName: w.bankName }; } - const { content } = await getReservedAccountTransactions(w.accountReference); - const inbound = content.filter((t) => t.paymentStatus === "PAID").reduce((s, t) => s + t.amount, 0); - const balance = await availableFrom(accountId, inbound); + // Kobo from the provider, naira out to callers that still speak it. + const balance = toNaira(await paymentProvider().getBalanceKobo(accountId)); balanceCache.set(accountId, { value: balance, at: Date.now() }); return { balance, account: w.accountNumber, bankName: w.bankName }; } catch (e) { @@ -238,12 +293,25 @@ export async function getBalance(accountId: string): Promise<{ balance: number; export async function recordWithdrawal(accountId: string, r: Omit): Promise { // r.amount is naira at this boundary; the ledger stores whole kobo. - await convexClient().mutation(api.wallets.recordWithdrawal, { accountId, amountKobo: toKobo(r.amount), accountName: r.accountName, status: r.status, at: Date.now() }); + await convexClient().mutation(api.wallets.recordWithdrawal, { + accountId, + amountKobo: toKobo(r.amount), + accountName: r.accountName, + status: r.status, + at: Date.now(), + // Stamped at write time. A row that does not name the provider that moved + // the money is indistinguishable from a Monnify-era one, and the next + // switch would hide a real payout instead of a stranded one. + provider: selectedProvider(), + }); balanceCache.delete(accountId); // money left — never serve a stale total } export async function getWithdrawals(accountId: string): Promise { - const rows = (await convexClient().query(api.wallets.listWithdrawals, { accountId })) as WithdrawalRecord[]; + const rows = (await convexClient().query(api.wallets.listWithdrawals, { + accountId, + provider: selectedProvider(), + })) as WithdrawalRecord[]; return rows.map((r) => ({ accountId: r.accountId, amount: r.amount, accountName: r.accountName, status: r.status, at: r.at })); } @@ -332,22 +400,48 @@ async function resolveDestination( accountId: string, dest: WithdrawalDestination | undefined, wallet: Wallet, -): Promise<{ ok: true; account: string; bankCode: string; accountName: string; addedAt?: number } | { ok: false; message: string }> { +): Promise< + | { ok: true; account: string; bankCode: string; accountName: string; nameVerified: boolean; addedAt?: number } + | { ok: false; message: string } +> { if (dest?.accountNumber && dest?.bankCode) { try { - const r = await validateBankAccount(dest.accountNumber.trim(), dest.bankCode.trim()); - return { ok: true, account: r.accountNumber, bankCode: dest.bankCode.trim(), accountName: r.accountName, addedAt: Date.now() }; + // Through the seam: the bank CODES differ per provider, so verifying a + // BMONI destination against Monnify's name enquiry resolves a different + // bank or none at all. This is the check that reads a real account + // holder's name back before a worker approves the transfer. + const r = await paymentProvider().verifyDestination(accountId, dest.accountNumber.trim(), dest.bankCode.trim()); + return { + ok: true, + account: r.accountNumber, + bankCode: dest.bankCode.trim(), + accountName: r.accountName, + nameVerified: r.nameVerified, + addedAt: Date.now(), + }; } catch { return { ok: false, message: "Bank details not found — check the account number and bank, then try again." }; } } + // A saved beneficiary's name was captured by the same name enquiry. If that + // endpoint fabricates, the stored name is a fabrication that has merely been + // sitting in the database a while — age does not make it true. + const savedNamesTrustworthy = !isFabricatedNameEnquiry(selectedProvider(), process.env.BMONI_BASE_URL); + const beneficiaries = await listBeneficiaries(accountId); if (dest?.beneficiaryName) { const q = dest.beneficiaryName.trim().toLowerCase(); const matches = beneficiaries.filter((b) => b.accountName.toLowerCase().includes(q)); if (matches.length === 1) { const b = matches[0]; - return { ok: true, account: b.accountNumber, bankCode: b.bankCode, accountName: b.accountName, addedAt: b.at }; + return { + ok: true, + account: b.accountNumber, + bankCode: b.bankCode, + accountName: b.accountName, + nameVerified: savedNamesTrustworthy, + addedAt: b.at, + }; } if (matches.length > 1) { return { ok: false, message: `More than one saved beneficiary matches "${dest.beneficiaryName}" — say the full name.` }; @@ -356,14 +450,28 @@ async function resolveDestination( } if (beneficiaries.length === 1) { const b = beneficiaries[0]; - return { ok: true, account: b.accountNumber, bankCode: b.bankCode, accountName: b.accountName, addedAt: b.at }; + return { + ok: true, + account: b.accountNumber, + bankCode: b.bankCode, + accountName: b.accountName, + nameVerified: savedNamesTrustworthy, + addedAt: b.at, + }; } if (beneficiaries.length > 1) { const names = beneficiaries.map((b) => b.accountName).join(", "); return { ok: false, message: `Which account should the money go to? Your saved beneficiaries are: ${names}. Or give a new account number and bank.` }; } if (wallet.payoutAccount && wallet.payoutBankCode && wallet.payoutAccountName) { - return { ok: true, account: wallet.payoutAccount, bankCode: wallet.payoutBankCode, accountName: wallet.payoutAccountName, addedAt: wallet.payoutSetAt }; + return { + ok: true, + account: wallet.payoutAccount, + bankCode: wallet.payoutBankCode, + accountName: wallet.payoutAccountName, + nameVerified: savedNamesTrustworthy, + addedAt: wallet.payoutSetAt, + }; } return { ok: false, message: "No destination account. Give the account number and the bank the money should go to." }; } @@ -374,7 +482,22 @@ async function resolveDestination( // personal spoken security phrase (the accessible OTP replacement); employers // confirm with a per-withdrawal random word. export async function armWithdrawal(accountId: string, amount: number, dest?: WithdrawalDestination): Promise< - | { ok: true; amount: number; accountName: string; account: string; mode: "word" | "passphrase"; phrase?: string } + | { + ok: true; + amount: number; + accountName: string; + account: string; + // False when the provider's name enquiry fabricates. The caller must not + // present or speak `accountName` as confirmation; `destination` is what + // to say instead. + nameVerified: boolean; + // The one line Aide reads before the money moves, already correct for + // whether the name means anything. Built here so the voice path and the + // screen path cannot drift apart on the single sentence that matters. + destination: string; + mode: "word" | "passphrase"; + phrase?: string; + } | { ok: false; message: string; needsSecurityPhrase?: boolean } > { const w = await getWallet(accountId); @@ -433,6 +556,12 @@ export async function armWithdrawal(accountId: string, amount: number, dest?: Wi amount, accountName: resolved.accountName, account: resolved.account, + nameVerified: resolved.nameVerified, + destination: destinationConfirmation({ + accountName: resolved.accountName, + accountNumber: resolved.account, + nameVerified: resolved.nameVerified, + }), mode, // The random word is only revealed for word mode; a worker's security // phrase is theirs and is never echoed back. diff --git a/lib/store/persona-phone.ts b/lib/store/persona-phone.ts new file mode 100644 index 0000000..3b177c8 --- /dev/null +++ b/lib/store/persona-phone.ts @@ -0,0 +1,32 @@ +import { createHash } from "node:crypto"; + +// A Nigerian E.164 phone number for a demo persona, unique to this deployment. +// +// BMONI enforces global uniqueness on phoneNumber across every team sharing the +// development sandbox, and the two documented persona numbers +// (+2348000000000 / +2348000000001) are the first thing anyone reaches for. +// They are already registered to other teams, so creating a user with one +// returns "409 User already exists with this phoneNumber" — and because the +// existing record carries someone else's email, the conflict recovery +// correctly refuses to adopt it rather than binding a worker to a stranger's +// wallet. +// +// The phone is NOT part of identity matching. Verified against the sandbox: a +// user created with a generated number, the persona's real name and the +// persona's BVN still completed start-nigeria and was issued a virtual account +// in the persona's name. Only the BVN and the name are matched. +// +// Derived rather than random because it must be STABLE. A number that changed +// between calls would create a fresh BMONI user on every provisioning attempt, +// and smart-wallet creation has no uniqueness guard behind it. +export function personaPhone(accountId: string, deployment: string): string { + const digits = createHash("sha256").update(`aide-persona:${deployment}:${accountId}`).digest("hex").replace(/\D/g, ""); + // 0803… is a real Nigerian mobile prefix range; 9 more digits after the 8. + return `+2348${digits.slice(0, 9).padEnd(9, "0")}`; +} + +// The deployment this app is pointed at, used to keep two clones of the repo +// from claiming the same numbers. +export function currentDeployment(): string { + return process.env.CONVEX_DEPLOYMENT?.trim() || process.env.NEXT_PUBLIC_CONVEX_URL?.trim() || "local"; +} diff --git a/lib/store/seed.ts b/lib/store/seed.ts index 2e997e8..661fc10 100644 --- a/lib/store/seed.ts +++ b/lib/store/seed.ts @@ -1,6 +1,37 @@ import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { worker } from "./state"; +import { currentDeployment, personaPhone } from "./persona-phone"; + +// The two BMONI sandbox personas. Only these BVNs resolve, so the demo worker +// and demo employer ARE these people — the names on the payments page, the +// virtual accounts money is paid into, and the identities BMONI onboards are +// all one and the same. +// +// Seeded rather than collected because there is nobody to collect them from: +// a judge opening the app for the first time falls straight into demo-worker, +// and provisioning refuses without a name split, an E.164 phone and a BVN. +// +// The emails are deliberately on a real domain. BMONI rejects card issuance +// with "The email address on your account is invalid" for made-up ones like +// @aide.test, and it says so only at the card step — long after the account +// looks fine. +// The phone numbers are DERIVED, not the documented persona ones. BMONI +// enforces global uniqueness on phoneNumber across every team on the shared +// sandbox, so +2348000000000 and +2348000000001 are long since taken — and the +// phone is not what identity matching uses. See lib/store/persona-phone.ts. +const PERSONAS = { + worker: { + firstName: "Bunch", + lastName: "Dillon", + bvn: "95888168924", + }, + employer: { + firstName: "Samson", + lastName: "Jabo", + bvn: "22222222222", + }, +} as const; // A freshly created Convex deployment — a teammate's, a judge's, a preview // branch's — starts completely empty, so the demo identities that every @@ -24,14 +55,21 @@ export function ensureSeeded(): Promise { skills: [...worker.skills], bio: worker.bio, createdAt: Date.now(), + ...PERSONAS.worker, + phoneNumber: personaPhone(worker.id, currentDeployment()), }, { key: "demo-employer", name: "ClearVoice Media", role: "employer" as const, + // BMONI will not create a user without one, and an employer with no + // BMONI user cannot pay anybody. + email: "aide-demo-employer@aide.test", skills: [], bio: "", createdAt: Date.now(), + ...PERSONAS.employer, + phoneNumber: personaPhone("demo-employer", currentDeployment()), }, ], }) diff --git a/lib/store/state.ts b/lib/store/state.ts index a1b2474..19939e6 100644 --- a/lib/store/state.ts +++ b/lib/store/state.ts @@ -69,6 +69,15 @@ export type Account = { // Present on real credentialed accounts; absent on the passwordless demo // identities. Never leaves the server — always strip via publicAccount(). passwordHash?: string; + // The BMONI identity. Separate from `name` because BMONI needs a first/last + // split and Aide only ever collected one free-text string; splitting it + // would be a guess about someone's legal name, which fails KYC in a way + // nobody can debug afterwards. Absent on accounts that have not supplied + // them, and provisioning refuses rather than inventing any of it. + firstName?: string; + lastName?: string; + phoneNumber?: string; // E.164 + bvn?: string; // 11 digits }; // A record of money leaving via voice-confirmed withdrawal — Monnify has no @@ -113,8 +122,13 @@ export type Wallet = { accountId: string; accountReference: string; status: "unprovisioned" | "active" | "failed"; + // Which provider provisioned it. Absent on rows written before the field, + // which are Monnify's. + provider?: string; accountNumber?: string; bankName?: string; + // The name the bank holds for this account. Distinct from the profile name. + accountName?: string; lastError?: string; payoutAccount?: string; payoutBankCode?: string; @@ -169,7 +183,7 @@ const SEED_JOBS: Job[] = [ function seedState(): StoreState { const w: Worker = { id: "demo-worker", - name: "Aide Demo Worker", + name: "Bunch Dillon", email: "aide-demo-worker@aide.test", skills: ["audio transcription", "translation", "data entry"], bio: "Experienced transcriber fluent in English and Yoruba. Detail-oriented and dedicated to delivering clean text under tight schedules.", diff --git a/lib/voice/pidgin.ts b/lib/voice/pidgin.ts new file mode 100644 index 0000000..cc096be --- /dev/null +++ b/lib/voice/pidgin.ts @@ -0,0 +1,138 @@ +// Nigerian Pidgin, arriving through an English speech recognizer. +// +// The Web Speech API has no Pidgin model. `pcm` is not a language tag it +// accepts, so `en-NG` is the closest available and pidgin comes back rendered +// as the nearest English words — "dey" as "day", "wetin" as "we tin", "sabi" +// as "savvy", "comot" as "come out". The agent then reads English that means +// something else, or nothing, and asks the user to repeat themselves. +// +// THE RULE THAT OUTRANKS EVERYTHING HERE: never touch anything that could be +// an amount. This text becomes a withdrawal. Misunderstanding a sentence costs +// one "sorry, say that again"; rewriting "one thousand" costs a worker money +// they cannot see to check. Every rule below is anchored on words that make +// the number reading impossible. + +// Words that only appear in pidgin, used to score a candidate transcript. +// Deliberately excludes anything that is also ordinary English ("day", "one", +// "come out"), so the score cannot be inflated by an English sentence. +const MARKERS = [ + "abeg", + "wetin", + "dey", + "comot", + "sabi", + "una", + "oya", + "abi", + "wahala", + "naija", + "biko", + "shey", + "chop", + "waka", + "yawa", + "gbese", + "kudi", + "ego", + "oga", + "madam", + "pikin", + "wey", + "make i", + "no be", + "na so", + "how far", + "i go", + "e go", + "e be", + "dem", +]; + +// How much a transcript reads as Pidgin. Used only to choose between the +// recognizer's own alternatives — never to reject what the user said. +export function pidginScore(text: string): number { + const t = ` ${text.toLowerCase()} `; + let score = 0; + for (const m of MARKERS) { + // Word-boundary anchored so "dem" does not match "modem" and "ego" does + // not match "category". + const re = new RegExp(`(^|[^a-z])${m.replace(/ /g, "\\s+")}([^a-z]|$)`, "g"); + score += (t.match(re) ?? []).length; + } + return score; +} + +// The Web Speech API returns several candidate transcriptions ranked by its +// own English language model, which systematically prefers English readings of +// pidgin. The right words are often present but second. +// +// The first alternative wins ties and wins outright unless another is +// STRICTLY more pidgin — the recognizer is better at this than the heuristic +// is, and should only be overruled on positive evidence. +export function bestAlternative(alternatives: string[]): string { + if (alternatives.length === 0) return ""; + let best = alternatives[0]; + let bestScore = pidginScore(best); + for (const alt of alternatives.slice(1)) { + const score = pidginScore(alt); + if (score > bestScore) { + best = alt; + bestScore = score; + } + } + return best; +} + +// Case is left exactly as the recognizer produced it, apart from the words +// being replaced. An earlier version tried to carry the leading capital across +// and turned "I dey" into "I Dey" — capitalisation is not worth a rule that +// can misfire on text headed for a money command. + +// Ordered. Each entry is anchored on context that rules out the English +// reading — that anchoring is the whole safety argument. +const REPAIRS: Array<[RegExp, string]> = [ + // "wetin" — the recognizer splits it or spells it phonetically. No English + // reading of "we tin" exists, so this is unconditional. + [/\bwe\s+tin\b/gi, "wetin"], + [/\bweting\b/gi, "wetin"], + [/\bwe\s+ting\b/gi, "wetin"], + + // "abeg" (please). "I beg" and "a beg" are how it lands. In Nigerian usage + // "I beg" IS this word, so nothing is lost. + [/\b(?:i|a)\s+beg\b/gi, "abeg"], + + // "dey" — only where "day" cannot be a day. Anchored on a pronoun or + // negation in front, or a possessive/preposition behind. "every day", + // "three day", "next day" are untouched because none of these match. + [/\b(i|you|we|dem|e|he|she|they|it|no|money|work|who|wetin|dis|this)\s+day\b/gi, "$1 dey"], + [/\bday\s+(my|your|our|their|for|there|here|inside|come|go|find|wait|work)\b/gi, "dey $1"], + + // "comot" (take out / remove). "come out my money" is not English. + [/\bcome\s+out\s+(my|the|all)\b/gi, "comot $1"], + + // "sabi" (know). "savvy" is the recognizer's nearest word. + [/\bsavvy\b/gi, "sabi"], + + // "wan" (want). ONLY after a pronoun and ONLY when what follows is not a + // number — "I one thousand naira" is a badly-spoken amount, not "I want". + // This is the rule that would do real damage if it were loose. + [ + /\b(i|we|you|dem|e|he|she|they)\s+one\s+(?!hundred|thousand|million|billion|naira|zero|one|two|three|four|five|six|seven|eight|nine|ten|\d)/gi, + "$1 wan ", + ], + + // "una" (you plural), heard as two words. + [/\byou\s+nah\b/gi, "una"], + [/\boona\b/gi, "una"], + + // "oya" (come on / let's go). + [/\boh\s+ya\b/gi, "oya"], +]; + +export function normalizePidgin(text: string): string { + let out = text; + for (const [pattern, replacement] of REPAIRS) out = out.replace(pattern, replacement); + // Collapse whitespace the substitutions may have doubled, without trimming + // meaning. + return out.replace(/[ \t]{2,}/g, " ").trim(); +} diff --git a/package-lock.json b/package-lock.json index b23cd99..e070296 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "ai": "^4.3.0", "convex": "^1.42.3", "dotenv": "^16.4.5", + "ethers": "^6.17.0", "express": "^4.19.2", "next": "^15.3.0", "react": "^19.1.0", @@ -33,6 +34,12 @@ "vitest": "^4.1.11" } }, + "node_modules/@adraffy/ens-normalize": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.11.1.tgz", + "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==", + "license": "MIT" + }, "node_modules/@ai-sdk/deepseek": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-0.2.16.tgz", @@ -1274,6 +1281,30 @@ "node": ">= 10" } }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@opentelemetry/api": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", @@ -2153,6 +2184,12 @@ "node": ">= 0.6" } }, + "node_modules/aes-js": { + "version": "4.0.0-beta.5", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz", + "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==", + "license": "MIT" + }, "node_modules/ai": { "version": "4.3.19", "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.19.tgz", @@ -3072,6 +3109,55 @@ "node": ">= 0.6" } }, + "node_modules/ethers": { + "version": "6.17.0", + "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.17.0.tgz", + "integrity": "sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/ethers-io/" + }, + { + "type": "individual", + "url": "https://www.buymeacoffee.com/ricmoo" + } + ], + "license": "MIT", + "dependencies": { + "@adraffy/ens-normalize": "1.11.1", + "@noble/curves": "1.2.0", + "@noble/hashes": "1.3.2", + "@types/node": "22.7.5", + "aes-js": "4.0.0-beta.5", + "tslib": "2.7.0", + "ws": "8.21.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ethers/node_modules/@types/node": { + "version": "22.7.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz", + "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/ethers/node_modules/tslib": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz", + "integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==", + "license": "0BSD" + }, + "node_modules/ethers/node_modules/undici-types": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", @@ -3934,7 +4020,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, diff --git a/package.json b/package.json index 2485abc..37d142f 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "ai": "^4.3.0", "convex": "^1.42.3", "dotenv": "^16.4.5", + "ethers": "^6.17.0", "express": "^4.19.2", "next": "^15.3.0", "react": "^19.1.0", diff --git a/tests/money/balance-unavailable.test.ts b/tests/money/balance-unavailable.test.ts index 69f63e4..c452447 100644 --- a/tests/money/balance-unavailable.test.ts +++ b/tests/money/balance-unavailable.test.ts @@ -38,6 +38,12 @@ const ask = async () => { }; beforeEach(() => { + // These exercise the Monnify adapter specifically — the Monnify HTTP module + // is what they mock. BMONI is the default provider now, so the rail under + // test is named rather than assumed; without this they would run against + // BMONI and fail on a missing sandbox user rather than on anything they are + // actually asserting. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); vi.clearAllMocks(); store.getAccount.mockResolvedValue(ACCOUNT); store.getWallet.mockResolvedValue(WALLET); diff --git a/tests/money/bmoni-amounts.test.ts b/tests/money/bmoni-amounts.test.ts new file mode 100644 index 0000000..6672af1 --- /dev/null +++ b/tests/money/bmoni-amounts.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { decimalStringToKobo, koboToDecimalString } from "../../lib/banking/amounts"; + +// The boundary where Aide's integer kobo meets BMONI's decimal strings. +// +// BMONI mixes three amount formats across its API — decimal strings ("100.00" +// for NGN offramps), minor-unit strings ("100000000"), and plain numbers for +// card transactions — and none of them are type-checked. Every one arrives as +// a `string` or a `number`, so nothing catches a unit mistake but this file. +// +// The direction that matters most is parsing. `parseFloat` is far more +// permissive than an amount of money should ever be: it takes the leading +// digits of "100abc", expands "1e3" to a thousand, and rounds "100.005" UP to +// a kobo that does not exist. Each of those produces a plausible number that +// Aide would then state out loud as fact. +// +// So parsing fails closed. A refusal surfaces as "I could not check that just +// now", which is true; a silently wrong figure is Aide asserting a financial +// fact that is not so, to someone with no screen to check it against. + +describe("kobo to a BMONI decimal string", () => { + it("always carries exactly two decimal places", () => { + expect(koboToDecimalString(10_000)).toBe("100.00"); + expect(koboToDecimalString(1)).toBe("0.01"); + expect(koboToDecimalString(0)).toBe("0.00"); + expect(koboToDecimalString(12_345)).toBe("123.45"); + }); + + it("refuses a figure that is not whole kobo", () => { + // A naira float that reached here unconverted. + expect(() => koboToDecimalString(100.5)).toThrow(); + }); +}); + +describe("a BMONI decimal string to kobo", () => { + it("reads the amounts BMONI actually sends", () => { + expect(decimalStringToKobo("100.00")).toBe(10_000); + expect(decimalStringToKobo("0.01")).toBe(1); + expect(decimalStringToKobo("100")).toBe(10_000); + expect(decimalStringToKobo("100.5")).toBe(10_050); + }); + + it("round-trips whatever we sent", () => { + for (const kobo of [1, 7, 99, 100, 101, 12_345, 999_999_99]) { + expect(decimalStringToKobo(koboToDecimalString(kobo))).toBe(kobo); + } + }); + + it("refuses trailing garbage instead of taking the leading digits", () => { + // parseFloat("100abc") is 100. A truncated or corrupted field would be + // read as a real amount and paid out. + expect(() => decimalStringToKobo("100abc")).toThrow(); + expect(() => decimalStringToKobo("100.00 NGN")).toThrow(); + }); + + it("refuses scientific notation", () => { + // parseFloat("1e3") is 1000 — a hundred naira becomes a thousand. + expect(() => decimalStringToKobo("1e3")).toThrow(); + }); + + it("refuses precision finer than a kobo rather than rounding it away", () => { + // parseFloat("100.005")*100 rounds to 10001 — a kobo that never existed. + // Refusing is the only honest answer: we cannot pay a fraction of a kobo, + // and inventing one is Aide making up money. + expect(() => decimalStringToKobo("100.005")).toThrow(); + expect(() => decimalStringToKobo("100.999")).toThrow(); + }); + + it("refuses what is not a number at all", () => { + expect(() => decimalStringToKobo("")).toThrow(); + expect(() => decimalStringToKobo("abc")).toThrow(); + expect(() => decimalStringToKobo("Infinity")).toThrow(); + expect(() => decimalStringToKobo("NaN")).toThrow(); + }); + + it("refuses a negative amount", () => { + // No BMONI field Aide reads is legitimately negative, and a minus sign + // slipping through would flip the direction money moves. + expect(() => decimalStringToKobo("-5.00")).toThrow(); + }); + + it("tolerates surrounding whitespace, which is not ambiguous", () => { + expect(decimalStringToKobo(" 100.00 ")).toBe(10_000); + }); +}); diff --git a/tests/money/bmoni-balance-path.test.ts b/tests/money/bmoni-balance-path.test.ts new file mode 100644 index 0000000..4c6d0e9 --- /dev/null +++ b/tests/money/bmoni-balance-path.test.ts @@ -0,0 +1,119 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeDispatch, walletDoc, type ConvexCall, type Handlers } from "../helpers/fake-convex"; + +// The balance the payments page and the greeting now read, end to end: store → +// seam → BMONI adapter. Previously nothing exercised this at the store level, +// because nothing routed through the seam at all. +// +// The thing being pinned down is that Aide does NOT re-derive the figure. +// Monnify has no balance for a reserved account, so its adapter builds one by +// subtracting Aide's own withdrawal ledger from confirmed credits. BMONI holds +// the wallet, and the payouts have already left it — subtracting the ledger a +// second time would understate a worker's money by everything they have ever +// withdrawn. + +const hoisted = vi.hoisted(() => ({ + dispatch: (_ref: any, _args: any): any => { + throw new Error("dispatch not installed"); + }, +})); + +vi.mock("../../lib/convex-server", () => ({ + convexClient: () => ({ + query: (ref: any, args: any) => hoisted.dispatch(ref, args), + mutation: (ref: any, args: any) => hoisted.dispatch(ref, args), + }), + publishConvexEvent: () => Promise.resolve(), +})); + +const bmoni = vi.hoisted(() => ({ balances: vi.fn() })); + +// Only the HTTP transport is faked. parseNgnBalanceKobo, the kobo conversion +// and the seam all run for real, so a shape change would surface here. +vi.mock("../../lib/banking/bmoni-client", () => ({ + bmoniRead: (call: any) => bmoni.balances(call), + bmoniMove: (call: any) => bmoni.balances(call), + BmoniError: class extends Error {}, +})); + +let getBalance: typeof import("../../lib/store/payments").getBalance; +let calls: ConvexCall[]; + +beforeEach(async () => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "bmoni"); + vi.stubEnv("BMONI_API_KEY", "pk_test"); + ({ getBalance } = await import("../../lib/store/payments")); + calls = []; + const handlers: Handlers = { + "accounts:seedDefaults": () => null, + "wallets:getByAccount": () => ({ + ...walletDoc(), + status: "active", + // Provisioned BY BMONI. Without this the wallet reads as a Monnify-era + // row and ensureWallet correctly re-provisions it before reading a + // balance — see tests/money/wallet-provider-switch.test.ts. + provider: "bmoni", + accountNumber: "4534076021", + bankName: "PROVIDUS BANK", + bmoniUserId: "54db4056-620d-4aae-8205-88cb1b6483c8", + bmoniSmartWalletId: "6ce865d5-a0d6-4e43-b92c-bf3474891443", + }), + "wallets:ensure": () => null, + "wallets:setProvisioned": () => null, + // Deliberately non-zero: if the store still subtracted this, the assertions + // below would come out ₦500 short. + "wallets:withdrawnTotal": () => 50_000, + }; + hoisted.dispatch = makeDispatch(handlers, calls); + bmoni.balances.mockReset(); +}); + +const balances = (balance: string, currency = "NGN", error: string | null = null) => ({ + smartAccountAddress: "0x7C0d36A19870D267A95338623882E03BCd50c0b4", + balances: [{ smartWalletId: "6ce865d5-a0d6-4e43-b92c-bf3474891443", currency, balance, error }], +}); + +describe("the balance, read from BMONI", () => { + it("reports what BMONI holds, in naira", async () => { + bmoni.balances.mockResolvedValue(balances("12000.50")); + expect((await getBalance("demo-worker")).balance).toBe(12000.5); + }); + + it("does not subtract Aide's withdrawal ledger a second time", async () => { + // BMONI has already debited the wallet for every payout. ₦500 of + // withdrawals is in the fake ledger above; the answer must ignore it. + bmoni.balances.mockResolvedValue(balances("12000")); + expect((await getBalance("demo-worker")).balance).toBe(12000); + }); + + it("reports a genuine zero as zero", async () => { + // The state the demo worker is actually in. Zero is a real answer and has + // to survive the whole path — the greeting says it out loud. + bmoni.balances.mockResolvedValue(balances("0")); + expect((await getBalance("demo-worker")).balance).toBe(0); + }); + + it("returns the BMONI account details alongside it", async () => { + bmoni.balances.mockResolvedValue(balances("0")); + const r = await getBalance("demo-worker"); + expect(r.account).toBe("4534076021"); + expect(r.bankName).toBe("PROVIDUS BANK"); + }); + + it("fails rather than reporting zero when BMONI cannot be reached", async () => { + // Unknown is not empty. A zero here would be read aloud as "your balance is + // ₦0.00", which is indistinguishable from a true empty account to someone + // who cannot see the screen. + bmoni.balances.mockRejectedValue(new Error("fetch failed")); + await expect(getBalance("demo-worker")).rejects.toThrow(/fetch failed/); + }); + + it("fails rather than reporting zero when BMONI admits it could not price the wallet", async () => { + // A per-entry `error` arrives inside an HTTP 200, alongside a `balance` of + // "0" that means nothing. + bmoni.balances.mockResolvedValue(balances("0", "NGN", "upstream timeout")); + await expect(getBalance("demo-worker")).rejects.toThrow(/upstream timeout/); + }); +}); diff --git a/tests/money/bmoni-cards.test.ts b/tests/money/bmoni-cards.test.ts new file mode 100644 index 0000000..01bd025 --- /dev/null +++ b/tests/money/bmoni-cards.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { parseCards } from "../../lib/banking/bmoni-cards"; + +// A card on this page is read by someone who cannot see it, so the states have +// to be distinguishable in words, not just in colour. There are three real +// ones and they mean different things to a worker: no card at all, a card +// asked for and still being issued, and a live card. +// +// The list shape is documented; the issued-card payload is not fully — BMONI +// publishes no field name for a masked PAN or expiry, and none of the wallets +// on the sandbox has an issued card to read. So this parser exposes what is +// documented and does NOT invent a number to print on the front of the card. + +describe("the card list", () => { + it("reports no card when the wallet has none", () => { + expect(parseCards({ cards: [] })).toEqual([]); + }); + + it("reads a live card", () => { + const live = { + cards: [ + { + id: "card-1", + cardName: "Aide Wages", + cardColor: "#1B4332", + currency: "NGN", + type: "virtual", + status: "active", + fundedAt: "2026-09-04T01:00:00.000Z", + }, + ], + }; + expect(parseCards(live)).toEqual([ + { + id: "card-1", + name: "Aide Wages", + color: "#1B4332", + currency: "NGN", + type: "virtual", + status: "active", + reserved: false, + spoken: "Your Aide Wages card is active.", + }, + ]); + }); + + it("keeps a requested-but-unissued card visible as its own state", () => { + // A reserved entry has no card id — `id` is the issuance proposal id. If + // this were filtered out, a worker who asked for a card would be told they + // have none, and would reasonably ask for a second one. + const reserved = { + cards: [ + { + id: "b7c9e9a2-6a54-4f24-9a83-2f6f7c1d9e10", + proposalId: "b7c9e9a2-6a54-4f24-9a83-2f6f7c1d9e10", + isReserved: true, + status: "RESERVED", + proposalStatus: "PENDING_SIGNATURES", + cardName: "Aide Wages", + cardColor: "#1B4332", + currency: "NGN", + type: "virtual", + }, + ], + }; + const [c] = parseCards(reserved); + expect(c.reserved).toBe(true); + expect(c.spoken).toBe("Your Aide Wages card is still being issued."); + }); + + it("does not claim an unknown issuer status is usable", () => { + // The issuer's status vocabulary is open — BMONI's own docs say to treat it + // as such and fall through to a neutral state rather than asserting an + // exhaustive match. Saying "active" for a status we do not recognise would + // send someone to a shop with a card that declines. + const [c] = parseCards({ + cards: [{ id: "card-9", cardName: "Aide Wages", currency: "NGN", type: "virtual", status: "restricted" }], + }); + expect(c.spoken).toBe("Your Aide Wages card is not usable right now."); + }); + + it("reads the status case-insensitively", () => { + // Documented as the issuer's vocabulary, and observed in both cases. + const [c] = parseCards({ + cards: [{ id: "card-2", cardName: "Aide Wages", currency: "NGN", type: "virtual", status: "ACTIVE" }], + }); + expect(c.spoken).toBe("Your Aide Wages card is active."); + }); + + it("never carries a card number", () => { + // The PAN and CVV come only from POST /cards/sensitive-data, which the docs + // say to pass straight to the surface that renders it and never cache. A + // view model that could hold one would eventually be logged. + const [c] = parseCards({ + cards: [{ id: "card-1", cardName: "Aide Wages", currency: "NGN", type: "virtual", status: "active" }], + }); + expect(Object.keys(c)).not.toContain("pan"); + expect(JSON.stringify(c)).not.toMatch(/\d{13,19}/); + }); + + it("throws when the response is not a card list", () => { + expect(() => parseCards({})).toThrow(/cards/); + }); +}); diff --git a/tests/money/bmoni-client.test.ts b/tests/money/bmoni-client.test.ts new file mode 100644 index 0000000..6957768 --- /dev/null +++ b/tests/money/bmoni-client.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The transport rule, and why it is not symmetric. +// +// BMONI has no idempotency keys anywhere. A read that gives up costs one more +// request. A transfer that gives up costs a worker their wages — or pays them +// twice, which is the same money gone from someone else. So reads may time out +// and retry; money-moving calls may do neither. +// +// The subtle half is that aborting a slow transfer does not cancel it. The +// server carries on; all the abort destroys is our record of having asked. That +// is how a "failed" withdrawal becomes a second one. + +let client: typeof import("../../lib/banking/bmoni-client"); +let fetchMock: ReturnType; + +const ok = (data: unknown) => + ({ ok: true, status: 200, text: () => Promise.resolve(JSON.stringify({ data })) }) as any; +const fail = (status: number, body: unknown) => + ({ ok: false, status, text: () => Promise.resolve(JSON.stringify(body)) }) as any; + +beforeEach(async () => { + vi.resetModules(); + process.env.BMONI_API_KEY = "pk_test_key"; + process.env.BMONI_BASE_URL = "https://embedded-dev.bmoni.test"; + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + client = await import("../../lib/banking/bmoni-client"); +}); + +describe("authentication and shape", () => { + it("sends the partner key as x-api-key on every call", async () => { + fetchMock.mockResolvedValue(ok({ id: "u1" })); + await client.bmoniRead({ path: "/v1/users/u1" }); + const [url, init] = fetchMock.mock.calls[0]; + expect(url).toBe("https://embedded-dev.bmoni.test/v1/users/u1"); + expect(init.headers["x-api-key"]).toBe("pk_test_key"); + }); + + it("unwraps the data envelope", async () => { + fetchMock.mockResolvedValue(ok({ bmoniUserId: "u1" })); + expect(await client.bmoniRead({ path: "/x" })).toEqual({ bmoniUserId: "u1" }); + }); + + it("refuses to run without an API key rather than calling unauthenticated", async () => { + vi.resetModules(); + delete process.env.BMONI_API_KEY; + const bare = await import("../../lib/banking/bmoni-client"); + await expect(bare.bmoniRead({ path: "/x" })).rejects.toThrow(/BMONI_API_KEY/); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("error reporting", () => { + it("joins the array form so every field fault arrives at once", async () => { + fetchMock.mockResolvedValue( + fail(400, { message: ["phoneNumber must be a valid phone number", "email must be an email"], error: "Bad Request" }), + ); + await expect(client.bmoniRead({ path: "/v1/users", method: "POST", body: {} })).rejects.toThrow( + /phoneNumber must be a valid phone number; email must be an email/, + ); + }); + + it("marks a 409 as recoverable, since it means an earlier attempt landed", async () => { + fetchMock.mockResolvedValue(fail(409, { message: "User already exists with this email" })); + await expect(client.bmoniRead({ path: "/v1/users", method: "POST", body: {} })).rejects.toMatchObject({ + isConflict: true, + }); + }); + + it("treats a 500 carrying a signature fault as OUR bug, not a transient one", async () => { + // These never succeed on retry. Mistaking one for a flaky server is how a + // broken signature gets submitted again and again. + fetchMock.mockResolvedValue(fail(500, { message: "Invalid yParityOrV" })); + await expect(client.bmoniMove({ path: "/sign", method: "POST", body: {} })).rejects.toMatchObject({ + isClientFault: true, + }); + }); + + it("does not guess at an unreadable body", async () => { + fetchMock.mockResolvedValue({ ok: false, status: 502, text: () => Promise.resolve("gateway") } as any); + await expect(client.bmoniMove({ path: "/x", method: "POST" })).rejects.toThrow(/unreadable response body/); + }); +}); + +describe("reads may retry", () => { + it("retries a transport failure and succeeds", async () => { + fetchMock.mockRejectedValueOnce(new Error("ECONNRESET")).mockResolvedValue(ok({ balance: "1.00" })); + expect(await client.bmoniRead({ path: "/balances" })).toEqual({ balance: "1.00" }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("does NOT retry an answer the server actually gave", async () => { + // A 404 will still be a 404. Repeating it just delays telling the user. + fetchMock.mockResolvedValue(fail(404, { message: "User not found" })); + await expect(client.bmoniRead({ path: "/v1/users/nope" })).rejects.toThrow(/User not found/); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("passes an abort signal, so a hung read cannot wedge the request", async () => { + fetchMock.mockResolvedValue(ok({})); + await client.bmoniRead({ path: "/x" }); + expect(fetchMock.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal); + }); +}); + +describe("money-moving calls may not", () => { + it("NEVER retries — one attempt, then the error surfaces", async () => { + // The whole rule. A retried transfer is a worker paid twice. + fetchMock.mockRejectedValue(new Error("ECONNRESET")); + await expect(client.bmoniMove({ path: "/offramp", method: "POST", body: {} })).rejects.toThrow("ECONNRESET"); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("NEVER aborts early — no timeout signal is attached", async () => { + // Aborting does not cancel what the server is doing. It only destroys our + // record of having asked, which is how wages go missing. + fetchMock.mockResolvedValue(ok({ proposalId: "p1" })); + await client.bmoniMove({ path: "/offramp", method: "POST", body: {} }); + expect(fetchMock.mock.calls[0][1].signal).toBeUndefined(); + }); +}); diff --git a/tests/money/bmoni-keys.test.ts b/tests/money/bmoni-keys.test.ts new file mode 100644 index 0000000..0b6b16b --- /dev/null +++ b/tests/money/bmoni-keys.test.ts @@ -0,0 +1,89 @@ +import { ethers } from "ethers"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// Aide holds the owner key for every user's BMONI smart wallet, because the +// alternative — BMONI's SDK — puts a 4-to-6 digit PIN on screen for every +// signature, which is the exact interaction the spoken security phrase exists +// to replace. That trade is deliberate, and it means the key is ours to +// protect: whoever holds it can sign a transfer. +// +// So the private key must never sit in Convex in a form that is useful to +// anyone who reads the table. Encrypted at rest, authenticated so tampering is +// detected rather than silently producing a different key, and useless without +// a secret that lives only in the environment. + +const SECRET = "0".repeat(64); // 32 bytes of hex — a test master key, not a real one +const OTHER_SECRET = "f".repeat(64); + +let keys: typeof import("../../lib/banking/keys"); + +beforeEach(async () => { + vi.resetModules(); + process.env.BMONI_KEY_SECRET = SECRET; + keys = await import("../../lib/banking/keys"); +}); + +describe("generating an owner key", () => { + it("produces a usable secp256k1 key whose address matches", () => { + const k = keys.generateOwnerKey(); + expect(k.privateKey).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(new ethers.Wallet(k.privateKey).address).toBe(k.address); + }); + + it("never repeats a key", () => { + const seen = new Set(Array.from({ length: 50 }, () => keys.generateOwnerKey().privateKey)); + expect(seen.size).toBe(50); + }); +}); + +describe("protecting the key at rest", () => { + it("round-trips through seal and open", () => { + const { privateKey } = keys.generateOwnerKey(); + expect(keys.openPrivateKey(keys.sealPrivateKey(privateKey))).toBe(privateKey); + }); + + it("does not leave the key readable in what it stores", () => { + const { privateKey } = keys.generateOwnerKey(); + const sealed = keys.sealPrivateKey(privateKey); + // The bare key must not be recoverable by reading the row, in hex or as + // any trivial re-encoding of it. + expect(sealed).not.toContain(privateKey); + expect(sealed).not.toContain(privateKey.slice(2)); + expect(Buffer.from(sealed, "base64").toString("utf8")).not.toContain(privateKey.slice(2, 20)); + }); + + it("seals the same key differently every time", () => { + // Otherwise equal ciphertexts reveal that two users share a key, and a + // stolen ciphertext can be replayed onto another row. + const { privateKey } = keys.generateOwnerKey(); + expect(keys.sealPrivateKey(privateKey)).not.toBe(keys.sealPrivateKey(privateKey)); + }); + + it("refuses a tampered value instead of returning a different key", async () => { + // A silently-different key would sign proposals that recover to an address + // BMONI does not recognise — accepted, recorded, never executed. + const { privateKey } = keys.generateOwnerKey(); + const sealed = keys.sealPrivateKey(privateKey); + const raw = Buffer.from(sealed, "base64"); + raw[raw.length - 1] ^= 0xff; + expect(() => keys.openPrivateKey(raw.toString("base64"))).toThrow(); + }); + + it("is useless without the right secret", async () => { + const { privateKey } = keys.generateOwnerKey(); + const sealed = keys.sealPrivateKey(privateKey); + vi.resetModules(); + process.env.BMONI_KEY_SECRET = OTHER_SECRET; + const other = await import("../../lib/banking/keys"); + expect(() => other.openPrivateKey(sealed)).toThrow(); + }); + + it("refuses to run at all without a secret configured", async () => { + vi.resetModules(); + delete process.env.BMONI_KEY_SECRET; + const bare = await import("../../lib/banking/keys"); + // Lazily, so importing this module cannot break a build that has no + // secrets — but never by falling back to a default key. + expect(() => bare.sealPrivateKey("0x" + "1".repeat(64))).toThrow(/BMONI_KEY_SECRET/); + }); +}); diff --git a/tests/money/bmoni-payout-guard.test.ts b/tests/money/bmoni-payout-guard.test.ts new file mode 100644 index 0000000..b0d019f --- /dev/null +++ b/tests/money/bmoni-payout-guard.test.ts @@ -0,0 +1,183 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeDispatch, type ConvexCall, type Handlers } from "../helpers/fake-convex"; + +// The double-payment guard. +// +// Paying out through BMONI is four calls — create, approve, fetch payload, +// sign — and BMONI has no idempotency keys. A failure anywhere after the first +// leaves a REAL payout sitting at BMONI. Starting the flow over would create a +// second one for the same wages, and the money is gone from someone else's +// account to pay it. +// +// So the proposal id is claimed in Convex the instant it exists, and any later +// attempt resumes that proposal instead of creating another. The guard is only +// released on a terminal status: an unknown status leaves it in flight on +// purpose, because "we do not know what happened to the last payout" is +// precisely when a second one must not be started. + +const hoisted = vi.hoisted(() => ({ + dispatch: (_ref: any, _args: any): any => { + throw new Error("dispatch not installed"); + }, +})); + +vi.mock("../../lib/convex-server", () => ({ + convexClient: () => ({ + query: (ref: any, args: any) => hoisted.dispatch(ref, args), + mutation: (ref: any, args: any) => hoisted.dispatch(ref, args), + }), + publishConvexEvent: () => Promise.resolve(), +})); + +const bmoni = vi.hoisted(() => ({ + createNgnOfframp: vi.fn(), + getProposal: vi.fn(), + approveProposal: vi.fn(), + getSignPayload: vi.fn(), + submitProposalSignature: vi.fn(), + verifyNigerianAccount: vi.fn(), + registerWithdrawalAccount: vi.fn(), + createBmoniUser: vi.fn(), + createSmartWallet: vi.fn(), + requestOwnerProofChallenge: vi.fn(), + listBalances: vi.fn(), + startNigeriaOnboarding: vi.fn(), + pointDepositsAtWallet: vi.fn(), +})); + +vi.mock("../../lib/banking/bmoni", async () => { + const actual = await vi.importActual("../../lib/banking/bmoni"); + return { ...actual, ...bmoni }; +}); + +// A real sealed key, so signing runs for real rather than being stubbed. +const ANVIL_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const HASH = "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31"; + +let payOutToBank: typeof import("../../lib/banking/bmoni-wallet").payOutToBank; +let calls: ConvexCall[]; +let walletRow: Record; +let claimResult: { ok: boolean; inFlight?: string }; + +const PROVISIONED = (over: Record = {}) => ({ + accountId: "u-worker", + accountReference: "aide-u-worker", + status: "active", + knownTxRefs: [], + txSeeded: true, + bmoniUserId: "bm-user-1", + bmoniSmartWalletId: "sw-1", + bmoniWalletAddress: "0xwallet", + bmoniOwnerAddress: "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266", + bmoniBankAccountIds: [{ key: "0123456789:058", id: "ba-1" }], + ...over, +}); + +beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + process.env.BMONI_KEY_SECRET = "0".repeat(64); + const { sealPrivateKey } = await import("../../lib/banking/keys"); + walletRow = PROVISIONED({ bmoniSealedOwnerKey: sealPrivateKey(ANVIL_KEY) }); + claimResult = { ok: true }; + calls = []; + const handlers: Handlers = { + "wallets:getByAccount": () => walletRow, + "wallets:claimBmoniProposal": () => claimResult, + "wallets:clearBmoniProposal": () => null, + "wallets:rememberBmoniBankAccount": () => null, + }; + hoisted.dispatch = makeDispatch(handlers, calls); + ({ payOutToBank } = await import("../../lib/banking/bmoni-wallet")); + + bmoni.getSignPayload.mockResolvedValue({ hashToSign: HASH }); + bmoni.submitProposalSignature.mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + bmoni.approveProposal.mockResolvedValue({}); +}); + +const DEST = { accountNumber: "0123456789", bankCode: "058", bankName: "Wema Bank" }; + +describe("a fresh payout", () => { + it("claims the proposal id the moment it exists, before signing", async () => { + bmoni.createNgnOfframp.mockResolvedValue({ proposalId: "p-1", status: "PENDING_APPROVALS" }); + bmoni.getProposal + .mockResolvedValueOnce({ proposalId: "p-1", status: "PENDING_APPROVALS" }) + .mockResolvedValueOnce({ proposalId: "p-1", status: "PENDING_SIGNATURES" }) + .mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + + const r = await payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST }); + expect(r.outcome.state).toBe("completed"); + + const claimIndex = calls.findIndex((c) => c.name === "wallets:claimBmoniProposal"); + expect(claimIndex).toBeGreaterThanOrEqual(0); + // The claim must land before any signature is submitted, or a crash in + // between leaves a payout nobody has a handle on. + expect(bmoni.submitProposalSignature).toHaveBeenCalled(); + expect(calls[claimIndex].args).toMatchObject({ proposalId: "p-1", amountKobo: 500_000 }); + }); + + it("sends the amount as a decimal string, converted from kobo", async () => { + bmoni.createNgnOfframp.mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + bmoni.getProposal.mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + await payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST }); + expect(bmoni.createNgnOfframp).toHaveBeenCalledWith(expect.objectContaining({ amountKobo: 500_000 })); + }); +}); + +describe("a payout that is already in flight", () => { + it("resumes it instead of creating a second one", async () => { + // This is the case that would pay a worker twice. + walletRow = PROVISIONED({ + bmoniSealedOwnerKey: walletRow.bmoniSealedOwnerKey, + bmoniPendingProposal: { proposalId: "p-old", amountKobo: 500_000, createdAt: Date.now() }, + }); + bmoni.getProposal.mockResolvedValue({ proposalId: "p-old", status: "COMPLETED" }); + + const r = await payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST }); + + expect(bmoni.createNgnOfframp).not.toHaveBeenCalled(); + expect(r.proposalId).toBe("p-old"); + }); + + it("refuses when the claim finds a different proposal already in flight", async () => { + bmoni.createNgnOfframp.mockResolvedValue({ proposalId: "p-new", status: "PENDING_APPROVALS" }); + bmoni.getProposal.mockResolvedValue({ proposalId: "p-new", status: "PENDING_APPROVALS" }); + claimResult = { ok: false, inFlight: "p-other" }; + + await expect(payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST })).rejects.toThrow( + /Two payouts now exist/, + ); + }); +}); + +describe("releasing the guard", () => { + it("clears it on a terminal status", async () => { + bmoni.createNgnOfframp.mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + bmoni.getProposal.mockResolvedValue({ proposalId: "p-1", status: "COMPLETED" }); + await payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST }); + expect(calls.some((c) => c.name === "wallets:clearBmoniProposal")).toBe(true); + }); + + it("does NOT clear it on a status nobody recognises", async () => { + // The point of failing closed. An unrecognised status means we do not know + // whether the money moved, and that is exactly when a second payout must + // stay blocked. + bmoni.createNgnOfframp.mockResolvedValue({ proposalId: "p-1", status: "WHO_KNOWS" }); + bmoni.getProposal.mockResolvedValue({ proposalId: "p-1", status: "WHO_KNOWS" }); + + const r = await payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST }); + expect(r.outcome.state).toBe("unknown"); + expect(r.outcome.mayAnnounceArrival).toBe(false); + expect(calls.some((c) => c.name === "wallets:clearBmoniProposal")).toBe(false); + }); +}); + +describe("an unprovisioned wallet", () => { + it("refuses rather than half-starting a payout", async () => { + walletRow = { accountId: "u-worker", accountReference: "aide-u-worker", status: "active" }; + await expect(payOutToBank({ accountId: "u-worker", amountKobo: 500_000, ...DEST })).rejects.toThrow( + /No provisioned BMONI wallet/, + ); + expect(bmoni.createNgnOfframp).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/money/bmoni-proposal-status.test.ts b/tests/money/bmoni-proposal-status.test.ts new file mode 100644 index 0000000..e4e88ff --- /dev/null +++ b/tests/money/bmoni-proposal-status.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { readProposalOutcome } from "../../lib/banking/proposal-status"; + +// What Aide is allowed to SAY about a transfer, given what BMONI reported. +// +// This is the rule from ARCHITECTURE.md at its sharpest: the model never +// decides a financial fact, it only narrates what a tool returned. So the tool +// has to be certain, and there are only three honest answers — it is done, it +// is still going, or it failed. Anything BMONI says that does not clearly mean +// one of those must resolve to "I do not know", never to "done". +// +// The asymmetry is the point. Calling a completed transfer "still going" costs +// a worker a second question. Calling an unknown one "completed" tells someone +// who cannot see the screen that money arrived when it did not. + +describe("statuses BMONI documents", () => { + it("treats COMPLETED as settled", () => { + expect(readProposalOutcome("COMPLETED")).toEqual({ + state: "completed", + raw: "COMPLETED", + mayAnnounceArrival: true, + }); + }); + + it("treats the pending states as still moving", () => { + expect(readProposalOutcome("PENDING_APPROVALS").state).toBe("pending"); + expect(readProposalOutcome("PENDING_SIGNATURES").state).toBe("pending"); + expect(readProposalOutcome("EXECUTING").state).toBe("pending"); + }); + + it("treats FAILED as failed", () => { + expect(readProposalOutcome("FAILED").state).toBe("failed"); + }); +}); + +describe("anything else fails closed", () => { + it("does NOT call an unrecognised status completed", () => { + // The whole rule. A new status shipped by BMONI must not become "your + // money has arrived" by default. + for (const s of ["SETTLED", "DONE", "OK", "SUCCESS", "APPROVED", "REVERSED", "CLAWED_BACK"]) { + expect(readProposalOutcome(s).state).toBe("unknown"); + } + }); + + it("does not guess at empty, null, or malformed input", () => { + for (const s of ["", " ", null, undefined, 42, {}, []] as unknown[]) { + expect(readProposalOutcome(s as string).state).toBe("unknown"); + } + }); + + it("keeps the raw status so it can be logged and chased", () => { + // An unknown state is an operational event. Losing the string would make + // it undiagnosable. + expect(readProposalOutcome("WEIRD_NEW_STATE").raw).toBe("WEIRD_NEW_STATE"); + }); + + it("is case-sensitive rather than helpfully lenient", () => { + // "completed" lowercase is not a status BMONI documents. Accepting it + // would mean guessing that a different-looking payload means the same + // thing, which is exactly the guess this file exists to prevent. + expect(readProposalOutcome("completed").state).toBe("unknown"); + }); +}); + +describe("what Aide may say", () => { + it("only permits a spoken confirmation when settled", () => { + expect(readProposalOutcome("COMPLETED").mayAnnounceArrival).toBe(true); + for (const s of ["PENDING_SIGNATURES", "EXECUTING", "FAILED", "MYSTERY"]) { + expect(readProposalOutcome(s).mayAnnounceArrival).toBe(false); + } + }); +}); diff --git a/tests/money/bmoni-shapes.test.ts b/tests/money/bmoni-shapes.test.ts new file mode 100644 index 0000000..41490fc --- /dev/null +++ b/tests/money/bmoni-shapes.test.ts @@ -0,0 +1,310 @@ +import { describe, expect, it } from "vitest"; +import { + parseBanks, + parseVerifiedAccount, + parseCreatedUser, + parseCreatedWallet, + parseNgnBalanceKobo, + parseNgnDepositAccount, + soleNgnWalletIndex, +} from "../../lib/banking/bmoni-shapes"; + +// Every payload in this file was captured from the live BMONI sandbox at +// https://embedded-dev.bmoni.com on 2026-09-03, not transcribed from the docs +// and not imagined. Three of these shapes did not match what the client +// assumed, and each mismatch was silent: the field simply came back +// `undefined` and got persisted or spoken as such. +// +// If BMONI changes a shape, these tests are the thing that notices. Parsing +// must therefore THROW on an unrecognised body rather than defaulting — a +// balance parser that falls back to zero would tell a worker with money in +// their account that they have none, out loud, with no screen to check it +// against. + +describe("POST /v1/users", () => { + // The response wraps the user. Reading `bmoniUserId` off the top level gives + // undefined, which is then stored and used to build every later path as + // `/v1/users/undefined/...`. + const real = { + user: { + id: "930b5d40-572c-4835-939d-2b66adc380bd", + identityId: "ac5c8320-8878-4635-9853-d8023b6bc0d4", + bmoniUserId: "7c9cc36c-c055-48dc-a7d1-f22a071f97f3", + firstName: "Aide", + lastName: "Probe1788460408", + email: "aide.probe.1788460408@aide.test", + phoneNumber: "+2348088460408", + }, + }; + + it("takes bmoniUserId from inside the user wrapper", () => { + expect(parseCreatedUser(real)).toEqual({ bmoniUserId: "7c9cc36c-c055-48dc-a7d1-f22a071f97f3" }); + }); + + it("does not mistake the record id for the id every other endpoint keys on", () => { + // `id` and `bmoniUserId` are both UUIDs and both present. Picking the wrong + // one is not a type error and not a 400 — it is a 404 "User not found" on + // the next call, long after the wrong value was persisted. + expect(parseCreatedUser(real).bmoniUserId).not.toBe(real.user.id); + }); + + it("refuses a body with no bmoniUserId rather than returning undefined", () => { + expect(() => parseCreatedUser({ user: { id: "930b5d40" } })).toThrow(/bmoniUserId/); + }); +}); + +describe("POST /v1/users/{id}/smart-wallets/create-managed", () => { + // Neither field is named what the client expected: not `smartWalletId`, not + // `address`. And the currency comes back NGN even though CNGN was asked for. + const real = { + id: "1e40b487-04fc-4c2c-ba23-1a29fb57ba86", + currency: "NGN", + walletAddress: "0x4DD616C8dcB767CD838e54dF97dd59B50068b6FA", + isActive: true, + createdByUserId: "7c9cc36c-c055-48dc-a7d1-f22a071f97f3", + }; + + it("reads the wallet id from `id` and the address from `walletAddress`", () => { + expect(parseCreatedWallet(real)).toEqual({ + smartWalletId: "1e40b487-04fc-4c2c-ba23-1a29fb57ba86", + address: "0x4DD616C8dcB767CD838e54dF97dd59B50068b6FA", + currency: "NGN", + }); + }); + + it("refuses a wallet with no address", () => { + // An address-less wallet stored as provisioned is a wallet that can never + // be paid into, and nothing downstream would report why. + expect(() => parseCreatedWallet({ id: "1e40b487" })).toThrow(/walletAddress/); + }); +}); + +describe("GET /v1/users/{id}/smart-wallets/account/balances", () => { + const real = { + smartAccountAddress: "0x4DD616C8dcB767CD838e54dF97dd59B50068b6FA", + balances: [{ smartWalletId: "1e40b487-04fc-4c2c-ba23-1a29fb57ba86", currency: "NGN", balance: "0", error: null }], + }; + + it("reads a zero NGN balance as zero kobo", () => { + expect(parseNgnBalanceKobo(real)).toBe(0); + }); + + it("reads the balance as naira, in whole kobo", () => { + // `balance` is a STRING, and decimal. Number-coercing it and keeping naira + // floats is the bug the kobo migration exists to prevent. + const b = { ...real, balances: [{ ...real.balances[0], balance: "12000.50" }] }; + expect(parseNgnBalanceKobo(b)).toBe(1_200_050); + }); + + it("throws when the entry carries a provider-side error instead of reporting the balance", () => { + // `error` is per-entry and lives inside a 200 response. A non-null error + // means BMONI could not price this wallet; `balance` alongside it is not a + // figure anyone should hear. + const b = { ...real, balances: [{ ...real.balances[0], balance: "0", error: "upstream timeout" }] }; + expect(() => parseNgnBalanceKobo(b)).toThrow(/upstream timeout/); + }); + + it("throws rather than returning zero when there is no NGN wallet", () => { + // Wallets on this platform can be USD. Falling through to 0 would present + // "no naira wallet exists" as "your account is empty" — two different + // facts, one of which tells a worker to stop waiting for their wages. + const usd = { ...real, balances: [{ ...real.balances[0], currency: "USD" }] }; + expect(() => parseNgnBalanceKobo(usd)).toThrow(/NGN/); + }); + + it("throws on an empty balances array", () => { + expect(() => parseNgnBalanceKobo({ ...real, balances: [] })).toThrow(/NGN/); + }); +}); + +describe("the ngnWalletIndex that start-nigeria demands", () => { + // POST …/onboarding/start-nigeria answers 400 with all three required fields + // when any is missing, captured live on 2026-09-04: + // + // ["bvn must be a string", "ngnWalletAddress must be a string", + // "ngnWalletIndex must be a number conforming to the specified constraints"] + // + // The client only ever sent two of them, so the Nigerian rail never started, + // so no virtual account was ever issued, so the deposit list held nothing but + // BMONI's pooled house account — and the payments page showed the stale + // Monnify NUBAN instead. One unsent integer, four symptoms. + // + // Nothing in any response carries the index. No endpoint returns it: not the + // wallet, not the balances entry, not the user. So it is DERIVED, and the + // only derivation that cannot be quietly wrong is the one below. + const oneWallet = { + smartAccountAddress: "0xbb7EF869d8A6451a721cCdB6816405d27Bd529F4", + balances: [{ smartWalletId: "ce91a25e-694b-4bc7-8810-349c4b912f18", currency: "NGN", balance: "0", error: null }], + }; + + it("is 0 for the one wallet Aide provisions", () => { + // Verified against the sandbox: index 0 with this exact wallet returned + // 200 and the anchor rail went from not_started to active. + expect(soleNgnWalletIndex(oneWallet, "ce91a25e-694b-4bc7-8810-349c4b912f18")).toBe(0); + }); + + it("refuses when the wallet being onboarded is not the one BMONI lists", () => { + // Would otherwise hand back 0 and onboard somebody else's wallet under + // this worker's BVN. + expect(() => soleNgnWalletIndex(oneWallet, "00000000-0000-0000-0000-000000000000")).toThrow(/does not list/i); + }); + + it("refuses to pick an index when the user has more than one wallet", () => { + // Position in this array is a GUESS, not a documented mapping — BMONI + // never states whether its index counts every wallet or only the NGN ones. + // A wrong index points a worker's incoming wages at a wallet Aide does not + // read, and the money arrives somewhere real, so nothing errors. Refusing + // is the only honest answer, and it cannot happen in practice: Aide creates + // exactly one wallet per user and provisioning already refuses to make a + // second. + const two = { + ...oneWallet, + balances: [...oneWallet.balances, { smartWalletId: "aaaaaaaa-0000-0000-0000-000000000000", currency: "NGN", balance: "0", error: null }], + }; + expect(() => soleNgnWalletIndex(two, "ce91a25e-694b-4bc7-8810-349c4b912f18")).toThrow(/refusing to guess/i); + }); + + it("refuses on an empty wallet list rather than defaulting to 0", () => { + expect(() => soleNgnWalletIndex({ ...oneWallet, balances: [] }, "ce91a25e")).toThrow(/does not list/i); + }); +}); + +describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/NGN", () => { + // Before the per-user virtual account is provisioned, BMONI answers 200 with + // a POOLED house account — Bkey Limited's own, shared by every user on the + // platform, with `bankCode` literally "XXXXXXX". + const pooled = { + accounts: [ + { + id: "pooled-vba-1", + accountName: "Bkey Limited", + bankName: "9 Payment Service Bank", + currency: "NGN", + accountNumber: "6177463833", + bankCode: "XXXXXXX", + targetCurrency: "EUR", + }, + ], + }; + + // Captured live: the worker's own account is issued asynchronously, so the + // same user returns the pooled account alone for a few seconds and then both, + // own-account-first. Position is therefore meaningless. + const own = { + id: "143d061f-e98d-4ce1-b933-139176963dbb", + accountName: "Dillon Bunch", + bankName: "PROVIDUS BANK", + currency: "NGN", + accountNumber: "4534076021", + bankCode: "000023", + targetCurrency: "NGN", + }; + + it("picks the worker's own account out of a list that also holds the pooled one", () => { + expect(parseNgnDepositAccount({ accounts: [own, ...pooled.accounts] })).toEqual({ + id: "143d061f-e98d-4ce1-b933-139176963dbb", + accountNumber: "4534076021", + bankName: "PROVIDUS BANK", + accountName: "Dillon Bunch", + }); + }); + + it("returns the name the BANK holds, which is not the profile name", () => { + // The payments page used to show the Aide profile name here. An employer + // doing the transfer sees their own bank's name enquiry — "Dillon Bunch" — + // and if Aide has told them the account belongs to someone called + // something else, the sensible thing for them to do is stop, assuming they + // have the wrong number. Worse, the worker cannot see the screen to catch + // it: Aide would simply be stating a name no bank agrees with. + expect(parseNgnDepositAccount({ accounts: [own] }).accountName).toBe("Dillon Bunch"); + }); + + it("still finds it when the pooled account is listed first", () => { + // Nothing documents the ordering, so neither position may be assumed. + expect(parseNgnDepositAccount({ accounts: [...pooled.accounts, own] }).accountNumber).toBe("4534076021"); + }); + + it("refuses to hand out the pooled account as a worker's own", () => { + // This is the most dangerous shape in the integration. It is a valid, + // payable Nigerian account number — so Aide would read it aloud happily + // and an employer would successfully transfer wages into a shared pool + // with nothing tying the payment to the worker. Money would genuinely be + // gone. Never speak an account number that is not the worker's own. + expect(() => parseNgnDepositAccount(pooled)).toThrow(/pooled/i); + }); + + it("accepts a real per-user account", () => { + const own = { + accounts: [ + { + id: "3f0a1e5c-4d2b-4a8e-9c11-7b6d5e8a2f34", + accountName: "Aide Demo Worker", + bankName: "9 Payment Service Bank", + currency: "NGN", + accountNumber: "8012345678", + bankCode: "120001", + }, + ], + }; + expect(parseNgnDepositAccount(own)).toEqual({ + // Carried because onramp/vba/nigeria needs it to route deposits. + id: "3f0a1e5c-4d2b-4a8e-9c11-7b6d5e8a2f34", + accountNumber: "8012345678", + bankName: "9 Payment Service Bank", + accountName: "Aide Demo Worker", + }); + }); + + it("throws when there is no account at all", () => { + expect(() => parseNgnDepositAccount({ accounts: [] })).toThrow(/no naira deposit account/i); + }); +}); + +describe("GET /v1/users/{id}/bank-accounts/nigerian-banks", () => { + it("reads the wrapped list and its own field names", () => { + // Wrapped in `banks`, and the fields are bankName/bankCode, not name/code. + expect(parseBanks({ banks: [{ bankName: "WEMA BANK", bankCode: "000017" }] })).toEqual([ + { name: "WEMA BANK", code: "000017" }, + ]); + }); + + it("carries BMONI's own bank codes, which are not the NIP codes the UI uses", () => { + // Wema is NIP 035 in the payments page and Monnify, but 000017 here. + // Sending a NIP code to BMONI resolves to a different bank or none. + const [wema] = parseBanks({ banks: [{ bankName: "WEMA BANK", bankCode: "000017" }] }); + expect(wema.code).not.toBe("035"); + }); + + it("throws on an unwrapped array rather than silently reading no banks", () => { + expect(() => parseBanks([{ bankName: "WEMA BANK", bankCode: "000017" }])).toThrow(/array where an object/); + }); +}); + +describe("POST …/bank-accounts/verify-nigerian-account", () => { + // Captured live on 2026-09-04. The client typed this as + // `accountHolderName`; the field is `accountName`, so name enquiry returned + // undefined and the withdrawal flow read out an undefined account holder — + // or registered the destination under an empty name. + const real = { + accountNumber: "3463455722", + accountName: "Jabo Samson Joe", + bankName: "PROVIDUS BANK", + bankCode: "000023", + }; + + it("reads the holder name from accountName", () => { + expect(parseVerifiedAccount(real)).toEqual({ + accountNumber: "3463455722", + accountName: "Jabo Samson Joe", + bankName: "PROVIDUS BANK", + bankCode: "000023", + }); + }); + + it("throws rather than returning an unnamed account", () => { + // The whole point of name enquiry is the name. Aide reads it back so the + // payer can stop before the money moves — an empty one removes the only + // check a blind user has on where their wages are going. + expect(() => parseVerifiedAccount({ ...real, accountName: undefined })).toThrow(/accountName/); + }); +}); diff --git a/tests/money/bmoni-signing.test.ts b/tests/money/bmoni-signing.test.ts new file mode 100644 index 0000000..f1361a7 --- /dev/null +++ b/tests/money/bmoni-signing.test.ts @@ -0,0 +1,113 @@ +import { ethers } from "ethers"; +import { describe, expect, it } from "vitest"; +import { signOwnerProof, signProposalDigest } from "../../lib/banking/signing"; + +// BMONI takes two signatures and they are made with OPPOSITE methods. Owner +// proof signs challenge text WITH the EIP-191 prefix; a proposal signs a raw +// 32-byte digest WITHOUT it. +// +// Getting the proposal one wrong is the failure this file exists to prevent, +// and it is nasty: the signature is structurally valid, decodes cleanly, has a +// correct v byte, and is ACCEPTED and RECORDED. It simply recovers to a +// different address, so it never matches the proposal's signer snapshot and +// the transfer silently never executes. BMONI has no endpoint that validates a +// signature without submitting it, so this vector is the only way to know our +// toolchain is right before real money depends on it. +// +// For Aide the silent part is what matters. A transfer that fails loudly is +// recoverable; one that is accepted and never runs means Aide told a worker +// their wages were sent, truthfully as far as it could tell, and they were not. +// +// Values are BMONI's published vector, regenerable from scratch: the digest is +// keccak256 of the preimage below, and the key is the well-known public Anvil +// test account, which holds nothing and must never be used for anything else. +const PREIMAGE = "bmoni-embedded:BKE-2041:sign-payload-example"; +const ANVIL_KEY = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; +const OWNER_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"; +const HASH_TO_SIGN = "0x8f5156823a5c2cdc7bedc12253e49e4946c6fff0273034eb485750035d21ad31"; +const EXPECTED_SIGNATURE = + "0x628f1aff48c9d1f35d45a735eb026db0437c5ed334a94dc7fb0ac86ca32c10bd" + + "173a653a7f064c4512244f6fcbefb07e13bfe7368fcacdcc4e6fb153f50050991b"; +// Where the WRONG method's signature recovers to. Published by BMONI precisely +// because the failure is otherwise invisible. +const WRONG_METHOD_ADDRESS = "0xC69f336bb0C1e391a97861cC1837e1f10a9Ba041"; +// There are actually TWO wrong ways, and BMONI documents only the first. +// signMessage(getBytes(hash)) prefixes the 32 raw bytes and gives the address +// above; signMessage(hash) treats the "0x…" string as UTF-8 text and gives +// this one. Both are structurally valid and both silently never execute, so +// both are pinned here. +const WRONG_METHOD_AS_TEXT_ADDRESS = "0x5e474E09C62E717AEBe0e307c3285351442C1167"; + +describe("the vector itself", () => { + it("regenerates BMONI's digest from the preimage", () => { + // If this drifts, the vector below is not testing what it claims to. + expect(ethers.keccak256(ethers.toUtf8Bytes(PREIMAGE))).toBe(HASH_TO_SIGN); + }); + + it("uses a key that really is the published Anvil account", () => { + expect(new ethers.Wallet(ANVIL_KEY).address).toBe(OWNER_ADDRESS); + }); +}); + +describe("signing a proposal — the signature that moves money", () => { + it("produces the exact bytes BMONI expects", async () => { + expect(await signProposalDigest(ANVIL_KEY, HASH_TO_SIGN)).toBe(EXPECTED_SIGNATURE); + }); + + it("recovers to the registered owner, which is what the backend checks", async () => { + const sig = await signProposalDigest(ANVIL_KEY, HASH_TO_SIGN); + expect(ethers.recoverAddress(HASH_TO_SIGN, sig)).toBe(OWNER_ADDRESS); + }); + + it("does NOT recover to either address the message-signing method gives", async () => { + // The whole point. A signature recovering to either of these is accepted + // and recorded, and the money never moves. + const sig = await signProposalDigest(ANVIL_KEY, HASH_TO_SIGN); + const recovered = ethers.recoverAddress(HASH_TO_SIGN, sig); + expect(recovered).not.toBe(WRONG_METHOD_ADDRESS); + expect(recovered).not.toBe(WRONG_METHOD_AS_TEXT_ADDRESS); + }); + + it("refuses anything that is not a 32-byte digest", () => { + // A sign-payload response that arrived empty, truncated, or as an object + // would otherwise be signed as text: a valid signature over the wrong + // thing, which fails the same silent way. + expect(() => signProposalDigest(ANVIL_KEY, "not-a-digest")).toThrow(/32-byte hex digest/); + expect(() => signProposalDigest(ANVIL_KEY, "0x8f5156")).toThrow(/32-byte hex digest/); + }); + + it("is 65 bytes with an Ethereum-convention v byte", async () => { + const sig = await signProposalDigest(ANVIL_KEY, HASH_TO_SIGN); + expect(sig).toMatch(/^0x[0-9a-fA-F]{130}$/); + expect([27, 28]).toContain(parseInt(sig.slice(-2), 16)); + }); +}); + +describe("signing the owner-proof challenge — the opposite method", () => { + it("applies the EIP-191 prefix, so it recovers under verifyMessage", async () => { + const message = "prove you control this address"; + const sig = await signOwnerProof(ANVIL_KEY, message); + expect(ethers.verifyMessage(message, sig)).toBe(OWNER_ADDRESS); + }); + + it("is not interchangeable with the proposal signature", async () => { + // Same key, same input, deliberately different bytes. If these two ever + // agree, one of them is using the wrong method. + const asProof = await signOwnerProof(ANVIL_KEY, HASH_TO_SIGN); + const asProposal = signProposalDigest(ANVIL_KEY, HASH_TO_SIGN); + expect(asProof).not.toBe(asProposal); + }); + + it("reproduces both documented wrong paths, so the trap stays described", async () => { + // Not testing our code — testing that the two failure addresses above are + // still what these mistakes produce. If ethers ever changed how it treats + // a string vs bytes, the comments here would quietly stop being true. + const wallet = new ethers.Wallet(ANVIL_KEY); + expect(ethers.recoverAddress(HASH_TO_SIGN, await wallet.signMessage(ethers.getBytes(HASH_TO_SIGN)))).toBe( + WRONG_METHOD_ADDRESS, + ); + expect(ethers.recoverAddress(HASH_TO_SIGN, await wallet.signMessage(HASH_TO_SIGN))).toBe( + WRONG_METHOD_AS_TEXT_ADDRESS, + ); + }); +}); diff --git a/tests/money/bmoni-transactions.test.ts b/tests/money/bmoni-transactions.test.ts new file mode 100644 index 0000000..d706d97 --- /dev/null +++ b/tests/money/bmoni-transactions.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { parseWalletTransactions } from "../../lib/banking/bmoni-shapes"; + +// Wallet transaction history. The envelope below was captured live from +// GET /v1/users/{userId}/smart-wallets/{smartWalletId}/transactions on +// 2026-09-04; no wallet on the shared sandbox has a transaction yet, so the +// ITEM field names come from the SDK's EmbeddedWalletTransaction model rather +// than from observed JSON. +// +// That asymmetry is why this parser throws on an item it cannot read instead +// of skipping it. If the names are wrong, the page must say it could not list +// the payments — loudly and once — rather than quietly rendering a short list +// and letting a worker conclude that a payment never arrived. + +const envelope = (transactions: unknown[]) => ({ + transactions, + page: 1, + perPage: 50, + total: transactions.length, + pageCount: 1, + hasNextPage: false, + hasPreviousPage: false, +}); + +describe("wallet transactions", () => { + it("reads an empty history as genuinely empty", () => { + // This is now a real answer from the provider, not a gap in the API. It + // means no money has arrived — which the page is entitled to say. + expect(parseWalletTransactions(envelope([]))).toEqual([]); + }); + + it("reads an incoming credit in kobo", () => { + const [c] = parseWalletTransactions( + envelope([ + { + id: "tx-1", + amount: 12000.5, + currency: "NGN", + direction: "incoming", + status: "completed", + counterpartyName: "ClearVoice Media", + createdAt: "2026-09-04T01:00:00.000Z", + }, + ]), + ); + expect(c).toEqual({ + amountKobo: 1_200_050, + reference: "tx-1", + from: "ClearVoice Media", + at: Date.parse("2026-09-04T01:00:00.000Z"), + }); + }); + + it("leaves out money going the other way", () => { + // Money out is Aide's own withdrawal ledger, which it can vouch for. + // Counting an outgoing transfer as a credit would inflate what a worker + // believes has been paid to them. + expect( + parseWalletTransactions( + envelope([{ id: "tx-2", amount: 5000, direction: "outgoing", status: "completed", createdAt: 1 }]), + ), + ).toEqual([]); + }); + + it("leaves out a credit that has not completed", () => { + // Aide announces money as ARRIVED. A pending credit has not arrived, and + // reversed means it left again. + for (const status of ["pending", "failed", "reversed"]) { + expect( + parseWalletTransactions( + envelope([{ id: "tx-3", amount: 5000, direction: "incoming", status, createdAt: 1 }]), + ), + ).toEqual([]); + } + }); + + it("throws rather than skipping a credit it cannot price", () => { + // A silently dropped row is a payment the worker was never told about. + expect(() => + parseWalletTransactions(envelope([{ id: "tx-4", direction: "incoming", status: "completed" }])), + ).toThrow(/amount/i); + }); + + it("throws when the response is not a transaction list", () => { + expect(() => parseWalletTransactions({})).toThrow(/transactions/); + }); +}); diff --git a/tests/money/bmoni-wallet-precheck.test.ts b/tests/money/bmoni-wallet-precheck.test.ts new file mode 100644 index 0000000..b1a04cf --- /dev/null +++ b/tests/money/bmoni-wallet-precheck.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { walletAlreadyExists } from "../../lib/banking/bmoni-wallet"; + +// The read-before-create guard. +// +// Smart-wallet creation has no uniqueness guard at BMONI, so a blind retry +// produces a SECOND wallet and deposits then route to whichever one we did not +// record. Before creating, we ask whether one already exists. +// +// The trap: the balances endpoint answers 400 when the user has no wallet — +// "No embedded smart wallet group found for this user. Call POST +// .../owner-proof-challenges first." That is the NORMAL state of a user about +// to get their first wallet, so the guard was throwing on the exact response +// that means "safe to proceed", and provisioning could never complete. +// +// It must read that one message as "no wallet", and everything else as +// "cannot tell" — which has to fail closed, because a network blip misread as +// "no wallet" is how a duplicate gets created. + +describe("deciding whether a wallet already exists", () => { + it("reads the documented no-wallet 400 as no wallet", () => { + expect( + walletAlreadyExists({ + ok: false, + message: "BMONI 400: No embedded smart wallet group found for this user. Call POST .../owner-proof-challenges first.", + }), + ).toBe(false); + }); + + it("sees a wallet when balances come back with one", () => { + expect( + walletAlreadyExists({ + ok: true, + body: { balances: [{ smartWalletId: "1e40b487", currency: "NGN", balance: "0", error: null }] }, + }), + ).toBe(true); + }); + + it("reads an empty balances array as no wallet", () => { + expect(walletAlreadyExists({ ok: true, body: { balances: [] } })).toBe(false); + }); + + it("fails closed on any other error", () => { + // A timeout must never be read as "no wallet". That is how the second + // wallet gets created, and deposits then land on the one we did not store. + for (const message of ["fetch failed", "BMONI 500: upstream", "BMONI 401: bad key", "socket hang up"]) { + expect(walletAlreadyExists({ ok: false, message })).toBe(true); + } + }); + + it("fails closed on a body it cannot read", () => { + expect(walletAlreadyExists({ ok: true, body: null })).toBe(true); + expect(walletAlreadyExists({ ok: true, body: { unexpected: 1 } })).toBe(true); + }); +}); diff --git a/tests/money/demo-balance.test.ts b/tests/money/demo-balance.test.ts index 9525644..9b0296f 100644 --- a/tests/money/demo-balance.test.ts +++ b/tests/money/demo-balance.test.ts @@ -57,6 +57,12 @@ const outage = () => Object.assign(new Error("The operation was aborted due to t beforeEach(async () => { vi.resetModules(); vi.unstubAllEnvs(); + // These exercise the Monnify adapter specifically — the Monnify HTTP module + // is what they mock. BMONI is the default provider now, so the rail under + // test is named rather than assumed; without this they would run against + // BMONI and fail on a missing sandbox user rather than on anything they are + // actually asserting. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); vi.spyOn(console, "warn").mockImplementation(() => {}); ({ getBalance } = await import("../../lib/store/payments")); calls = []; diff --git a/tests/money/greeting-balance-line.test.ts b/tests/money/greeting-balance-line.test.ts new file mode 100644 index 0000000..f93d43f --- /dev/null +++ b/tests/money/greeting-balance-line.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { balanceLine } from "../../lib/greeting-balance"; + +// What Aide says about money in its opening breath. +// +// The person listening cannot see the screen, so silence has to mean exactly +// one thing. Before this, a balance of zero was skipped entirely — the same +// silence as a balance that had not come back yet. "You have nothing" and "I +// have not checked" sounded identical, and only one of them is a reason to +// stop waiting for wages to land. +// +// So: a known balance is always stated, zero included. Silence now means only +// that the figure was not back in time, and the user can ask again. + +describe("the balance line in the greeting", () => { + it("states a real balance", () => { + expect(balanceLine(12000)).toBe("You have ₦12,000.00 in your account, ready to withdraw."); + }); + + it("states zero out loud instead of skipping it", () => { + // The bug this file exists for. A worker with nothing must hear so. + expect(balanceLine(0)).toBe("Your balance is ₦0.00 right now."); + }); + + it("says nothing when the figure never arrived", () => { + // Not an error — the greeting gives the balance 2.5s so Aide can start + // speaking promptly. Saying "I could not check" every slow morning would + // alarm people about a delay that is routine. Asking again is cheap. + expect(balanceLine(null)).toBeNull(); + }); + + it("does not describe an empty account as ready to withdraw", () => { + expect(balanceLine(0)).not.toMatch(/ready to withdraw/); + }); + + it("shows kobo rather than rounding them away", () => { + // A worker owed ₦12,000.50 who hears "twelve thousand naira" has been told + // a number that is not theirs. + expect(balanceLine(12000.5)).toContain("₦12,000.50"); + }); +}); diff --git a/tests/money/name-enquiry-trust.test.ts b/tests/money/name-enquiry-trust.test.ts new file mode 100644 index 0000000..72d1c1a --- /dev/null +++ b/tests/money/name-enquiry-trust.test.ts @@ -0,0 +1,123 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { isFabricatedNameEnquiry, destinationConfirmation } from "../../lib/banking/name-enquiry"; + +// BMONI's sandbox does not perform name enquiry. It returns a plausible +// Nigerian name for ANY ten-digit number against ANY bank code, deterministic +// per number so repeated lookups agree with each other and look real. +// Measured on 2026-09-04 against embedded-dev.bmoni.com: +// +// 0123456789 / GTBANK -> "Ekon Orji" +// 1111111111 / GTBANK -> "Alika Alabi" +// 0000000000 / GTBANK -> "Amarachi Nwosu" +// 4534076021 / any of six different banks -> "Dillon Bunch" every time +// +// Aide reads the account holder's name back so the person paying can stop +// before the money moves. That check is the only one a blind user has, and +// against a fabricator it is worse than having none: it turns a mistyped +// account number into a confident "Sending ₦20,000 to Ekon Orji". +// +// The decision is to surface whatever the provider says, so Aide behaves the +// same on the dev host as it will on production. The guard still exists and is +// still tested — it is opt-in behind BMONI_STRICT_NAME_ENQUIRY, so the day +// this points at a funded wallet it is one variable rather than a rewrite. + +describe("by default, the provider's answer is taken as given", () => { + beforeEach(() => vi.unstubAllEnvs()); + + it("does not second-guess the development host", () => { + // Aide will read "Ekon Orji" out loud for an account number nobody typed + // correctly. On the sandbox that is harmless: no wallet holds money and no + // persona is a real person. + expect(isFabricatedNameEnquiry("bmoni", "https://embedded-dev.bmoni.com")).toBe(false); + }); + + it("does not second-guess an unknown host either", () => { + expect(isFabricatedNameEnquiry("bmoni", undefined)).toBe(false); + }); +}); + +describe("with BMONI_STRICT_NAME_ENQUIRY turned on", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + vi.stubEnv("BMONI_STRICT_NAME_ENQUIRY", "true"); + }); + + it("treats the BMONI development host as fabricating", () => { + expect(isFabricatedNameEnquiry("bmoni", "https://embedded-dev.bmoni.com")).toBe(true); + }); + + it("treats any non-production BMONI host as fabricating", () => { + // Sandbox, staging, test — all the same promise, which is none. + for (const host of [ + "https://embedded-sandbox.bmoni.com", + "https://embedded-staging.bmoni.com", + "https://embedded-test.bmoni.com", + "https://embedded-dev.bmoni.com/", + ]) { + expect(isFabricatedNameEnquiry("bmoni", host)).toBe(true); + } + }); + + it("trusts the production BMONI host", () => { + expect(isFabricatedNameEnquiry("bmoni", "https://embedded.bmoni.com")).toBe(false); + }); + + it("trusts Monnify, whose sandbox does real name enquiry", () => { + // Monnify's sandbox rejects an account that does not exist, which is why + // this check is specific to the provider rather than to "is this a sandbox". + expect(isFabricatedNameEnquiry("monnify", "https://sandbox.monnify.com")).toBe(false); + }); + + it("distrusts an unknown host", () => { + // "We cannot tell" must not resolve to "verified" once strictness is on. + expect(isFabricatedNameEnquiry("bmoni", undefined)).toBe(true); + expect(isFabricatedNameEnquiry("bmoni", "")).toBe(true); + }); +}); + +describe("what Aide says before money moves", () => { + it("reads the name back when it was verified", () => { + expect(destinationConfirmation({ accountName: "Jabo Samson Joe", accountNumber: "3463455722", nameVerified: true })) + .toBe("to Jabo Samson Joe, account 3 4 6 3 4 5 5 7 2 2"); + }); + + it("spells the digits even alongside a verified name", () => { + // Accessibility rather than trust: "3463455722" reaches a screen reader as + // "three billion, four hundred and sixty-three million…", which cannot be + // checked against the number someone meant to type. + expect( + destinationConfirmation({ accountName: "Jabo Samson Joe", accountNumber: "3463455722", nameVerified: true }), + ).toContain("3 4 6 3 4 5 5 7 2 2"); + }); + + it("reads the digits back instead when the name was not verified", () => { + // The account number is the thing the user supplied and can check. The + // name is not. + const said = destinationConfirmation({ + accountName: "Ekon Orji", + accountNumber: "0123456789", + nameVerified: false, + }); + expect(said).toContain("0 1 2 3 4 5 6 7 8 9"); + expect(said).toMatch(/could not confirm/i); + }); + + it("never speaks an unverified name", () => { + // The whole point. If "Ekon Orji" is said out loud at all, someone will + // hear it as confirmation. + const said = destinationConfirmation({ + accountName: "Ekon Orji", + accountNumber: "0123456789", + nameVerified: false, + }); + expect(said).not.toContain("Ekon Orji"); + }); + + it("spaces the digits so they are heard one at a time", () => { + // "0123456789" is read by a screen reader as a single huge number. Digits + // separated are what makes a mistyped one audible. + expect( + destinationConfirmation({ accountName: "x", accountNumber: "3463455722", nameVerified: false }), + ).toContain("3 4 6 3 4 5 5 7 2 2"); + }); +}); diff --git a/tests/money/payment-coverage.test.ts b/tests/money/payment-coverage.test.ts index aed309b..9dba67e 100644 --- a/tests/money/payment-coverage.test.ts +++ b/tests/money/payment-coverage.test.ts @@ -54,6 +54,12 @@ let inbound: number; let paidApps: any[]; beforeEach(async () => { + // These exercise the Monnify adapter specifically — the Monnify HTTP module + // is what they mock. BMONI is the default provider now, so the rail under + // test is named rather than assumed; without this they would run against + // BMONI and fail on a missing sandbox user rather than on anything they are + // actually asserting. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); vi.resetModules(); ({ verifyPaymentCoverage } = await import("../../lib/store/applications")); calls = []; diff --git a/tests/money/pending-amount-spoken.test.ts b/tests/money/pending-amount-spoken.test.ts index b26d20e..545655a 100644 --- a/tests/money/pending-amount-spoken.test.ts +++ b/tests/money/pending-amount-spoken.test.ts @@ -39,6 +39,12 @@ const PENDING_BASE = { }; beforeEach(async () => { + // These exercise the Monnify adapter specifically — the Monnify HTTP module + // is what they mock. BMONI is the default provider now, so the rail under + // test is named rather than assumed; without this they would run against + // BMONI and fail on a missing sandbox user rather than on anything they are + // actually asserting. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); vi.resetModules(); ({ getWallet } = await import("../../lib/store/payments")); calls = []; diff --git a/tests/money/provider-seam.test.ts b/tests/money/provider-seam.test.ts new file mode 100644 index 0000000..7bb931a --- /dev/null +++ b/tests/money/provider-seam.test.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { selectedProvider } from "../../lib/banking/provider"; + +// BMONI is the provider now: it holds the wallets, the naira balance and the +// account numbers workers give out. So it is the default, and an unset or +// misspelled env var lands there rather than on the old rail. +// +// Monnify has not been deleted — it is still reachable by name, as the way +// back if the sandbox goes down mid-demo. But it is opt-in now, which is the +// exact reverse of how this file used to read. + +describe("which provider is live", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + delete process.env.AIDE_PAYMENT_PROVIDER; + }); + + it("is BMONI when nothing is set", () => { + expect(selectedProvider()).toBe("bmoni"); + }); + + it("is Monnify only when explicitly asked for", () => { + process.env.AIDE_PAYMENT_PROVIDER = "monnify"; + expect(selectedProvider()).toBe("monnify"); + process.env.AIDE_PAYMENT_PROVIDER = " MONNIFY "; + expect(selectedProvider()).toBe("monnify"); + }); + + it("falls back to BMONI on anything unrecognised", () => { + // A typo must land on the provider that actually holds the money, not on + // the one whose account numbers nobody is being paid into any more. + for (const v of ["monify", "monnify-sandbox", "MONEY", "true", "1", ""]) { + process.env.AIDE_PAYMENT_PROVIDER = v; + expect(selectedProvider()).toBe("bmoni"); + } + }); +}); diff --git a/tests/money/wallet-provider-switch.test.ts b/tests/money/wallet-provider-switch.test.ts new file mode 100644 index 0000000..0e8ea2e --- /dev/null +++ b/tests/money/wallet-provider-switch.test.ts @@ -0,0 +1,142 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeDispatch, walletDoc, type ConvexCall, type Handlers } from "../helpers/fake-convex"; + +// A wallet provisioned under one provider, on a deployment that has since +// switched to another. +// +// ensureWallet used to return early on `status === "active"` alone. That is +// true of every wallet Monnify ever provisioned, so switching to BMONI left +// those accounts permanently half-migrated: the page kept showing the old +// Monnify NUBAN, no BMONI user was ever created, and every BMONI call failed +// with "No BMONI user provisioned for demo-worker" — balance, bank list and +// card all at once. +// +// It cost real confusion because it looked like a BMONI outage rather than a +// wallet that had never been asked to migrate. Worse, the account number on +// screen stayed plausible and payable, so a worker could still hand it out — +// to an account this app no longer reads. +// +// "Active" therefore has to mean active FOR THE PROVIDER THAT IS LIVE. + +const hoisted = vi.hoisted(() => ({ + dispatch: (_ref: any, _args: any): any => { + throw new Error("dispatch not installed"); + }, +})); + +vi.mock("../../lib/convex-server", () => ({ + convexClient: () => ({ + query: (ref: any, args: any) => hoisted.dispatch(ref, args), + mutation: (ref: any, args: any) => hoisted.dispatch(ref, args), + }), + publishConvexEvent: () => Promise.resolve(), +})); + +const bmoni = vi.hoisted(() => ({ ensureWallet: vi.fn() })); +vi.mock("../../lib/banking/bmoni-provider", () => ({ + bmoniProvider: { + name: "bmoni", + ensureWallet: (id: string) => bmoni.ensureWallet(id), + getBalanceKobo: async () => 0, + listInbound: async () => [], + listBanks: async () => [], + verifyDestination: async () => ({ accountNumber: "", accountName: "", bankCode: "", nameVerified: true }), + payOut: async () => ({ state: "unknown" as const, reference: "", mayAnnounceArrival: false }), + }, +})); + +let ensureWallet: typeof import("../../lib/store/payments").ensureWallet; +let calls: ConvexCall[]; +let stored: Record; + +beforeEach(async () => { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "bmoni"); + vi.stubEnv("BMONI_API_KEY", "pk_test"); + ({ ensureWallet } = await import("../../lib/store/payments")); + calls = []; + bmoni.ensureWallet.mockReset(); + bmoni.ensureWallet.mockResolvedValue({ + accountNumber: "4534076021", + bankName: "PROVIDUS BANK", + accountName: "Dillon Bunch", + }); +}); + +function install(wallet: Record) { + stored = wallet; + const handlers: Handlers = { + "accounts:seedDefaults": () => null, + "wallets:getByAccount": () => stored, + "wallets:ensure": () => null, + "wallets:setProvisioned": (a: any) => { + stored = { ...stored, ...a, status: "active" }; + return null; + }, + "wallets:setFailed": () => null, + }; + hoisted.dispatch = makeDispatch(handlers, calls); +} + +describe("a wallet left behind by a provider switch", () => { + it("re-provisions a Monnify-era wallet when BMONI is live", async () => { + // Exactly the row a real deployment carries: active, with a working + // Monnify NUBAN, and no idea BMONI exists. + install({ ...walletDoc(), status: "active", accountNumber: "2209049616", bankName: "Sterling bank" }); + + const w = await ensureWallet("demo-worker"); + + expect(bmoni.ensureWallet).toHaveBeenCalledWith("demo-worker"); + expect(w.accountNumber).toBe("4534076021"); + expect(w.bankName).toBe("PROVIDUS BANK"); + }); + + it("stops showing the old provider's account number", async () => { + // The dangerous half. 2209049616 is a real, payable account — it just is + // not the one this app can read a balance from any more. Left on screen, + // a worker hands it to an employer and the money lands where Aide will + // never see it. + install({ ...walletDoc(), status: "active", accountNumber: "2209049616", bankName: "Sterling bank" }); + const w = await ensureWallet("demo-worker"); + expect(w.accountNumber).not.toBe("2209049616"); + }); + + it("does not re-provision a wallet already on the live provider", async () => { + // Once migrated it must settle. Re-running provisioning on every request + // would create a second BMONI wallet, which has no uniqueness guard. + install({ + ...walletDoc(), + status: "active", + provider: "bmoni", + accountNumber: "4534076021", + bankName: "PROVIDUS BANK", + }); + + const w = await ensureWallet("demo-worker"); + + expect(bmoni.ensureWallet).not.toHaveBeenCalled(); + expect(w.accountNumber).toBe("4534076021"); + }); + + it("records which provider provisioned it, so the next request settles", async () => { + install({ ...walletDoc(), status: "active", accountNumber: "2209049616", bankName: "Sterling bank" }); + await ensureWallet("demo-worker"); + + const provisioned = calls.find((c) => c.name === "wallets:setProvisioned"); + expect((provisioned?.args as any)?.provider).toBe("bmoni"); + }); + + it("treats a wallet with no recorded provider as Monnify's", async () => { + // Every row written before this field existed was Monnify's. Guessing the + // other way would re-provision an untouched Monnify deployment onto BMONI. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); + const { ensureWallet: ensureMonnify } = await import("../../lib/store/payments"); + install({ ...walletDoc(), status: "active", accountNumber: "2209049616", bankName: "Sterling bank" }); + + const w = await ensureMonnify("demo-worker"); + + expect(bmoni.ensureWallet).not.toHaveBeenCalled(); + expect(w.accountNumber).toBe("2209049616"); + }); +}); diff --git a/tests/money/withdrawal-guards.test.ts b/tests/money/withdrawal-guards.test.ts index 808479f..6b0b8c3 100644 --- a/tests/money/withdrawal-guards.test.ts +++ b/tests/money/withdrawal-guards.test.ts @@ -56,6 +56,12 @@ const baseHandlers = (over: Handlers = {}): Handlers => ({ }); beforeEach(() => { + // These exercise the Monnify adapter specifically — the Monnify HTTP module + // is what they mock. BMONI is the default provider now, so the rail under + // test is named rather than assumed; without this they would run against + // BMONI and fail on a missing sandbox user rather than on anything they are + // actually asserting. + vi.stubEnv("AIDE_PAYMENT_PROVIDER", "monnify"); acct = `u-worker-${++seq}`; calls = []; handlers = baseHandlers(); diff --git a/tests/money/withdrawal-provider-scope.test.ts b/tests/money/withdrawal-provider-scope.test.ts new file mode 100644 index 0000000..d51d396 --- /dev/null +++ b/tests/money/withdrawal-provider-scope.test.ts @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { makeDispatch, type ConvexCall, type Handlers } from "../helpers/fake-convex"; + +// The withdrawal ledger, after a provider switch. +// +// The worker's page listed three payouts made through Monnify — ₦50 and ₦100 to +// one stranger, ₦19 to another — all still reading "processing", months old and +// permanently so, because nothing on BMONI will ever advance a Monnify +// reference. The employer's page, having no such history, correctly said +// nothing had been sent. +// +// That is worse than untidy. Aide reads this list aloud. A blind worker asking +// "what have I sent?" would hear three transfers to names they may not know, +// described as still in flight — money apparently leaving their account right +// now, from a provider this deployment no longer talks to. There is no screen +// on which to notice the dates are from another era. +// +// So the ledger is scoped the same way the wallet is: rows belong to the +// provider that made them, and only the live provider's rows are shown. + +const hoisted = vi.hoisted(() => ({ + dispatch: (_ref: any, _args: any): any => { + throw new Error("dispatch not installed"); + }, +})); + +vi.mock("../../lib/convex-server", () => ({ + convexClient: () => ({ + query: (ref: any, args: any) => hoisted.dispatch(ref, args), + mutation: (ref: any, args: any) => hoisted.dispatch(ref, args), + }), + publishConvexEvent: () => Promise.resolve(), +})); + +let calls: ConvexCall[]; + +async function load(provider: string) { + vi.resetModules(); + vi.unstubAllEnvs(); + vi.stubEnv("AIDE_PAYMENT_PROVIDER", provider); + vi.stubEnv("BMONI_API_KEY", "pk_test"); + return await import("../../lib/store/payments"); +} + +function install(rows: unknown[]) { + const handlers: Handlers = { + "accounts:seedDefaults": () => null, + "wallets:listWithdrawals": () => rows, + "wallets:recordWithdrawal": () => null, + }; + hoisted.dispatch = makeDispatch(handlers, calls); +} + +beforeEach(() => { + calls = []; +}); + +describe("the withdrawal list, across a provider switch", () => { + it("asks Convex only for the live provider's rows", async () => { + const { getWithdrawals } = await load("bmoni"); + install([]); + await getWithdrawals("demo-worker"); + + const q = calls.find((c) => c.name === "wallets:listWithdrawals"); + expect((q?.args as any)?.provider).toBe("bmoni"); + }); + + it("records which provider moved the money", async () => { + // Without this the row is indistinguishable from a Monnify one the moment + // it is written, and the next switch hides a real payout instead. + const { recordWithdrawal } = await load("bmoni"); + install([]); + await recordWithdrawal("demo-worker", { amount: 500, accountName: "Ada Test", status: "PENDING" } as any); + + const m = calls.find((c) => c.name === "wallets:recordWithdrawal"); + expect((m?.args as any)?.provider).toBe("bmoni"); + }); + + it("shows an empty history rather than a stranger's name", async () => { + // What the page should have said all along, and what the employer's page + // already said: nothing sent yet. + const { getWithdrawals } = await load("bmoni"); + install([]); + expect(await getWithdrawals("demo-worker")).toEqual([]); + }); + + it("still lists the live provider's own withdrawals", async () => { + // The filter must not be so eager that a real payout disappears — a worker + // told their transfer was never made is the mirror-image lie. + const { getWithdrawals } = await load("bmoni"); + install([{ accountId: "demo-worker", amount: 500, accountName: "Ada Test", status: "PENDING", at: 1, provider: "bmoni" }]); + + const rows = await getWithdrawals("demo-worker"); + expect(rows).toHaveLength(1); + expect(rows[0].accountName).toBe("Ada Test"); + }); +}); diff --git a/tests/unit/persona-phone.test.ts b/tests/unit/persona-phone.test.ts new file mode 100644 index 0000000..7d5d081 --- /dev/null +++ b/tests/unit/persona-phone.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { personaPhone } from "../../lib/store/persona-phone"; + +// BMONI enforces global uniqueness on phoneNumber across every team sharing +// the development sandbox, and the two documented persona numbers +// (+2348000000000 and +2348000000001) are the first thing anyone reaches for. +// They are long gone: creating a user with one returns +// "409 User already exists with this phoneNumber", and because another team +// registered it under their own email, our conflict recovery correctly refuses +// to adopt it — binding a worker to a stranger's wallet is worse than failing. +// +// The phone is not part of identity matching. Verified against the sandbox: a +// user created with a generated number, the persona's real name, and the +// persona's BVN still completed start-nigeria and was issued a virtual account +// in the persona's name. Only the BVN and the name are matched. +// +// So the number just has to be well-formed, ours, and STABLE — a number that +// changed between calls would create a fresh BMONI user every time. + +describe("the phone number a demo persona is registered with", () => { + it("is a valid Nigerian E.164 number", () => { + expect(personaPhone("demo-worker", "dep-1")).toMatch(/^\+234[789]\d{9}$/); + }); + + it("is stable for the same account and deployment", () => { + // Instability here does not fail loudly — it silently creates a second + // BMONI user, and wallet creation has no uniqueness guard behind it. + expect(personaPhone("demo-worker", "dep-1")).toBe(personaPhone("demo-worker", "dep-1")); + }); + + it("differs between the worker and the employer", () => { + expect(personaPhone("demo-worker", "dep-1")).not.toBe(personaPhone("demo-employer", "dep-1")); + }); + + it("differs between deployments, so two clones of this repo do not collide", () => { + // The whole reason the documented numbers are unusable. + expect(personaPhone("demo-worker", "dep-1")).not.toBe(personaPhone("demo-worker", "dep-2")); + }); + + it("never produces one of the documented persona numbers", () => { + for (const dep of ["a", "b", "c", "dep-1", "anonymous:anonymous-agent"]) { + expect(["+2348000000000", "+2348000000001"]).not.toContain(personaPhone("demo-worker", dep)); + } + }); +}); diff --git a/tests/unit/pidgin.test.ts b/tests/unit/pidgin.test.ts new file mode 100644 index 0000000..d79b3b4 --- /dev/null +++ b/tests/unit/pidgin.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import { normalizePidgin, pidginScore, bestAlternative } from "../../lib/voice/pidgin"; + +// Nigerian Pidgin through an English recognizer. +// +// The Web Speech API has no Nigerian Pidgin model — `pcm` is not a language it +// accepts — so pidgin arrives transcribed by an English one, which renders it +// as the nearest English words: "dey" as "day", "wetin" as "we tin", "sabi" as +// "savvy". The model that has to act on the sentence then sees English that +// means something else, or nothing. +// +// Two repairs, in this order of importance: +// 1. Pick the best of the recognizer's alternatives, since the right words +// are often in the list but not first. +// 2. Repair the mishearings that survive. +// +// The hard rule throughout: NEVER touch anything that could be an amount. This +// text becomes a withdrawal. A rewrite that turns "one thousand" into +// something else is worse than any amount of misunderstood pidgin, because the +// user cannot see the figure to catch it. + +describe("leaving amounts alone", () => { + // These run first because they are the ones that must never regress. + it("does not alter digits", () => { + expect(normalizePidgin("send 25000 give am")).toContain("25000"); + expect(normalizePidgin("I wan comot 1500 naira")).toContain("1500"); + }); + + it("does not alter number words", () => { + // "one" is the trap: it is also how a recognizer hears "wan" (want). + // Rewriting it blindly turns "one thousand naira" into "want thousand". + for (const said of [ + "send one thousand naira", + "comot two hundred and fifty naira", + "I get five thousand for my account", + "make e be one five zero zero", + ]) { + const out = normalizePidgin(said); + for (const w of ["one", "two", "hundred", "thousand", "fifty", "five", "zero"]) { + if (said.includes(w)) expect(out).toContain(w); + } + } + }); + + it("still repairs 'wan' when it is clearly the verb, not the number", () => { + // "I one comot money" is never "the number one". The pronoun in front is + // what makes it safe to change. + expect(normalizePidgin("I one comot money")).toContain("wan"); + expect(normalizePidgin("I one comot money")).not.toContain("one comot"); + }); + + it("leaves 'one' alone when a number follows it", () => { + // "I one thousand" is someone saying an amount badly, not "I want". + expect(normalizePidgin("I one thousand naira")).toContain("one thousand"); + }); +}); + +describe("repairing what an English recognizer does to pidgin", () => { + it("hears 'dey' behind 'day'", () => { + expect(normalizePidgin("how much day my account")).toContain("dey my account"); + expect(normalizePidgin("I day find work")).toContain("I dey find work"); + expect(normalizePidgin("money no day")).toContain("money no dey"); + }); + + it("does not touch 'day' when it really means a day", () => { + // "two day work", "every day", "next day" are ordinary English. + expect(normalizePidgin("I fit work every day")).toContain("every day"); + expect(normalizePidgin("the work na three day")).toContain("three day"); + }); + + it("hears 'wetin' behind its spellings", () => { + for (const said of ["we tin dey happen", "weting dey happen", "wetin dey happen"]) { + expect(normalizePidgin(said)).toContain("wetin dey happen"); + } + }); + + it("hears 'abeg' behind 'I beg' and 'a beg'", () => { + expect(normalizePidgin("a beg check my balance")).toContain("abeg"); + expect(normalizePidgin("I beg check my balance")).toContain("abeg"); + }); + + it("hears 'comot' behind 'come out'", () => { + // "comot my money" is a withdrawal. "come out my money" is not English at + // all, so there is nothing to lose by repairing it. + expect(normalizePidgin("come out my money")).toContain("comot my money"); + }); + + it("hears 'sabi' behind 'savvy'", () => { + expect(normalizePidgin("I no savvy computer")).toContain("sabi"); + }); + + it("is case-insensitive and keeps the rest of the sentence", () => { + expect(normalizePidgin("Abeg How Much Day My Account")).toMatch(/dey my account/i); + }); + + it("leaves plain English untouched", () => { + const said = "please check my balance and find me a transcription job"; + expect(normalizePidgin(said)).toBe(said); + }); +}); + +describe("choosing between the recognizer's alternatives", () => { + it("scores a sentence by how much pidgin is in it", () => { + expect(pidginScore("wetin dey my account")).toBeGreaterThan(pidginScore("we tin day my account")); + }); + + it("picks the alternative that reads as pidgin", () => { + // The right words are often present but ranked second, because the + // recognizer's language model prefers ordinary English. + expect(bestAlternative(["we tin day happen", "wetin dey happen"])).toBe("wetin dey happen"); + }); + + it("keeps the recognizer's first choice when nothing looks like pidgin", () => { + // The recognizer is better at English than this heuristic is. Only + // outright pidgin markers may override it. + expect(bestAlternative(["check my balance", "cheque my balance"])).toBe("check my balance"); + }); + + it("keeps the first choice on a tie", () => { + expect(bestAlternative(["abeg send am", "abeg send am now"])).toBe("abeg send am"); + }); + + it("survives an empty list", () => { + expect(bestAlternative([])).toBe(""); + }); +}); diff --git a/tests/unit/public-account.test.ts b/tests/unit/public-account.test.ts new file mode 100644 index 0000000..193c0e8 --- /dev/null +++ b/tests/unit/public-account.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { publicAccount } from "../../lib/store/accounts"; +import type { Account } from "../../lib/store/state"; + +// publicAccount is the only shape of an account allowed to reach the browser. +// It gained new fields when BMONI provisioning needed a name split, a phone +// and a BVN — and a BVN is not a profile field. It is the identifier every +// Nigerian bank uses to tie a person to their accounts, it cannot be changed, +// and it is enough on its own to start an identity fraud. +// +// So this file exists to fail the moment a sensitive field is added to Account +// without a decision being made about whether it may be serialized. + +const full: Account = { + id: "demo-worker", + name: "Bunch Dillon", + role: "worker", + email: "aide-demo-worker@aide.test", + createdAt: 0, + skills: [], + bio: "", + passwordHash: "$2b$10$notarealhash", + firstName: "Bunch", + lastName: "Dillon", + phoneNumber: "+2348000000000", + bvn: "95888168924", +}; + +describe("what an account may tell the browser", () => { + it("never includes the BVN", () => { + const pub = publicAccount(full) as Record; + expect(pub.bvn).toBeUndefined(); + expect(JSON.stringify(pub)).not.toContain("95888168924"); + }); + + it("never includes the password hash", () => { + const pub = publicAccount(full) as Record; + expect(pub.passwordHash).toBeUndefined(); + }); + + it("still carries the things the profile page renders", () => { + const pub = publicAccount(full); + expect(pub.name).toBe("Bunch Dillon"); + expect(pub.role).toBe("worker"); + expect(pub.authenticated).toBe(true); + }); + + it("keeps the fields the account holder is entitled to see about themselves", () => { + // Their own name split and phone number are theirs; the BVN is the one + // that stays server-side, because it is a credential rather than a detail. + const pub = publicAccount(full) as Record; + expect(pub.firstName).toBe("Bunch"); + expect(pub.phoneNumber).toBe("+2348000000000"); + }); +});