Skip to main content

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}