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}