kasapay_core/error.rs
1//! The one error every provider reports through.
2
3use std::error::Error as StdError;
4use std::fmt;
5
6use crate::provider::ProviderId;
7
8/// What went wrong, in terms a caller can branch on without knowing the provider.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum ErrorKind {
12 /// Credentials were missing, wrong, or not allowed to do this.
13 Auth,
14 /// The request was rejected before it reached the card network.
15 InvalidRequest,
16 /// The bank or the network refused the payment.
17 Declined,
18 /// The payment was not found, or no longer exists.
19 NotFound,
20 /// The provider asked us to slow down.
21 RateLimited,
22 /// The request never got a usable answer: DNS, TLS, timeout, socket.
23 Transport,
24 /// The provider answered, but with something this crate cannot read.
25 Malformed,
26 /// The answer could not be shown to have come from the provider.
27 ///
28 /// A signature that does not match, or one that is missing where the
29 /// provider always sends it. Not a transport failure and not a decline: it
30 /// means the message may not be theirs, and it must never be acted on.
31 Untrusted,
32 /// The provider does not offer what was asked of it.
33 Unsupported,
34 /// The provider failed on its own side.
35 Provider,
36}
37
38impl ErrorKind {
39 /// Whether replaying the same request unchanged could plausibly succeed.
40 ///
41 /// # This does not mean the retry is safe
42 ///
43 /// It says the failure was not a verdict. It says nothing about whether
44 /// the first attempt took the money — a timeout is exactly the case where
45 /// nobody knows.
46 ///
47 /// Replaying a **charge** is safe only where the provider offers
48 /// idempotency, and not every one does:
49 ///
50 /// | | replaying a charge |
51 /// |---|---|
52 /// | Stripe | safe — `ChargeRequest::idempotency_key` is sent as `Idempotency-Key` |
53 /// | iyzico | **not documented safe** — it refuses an idempotency key, and does not say what a reused `orderId` does |
54 /// | PayTR | **not documented safe** — no idempotency mechanism is documented for opening a payment |
55 /// | Mollie | safe — `ChargeRequest::idempotency_key` is sent as `Idempotency-Key`, and Mollie replays the first answer for an hour |
56 /// | PayPal | safe — `ChargeRequest::idempotency_key` is sent as `PayPal-Request-Id`, and PayPal returns the first answer for a repeated key |
57 ///
58 /// Where it is not safe, read the payment back before sending it again.
59 /// Reading is always safe — but **which call reads it back depends on what
60 /// you still have.** A charge that answered and then failed later leaves a
61 /// [`Charge::id`](crate::Charge::id), and
62 /// [`Provider::charge_status`](crate::Provider::charge_status) takes it. A
63 /// charge whose *answer never arrived* leaves nothing but the order
64 /// reference, and that is
65 /// [`Provider::lookup`](crate::Provider::lookup) — which is exactly the
66 /// timeout case, and exactly the two providers whose replay is not
67 /// documented as safe: iyzico and PayTR can both be asked.
68 ///
69 /// **Replaying a capture is a narrower question, because a capture takes
70 /// money rather than opening a request for it.**
71 /// [`Provider::capture`](crate::Provider::capture) carries its own
72 /// `idempotency`, and what a timeout means depends on whether one was
73 /// sent:
74 ///
75 /// | | replaying a capture, with a key | replaying a capture, without one |
76 /// |---|---|---|
77 /// | Stripe | safe — sent as `Idempotency-Key`, same as a charge | **not safe** — a second PaymentIntent capture can take the funds twice |
78 /// | iyzico | n/a — a key is refused outright, because iyzico accepts none | **not safe** — `classic`'s capture is `/payment/postauth`, and iyzico documents no idempotency mechanism for it |
79 /// | PayTR | n/a — no capture step; the hosted form takes the money as it goes | n/a |
80 /// | Mollie | safe — sent as `Idempotency-Key` on the captures endpoint, answered from the cache for an hour | **not safe** — a second capture against the same authorisation can take the funds twice |
81 /// | PayPal | safe — sent as `PayPal-Request-Id`, same as opening an order | **not safe, and PayPal documents it** — a second capture of the same order can take the funds twice |
82 ///
83 /// Where it is not safe, this table's answer does not change: read the
84 /// payment back with
85 /// [`Provider::charge_status`](crate::Provider::charge_status) rather
86 /// than sending [`Provider::capture`](crate::Provider::capture) again. A
87 /// capture whose outcome `is_retryable` does not resolve is read back,
88 /// never resent — with a key, resending is safe but reading is still
89 /// simpler and costs nothing extra.
90 #[must_use]
91 pub const fn is_retryable(self) -> bool {
92 matches!(self, Self::RateLimited | Self::Transport | Self::Provider)
93 }
94}
95
96impl fmt::Display for ErrorKind {
97 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98 let text = match self {
99 Self::Auth => "authentication",
100 Self::InvalidRequest => "invalid request",
101 Self::Declined => "declined",
102 Self::NotFound => "not found",
103 Self::RateLimited => "rate limited",
104 Self::Transport => "transport",
105 Self::Malformed => "malformed response",
106 Self::Untrusted => "unverified response",
107 Self::Unsupported => "unsupported",
108 Self::Provider => "provider failure",
109 };
110 f.write_str(text)
111 }
112}
113
114/// A payment operation failed.
115#[derive(Debug)]
116pub struct Error {
117 kind: ErrorKind,
118 provider: ProviderId,
119 message: Box<str>,
120 code: Option<Box<str>>,
121 source: Option<Box<dyn StdError + Send + Sync>>,
122}
123
124impl Error {
125 /// Builds an error attributed to a provider.
126 pub fn new(kind: ErrorKind, provider: ProviderId, message: impl Into<Box<str>>) -> Self {
127 Self {
128 kind,
129 provider,
130 message: message.into(),
131 code: None,
132 source: None,
133 }
134 }
135
136 /// Attaches the provider's own error code, verbatim.
137 #[must_use]
138 pub fn with_code(mut self, code: impl Into<Box<str>>) -> Self {
139 self.code = Some(code.into());
140 self
141 }
142
143 /// Attaches the underlying error.
144 #[must_use]
145 pub fn with_source(mut self, source: impl StdError + Send + Sync + 'static) -> Self {
146 self.source = Some(Box::new(source));
147 self
148 }
149
150 /// What went wrong.
151 #[must_use]
152 pub const fn kind(&self) -> ErrorKind {
153 self.kind
154 }
155
156 /// Which provider reported it.
157 #[must_use]
158 pub const fn provider(&self) -> ProviderId {
159 self.provider
160 }
161
162 /// The provider's own error code, when it gave one.
163 #[must_use]
164 pub fn code(&self) -> Option<&str> {
165 self.code.as_deref()
166 }
167
168 /// Whether replaying the same request unchanged could plausibly succeed.
169 #[must_use]
170 pub const fn is_retryable(&self) -> bool {
171 self.kind.is_retryable()
172 }
173}
174
175impl fmt::Display for Error {
176 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177 write!(f, "{}: {} ({})", self.provider, self.message, self.kind)?;
178 if let Some(code) = &self.code {
179 write!(f, " [{code}]")?;
180 }
181 Ok(())
182 }
183}
184
185impl StdError for Error {
186 fn source(&self) -> Option<&(dyn StdError + 'static)> {
187 self.source
188 .as_ref()
189 .map(|e| &**e as &(dyn StdError + 'static))
190 }
191}