Skip to main content

bkash_rs/
error.rs

1//! Error types returned by the bKash client.
2//!
3//! [`Error`] is the crate-level error type, modeled with `thiserror 2`. The
4//! [`ErrorCode`] enum is a strongly-typed view of the `errorCode` field
5//! returned by bKash endpoints. Use [`ErrorCode::is_transient`] and
6//! [`ErrorCode::is_auth`] to drive retry / re-grant policy.
7
8use std::fmt;
9use std::str::FromStr;
10
11/// Crate-level error type.
12#[derive(Debug, thiserror::Error)]
13pub enum Error {
14    /// Underlying HTTP transport error (network, timeout, TLS, etc.).
15    #[error("HTTP error: {0}")]
16    Http(#[from] reqwest::Error),
17
18    /// Failed to decode a JSON response body.
19    #[error("failed to decode response: {0}")]
20    Decode(#[from] serde_json::Error),
21
22    /// A bKash API call returned a non-success envelope (i.e. `errorCode` was
23    /// present, or `statusCode` was not `"0000"`).
24    #[error("bKash API error: {code} — {message}")]
25    Api {
26        /// The bKash `errorCode` (or `externalCode` for refund responses).
27        code: String,
28        /// The human-readable error message from bKash.
29        message: String,
30        /// The HTTP status code returned alongside the body.
31        status: u16,
32    },
33
34    /// Authentication / authorization failure (e.g. `2001` Invalid App Key, or
35    /// repeated 401 after a force-regrant).
36    #[error("bKash auth error: {0}")]
37    Auth(String),
38
39    /// The client was built with an invalid configuration.
40    #[error("invalid configuration: {0}")]
41    Config(String),
42
43    /// Webhook signature verification failed.
44    #[error("webhook signature verification failed")]
45    InvalidSignature,
46
47    /// Failed to parse a URL.
48    #[error("URL parse error: {0}")]
49    Url(#[from] url::ParseError),
50
51    /// The token grant / refresh endpoint returned an error.
52    #[error("token endpoint error: {0}")]
53    Token(String),
54}
55
56impl Error {
57    /// Returns `true` if this error represents a transient condition that may
58    /// succeed on retry (network error, HTTP 5xx, or `errorCode == "503"`).
59    #[must_use]
60    pub fn is_transient(&self) -> bool {
61        match self {
62            Self::Http(_) => true,
63            Self::Api { code, status, .. } => *status >= 500 || code == "503",
64            _ => false,
65        }
66    }
67
68    /// Returns `true` if this error represents a credential / authorization
69    /// failure that warrants clearing the token cache and re-granting.
70    #[must_use]
71    pub fn is_auth(&self) -> bool {
72        match self {
73            Self::Auth(_) => true,
74            Self::Api { code, .. } => ErrorCode::from_code(code).is_auth(),
75            _ => false,
76        }
77    }
78}
79
80/// Strongly-typed view of bKash's `errorCode` field.
81///
82/// Unknown codes are captured as [`ErrorCode::Other`] so the client never
83/// panics on a new code bKash may introduce.
84#[derive(Debug, Clone, PartialEq, Eq, Hash)]
85pub enum ErrorCode {
86    /// `2001` — Invalid App Key.
87    InvalidAppKey,
88    /// `2002` — Invalid Payment ID.
89    InvalidPaymentId,
90    /// `2003` — Process failed.
91    ProcessFailed,
92    /// `2004` — Invalid firstPaymentDate.
93    InvalidFirstPaymentDate,
94    /// `2005` — Invalid frequency.
95    InvalidFrequency,
96    /// `2006` — Invalid amount.
97    InvalidAmount,
98    /// `2007` — Invalid currency.
99    InvalidCurrency,
100    /// `2008` — Invalid intent.
101    InvalidIntent,
102    /// `2009` — Invalid Wallet.
103    InvalidWallet,
104    /// `2010` — Invalid OTP.
105    InvalidOtp,
106    /// `2011` — Invalid PIN.
107    InvalidPin,
108    /// `2012` — Invalid Receiver MSISDN.
109    InvalidReceiverMsisdn,
110    /// `2013` — Resend Limit Exceeded.
111    ResendLimitExceeded,
112    /// `2014` — Wrong PIN.
113    WrongPin,
114    /// `2015` — Wrong PIN count exceeded.
115    WrongPinCountExceeded,
116    /// `2016` — Wrong verification code.
117    WrongVerificationCode,
118    /// `2017` — Wrong verification limit.
119    WrongVerificationLimit,
120    /// `2018` — OTP verification time expired.
121    OtpVerificationTimeExpired,
122    /// `2019` — PIN verification time expired.
123    PinVerificationTimeExpired,
124    /// `2020` — Exception occurred.
125    ExceptionOccurred,
126    /// `2021` — Invalid Mandate ID.
127    InvalidMandateId,
128    /// `2022` — Missing Mandate ID.
129    MissingMandateId,
130    /// `2023` — Insufficient Balance.
131    InsufficientBalance,
132    /// `2024` — Exception occurred (alternate).
133    ExceptionOccurredAlt,
134    /// `2025` — Invalid request body.
135    InvalidRequestBody,
136    /// `2026` — Reversal amount > original.
137    ReversalAmountExceedsOriginal,
138    /// `2027` — Mandate already exists.
139    MandateAlreadyExists,
140    /// `2028` — Reversal error.
141    ReversalError,
142    /// `2029` — Duplicate request.
143    DuplicateRequest,
144    /// `2030` — Mandate type error.
145    MandateTypeError,
146    /// `2031` — Invalid merchant invoice number.
147    InvalidMerchantInvoiceNumber,
148    /// `2032` — Invalid transfer type.
149    InvalidTransferType,
150    /// `2033` — Transaction not found.
151    TransactionNotFound,
152    /// `2034` — Reversal not allowed.
153    ReversalNotAllowed,
154    /// `2035` — Account state error.
155    AccountStateError,
156    /// `2036` — Account permission error.
157    AccountPermissionError,
158    /// `2037` — Account state error (alt).
159    AccountStateErrorAlt,
160    /// `2038` — Account state error (alt 2).
161    AccountStateErrorAlt2,
162    /// `2039` — Account state error (alt 3).
163    AccountStateErrorAlt3,
164    /// `2040` — Account state error (alt 4).
165    AccountStateErrorAlt4,
166    /// `2041` — Account state error (alt 5).
167    AccountStateErrorAlt5,
168    /// `2042` — Account state error (alt 6).
169    AccountStateErrorAlt6,
170    /// `2043` — Security error.
171    SecurityError,
172    /// `2044` — Security error (alt).
173    SecurityErrorAlt,
174    /// `2045` — Subscription error.
175    SubscriptionError,
176    /// `2046` — Subscription error (alt).
177    SubscriptionErrorAlt,
178    /// `2047` — TLV format error.
179    TlvFormat,
180    /// `2048` — Invalid Payer Reference.
181    InvalidPayerReference,
182    /// `2049` — Invalid Merchant Callback URL.
183    InvalidMerchantCallbackUrl,
184    /// `2050` — Agreement already exists.
185    AgreementAlreadyExists,
186    /// `2051` — Invalid Agreement ID.
187    InvalidAgreementId,
188    /// `2052` — Agreement state error.
189    AgreementStateError,
190    /// `2053` — Agreement state error (alt).
191    AgreementStateErrorAlt,
192    /// `2054` — Agreement state error (alt 2).
193    AgreementStateErrorAlt2,
194    /// `2055` — Agreement state error (alt 3).
195    AgreementStateErrorAlt3,
196    /// `2056` — Invalid Payment State.
197    InvalidPaymentState,
198    /// `2057` — Not a bKash Account.
199    NotBkashAccount,
200    /// `2058` — Customer Wallet error.
201    CustomerWalletError,
202    /// `2059` — Multiple OTP request denied.
203    MultipleOtpRequestDenied,
204    /// `2060` — Payment execution pre-requisite not met.
205    PaymentExecutionPrerequisiteNotMet,
206    /// `2061` — Initiator-only action.
207    InitiatorOnlyAction,
208    /// `2062` — Payment already completed.
209    PaymentAlreadyCompleted,
210    /// `2063` — Mode not valid.
211    ModeNotValid,
212    /// `2064` — Product mode unavailable.
213    ProductModeUnavailable,
214    /// `2065` — Mandatory field missing.
215    MandatoryFieldMissing,
216    /// `2066` — Agreement sharing not allowed.
217    AgreementSharingNotAllowed,
218    /// `2067` — Agreement permission error.
219    AgreementPermissionError,
220    /// `2068` — Payment already completed (alt).
221    PaymentAlreadyCompletedAlt,
222    /// `2069` — Agreement already cancelled.
223    AgreementAlreadyCancelled,
224    /// `2071` — Refund: invalid amount.
225    RefundInvalidAmount,
226    /// `2072` — Refund: amount exceeds available.
227    RefundAmountExceedsAvailable,
228    /// `2073` — Refund: transaction not eligible.
229    RefundTransactionNotEligible,
230    /// `2074` — Refund: already fully refunded.
231    RefundAlreadyFullyRefunded,
232    /// `2075` — Refund: duplicate refund.
233    RefundDuplicate,
234    /// `2076` — Refund: charge greater than original.
235    RefundChargeExceedsOriginal,
236    /// `2077` — Refund: not permitted.
237    RefundNotPermitted,
238    /// `2078` — Refund: maximum refunds reached.
239    RefundMaxRefundsReached,
240    /// `2079` — Refund: invalid refund reference.
241    RefundInvalidReference,
242    /// `2080` — Refund: not found.
243    RefundNotFound,
244    /// `2081` — Refund: not allowed in current state.
245    RefundInvalidState,
246    /// `2082` — Merchant not permitted.
247    MerchantNotPermitted,
248    /// `2116` — Agreement execution already completed.
249    AgreementExecutionAlreadyCompleted,
250    /// `2117` — Payment execution already completed.
251    PaymentExecutionAlreadyCompleted,
252    /// `2118` — Invalid Platform.
253    InvalidPlatform,
254    /// `2119` — Authorized payment already processed.
255    AuthorizedPaymentAlreadyProcessed,
256    /// `2127` — Transaction not yet completed.
257    TransactionNotYetCompleted,
258    /// `503` — System undergoing maintenance.
259    SystemUndergoingMaintenance,
260    /// Any unknown / unrecognised error code. Preserved as a raw string.
261    Other(String),
262}
263
264impl ErrorCode {
265    /// Parse a bKash `errorCode` string into a typed [`ErrorCode`].
266    #[must_use]
267    pub fn from_code(code: &str) -> Self {
268        match code {
269            "2001" => Self::InvalidAppKey,
270            "2002" => Self::InvalidPaymentId,
271            "2003" => Self::ProcessFailed,
272            "2004" => Self::InvalidFirstPaymentDate,
273            "2005" => Self::InvalidFrequency,
274            "2006" => Self::InvalidAmount,
275            "2007" => Self::InvalidCurrency,
276            "2008" => Self::InvalidIntent,
277            "2009" => Self::InvalidWallet,
278            "2010" => Self::InvalidOtp,
279            "2011" => Self::InvalidPin,
280            "2012" => Self::InvalidReceiverMsisdn,
281            "2013" => Self::ResendLimitExceeded,
282            "2014" => Self::WrongPin,
283            "2015" => Self::WrongPinCountExceeded,
284            "2016" => Self::WrongVerificationCode,
285            "2017" => Self::WrongVerificationLimit,
286            "2018" => Self::OtpVerificationTimeExpired,
287            "2019" => Self::PinVerificationTimeExpired,
288            "2020" => Self::ExceptionOccurred,
289            "2021" => Self::InvalidMandateId,
290            "2022" => Self::MissingMandateId,
291            "2023" => Self::InsufficientBalance,
292            "2024" => Self::ExceptionOccurredAlt,
293            "2025" => Self::InvalidRequestBody,
294            "2026" => Self::ReversalAmountExceedsOriginal,
295            "2027" => Self::MandateAlreadyExists,
296            "2028" => Self::ReversalError,
297            "2029" => Self::DuplicateRequest,
298            "2030" => Self::MandateTypeError,
299            "2031" => Self::InvalidMerchantInvoiceNumber,
300            "2032" => Self::InvalidTransferType,
301            "2033" => Self::TransactionNotFound,
302            "2034" => Self::ReversalNotAllowed,
303            "2035" => Self::AccountStateError,
304            "2036" => Self::AccountPermissionError,
305            "2037" => Self::AccountStateErrorAlt,
306            "2038" => Self::AccountStateErrorAlt2,
307            "2039" => Self::AccountStateErrorAlt3,
308            "2040" => Self::AccountStateErrorAlt4,
309            "2041" => Self::AccountStateErrorAlt5,
310            "2042" => Self::AccountStateErrorAlt6,
311            "2043" => Self::SecurityError,
312            "2044" => Self::SecurityErrorAlt,
313            "2045" => Self::SubscriptionError,
314            "2046" => Self::SubscriptionErrorAlt,
315            "2047" => Self::TlvFormat,
316            "2048" => Self::InvalidPayerReference,
317            "2049" => Self::InvalidMerchantCallbackUrl,
318            "2050" => Self::AgreementAlreadyExists,
319            "2051" => Self::InvalidAgreementId,
320            "2052" => Self::AgreementStateError,
321            "2053" => Self::AgreementStateErrorAlt,
322            "2054" => Self::AgreementStateErrorAlt2,
323            "2055" => Self::AgreementStateErrorAlt3,
324            "2056" => Self::InvalidPaymentState,
325            "2057" => Self::NotBkashAccount,
326            "2058" => Self::CustomerWalletError,
327            "2059" => Self::MultipleOtpRequestDenied,
328            "2060" => Self::PaymentExecutionPrerequisiteNotMet,
329            "2061" => Self::InitiatorOnlyAction,
330            "2062" => Self::PaymentAlreadyCompleted,
331            "2063" => Self::ModeNotValid,
332            "2064" => Self::ProductModeUnavailable,
333            "2065" => Self::MandatoryFieldMissing,
334            "2066" => Self::AgreementSharingNotAllowed,
335            "2067" => Self::AgreementPermissionError,
336            "2068" => Self::PaymentAlreadyCompletedAlt,
337            "2069" => Self::AgreementAlreadyCancelled,
338            "2071" => Self::RefundInvalidAmount,
339            "2072" => Self::RefundAmountExceedsAvailable,
340            "2073" => Self::RefundTransactionNotEligible,
341            "2074" => Self::RefundAlreadyFullyRefunded,
342            "2075" => Self::RefundDuplicate,
343            "2076" => Self::RefundChargeExceedsOriginal,
344            "2077" => Self::RefundNotPermitted,
345            "2078" => Self::RefundMaxRefundsReached,
346            "2079" => Self::RefundInvalidReference,
347            "2080" => Self::RefundNotFound,
348            "2081" => Self::RefundInvalidState,
349            "2082" => Self::MerchantNotPermitted,
350            "2116" => Self::AgreementExecutionAlreadyCompleted,
351            "2117" => Self::PaymentExecutionAlreadyCompleted,
352            "2118" => Self::InvalidPlatform,
353            "2119" => Self::AuthorizedPaymentAlreadyProcessed,
354            "2127" => Self::TransactionNotYetCompleted,
355            "503" => Self::SystemUndergoingMaintenance,
356            other => Self::Other(other.to_string()),
357        }
358    }
359
360    /// Returns the string code (e.g. `"2001"`).
361    #[must_use]
362    pub fn as_code(&self) -> &str {
363        match self {
364            Self::InvalidAppKey => "2001",
365            Self::InvalidPaymentId => "2002",
366            Self::ProcessFailed => "2003",
367            Self::InvalidFirstPaymentDate => "2004",
368            Self::InvalidFrequency => "2005",
369            Self::InvalidAmount => "2006",
370            Self::InvalidCurrency => "2007",
371            Self::InvalidIntent => "2008",
372            Self::InvalidWallet => "2009",
373            Self::InvalidOtp => "2010",
374            Self::InvalidPin => "2011",
375            Self::InvalidReceiverMsisdn => "2012",
376            Self::ResendLimitExceeded => "2013",
377            Self::WrongPin => "2014",
378            Self::WrongPinCountExceeded => "2015",
379            Self::WrongVerificationCode => "2016",
380            Self::WrongVerificationLimit => "2017",
381            Self::OtpVerificationTimeExpired => "2018",
382            Self::PinVerificationTimeExpired => "2019",
383            Self::ExceptionOccurred => "2020",
384            Self::InvalidMandateId => "2021",
385            Self::MissingMandateId => "2022",
386            Self::InsufficientBalance => "2023",
387            Self::ExceptionOccurredAlt => "2024",
388            Self::InvalidRequestBody => "2025",
389            Self::ReversalAmountExceedsOriginal => "2026",
390            Self::MandateAlreadyExists => "2027",
391            Self::ReversalError => "2028",
392            Self::DuplicateRequest => "2029",
393            Self::MandateTypeError => "2030",
394            Self::InvalidMerchantInvoiceNumber => "2031",
395            Self::InvalidTransferType => "2032",
396            Self::TransactionNotFound => "2033",
397            Self::ReversalNotAllowed => "2034",
398            Self::AccountStateError => "2035",
399            Self::AccountPermissionError => "2036",
400            Self::AccountStateErrorAlt => "2037",
401            Self::AccountStateErrorAlt2 => "2038",
402            Self::AccountStateErrorAlt3 => "2039",
403            Self::AccountStateErrorAlt4 => "2040",
404            Self::AccountStateErrorAlt5 => "2041",
405            Self::AccountStateErrorAlt6 => "2042",
406            Self::SecurityError => "2043",
407            Self::SecurityErrorAlt => "2044",
408            Self::SubscriptionError => "2045",
409            Self::SubscriptionErrorAlt => "2046",
410            Self::TlvFormat => "2047",
411            Self::InvalidPayerReference => "2048",
412            Self::InvalidMerchantCallbackUrl => "2049",
413            Self::AgreementAlreadyExists => "2050",
414            Self::InvalidAgreementId => "2051",
415            Self::AgreementStateError => "2052",
416            Self::AgreementStateErrorAlt => "2053",
417            Self::AgreementStateErrorAlt2 => "2054",
418            Self::AgreementStateErrorAlt3 => "2055",
419            Self::InvalidPaymentState => "2056",
420            Self::NotBkashAccount => "2057",
421            Self::CustomerWalletError => "2058",
422            Self::MultipleOtpRequestDenied => "2059",
423            Self::PaymentExecutionPrerequisiteNotMet => "2060",
424            Self::InitiatorOnlyAction => "2061",
425            Self::PaymentAlreadyCompleted => "2062",
426            Self::ModeNotValid => "2063",
427            Self::ProductModeUnavailable => "2064",
428            Self::MandatoryFieldMissing => "2065",
429            Self::AgreementSharingNotAllowed => "2066",
430            Self::AgreementPermissionError => "2067",
431            Self::PaymentAlreadyCompletedAlt => "2068",
432            Self::AgreementAlreadyCancelled => "2069",
433            Self::RefundInvalidAmount => "2071",
434            Self::RefundAmountExceedsAvailable => "2072",
435            Self::RefundTransactionNotEligible => "2073",
436            Self::RefundAlreadyFullyRefunded => "2074",
437            Self::RefundDuplicate => "2075",
438            Self::RefundChargeExceedsOriginal => "2076",
439            Self::RefundNotPermitted => "2077",
440            Self::RefundMaxRefundsReached => "2078",
441            Self::RefundInvalidReference => "2079",
442            Self::RefundNotFound => "2080",
443            Self::RefundInvalidState => "2081",
444            Self::MerchantNotPermitted => "2082",
445            Self::AgreementExecutionAlreadyCompleted => "2116",
446            Self::PaymentExecutionAlreadyCompleted => "2117",
447            Self::InvalidPlatform => "2118",
448            Self::AuthorizedPaymentAlreadyProcessed => "2119",
449            Self::TransactionNotYetCompleted => "2127",
450            Self::SystemUndergoingMaintenance => "503",
451            Self::Other(s) => s.as_str(),
452        }
453    }
454
455    /// Returns `true` if this error is a transient condition eligible for
456    /// retry (e.g. system maintenance).
457    #[must_use]
458    pub fn is_transient(&self) -> bool {
459        matches!(self, Self::SystemUndergoingMaintenance)
460    }
461
462    /// Returns `true` if this error is a credential / authorization failure
463    /// that warrants clearing the token cache and forcing a re-grant.
464    #[must_use]
465    pub fn is_auth(&self) -> bool {
466        matches!(self, Self::InvalidAppKey)
467    }
468}
469
470impl fmt::Display for ErrorCode {
471    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
472        f.write_str(self.as_code())
473    }
474}
475
476impl FromStr for ErrorCode {
477    type Err = std::convert::Infallible;
478
479    fn from_str(s: &str) -> Result<Self, Self::Err> {
480        Ok(Self::from_code(s))
481    }
482}
483
484impl serde::Serialize for ErrorCode {
485    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
486        s.serialize_str(self.as_code())
487    }
488}
489
490impl<'de> serde::Deserialize<'de> for ErrorCode {
491    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
492        let s = String::deserialize(d)?;
493        Ok(Self::from_code(&s))
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    #[test]
502    fn from_str_parses_known_codes() {
503        assert_eq!(ErrorCode::from_code("2001"), ErrorCode::InvalidAppKey);
504        assert_eq!(
505            ErrorCode::from_code("503"),
506            ErrorCode::SystemUndergoingMaintenance
507        );
508        assert_eq!(
509            ErrorCode::from_code("2127"),
510            ErrorCode::TransactionNotYetCompleted
511        );
512    }
513
514    #[test]
515    fn from_str_falls_back_to_other() {
516        assert_eq!(
517            ErrorCode::from_code("99999"),
518            ErrorCode::Other("99999".to_string())
519        );
520    }
521
522    #[test]
523    fn is_transient_for_503() {
524        assert!(ErrorCode::SystemUndergoingMaintenance.is_transient());
525        assert!(ErrorCode::from_code("503").is_transient());
526    }
527
528    #[test]
529    fn is_transient_false_for_2001() {
530        assert!(!ErrorCode::InvalidAppKey.is_transient());
531    }
532
533    #[test]
534    fn is_auth_for_2001() {
535        assert!(ErrorCode::InvalidAppKey.is_auth());
536        assert!(ErrorCode::from_code("2001").is_auth());
537    }
538
539    #[test]
540    fn is_auth_false_for_503() {
541        assert!(!ErrorCode::SystemUndergoingMaintenance.is_auth());
542    }
543
544    #[test]
545    fn display_returns_code_string() {
546        assert_eq!(ErrorCode::InvalidAppKey.to_string(), "2001");
547        assert_eq!(ErrorCode::SystemUndergoingMaintenance.to_string(), "503");
548    }
549
550    #[test]
551    fn as_code_round_trips() {
552        for variant in [
553            ErrorCode::InvalidAppKey,
554            ErrorCode::SystemUndergoingMaintenance,
555            ErrorCode::TransactionNotYetCompleted,
556        ] {
557            let code = variant.as_code();
558            assert_eq!(ErrorCode::from_code(code), variant);
559        }
560    }
561
562    #[test]
563    fn error_display_does_not_leak_credentials() {
564        let e = Error::Api {
565            code: "2001".to_string(),
566            message: "Invalid App Key".to_string(),
567            status: 200,
568        };
569        let s = e.to_string();
570        assert!(s.contains("2001"));
571        assert!(s.contains("Invalid App Key"));
572    }
573
574    #[test]
575    fn error_is_transient_for_503() {
576        let e = Error::Api {
577            code: "503".to_string(),
578            message: "maintenance".to_string(),
579            status: 200,
580        };
581        assert!(e.is_transient());
582    }
583
584    #[test]
585    fn error_is_auth_for_2001() {
586        let e = Error::Api {
587            code: "2001".to_string(),
588            message: "bad key".to_string(),
589            status: 200,
590        };
591        assert!(e.is_auth());
592    }
593}