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}