diff --git a/docs/NGN_RAILS.md b/docs/NGN_RAILS.md index 6e60f585f..582d8f603 100644 --- a/docs/NGN_RAILS.md +++ b/docs/NGN_RAILS.md @@ -362,8 +362,8 @@ The **Executive Order on Virtual Assets Coordination (signed 17 July 2026)** spl | Half | Posture | Status | |---|---|---| -| **Spend crypto on airtime / data / bills** | The aggregator holds the NCC/NERC licence and the vend runs under it. We are a reseller buying a product, closer to merchant acceptance than to intermediation. `reseller-needs-no-licence` remains an **inference, not a verified rule** — but it is the same posture other Nigerian crypto-bills platforms already operate under | **Build now** | -| **Crypto → naira in a user's bank account** | The *arranging* limb of ISA 2025 Sch.2 Pt.II ¶2, no exclusions schedule, s.61 criminal liability. Self-custody does not help here | **Gated** on the SEC letter or the CBN sandbox | +| **Spend crypto on airtime / data / bills** | The aggregator holds the NCC/NERC licence and the vend runs under it. We are a reseller buying a product, closer to merchant acceptance than to intermediation. `reseller-needs-no-licence` remains an **inference, not a verified rule** — but it is the same posture other Nigerian crypto-bills platforms already operate under | **Shipped on Linq** (`wraith/src/linq/bills.ts`, `/ngn/bills`; airtime live in mobile and web wallets) | +| **Crypto → naira in a user's bank account** | The *arranging* limb of ISA 2025 Sch.2 Pt.II ¶2, no exclusions schedule, s.61 criminal liability. Self-custody does not help here | **Gated** on the SEC letter or the CBN sandbox (closed internal testing only) | All the weight in "The licensing question" above attaches to the **second** row. Nothing in this document says a reseller buying airtime needs a VASP registration. @@ -381,9 +381,11 @@ The SCF application currently has to describe the naira story rather than show i **Not a revenue line.** Verified first-hand: median margin is **1%** on MTN and Airtel, and every MTN and Airtel SME plan is currently `Unavailable` — the tiers that carry Nigerian volume cannot be sold at any margin. Glo SME is the only 10% inventory and Glo is a distant third by subscriber share. Bills is a **retention feature**. No model may assume otherwise. -### First blocker +### First blocker (superseded 2026-10-02) -**The eBills "reseller role"** — transactional endpoints require it, and how it is granted is behind the login wall. Thirty minutes of clicking settles it and nothing else starts until it does. +~~**The eBills "reseller role"** — transactional endpoints require it, and how it is granted is behind the login wall. Thirty minutes of clicking settles it and nothing else starts until it does.~~ + +**Superseded by Linq.** The naira leg and `/ngn/bills` (`airtime`, `data`, `electricity`, `cabletv`, `betting`) shipped on Linq (`wraith/src/linq/bills.ts` and `wraith/src/api/ngn.ts`), and airtime is live in both the mobile and web wallets. No eBills wallet float is needed. ### UI consequence, already shipped @@ -429,7 +431,7 @@ dashboards for the two prefunded wallets. > bill rather than a failed order. **Needs a human with a login (cannot be desk-researched):** -- ~~**eBills "reseller role"**~~ — **SETTLED 2026-08-26: the role is gated on funding the eBills wallet.** Probed directly: `/jwt-auth/v1/token` authenticates fine on an unfunded account, `GET /api/v2/balance` returns `403 rest_forbidden`. So the role is not a support request or a form — it is a deposit. **This is now a money blocker, not a paperwork one**, and the airtime build is parked until there is float to fund. Probe kept at `scripts/ebills-probe.mjs`; re-run it after funding to confirm. +- ~~**eBills "reseller role"** — SETTLED 2026-08-26: the role is gated on funding the eBills wallet.~~ **SUPERSEDED 2026-10-02:** the naira leg and bills (`POST /ngn/bills`, `GET /ngn/bills/:orderId`) shipped on Linq, and airtime is live in the wallet (`frontend/mobile/app/airtime.tsx`, `frontend/wallet/app/bills/page.tsx`). The eBills float blocker no longer applies (`scripts/ebills-probe.mjs` is historical only). - **eBills duplicate semantics** — does replaying a `request_id` after the 3-minute `duplicate_order` window create a **second vend**? Ask support before any retry logic ships. - **eBills margins on a funded account** — confirm the live 1%-median finding before any revenue model depends on it. - **eBills top-up mechanism** — undocumented publicly. diff --git a/frontend/mobile/app/airtime.tsx b/frontend/mobile/app/airtime.tsx index 9cf3d905a..335e81ddf 100644 --- a/frontend/mobile/app/airtime.tsx +++ b/frontend/mobile/app/airtime.tsx @@ -1,59 +1,34 @@ /** * Buy airtime with crypto from this wallet. * - * Screens b1–b5 of the design. + * Screens b1–b5 of the design, built on the shared `BillFlow` + * (`components/BillFlow.tsx` and `lib/billFlow.ts`). * - * ## This is the mirror image of the onramp, and that is the point + * ## What lives here vs in `BillFlow` * - * Buying crypto hands the user a bank account and sends them to their bank app; - * nothing is signed. Airtime is the reverse — the deposit is crypto *leaving* - * this wallet — so it is signed, with a passkey, and it never leaves the app. - * That is why it is the better of the two flows and why it ships first. + * `BillFlow` owns the shared payment machinery — mainnet gating, per-coin rate + * loading, `customerRef` and classic `G…` `refundAddress` resolution, the + * one-time NIN verification bounce to `/verify`, `previewSpendFee` on the + * confirm step before the passkey prompt, reserving the order first (`payBill`) + * and spending the locked amount second (`spendAsset`), and polling + * `getBillStatus` until delivered or refunded. * - * ## The order of operations is not arbitrary - * - * `payBill` reserves the order and locks the rate, and returns the address to - * pay. Only then is anything spent. Reserving first means a failure to reserve - * costs nothing, and the amount we send is the amount the order was quoted at. - * - * ## Underpayment is not a failed order - * - * Linq settles at whatever actually arrives, converted at the locked rate — - * "this is a manual deposit by design". So sending less than quoted buys *less - * airtime*, silently. The amount sent is therefore taken from the order rather - * than re-derived from anything on screen. - * - * ## The refund address matters most when everything has already gone wrong - * - * If the biller rejects the top-up after the deposit lands, the crypto comes - * back to `refundAddress`. It must be an address the user controls, and a - * classic `G…` one — a classic payment cannot name a contract. That is the - * fee-payer account, whose balances the holdings list already shows as part of - * this wallet. + * This file holds only what is specific to airtime: + * - Nigerian mobile prefix detection (`MTN`, `AIRTEL`, `GLO`, `9MOBILE`) and + * the editable network chip row (since ported numbers must be overridable) + * - the 11-digit phone number input and contact picker (`pickContactNumber`) + * - the airtime copy and confirmation rows passed across the `BillFlow` seam */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - ActivityIndicator, - KeyboardAvoidingView, - Platform, - Pressable, - ScrollView, - StyleSheet, - Text, - TextInput, - View, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useRouter } from 'expo-router'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { BillFlow, createBillFlowStyles } from '../components/BillFlow'; import * as Clipboard from 'expo-clipboard'; import { ContactIcon } from '../components/icons'; -import { pickContactNumber } from '../lib/pickContact'; -import { FlowHeader } from '../components/FlowHeader'; -import { SlideToConfirm } from '../components/SlideToConfirm'; -import { useWallet } from '../components/WalletProvider'; import { useTheme } from '../hooks/useTheme'; +import type { BillCategoryConfig } from '../lib/billFlow'; +import { pickContactNumber } from '../lib/pickContact'; import { useNetwork } from '../hooks/useNetwork'; import type { ThemeColors } from '../lib/theme'; import { fontFamily } from '../theme/typography'; @@ -89,7 +64,7 @@ import { BillPoller, MAX_CONSECUTIVE_POLL_ERRORS } from '../lib/billPolling'; type Step = 'form' | 'confirm' | 'paying' | 'done' | 'failed' | 'unconfirmed'; -const QUICK_AMOUNTS = [200, 500, 1000, 2000]; +const QUICK_AMOUNTS = [200, 500, 1000, 2000] as const; /** * Nigerian mobile prefixes, so the network is detected rather than asked for. @@ -98,16 +73,16 @@ const QUICK_AMOUNTS = [200, 500, 1000, 2000]; * the wrong biller is a failed vend. So the picker stays visible and editable, * and the hint says where the guess came from. */ -const PREFIXES: Record = { +export const PREFIXES: Record = { MTN: ['0803', '0806', '0703', '0706', '0813', '0816', '0810', '0814', '0903', '0906', '0913', '0916'], AIRTEL: ['0802', '0808', '0708', '0812', '0701', '0902', '0901', '0904', '0907', '0912'], GLO: ['0805', '0807', '0705', '0815', '0811', '0905', '0915'], '9MOBILE': ['0809', '0818', '0817', '0909', '0908'], }; -const NETWORKS = Object.keys(PREFIXES); +export const NETWORKS = Object.keys(PREFIXES); -function detectNetwork(phone: string): string | null { +export function detectNetwork(phone: string): string | null { const p = phone.replace(/\D/g, ''); if (p.length < 4) return null; const prefix = p.slice(0, 4); @@ -117,25 +92,63 @@ function detectNetwork(phone: string): string | null { return null; } -function naira(n: number): string { - return `₦${n.toLocaleString('en-NG', { maximumFractionDigits: 2 })}`; +export function isValidNigerianPhone(phoneDigits: string): boolean { + return phoneDigits.length === 11 && phoneDigits.startsWith('0'); +} + +export function buildAirtimeCategoryConfig(params: { + provider: string; + phone: string; +}): BillCategoryConfig { + const phoneDigits = params.phone.replace(/\D/g, ''); + const { provider } = params; + return { + category: 'airtime', + title: 'Airtime', + returnTo: '/airtime', + provider, + customerId: phoneDigits, + customerValid: isValidNigerianPhone(phoneDigits), + quickAmounts: QUICK_AMOUNTS, + copy: { + mainnetTitle: 'Switch to mainnet to buy airtime', + mainnetHint: + 'Top-ups are vended against real crypto, so this only works on mainnet — there is no test mode. Switch network in Settings, then come back.', + insufficientBalanceNoun: 'top-up', + verifySummary: (amountFormatted) => `Airtime · ${amountFormatted} to ${params.phone}`, + confirmQuestion: (amountFormatted) => `${amountFormatted} airtime to ${phoneDigits} costs`, + confirmRows: [ + { label: 'Network', value: provider }, + { label: 'Phone', value: phoneDigits }, + ], + refundNote: `If ${provider} can't complete the top-up, the full amount comes straight back to this wallet.`, + payingTitle: 'Sending your top-up', + payingHint: (amountFormatted) => `${provider} is vending ${amountFormatted} to ${phoneDigits}.`, + payingWaitLabel: 'Waiting for the top-up…', + payingSummaryLabel: 'Airtime', + payingSummaryValue: (amountFormatted) => `${amountFormatted} · ${provider} ${phoneDigits}`, + doneTitle: 'Airtime sent', + doneHint: (amountFormatted) => + `${amountFormatted} is on ${phoneDigits}. It usually shows on the phone within a minute.`, + doneRows: [{ label: 'Network', value: provider }], + doneAgainLabel: 'Buy again', + failedHint: (spentFormatted) => + `Your ${spentFormatted} is being returned to this wallet. No airtime was added to ${phoneDigits}.`, + failedReasonLabel: `${provider} didn't complete the top-up`, + }, + }; } export default function AirtimeScreen() { - const router = useRouter(); const { colors } = useTheme(); - const styles = useMemo(() => createStyles(colors), [colors]); - const { wallet } = useWallet(); - - const { networkName } = useNetwork(); - const mainnetOnly = networkName !== 'mainnet'; - - const [step, setStep] = useState('form'); - const [error, setError] = useState(null); + const styles = useMemo(() => createBillFlowStyles(colors), [colors]); const [provider, setProvider] = useState('MTN'); const [providerTouched, setProviderTouched] = useState(false); const [phone, setPhone] = useState(''); + const [contactError, setContactError] = useState(null); + + const phoneDigits = phone.replace(/\D/g, ''); const [amountNGN, setAmountNGN] = useState(''); const [coin, setCoin] = useState('usdc'); @@ -193,6 +206,7 @@ export default function AirtimeScreen() { }, [handleRecheckOrders]); const detected = detectNetwork(phone); + // A detected network wins until the user overrides it. Ported numbers are why // the override exists at all. useEffect(() => { @@ -307,13 +321,17 @@ export default function AirtimeScreen() { const picked = await pickContactNumber(); if (picked.ok) { setPhone(picked.phone); - setError(null); + setContactError(null); return; } // A cancel is a decision, not a failure, and gets no message. - if (picked.reason !== 'cancelled') setError(picked.message); + if (picked.reason !== 'cancelled') setContactError(picked.message); }, []); + const config = useMemo( + () => buildAirtimeCategoryConfig({ provider, phone }), + [provider, phone], + ); const continueFromForm = useCallback(async () => { // Validate before the confirm step, naming the specific problem const validation = await validateIdentifierBeforeConfirm('airtime', phone, { provider }); @@ -448,6 +466,24 @@ export default function AirtimeScreen() { }, [prepaidToken]); return ( + setContactError(null)} + > + NETWORK + + {NETWORKS.map((n) => ( + { + setProvider(n); + setProviderTouched(true); + }} + style={[styles.networkChip, provider === n && styles.networkChipActive]} + > + + {n} @@ -827,7 +863,46 @@ export default function AirtimeScreen() { Nothing for you to do. It returns to this wallet and will show in your activity. + + ))} + + + PHONE NUMBER + + setPhone(t.replace(/[^\d]/g, '').slice(0, 11))} + keyboardType="number-pad" + placeholder="0803 512 4471" + placeholderTextColor={colors.textMuted} + /> + {/* Nobody remembers the number they are topping up. It is + almost always someone else's, which is the whole reason + this screen exists. */} + void chooseContact()} + accessibilityRole="button" + accessibilityLabel="Choose from contacts" + hitSlop={8} + style={({ pressed }) => [styles.contactBtn, pressed && styles.pressed]} + > + + + + {detected ? ( + + {detected} number, detected from {phoneDigits.slice(0, 4)} + {providerTouched && provider !== detected ? ` — sending to ${provider}` : ''} + + ) : ( + + Numbers get ported, so check the network above matches. + + )} + + { setStep('form'); diff --git a/frontend/mobile/components/BillFlow.tsx b/frontend/mobile/components/BillFlow.tsx new file mode 100644 index 000000000..f3d5e4b3c --- /dev/null +++ b/frontend/mobile/components/BillFlow.tsx @@ -0,0 +1,795 @@ +/** + * Shared bill payment screen (`form` → `confirm` → `paying` → `done` / `failed`). + * + * Owns everything that is identical across airtime, data, electricity, cable TV, + * and betting: + * + * - mainnet gating (`networkName === 'mainnet'`) + * - resolving `customerRef` and `refundAddress` (the classic `G…` fee-payer) + * - loading the per-coin NGN rate (`xlm` / `usdc`) + * - one-time NIN verification check (`isNairaVerified`) and `/verify` bounce + * - amount and asset selection (or a category-supplied fixed bundle amount) + * - `previewSpendFee` on the confirm step before the passkey prompt + * - reserving the order first (`payBill`) and spending the locked crypto + * amount second (`spendAsset`) + * - polling `getBillStatus` until delivered or refunded, without ever + * treating a transient poll failure as a failed payment after funds have + * left the wallet + * + * Category-specific inputs, validation, provider lookups, and copy stay on the + * caller's side of the seam (see `lib/billFlow.ts`). + */ + +import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useRouter } from 'expo-router'; + +import { FlowHeader } from './FlowHeader'; +import { SlideToConfirm } from './SlideToConfirm'; +import { useWallet } from './WalletProvider'; +import { useTheme } from '../hooks/useTheme'; +import { useNetwork } from '../hooks/useNetwork'; +import type { ThemeColors } from '../lib/theme'; +import { fontFamily } from '../theme/typography'; +import { errorMessage } from '../lib/errorMessage'; +import { getAssetIssuer } from '../lib/assets'; +import { getFeePayerAddress } from '../lib/activity'; +import { NotEnoughToSend, previewSpendFee } from '../lib/spendAsset'; +import { isPasskeyCancellation } from '../lib/passkeyOutcome'; +import type { PlatformFee } from '../lib/platformFee'; +import { + getBillStatus, + getCustomerRef, + getOnrampRate, + isNairaVerified, + type NairaCoin, +} from '../lib/onramp'; +import { + canContinueBillForm, + classifyBillPollStatus, + computeBillCryptoCost, + DEFAULT_QUICK_AMOUNTS, + executeBillPayment, + formatNaira, + parseBillAmountNgn, + type BillCategoryConfig, + type BillRateState, + type BillStep, +} from '../lib/billFlow'; + +export interface BillFlowProps extends BillCategoryConfig { + /** Category-specific form fields rendered above the amount and asset controls. */ + children: ReactNode; + /** + * Optional error raised by a category field action (such as the contact + * picker in `airtime.tsx`), rendered in the shared error banner. + */ + fieldError?: string | null; + /** Called when the shared flow clears errors on retry or step transition. */ + onClearFieldError?: () => void; + /** + * Optional fixed NGN amount supplied by a bundle/bouquet picker (e.g. data or + * cable TV plans). When provided, overrides the free-form amount input. + */ + presetAmountNgn?: number | null; +} + +export function BillFlow({ + category, + title, + returnTo, + provider, + customerId, + customerValid, + quickAmounts = DEFAULT_QUICK_AMOUNTS, + copy, + children, + fieldError = null, + onClearFieldError, + presetAmountNgn = null, +}: BillFlowProps) { + const router = useRouter(); + const { colors } = useTheme(); + const styles = useMemo(() => createBillFlowStyles(colors), [colors]); + const { wallet } = useWallet(); + + const { networkName } = useNetwork(); + const mainnetOnly = networkName !== 'mainnet'; + + const [step, setStep] = useState('form'); + const [error, setError] = useState(null); + + const [amountNGN, setAmountNGN] = useState(''); + const [coin, setCoin] = useState('usdc'); + + const [rate, setRate] = useState(null); + const [rateState, setRateState] = useState('loading'); + + const [customerRef, setCustomerRef] = useState(null); + const [refundAddress, setRefundAddress] = useState(null); + + const [orderId, setOrderId] = useState(null); + const [payHash, setPayHash] = useState(null); + // Shown before the passkey, never after. `previewSpendFee` decides the fee + // the same way `spendAsset` will, so the figure on the confirm screen matches + // what is actually charged. + const [veilFee, setVeilFee] = useState(null); + const [spent, setSpent] = useState(null); + const [waitingForPasskey, setWaitingForPasskey] = useState(false); + + const pollRef = useRef | null>(null); + + useEffect(() => { + let cancelled = false; + (async () => { + const [addr, ref] = await Promise.all([ + getFeePayerAddress().catch(() => null), + getCustomerRef().catch(() => null), + ]); + if (cancelled) return; + setRefundAddress(addr); + setCustomerRef(ref); + })(); + return () => { + cancelled = true; + }; + }, []); + + const loadRate = useCallback(async () => { + if (mainnetOnly) return; + setRateState('loading'); + try { + setRate(await getOnrampRate(coin)); + setRateState('ready'); + } catch { + setRate(null); + setRateState('failed'); + } + }, [mainnetOnly, coin]); + + useEffect(() => { + void loadRate(); + }, [loadRate]); + + // Poll the vend result once the on-chain deposit has left the wallet. + useEffect(() => { + if (!orderId || !customerRef || step !== 'paying' || !payHash) return; + const check = async () => { + try { + const s = await getBillStatus(customerRef, orderId); + const outcome = classifyBillPollStatus(s.status); + if (outcome === 'done') setStep('done'); + if (outcome === 'failed') setStep('failed'); + } catch (err) { + // Silent to the user: the payment has already left the wallet, so + // surfacing a poll network error would falsely suggest the payment did + // not go through. Logged so support/debugging can still see it. + console.warn(`[${category}] status poll failed:`, errorMessage(err)); + } + }; + void check(); + pollRef.current = setInterval(check, 10_000); + return () => { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + }; + }, [orderId, customerRef, step, payHash, category]); + + const ngn = presetAmountNgn !== null ? presetAmountNgn : parseBillAmountNgn(amountNGN); + const code = coin.toUpperCase(); + const cost = computeBillCryptoCost(ngn, rate); + const formattedNgn = formatNaira(ngn); + + // Only on the confirm step: `previewSpendFee` reads two balances, and running + // it on every keystroke of the amount input would fire a request per digit. + useEffect(() => { + if (step !== 'confirm' || cost <= 0) return; + let cancelled = false; + void (async () => { + const issuer = coin === 'usdc' ? getAssetIssuer('USDC', 'mainnet') : null; + const fee = await previewSpendFee({ + amount: String(cost), + ...(issuer ? { asset: { code: 'USDC', issuer } } : {}), + }).catch(() => null); + if (!cancelled) setVeilFee(fee); + })(); + return () => { + cancelled = true; + }; + }, [step, cost, coin]); + + const continueFromForm = useCallback(async () => { + setError(null); + onClearFieldError?.(); + if (await isNairaVerified()) { + setStep('confirm'); + return; + } + router.push({ + pathname: '/verify', + params: { + returnTo, + label: title, + summary: copy.verifySummary(formattedNgn), + }, + }); + }, [router, returnTo, title, copy, formattedNgn, onClearFieldError]); + + const pay = useCallback(async () => { + if (!customerRef || !refundAddress || !rate) return; + setStep('paying'); + setError(null); + onClearFieldError?.(); + try { + const { txHash } = await executeBillPayment({ + customerRef, + billCategory: category, + provider, + customerId, + amountNgn: ngn, + rate, + coin, + refundAddress, + deploy: wallet.deploy, + onOrderReserved: (order, amountStableCoin) => { + setOrderId(order.id); + setSpent(amountStableCoin); + }, + onBeforePasskey: () => setWaitingForPasskey(true), + onAfterPasskey: () => setWaitingForPasskey(false), + }); + setPayHash(txHash); + } catch (err) { + setWaitingForPasskey(false); + // Back to the confirmation with the order intact and no red error: the + // user simply chose not to authorise, and no crypto left the wallet. + if (isPasskeyCancellation(err)) { + setError(null); + setStep('confirm'); + return; + } + if (err instanceof NotEnoughToSend) { + const noun = copy.insufficientBalanceNoun ?? 'top-up'; + setError( + `This wallet holds ${err.available.toLocaleString('en-US', { maximumFractionDigits: 7 })} ${code}, less than this ${noun} needs.`, + ); + } else { + setError(errorMessage(err)); + } + setStep('confirm'); + } + }, [ + customerRef, + refundAddress, + rate, + category, + provider, + customerId, + ngn, + coin, + wallet.deploy, + copy.insufficientBalanceNoun, + code, + onClearFieldError, + ]); + + const canContinue = canContinueBillForm({ + customerValid, + amountNgn: ngn, + rateState, + }); + const activeError = error ?? fieldError; + const stepNumber = step === 'form' ? 1 : 2; + const formattedSpent = `${(spent ?? cost).toFixed(coin === 'xlm' ? 4 : 2)} ${code}`; + + return ( + + + + {!mainnetOnly && (step === 'form' || step === 'confirm') ? ( + + + + + + {stepNumber} / 2 + + ) : null} + + + + + {mainnetOnly ? ( + + MAINNET ONLY + {copy.mainnetTitle} + {copy.mainnetHint} + + ) : null} + + {activeError ? ( + + {activeError} + + ) : null} + + {/* ── b1 · form ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'form' && ( + <> + {children} + + {presetAmountNgn === null ? ( + <> + AMOUNT + + ₦ + setAmountNGN(t.replace(/[^\d]/g, ''))} + keyboardType="number-pad" + placeholder="0" + placeholderTextColor={colors.textMuted} + /> + + + {quickAmounts.map((q) => ( + setAmountNGN(String(q))} + style={styles.quickChip} + > + ₦{q.toLocaleString('en-NG')} + + ))} + + + ) : null} + + + {(['xlm', 'usdc'] as NairaCoin[]).map((c) => ( + setCoin(c)} + style={[styles.segmentItem, coin === c && styles.segmentActive]} + > + + {c.toUpperCase()} + + + ))} + + + + + Costs + + {rateState === 'ready' && cost > 0 + ? `${cost.toFixed(coin === 'xlm' ? 4 : 2)} ${code}` + : rateState === 'loading' + ? 'Checking…' + : '—'} + + + {rateState === 'failed' ? ( + void loadRate()}> + Rate unavailable — try again + + ) : null} + + + void continueFromForm()} + disabled={!canContinue} + style={({ pressed }) => [ + styles.primaryBtn, + !canContinue && styles.disabled, + pressed && styles.pressed, + ]} + > + Continue + + + )} + + {/* ── b2 · confirm ──────────────────────────────────────────────── */} + {!mainnetOnly && step === 'confirm' && ( + <> + + {copy.confirmQuestion(formattedNgn)} + + {cost.toFixed(coin === 'xlm' ? 4 : 2)} {code} + + + + + {copy.confirmRows.map((r) => ( + + {r.label} + {r.value} + + ))} + + Paid from + {code} balance + + + Network fee + Sponsored + + {veilFee ? ( + + Veil fee + + {veilFee.amount} {veilFee.assetCode} + + + ) : null} + + + + {copy.refundNote} + + + void pay()} /> + Then confirm with your passkey + + setStep('form')}> + Back + + + )} + + {/* ── b3 · passkey / in flight ──────────────────────────────────── */} + {!mainnetOnly && step === 'paying' && ( + <> + + + {waitingForPasskey ? 'Confirm with your passkey' : copy.payingTitle} + + + {waitingForPasskey + ? 'Face ID, fingerprint or device PIN, whichever this phone uses.' + : copy.payingHint(formattedNgn)} + + + + + + {copy.payingSummaryLabel} + {copy.payingSummaryValue(formattedNgn)} + + + You spend + {formattedSpent} + + + + + + + {waitingForPasskey ? 'Waiting for your passkey…' : copy.payingWaitLabel} + + + + )} + + {/* ── b4 · done ─────────────────────────────────────────────────── */} + {!mainnetOnly && step === 'done' && ( + <> + + {copy.doneTitle} + {copy.doneHint(formattedNgn)} + + + + {copy.doneRows.map((r) => ( + + {r.label} + {r.value} + + ))} + + You spent + {formattedSpent} + + {orderId ? ( + + Reference + {orderId.slice(0, 8).toUpperCase()} + + ) : null} + + + { + setStep('form'); + setAmountNGN(''); + setOrderId(null); + setPayHash(null); + setSpent(null); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + {copy.doneAgainLabel ?? 'Buy again'} + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Done + + + )} + + {/* ── b5 · failed, refunding ────────────────────────────────────── */} + {!mainnetOnly && step === 'failed' && ( + <> + + Payment failed + {copy.failedHint(formattedSpent)} + + + + + {copy.failedReasonLabel} + — + + + Refund on its way back + Usually a few minutes + + + + + Nothing for you to do. It returns to this wallet and will show in your activity. + + + { + setStep('form'); + setOrderId(null); + setPayHash(null); + setError(null); + onClearFieldError?.(); + void loadRate(); + }} + style={({ pressed }) => [styles.primaryBtn, pressed && styles.pressed]} + > + Try again + + router.back()} + style={({ pressed }) => [styles.secondaryBtn, pressed && styles.pressed]} + > + Back to home + + {orderId ? ( + Ref {orderId.slice(0, 8).toUpperCase()} + ) : null} + + )} + + + + ); +} + +export function createBillFlowStyles(colors: ThemeColors) { + return StyleSheet.create({ + screen: { flex: 1, backgroundColor: colors.background }, + flex: { flex: 1 }, + header: { paddingHorizontal: 20, paddingTop: 16 }, + body: { padding: 20, paddingBottom: 60, gap: 16 }, + + progressRow: { flexDirection: 'row', alignItems: 'center', gap: 10, paddingVertical: 10 }, + progressTrack: { + flex: 1, + flexDirection: 'row', + height: 3, + borderRadius: 2, + backgroundColor: colors.border, + }, + progressFill: { backgroundColor: colors.accent, borderRadius: 2 }, + progressLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 12 }, + + card: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + gap: 8, + }, + noteCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 16, + padding: 16, + backgroundColor: colors.surface, + }, + errorCard: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.danger, + borderRadius: 12, + padding: 12, + backgroundColor: colors.dangerSurface, + }, + errorText: { color: colors.danger, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 }, + + eyebrow: { + color: colors.textMuted, + fontFamily: fontFamily.accent, + fontSize: 11, + letterSpacing: 0.8, + }, + question: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 20 }, + hint: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, lineHeight: 19 }, + centeredHint: { + color: colors.textMuted, + fontFamily: fontFamily.body, + fontSize: 13, + textAlign: 'center', + }, + money: { color: colors.textStrong, fontFamily: fontFamily.heading, fontSize: 28 }, + + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 4, + gap: 12, + }, + rowLabel: { color: colors.textMuted, fontFamily: fontFamily.body, fontSize: 13, flexShrink: 1 }, + rowValue: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 13 }, + rowValueStrong: { + color: colors.textPrimary, + fontFamily: fontFamily.bodySemiBold, + fontSize: 15, + }, + + field: { gap: 6 }, + label: { + color: colors.textMuted, + fontFamily: fontFamily.accent, + fontSize: 11, + letterSpacing: 0.8, + }, + phoneRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingTop: 8, + paddingBottom: 10, + borderBottomWidth: 1.5, + borderBottomColor: colors.accent, + }, + phoneInput: { + flex: 1, + minWidth: 0, + padding: 0, + color: colors.textStrong, + fontFamily: fontFamily.address, + fontSize: 21, + letterSpacing: 0.8, + }, + contactBtn: { + width: 36, + height: 36, + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + input: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + color: colors.textPrimary, + fontFamily: fontFamily.body, + fontSize: 16, + }, + + networkRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + networkChip: { + paddingHorizontal: 14, + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + networkChipActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + networkText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 13 }, + networkTextActive: { color: colors.onAccent }, + + amountRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + currency: { color: colors.textMuted, fontFamily: fontFamily.heading, fontSize: 30 }, + amountInput: { + flex: 1, + color: colors.textStrong, + fontFamily: fontFamily.heading, + fontSize: 34, + paddingVertical: 4, + }, + + quickRow: { flexDirection: 'row', gap: 8, flexWrap: 'wrap' }, + quickChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + quickText: { color: colors.textPrimary, fontFamily: fontFamily.body, fontSize: 13 }, + + segment: { flexDirection: 'row', gap: 8 }, + segmentItem: { + flex: 1, + alignItems: 'center', + paddingVertical: 10, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + }, + segmentActive: { backgroundColor: colors.accent, borderColor: colors.accent }, + segmentText: { color: colors.textMuted, fontFamily: fontFamily.bodySemiBold, fontSize: 14 }, + segmentTextActive: { color: colors.onAccent }, + + waitRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + justifyContent: 'center', + paddingVertical: 8, + }, + + primaryBtn: { + backgroundColor: colors.accent, + borderRadius: 999, + paddingVertical: 16, + alignItems: 'center', + }, + primaryText: { color: colors.onAccent, fontFamily: fontFamily.bodySemiBold, fontSize: 16 }, + secondaryBtn: { + borderWidth: StyleSheet.hairlineWidth, + borderColor: colors.border, + borderRadius: 999, + paddingVertical: 16, + alignItems: 'center', + }, + secondaryText: { color: colors.textPrimary, fontFamily: fontFamily.bodySemiBold, fontSize: 16 }, + linkText: { + color: colors.accent, + fontFamily: fontFamily.body, + fontSize: 13, + paddingTop: 6, + textAlign: 'center', + }, + + disabled: { opacity: 0.4 }, + pressed: { opacity: 0.85 }, + }); +} diff --git a/frontend/mobile/lib/__tests__/billFlow.test.ts b/frontend/mobile/lib/__tests__/billFlow.test.ts index 822ca6e03..866f3d8bb 100644 --- a/frontend/mobile/lib/__tests__/billFlow.test.ts +++ b/frontend/mobile/lib/__tests__/billFlow.test.ts @@ -1,3 +1,18 @@ +/** + * Tests for the shared bill payment flow (`lib/billFlow.ts`) and its category + * seam (`app/airtime.tsx` + a second category plugged into the same seam). + * + * Pins the financial-safety invariants: + * - `payBill` reserves the order first; if reservation fails, `spendAsset` + * is never called. + * - `spendAsset` sends the exact 7-decimal `amountStableCoin` locked in the + * order to `order.wallet`. + * - `classifyBillPollStatus` maps terminal statuses cleanly and leaves + * in-flight statuses pending. + * - Both airtime and a second bill category (`electricity`) plug into the + * shared seam without duplicating the payment machinery. + */ + import { canContinueBillForm, classifyBillPollStatus, @@ -6,158 +21,251 @@ import { formatNaira, lockBillAmountStableCoin, parseBillAmountNgn, + type BillCategoryConfig, } from '../billFlow'; -import type { BillOrder } from '../onramp'; - -describe('billFlow', () => { - describe('formatting & calculation helpers', () => { - it('formats naira amounts with commas', () => { - expect(formatNaira(1000)).toBe('₦1,000'); - expect(formatNaira(5500.5)).toBe('₦5,500.5'); - expect(formatNaira(0)).toBe('₦0'); - }); +import { + buildAirtimeCategoryConfig, + detectNetwork, + isValidNigerianPhone, +} from '../../app/airtime'; - it('parses naira input stripping non-digits', () => { - expect(parseBillAmountNgn('₦5,000')).toBe(5000); - expect(parseBillAmountNgn('10 000')).toBe(10000); - expect(parseBillAmountNgn('')).toBe(0); - expect(parseBillAmountNgn('abc')).toBe(0); +const DEPOSIT_WALLET = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7'; +const REFUND_WALLET = 'GBDEVU63Y6NTHJQQZIKVTC23NWLQVP3WJ2RI2OTSJTNYOIGICST6DUXR'; +const USDC_MAINNET_ISSUER = 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN'; + +describe('shared bill flow math and form gating', () => { + it('parses NGN input digits and computes crypto cost from the per-coin rate', () => { + expect(parseBillAmountNgn('₦1,500')).toBe(1500); + expect(parseBillAmountNgn('')).toBe(0); + expect(computeBillCryptoCost(1500, 1500)).toBe(1); + expect(computeBillCryptoCost(1500, null)).toBe(0); + expect(lockBillAmountStableCoin(1000, 1500)).toBe(0.6666667); + expect(formatNaira(1500)).toBe('₦1,500'); + }); + + it('requires valid customer details, a positive amount, and a ready rate before continuing', () => { + expect( + canContinueBillForm({ customerValid: true, amountNgn: 500, rateState: 'ready' }), + ).toBe(true); + expect( + canContinueBillForm({ customerValid: false, amountNgn: 500, rateState: 'ready' }), + ).toBe(false); + expect( + canContinueBillForm({ customerValid: true, amountNgn: 0, rateState: 'ready' }), + ).toBe(false); + expect( + canContinueBillForm({ customerValid: true, amountNgn: 500, rateState: 'loading' }), + ).toBe(false); + }); + + it('classifies poll statuses into done, failed, or pending', () => { + expect(classifyBillPollStatus('completed')).toBe('done'); + expect(classifyBillPollStatus('DELIVERED')).toBe('done'); + expect(classifyBillPollStatus('success')).toBe('done'); + expect(classifyBillPollStatus('failed')).toBe('failed'); + expect(classifyBillPollStatus('refunded')).toBe('failed'); + expect(classifyBillPollStatus('reversed')).toBe('failed'); + expect(classifyBillPollStatus('cancelled')).toBe('failed'); + expect(classifyBillPollStatus('pending')).toBe('pending'); + expect(classifyBillPollStatus('processing')).toBe('pending'); + }); +}); + +describe('executeBillPayment — reserve first, spend second', () => { + it('reserves the bill order before spending the locked amount to the order wallet', async () => { + const callOrder: string[] = []; + const payBillFn = jest.fn(async () => { + callOrder.push('payBill'); + return { + id: 'bill_order_101', + customerRef: 'veil_4sjjjzwamdoe', + wallet: DEPOSIT_WALLET, + status: 'pending', + }; + }); + const spendAssetFn = jest.fn(async () => { + callOrder.push('spendAsset'); + return 'tx_hash_airtime_101'; }); - it('computes crypto cost from rate', () => { - expect(computeBillCryptoCost(1500, 1500)).toBe(1); - expect(computeBillCryptoCost(3000, 1500)).toBe(2); - expect(computeBillCryptoCost(0, 1500)).toBe(0); - expect(computeBillCryptoCost(1500, null)).toBe(0); - expect(computeBillCryptoCost(1500, 0)).toBe(0); + const result = await executeBillPayment({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: 'airtime', + provider: 'MTN', + customerId: '08035124471', + amountNgn: 1000, + rate: 1500, + coin: 'usdc', + refundAddress: REFUND_WALLET, + deploy: jest.fn(), + payBillFn, + spendAssetFn, }); - it('locks crypto amount to 7 decimals for Linq manual deposit match', () => { - // 1000 / 1450 = 0.6896551724... - const locked = lockBillAmountStableCoin(1000, 1450); - expect(locked).toBe(0.6896552); - expect(String(locked).split('.')[1]?.length ?? 0).toBeLessThanOrEqual(7); + expect(callOrder).toEqual(['payBill', 'spendAsset']); + expect(result.amountStableCoin).toBe(0.6666667); + expect(result.txHash).toBe('tx_hash_airtime_101'); + + expect(payBillFn).toHaveBeenCalledWith({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: 'airtime', + provider: 'MTN', + customerId: '08035124471', + amountNgn: 1000, + amountStableCoin: 0.6666667, + rate: 1500, + coin: 'usdc', + refundAddress: REFUND_WALLET, }); - it('evaluates canContinueBillForm accurately', () => { - expect( - canContinueBillForm({ customerValid: true, amountNgn: 1000, rateState: 'ready' }), - ).toBe(true); - expect( - canContinueBillForm({ customerValid: false, amountNgn: 1000, rateState: 'ready' }), - ).toBe(false); - expect( - canContinueBillForm({ customerValid: true, amountNgn: 0, rateState: 'ready' }), - ).toBe(false); - expect( - canContinueBillForm({ customerValid: true, amountNgn: 1000, rateState: 'loading' }), - ).toBe(false); - expect( - canContinueBillForm({ customerValid: true, amountNgn: 1000, rateState: 'failed' }), - ).toBe(false); + expect(spendAssetFn).toHaveBeenCalledWith({ + to: DEPOSIT_WALLET, + amount: '0.6666667', + asset: { code: 'USDC', issuer: USDC_MAINNET_ISSUER }, + deploy: expect.any(Function), }); + }); - it('classifies poll statuses', () => { - expect(classifyBillPollStatus('completed')).toBe('done'); - expect(classifyBillPollStatus('complete')).toBe('done'); - expect(classifyBillPollStatus('success')).toBe('done'); - expect(classifyBillPollStatus('delivered')).toBe('done'); - expect(classifyBillPollStatus('failed')).toBe('failed'); - expect(classifyBillPollStatus('refunded')).toBe('failed'); - expect(classifyBillPollStatus('reversed')).toBe('failed'); - expect(classifyBillPollStatus('cancelled')).toBe('failed'); - expect(classifyBillPollStatus('pending')).toBe('pending'); - expect(classifyBillPollStatus('initiated')).toBe('pending'); + it('never spends crypto when reserving the order fails', async () => { + const payBillFn = jest.fn(async () => { + throw new Error('Service temporarily unavailable'); }); + const spendAssetFn = jest.fn(); + + await expect( + executeBillPayment({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: 'airtime', + provider: 'MTN', + customerId: '08035124471', + amountNgn: 1000, + rate: 1500, + coin: 'xlm', + refundAddress: REFUND_WALLET, + deploy: jest.fn(), + payBillFn, + spendAssetFn, + }), + ).rejects.toThrow('Service temporarily unavailable'); + + expect(spendAssetFn).not.toHaveBeenCalled(); }); +}); - describe('executeBillPayment invariants', () => { - it('reserves order first, then spends the exact locked stablecoin amount to order.wallet', async () => { - const mockOrder: BillOrder = { - id: 'ord_res_123', - customerRef: 'usr_test', - wallet: 'GDXYZ_LINQ_DEPOSIT_WALLET', - status: 'initiated', - }; +describe('airtime category on the seam', () => { + it('detects Nigerian mobile networks from 4-digit prefixes and validates 11-digit numbers', () => { + expect(detectNetwork('08035124471')).toBe('MTN'); + expect(detectNetwork('08021234567')).toBe('AIRTEL'); + expect(detectNetwork('08051234567')).toBe('GLO'); + expect(detectNetwork('08091234567')).toBe('9MOBILE'); + expect(detectNetwork('080')).toBeNull(); - const mockPayBill = jest.fn().mockResolvedValue(mockOrder); - const mockSpendAsset = jest.fn().mockResolvedValue('0x_tx_hash_abc'); - const mockDeploy = jest.fn(); + expect(isValidNigerianPhone('08035124471')).toBe(true); + expect(isValidNigerianPhone('8035124471')).toBe(false); + expect(isValidNigerianPhone('0803512')).toBe(false); + }); - const onOrderReserved = jest.fn(); - const onBeforePasskey = jest.fn(); - const onAfterPasskey = jest.fn(); + it('builds the airtime category configuration without leaking flow state', () => { + const cfg = buildAirtimeCategoryConfig({ provider: 'AIRTEL', phone: '0802 123 4567' }); + expect(cfg.category).toBe('airtime'); + expect(cfg.provider).toBe('AIRTEL'); + expect(cfg.customerId).toBe('08021234567'); + expect(cfg.customerValid).toBe(true); + expect(cfg.copy.confirmQuestion('₦1,000')).toBe('₦1,000 airtime to 08021234567 costs'); + expect(cfg.copy.confirmRows).toEqual([ + { label: 'Network', value: 'AIRTEL' }, + { label: 'Phone', value: '08021234567' }, + ]); + }); +}); - const result = await executeBillPayment({ - customerRef: 'usr_test', - billCategory: 'electricity', - provider: 'IKEDC', - customerId: '014256789012', - amountNgn: 5000, - rate: 1450, - coin: 'usdc', - refundAddress: 'G_REFUND_WALLET', - deploy: mockDeploy, - onOrderReserved, - onBeforePasskey, - onAfterPasskey, - payBillFn: mockPayBill, - spendAssetFn: mockSpendAsset, - }); - - // 1. payBill called with parameters - expect(mockPayBill).toHaveBeenCalledWith({ - customerRef: 'usr_test', +describe('adding a second category without copying the flow', () => { + function buildElectricityCategoryConfig(params: { + disco: string; + meterNumber: string; + }): BillCategoryConfig { + const meterDigits = params.meterNumber.replace(/\D/g, ''); + return { + category: 'electricity', + title: 'Electricity', + returnTo: '/electricity', + provider: params.disco, + customerId: meterDigits, + customerValid: meterDigits.length >= 11 && meterDigits.length <= 13, + quickAmounts: [2000, 5000, 10000, 20000], + copy: { + mainnetTitle: 'Switch to mainnet to buy electricity', + mainnetHint: 'Prepaid tokens are vended against real crypto on mainnet only.', + insufficientBalanceNoun: 'purchase', + verifySummary: (amt) => `Electricity · ${amt} to meter ${meterDigits}`, + confirmQuestion: (amt) => `${amt} electricity for ${meterDigits} costs`, + confirmRows: [ + { label: 'Distributor', value: params.disco }, + { label: 'Meter', value: meterDigits }, + ], + refundNote: `If ${params.disco} cannot issue the token, the full amount returns to this wallet.`, + payingTitle: 'Purchasing your token', + payingHint: (amt) => `${params.disco} is issuing ${amt} for meter ${meterDigits}.`, + payingWaitLabel: 'Waiting for the meter token…', + payingSummaryLabel: 'Electricity', + payingSummaryValue: (amt) => `${amt} · ${params.disco} ${meterDigits}`, + doneTitle: 'Token issued', + doneHint: (amt) => `${amt} prepaid units purchased for meter ${meterDigits}.`, + doneRows: [ + { label: 'Distributor', value: params.disco }, + { label: 'Meter', value: meterDigits }, + ], + failedHint: (spentStr) => + `Your ${spentStr} is being returned to this wallet. No token was issued for ${meterDigits}.`, + failedReasonLabel: `${params.disco} didn't issue the token`, + }, + }; + } + + it('executes a second bill category through the exact same payment flow', async () => { + const elec = buildElectricityCategoryConfig({ + disco: 'IKEDC', + meterNumber: '45012345678', + }); + expect(elec.customerValid).toBe(true); + + const payBillFn = jest.fn(async () => ({ + id: 'bill_elec_202', + customerRef: 'veil_4sjjjzwamdoe', + wallet: DEPOSIT_WALLET, + status: 'pending', + })); + const spendAssetFn = jest.fn(async () => 'tx_hash_elec_202'); + + const res = await executeBillPayment({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: elec.category, + provider: elec.provider, + customerId: elec.customerId, + amountNgn: 5000, + rate: 250, + coin: 'xlm', + refundAddress: REFUND_WALLET, + deploy: jest.fn(), + payBillFn, + spendAssetFn, + }); + + expect(res.amountStableCoin).toBe(20); + expect(payBillFn).toHaveBeenCalledWith( + expect.objectContaining({ billCategory: 'electricity', provider: 'IKEDC', - customerId: '014256789012', + customerId: '45012345678', amountNgn: 5000, - amountStableCoin: 3.4482759, - rate: 1450, - coin: 'usdc', - refundAddress: 'G_REFUND_WALLET', - }); - - // Callback notified of reservation before spend - expect(onOrderReserved).toHaveBeenCalledWith(mockOrder, 3.4482759); - - // 2. spendAsset called with order's wallet and exact locked amount - expect(mockSpendAsset).toHaveBeenCalledWith( - expect.objectContaining({ - to: 'GDXYZ_LINQ_DEPOSIT_WALLET', - amount: '3.4482759', - }), - ); - - expect(onBeforePasskey).toHaveBeenCalled(); - expect(onAfterPasskey).toHaveBeenCalled(); - expect(result.order).toBe(mockOrder); - expect(result.amountStableCoin).toBe(3.4482759); - expect(result.txHash).toBe('0x_tx_hash_abc'); - }); - - it('does NOT call spendAsset if order reservation fails', async () => { - const mockPayBill = jest.fn().mockRejectedValue(new Error('Reservation failed')); - const mockSpendAsset = jest.fn(); - const mockDeploy = jest.fn(); - - await expect( - executeBillPayment({ - customerRef: 'usr_test', - billCategory: 'electricity', - provider: 'IKEDC', - customerId: '014256789012', - amountNgn: 5000, - rate: 1450, - coin: 'usdc', - refundAddress: 'G_REFUND_WALLET', - deploy: mockDeploy, - payBillFn: mockPayBill, - spendAssetFn: mockSpendAsset, - }), - ).rejects.toThrow('Reservation failed'); - - expect(mockSpendAsset).not.toHaveBeenCalled(); + amountStableCoin: 20, + coin: 'xlm', + }), + ); + expect(spendAssetFn).toHaveBeenCalledWith({ + to: DEPOSIT_WALLET, + amount: '20', + deploy: expect.any(Function), }); }); }); diff --git a/frontend/mobile/lib/billFlow.ts b/frontend/mobile/lib/billFlow.ts index ccd9e09a2..3787f4c87 100644 --- a/frontend/mobile/lib/billFlow.ts +++ b/frontend/mobile/lib/billFlow.ts @@ -1,16 +1,50 @@ /** - * Shared bill payment machinery and per-category seam from V296. + * Shared bill payment machinery and the per-category seam. + * + * Every Nigerian bill category in wraith (`airtime`, `data`, `electricity`, + * `cabletv`, `betting`) moves money the same way: * - * Every Nigerian bill category (`airtime`, `data`, `electricity`, `cabletv`, `betting`) - * moves money the same way: * 1. Gate on mainnet (Linq has no sandbox; every call moves real money). - * 2. Resolve customerRef and classic G… refundAddress (fee-payer account). - * 3. Quote live NGN rate per coin (usdc / xlm). - * 4. Gate confirm step on one-time NIN check (`isNairaVerified`). - * 5. Preview platform fee on confirm step (`previewSpendFee`) before passkey prompt. - * 6. Reserve order first (`payBill`) to lock rate and get deposit address, then spend - * exact locked amount (`spendAsset`). - * 7. Poll `getBillStatus` with bounded retry budget and distinguish `unconfirmed` from `failed`. + * 2. Resolve `customerRef` and the classic `G…` `refundAddress` (the + * fee-payer account, because a classic refund payment cannot name a + * `C…` smart wallet contract). + * 3. Quote the live NGN rate per coin (`usdc` or `xlm`). + * 4. Gate the confirm step on the one-time NIN check (`isNairaVerified`), + * bouncing unverified users to `/verify` with a return path and summary. + * 5. Preview any platform fee on the confirm step (`previewSpendFee`) so the + * user sees it *before* the passkey prompt, never in history afterwards. + * 6. Reserve the bill order (`payBill`) to lock the rate and obtain the + * deposit address, and only then spend the exact locked amount from the + * wallet (`spendAsset`). + * 7. Poll `getBillStatus` until the vend settles (`done`) or is rejected and + * refunded (`failed`), while keeping transient poll errors silent to the + * user so a network blip after payment has left never reads as a failed + * payment. + * + * ## The per-category seam + * + * **What belongs on the shared side (`lib/billFlow.ts` + `components/BillFlow.tsx`):** + * - Network gating (`mainnet` only) + * - `customerRef` and `refundAddress` resolution + * - Per-coin rate loading (`xlm` / `usdc`), retry, and crypto cost math + * - NGN amount input, quick-amount chips, and asset segment selector + * - One-time NIN verification check and `/verify` redirect + * - Pre-passkey `previewSpendFee` on the confirm step + * - Order reservation (`payBill`) followed by `spendAsset` using the order's + * locked `amountStableCoin` and `wallet` + * - Status polling (`getBillStatus`) and step transitions + * (`form` → `confirm` → `paying` → `done` / `failed`) + * + * **What belongs on the category side (e.g. `app/airtime.tsx`):** + * - `category`: the `BillCategory` key sent to `/ngn/bills` + * - `provider`: the selected biller / network code (e.g. `'MTN'`, `'IKEDC'`) + * - `customerId`: the normalized recipient identifier (phone digits, meter + * number, smartcard/IUC number, or betting account ID) + * - `customerValid`: category-specific validation of `provider` + `customerId` + * - Category-specific form controls and helpers (for airtime: prefix-based + * network auto-detection, network override chips, phone input, and contact + * picker; for electricity/TV: meter or smartcard input and lookup) + * - `copy`: category-specific labels, confirmation rows, and status prose */ import type { DeployFn } from './deployWallet'; @@ -32,35 +66,71 @@ export interface BillSummaryRow { value: string; } +/** + * Category-supplied copy and summary rows across the five bill steps. + * + * Keeping copy on the category side of the seam prevents airtime nouns + * ("airtime", "top-up", "phone") from leaking into the shared flow while + * letting every category share the same layout and state machine. + */ export interface BillCategoryCopy { + /** Heading shown when the wallet is on testnet. */ mainnetTitle: string; + /** Explanation shown under `mainnetTitle` when on testnet. */ mainnetHint: string; + /** Noun used in `NotEnoughToSend` errors (e.g. `'top-up'`, `'payment'`). */ insufficientBalanceNoun?: string; + /** Summary passed to `/verify` when the customer has not completed NIN KYC. */ verifySummary: (amountFormatted: string) => string; + /** Headline above the crypto cost on the confirm step. */ confirmQuestion: (amountFormatted: string) => string; + /** Category-specific detail rows shown on the confirm card before "Paid from". */ confirmRows: BillSummaryRow[]; + /** Refund reassurance note on the confirm step. */ refundNote: string; + /** Headline on the paying step after the passkey prompt completes. */ payingTitle: string; + /** Subtitle on the paying step after the passkey prompt completes. */ payingHint: (amountFormatted: string) => string; + /** Spinner caption while polling `getBillStatus`. */ payingWaitLabel: string; + /** Left-hand label on the paying summary row (e.g. `'Airtime'`, `'Electricity'`). */ payingSummaryLabel: string; + /** Right-hand value on the paying summary row. */ payingSummaryValue: (amountFormatted: string) => string; + /** Headline on the `done` step. */ doneTitle: string; + /** Subtitle on the `done` step. */ doneHint: (amountFormatted: string) => string; + /** Category-specific detail rows on the `done` step before "You spent". */ doneRows: BillSummaryRow[]; + /** Primary button label on the `done` step (defaults to `'Buy again'`). */ doneAgainLabel?: string; + /** Subtitle on the `failed` step explaining that the crypto is being returned. */ failedHint: (spentFormatted: string) => string; + /** Left-hand label on the `failed` card explaining what the provider rejected. */ failedReasonLabel: string; } +/** + * The complete per-category configuration passed across the seam into `BillFlow`. + */ export interface BillCategoryConfig { + /** Backend category key (`'airtime' | 'data' | 'electricity' | 'cabletv' | 'betting'`). */ category: BillCategory; + /** Header title for `FlowHeader` and `/verify` return label. */ title: string; + /** Route path `/verify` returns to after one-time NIN verification. */ returnTo: string; + /** Selected biller/network identifier sent to `payBill`. */ provider: string; + /** Normalized customer identifier (phone digits, meter number, etc.) sent to `payBill`. */ customerId: string; + /** Whether the category-specific fields are valid and ready to continue. */ customerValid: boolean; + /** Quick NGN amount chips shown below the amount input. */ quickAmounts?: readonly number[]; + /** Category-specific copy and summary rows for each step. */ copy: BillCategoryCopy; } diff --git a/frontend/wallet/app/bills/page.tsx b/frontend/wallet/app/bills/page.tsx index f93e1b615..0c7f9cca6 100644 --- a/frontend/wallet/app/bills/page.tsx +++ b/frontend/wallet/app/bills/page.tsx @@ -19,7 +19,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useRouter } from 'next/navigation' import { Keypair } from '@stellar/stellar-sdk' -import { AccentCard, Card, PageHeader, PrimaryAction, SectionLabel } from '@/components/ui/primitives' +import { AccentCard, Card, PageHeader, PrimaryAction, SectionLabel, Tag } from '@/components/ui/primitives' import { previewBillSpendFee, sendBillPayment } from '@/lib/billSpend' import { isPasskeyCancellation } from '@/lib/passkeyOutcome' import { @@ -65,6 +65,36 @@ const PREFIXES: Record = { const NETWORKS = Object.keys(PREFIXES) +interface Service { + id: string + name: string + sub: string + icon: string + tone: 'gold' | 'teal' | 'lilac' + status: 'live' | 'soon' + /** Live services that live on another page link there; airtime is this page. */ + route?: string +} + +// What the web wallet can actually pay for today. Unshipped categories stay +// honestly marked Soon rather than looking clickable. +const SERVICES: Service[] = [ + { id: 'airtime', name: 'Airtime', sub: 'All networks', icon: '☎', tone: 'gold', status: 'live' }, + { id: 'data', name: 'Data', sub: 'Bundles', icon: '▦', tone: 'gold', status: 'soon' }, + { id: 'power', name: 'Power', sub: 'Prepaid units', icon: '⌁', tone: 'teal', status: 'soon' }, + { id: 'tv', name: 'TV', sub: 'DStv · GOtv', icon: '▤', tone: 'lilac', status: 'soon' }, + { id: 'water', name: 'Water', sub: 'State boards', icon: '◈', tone: 'teal', status: 'soon' }, + { id: 'transfer', name: 'Transfer', sub: 'To any bank', icon: '↗', tone: 'gold', status: 'live', route: '/cashout' }, + { id: 'betting', name: 'Betting', sub: 'Top up', icon: '◉', tone: 'lilac', status: 'soon' }, + { id: 'education', name: 'Education', sub: 'WAEC · JAMB', icon: '✎', tone: 'teal', status: 'soon' }, +] + +const TONE_FG = { + gold: 'var(--gold)', + teal: 'var(--teal)', + lilac: 'var(--lilac)', +} as const + function detectNetwork(phone: string): string | null { const p = phone.replace(/\D/g, '') if (p.length < 4) return null @@ -388,6 +418,60 @@ export default function BillsPage() {
+ + Pay for +
+ {SERVICES.map((s) => { + const isLive = s.status === 'live' + const commonClass = + 'bg-[rgba(255,255,255,0.04)] border rounded-[18px] px-4 py-[18px] flex flex-col gap-[5px] text-left transition-colors' + if (isLive) { + return ( + + ) + } + return ( +
+
+ + {s.icon} + + Soon +
+
{s.name}
+
+ {s.sub} +
+
+ ) + })} +
+
{mismatch && (

diff --git a/frontend/wallet/lib/__tests__/ngn.test.ts b/frontend/wallet/lib/__tests__/ngn.test.ts index e27a2558d..505553a61 100644 --- a/frontend/wallet/lib/__tests__/ngn.test.ts +++ b/frontend/wallet/lib/__tests__/ngn.test.ts @@ -361,3 +361,71 @@ describe('classifyBillStatus', () => { expect(classifyBillStatus('unknown_code')).toBe('pending') }) }) + +describe('bills on the shared naira client', () => { + const DEPOSIT_WALLET = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN7' + const REFUND_WALLET = 'GBDEVU63Y6NTHJQQZIKVTC23NWLQVP3WJ2RI2OTSJTNYOIGICST6DUXR' + + it('reserves a bill on POST /ngn/bills with the mainnet header', async () => { + respondWith({ + id: 'bill_98765432', + customerRef: 'veil_4sjjjzwamdoe', + wallet: DEPOSIT_WALLET, + status: 'pending', + }) + const { payBill } = loadModule() + + const order = await payBill({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: 'airtime', + provider: 'MTN', + customerId: '08035124471', + amountNgn: 1000, + amountStableCoin: 0.6666667, + rate: 1500, + coin: 'usdc', + refundAddress: REFUND_WALLET, + }) + + expect(order).toEqual({ + id: 'bill_98765432', + customerRef: 'veil_4sjjjzwamdoe', + wallet: DEPOSIT_WALLET, + status: 'pending', + }) + + const { url, init } = lastRequest() + expect(url).toBe(`${WRAITH}/ngn/bills`) + expect(init.method).toBe('POST') + expect((init.headers as Record)['x-network']).toBe('mainnet') + expect(JSON.parse(String(init.body))).toEqual({ + customerRef: 'veil_4sjjjzwamdoe', + billCategory: 'airtime', + provider: 'MTN', + customerId: '08035124471', + amountNgn: 1000, + amountStableCoin: 0.6666667, + rate: 1500, + coin: 'usdc', + refundAddress: REFUND_WALLET, + }) + }) + + it('queries bill status with both orderId and customerRef url-encoded', async () => { + respondWith({ + orderId: 'bill_98765432', + customerRef: 'veil_4sjjjzwamdoe', + status: 'completed', + billCategory: 'airtime', + amountNgn: 1000, + amountStableCoin: 0.6666667, + wallet: DEPOSIT_WALLET, + description: 'MTN 08035124471', + }) + const { getBillStatus } = loadModule() + + const status = await getBillStatus('veil_a b', 'bill/1') + expect(status.status).toBe('completed') + expect(lastRequest().url).toBe(`${WRAITH}/ngn/bills/bill%2F1?customerRef=veil_a%20b`) + }) +})