From 4bda55fcf4091f85f05ed0bfa5203eb917c4ca04 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 07:01:54 +0000 Subject: [PATCH 01/17] feat(bmoni): sign BMONI proposals and owner proofs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BMONI takes two signatures with opposite methods: owner proof signs challenge text WITH the EIP-191 prefix, a proposal signs a raw 32-byte digest WITHOUT it. Signing a proposal the message way is the failure worth building against. The signature is structurally valid, decodes cleanly, carries a correct v byte, and is accepted and recorded — it just recovers to a different address, never matches the signer snapshot, and the transfer silently never executes. For Aide that is the worst shape a bug can take: it would have every reason to tell a worker their wages were sent, and no way to find out otherwise. BMONI has no endpoint that validates a signature without submitting it, so the published vector is the only proof available before real money depends on it. The module was written first with the WRONG method, so the red test was the actual bug — it recovered to 0x5e474E09 instead of 0xf39Fd6e5. It now matches BMONI's expected bytes exactly. Two wrong paths, not one. The docs name signMessage(getBytes(hash)) -> 0xC69f336b. Passing the hash as a string instead gives a THIRD address, 0x5e474E09, undocumented and failing the same silent way. Both are pinned, along with the digest regenerated from its preimage, so the comments cannot quietly stop being true. signProposalDigest is sync. There is nothing to await, and a Promise would imply a round trip that does not happen. --- lib/banking/signing.ts | 59 ++++++++++++++++ package-lock.json | 87 ++++++++++++++++++++++- package.json | 1 + tests/money/bmoni-signing.test.ts | 113 ++++++++++++++++++++++++++++++ 4 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 lib/banking/signing.ts create mode 100644 tests/money/bmoni-signing.test.ts 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/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/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, + ); + }); +}); From 1716650068d2b1179dca1cf0495827b238aaaf15 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 07:04:58 +0000 Subject: [PATCH 02/17] feat(bmoni): generate and encrypt per-user wallet owner keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aide holds the owner key for each user's smart wallet. BMONI's SDK would hold it in the Keystore or Secure Enclave, but it also puts a 4-to-6 digit PIN on screen for every signature — the interaction the spoken security phrase exists to replace, and one a blind user cannot complete. Aide is a web app besides, so the SDK was never available. The cost of that trade is that this key is ours to protect: whoever holds it can sign a transfer out of a worker's wallet. So it is AES-256-GCM sealed before it goes near Convex. GCM specifically, for the authentication rather than the secrecy. A tampered row must fail loudly, not decrypt to a DIFFERENT key — a different key signs proposals that recover to an address BMONI does not recognise, which it accepts, records, and never executes. That is the same silent failure as the signing module, one layer down. The random IV matters too: identical ciphertexts would reveal that two users share a key and let a stolen row be replayed onto another. BMONI_KEY_SECRET is read lazily so importing this cannot break a build that runs with no secrets, but there is no default and no fallback — without it, sealing and opening both refuse rather than quietly using a key that ships in the repository. --- .env.example | 15 ++++++ lib/banking/keys.ts | 68 ++++++++++++++++++++++++++ tests/money/bmoni-keys.test.ts | 89 ++++++++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 lib/banking/keys.ts create mode 100644 tests/money/bmoni-keys.test.ts diff --git a/.env.example b/.env.example index e709896..6114c62 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,21 @@ 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. +# BMONI_BASE_URL=https://embedded-dev.bmoni.com +# BMONI_API_KEY=pk_xxxxxxxx + +# 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/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/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/); + }); +}); From 193efd33040cce0ef3bc8d9f67f69aa3438f4bb0 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 11:53:22 +0000 Subject: [PATCH 03/17] feat(bmoni): amounts, HTTP transport, operations and proposal status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four pieces, all of which exist because BMONI hands back plain strings and numbers with nothing to distinguish a unit or a state. amounts.ts — BMONI mixes decimal strings, minor-unit strings and plain numbers, none type-checked. Parsing fails closed: parseFloat takes the leading digits of "100abc", expands "1e3" to a thousand, and rounds "100.005" up to a kobo that never existed. Each is a plausible number Aide would then say out loud. Verified those against the real parseFloat rather than assuming them; the naive round-trip I expected to break turned out to be fine, so the tests pin the faults that are actually there. bmoni-client.ts — the transport rule, and it is deliberately asymmetric. BMONI has NO idempotency keys anywhere; wallet creation has no uniqueness guard at all. So reads may time out and retry, and money-moving calls may do neither. Aborting a slow transfer does not cancel it — it only destroys our record of having asked, which is how a "failed" withdrawal becomes a second one. Both money rules were written before their tests, so the tests were proved by mutation rather than trusted: adding a retry failed the retry test, adding a timeout failed the abort test, and the implementation was restored after each. bmoni.ts — operations in lifecycle order. Note the wallet precedes KYC, which is backwards from most providers: start-nigeria needs the wallet address. proposal-status.ts — an unknown status must never mean "completed". Exact and case-sensitive: "SUCCESS", "SETTLED" and lowercase "completed" all resolve to unknown, because a status we have not seen documented resolving to "your wages arrived" is the one error the listener cannot catch. Gap worth recording: BMONI's docs recover a 409 with findUserByEmail, but publish no such endpoint. There is no way to ask which user collided, so bmoniUserId must be persisted by us at creation and a 409 recovered from our own store. --- lib/banking/amounts.ts | 50 +++++ lib/banking/bmoni-client.ts | 125 ++++++++++++ lib/banking/bmoni.ts | 227 ++++++++++++++++++++++ lib/banking/proposal-status.ts | 48 +++++ tests/money/bmoni-amounts.test.ts | 85 ++++++++ tests/money/bmoni-client.test.ts | 122 ++++++++++++ tests/money/bmoni-proposal-status.test.ts | 72 +++++++ 7 files changed, 729 insertions(+) create mode 100644 lib/banking/amounts.ts create mode 100644 lib/banking/bmoni-client.ts create mode 100644 lib/banking/bmoni.ts create mode 100644 lib/banking/proposal-status.ts create mode 100644 tests/money/bmoni-amounts.test.ts create mode 100644 tests/money/bmoni-client.test.ts create mode 100644 tests/money/bmoni-proposal-status.test.ts 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-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.ts b/lib/banking/bmoni.ts new file mode 100644 index 0000000..143fedd --- /dev/null +++ b/lib/banking/bmoni.ts @@ -0,0 +1,227 @@ +import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; +import { koboToDecimalString } from "./amounts"; + +// 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 }; +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. BMONI documents +// recovering with `findUserByEmail`, but publishes no such endpoint — there is +// no way to ask "which user is that?" after the fact. So the bmoniUserId must +// be persisted by US at creation, and a 409 is only survivable if we already +// hold it. The caller is responsible for that; this throws with the conflict +// flagged so it can look in its own store rather than creating a second user. +export async function createBmoniUser(u: NewUser): Promise { + return 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 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` }); +} + +// ---- 3. KYC / rail ---------------------------------------------------------- + +export async function startNigeriaOnboarding(args: { + userId: string; + bvn: string; + ngnWalletAddress: 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)}`); + return await bmoniRead({ + path: `/v1/users/${args.userId}/onboarding/start-nigeria`, + method: "POST", + body: { bvn, ngnWalletAddress: args.ngnWalletAddress }, + }); +} + +export async function onboardingStatus(userId: string): Promise<{ status?: string }> { + return await bmoniRead({ path: `/v1/users/${userId}/onboarding/status` }); +} + +// ---- 4. Deposits (money in) ------------------------------------------------- + +export async function pointDepositsAtWallet(userId: string, smartWalletId: string): Promise { + return await bmoniRead({ + path: `/v1/users/${userId}/smart-wallets/${smartWalletId}/onramp/vba/nigeria`, + method: "POST", + }); +} + +// The account number a worker actually gives out to be paid into. +export async function getNgnDepositAccount(userId: string): Promise { + return await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/deposit-accounts/NGN` }); +} + +// ---- 5. Withdrawal destinations -------------------------------------------- + +export async function listNigerianBanks(userId: string): Promise> { + return 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<{ accountHolderName: string }> { + return 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 }; 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/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-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-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); + } + }); +}); From 3a3cd6793181fbd8c2824e96d9cca984feb39ac1 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 12:36:01 +0000 Subject: [PATCH 04/17] feat(bmoni): wallet provisioning, bank payouts and the provider seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the money path: provision a user and smart wallet, activate the NGN rail, and pay out to a Nigerian bank — behind a seam that keeps Monnify the default. The double-payment guard is the important part. A payout is four calls (create, approve, fetch payload, sign) and BMONI has no idempotency keys, so a failure anywhere after the first leaves a REAL payout sitting at BMONI. Starting over would pay the same wages twice. The proposal id is therefore claimed in Convex the instant it exists, a later attempt resumes that proposal rather than creating another, and the claim refuses instead of overwriting when a different one is already in flight. The guard is released only on a terminal status. An UNKNOWN status deliberately does not release it: not knowing what happened to the last payout is exactly when a second must stay blocked. Provisioning is resumable for the same reason. bmoniUserId is 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 — and BMONI publishes no lookup endpoint, so losing that id spends the phone number for good. Wallet creation has no uniqueness guard at all, so if one may already exist the balances are read first and the flow refuses rather than making a second wallet. Both guards were written before their tests, so both were proved by mutation rather than trusted: removing the resume failed the resume test, clearing on unknown failed the fail-closed test. Two honest gaps, recorded in code rather than papered over. listInbound returns [] on BMONI — there is no wallet-level inbound history endpoint, only card history, and synthesising a credit from a balance delta would be Aide announcing a payment it cannot see. Provisioning refuses without a first/last name and phone, which Aide has never collected; splitting one free-text name would be a guess about someone's legal identity that fails KYC undebuggably later. The seam itself is explicit opt-in: anything that is not exactly "bmoni" resolves to Monnify, so a typo cannot move real wages onto a sandbox. lib/monnify.ts and lib/store/payments.ts are untouched. --- .env.example | 10 + convex/schema.ts | 43 ++++ convex/wallets.ts | 105 +++++++++ lib/banking/bmoni-provider.ts | 79 +++++++ lib/banking/bmoni-wallet.ts | 304 +++++++++++++++++++++++++ lib/banking/index.ts | 17 ++ lib/banking/monnify-provider.ts | 66 ++++++ lib/banking/provider.ts | 69 ++++++ tests/money/bmoni-payout-guard.test.ts | 183 +++++++++++++++ tests/money/provider-seam.test.ts | 35 +++ 10 files changed, 911 insertions(+) create mode 100644 lib/banking/bmoni-provider.ts create mode 100644 lib/banking/bmoni-wallet.ts create mode 100644 lib/banking/index.ts create mode 100644 lib/banking/monnify-provider.ts create mode 100644 lib/banking/provider.ts create mode 100644 tests/money/bmoni-payout-guard.test.ts create mode 100644 tests/money/provider-seam.test.ts diff --git a/.env.example b/.env.example index 6114c62..e93e89e 100644 --- a/.env.example +++ b/.env.example @@ -42,9 +42,19 @@ 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= + # 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 diff --git a/convex/schema.ts b/convex/schema.ts index 2199e42..9f62fd0 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -39,6 +39,13 @@ 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 }) .index("by_key", ["key"]) .index("by_email", ["email"]), @@ -81,6 +88,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 diff --git a/convex/wallets.ts b/convex/wallets.ts index d6dc092..ac37d7e 100644 --- a/convex/wallets.ts +++ b/convex/wallets.ts @@ -196,6 +196,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({ diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts new file mode 100644 index 0000000..df29075 --- /dev/null +++ b/lib/banking/bmoni-provider.ts @@ -0,0 +1,79 @@ +import { api } from "../../convex/_generated/api"; +import { convexClient } from "../convex-server"; +import { getAccount } from "../store/accounts"; +import { decimalStringToKobo } from "./amounts"; +import { getNgnDepositAccount, listBalances, verifyNigerianAccount } from "./bmoni"; +import { payOutToBank, provisionBmoniWallet } from "./bmoni-wallet"; +import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; + +// BMONI behind the seam. Every amount crossing back out of here is kobo. + +async function bmoniUserOf(accountId: string): Promise { + const w = (await convexClient().query(api.wallets.getByAccount, { accountId })) as { bmoniUserId?: string } | null; + if (!w?.bmoniUserId) throw new Error(`No BMONI user provisioned for ${accountId}`); + return w.bmoniUserId; +} + +export const bmoniProvider: PaymentProvider = { + name: "bmoni", + + async ensureWallet(accountId) { + const acc = (await getAccount(accountId)) as { + firstName?: string; + lastName?: string; + phoneNumber?: string; + email?: string; + }; + const { bmoniUserId } = await provisionBmoniWallet(accountId, `aide-${accountId}`, acc); + // The NUBAN a worker actually gives out. Read separately: the wallet is an + // address, the deposit account is the bank-facing side of it. + const deposit = (await getNgnDepositAccount(bmoniUserId)) as { + accountNumber?: string; + bankName?: string; + } | null; + return { accountNumber: deposit?.accountNumber, bankName: deposit?.bankName }; + }, + + async listInbound(accountId): Promise { + // KNOWN GAP, and the biggest one in this integration. BMONI's intro page + // claims transaction history, but the reference documents it only for + // cards — there is no wallet-level inbound history endpoint. Balances give + // a total, not the individual credits Aide announces ("₦5,000 just landed + // from Adebayo"). + // + // Returning [] rather than fabricating a credit from the balance delta is + // the honest answer: a synthesised credit would be Aide announcing a + // payment it cannot actually see. Until the endpoint exists or webhooks are + // wired, inbound announcements are a Monnify-only feature. + void (await listBalances(await bmoniUserOf(accountId))); + return []; + }, + + async verifyDestination(accountNumber, bankCode): Promise { + // Name enquiry needs a user context in BMONI, unlike Monnify's global one. + const anyUser = process.env.BMONI_VERIFY_AS_USER_ID?.trim(); + if (!anyUser) { + throw new Error( + "BMONI name enquiry is per-user; set BMONI_VERIFY_AS_USER_ID or verify through the paying account.", + ); + } + const r = await verifyNigerianAccount(anyUser, accountNumber, bankCode); + return { accountNumber, bankCode, accountName: r.accountHolderName }; + }, + + 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-wallet.ts b/lib/banking/bmoni-wallet.ts new file mode 100644 index 0000000..3178068 --- /dev/null +++ b/lib/banking/bmoni-wallet.ts @@ -0,0 +1,304 @@ +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, + 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) { + throw new Error( + `BMONI already holds a user for this email or phone (${e.message}), but we have no stored ` + + `bmoniUserId for ${accountId} and BMONI publishes no lookup endpoint. This needs manual ` + + `recovery — do not retry, it will keep colliding.`, + ); + } + 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 balances = await listBalances(bmoniUserId); + if (hasWallet(balances)) { + 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 }; +} + +// Deliberately conservative: anything that looks like a wallet counts. This +// gates "may I create another", so a false positive costs a manual check and a +// false negative costs a duplicate wallet. +function hasWallet(balances: unknown): boolean { + if (!balances) return false; + const list = Array.isArray(balances) ? balances : (balances as any)?.wallets ?? (balances as any)?.balances; + return Array.isArray(list) && 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; +}): Promise { + await startNigeriaOnboarding({ + userId: args.bmoniUserId, + bvn: args.bvn, + ngnWalletAddress: args.walletAddress, + }); + await pointDepositsAtWallet(args.bmoniUserId, args.smartWalletId); +} + +// ---- 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.accountHolderName, + }); + 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/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/monnify-provider.ts b/lib/banking/monnify-provider.ts new file mode 100644 index 0000000..5709873 --- /dev/null +++ b/lib/banking/monnify-provider.ts @@ -0,0 +1,66 @@ +import { randomUUID } from "node:crypto"; +import { getReservedAccountTransactions, singleTransfer, validateBankAccount } from "../monnify"; +import { ensureWallet as ensureMonnifyWallet, getWallet } from "../store/payments"; +import { 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"]); + +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(), + })); + }, + + async verifyDestination(accountNumber, bankCode): Promise { + const r = await validateBankAccount(accountNumber, bankCode); + return { accountNumber: r.accountNumber, accountName: r.accountName, bankCode }; + }, + + 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/provider.ts b/lib/banking/provider.ts new file mode 100644 index 0000000..89a217e --- /dev/null +++ b/lib/banking/provider.ts @@ -0,0 +1,69 @@ +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, already name-enquiry verified. +export type VerifiedAccount = { accountNumber: string; accountName: string; bankCode: string }; + +// 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. + ensureWallet(accountId: string): Promise<{ accountNumber?: string; bankName?: string }>; + + // Confirmed money IN, in kobo. The credit side of the balance. + listInbound(accountId: string): Promise; + + // Name enquiry. Throws if the account does not resolve — never guesses a name. + verifyDestination(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. Monnify unless BMONI is explicitly selected, so an +// unset or misspelled value falls back to the one that has been running rather +// than to the new one. +export function selectedProvider(): ProviderName { + return process.env.AIDE_PAYMENT_PROVIDER?.trim().toLowerCase() === "bmoni" ? "bmoni" : "monnify"; +} + +// Monnify reports naira as a float; everything above this line is kobo. +export function monnifyNairaToKobo(naira: number): number { + return toKobo(naira); +} 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/provider-seam.test.ts b/tests/money/provider-seam.test.ts new file mode 100644 index 0000000..345fb17 --- /dev/null +++ b/tests/money/provider-seam.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { selectedProvider } from "../../lib/banking/provider"; + +// The seam's only job at this stage is to be reversible, and reversibility +// rests on one thing: the default has to be the provider that already works. +// +// A misspelled or half-set env var must not silently move a worker's wages +// onto a sandbox integration. So selection is explicit-opt-in — anything that +// is not exactly "bmoni" is Monnify. + +describe("which provider is live", () => { + beforeEach(() => { + vi.unstubAllEnvs(); + delete process.env.AIDE_PAYMENT_PROVIDER; + }); + + it("is Monnify when nothing is set", () => { + expect(selectedProvider()).toBe("monnify"); + }); + + it("is BMONI only when explicitly asked for", () => { + process.env.AIDE_PAYMENT_PROVIDER = "bmoni"; + expect(selectedProvider()).toBe("bmoni"); + process.env.AIDE_PAYMENT_PROVIDER = " BMONI "; + expect(selectedProvider()).toBe("bmoni"); + }); + + it("falls back to Monnify on anything unrecognised", () => { + // A typo must land on the provider that has been running, not the new one. + for (const v of ["bmon", "bmoni-dev", "BMONEY", "true", "1", ""]) { + process.env.AIDE_PAYMENT_PROVIDER = v; + expect(selectedProvider()).toBe("monnify"); + } + }); +}); From 57ac7b676f7c4d56f59860d18d472a883d33914f Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 15:20:16 +0000 Subject: [PATCH 05/17] fix(voice): say a zero balance out loud instead of skipping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The greeting only mentioned money when the balance was above zero, so a worker with nothing heard the same thing as a worker whose balance had not come back yet: nothing at all. Those are different facts and only one of them means "stop waiting for your wages". A sighted user resolves the ambiguity by glancing at the screen. Ours cannot, which is the case this whole product is built for. Zero is now stated plainly, and not described as ready to withdraw. Silence in the money line now means only that the figure missed the 2.5s budget the greeting allows so Aide can start speaking promptly — still deliberate, since announcing a failure on every slow morning would worry people about something routine. Both lines now format through formatNaira, so kobo are spoken rather than rounded away: a worker owed 12,000.50 who hears "twelve thousand naira" has been told a number that is not theirs. Extracted as a pure function so what Aide says can be asserted without standing up a route. Proved by mutation — restoring the skip-zero behaviour fails two of the five tests. --- app/api/greeting/route.ts | 14 +++++--- lib/greeting-balance.ts | 21 ++++++++++++ tests/money/greeting-balance-line.test.ts | 41 +++++++++++++++++++++++ 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 lib/greeting-balance.ts create mode 100644 tests/money/greeting-balance-line.test.ts 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/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/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"); + }); +}); From ccfac3d0a0fe7f8370374863e6be5f2c382ef698 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Thu, 3 Sep 2026 18:40:35 +0000 Subject: [PATCH 06/17] fix(bmoni): parse the shapes the sandbox actually returns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Probed the live sandbox rather than trusting the documented client types. Three responses did not match, and every mismatch was silent — the field came back undefined, got persisted, and would have surfaced later as a 404 on a path containing the word "undefined". - POST /v1/users wraps the user, and carries two uuids. `id` is the partner record; `bmoniUserId` is what every other endpoint paths on. Reading the top level gave undefined; picking `id` gives "User not found". - create-managed returns `id`/`walletAddress`, not `smartWalletId`/`address`. A wallet requested as CNGN comes back as NGN. - balances wraps in `balances`, `balance` is a decimal string, and each entry carries its own `error` inside an HTTP 200. - nigerian-banks wraps in `banks` with bankName/bankCode, and the codes are NIBSS institution codes (Wema 000017), not the NIP codes the payments page uses (Wema 035). The two lists are not interchangeable. - onboarding/status has no `status`: one field per rail, all independent. - onramp/vba/nigeria requires a bankAccountId UUID. Which one is still unknown, so it is a required argument rather than a guess. The deposit-account parser refuses BMONI's pooled house account. It is a real, payable NUBAN returned to any user without their own virtual account, and nothing in the response says it is not theirs — Aide would have read it aloud as the worker's own and an employer would have paid wages into a shared pool with no reference tying them to anyone. Nothing defaults. A balance parser that fell back to zero would tell a worker with money that they have none, out loud, with no screen to check. --- lib/banking/bmoni-shapes.ts | 117 ++++++++++++++++++++ lib/banking/bmoni-wallet.ts | 8 +- lib/banking/bmoni.ts | 57 ++++++++-- tests/money/bmoni-shapes.test.ts | 184 +++++++++++++++++++++++++++++++ 4 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 lib/banking/bmoni-shapes.ts create mode 100644 tests/money/bmoni-shapes.test.ts diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts new file mode 100644 index 0000000..f75f022 --- /dev/null +++ b/lib/banking/bmoni-shapes.ts @@ -0,0 +1,117 @@ +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); +} + +// ---- 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): { accountNumber: string; bankName: 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"); + } + const a = obj(list[0]); + const id = typeof a.id === "string" ? a.id : ""; + const name = typeof a.accountName === "string" ? a.accountName : ""; + + if (POOLED_ID.test(id) || POOLED_NAME.test(name)) { + // Refusing here is the whole point. Handing this 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 returned its pooled house account (${name || id}), not this worker's own. ` + + `A per-user virtual account has not been provisioned yet — it requires completed Nigerian onboarding.`, + ); + } + return { accountNumber: str(a.accountNumber, "accounts[].accountNumber"), bankName: str(a.bankName, "accounts[].bankName") }; +} + +// ---- 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") })); +} diff --git a/lib/banking/bmoni-wallet.ts b/lib/banking/bmoni-wallet.ts index 3178068..8e39eb8 100644 --- a/lib/banking/bmoni-wallet.ts +++ b/lib/banking/bmoni-wallet.ts @@ -178,13 +178,19 @@ export async function activateNigeriaRail(args: { 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, }); - await pointDepositsAtWallet(args.bmoniUserId, args.smartWalletId); + await pointDepositsAtWallet(args.bmoniUserId, args.smartWalletId, args.bankAccountId); } // ---- Paying out ------------------------------------------------------------- diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index 143fedd..ff99c9a 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -1,5 +1,6 @@ import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; import { koboToDecimalString } from "./amounts"; +import { parseBanks, parseCreatedUser, parseCreatedWallet, parseNgnBalanceKobo, parseNgnDepositAccount } from "./bmoni-shapes"; // The BMONI Embedded operations Aide uses, in lifecycle order: // @@ -13,7 +14,7 @@ import { koboToDecimalString } from "./amounts"; // timeout, no retry. Everything else is a read. export type BmoniUser = { bmoniUserId: string }; -export type SmartWallet = { smartWalletId: string; address: 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 }; @@ -29,7 +30,7 @@ export type NewUser = { firstName: string; lastName: string; email: string; phon // hold it. The caller is responsible for that; this throws with the conflict // flagged so it can look in its own store rather than creating a second user. export async function createBmoniUser(u: NewUser): Promise { - return await bmoniRead({ path: "/v1/users", method: "POST", body: u }); + return parseCreatedUser(await bmoniRead({ path: "/v1/users", method: "POST", body: u })); } // ---- 2. Smart wallet -------------------------------------------------------- @@ -57,7 +58,8 @@ export async function createSmartWallet(args: { ownerProofSignature: string; currency?: string; }): Promise { - return await bmoniMove({ + return parseCreatedWallet( + await bmoniMove({ path: `/v1/users/${args.userId}/smart-wallets/create-managed`, method: "POST", body: { @@ -66,7 +68,8 @@ export async function createSmartWallet(args: { ownerProofChallengeId: args.ownerProofChallengeId, ownerProofSignature: args.ownerProofSignature, }, - }); + }), + ); } // The read-before-retry that makes a failed wallet create safe to recover from. @@ -74,6 +77,12 @@ 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: { @@ -92,28 +101,58 @@ export async function startNigeriaOnboarding(args: { }); } -export async function onboardingStatus(userId: string): Promise<{ status?: string }> { +// 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) ------------------------------------------------- -export async function pointDepositsAtWallet(userId: string, smartWalletId: string): Promise { +// 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 { - return await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/deposit-accounts/NGN` }); +export async function getNgnDepositAccount(userId: string): Promise<{ accountNumber: string; bankName: 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 await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/nigerian-banks` }); + 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 diff --git a/tests/money/bmoni-shapes.test.ts b/tests/money/bmoni-shapes.test.ts new file mode 100644 index 0000000..5efa31c --- /dev/null +++ b/tests/money/bmoni-shapes.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + parseBanks, + parseCreatedUser, + parseCreatedWallet, + parseNgnBalanceKobo, + parseNgnDepositAccount, +} 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("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", + }, + ], + }; + + 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({ accountNumber: "8012345678", bankName: "9 Payment Service Bank" }); + }); + + 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/); + }); +}); From 93129aad3a228983714915708c5fafd739559734 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 02:05:57 +0000 Subject: [PATCH 07/17] feat(bmoni): card panel on the payments page, and the deposit account fix the sandbox forced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walked Bunch Dillon (worker persona) through the whole lifecycle on the shared sandbox: user, wallet, start-nigeria, virtual account, VBA link. Nigerian onboarding does NOT require the full KYC wizard — kyc/readiness still reports address and biometrics missing, but start-nigeria returns 200 and anchorStatus goes active, which issues a real per-user account: 1442873873 at PROVIDUS BANK in the worker's own name. That run also disproved how the deposit parser picked an account. It took accounts[0]. Live, the worker's own account is issued asynchronously — for the first few seconds the list holds ONLY the pooled Bkey house account, and afterwards it holds both with the worker's first. So accounts[0] is sometimes right and sometimes the pooled account, with nothing in the response distinguishing them. It now filters by identity instead. Cards: POST /v1/users/{userId}/cards exists after all. Creation is on the USER path while listing is on the SMART-WALLET path, which is why posting to the wallet path 404s. Issuance is proven end to end and stops only at E503 "Insufficient balance to cover the card fee" — the ₦1,000 fee against a zero wallet, which needs BMONI to credit the sandbox balance. The panel renders the real state read from BMONI, and keeps four states apart: not switched on, no wallet, no card, and card being issued. Folding "being issued" into "no card" would tell a worker mid-issuance that nothing happened, and they would request a second card and be charged twice. No card number is rendered or held. BMONI returns the PAN only from the sensitive-data call it documents as never-log and never-cache, and publishes no masked-PAN field on the list route — so any digits on the card face would be invented ones. --- app/api/payments/card/route.ts | 48 ++++++++++++ app/payments/page.tsx | 125 ++++++++++++++++++++++++++++++- lib/banking/bmoni-cards.ts | 67 +++++++++++++++++ lib/banking/bmoni-shapes.ts | 31 +++++--- lib/banking/bmoni.ts | 9 +++ tests/money/bmoni-cards.test.ts | 104 +++++++++++++++++++++++++ tests/money/bmoni-shapes.test.ts | 25 +++++++ 7 files changed, 397 insertions(+), 12 deletions(-) create mode 100644 app/api/payments/card/route.ts create mode 100644 lib/banking/bmoni-cards.ts create mode 100644 tests/money/bmoni-cards.test.ts 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/payments/page.tsx b/app/payments/page.tsx index 0b0ebf9..f52c70f 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. @@ -59,6 +79,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); @@ -100,10 +123,15 @@ 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] = 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 })), ]); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error || "Could not load your payment details."); @@ -114,6 +142,7 @@ 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" }); } catch (e) { setError((e as Error).message); } finally { @@ -620,10 +649,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/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-shapes.ts b/lib/banking/bmoni-shapes.ts index f75f022..73d2ba9 100644 --- a/lib/banking/bmoni-shapes.ts +++ b/lib/banking/bmoni-shapes.ts @@ -88,20 +88,29 @@ export function parseNgnDepositAccount(body: unknown): { accountNumber: string; if (!Array.isArray(list) || list.length === 0) { throw new Error("BMONI returned no naira deposit account for this user"); } - const a = obj(list[0]); - const id = typeof a.id === "string" ? a.id : ""; - const name = typeof a.accountName === "string" ? a.accountName : ""; - - if (POOLED_ID.test(id) || POOLED_NAME.test(name)) { - // Refusing here is the whole point. Handing this 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. + // 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 returned its pooled house account (${name || id}), not this worker's own. ` + - `A per-user virtual account has not been provisioned yet — it requires completed Nigerian onboarding.`, + "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 { accountNumber: str(a.accountNumber, "accounts[].accountNumber"), bankName: str(a.bankName, "accounts[].bankName") }; } diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index ff99c9a..a5b3ca6 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -1,5 +1,6 @@ import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; import { koboToDecimalString } from "./amounts"; +import { parseCards } from "./bmoni-cards"; import { parseBanks, parseCreatedUser, parseCreatedWallet, parseNgnBalanceKobo, parseNgnDepositAccount } from "./bmoni-shapes"; // The BMONI Embedded operations Aide uses, in lifecycle order: @@ -264,3 +265,11 @@ export async function getProposal(userId: string, proposalId: string): Promise

{ + 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-shapes.test.ts b/tests/money/bmoni-shapes.test.ts index 5efa31c..19dc898 100644 --- a/tests/money/bmoni-shapes.test.ts +++ b/tests/money/bmoni-shapes.test.ts @@ -133,6 +133,31 @@ describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/NGN", () => { ], }; + // 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({ + accountNumber: "4534076021", + bankName: "PROVIDUS BANK", + }); + }); + + 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 From bc1c890124c10af501e2ec25a0f675d672ecf2c5 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 02:18:30 +0000 Subject: [PATCH 08/17] feat(bmoni): make BMONI the provider, not an option behind a flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The seam existed but nothing routed through it, so every number on the payments page still came from Monnify. The default is inverted now: BMONI unless AIDE_PAYMENT_PROVIDER explicitly says monnify, which stays only as a way back if the sandbox goes down mid-demo. - getBalance and ensureWallet in lib/store/payments.ts go through paymentProvider(). The store no longer knows which rail it is on. - Added getBalanceKobo to the seam, because the two providers know the figure in genuinely different ways. Monnify has no balance for a reserved account, so its adapter derives one by subtracting Aide's withdrawal ledger from confirmed credits. BMONI holds the wallet and has already debited every payout — subtracting the ledger again would understate a worker's money by everything they had ever withdrawn. - Added canListInbound. BMONI publishes a balance but no wallet-level inbound history, and returning [] would have rendered as "No payments received yet" — a claim that nobody has paid this worker, which is exactly what they cannot check for themselves. The page now says it can see the total but not the individual payments. - The demo worker is Bunch Dillon, the sandbox persona, and their wallet points at the BMONI virtual account rather than the Monnify one. Live: balance ₦0.00 from BMONI, account 4534076021 at PROVIDUS BANK, and the greeting says the zero out loud. The Monnify-mocking tests now name their rail instead of relying on the default. New tests cover the BMONI balance path end to end, including the two ways it must fail rather than answer zero: an unreachable provider, and a per-entry error arriving inside an HTTP 200. --- app/api/payments/transactions/route.ts | 42 +++++--- app/profile/page.tsx | 2 +- lib/banking/bmoni-provider.ts | 19 ++-- lib/banking/monnify-provider.ts | 17 +++- lib/banking/provider.ts | 34 ++++++- lib/store/payments.ts | 36 +++++-- lib/store/state.ts | 2 +- tests/money/balance-unavailable.test.ts | 6 ++ tests/money/bmoni-balance-path.test.ts | 115 ++++++++++++++++++++++ tests/money/demo-balance.test.ts | 6 ++ tests/money/payment-coverage.test.ts | 6 ++ tests/money/pending-amount-spoken.test.ts | 6 ++ tests/money/provider-seam.test.ts | 34 ++++--- tests/money/withdrawal-guards.test.ts | 6 ++ 14 files changed, 282 insertions(+), 49 deletions(-) create mode 100644 tests/money/bmoni-balance-path.test.ts diff --git a/app/api/payments/transactions/route.ts b/app/api/payments/transactions/route.ts index 763e15a..6f90fe2 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. @@ -21,14 +23,30 @@ export async function GET(req: Request) { const outbound = await getWithdrawals(acc.id).catch(() => []); + const provider = paymentProvider(); + + // Not a failure, and not "no payments". BMONI publishes a wallet balance but + // no wallet-level list of the credits behind it, so there is nothing + // truthful to itemise. Returning [] would render as "No payments received + // yet" — a claim that nobody has paid this worker, which is exactly the + // thing they cannot check for themselves. + if (!provider.canListInbound) { + return Response.json({ + inbound: null, + inboundUnavailable: + "I can see your balance, but I cannot list the individual payments behind it yet. " + + "Your balance above is the confirmed total.", + outbound, + }); + } + 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 provider.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/profile/page.tsx b/app/profile/page.tsx index 98d5255..bfdd429 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -146,7 +146,7 @@ export default function ProfilePage() { }} className="min-h-10 rounded-lg border-2 border-[var(--ink)] px-4 py-2 font-bold text-[var(--ink)]" > - Switch to Demo Worker (Aide Demo Worker) + Switch to Demo Worker (Bunch Dillon) )} diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index df29075..31046c7 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -2,7 +2,7 @@ import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { getAccount } from "../store/accounts"; import { decimalStringToKobo } from "./amounts"; -import { getNgnDepositAccount, listBalances, verifyNigerianAccount } from "./bmoni"; +import { getNgnBalanceKobo, getNgnDepositAccount, listBalances, verifyNigerianAccount } from "./bmoni"; import { payOutToBank, provisionBmoniWallet } from "./bmoni-wallet"; import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; @@ -16,6 +16,8 @@ async function bmoniUserOf(accountId: string): Promise { export const bmoniProvider: PaymentProvider = { name: "bmoni", + // See PaymentProvider.canListInbound. BMONI has no such endpoint. + canListInbound: false, async ensureWallet(accountId) { const acc = (await getAccount(accountId)) as { @@ -27,11 +29,16 @@ export const bmoniProvider: PaymentProvider = { const { bmoniUserId } = await provisionBmoniWallet(accountId, `aide-${accountId}`, acc); // The NUBAN a worker actually gives out. Read separately: the wallet is an // address, the deposit account is the bank-facing side of it. - const deposit = (await getNgnDepositAccount(bmoniUserId)) as { - accountNumber?: string; - bankName?: string; - } | null; - return { accountNumber: deposit?.accountNumber, bankName: deposit?.bankName }; + const deposit = await getNgnDepositAccount(bmoniUserId); + return { accountNumber: deposit.accountNumber, bankName: deposit.bankName }; + }, + + // 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 { diff --git a/lib/banking/monnify-provider.ts b/lib/banking/monnify-provider.ts index 5709873..371a2ad 100644 --- a/lib/banking/monnify-provider.ts +++ b/lib/banking/monnify-provider.ts @@ -1,7 +1,9 @@ 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 { toKobo, toNaira } from "../money"; +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 @@ -18,6 +20,7 @@ const FAILED = new Set(["FAILED", "REVERSED", "EXPIRED"]); export const monnifyProvider: PaymentProvider = { name: "monnify", + canListInbound: true, async ensureWallet(accountId) { const w = await ensureMonnifyWallet(accountId); @@ -38,6 +41,18 @@ export const monnifyProvider: PaymentProvider = { })); }, + // 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); + }, + async verifyDestination(accountNumber, bankCode): Promise { const r = await validateBankAccount(accountNumber, bankCode); return { accountNumber: r.accountNumber, accountName: r.accountName, bankCode }; diff --git a/lib/banking/provider.ts b/lib/banking/provider.ts index 89a217e..92c7420 100644 --- a/lib/banking/provider.ts +++ b/lib/banking/provider.ts @@ -39,9 +39,32 @@ export interface PaymentProvider { // Make sure the account can receive money, and return where it lands. ensureWallet(accountId: string): Promise<{ accountNumber?: string; bankName?: string }>; + // Whether this provider can itemise money in at all. + // + // False is NOT "no payments". BMONI publishes a wallet balance but no + // wallet-level inbound history, so listInbound has nothing truthful to + // return — and an empty array would be rendered as "no payments received + // yet", which is a claim about the worker's employer that Aide cannot make. + // Callers must branch on this rather than on an empty list. + readonly canListInbound: boolean; + // Confirmed money IN, in kobo. The credit side of the balance. + // Only meaningful when canListInbound is true. 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; + // Name enquiry. Throws if the account does not resolve — never guesses a name. verifyDestination(accountNumber: string, bankCode: string): Promise; @@ -56,11 +79,14 @@ export interface PaymentProvider { }): Promise; } -// Which provider is live. Monnify unless BMONI is explicitly selected, so an -// unset or misspelled value falls back to the one that has been running rather -// than to the new one. +// 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() === "bmoni" ? "bmoni" : "monnify"; + return process.env.AIDE_PAYMENT_PROVIDER?.trim().toLowerCase() === "monnify" ? "monnify" : "bmoni"; } // Monnify reports naira as a float; everything above this line is kobo. diff --git a/lib/store/payments.ts b/lib/store/payments.ts index b78d7fd..afc69c7 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -1,15 +1,19 @@ import { createHash } from "node:crypto"; -import { createReservedAccount, getReservedAccount, getReservedAccountTransactions, validateBankAccount } from "../monnify"; +import { createReservedAccount, getReservedAccount, validateBankAccount } from "../monnify"; +import { paymentProvider, selectedProvider } from "../banking"; 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; @@ -124,6 +128,23 @@ export function ensureWallet(accountId: string): Promise { const p = (async () => { const wallet = await getWallet(accountId); if (wallet.status === "active") 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 } = 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 }); + return { ...wallet, status: "active" as const, accountNumber, bankName, lastError: undefined }; + } + const acc = await getAccount(accountId); const ref = wallet.accountReference; let reserved; @@ -212,9 +233,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) { diff --git a/lib/store/state.ts b/lib/store/state.ts index a1b2474..6862bdd 100644 --- a/lib/store/state.ts +++ b/lib/store/state.ts @@ -169,7 +169,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/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-balance-path.test.ts b/tests/money/bmoni-balance-path.test.ts new file mode 100644 index 0000000..d15bd91 --- /dev/null +++ b/tests/money/bmoni-balance-path.test.ts @@ -0,0 +1,115 @@ +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", + 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/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/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 index 345fb17..7bb931a 100644 --- a/tests/money/provider-seam.test.ts +++ b/tests/money/provider-seam.test.ts @@ -1,12 +1,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { selectedProvider } from "../../lib/banking/provider"; -// The seam's only job at this stage is to be reversible, and reversibility -// rests on one thing: the default has to be the provider that already works. +// 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. // -// A misspelled or half-set env var must not silently move a worker's wages -// onto a sandbox integration. So selection is explicit-opt-in — anything that -// is not exactly "bmoni" is Monnify. +// 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(() => { @@ -14,22 +15,23 @@ describe("which provider is live", () => { delete process.env.AIDE_PAYMENT_PROVIDER; }); - it("is Monnify when nothing is set", () => { - expect(selectedProvider()).toBe("monnify"); + it("is BMONI when nothing is set", () => { + expect(selectedProvider()).toBe("bmoni"); }); - it("is BMONI only when explicitly asked for", () => { - process.env.AIDE_PAYMENT_PROVIDER = "bmoni"; - expect(selectedProvider()).toBe("bmoni"); - process.env.AIDE_PAYMENT_PROVIDER = " BMONI "; - 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 Monnify on anything unrecognised", () => { - // A typo must land on the provider that has been running, not the new one. - for (const v of ["bmon", "bmoni-dev", "BMONEY", "true", "1", ""]) { + 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("monnify"); + expect(selectedProvider()).toBe("bmoni"); } }); }); 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(); From 9ee024adeb44f28b903099bc4954d557571fffee Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 02:24:52 +0000 Subject: [PATCH 09/17] =?UTF-8?q?feat(bmoni):=20real=20inbound=20history?= =?UTF-8?q?=20=E2=80=94=20the=20endpoint=20existed=20after=20all?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payments page was showing "I can see your balance, but I cannot list the individual payments behind it yet", on my claim that BMONI publishes no wallet-level history. That was wrong. The SDK documents an EmbeddedWalletTransactionsResponse, which meant something had to serve it, and it does: GET /v1/users/{userId}/smart-wallets/{smartWalletId}/transactions → { transactions, page, perPage, total, pageCount, hasNextPage, hasPreviousPage } So listInbound is real for BMONI, canListInbound is deleted along with the message, and an empty history now honestly means no money has arrived. The envelope is verified against the sandbox. The ITEM field names are not — no wallet on the shared sandbox has a transaction yet, so they come from the SDK's EmbeddedWalletTransaction model. That is why the parser throws on a row it cannot read rather than skipping it: a skipped row is a payment the worker is never told about, and they have no screen on which to notice the list came back short. If the names are wrong the page says it could not list the payments, which is recoverable; a quietly short list is not. Only completed incoming credits count. Pending has not arrived and reversed has left again, and Aide announces money as having landed. --- app/api/payments/transactions/route.ts | 19 +----- lib/banking/bmoni-provider.ts | 26 ++++---- lib/banking/bmoni-shapes.ts | 40 ++++++++++++ lib/banking/bmoni.ts | 10 +++ lib/banking/monnify-provider.ts | 1 - lib/banking/provider.ts | 15 ++--- tests/money/bmoni-transactions.test.ts | 87 ++++++++++++++++++++++++++ 7 files changed, 154 insertions(+), 44 deletions(-) create mode 100644 tests/money/bmoni-transactions.test.ts diff --git a/app/api/payments/transactions/route.ts b/app/api/payments/transactions/route.ts index 6f90fe2..c497fdc 100644 --- a/app/api/payments/transactions/route.ts +++ b/app/api/payments/transactions/route.ts @@ -23,25 +23,8 @@ export async function GET(req: Request) { const outbound = await getWithdrawals(acc.id).catch(() => []); - const provider = paymentProvider(); - - // Not a failure, and not "no payments". BMONI publishes a wallet balance but - // no wallet-level list of the credits behind it, so there is nothing - // truthful to itemise. Returning [] would render as "No payments received - // yet" — a claim that nobody has paid this worker, which is exactly the - // thing they cannot check for themselves. - if (!provider.canListInbound) { - return Response.json({ - inbound: null, - inboundUnavailable: - "I can see your balance, but I cannot list the individual payments behind it yet. " + - "Your balance above is the confirmed total.", - outbound, - }); - } - try { - const inbound = (await provider.listInbound(acc.id)).map((c) => ({ + const inbound = (await paymentProvider().listInbound(acc.id)).map((c) => ({ amount: toNaira(c.amountKobo), status: "PAID", from: c.from ?? "Bank transfer", diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index 31046c7..4dba991 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -2,7 +2,7 @@ import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { getAccount } from "../store/accounts"; import { decimalStringToKobo } from "./amounts"; -import { getNgnBalanceKobo, getNgnDepositAccount, listBalances, verifyNigerianAccount } from "./bmoni"; +import { getNgnBalanceKobo, getNgnDepositAccount, listWalletTransactions, verifyNigerianAccount } from "./bmoni"; import { payOutToBank, provisionBmoniWallet } from "./bmoni-wallet"; import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; @@ -16,8 +16,6 @@ async function bmoniUserOf(accountId: string): Promise { export const bmoniProvider: PaymentProvider = { name: "bmoni", - // See PaymentProvider.canListInbound. BMONI has no such endpoint. - canListInbound: false, async ensureWallet(accountId) { const acc = (await getAccount(accountId)) as { @@ -42,18 +40,16 @@ export const bmoniProvider: PaymentProvider = { }, async listInbound(accountId): Promise { - // KNOWN GAP, and the biggest one in this integration. BMONI's intro page - // claims transaction history, but the reference documents it only for - // cards — there is no wallet-level inbound history endpoint. Balances give - // a total, not the individual credits Aide announces ("₦5,000 just landed - // from Adebayo"). - // - // Returning [] rather than fabricating a credit from the balance delta is - // the honest answer: a synthesised credit would be Aide announcing a - // payment it cannot actually see. Until the endpoint exists or webhooks are - // wired, inbound announcements are a Monnify-only feature. - void (await listBalances(await bmoniUserOf(accountId))); - return []; + 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 verifyDestination(accountNumber, bankCode): Promise { diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts index 73d2ba9..7a7b952 100644 --- a/lib/banking/bmoni-shapes.ts +++ b/lib/banking/bmoni-shapes.ts @@ -124,3 +124,43 @@ export function parseBanks(body: unknown): Array<{ name: string; code: string }> 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; +} diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index a5b3ca6..50b7d8e 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -1,6 +1,7 @@ import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; import { koboToDecimalString } from "./amounts"; import { parseCards } from "./bmoni-cards"; +import { parseWalletTransactions } from "./bmoni-shapes"; import { parseBanks, parseCreatedUser, parseCreatedWallet, parseNgnBalanceKobo, parseNgnDepositAccount } from "./bmoni-shapes"; // The BMONI Embedded operations Aide uses, in lifecycle order: @@ -273,3 +274,12 @@ export { BmoniError }; 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/monnify-provider.ts b/lib/banking/monnify-provider.ts index 371a2ad..91d29e1 100644 --- a/lib/banking/monnify-provider.ts +++ b/lib/banking/monnify-provider.ts @@ -20,7 +20,6 @@ const FAILED = new Set(["FAILED", "REVERSED", "EXPIRED"]); export const monnifyProvider: PaymentProvider = { name: "monnify", - canListInbound: true, async ensureWallet(accountId) { const w = await ensureMonnifyWallet(accountId); diff --git a/lib/banking/provider.ts b/lib/banking/provider.ts index 92c7420..0f7c5d7 100644 --- a/lib/banking/provider.ts +++ b/lib/banking/provider.ts @@ -39,17 +39,12 @@ export interface PaymentProvider { // Make sure the account can receive money, and return where it lands. ensureWallet(accountId: string): Promise<{ accountNumber?: string; bankName?: string }>; - // Whether this provider can itemise money in at all. - // - // False is NOT "no payments". BMONI publishes a wallet balance but no - // wallet-level inbound history, so listInbound has nothing truthful to - // return — and an empty array would be rendered as "no payments received - // yet", which is a claim about the worker's employer that Aide cannot make. - // Callers must branch on this rather than on an empty list. - readonly canListInbound: boolean; - // Confirmed money IN, in kobo. The credit side of the balance. - // Only meaningful when canListInbound is true. + // + // 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. 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/); + }); +}); From 9687baec31294ec53b003d71ea581379c042d128 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 02:35:57 +0000 Subject: [PATCH 10/17] fix(profile): switch demo identities through the server, not document.cookie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The switch button did nothing at all. It wrote document.cookie = "aide-user=demo-employer" and reloaded, but aide-user is HMAC-signed and HttpOnly, so a hand-written value fails verification and the request stays whoever it already was. tests/unit/session.test.ts already covers this exact case under "refuses a hand-written cookie naming an account" — the cookie was doing its job, the button was asking it not to. POST /api/account/switch already existed and issues a correctly signed cookie. The buttons call it now. The reload also only happens if the switch succeeded, and a failure is shown. Reloading unconditionally is what made a broken switch look exactly like a working one: the page came back, the name had not changed, and there was nothing to tell you which of those two things had happened. --- app/profile/page.tsx | 48 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/app/profile/page.tsx b/app/profile/page.tsx index bfdd429..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} +

+ )}
)} From 2285deeec98f3636e0f8691dafd089f281436768 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 02:48:00 +0000 Subject: [PATCH 11/17] feat(bmoni): give the employer a BMONI wallet, and seed both sandbox personas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The employer's payments page was still failing outright — no BMONI wallet existed for demo-employer, so the balance came back unavailable and the card panel said no-wallet. ClearVoice Media is Samson Jabo, the second sandbox persona, and now has a real virtual account: 3463455722 at PROVIDUS BANK, provisioned through the same lifecycle as the worker. The identities are seeded rather than hand-patched. Both demo accounts now carry the persona's first/last name, E.164 phone and BVN, so a fresh Convex deployment provisions itself instead of needing someone to know which UUIDs to paste in. seedDefaults backfills these onto accounts that predate the fields, and never overwrites a value that is already there — a real user who filled these in keeps what they entered. The BVN is now stripped by publicAccount, alongside the password hash. Adding it to Account had quietly put it in every /api/account response. It is not a profile field: 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. tests/unit/public-account fails if a sensitive field is added to Account without that decision being made again. --- convex/accounts.ts | 21 +++++++++++- convex/schema.ts | 5 +++ lib/store/accounts.ts | 17 ++++++++-- lib/store/seed.ts | 30 +++++++++++++++++ lib/store/state.ts | 9 +++++ tests/unit/public-account.test.ts | 55 +++++++++++++++++++++++++++++++ 6 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 tests/unit/public-account.test.ts diff --git a/convex/accounts.ts b/convex/accounts.ts index f605c81..992f799 100644 --- a/convex/accounts.ts +++ b/convex/accounts.ts @@ -80,13 +80,32 @@ 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 = {}; + for (const k of ["firstName", "lastName", "phoneNumber", "bvn"] as const) { + if (a[k] && !existing[k]) patch[k] = a[k]; + } + if (Object.keys(patch).length) await ctx.db.patch(existing._id, patch); } }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 9f62fd0..2b54db5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -46,6 +46,11 @@ export default defineSchema({ 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"]), 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/seed.ts b/lib/store/seed.ts index 2e997e8..a72c8ed 100644 --- a/lib/store/seed.ts +++ b/lib/store/seed.ts @@ -2,6 +2,34 @@ import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { worker } from "./state"; +// 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. +const PERSONAS = { + worker: { + firstName: "Bunch", + lastName: "Dillon", + phoneNumber: "+2348000000000", + bvn: "95888168924", + }, + employer: { + firstName: "Samson", + lastName: "Jabo", + phoneNumber: "+2348000000001", + 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 // signed-out visitor falls back to would not exist and the account switcher @@ -24,6 +52,7 @@ export function ensureSeeded(): Promise { skills: [...worker.skills], bio: worker.bio, createdAt: Date.now(), + ...PERSONAS.worker, }, { key: "demo-employer", @@ -32,6 +61,7 @@ export function ensureSeeded(): Promise { skills: [], bio: "", createdAt: Date.now(), + ...PERSONAS.employer, }, ], }) diff --git a/lib/store/state.ts b/lib/store/state.ts index 6862bdd..97edabd 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 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"); + }); +}); From 3b50350c3f1121d837027eeabadf4854cdcdfdef Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 03:00:14 +0000 Subject: [PATCH 12/17] fix(payments): show the name the bank holds, not the Aide profile name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Account name" row rendered `summary.name` — the Aide profile. BMONI holds a different name for the same account: "Jabo Samson Joe" against a profile reading "ClearVoice Media", and "Dillon Bunch" against "Bunch Dillon". Whoever pays in sees the bank's name during their own name enquiry. Being told the account belongs to ClearVoice Media and then shown Jabo Samson Joe reads exactly like a mistyped account number, and abandoning the transfer is the sensible response — so the wages never arrive, for a reason nobody involved can diagnose. The worker cannot check the screen either; Aide would simply be stating a name no bank agrees with. accountName now comes from the deposit-account response, is stored on the wallet, and is what the page and the summary route present as the account holder. The parser throws if BMONI omits it rather than falling back to the profile name, because falling back is the bug. Optional on setProvisioned so the Monnify path, which has no separate bank-held name, is unchanged. --- app/api/payments/summary/route.ts | 5 +++++ app/payments/page.tsx | 9 ++++++++- convex/schema.ts | 6 ++++++ convex/wallets.ts | 18 ++++++++++++++++-- lib/banking/bmoni-provider.ts | 2 +- lib/banking/bmoni-shapes.ts | 13 +++++++++++-- lib/banking/bmoni.ts | 2 +- lib/banking/provider.ts | 6 +++++- lib/store/payments.ts | 8 +++++--- lib/store/state.ts | 2 ++ tests/money/bmoni-shapes.test.ts | 17 ++++++++++++++++- 11 files changed, 76 insertions(+), 12 deletions(-) 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/payments/page.tsx b/app/payments/page.tsx index f52c70f..fba0c5a 100644 --- a/app/payments/page.tsx +++ b/app/payments/page.tsx @@ -36,6 +36,13 @@ 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; @@ -398,7 +405,7 @@ export default function PaymentsPage() {

Receive money

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

- +
diff --git a/convex/schema.ts b/convex/schema.ts index 2b54db5..75fbcef 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -61,6 +61,12 @@ export default defineSchema({ status: v.union(v.literal("unprovisioned"), v.literal("active"), v.literal("failed")), 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()), diff --git a/convex/wallets.ts b/convex/wallets.ts index ac37d7e..40d25dd 100644 --- a/convex/wallets.ts +++ b/convex/wallets.ts @@ -63,10 +63,24 @@ 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()), + }, 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, + 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 }); }, diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index 4dba991..970c52e 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -28,7 +28,7 @@ export const bmoniProvider: PaymentProvider = { // The NUBAN a worker actually gives out. Read separately: the wallet is an // address, the deposit account is the bank-facing side of it. const deposit = await getNgnDepositAccount(bmoniUserId); - return { accountNumber: deposit.accountNumber, bankName: deposit.bankName }; + return { accountNumber: deposit.accountNumber, bankName: deposit.bankName, accountName: deposit.accountName }; }, // BMONI holds the wallet, so the balance is simply what it reports. Nothing diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts index 7a7b952..f9d2d05 100644 --- a/lib/banking/bmoni-shapes.ts +++ b/lib/banking/bmoni-shapes.ts @@ -83,7 +83,7 @@ export function parseNgnBalanceKobo(body: unknown): number { const POOLED_ID = /^pooled-/i; const POOLED_NAME = /bkey/i; -export function parseNgnDepositAccount(body: unknown): { accountNumber: string; bankName: string } { +export function parseNgnDepositAccount(body: unknown): { 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"); @@ -111,7 +111,16 @@ export function parseNgnDepositAccount(body: unknown): { accountNumber: string; ); } const a = own[0]; - return { accountNumber: str(a.accountNumber, "accounts[].accountNumber"), bankName: str(a.bankName, "accounts[].bankName") }; + return { + 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 ------------------------------------- diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index 50b7d8e..fa6c796 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -147,7 +147,7 @@ export async function pointDepositsAtWallet( } // The account number a worker actually gives out to be paid into. -export async function getNgnDepositAccount(userId: string): Promise<{ accountNumber: string; bankName: string }> { +export async function getNgnDepositAccount(userId: string): Promise<{ accountNumber: string; bankName: string; accountName: string }> { return parseNgnDepositAccount(await bmoniRead({ path: `/v1/users/${userId}/bank-accounts/deposit-accounts/NGN` })); } diff --git a/lib/banking/provider.ts b/lib/banking/provider.ts index 0f7c5d7..68ed1ce 100644 --- a/lib/banking/provider.ts +++ b/lib/banking/provider.ts @@ -37,7 +37,11 @@ export interface PaymentProvider { readonly name: ProviderName; // Make sure the account can receive money, and return where it lands. - ensureWallet(accountId: string): Promise<{ accountNumber?: string; bankName?: string }>; + // + // `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. // diff --git a/lib/store/payments.ts b/lib/store/payments.ts index afc69c7..e9ee268 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -51,6 +51,7 @@ type WalletDoc = { status: "unprovisioned" | "active" | "failed"; accountNumber?: string; bankName?: string; + accountName?: string; lastError?: string; payoutAccount?: string; payoutBankCode?: string; @@ -90,6 +91,7 @@ function toWallet(d: WalletDoc): Wallet { status: d.status, accountNumber: d.accountNumber, bankName: d.bankName, + accountName: d.accountName, lastError: d.lastError, payoutAccount: d.payoutAccount, payoutBankCode: d.payoutBankCode, @@ -135,14 +137,14 @@ export function ensureWallet(accountId: string): Promise { // adapter calls back into this very function. if (selectedProvider() !== "monnify") { const ref = wallet.accountReference; - const { accountNumber, bankName } = await paymentProvider().ensureWallet(accountId); + 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 }); - return { ...wallet, status: "active" as const, accountNumber, bankName, lastError: undefined }; + await convexClient().mutation(api.wallets.setProvisioned, { accountId, accountReference: ref, accountNumber, bankName, accountName }); + return { ...wallet, status: "active" as const, accountNumber, bankName, accountName, lastError: undefined }; } const acc = await getAccount(accountId); diff --git a/lib/store/state.ts b/lib/store/state.ts index 97edabd..2afbe5c 100644 --- a/lib/store/state.ts +++ b/lib/store/state.ts @@ -124,6 +124,8 @@ export type Wallet = { status: "unprovisioned" | "active" | "failed"; 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; diff --git a/tests/money/bmoni-shapes.test.ts b/tests/money/bmoni-shapes.test.ts index 19dc898..24af94e 100644 --- a/tests/money/bmoni-shapes.test.ts +++ b/tests/money/bmoni-shapes.test.ts @@ -150,9 +150,20 @@ describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/NGN", () => { expect(parseNgnDepositAccount({ accounts: [own, ...pooled.accounts] })).toEqual({ 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"); @@ -180,7 +191,11 @@ describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/NGN", () => { }, ], }; - expect(parseNgnDepositAccount(own)).toEqual({ accountNumber: "8012345678", bankName: "9 Payment Service Bank" }); + expect(parseNgnDepositAccount(own)).toEqual({ + accountNumber: "8012345678", + bankName: "9 Payment Service Bank", + accountName: "Aide Demo Worker", + }); }); it("throws when there is no account at all", () => { From 613099cbbc4cd85f6c3efdc54cf72c6fef29f9b3 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 03:18:40 +0000 Subject: [PATCH 13/17] fix(bmoni): bank codes, name enquiry, payouts and arrivals all through the seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bank dropdown hardcoded thirteen NIP codes. Those are Monnify's vocabulary. Verified against the sandbox: bankCode 035 (Wema, NIP) returns "We could not verify this account", while 000017 (Wema, NIBSS) resolves. The failure names the ACCOUNT NUMBER, so the user retries a different account instead of a different bank, and the withdrawal never works. The list is now fetched from the provider — GET /api/payments/banks, 302 banks with the codes that rail actually accepts, and no hardcoded fallback, because a fallback of wrong codes is the bug. Nothing is pre-selected any more. With thirteen banks a default of GTBank was harmless; with 302 the default landed on AGOSASA MICROFINANCE BANK, a bank nobody chose, sitting under an account number someone had typed. On a screen you would catch that. Name enquiry also no longer runs until a bank is chosen, so it cannot verify against a default. Another silent shape mismatch: verify-nigerian-account returns the holder as `accountName`, not `accountHolderName`. Name enquiry was returning undefined — the one check a blind user has before approving where their wages go. Also moved onto the seam: the payout itself (lib/payments.ts still called Monnify's singleTransfer directly, so every withdrawal went to the wrong rail), destination resolution, beneficiary saving, the validate-account route, and the agent's save_beneficiary. Added a list_banks tool so the model looks codes up instead of recalling them. lib/store/events.ts is the one that would have hurt most. It is the poller that lets Aide say "₦5,000 just landed", and it was polling Monnify's reserved-account transactions — an endpoint with nothing in it under BMONI. Wages would have arrived in silence, which is the one thing a blind user cannot tell apart from a crash. An unknown payout outcome now maps to pending rather than success, so the "has it arrived" claim stays false unless the provider said completed. The Monnify webhook route is marked inert under BMONI so a quiet log there is not mistaken for no money arriving. And the create-user conflict path can recover now: GET /v1/users does exist. It matches on exact email AND phone and refuses unless exactly one row matches — the sandbox list is shared with other teams, and a loose match would bind a worker's wages to a stranger's wallet. --- app/api/payments/banks/route.ts | 34 +++++++++++++ app/api/payments/beneficiaries/route.ts | 4 +- app/api/payments/validate-account/route.ts | 11 +++- app/api/payments/webhook/route.ts | 6 +++ app/payments/page.tsx | 59 +++++++++++++++------- lib/agent/tools.ts | 31 ++++++++++-- lib/banking/bmoni-provider.ts | 29 +++++++---- lib/banking/bmoni-shapes.ts | 21 ++++++++ lib/banking/bmoni-wallet.ts | 27 +++++++--- lib/banking/bmoni.ts | 41 ++++++++++----- lib/banking/monnify-provider.ts | 27 +++++++++- lib/banking/provider.ts | 16 +++++- lib/payments.ts | 50 +++++++++++++----- lib/store/events.ts | 37 ++++++++++---- lib/store/payments.ts | 15 +++++- tests/money/bmoni-shapes.test.ts | 30 +++++++++++ 16 files changed, 350 insertions(+), 88 deletions(-) create mode 100644 app/api/payments/banks/route.ts 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/validate-account/route.ts b/app/api/payments/validate-account/route.ts index 7206449..024b358 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,7 +22,9 @@ 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()); + // 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()); return Response.json({ ok: true, accountName: r.accountName, accountNumber: r.accountNumber }); } 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 fba0c5a..148cf01 100644 --- a/app/payments/page.tsx +++ b/app/payments/page.tsx @@ -49,21 +49,7 @@ type Summary = { 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"); @@ -101,7 +87,14 @@ 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); @@ -130,7 +123,7 @@ export default function PaymentsPage() { setError(null); try { // Summary is primary; history and beneficiaries are secondary and never fail the page. - const [res, h, b, c] = 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), @@ -139,6 +132,9 @@ export default function PaymentsPage() { 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."); @@ -150,6 +146,18 @@ export default function PaymentsPage() { 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 { @@ -166,7 +174,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; } @@ -498,14 +508,25 @@ 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 */}

diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts index 47d2220..03d9138 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." }; } @@ -472,7 +493,7 @@ export function makeTools(account: Account) { 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/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index 970c52e..08fa056 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -2,7 +2,13 @@ import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; import { getAccount } from "../store/accounts"; import { decimalStringToKobo } from "./amounts"; -import { getNgnBalanceKobo, getNgnDepositAccount, listWalletTransactions, verifyNigerianAccount } from "./bmoni"; +import { + getNgnBalanceKobo, + getNgnDepositAccount, + listNigerianBanks, + listWalletTransactions, + verifyNigerianAccount, +} from "./bmoni"; import { payOutToBank, provisionBmoniWallet } from "./bmoni-wallet"; import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } from "./provider"; @@ -52,16 +58,17 @@ export const bmoniProvider: PaymentProvider = { return await listWalletTransactions(w.bmoniUserId, w.bmoniSmartWalletId); }, - async verifyDestination(accountNumber, bankCode): Promise { - // Name enquiry needs a user context in BMONI, unlike Monnify's global one. - const anyUser = process.env.BMONI_VERIFY_AS_USER_ID?.trim(); - if (!anyUser) { - throw new Error( - "BMONI name enquiry is per-user; set BMONI_VERIFY_AS_USER_ID or verify through the paying account.", - ); - } - const r = await verifyNigerianAccount(anyUser, accountNumber, bankCode); - return { accountNumber, bankCode, accountName: r.accountHolderName }; + 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 }; }, async payOut(args): Promise { diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts index f9d2d05..2e3c555 100644 --- a/lib/banking/bmoni-shapes.ts +++ b/lib/banking/bmoni-shapes.ts @@ -173,3 +173,24 @@ export function parseWalletTransactions(body: unknown): WalletCredit[] { } 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 index 8e39eb8..4aae02c 100644 --- a/lib/banking/bmoni-wallet.ts +++ b/lib/banking/bmoni-wallet.ts @@ -6,6 +6,7 @@ import { readProposalOutcome, type ProposalOutcome } from "./proposal-status"; import { approveProposal, createBmoniUser, + listBmoniUsers, createNgnOfframp, createSmartWallet, getProposal, @@ -101,13 +102,27 @@ export async function provisionBmoniWallet( bmoniUserId = (await createBmoniUser(identity)).bmoniUserId; } catch (e) { if (e instanceof BmoniError && e.isConflict) { - throw new Error( - `BMONI already holds a user for this email or phone (${e.message}), but we have no stored ` + - `bmoniUserId for ${accountId} and BMONI publishes no lookup endpoint. This needs manual ` + - `recovery — do not retry, it will keep colliding.`, + // 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; } - throw e; } await convexClient().mutation(api.wallets.setBmoniUser, { accountId, accountReference, bmoniUserId }); } @@ -237,7 +252,7 @@ export async function payOutToBank(args: { accountNumber: args.accountNumber, bankCode: args.bankCode, bankName: args.bankName, - accountHolderName: verified.accountHolderName, + accountHolderName: verified.accountName, }); bankAccountId = registered.id; await convexClient().mutation(api.wallets.rememberBmoniBankAccount, { diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index fa6c796..037e80e 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -1,7 +1,7 @@ import { bmoniMove, bmoniRead, BmoniError } from "./bmoni-client"; import { koboToDecimalString } from "./amounts"; import { parseCards } from "./bmoni-cards"; -import { parseWalletTransactions } from "./bmoni-shapes"; +import { parseVerifiedAccount, parseWalletTransactions } from "./bmoni-shapes"; import { parseBanks, parseCreatedUser, parseCreatedWallet, parseNgnBalanceKobo, parseNgnDepositAccount } from "./bmoni-shapes"; // The BMONI Embedded operations Aide uses, in lifecycle order: @@ -25,12 +25,15 @@ export type Proposal = { proposalId: string; status: string }; export type NewUser = { firstName: string; lastName: string; email: string; phoneNumber: string }; -// A 409 means a previous attempt already created this user. BMONI documents -// recovering with `findUserByEmail`, but publishes no such endpoint — there is -// no way to ask "which user is that?" after the fact. So the bmoniUserId must -// be persisted by US at creation, and a 409 is only survivable if we already -// hold it. The caller is responsible for that; this throws with the conflict -// flagged so it can look in its own store rather than creating a second user. +// 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 })); } @@ -163,12 +166,14 @@ export async function verifyNigerianAccount( userId: string, accountNumber: string, bankCode: string, -): Promise<{ accountHolderName: string }> { - return await bmoniRead({ - path: `/v1/users/${userId}/bank-accounts/verify-nigerian-account`, - method: "POST", - body: { accountNumber, bankCode }, - }); +): 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 @@ -271,6 +276,16 @@ export { BmoniError }; // 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` })); } diff --git a/lib/banking/monnify-provider.ts b/lib/banking/monnify-provider.ts index 91d29e1..eabadda 100644 --- a/lib/banking/monnify-provider.ts +++ b/lib/banking/monnify-provider.ts @@ -18,6 +18,25 @@ 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", @@ -52,7 +71,13 @@ export const monnifyProvider: PaymentProvider = { return Math.max(0, sumKobo(inbound.map((c) => c.amountKobo)) - withdrawnKobo); }, - async verifyDestination(accountNumber, bankCode): Promise { + // 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); return { accountNumber: r.accountNumber, accountName: r.accountName, bankCode }; }, diff --git a/lib/banking/provider.ts b/lib/banking/provider.ts index 68ed1ce..12cb754 100644 --- a/lib/banking/provider.ts +++ b/lib/banking/provider.ts @@ -64,8 +64,20 @@ export interface PaymentProvider { // exactly like a true one when read aloud. getBalanceKobo(accountId: string): Promise; - // Name enquiry. Throws if the account does not resolve — never guesses a name. - verifyDestination(accountNumber: string, bankCode: 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: { 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/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 e9ee268..d5e8d45 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -1,5 +1,5 @@ import { createHash } from "node:crypto"; -import { createReservedAccount, getReservedAccount, validateBankAccount } from "../monnify"; +import { createReservedAccount, getReservedAccount } from "../monnify"; import { paymentProvider, selectedProvider } from "../banking"; import { api } from "../../convex/_generated/api"; import { convexClient } from "../convex-server"; @@ -209,6 +209,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 @@ -357,7 +364,11 @@ async function resolveDestination( ): Promise<{ ok: true; account: string; bankCode: string; accountName: string; addedAt?: number } | { ok: false; message: string }> { if (dest?.accountNumber && dest?.bankCode) { try { - const r = await validateBankAccount(dest.accountNumber.trim(), dest.bankCode.trim()); + // 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, addedAt: Date.now() }; } catch { return { ok: false, message: "Bank details not found — check the account number and bank, then try again." }; diff --git a/tests/money/bmoni-shapes.test.ts b/tests/money/bmoni-shapes.test.ts index 24af94e..80ee4b1 100644 --- a/tests/money/bmoni-shapes.test.ts +++ b/tests/money/bmoni-shapes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseBanks, + parseVerifiedAccount, parseCreatedUser, parseCreatedWallet, parseNgnBalanceKobo, @@ -222,3 +223,32 @@ describe("GET /v1/users/{id}/bank-accounts/nigerian-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/); + }); +}); From 59db805ca5803262808f6fefe18b3d7bd8fddb56 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 03:26:28 +0000 Subject: [PATCH 14/17] fix(bmoni): stop presenting a fabricated account holder as verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 lookups agree and it reads exactly like a real answer. Measured against embedded-dev.bmoni.com: 4534076021 against six different banks -> "Dillon Bunch" every time 0123456789 / GTBANK -> "Ekon Orji" 1111111111 / GTBANK -> "Alika Alabi" 0000000000 / GTBANK -> "Amarachi Nwosu" Aide read that name aloud as the confirmation before moving money. Against a fabricator that is worse than having no check at all: a mistyped account number comes back as a confident "You are sending 20,000 naira to Ekon Orji", and the person listening cannot see the digits to know better. The green "✓ Account found" said the same thing on screen. VerifiedAccount now carries nameVerified, false whenever the BMONI base URL is not the production host — and false when the host is unknown, because "cannot tell" must not resolve to "verified". Monnify stays true: its sandbox rejects accounts that do not exist. When the name is not verified it is never shown and never spoken. The confirmation reads the digits back one at a time instead — "to account 3 4 6 3 4 5 5 7 2 2" — because those are the thing the user supplied and can actually check. The line is built server-side so the screen and the spoken path cannot drift apart on the one sentence that matters. Saved beneficiaries captured from a fabricating endpoint are marked unverified too; sitting in our database for a week does not make a name true. save_beneficiary refuses to store one rather than launder it into something that later looks user-confirmed. Also corrects an earlier misreading of mine: bankCode 035 failing was an invalid code being rejected, not evidence that account lookup was real. --- app/api/payments/validate-account/route.ts | 10 ++- app/payments/page.tsx | 52 ++++++++++-- lib/agent/tools.ts | 15 +++- lib/banking/bmoni-provider.ts | 11 ++- lib/banking/monnify-provider.ts | 4 +- lib/banking/name-enquiry.ts | 54 ++++++++++++ lib/banking/provider.ts | 15 +++- lib/store/payments.ts | 70 ++++++++++++++-- tests/money/name-enquiry-trust.test.ts | 96 ++++++++++++++++++++++ 9 files changed, 306 insertions(+), 21 deletions(-) create mode 100644 lib/banking/name-enquiry.ts create mode 100644 tests/money/name-enquiry-trust.test.ts diff --git a/app/api/payments/validate-account/route.ts b/app/api/payments/validate-account/route.ts index 024b358..b774fe0 100644 --- a/app/api/payments/validate-account/route.ts +++ b/app/api/payments/validate-account/route.ts @@ -25,7 +25,15 @@ export async function POST(req: Request) { // 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()); - return Response.json({ ok: true, accountName: r.accountName, accountNumber: r.accountNumber }); + // 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/payments/page.tsx b/app/payments/page.tsx index 148cf01..46f6ee6 100644 --- a/app/payments/page.tsx +++ b/app/payments/page.tsx @@ -59,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() { @@ -100,7 +103,17 @@ export default function PaymentsPage() { // 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); @@ -191,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." }); @@ -288,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); @@ -531,7 +556,18 @@ export default function PaymentsPage() { {/* 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. @@ -566,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" ? (

diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts index 03d9138..a59c336 100644 --- a/lib/agent/tools.ts +++ b/lib/agent/tools.ts @@ -477,7 +477,18 @@ export function makeTools(account: Account) { if (!name) { try { const { paymentProvider } = await import("../banking"); - name = (await paymentProvider().verifyDestination(account.id, accountNumber, bankCode)).accountName; + const v = await paymentProvider().verifyDestination(account.id, accountNumber, bankCode); + if (!v.nameVerified) { + // Saving a fabricated name would launder it: next time it is + // recalled from our own database and looks like something the + // user once confirmed. + return { + ok: false, + message: + "I could not confirm the account holder's name on this connection, so I will not save it under a name I cannot vouch for. Tell me the name to save it as and I will use that.", + }; + } + name = v.accountName; } catch { return { ok: false, message: "Bank details not found — check the account number and bank." }; } @@ -489,7 +500,7 @@ 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"), diff --git a/lib/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index 08fa056..4eed78a 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -10,6 +10,7 @@ import { 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. @@ -68,7 +69,15 @@ export const bmoniProvider: PaymentProvider = { // 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 }; + 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 { diff --git a/lib/banking/monnify-provider.ts b/lib/banking/monnify-provider.ts index eabadda..20fe840 100644 --- a/lib/banking/monnify-provider.ts +++ b/lib/banking/monnify-provider.ts @@ -79,7 +79,9 @@ export const monnifyProvider: PaymentProvider = { async verifyDestination(_accountId, accountNumber, bankCode): Promise { const r = await validateBankAccount(accountNumber, bankCode); - return { accountNumber: r.accountNumber, accountName: r.accountName, 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 { diff --git a/lib/banking/name-enquiry.ts b/lib/banking/name-enquiry.ts new file mode 100644 index 0000000..b8a14ea --- /dev/null +++ b/lib/banking/name-enquiry.ts @@ -0,0 +1,54 @@ +import type { ProviderName } from "./provider"; + +// Whether a name enquiry can be believed. +// +// BMONI's development host does not perform one. It returns a plausible +// Nigerian name for any ten-digit number against any bank code — deterministic +// per number, so two lookups agree and it reads exactly like a real answer. +// Measured 2026-09-04: account 4534076021 came back "Dillon Bunch" against six +// different banks, and 0123456789 came back "Ekon Orji" against a bank it has +// no relationship with. +// +// Aide reads the account holder's name back so the payer can stop before the +// money moves. Against a fabricator that check is worse than absent: it takes +// a mistyped account number and answers it with a confident name, and the +// person listening has no screen on which to notice the name means nothing. +// +// So the name is marked unverified, and the confirmation falls back to +// something the user can actually check: the digits they typed. + +// Distrust unless the host is recognisably production. A new hostname should +// arrive as "we cannot vouch for this", not as "verified". +const BMONI_PRODUCTION = /^https?:\/\/embedded\.bmoni\.com\/?$/i; + +export function isFabricatedNameEnquiry(provider: ProviderName, baseUrl: string | undefined): boolean { + // Monnify's sandbox does real name enquiry — it rejects accounts that do not + // exist — so this is a fact about BMONI's dev host, not about sandboxes. + 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 { + if (dest.nameVerified) return `to ${dest.accountName}, account ${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/provider.ts b/lib/banking/provider.ts index 12cb754..a84e9d6 100644 --- a/lib/banking/provider.ts +++ b/lib/banking/provider.ts @@ -19,8 +19,19 @@ import { toKobo } from "../money"; export type ProviderName = "monnify" | "bmoni"; -// A destination account, already name-enquiry verified. -export type VerifiedAccount = { accountNumber: string; accountName: string; bankCode: string }; +// 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 diff --git a/lib/store/payments.ts b/lib/store/payments.ts index d5e8d45..037cbb8 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; 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"; @@ -361,7 +362,10 @@ 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 { // Through the seam: the bank CODES differ per provider, so verifying a @@ -369,18 +373,37 @@ async function resolveDestination( // 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, addedAt: Date.now() }; + 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.` }; @@ -389,14 +412,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." }; } @@ -407,7 +444,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); @@ -466,6 +518,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/tests/money/name-enquiry-trust.test.ts b/tests/money/name-enquiry-trust.test.ts new file mode 100644 index 0000000..8fc8830 --- /dev/null +++ b/tests/money/name-enquiry-trust.test.ts @@ -0,0 +1,96 @@ +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". +// +// So a fabricated name must never be presented as confirmation, and what is +// read back instead has to be something the user can actually check — the +// digits they typed. + +describe("recognising an endpoint that cannot really verify a name", () => { + beforeEach(() => vi.unstubAllEnvs()); + + 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("defaults to distrust when the host is unknown", () => { + // An unset base URL means we cannot tell. Claiming a name is verified when + // we do not know is the failure this file exists to prevent. + 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 really verified", () => { + expect(destinationConfirmation({ accountName: "Jabo Samson Joe", accountNumber: "3463455722", nameVerified: true })) + .toBe("to Jabo Samson Joe, account 3463455722"); + }); + + 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"); + }); +}); From ff4f5c89e50585a083cc0b5e1b4238848fe9a4db Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 03:44:51 +0000 Subject: [PATCH 15/17] revert(bmoni): show the provider's account name as-is by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces whatever BMONI returns, so Aide behaves on the development host exactly as it will on production, rather than second-guessing the sandbox. "✓ Account found: NAME" is back. The measurement stands and is recorded where it will be found: the dev host does not perform name enquiry, and returns a plausible Nigerian name for any ten-digit number against any bank code, deterministic per number so repeated look-ups agree. Aide is therefore capable of speaking a fabricated account holder's name on this host. That costs nothing while the sandbox wallets hold no money and the personas are not real people. The guard is kept and still tested, behind BMONI_STRICT_NAME_ENQUIRY=true, so the day this points at a funded wallet it is one variable rather than a rewrite. Under it, an unverified name is never shown or spoken and the confirmation reads the account digits back instead. Kept regardless of the flag: the digits are spelled out one at a time even alongside a verified name. That is accessibility, not trust — a screen reader renders "3463455722" as "three billion, four hundred and sixty-three million…", which cannot be checked against the number someone meant to type. --- .env.example | 17 +++++++++ lib/agent/tools.ts | 13 +------ lib/banking/name-enquiry.ts | 50 +++++++++++++++++--------- tests/money/name-enquiry-trust.test.ts | 45 ++++++++++++++++++----- 4 files changed, 87 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index e93e89e..8de60e8 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,23 @@ SESSION_SECRET= # 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 diff --git a/lib/agent/tools.ts b/lib/agent/tools.ts index a59c336..001a00d 100644 --- a/lib/agent/tools.ts +++ b/lib/agent/tools.ts @@ -477,18 +477,7 @@ export function makeTools(account: Account) { if (!name) { try { const { paymentProvider } = await import("../banking"); - const v = await paymentProvider().verifyDestination(account.id, accountNumber, bankCode); - if (!v.nameVerified) { - // Saving a fabricated name would launder it: next time it is - // recalled from our own database and looks like something the - // user once confirmed. - return { - ok: false, - message: - "I could not confirm the account holder's name on this connection, so I will not save it under a name I cannot vouch for. Tell me the name to save it as and I will use that.", - }; - } - name = v.accountName; + name = (await paymentProvider().verifyDestination(account.id, accountNumber, bankCode)).accountName; } catch { return { ok: false, message: "Bank details not found — check the account number and bank." }; } diff --git a/lib/banking/name-enquiry.ts b/lib/banking/name-enquiry.ts index b8a14ea..ff386ae 100644 --- a/lib/banking/name-enquiry.ts +++ b/lib/banking/name-enquiry.ts @@ -2,28 +2,40 @@ import type { ProviderName } from "./provider"; // Whether a name enquiry can be believed. // -// BMONI's development host does not perform one. It returns a plausible -// Nigerian name for any ten-digit number against any bank code — deterministic -// per number, so two lookups agree and it reads exactly like a real answer. -// Measured 2026-09-04: account 4534076021 came back "Dillon Bunch" against six -// different banks, and 0123456789 came back "Ekon Orji" against a bank it has -// no relationship with. +// KNOWN, MEASURED, AND DELIBERATELY NOT GUARDED AGAINST BY DEFAULT. // -// Aide reads the account holder's name back so the payer can stop before the -// money moves. Against a fabricator that check is worse than absent: it takes -// a mistyped account number and answers it with a confident name, and the -// person listening has no screen on which to notice the name means nothing. +// 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: // -// So the name is marked unverified, and the confirmation falls back to -// something the user can actually check: the digits they typed. - -// Distrust unless the host is recognisably production. A new hostname should -// arrive as "we cannot vouch for this", not as "verified". +// 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 this is a fact about BMONI's dev host, not about sandboxes. + // 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()); @@ -43,7 +55,11 @@ export function destinationConfirmation(dest: { accountNumber: string; nameVerified: boolean; }): string { - if (dest.nameVerified) return `to ${dest.accountName}, account ${dest.accountNumber}`; + // 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. diff --git a/tests/money/name-enquiry-trust.test.ts b/tests/money/name-enquiry-trust.test.ts index 8fc8830..72d1c1a 100644 --- a/tests/money/name-enquiry-trust.test.ts +++ b/tests/money/name-enquiry-trust.test.ts @@ -16,13 +16,32 @@ import { isFabricatedNameEnquiry, destinationConfirmation } from "../../lib/bank // against a fabricator it is worse than having none: it turns a mistyped // account number into a confident "Sending ₦20,000 to Ekon Orji". // -// So a fabricated name must never be presented as confirmation, and what is -// read back instead has to be something the user can actually check — the -// digits they typed. +// 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("recognising an endpoint that cannot really verify a name", () => { +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); }); @@ -49,18 +68,26 @@ describe("recognising an endpoint that cannot really verify a name", () => { expect(isFabricatedNameEnquiry("monnify", "https://sandbox.monnify.com")).toBe(false); }); - it("defaults to distrust when the host is unknown", () => { - // An unset base URL means we cannot tell. Claiming a name is verified when - // we do not know is the failure this file exists to prevent. + 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 really verified", () => { + 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 3463455722"); + .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", () => { From eb26955f36622eba6c2d55e44b28e4a212332812 Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 03:51:05 +0000 Subject: [PATCH 16/17] feat(voice): understand Nigerian Pidgin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Web Speech API has no Pidgin model — `pcm` is not a language tag it accepts — so pidgin was arriving transcribed by an English recognizer as the nearest English words: "dey" as "day", "wetin" as "we tin", "sabi" as "savvy", "comot" as "come out", "abeg" as "I beg". The agent then read English that meant something else, and asked the user to repeat themselves. Three repairs, in increasing order of how much they matter: 1. maxAlternatives is now 4, and the most Pidgin-looking candidate wins. The recognizer ranks by an English language model, so the right words are often present but second — it offers "we tin day happen" ahead of "wetin dey happen". The first alternative still wins ties and wins unless another is strictly more Pidgin; the recognizer is better at English than this heuristic is. 2. A repair pass on what survives, applied to finals only. Interims are redrawn several times a second and never acted on, so rewriting them would just make the on-screen text churn under the user. 3. The agent is told it will receive Pidgin, given the specific mishearings and the phrases that matter ("wetin dey my account", "I wan comot money"), and told to reply in whichever language the user used — never to ask them to repeat in English, correct them, or remark on it. Every repair rule is anchored on context that rules out the English reading, because this text becomes a withdrawal. "day" only becomes "dey" after a pronoun or before a possessive, so "every day" and "three day" survive. "one" only becomes "wan" after a pronoun AND when no number follows, so "send one thousand naira" is untouched — that one would have cost a worker money they cannot see to check. The tests assert the amount-preserving cases first and by name. The prompt carries the matching rule: read through mangled words, but never guess through a mangled AMOUNT — ask for the figure again instead. --- app/aide/voice-engine.ts | 28 +++++++- lib/agent/system.ts | 3 + lib/voice/pidgin.ts | 138 ++++++++++++++++++++++++++++++++++++++ tests/unit/pidgin.test.ts | 126 ++++++++++++++++++++++++++++++++++ 4 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 lib/voice/pidgin.ts create mode 100644 tests/unit/pidgin.test.ts 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/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/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/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(""); + }); +}); From 50230043d28fe872eaa9c5813c3d150dec7858df Mon Sep 17 00:00:00 2001 From: 0dillon Date: Fri, 4 Sep 2026 09:49:45 +0000 Subject: [PATCH 17/17] =?UTF-8?q?fix(bmoni):=20finish=20the=20move=20off?= =?UTF-8?q?=20Monnify=20=E2=80=94=20index,=20provider=20scope,=20personas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The payments page still showed Monnify. Four separate faults, each of which alone was enough to keep it there. start-nigeria requires THREE fields, not two. The client never sent `ngnWalletIndex`, 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 — which the parser (correctly) refuses to hand out. Nothing in any BMONI response carries that index, so it is derived, and only for the single wallet Aide provisions; with more than one it refuses rather than guessing, because a wrong index routes a worker's wages to a wallet Aide cannot read and nothing anywhere reports an error. ensureWallet returned early on `status === "active"` alone. That is true of every wallet Monnify ever made, so a switched deployment stayed permanently half-migrated: the old NUBAN on screen, no BMONI user, and every BMONI read failing at once so it looked like an outage. Wallets now record who provisioned them, and "active" means active for the provider that is live. The withdrawal ledger had the same shape of bug with a worse symptom. Three Monnify-era payouts sat at "processing" for ever, and Aide reads that list aloud — a blind worker asking what they had sent would hear transfers to names they may not know, described as still in flight, with no screen on which to notice the dates were from another era. Rows now name their provider; rows that name none are Monnify's. Personas: the demo accounts carry the sandbox identities whose BVNs actually resolve, phone numbers derived per deployment (the documented sandbox numbers are registered to other teams and answer 409 for ever), and an email for the employer, without which BMONI will not create a user at all. Wallet creation still has no uniqueness guard at BMONI, so the pre-create check reads balances first and fails closed on anything it cannot positively read as "no wallet yet" — a wrong "no" makes a second wallet and deposits route to whichever one we did not record. --- .gitignore | 1 + app/payments/page.tsx | 2 +- convex/accounts.ts | 20 ++- convex/schema.ts | 13 ++ convex/wallets.ts | 35 ++++- lib/banking/bmoni-provider.ts | 88 ++++++++++- lib/banking/bmoni-shapes.ts | 42 +++++- lib/banking/bmoni-wallet.ts | 39 +++-- lib/banking/bmoni.ts | 20 ++- lib/store/payments.ts | 52 ++++++- lib/store/persona-phone.ts | 32 ++++ lib/store/seed.ts | 12 +- lib/store/state.ts | 3 + tests/money/bmoni-balance-path.test.ts | 4 + tests/money/bmoni-shapes.test.ts | 56 +++++++ tests/money/bmoni-wallet-precheck.test.ts | 55 +++++++ tests/money/wallet-provider-switch.test.ts | 142 ++++++++++++++++++ tests/money/withdrawal-provider-scope.test.ts | 97 ++++++++++++ tests/unit/persona-phone.test.ts | 45 ++++++ 19 files changed, 726 insertions(+), 32 deletions(-) create mode 100644 lib/store/persona-phone.ts create mode 100644 tests/money/bmoni-wallet-precheck.test.ts create mode 100644 tests/money/wallet-provider-switch.test.ts create mode 100644 tests/money/withdrawal-provider-scope.test.ts create mode 100644 tests/unit/persona-phone.test.ts 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/payments/page.tsx b/app/payments/page.tsx index 46f6ee6..14f0c5f 100644 --- a/app/payments/page.tsx +++ b/app/payments/page.tsx @@ -686,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) => ( diff --git a/convex/accounts.ts b/convex/accounts.ts index 992f799..9d55c72 100644 --- a/convex/accounts.ts +++ b/convex/accounts.ts @@ -102,9 +102,27 @@ export const seedDefaults = mutation({ // 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 = {}; - for (const k of ["firstName", "lastName", "phoneNumber", "bvn"] as const) { + // `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 75fbcef..37f6eda 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -59,6 +59,14 @@ 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 — @@ -149,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 40d25dd..e40e924 100644 --- a/convex/wallets.ts +++ b/convex/wallets.ts @@ -71,11 +71,13 @@ export const setProvisioned = mutation({ // 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, + provider: a.provider, accountNumber: a.accountNumber, bankName: a.bankName, accountName: a.accountName, @@ -347,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. @@ -355,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/banking/bmoni-provider.ts b/lib/banking/bmoni-provider.ts index 4eed78a..8ef6710 100644 --- a/lib/banking/bmoni-provider.ts +++ b/lib/banking/bmoni-provider.ts @@ -4,6 +4,10 @@ import { getAccount } from "../store/accounts"; import { decimalStringToKobo } from "./amounts"; import { getNgnBalanceKobo, + onboardingStatus, + type OnboardingStatus, + pointDepositsAtWallet, + startNigeriaOnboarding, getNgnDepositAccount, listNigerianBanks, listWalletTransactions, @@ -15,10 +19,49 @@ import type { InboundCredit, PaymentProvider, PayoutOutcome, VerifiedAccount } f // 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) throw new Error(`No BMONI user provisioned for ${accountId}`); - return w.bmoniUserId; + 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 = { @@ -30,11 +73,48 @@ export const bmoniProvider: PaymentProvider = { lastName?: string; phoneNumber?: string; email?: string; + bvn?: string; }; - const { bmoniUserId } = await provisionBmoniWallet(accountId, `aide-${accountId}`, acc); + 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. - const deposit = await getNgnDepositAccount(bmoniUserId); + // + // 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 }; }, diff --git a/lib/banking/bmoni-shapes.ts b/lib/banking/bmoni-shapes.ts index 2e3c555..0ba558c 100644 --- a/lib/banking/bmoni-shapes.ts +++ b/lib/banking/bmoni-shapes.ts @@ -75,6 +75,44 @@ export function parseNgnBalanceKobo(body: unknown): number { 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 @@ -83,7 +121,7 @@ export function parseNgnBalanceKobo(body: unknown): number { const POOLED_ID = /^pooled-/i; const POOLED_NAME = /bkey/i; -export function parseNgnDepositAccount(body: unknown): { accountNumber: string; bankName: string; accountName: string } { +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"); @@ -112,6 +150,8 @@ export function parseNgnDepositAccount(body: unknown): { accountNumber: string; } 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 diff --git a/lib/banking/bmoni-wallet.ts b/lib/banking/bmoni-wallet.ts index 4aae02c..530b7b8 100644 --- a/lib/banking/bmoni-wallet.ts +++ b/lib/banking/bmoni-wallet.ts @@ -148,8 +148,11 @@ export async function provisionBmoniWallet( // second wallet. If one may already exist, read balances first and refuse // rather than guessing. if (existing?.bmoniUserId && !existing.bmoniSmartWalletId) { - const balances = await listBalances(bmoniUserId); - if (hasWallet(balances)) { + 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.`, @@ -178,13 +181,30 @@ export async function provisionBmoniWallet( return { bmoniUserId, smartWalletId: wallet.smartWalletId, walletAddress: wallet.address }; } -// Deliberately conservative: anything that looks like a wallet counts. This -// gates "may I create another", so a false positive costs a manual check and a -// false negative costs a duplicate wallet. -function hasWallet(balances: unknown): boolean { - if (!balances) return false; - const list = Array.isArray(balances) ? balances : (balances as any)?.wallets ?? (balances as any)?.balances; - return Array.isArray(list) && list.length > 0; +// 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. @@ -204,6 +224,7 @@ export async function activateNigeriaRail(args: { userId: args.bmoniUserId, bvn: args.bvn, ngnWalletAddress: args.walletAddress, + smartWalletId: args.smartWalletId, }); await pointDepositsAtWallet(args.bmoniUserId, args.smartWalletId, args.bankAccountId); } diff --git a/lib/banking/bmoni.ts b/lib/banking/bmoni.ts index 037e80e..7eab374 100644 --- a/lib/banking/bmoni.ts +++ b/lib/banking/bmoni.ts @@ -2,7 +2,14 @@ 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 } from "./bmoni-shapes"; +import { + parseBanks, + parseCreatedUser, + parseCreatedWallet, + parseNgnBalanceKobo, + parseNgnDepositAccount, + soleNgnWalletIndex, +} from "./bmoni-shapes"; // The BMONI Embedded operations Aide uses, in lifecycle order: // @@ -94,15 +101,22 @@ 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 }, + body: { bvn, ngnWalletAddress: args.ngnWalletAddress, ngnWalletIndex }, }); } @@ -150,7 +164,7 @@ export async function pointDepositsAtWallet( } // The account number a worker actually gives out to be paid into. -export async function getNgnDepositAccount(userId: string): Promise<{ accountNumber: string; bankName: string; accountName: string }> { +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` })); } diff --git a/lib/store/payments.ts b/lib/store/payments.ts index 037cbb8..2b56639 100644 --- a/lib/store/payments.ts +++ b/lib/store/payments.ts @@ -50,6 +50,7 @@ type WalletDoc = { accountId: string; accountReference: string; status: "unprovisioned" | "active" | "failed"; + provider?: string; accountNumber?: string; bankName?: string; accountName?: string; @@ -90,6 +91,7 @@ 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, @@ -130,7 +132,15 @@ 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 @@ -144,8 +154,23 @@ export function ensureWallet(accountId: string): Promise { // 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 }); - return { ...wallet, status: "active" as const, accountNumber, bankName, accountName, lastError: undefined }; + 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); @@ -165,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( @@ -268,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 })); } 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 a72c8ed..661fc10 100644 --- a/lib/store/seed.ts +++ b/lib/store/seed.ts @@ -1,6 +1,7 @@ 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 @@ -15,17 +16,19 @@ import { worker } from "./state"; // 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", - phoneNumber: "+2348000000000", bvn: "95888168924", }, employer: { firstName: "Samson", lastName: "Jabo", - phoneNumber: "+2348000000001", bvn: "22222222222", }, } as const; @@ -53,15 +56,20 @@ export function ensureSeeded(): Promise { 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 2afbe5c..19939e6 100644 --- a/lib/store/state.ts +++ b/lib/store/state.ts @@ -122,6 +122,9 @@ 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. diff --git a/tests/money/bmoni-balance-path.test.ts b/tests/money/bmoni-balance-path.test.ts index d15bd91..4c6d0e9 100644 --- a/tests/money/bmoni-balance-path.test.ts +++ b/tests/money/bmoni-balance-path.test.ts @@ -51,6 +51,10 @@ beforeEach(async () => { "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", diff --git a/tests/money/bmoni-shapes.test.ts b/tests/money/bmoni-shapes.test.ts index 80ee4b1..41490fc 100644 --- a/tests/money/bmoni-shapes.test.ts +++ b/tests/money/bmoni-shapes.test.ts @@ -6,6 +6,7 @@ import { parseCreatedWallet, parseNgnBalanceKobo, parseNgnDepositAccount, + soleNgnWalletIndex, } from "../../lib/banking/bmoni-shapes"; // Every payload in this file was captured from the live BMONI sandbox at @@ -116,6 +117,58 @@ describe("GET /v1/users/{id}/smart-wallets/account/balances", () => { }); }); +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 @@ -149,6 +202,7 @@ describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/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", @@ -193,6 +247,8 @@ describe("GET /v1/users/{id}/bank-accounts/deposit-accounts/NGN", () => { ], }; 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", 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/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-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)); + } + }); +});