Skip to main content

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}