Skip to main content

alat/modules/
virtual_account.rs

1//! Virtual accounts β€” collect inbound payments with per-payment/per-user attribution.
2//!
3//! This module models Wema's **Virtual Account API** (`/VirtualAccount`,
4//! published in the πŸ”’ approval-gated *Virtual Account* product on the **APIM
5//! Dev** gateway β€” use [`Config::apim_dev`](crate::Config::apim_dev)).
6//!
7//! # The mental model (read this first)
8//!
9//! Virtual accounts here are **not** individually created at the bank. Instead:
10//!
11//! 1. You register **one prefix** (e.g. `"9988"`) with [`create_prefix`](crate::Client::create_prefix),
12//!    pointing it at:
13//!    - a real, debitable Wema account you own β€” the **`settle_account`** (every
14//!      naira paid into any virtual account under the prefix lands here), and
15//!    - two **webhook URLs you host** (`name_enquiry_uri`, `trans_notify_uri`).
16//! 2. You **mint virtual account numbers yourself** under that prefix β€” a NUBAN is
17//!    just `prefix + a suffix you choose` (see [`compose_virtual_account_number`]).
18//!    There is no per-account API call.
19//! 3. A payer sends money to one of those numbers from **any** bank (over NIP).
20//!    Wema recognises the prefix, calls your **name-enquiry** webhook to resolve
21//!    the holder name, credits your `settle_account`, then calls your
22//!    **trans-notify** webhook with the credit details.
23//! 4. You can also pull settled inflows on demand with
24//!    [`query_transactions`](crate::Client::query_transactions).
25//!
26//! ```text
27//!  any bank ──NIP──▢ 9988-00042 (you minted) ─┐
28//!  any bank ──NIP──▢ 9988-00043 (you minted) ─┼─▢ settle_account (real Wema acct)
29//!                                              β”€β”˜    + webhook: TransNotify β†’ you
30//! ```
31//!
32//! # Attribution patterns
33//!
34//! - **Per-payment (dynamic):** mint a fresh number per invoice, show it, expect
35//!   that payment, then recycle it. The number *is* the payment reference β€”
36//!   exact attribution, no amount/time guessing. Best for one-off, fixed-amount
37//!   checkouts.
38//! - **Per-user (static):** one number per user forever. Each credit is still a
39//!   distinct [`TransNotifyRequest`] with its own `session_id` and exact amount,
40//!   so you do **not** need fuzzy amount/time matching β€” you simply credit that
41//!   user's balance per notification. Best for recurring top-ups/wallets.
42//!
43//! Either way, every credit notification carries [`TransNotifyRequest::cr_account`]
44//! (which virtual account was paid) plus the payer's account/bank β€” which is all
45//! you need to attribute the payment and, later, to refund it.
46//!
47//! # Two webhooks YOU must implement
48//!
49//! Wema calls these on the URLs you register in the prefix; the SDK gives you the
50//! request/response **types**, not a client method (you are the server here):
51//!
52//! - **Name enquiry** β€” receives [`AccountLookupRequest`], returns the holder name
53//!   ([`AccountLookupResponse`]). Must resolve any number you have minted, so
54//!   store the number→name mapping *before* you display the account to a payer.
55//! - **Transaction notify** β€” receives [`TransNotifyRequest`] (the credit), returns
56//!   [`TransNotifyResponse`] to acknowledge. **Dedupe on `session_id`** β€” webhooks
57//!   can be redelivered.
58//!
59//! # Caveats
60//! - A NUBAN is **10 digits**; the prefix consumes the leading digits, so plan the
61//!   remaining suffix space (recycle dynamic numbers; bound per-user counts).
62//! - The exact response contract for the name-enquiry webhook is **vendor-defined**
63//!   and should be confirmed with Wema β€” see [`AccountLookupResponse`].
64
65use crate::client::Client;
66use crate::error::Result;
67use serde::{Deserialize, Serialize};
68
69// ===========================================================================
70// Prefix configuration (client-side calls)
71// ===========================================================================
72
73/// Configuration for a virtual-account **prefix** β€” the unit Wema actually
74/// registers (server type: `PrefixSetup`).
75///
76/// One prefix governs an entire range of virtual account numbers and routes all
77/// of their inflows to a single settlement account, while delegating name
78/// resolution and credit notification to your webhooks.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct PrefixSetup {
82    /// A label for the prefix owner (your channel/merchant name).
83    pub user_name: String,
84    /// The leading digits shared by every virtual account in this range
85    /// (e.g. `"9988"`). All numbers you mint must start with this.
86    pub prefix: String,
87    /// Account currency (e.g. `"NGN"`).
88    pub currency: String,
89    /// Base URL of your webhook host; the enquiry/notify URIs are resolved
90    /// against it.
91    pub base_url: String,
92    /// Path/URL of your **name-enquiry** webhook (Wema β†’ you). Receives
93    /// [`AccountLookupRequest`].
94    pub name_enquiry_uri: String,
95    /// Path/URL of your **transaction-notify** webhook (Wema β†’ you). Receives
96    /// [`TransNotifyRequest`].
97    pub trans_notify_uri: String,
98    /// How Wema authenticates to your webhooks (scheme agreed with Wema).
99    pub auth_type: String,
100    /// The credential Wema presents to your webhooks under `auth_type`.
101    pub auth_key: String,
102    /// The **real, debitable Wema account** that all inflows to this prefix are
103    /// credited into. This is the "central account" you later pay out from.
104    pub settle_account: String,
105    /// Whether the prefix is active (inflows are only routed while `true`).
106    pub is_active: bool,
107}
108
109/// A settled inflow row returned by [`query_transactions`](Client::query_transactions)
110/// (server type: the items of `TransQueryResponse.transactions`).
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct VirtualAccountTransaction {
113    /// Payer's account number.
114    #[serde(rename = "originatoraccountnumber")]
115    pub originator_account_number: Option<String>,
116    /// Amount credited (the API returns this as a string).
117    pub amount: Option<String>,
118    /// Payer's name.
119    #[serde(rename = "originatorname")]
120    pub originator_name: Option<String>,
121    /// Transfer narration.
122    pub narration: Option<String>,
123    /// Name on the credited virtual account.
124    #[serde(rename = "craccountname")]
125    pub cr_account_name: Option<String>,
126    /// Payment reference.
127    #[serde(rename = "paymentreference")]
128    pub payment_reference: Option<String>,
129    /// Payer's bank name.
130    #[serde(rename = "bankname")]
131    pub bank_name: Option<String>,
132    /// NIP session id (globally unique per credit β€” use as an idempotency key).
133    #[serde(rename = "sessionid")]
134    pub session_id: Option<String>,
135    /// The **virtual account number** that was credited.
136    #[serde(rename = "craccount")]
137    pub cr_account: Option<String>,
138    /// Payer's bank code.
139    #[serde(rename = "bankcode")]
140    pub bank_code: Option<String>,
141    /// When the inflow was requested.
142    #[serde(rename = "requestdate")]
143    pub request_date: Option<String>,
144    /// Raw NIBSS response.
145    #[serde(rename = "nibssresponse")]
146    pub nibss_response: Option<String>,
147    /// Status of the onward send to your webhook.
148    #[serde(rename = "sendstatus")]
149    pub send_status: Option<String>,
150    /// Response your webhook returned.
151    #[serde(rename = "sendresponse")]
152    pub send_response: Option<String>,
153}
154
155/// Request body to query settled inflows (server type: `TransQueryRequest`).
156///
157/// Acts as a filter β€” narrow by session id, credited virtual account, amount,
158/// and/or transaction date depending on what you have.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct TransQueryRequest {
161    /// NIP session id to look up (if known).
162    #[serde(rename = "sessionid")]
163    pub session_id: String,
164    /// The virtual account number to filter by.
165    #[serde(rename = "craccount")]
166    pub cr_account: String,
167    /// Amount to filter by.
168    pub amount: f64,
169    /// Transaction date to filter by.
170    #[serde(rename = "txndate")]
171    pub txn_date: String,
172}
173
174/// Response from [`query_transactions`](Client::query_transactions)
175/// (server type: `TransQueryResponse`).
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct TransQueryResponse {
178    /// Status code string for the query itself.
179    pub status: Option<String>,
180    /// Human-readable status description.
181    pub status_desc: Option<String>,
182    /// The matched inflows.
183    #[serde(default)]
184    pub transactions: Vec<VirtualAccountTransaction>,
185}
186
187// ===========================================================================
188// Webhook payloads (YOU implement these endpoints β€” types only)
189// ===========================================================================
190
191/// Payload your **name-enquiry** webhook receives (server type:
192/// `AccountLookupRequest`).
193///
194/// Wema sends this when a payer performs a name enquiry on one of your virtual
195/// accounts. Resolve `account_number` to the holder name and reply with an
196/// [`AccountLookupResponse`].
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct AccountLookupRequest {
199    /// The virtual account number being looked up.
200    #[serde(rename = "accountnumber")]
201    pub account_number: String,
202    /// The bank code (Wema's) the lookup is scoped to.
203    #[serde(rename = "bankcode")]
204    pub bank_code: String,
205}
206
207/// Suggested reply from your **name-enquiry** webhook.
208///
209/// > ⚠️ The portal does not publish the exact response schema for this
210/// > vendor-hosted endpoint β€” the only hard requirement is that you return the
211/// > **account holder name**. This struct is a sensible, documented default;
212/// > confirm the precise field names/shape with Wema during onboarding and adjust
213/// > if needed.
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct AccountLookupResponse {
216    /// The resolved account holder name to display to the payer.
217    #[serde(rename = "accountname")]
218    pub account_name: String,
219    /// Echo of the looked-up account number (optional).
220    #[serde(rename = "accountnumber", skip_serializing_if = "Option::is_none")]
221    pub account_number: Option<String>,
222    /// Optional status string (e.g. `"00"`/`"success"`), per your agreement with Wema.
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub status: Option<String>,
225}
226
227/// Payload your **transaction-notify** webhook receives when a virtual account is
228/// credited (server type: `TransNotifyRequest`).
229///
230/// This single payload carries everything needed to (a) attribute the payment
231/// via [`cr_account`](Self::cr_account) and (b) later refund the payer via
232/// [`originator_account_number`](Self::originator_account_number) +
233/// [`bank_code`](Self::bank_code). **Dedupe on [`session_id`](Self::session_id).**
234#[derive(Debug, Clone, Serialize, Deserialize)]
235pub struct TransNotifyRequest {
236    /// Payer's account number (use for refunds).
237    #[serde(rename = "originatoraccountnumber")]
238    pub originator_account_number: String,
239    /// Amount credited (the API sends this as a string).
240    pub amount: String,
241    /// Currency (e.g. `"NGN"`).
242    pub currency: String,
243    /// Payer's name.
244    #[serde(rename = "originatorname")]
245    pub originator_name: String,
246    /// Transfer narration.
247    pub narration: String,
248    /// Name on the credited virtual account.
249    #[serde(rename = "craccountname")]
250    pub cr_account_name: String,
251    /// Payment reference.
252    #[serde(rename = "paymentreference")]
253    pub payment_reference: String,
254    /// Secondary reference.
255    pub reference: String,
256    /// Payer's bank name.
257    #[serde(rename = "bankname")]
258    pub bank_name: String,
259    /// NIP session id β€” globally unique per credit. **Use as the idempotency key.**
260    #[serde(rename = "sessionid")]
261    pub session_id: String,
262    /// The **virtual account number that was credited** β€” your attribution key.
263    #[serde(rename = "craccount")]
264    pub cr_account: String,
265    /// Payer's bank code (use for refunds).
266    #[serde(rename = "bankcode")]
267    pub bank_code: String,
268    /// Timestamp the credit was created.
269    pub created_at: String,
270}
271
272/// Acknowledgement your **transaction-notify** webhook returns (server type:
273/// `TransNotifyResponse`).
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct TransNotifyResponse {
276    /// Your reference for the acknowledgement (wire key `ref`).
277    #[serde(rename = "ref")]
278    pub ack_ref: String,
279    /// The transaction reference you assign to the recorded credit.
280    #[serde(rename = "transactionreference")]
281    pub transaction_reference: String,
282    /// Status code you return (e.g. `"00"` for accepted).
283    pub status: String,
284    /// Human-readable status description.
285    pub status_desc: String,
286}
287
288/// Composes a virtual account number from a prefix and a suffix.
289///
290/// A NUBAN is 10 digits; this simply concatenates `prefix + suffix` (it does not
291/// enforce length, since prefix length is assigned by Wema). Mint and **store**
292/// the number before showing it to a payer, so your name-enquiry webhook can
293/// resolve it.
294///
295/// ```
296/// use alat::modules::virtual_account::compose_virtual_account_number;
297/// assert_eq!(compose_virtual_account_number("9988", "000042"), "9988000042");
298/// ```
299pub fn compose_virtual_account_number(prefix: &str, suffix: &str) -> String {
300    format!("{prefix}{suffix}")
301}
302
303impl Client {
304    /// Registers a new virtual-account **prefix** (points it at your settlement
305    /// account + webhooks). Returns the gateway's raw acknowledgement string.
306    ///
307    /// `POST /VirtualAccount/api/v1/Prefix/CreateNew`
308    pub async fn create_prefix(&self, setup: &PrefixSetup) -> Result<String> {
309        self.post_json::<_, String>("VirtualAccount/api/v1/Prefix/CreateNew", setup, &[])
310            .await
311    }
312
313    /// Updates an existing prefix's configuration (e.g. rotate webhook URLs,
314    /// toggle `is_active`). Returns the gateway's raw acknowledgement string.
315    ///
316    /// `POST /VirtualAccount/api/v1/Prefix/Modify`
317    pub async fn modify_prefix(&self, setup: &PrefixSetup) -> Result<String> {
318        self.post_json::<_, String>("VirtualAccount/api/v1/Prefix/Modify", setup, &[])
319            .await
320    }
321
322    /// Lists all prefixes registered for your channel.
323    ///
324    /// `GET /VirtualAccount/api/v1/Prefix`
325    pub async fn list_prefixes(&self) -> Result<Vec<PrefixSetup>> {
326        self.get_json::<Vec<PrefixSetup>>("VirtualAccount/api/v1/Prefix", &[], &[])
327            .await
328    }
329
330    /// Fetches the configuration of a single prefix.
331    ///
332    /// `GET /VirtualAccount/api/v1/Prefix/{prefix}`
333    pub async fn get_prefix(&self, prefix: &str) -> Result<PrefixSetup> {
334        let path = format!("VirtualAccount/api/v1/Prefix/{prefix}");
335        self.get_json::<PrefixSetup>(&path, &[], &[]).await
336    }
337
338    /// Queries settled inflows (use to reconcile against your webhook records).
339    ///
340    /// `POST /VirtualAccount/api/v1/Trans/TransQuery`
341    pub async fn query_transactions(
342        &self,
343        request: &TransQueryRequest,
344    ) -> Result<TransQueryResponse> {
345        self.post_json::<_, TransQueryResponse>("VirtualAccount/api/v1/Trans/TransQuery", request, &[])
346            .await
347    }
348}