Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4bda55f
feat(bmoni): sign BMONI proposals and owner proofs
0dillon Sep 3, 2026
1716650
feat(bmoni): generate and encrypt per-user wallet owner keys
0dillon Sep 3, 2026
193efd3
feat(bmoni): amounts, HTTP transport, operations and proposal status
0dillon Sep 3, 2026
3a3cd67
feat(bmoni): wallet provisioning, bank payouts and the provider seam
0dillon Sep 3, 2026
57ac7b6
fix(voice): say a zero balance out loud instead of skipping it
0dillon Sep 3, 2026
ccfac3d
fix(bmoni): parse the shapes the sandbox actually returns
0dillon Sep 3, 2026
93129aa
feat(bmoni): card panel on the payments page, and the deposit account…
0dillon Sep 4, 2026
bc1c890
feat(bmoni): make BMONI the provider, not an option behind a flag
0dillon Sep 4, 2026
9ee024a
feat(bmoni): real inbound history — the endpoint existed after all
0dillon Sep 4, 2026
9687bae
fix(profile): switch demo identities through the server, not document…
0dillon Sep 4, 2026
2285dee
feat(bmoni): give the employer a BMONI wallet, and seed both sandbox …
0dillon Sep 4, 2026
3b50350
fix(payments): show the name the bank holds, not the Aide profile name
0dillon Sep 4, 2026
613099c
fix(bmoni): bank codes, name enquiry, payouts and arrivals all throug…
0dillon Sep 4, 2026
59db805
fix(bmoni): stop presenting a fabricated account holder as verified
0dillon Sep 4, 2026
ff4f5c8
revert(bmoni): show the provider's account name as-is by default
0dillon Sep 4, 2026
eb26955
feat(voice): understand Nigerian Pidgin
0dillon Sep 4, 2026
5023004
fix(bmoni): finish the move off Monnify — index, provider scope, pers…
0dillon Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,48 @@ WEBHOOK_PORT=4000
# Locally you can leave it unset — a development-only default is used.
SESSION_SECRET=

# ---- BMONI Embedded (the stablecoin money layer) ----
# Additive: Monnify stays the default. Nothing below is read unless the BMONI
# provider is selected, so an unset block leaves the app exactly as it was.
#
# Selection is explicit opt-in. Anything that is not exactly "bmoni" — a typo,
# a half-set value — falls back to Monnify, so a mistake here cannot silently
# move real wages onto the sandbox integration.
# AIDE_PAYMENT_PROVIDER=bmoni

# BMONI_BASE_URL=https://embedded-dev.bmoni.com
# BMONI_API_KEY=pk_xxxxxxxx

# BMONI's name enquiry is per-user, unlike Monnify's global one. Verifying a
# destination before a payer is known needs some user context to call as.
# BMONI_VERIFY_AS_USER_ID=

# The development host does NOT perform bank-account name enquiry. It returns a
# plausible Nigerian name for any ten-digit number against any bank code —
# deterministic per number, so two look-ups agree and it reads like a real
# answer. Measured: 4534076021 returns "Dillon Bunch" against six different
# banks; 0123456789 returns "Ekon Orji".
#
# Left OFF by default, so Aide surfaces whatever the provider says and behaves
# the same here as on production. Harmless while the sandbox wallets hold no
# money and the personas are not real people.
#
# Turn it on before this points at a funded wallet on any host whose name
# enquiry has not been confirmed as real. Aide then refuses to show or speak an
# unverified holder name and reads the account digits back instead — the part
# the user actually supplied and can check. BMONI's docs cover sandbox
# behaviour for BVN/NIN look-ups in detail and say nothing about this endpoint.
# BMONI_STRICT_NAME_ENQUIRY=true

# Encrypts every user's smart-wallet owner private key before it is stored.
# Aide holds these keys because BMONI's SDK requires an on-screen PIN per
# signature, which is what the spoken security phrase replaces — so whoever
# holds this secret can sign transfers. There is deliberately NO default and no
# fallback: without it, sealing and opening keys both refuse to run.
# Generate one with:
# openssl rand -hex 32
# BMONI_KEY_SECRET=

# ---- TEMPORARY: demo balance stand-in ----
# Leave this UNSET. When set, it stands in for the confirmed balance ONLY when
# Monnify cannot be reached at all — a number the app otherwise refuses to
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,4 @@ venv/
# Python bytecode from the TTS worker / speak function
__pycache__/
*.pyc
.aide-local/
28 changes: 25 additions & 3 deletions app/aide/voice-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down
14 changes: 10 additions & 4 deletions app/api/greeting/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions app/api/payments/banks/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
4 changes: 2 additions & 2 deletions app/api/payments/beneficiaries/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 });
}
Expand Down
48 changes: 48 additions & 0 deletions app/api/payments/card/route.ts
Original file line number Diff line number Diff line change
@@ -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}`,
});
}
}
5 changes: 5 additions & 0 deletions app/api/payments/summary/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 13 additions & 12 deletions app/api/payments/transactions/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
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";
// Talks to the bank, which can be slow. The platform default is short enough
// 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.
Expand All @@ -22,13 +24,12 @@ export async function GET(req: Request) {
const outbound = await getWithdrawals(acc.id).catch(() => []);

try {
const wallet = await ensureWallet(acc.id);
const inbound = (await getReservedAccountTransactions(wallet.accountReference)).content.map((t) => ({
amount: t.amountPaid ?? t.amount,
status: t.paymentStatus,
from: t.customerDTO?.name ?? "Bank transfer",
reference: t.transactionReference,
at: t.createdOn ?? null,
const inbound = (await paymentProvider().listInbound(acc.id)).map((c) => ({
amount: toNaira(c.amountKobo),
status: "PAID",
from: c.from ?? "Bank transfer",
reference: c.reference,
at: c.at,
}));
return Response.json({ inbound, outbound });
} catch (e) {
Expand Down
21 changes: 18 additions & 3 deletions app/api/payments/validate-account/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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;
Expand All @@ -17,8 +22,18 @@ export async function POST(req: Request) {
return Response.json({ error: "accountNumber and bankCode are required." }, { status: 400 });
}
try {
const r = await validateBankAccount(accountNumber.trim(), bankCode.trim());
return Response.json({ ok: true, accountName: r.accountName, accountNumber: r.accountNumber });
// Through the seam: BMONI scopes name enquiry to the asking user, so this
// is verified as the account that will be paying.
const r = await paymentProvider().verifyDestination(acc.id, accountNumber.trim(), bankCode.trim());
// The name is only returned when it means something. On an endpoint that
// fabricates, sending it would put "Account found: Ekon Orji" on screen
// for a number nobody has verified — and Aide would read it out.
return Response.json({
ok: true,
accountNumber: r.accountNumber,
accountName: r.nameVerified ? r.accountName : undefined,
nameVerified: r.nameVerified,
});
} catch {
return Response.json({ ok: false, error: "Bank details not found — check the account number and bank." }, { status: 404 });
}
Expand Down
6 changes: 6 additions & 0 deletions app/api/payments/webhook/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading