Skip to main content

kasapay_core/
charge.rs

1//! What is asked of a provider, and what comes back.
2
3use std::collections::BTreeMap;
4use std::fmt;
5
6use url::Url;
7
8use crate::id::PaymentId;
9use crate::money::{Money, MoneyError};
10use crate::party::{Address, BasketItem, Buyer};
11use crate::provider::ProviderId;
12use crate::raw::Raw;
13
14/// Our own reference for an order, chosen by the caller.
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct OrderRef(Box<str>);
17
18impl OrderRef {
19    /// Wraps a caller-chosen order reference.
20    pub fn new(value: impl Into<Box<str>>) -> Self {
21        Self(value.into())
22    }
23
24    /// The reference as text.
25    #[must_use]
26    pub fn as_str(&self) -> &str {
27        &self.0
28    }
29}
30
31impl fmt::Display for OrderRef {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(&self.0)
34    }
35}
36
37/// A key that makes replaying a charge safe.
38#[derive(Debug, Clone, PartialEq, Eq, Hash)]
39pub struct IdempotencyKey(Box<str>);
40
41impl IdempotencyKey {
42    /// Wraps a caller-chosen key.
43    pub fn new(value: impl Into<Box<str>>) -> Self {
44        Self(value.into())
45    }
46
47    /// The key as text.
48    #[must_use]
49    pub fn as_str(&self) -> &str {
50        &self.0
51    }
52}
53
54/// Where a payment stands.
55///
56/// # Not every provider can produce every one of these
57///
58/// A caller that branches on a status a provider never sends has written a
59/// branch that never runs, and the compiler cannot say so. What each adapter
60/// can actually produce, from reading their mappings:
61///
62/// | | `Pending` | `RequiresAction` | `Authorized` | `Captured` | `Failed` | `Canceled` |
63/// |---|---|---|---|---|---|---|
64/// | Stripe | yes | yes | yes | yes | **no** | yes |
65/// | iyzico `in_store` | yes | yes | no | yes | yes | yes |
66/// | iyzico `classic` | yes | yes | yes | yes | yes | no |
67/// | PayTR | no | yes | no | yes | notice only | no |
68/// | Mollie | yes | yes | yes | yes | yes | yes |
69/// | PayPal | yes | yes | yes | yes | yes | read-only |
70///
71/// Five of those cells are worth knowing about.
72///
73/// **Stripe never reports `Failed`.** A PaymentIntent whose card was declined
74/// goes back to `requires_payment_method`, which arrives here as
75/// [`Status::RequiresAction`] — and that is honest, because the payer can try
76/// another card. A caller waiting for `Failed` from Stripe waits forever.
77///
78/// **PayTR reports a refusal only on the payment notice.** Its status query
79/// answers a payment that succeeded or an error, so `Failed` comes from
80/// `Notice::charge` and never from `charge_status`. Worse, that error is the
81/// same for a payment PayTR refused and an order it has never heard of —
82/// `ErrorKind::NotFound` either way, because PayTR sends nothing that
83/// separates them.
84///
85/// **Mollie's `Failed` is two of its own states.** A payment it refused is
86/// `failed`; one the payer abandoned until it could no longer be paid is
87/// `expired`, which is neither a refusal nor a withdrawal and has no word
88/// here. Both arrive as [`Status::Failed`], and which it was is in
89/// [`Charge::raw`]. A caller counting declines separately from abandoned
90/// checkouts reads it there.
91///
92/// **iyzico's classic API answers `Authorized` only for a payment it was asked
93/// to hold.** `classic::Client::start_checkout_form_preauth` and
94/// `preauth_with_saved_card` are what ask; the ordinary form and
95/// `/payment/auth` take the money as they go and answer [`Status::Captured`].
96/// One thing that cell hides: a payment *read back* after a hold reads as
97/// `Captured` too, because iyzico answers `paymentStatus: SUCCESS` for both
98/// and names no values for the field that would separate them. The adapter
99/// says so where a reader meets it.
100///
101/// **PayPal's `Canceled` is read-only.** `VOIDED` is a real value of its
102/// `order_status` enum, but nothing `kasapay-paypal` calls ever produces one:
103/// its `Provider::cancel` always refuses, because PayPal's Orders v2 API has
104/// no operation that withdraws an order. The only way this crate ever answers
105/// `Canceled` for PayPal is `Provider::charge_status` reading an order some
106/// other integration voided.
107///
108/// Each adapter's own documentation says the same thing where a reader will
109/// meet it.
110///
111/// # Nothing here says a payment was refunded
112///
113/// No provider reports one as a status. Stripe's PaymentIntent stays
114/// `succeeded` with the refunds beside it, PayTR lists them on the payment,
115/// and iyzico's In-Store receipt sets a flag on a payment that is still
116/// captured. A variant only one of them could ever produce would be a branch
117/// that never runs for the others.
118///
119/// So "how much of this has gone back" is the adapter's own refunds — Stripe's
120/// and PayTR's both answer a list — summed with [`Money::checked_add`] and
121/// compared against [`Charge::amount`]. Mollie is the one that answers the
122/// figure outright, as `amountRefunded` on a payment that still reads `paid`,
123/// and it is read off [`Charge::raw`] rather than off a status.
124///
125/// PayPal is the odd one out here rather than the usual shape: its capture
126/// carries `PARTIALLY_REFUNDED` and `REFUNDED` as two of the values of its
127/// *own* status field, the one thing this workspace reads to decide
128/// [`Status::Captured`] in the first place, rather than as a separate figure
129/// beside it. `kasapay-paypal` maps both to [`Status::Captured`] for the same
130/// reason every other refund fact is off [`Status`] — the money was taken,
131/// which is what that variant says — and the more specific answer is on
132/// [`Charge::raw`] there too.
133#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
134#[non_exhaustive]
135pub enum Status {
136    /// Accepted, nothing more required yet, not settled.
137    Pending,
138    /// Stalled until the payer does something — see [`Charge::next_action`].
139    RequiresAction,
140    /// Funds are held but not taken.
141    Authorized,
142    /// Funds are taken.
143    Captured,
144    /// Refused, and will not proceed.
145    Failed,
146    /// Withdrawn before it completed.
147    Canceled,
148}
149
150impl Status {
151    /// Whether the payment can still change without a further request from us.
152    #[must_use]
153    pub const fn is_open(self) -> bool {
154        matches!(
155            self,
156            Self::Pending | Self::RequiresAction | Self::Authorized
157        )
158    }
159}
160
161/// What the payer has to do before the payment can go on.
162#[derive(Debug, Clone, PartialEq, Eq)]
163#[non_exhaustive]
164pub enum NextAction {
165    /// Send the payer to this address — a hosted page, or an app deep link.
166    Redirect {
167        /// Where to send them.
168        url: Url,
169        /// A token to keep for when the payer comes back.
170        ///
171        /// `None` is a provider that named the payment as it opened the flow,
172        /// so [`Charge::id`] is the handle and
173        /// [`Provider::charge_status`](crate::Provider::charge_status) is what
174        /// finishes it.
175        ///
176        /// `Some` is a token, and what can be done with it is
177        /// [`Capabilities::resume_by_continuation`](crate::Capabilities::resume_by_continuation):
178        /// true means [`Provider::resume`](crate::Provider::resume) takes it
179        /// and answers the finished charge, which is the only handle iyzico's
180        /// classic form has until the payer is done. False means the token is
181        /// the provider's own — iyzico's In-Store `paymentSessionToken` opens
182        /// the encrypted callback it later posts, and PayTR's is the segment
183        /// of the form address — and the adapter's own call is what takes it.
184        continuation: Option<Box<str>>,
185    },
186    /// Hand this to the provider's client-side SDK and let it finish there.
187    ConfirmOnClient {
188        /// The provider's client-side handle for the payment.
189        client_secret: Box<str>,
190    },
191}
192
193/// A charge, as the provider currently sees it.
194///
195/// Every field is public and the struct is open: a provider adapter living
196/// outside this workspace has to be able to build one.
197#[derive(Debug, Clone)]
198pub struct Charge {
199    /// How the provider names this payment, where it names it at all.
200    ///
201    /// `None` is a payment nothing identifies yet — an iyzico checkout form the
202    /// payer has not finished has no `paymentId` — and it is `None` rather than
203    /// an empty string so that it cannot be handed back as a handle and quietly
204    /// read as a payment nobody made. A provider that never issues one and has
205    /// nothing to compose one from answers `None` here always, and
206    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) to
207    /// [`Provider::charge_status`](crate::Provider::charge_status).
208    ///
209    /// Read [`PaymentId::source`] before writing one into a unique index.
210    pub id: Option<PaymentId>,
211    /// The order reference the charge was created against, when the provider kept it.
212    pub order: Option<OrderRef>,
213    /// What the payer is charged. The money that moves.
214    ///
215    /// Not always what the goods came to: an instalment surcharge lands here
216    /// and not on the basket. This is the figure that reconciles against a
217    /// bank statement.
218    pub amount: Money,
219    /// What the goods came to, when the provider reports it separately and it
220    /// differs from [`Charge::amount`].
221    ///
222    /// `None` does not mean the two are equal — it means this provider does
223    /// not say. Stripe has no basket at all at the payment level, so it never
224    /// answers; iyzico's `price` and PayTR's `payment_amount` do.
225    pub order_amount: Option<Money>,
226    /// Where it stands.
227    pub status: Status,
228    /// What the payer must do next, if anything.
229    pub next_action: Option<NextAction>,
230    /// Which provider this came from.
231    pub provider: ProviderId,
232    /// The provider's own response, untouched.
233    ///
234    /// The escape hatch: everything kasapay does not model is still here.
235    pub raw: Raw,
236}
237
238/// A charge to create.
239///
240/// Build one with [`ChargeRequest::builder`].
241#[derive(Debug, Clone)]
242#[non_exhaustive]
243pub struct ChargeRequest {
244    /// The caller's reference for the order.
245    pub order: OrderRef,
246    /// The amount to take.
247    pub amount: Money,
248    /// The payer, in the provider's own terms, when there is one on file.
249    pub customer: Option<Box<str>>,
250    /// Free text shown on statements or in the provider's dashboard.
251    pub description: Option<Box<str>>,
252    /// Where the provider should send the payer back to.
253    pub return_url: Option<Url>,
254    /// A key that makes replaying this request safe.
255    ///
256    /// A provider either sends it or refuses the request with
257    /// [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported). Accepting a
258    /// key and dropping it would read as a guarantee against double charges
259    /// where there is none.
260    pub idempotency_key: Option<IdempotencyKey>,
261    /// Where the provider should send the payer when the payment fails.
262    ///
263    /// `None` means [`ChargeRequest::return_url`] serves for both, which is
264    /// what every provider but PayTR takes anyway — PayTR requires two URLs,
265    /// and sends the payer to whichever matches the outcome.
266    pub failure_url: Option<Url>,
267    /// The person paying, where the provider requires one.
268    ///
269    /// iyzico's classic API and PayTR both refuse a payment without a buyer;
270    /// Stripe and Mollie never ask. See [`Buyer`] for which of its fields a
271    /// given provider insists on.
272    pub buyer: Option<Buyer>,
273    /// Where to bill.
274    ///
275    /// `None` falls back to [`Buyer::address`] at the providers that require
276    /// one, because a shop that gave one address meant it for both.
277    pub billing_address: Option<Address>,
278    /// Where to ship, when that is somewhere else.
279    pub shipping_address: Option<Address>,
280    /// What is being paid for, line by line.
281    ///
282    /// Required by the providers that build a basket — iyzico refuses an empty
283    /// one — and ignored by the ones that do not. The lines do not have to sum
284    /// to [`ChargeRequest::amount`]: a surcharge is money the payer is charged
285    /// rather than a line of the basket. [`BasketItem::price`] is what one of
286    /// the thing costs, not what the line comes to.
287    pub basket: Vec<BasketItem>,
288    /// Key/value pairs handed to the provider and given back unchanged.
289    pub metadata: BTreeMap<String, String>,
290}
291
292impl ChargeRequest {
293    /// Starts building a charge.
294    #[must_use]
295    pub fn builder(order: OrderRef, amount: Money) -> ChargeRequestBuilder {
296        ChargeRequestBuilder {
297            order,
298            amount,
299            customer: None,
300            description: None,
301            return_url: None,
302            failure_url: None,
303            buyer: None,
304            billing_address: None,
305            shipping_address: None,
306            basket: Vec::new(),
307            idempotency_key: None,
308            metadata: BTreeMap::new(),
309        }
310    }
311}
312
313/// Collects the parts of a [`ChargeRequest`] before it is checked.
314#[derive(Debug, Clone)]
315pub struct ChargeRequestBuilder {
316    order: OrderRef,
317    amount: Money,
318    customer: Option<Box<str>>,
319    description: Option<Box<str>>,
320    return_url: Option<Url>,
321    failure_url: Option<Url>,
322    buyer: Option<Buyer>,
323    billing_address: Option<Address>,
324    shipping_address: Option<Address>,
325    basket: Vec<BasketItem>,
326    idempotency_key: Option<IdempotencyKey>,
327    metadata: BTreeMap<String, String>,
328}
329
330impl ChargeRequestBuilder {
331    /// Names the payer in the provider's own terms.
332    #[must_use]
333    pub fn customer(mut self, customer: impl Into<Box<str>>) -> Self {
334        self.customer = Some(customer.into());
335        self
336    }
337
338    /// Sets the free text shown on statements or in the provider's dashboard.
339    #[must_use]
340    pub fn description(mut self, description: impl Into<Box<str>>) -> Self {
341        self.description = Some(description.into());
342        self
343    }
344
345    /// Sets where the provider should send the payer back to.
346    #[must_use]
347    pub fn return_url(mut self, url: Url) -> Self {
348        self.return_url = Some(url);
349        self
350    }
351
352    /// Sets where the provider should send the payer when it fails.
353    ///
354    /// Only PayTR asks for a second address; everywhere else this is unused
355    /// and [`ChargeRequestBuilder::return_url`] serves for both outcomes.
356    #[must_use]
357    pub fn failure_url(mut self, url: Url) -> Self {
358        self.failure_url = Some(url);
359        self
360    }
361
362    /// Names the person paying, which some providers require.
363    #[must_use]
364    pub fn buyer(mut self, buyer: Buyer) -> Self {
365        self.buyer = Some(buyer);
366        self
367    }
368
369    /// Sets where to bill.
370    #[must_use]
371    pub fn billing_address(mut self, address: Address) -> Self {
372        self.billing_address = Some(address);
373        self
374    }
375
376    /// Sets where to ship, when that is somewhere else.
377    #[must_use]
378    pub fn shipping_address(mut self, address: Address) -> Self {
379        self.shipping_address = Some(address);
380        self
381    }
382
383    /// Adds one line to what is being paid for.
384    #[must_use]
385    pub fn item(mut self, item: BasketItem) -> Self {
386        self.basket.push(item);
387        self
388    }
389
390    /// Sets the key that makes replaying this request safe.
391    #[must_use]
392    pub fn idempotency_key(mut self, key: IdempotencyKey) -> Self {
393        self.idempotency_key = Some(key);
394        self
395    }
396
397    /// Adds one key/value pair to hand to the provider.
398    #[must_use]
399    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
400        self.metadata.insert(key.into(), value.into());
401        self
402    }
403
404    /// Checks the request and produces it.
405    pub fn build(self) -> Result<ChargeRequest, ChargeRequestError> {
406        if self.order.as_str().is_empty() {
407            return Err(ChargeRequestError::EmptyOrderRef);
408        }
409        self.amount.require_positive()?;
410        // A line that comes to nothing is a line somebody forgot to price or
411        // to count, and two of the providers that take a basket refuse one.
412        for item in &self.basket {
413            item.line_total()?.require_positive()?;
414        }
415        Ok(ChargeRequest {
416            order: self.order,
417            amount: self.amount,
418            customer: self.customer,
419            description: self.description,
420            return_url: self.return_url,
421            failure_url: self.failure_url,
422            buyer: self.buyer,
423            billing_address: self.billing_address,
424            shipping_address: self.shipping_address,
425            basket: self.basket,
426            idempotency_key: self.idempotency_key,
427            metadata: self.metadata,
428        })
429    }
430}
431
432/// A [`ChargeRequest`] was built out of parts that do not make a valid charge.
433#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
434#[non_exhaustive]
435pub enum ChargeRequestError {
436    /// The order reference was empty.
437    #[error("order reference is empty")]
438    EmptyOrderRef,
439    /// The amount was not one that can be charged.
440    #[error(transparent)]
441    Amount(#[from] MoneyError),
442}
443
444#[cfg(test)]
445mod tests {
446    use super::{ChargeRequest, ChargeRequestError, OrderRef};
447    use crate::money::{Currency, Money};
448    use crate::party::{Address, BasketItem, Buyer};
449
450    fn ten_lira() -> Money {
451        Money::from_minor_units(1000, Currency::Try)
452    }
453
454    #[test]
455    fn a_built_request_keeps_what_was_set() {
456        let request = ChargeRequest::builder(OrderRef::new("ord-1"), ten_lira())
457            .description("bir kahve")
458            .metadata("site", "vucod")
459            .build()
460            .expect("valid request");
461        assert_eq!(request.order.as_str(), "ord-1");
462        assert_eq!(request.description.as_deref(), Some("bir kahve"));
463        assert_eq!(
464            request.metadata.get("site").map(String::as_str),
465            Some("vucod")
466        );
467        assert!(request.customer.is_none());
468    }
469
470    #[test]
471    fn build_rejects_a_zero_amount() {
472        let err = ChargeRequest::builder(
473            OrderRef::new("ord-1"),
474            Money::from_minor_units(0, Currency::Try),
475        )
476        .build()
477        .expect_err("zero is not chargeable");
478        assert!(matches!(err, ChargeRequestError::Amount(_)));
479    }
480
481    #[test]
482    fn build_rejects_an_empty_order_reference() {
483        let err = ChargeRequest::builder(OrderRef::new(""), ten_lira())
484            .build()
485            .expect_err("an empty reference is not usable");
486        assert_eq!(err, ChargeRequestError::EmptyOrderRef);
487    }
488
489    #[test]
490    fn a_request_carries_a_buyer_and_a_basket_when_it_is_given_them() {
491        let request = ChargeRequest::builder(OrderRef::new("ord-1"), ten_lira())
492            .buyer(Buyer::new("Ayse", "ayse@example.test").identity_number("11111111111"))
493            .billing_address(Address::new("Bagdat Cad. 1", "Istanbul", "Turkey"))
494            .item(BasketItem::new("sku-1", "Kahve", ten_lira()))
495            .build()
496            .expect("valid request");
497
498        assert_eq!(
499            request.buyer.expect("a buyer").email.as_ref(),
500            "ayse@example.test"
501        );
502        assert_eq!(request.basket.len(), 1);
503        assert!(request.shipping_address.is_none());
504    }
505
506    #[test]
507    fn a_basket_line_that_comes_to_nothing_is_refused() {
508        let error = ChargeRequest::builder(OrderRef::new("ord-1"), ten_lira())
509            .item(BasketItem::new("sku-1", "Kahve", ten_lira()).quantity(0))
510            .build()
511            .expect_err("a line of none of something is not a line");
512        assert!(matches!(error, ChargeRequestError::Amount(_)));
513    }
514}