Skip to main content

kasapay_core/
party.rs

1//! Who is paying, where they are, and what they are buying.
2//!
3//! Three of the five providers this workspace ships refuse a payment without
4//! these. iyzico's hosted form wants a buyer with a national identity number,
5//! an address and an itemised basket; PayTR's wants an email, an address, a
6//! phone number and the payer's IP; PayPal builds a purchase unit out of much
7//! the same. Stripe and Mollie want none of it.
8//!
9//! # Why this is in core rather than in each adapter
10//!
11//! Because it was in each adapter, and the cost of that was the library's own
12//! promise: [`Provider::charge`](crate::Provider::charge) answered
13//! [`ErrorKind::Unsupported`](crate::ErrorKind::Unsupported) for iyzico's
14//! classic API and for PayTR, so "which provider takes the money is a
15//! deployment decision rather than a rewrite" was false for exactly the two a
16//! Turkish shop would swap between. A caller had to reach past the trait and
17//! build a `classic::CheckoutForm` or a `paytr::Payment` by hand.
18//!
19//! A buyer and a basket are not a provider's idea. They are a shop's, and a
20//! shop that is taking payments has both already.
21//!
22//! # None of it is required
23//!
24//! [`ChargeRequest`](crate::ChargeRequest) carries these as options, and a
25//! Stripe or Mollie caller who never sets one is unaffected. An adapter that
26//! needs a field and does not get it answers
27//! [`ErrorKind::InvalidRequest`](crate::ErrorKind::InvalidRequest) **naming
28//! the field**, before a socket opens — which is a better failure than the
29//! provider's own 400, and a much better one than a method that refuses every
30//! call.
31
32use crate::money::{Money, MoneyError};
33
34/// Somewhere to bill, ship or register a payer at.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[non_exhaustive]
37pub struct Address {
38    /// Who the address is for, where the provider asks separately.
39    ///
40    /// iyzico requires it on a billing address. `None` is a caller who did not
41    /// say, and an adapter that needs one falls back to the buyer's own name
42    /// rather than sending nothing.
43    pub contact_name: Option<Box<str>>,
44    /// The street address, as one line.
45    pub line: Box<str>,
46    /// City.
47    pub city: Box<str>,
48    /// Country.
49    pub country: Box<str>,
50    /// Postcode, where there is one.
51    pub zip_code: Option<Box<str>>,
52}
53
54impl Address {
55    /// An address with the three parts every provider that wants one asks for.
56    #[must_use]
57    pub fn new(
58        line: impl Into<Box<str>>,
59        city: impl Into<Box<str>>,
60        country: impl Into<Box<str>>,
61    ) -> Self {
62        Self {
63            contact_name: None,
64            line: line.into(),
65            city: city.into(),
66            country: country.into(),
67            zip_code: None,
68        }
69    }
70
71    /// Names who the address is for.
72    #[must_use]
73    pub fn contact_name(mut self, name: impl Into<Box<str>>) -> Self {
74        self.contact_name = Some(name.into());
75        self
76    }
77
78    /// Adds the postcode.
79    #[must_use]
80    pub fn zip_code(mut self, zip_code: impl Into<Box<str>>) -> Self {
81        self.zip_code = Some(zip_code.into());
82        self
83    }
84}
85
86/// The person paying.
87///
88/// Only the name and the email are required here, because they are the two
89/// every provider that asks for a buyer asks for. The rest are `Option`
90/// because which of them is mandatory is the provider's own decision — iyzico
91/// refuses a payment without an identity number, PayTR without the payer's IP
92/// — and an adapter says which one is missing when it needs it.
93#[derive(Debug, Clone, PartialEq, Eq)]
94#[non_exhaustive]
95pub struct Buyer {
96    /// Given name. A provider that wants one field takes this.
97    pub name: Box<str>,
98    /// Family name, where the provider keeps them apart. iyzico does.
99    pub surname: Option<Box<str>>,
100    /// Email address.
101    pub email: Box<str>,
102    /// Mobile number.
103    pub phone: Option<Box<str>>,
104    /// Turkish national identity number, or the equivalent. iyzico's classic
105    /// API requires one on every hosted form.
106    pub identity_number: Option<Box<str>>,
107    /// The address the request came from, which fraud checks read. PayTR
108    /// requires it.
109    pub ip: Option<Box<str>>,
110    /// Where the payer is registered, which iyzico sends as
111    /// `registrationAddress` and PayTR as the payer's address.
112    pub address: Option<Address>,
113}
114
115impl Buyer {
116    /// A buyer with the two fields every provider that wants one asks for.
117    #[must_use]
118    pub fn new(name: impl Into<Box<str>>, email: impl Into<Box<str>>) -> Self {
119        Self {
120            name: name.into(),
121            surname: None,
122            email: email.into(),
123            phone: None,
124            identity_number: None,
125            ip: None,
126            address: None,
127        }
128    }
129
130    /// Adds the family name.
131    #[must_use]
132    pub fn surname(mut self, surname: impl Into<Box<str>>) -> Self {
133        self.surname = Some(surname.into());
134        self
135    }
136
137    /// Adds the mobile number.
138    #[must_use]
139    pub fn phone(mut self, phone: impl Into<Box<str>>) -> Self {
140        self.phone = Some(phone.into());
141        self
142    }
143
144    /// Adds the national identity number.
145    #[must_use]
146    pub fn identity_number(mut self, identity_number: impl Into<Box<str>>) -> Self {
147        self.identity_number = Some(identity_number.into());
148        self
149    }
150
151    /// Adds the address the request came from.
152    #[must_use]
153    pub fn ip(mut self, ip: impl Into<Box<str>>) -> Self {
154        self.ip = Some(ip.into());
155        self
156    }
157
158    /// Adds where the payer is registered.
159    #[must_use]
160    pub fn address(mut self, address: Address) -> Self {
161        self.address = Some(address);
162        self
163    }
164}
165
166/// What kind of thing is being sold.
167///
168/// iyzico refuses a basket line without it, and it is what decides whether a
169/// shipping address means anything.
170///
171/// Deliberately exhaustive, for the reason
172/// [`Currency`](crate::Currency) is: it goes out to a provider, so a third
173/// variant would be one every adapter has to say what it sends for. A
174/// wildcard arm would let it go out as whatever the last variant mapped to.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
176pub enum ItemKind {
177    /// Something that ships. The default, because most baskets ship.
178    #[default]
179    Physical,
180    /// Something that does not.
181    Virtual,
182}
183
184/// One line of what is being paid for.
185///
186/// [`BasketItem::price`] is what **one** of the thing costs, and
187/// [`BasketItem::line_total`] is what the line comes to. The distinction is
188/// not cosmetic: PayTR takes the unit price and the count as two fields, and
189/// iyzico takes one figure and has nowhere to put a count, so an adapter that
190/// guessed which of the two it had been handed would silently charge a basket
191/// of three for one.
192///
193/// The sum of a basket does not have to equal
194/// [`ChargeRequest::amount`](crate::ChargeRequest::amount), and nothing here
195/// checks that it does: an instalment surcharge is money the payer is charged
196/// and not a line of the basket, which is exactly the difference
197/// [`Charge::order_amount`](crate::Charge::order_amount) exists for.
198#[derive(Debug, Clone, PartialEq, Eq)]
199#[non_exhaustive]
200pub struct BasketItem {
201    /// The shop's own id for it.
202    pub id: Box<str>,
203    /// What it is called.
204    pub name: Box<str>,
205    /// The category it sits in. iyzico asks; nobody else does.
206    pub category: Option<Box<str>>,
207    /// Whether it ships.
208    pub kind: ItemKind,
209    /// What one of it costs. [`BasketItem::line_total`] is the whole line.
210    pub price: Money,
211    /// How many. PayTR sends this as its own field; iyzico has no such field,
212    /// so its adapter sends the line total instead.
213    pub quantity: u32,
214}
215
216impl BasketItem {
217    /// One line, priced.
218    #[must_use]
219    pub fn new(id: impl Into<Box<str>>, name: impl Into<Box<str>>, price: Money) -> Self {
220        Self {
221            id: id.into(),
222            name: name.into(),
223            category: None,
224            kind: ItemKind::Physical,
225            price,
226            quantity: 1,
227        }
228    }
229
230    /// Says which category it sits in.
231    #[must_use]
232    pub fn category(mut self, category: impl Into<Box<str>>) -> Self {
233        self.category = Some(category.into());
234        self
235    }
236
237    /// Marks the line as something that does not ship.
238    #[must_use]
239    pub const fn virtual_item(mut self) -> Self {
240        self.kind = ItemKind::Virtual;
241        self
242    }
243
244    /// Says how many of it.
245    #[must_use]
246    pub const fn quantity(mut self, quantity: u32) -> Self {
247        self.quantity = quantity;
248        self
249    }
250
251    /// What the line comes to: the unit price times the count.
252    pub fn line_total(&self) -> Result<Money, MoneyError> {
253        self.price.checked_mul(self.quantity)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::{Address, BasketItem, Buyer, ItemKind};
260    use crate::money::{Currency, Money};
261
262    #[test]
263    fn a_buyer_carries_only_what_every_provider_asks_for_until_told_more() {
264        let plain = Buyer::new("Ayse", "ayse@example.test");
265        assert!(plain.surname.is_none());
266        assert!(plain.identity_number.is_none());
267
268        let full = Buyer::new("Ayse", "ayse@example.test")
269            .surname("Yilmaz")
270            .identity_number("11111111111")
271            .ip("203.0.113.7")
272            .phone("+905350000000")
273            .address(Address::new("Bagdat Cad. 1", "Istanbul", "Turkey").zip_code("34000"));
274        assert_eq!(full.surname.as_deref(), Some("Yilmaz"));
275        assert_eq!(
276            full.address.expect("an address").zip_code.as_deref(),
277            Some("34000")
278        );
279    }
280
281    #[test]
282    fn a_line_ships_unless_it_is_told_not_to() {
283        let item = BasketItem::new(
284            "sku-1",
285            "Kahve",
286            Money::from_minor_units(14_990, Currency::Try),
287        );
288        assert_eq!(item.kind, ItemKind::Physical);
289        assert_eq!(item.quantity, 1);
290
291        let download =
292            BasketItem::new("sku-2", "PDF", Money::from_minor_units(1000, Currency::Try))
293                .virtual_item()
294                .quantity(3)
295                .category("Kitap");
296        assert_eq!(download.kind, ItemKind::Virtual);
297        assert_eq!(download.quantity, 3);
298        assert_eq!(download.category.as_deref(), Some("Kitap"));
299    }
300
301    #[test]
302    fn a_line_total_is_the_unit_price_times_the_count() {
303        let three = BasketItem::new(
304            "sku-1",
305            "Kahve",
306            Money::from_minor_units(1499, Currency::Try),
307        )
308        .quantity(3);
309        assert_eq!(
310            three.line_total().expect("no overflow"),
311            Money::from_minor_units(4497, Currency::Try)
312        );
313    }
314
315    #[test]
316    fn a_line_that_would_overflow_says_so_rather_than_wrapping() {
317        let absurd = BasketItem::new(
318            "sku-1",
319            "Ev",
320            Money::from_minor_units(i64::MAX, Currency::Try),
321        )
322        .quantity(2);
323        assert!(absurd.line_total().is_err());
324    }
325}