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}