alat/modules/transfer.rs
1//! Funds transfer — move money to any Nigerian bank account.
2//!
3//! This is the **Funds Transfer OpenAPI** product (`/funds-transfer-open`),
4//! published on the APIM Dev sandbox (see [`Config::apim_dev`](crate::Config::apim_dev)).
5//! The safe transfer choreography is:
6//!
7//! 1. [`get_bank_list`](Client::get_bank_list) — resolve the destination bank's
8//! CBN/NIP code.
9//! 2. [`verify_account`](Client::verify_account) — **name enquiry**: confirm the
10//! beneficiary name on the destination NUBAN *before* sending money. Nigerian
11//! transfers are instant and irreversible, so this step is not optional.
12//! 3. [`transfer_funds`](Client::transfer_funds) — submit the (HMAC-signed)
13//! transfer. A successful call returns a *pending* result; the final outcome
14//! is delivered to your callback URL.
15//!
16//! # Security: the `hash` header
17//!
18//! Each transfer must carry an HMAC-SHA512 signature in the `hash` header,
19//! computed with a secret salt key over the fields in this exact order:
20//!
21//! ```text
22//! transactionReference + destinationBankCode + destinationAccountNumber + sourceAccountNumber + amount
23//! ```
24//!
25//! [`transfer_funds`](Client::transfer_funds) computes and attaches it for you;
26//! [`compute_transfer_hash`](Client::compute_transfer_hash) is exposed so you can
27//! verify/reproduce it.
28
29use crate::client::Client;
30use crate::envelope::{Envelope, ServiceResponse};
31use crate::error::{Error, Result};
32use hmac::{Hmac, Mac};
33use serde::{Deserialize, Deserializer, Serialize};
34use sha2::Sha512;
35
36/// A bank in the NIBSS directory: its NIP/CBN routing code and name.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(rename_all = "camelCase")]
39pub struct Bank {
40 /// The registered name of the financial institution.
41 pub bank_name: String,
42 /// The NIP/CBN routing code (e.g. `"058"` GTBank, `"057"` Zenith, `"035"` Wema).
43 pub bank_code: String,
44}
45
46/// Wrapper that tolerates the API returning either a single bank object or an
47/// array under `result`.
48///
49/// The portal's example for `GetAllBanks` shows a *single* object, but the
50/// endpoint is logically a list; this accepts both so neither shape breaks.
51#[derive(Debug, Clone)]
52pub struct BankList(pub Vec<Bank>);
53
54impl<'de> Deserialize<'de> for BankList {
55 fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
56 where
57 D: Deserializer<'de>,
58 {
59 #[derive(Deserialize)]
60 #[serde(untagged)]
61 enum OneOrMany {
62 Many(Vec<Bank>),
63 One(Bank),
64 }
65 Ok(match OneOrMany::deserialize(deserializer)? {
66 OneOrMany::Many(v) => BankList(v),
67 OneOrMany::One(b) => BankList(vec![b]),
68 })
69 }
70}
71
72/// A single fee band returned alongside a name enquiry.
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct ChargeFee {
76 /// Fee band id.
77 pub id: i64,
78 /// Human-readable fee name.
79 pub charge_fee_name: Option<String>,
80 /// Transaction type the fee applies to (enum encoded as an integer).
81 pub transaction_type: i64,
82 /// The fee charged within this band.
83 pub charge: f64,
84 /// Lower bound of the amount band this fee applies to.
85 pub lower: f64,
86 /// Upper bound of the amount band this fee applies to.
87 pub upper: f64,
88}
89
90impl ChargeFee {
91 /// Whether `amount` falls within this band's `[lower, upper]` range (inclusive).
92 pub fn applies_to(&self, amount: f64) -> bool {
93 amount >= self.lower && amount <= self.upper
94 }
95}
96
97/// Returns the fee band that applies to `amount`, if any.
98///
99/// Useful for refund math: deduct the applicable [`ChargeFee::charge`] (plus your
100/// own service charge) from the original amount before paying the customer back.
101pub fn charge_for(bands: &[ChargeFee], amount: f64) -> Option<&ChargeFee> {
102 bands.iter().find(|b| b.applies_to(amount))
103}
104
105/// The verified beneficiary returned by a name enquiry.
106///
107/// This is the `result` of `AccountNameEnquiryEnvelope`.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(rename_all = "camelCase")]
110pub struct AccountNameEnquiry {
111 /// The destination bank's code (echoed back).
112 pub bank_code: Option<String>,
113 /// **The verified account holder name** — compare this against user intent.
114 pub account_name: String,
115 /// The queried account number (echoed back).
116 pub account_number: Option<String>,
117 /// Account currency (e.g. `"NGN"`).
118 pub currency: Option<String>,
119 /// Terms-and-conditions text, where applicable.
120 pub terms_and_conditions: Option<String>,
121 /// URL to the terms and conditions, where applicable.
122 pub terms_and_conditions_url: Option<String>,
123 /// Applicable interbank fee bands.
124 #[serde(default)]
125 pub charge_fee: Vec<ChargeFee>,
126}
127
128impl AccountNameEnquiry {
129 /// The fee band that applies to `amount`, drawn from this enquiry's
130 /// [`charge_fee`](Self::charge_fee) bands — the in-product way to learn the
131 /// interbank charge on the funds-transfer flow without a separate call.
132 pub fn charge_for(&self, amount: f64) -> Option<&ChargeFee> {
133 charge_for(&self.charge_fee, amount)
134 }
135}
136
137/// The interbank charge schedule (server type: `NIPChargesEnvelope.result`).
138///
139/// Returned by [`get_nip_charges`](Client::get_nip_charges). On the funds-transfer
140/// flow you can instead read the bands directly off a name enquiry
141/// ([`AccountNameEnquiry::charge_for`]).
142#[derive(Debug, Clone, Serialize, Deserialize)]
143#[serde(rename_all = "camelCase")]
144pub struct NipCharges {
145 /// Fee bands by amount range.
146 #[serde(default)]
147 pub charge_fees: Vec<ChargeFee>,
148 /// Terms-and-conditions text, where applicable.
149 pub terms_and_conditions: Option<String>,
150 /// URL to the terms and conditions, where applicable.
151 pub terms_and_conditions_url: Option<String>,
152}
153
154impl NipCharges {
155 /// The fee band that applies to `amount`.
156 pub fn charge_for(&self, amount: f64) -> Option<&ChargeFee> {
157 charge_for(&self.charge_fees, amount)
158 }
159}
160
161/// Request body for a transfer.
162///
163/// Server type: `OpenApiTransferRequest`. Every field except `narration` is
164/// required by the bank. `destinationBankName` is part of the schema and **must**
165/// be sent (the previous SDK omitted it).
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct TransferRequest {
169 /// Amount to transfer, in Naira.
170 ///
171 /// Modeled as `f64` to match the API's JSON number. **Caveat:** this same
172 /// value is stringified into the HMAC (`format!("{amount}")`), so its textual
173 /// form must match what Wema signs server-side. Prefer whole-Naira amounts,
174 /// and confirm the canonical amount format with Wema for production use.
175 pub amount: f64,
176 /// Narration / memo for the transfer.
177 pub narration: String,
178 /// Your unique client transaction reference (idempotency key — never reuse
179 /// it on a retry; check status instead).
180 pub transaction_reference: String,
181 /// Destination bank's NIP/CBN code (from [`get_bank_list`](Client::get_bank_list)).
182 pub destination_bank_code: String,
183 /// Destination bank's name (from [`get_bank_list`](Client::get_bank_list)).
184 pub destination_bank_name: String,
185 /// Beneficiary's 10-digit NUBAN.
186 pub destination_account_number: String,
187 /// Beneficiary name, as confirmed by [`verify_account`](Client::verify_account).
188 pub destination_account_name: String,
189 /// The 10-digit NUBAN to debit.
190 pub source_account_number: String,
191}
192
193/// The `result` of a successful transfer submission.
194///
195/// Server type: `OpenAPITransactionResponseOpenApiServiceResponse.result`.
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct TransferResult {
199 /// Status indicator. The API encodes this as a free-form JSON value (an
200 /// object in the documented example), so it is preserved as raw JSON.
201 #[serde(default)]
202 pub status: serde_json::Value,
203 /// Status message (typically reflects a *pending* state on acceptance).
204 pub message: Option<String>,
205 /// The platform's transaction reference, used to query final status.
206 pub platform_transaction_reference: Option<String>,
207}
208
209impl Client {
210 /// Computes the HMAC-SHA512 `hash` for a transfer, hex-encoded.
211 ///
212 /// Concatenation order (per ALAT docs):
213 /// `transactionReference + destinationBankCode + destinationAccountNumber +
214 /// sourceAccountNumber + amount`.
215 ///
216 /// # Errors
217 /// [`Error::Validation`] if `salt_key` is empty; [`Error::Configuration`] if
218 /// HMAC initialization fails.
219 pub fn compute_transfer_hash(&self, salt_key: &str, request: &TransferRequest) -> Result<String> {
220 if salt_key.is_empty() {
221 return Err(Error::Validation("transfer salt key must not be empty".into()));
222 }
223 type HmacSha512 = Hmac<Sha512>;
224 let data = format!(
225 "{}{}{}{}{}",
226 request.transaction_reference,
227 request.destination_bank_code,
228 request.destination_account_number,
229 request.source_account_number,
230 request.amount,
231 );
232 let mut mac = HmacSha512::new_from_slice(salt_key.as_bytes())
233 .map_err(|e| Error::Configuration(format!("HMAC init failed: {e}")))?;
234 mac.update(data.as_bytes());
235 Ok(hex::encode(mac.finalize().into_bytes()))
236 }
237
238 /// Fetches every bank in the NIBSS directory (name + routing code).
239 ///
240 /// `GET /funds-transfer-open/api/OpenApiTransfer/GetAllBanks`
241 pub async fn get_bank_list(&self) -> Result<Vec<Bank>> {
242 let banks = self
243 .get_json::<ServiceResponse<BankList>>(
244 "funds-transfer-open/api/OpenApiTransfer/GetAllBanks",
245 &[],
246 &[],
247 )
248 .await?
249 .into_result()?;
250 Ok(banks.0)
251 }
252
253 /// **Name enquiry** — resolve the registered holder name for a NUBAN at a
254 /// given bank. Always do this before [`transfer_funds`](Client::transfer_funds).
255 ///
256 /// `channel_id` is an optional query parameter some channels require.
257 ///
258 /// `GET /funds-transfer-open/api/Shared/AccountNameEnquiry/{bankCode}/{accountNumber}`
259 pub async fn verify_account(
260 &self,
261 bank_code: &str,
262 account_number: &str,
263 channel_id: Option<&str>,
264 ) -> Result<AccountNameEnquiry> {
265 let path = format!(
266 "funds-transfer-open/api/Shared/AccountNameEnquiry/{}/{}",
267 bank_code, account_number
268 );
269 let query: Vec<(&str, &str)> = match channel_id {
270 Some(c) => vec![("channelId", c)],
271 None => vec![],
272 };
273 self.get_json::<Envelope<AccountNameEnquiry>>(&path, &query, &[])
274 .await?
275 .into_result()
276 }
277
278 /// Submits an interbank transfer, signing it with the `hash` header.
279 ///
280 /// Returns the pending [`TransferResult`]; the final status arrives via your
281 /// callback URL (or poll the platform reference).
282 ///
283 /// `POST /funds-transfer-open/api/OpenApiTransfer/transfer-fund-request`
284 pub async fn transfer_funds(
285 &self,
286 salt_key: &str,
287 request: &TransferRequest,
288 ) -> Result<TransferResult> {
289 let hash = self.compute_transfer_hash(salt_key, request)?;
290 self.post_json::<_, ServiceResponse<TransferResult>>(
291 "funds-transfer-open/api/OpenApiTransfer/transfer-fund-request",
292 request,
293 &[("hash", hash)],
294 )
295 .await?
296 .into_result()
297 }
298
299 /// Fetches the interbank (NIP) charge schedule.
300 ///
301 /// Served by the **Wallet Services** product's "Debit Wallet" group on the
302 /// Playground gateway (`/debit-wallet`) — i.e. call this with a client
303 /// configured for [`Config::playground`](crate::Config::playground) and a
304 /// Wallet Services subscription key, not the funds-transfer gateway. On the
305 /// funds-transfer flow itself, prefer reading the bands off a name enquiry via
306 /// [`AccountNameEnquiry::charge_for`].
307 ///
308 /// `GET /debit-wallet/api/Shared/GetNIPCharges`
309 pub async fn get_nip_charges(&self) -> Result<NipCharges> {
310 self.get_json::<Envelope<NipCharges>>("debit-wallet/api/Shared/GetNIPCharges", &[], &[])
311 .await?
312 .into_result()
313 }
314}