kasapay_core/refund.rs
1//! Money given back off a payment.
2
3use std::collections::BTreeMap;
4
5use url::Url;
6
7use crate::charge::{IdempotencyKey, NextAction};
8use crate::id::{PaymentId, RefundId};
9use crate::money::{Money, MoneyError};
10use crate::provider::ProviderId;
11use crate::raw::Raw;
12
13/// What a merchant tells the provider the money went back for.
14///
15/// Not a label for the shop's own use. Two of the five providers here have a
16/// field for it that ends up in chargeback and reconciliation reporting —
17/// Stripe's `reason`, iyzico's `reason` — and a fraudulent order refunded
18/// without saying so has told them nothing, and cannot tell them later.
19///
20/// The three named ones are the intersection: Stripe documents exactly these
21/// three, and iyzico's four are these three plus its own `OTHER`. What each
22/// adapter does with them, and with [`RefundReason::Other`], is on that
23/// adapter's `refund`.
24///
25/// # Exhaustive, for the same reason [`Currency`](crate::Currency) is
26///
27/// This is a value travelling *out* to a provider rather than one a caller
28/// reads back, and an adapter that met a reason it had no word for would
29/// quietly send none — which is the failure this type exists to prevent.
30/// Adding a variant is a breaking change, and it forces every adapter to say
31/// what it maps to.
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33pub enum RefundReason {
34 /// The same money was taken twice.
35 Duplicate,
36 /// The payment was not the cardholder's.
37 Fraudulent,
38 /// The buyer asked for their money back.
39 RequestedByCustomer,
40 /// Something else, in the caller's own words.
41 ///
42 /// A provider with a free-text field takes this; one with an enumeration
43 /// sends its own "other" value beside it, and one with neither drops it.
44 /// Which of the three a provider is, is on its `refund`.
45 Other(Box<str>),
46}
47
48/// Where a refund stands.
49///
50/// A refund is not instant anywhere. Every provider here answers a fresh one
51/// as something other than "the money is back": Stripe's is `pending`,
52/// Mollie's `queued`, and iyzico's In-Store refund has not even been agreed to
53/// yet — the payer approves it through a deep link, which is why this shape
54/// carries a [`Refund::next_action`] at all.
55///
56/// # Not every provider can produce every one of these
57///
58/// The same caution as [`Status`](crate::Status), and the same table shape.
59/// From each adapter's mapping:
60///
61/// | | `Pending` | `RequiresAction` | `Succeeded` | `Failed` | `Canceled` |
62/// |---|---|---|---|---|---|
63/// | Stripe | yes | yes | yes | yes | yes |
64/// | iyzico `classic` | no | no | yes | no | no |
65/// | iyzico `in_store` | no | yes | no | no | no |
66/// | PayTR | yes | no | no | no | no |
67/// | Mollie | yes | no | yes | yes | yes |
68/// | PayPal | yes | no | yes | yes | yes |
69///
70/// The two iyzico rows are the ones worth knowing about, and they are the two
71/// halves of the same fact: **iyzico answers a refund once, and never again.**
72/// `classic` answers a refund it has already accepted, so it is `Succeeded`
73/// the moment it is read and there is no later state to poll for; `in_store`
74/// answers a deep link and nothing else, so it is `RequiresAction` and what
75/// became of it arrives on the callback rather than here.
76///
77/// **PayTR's is always `Pending`.** Its `/odeme/iade` answers that it took the
78/// request, and the refund's own completion turns up later as a
79/// `date_completed` on the payment's status query — `PayTr::refunds` is where
80/// a caller reads it.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82#[non_exhaustive]
83pub enum RefundStatus {
84 /// Accepted, and the money is not back yet.
85 Pending,
86 /// Stalled until somebody acts — see [`Refund::next_action`].
87 RequiresAction,
88 /// The money is back with the payer.
89 Succeeded,
90 /// It will not be sent.
91 Failed,
92 /// Withdrawn before it was sent.
93 Canceled,
94}
95
96impl RefundStatus {
97 /// Whether the refund can still change without a further request from us.
98 #[must_use]
99 pub const fn is_open(self) -> bool {
100 matches!(self, Self::Pending | Self::RequiresAction)
101 }
102}
103
104/// Money given back off a payment, as the provider currently sees it.
105///
106/// Every field is public and the struct is open, for the same reason
107/// [`Charge`](crate::Charge) is: an adapter in someone else's repository has
108/// to be able to build one.
109///
110/// # There is no refunded status on the payment
111///
112/// [`Status`](crate::Status) has no variant for it and will not grow one — no
113/// provider reports a refund as a payment status, so it would be a branch that
114/// never runs for most of them. "Has all of this gone back" is these objects
115/// summed with [`Money::checked_add`] against
116/// [`Charge::amount`](crate::Charge::amount), and each adapter has a call that
117/// lists them.
118#[derive(Debug, Clone)]
119pub struct Refund {
120 /// How the provider names this refund, where it names it at all.
121 ///
122 /// `None` is not a missing field: **iyzico issues no identifier for a
123 /// refund**. The nearest thing it has is the bank's `hostReference`, which
124 /// exists only once the money has gone and so is no use for making the
125 /// attempt idempotent. So this is `Option` for the same reason
126 /// [`Charge::id`](crate::Charge::id) is — a composed key handed back in
127 /// the field a real one lives in would read as a guarantee nobody made.
128 ///
129 /// Read [`Id::source`](crate::Id::source) before writing one into a unique
130 /// index.
131 pub id: Option<RefundId>,
132 /// The payment the money came off.
133 pub payment: PaymentId,
134 /// How much went back.
135 ///
136 /// What the provider says it refunded, not what was asked for. A provider
137 /// that refunds the remainder of a partly-refunded payment answers the
138 /// remainder here, and it is smaller than
139 /// [`RefundRequest::amount`] was.
140 pub amount: Money,
141 /// Where it stands.
142 pub status: RefundStatus,
143 /// What somebody must do before the money moves, if anything.
144 ///
145 /// iyzico's In-Store refund is the one that has one: it answers a deep
146 /// link into iyzico's app, and the refund happens when the payer approves
147 /// it there. Everywhere else this is `None` — the refund was accepted by
148 /// the time the call answered.
149 pub next_action: Option<NextAction>,
150 /// Which provider this came from.
151 pub provider: ProviderId,
152 /// The provider's own response, untouched.
153 pub raw: Raw,
154}
155
156/// A refund to make.
157///
158/// Build one with [`RefundRequest::builder`].
159///
160/// Two of these fields are here because one provider needs them and the others
161/// ignore them, which is the same reason
162/// [`ChargeRequest::customer`](crate::ChargeRequest::customer) exists:
163/// iyzico's In-Store API wants a `userId` and a callback address on a refund
164/// exactly as it does on a payment, and a refund request with nowhere to put
165/// them would mean that adapter could never implement this trait method.
166#[derive(Debug, Clone)]
167#[non_exhaustive]
168pub struct RefundRequest {
169 /// The payment to take the money off.
170 pub payment: PaymentId,
171 /// How much to give back, or `None` for all of it.
172 ///
173 /// **`None` is not universally answerable.** Mollie and iyzico's classic
174 /// API have no "refund what is left" request — both take an amount and
175 /// only an amount — so their adapters say what they do with `None` on
176 /// their own `refund`, and one of the two costs an extra request to find
177 /// the figure out. See [`Provider::refund`](crate::Provider::refund) for
178 /// the table.
179 pub amount: Option<Money>,
180 /// What to tell the provider the money went back for.
181 pub reason: Option<RefundReason>,
182 /// A key that makes replaying this refund safe.
183 ///
184 /// Worth more here than on a charge. A replayed charge opens a second
185 /// payment somebody can see and cancel; a replayed refund gives the money
186 /// back twice, and the second one is the shop's. A provider that cannot
187 /// honour a key ignores it rather than refusing the call, and says so on
188 /// its own `refund`.
189 pub idempotency_key: Option<IdempotencyKey>,
190 /// The payer, in the provider's own terms, where the refund needs naming
191 /// them again.
192 ///
193 /// iyzico's In-Store `userId`. Everywhere else this is ignored: the
194 /// payment already knows whose it was.
195 pub customer: Option<Box<str>>,
196 /// Where the provider should send the payer back to, where a refund needs
197 /// their agreement.
198 ///
199 /// iyzico's In-Store refund posts its outcome here, the same way its
200 /// payment does. Everywhere else this is ignored.
201 pub return_url: Option<Url>,
202 /// Key/value pairs handed to the provider and given back unchanged.
203 pub metadata: BTreeMap<String, String>,
204}
205
206impl RefundRequest {
207 /// Starts building a refund.
208 #[must_use]
209 pub fn builder(payment: PaymentId) -> RefundRequestBuilder {
210 RefundRequestBuilder {
211 payment,
212 amount: None,
213 reason: None,
214 idempotency_key: None,
215 customer: None,
216 return_url: None,
217 metadata: BTreeMap::new(),
218 }
219 }
220}
221
222/// Collects the parts of a [`RefundRequest`] before it is checked.
223#[derive(Debug, Clone)]
224pub struct RefundRequestBuilder {
225 payment: PaymentId,
226 amount: Option<Money>,
227 reason: Option<RefundReason>,
228 idempotency_key: Option<IdempotencyKey>,
229 customer: Option<Box<str>>,
230 return_url: Option<Url>,
231 metadata: BTreeMap<String, String>,
232}
233
234impl RefundRequestBuilder {
235 /// Refunds part of the payment rather than all of it.
236 #[must_use]
237 pub fn amount(mut self, amount: Money) -> Self {
238 self.amount = Some(amount);
239 self
240 }
241
242 /// Says what the money went back for.
243 #[must_use]
244 pub fn reason(mut self, reason: RefundReason) -> Self {
245 self.reason = Some(reason);
246 self
247 }
248
249 /// Sets the key that makes replaying this refund safe.
250 #[must_use]
251 pub fn idempotency_key(mut self, key: IdempotencyKey) -> Self {
252 self.idempotency_key = Some(key);
253 self
254 }
255
256 /// Names the payer in the provider's own terms.
257 #[must_use]
258 pub fn customer(mut self, customer: impl Into<Box<str>>) -> Self {
259 self.customer = Some(customer.into());
260 self
261 }
262
263 /// Sets where the provider should send the payer back to.
264 #[must_use]
265 pub fn return_url(mut self, url: Url) -> Self {
266 self.return_url = Some(url);
267 self
268 }
269
270 /// Adds one key/value pair to hand to the provider.
271 #[must_use]
272 pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
273 self.metadata.insert(key.into(), value.into());
274 self
275 }
276
277 /// Checks the request and produces it.
278 pub fn build(self) -> Result<RefundRequest, RefundRequestError> {
279 if self.payment.as_str().is_empty() {
280 return Err(RefundRequestError::EmptyPaymentId);
281 }
282 if let Some(amount) = self.amount {
283 amount.require_positive()?;
284 }
285 Ok(RefundRequest {
286 payment: self.payment,
287 amount: self.amount,
288 reason: self.reason,
289 idempotency_key: self.idempotency_key,
290 customer: self.customer,
291 return_url: self.return_url,
292 metadata: self.metadata,
293 })
294 }
295}
296
297/// A [`RefundRequest`] was built out of parts that do not make a valid refund.
298#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
299#[non_exhaustive]
300pub enum RefundRequestError {
301 /// The payment identifier was empty.
302 #[error("payment identifier is empty")]
303 EmptyPaymentId,
304 /// The amount was not one that can be refunded.
305 #[error(transparent)]
306 Amount(#[from] MoneyError),
307}
308
309#[cfg(test)]
310mod tests {
311 use super::{RefundRequest, RefundRequestError, RefundStatus};
312 use crate::id::PaymentId;
313 use crate::money::{Currency, Money};
314
315 #[test]
316 fn a_refund_with_no_amount_asks_for_all_of_it() {
317 let request = RefundRequest::builder(PaymentId::issued("pi_1"))
318 .build()
319 .expect("valid request");
320 assert!(request.amount.is_none());
321 }
322
323 #[test]
324 fn build_rejects_a_zero_amount() {
325 let err = RefundRequest::builder(PaymentId::issued("pi_1"))
326 .amount(Money::from_minor_units(0, Currency::Try))
327 .build()
328 .expect_err("zero is not refundable");
329 assert!(matches!(err, RefundRequestError::Amount(_)));
330 }
331
332 #[test]
333 fn build_rejects_a_payment_nothing_names() {
334 let err = RefundRequest::builder(PaymentId::issued(""))
335 .build()
336 .expect_err("an empty identifier is not usable");
337 assert_eq!(err, RefundRequestError::EmptyPaymentId);
338 }
339
340 /// A refund nobody has to approve and nothing more will happen to is done.
341 #[test]
342 fn only_a_settled_refund_is_closed() {
343 assert!(RefundStatus::Pending.is_open());
344 assert!(RefundStatus::RequiresAction.is_open());
345 assert!(!RefundStatus::Succeeded.is_open());
346 assert!(!RefundStatus::Failed.is_open());
347 assert!(!RefundStatus::Canceled.is_open());
348 }
349}