use std::fmt;
use kasapay_core::{Error, ErrorKind, IdempotencyKey, InstrumentId, Money, MoneyError, OrderRef};
use crate::convert::PROVIDER;
#[derive(Debug, Clone)]
pub struct StoredCard {
pub token: InstrumentId,
pub brand: Brand,
pub last_four: Box<str>,
pub funding: Funding,
pub exp_month: i64,
pub exp_year: i64,
pub country: Option<Box<str>>,
}
impl TryFrom<stripe_shared::PaymentMethod> for StoredCard {
type Error = Error;
fn try_from(method: stripe_shared::PaymentMethod) -> Result<Self, Self::Error> {
let card = method.card.ok_or_else(|| {
Error::new(
ErrorKind::Malformed,
PROVIDER,
"a card-type PaymentMethod carried no card details",
)
})?;
Ok(Self {
token: InstrumentId::issued(method.id.as_str()),
brand: Brand::from(card.brand.as_str()),
last_four: card.last4.into_boxed_str(),
funding: Funding::from(card.funding.as_str()),
exp_month: card.exp_month,
exp_year: card.exp_year,
country: card.country.map(String::into_boxed_str),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Brand {
Amex,
CartesBancaires,
Diners,
Discover,
EftposAu,
Jcb,
Link,
Mastercard,
UnionPay,
Visa,
Unknown,
Other(Box<str>),
}
impl fmt::Display for Brand {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Amex => f.write_str("amex"),
Self::CartesBancaires => f.write_str("cartes_bancaires"),
Self::Diners => f.write_str("diners"),
Self::Discover => f.write_str("discover"),
Self::EftposAu => f.write_str("eftpos_au"),
Self::Jcb => f.write_str("jcb"),
Self::Link => f.write_str("link"),
Self::Mastercard => f.write_str("mastercard"),
Self::UnionPay => f.write_str("unionpay"),
Self::Visa => f.write_str("visa"),
Self::Unknown => f.write_str("unknown"),
Self::Other(name) => f.write_str(name),
}
}
}
impl From<&str> for Brand {
fn from(value: &str) -> Self {
match value {
"amex" => Self::Amex,
"cartes_bancaires" => Self::CartesBancaires,
"diners" => Self::Diners,
"discover" => Self::Discover,
"eftpos_au" => Self::EftposAu,
"jcb" => Self::Jcb,
"link" => Self::Link,
"mastercard" => Self::Mastercard,
"unionpay" => Self::UnionPay,
"visa" => Self::Visa,
"unknown" => Self::Unknown,
other => Self::Other(other.into()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Funding {
Credit,
Debit,
Prepaid,
Unknown,
Other(Box<str>),
}
impl fmt::Display for Funding {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Credit => f.write_str("credit"),
Self::Debit => f.write_str("debit"),
Self::Prepaid => f.write_str("prepaid"),
Self::Unknown => f.write_str("unknown"),
Self::Other(name) => f.write_str(name),
}
}
}
impl From<&str> for Funding {
fn from(value: &str) -> Self {
match value {
"credit" => Self::Credit,
"debit" => Self::Debit,
"prepaid" => Self::Prepaid,
"unknown" => Self::Unknown,
other => Self::Other(other.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct Payment {
pub order: OrderRef,
pub amount: Money,
pub customer: Box<str>,
pub instrument: InstrumentId,
pub off_session: bool,
pub description: Option<Box<str>>,
pub idempotency_key: Option<IdempotencyKey>,
}
impl Payment {
#[must_use]
pub fn builder(
order: OrderRef,
amount: Money,
customer: impl Into<Box<str>>,
instrument: InstrumentId,
) -> PaymentBuilder {
PaymentBuilder {
order,
amount,
customer: customer.into(),
instrument,
off_session: false,
description: None,
idempotency_key: None,
}
}
}
#[derive(Debug, Clone)]
pub struct PaymentBuilder {
order: OrderRef,
amount: Money,
customer: Box<str>,
instrument: InstrumentId,
off_session: bool,
description: Option<Box<str>>,
idempotency_key: Option<IdempotencyKey>,
}
impl PaymentBuilder {
#[must_use]
pub const fn off_session(mut self) -> Self {
self.off_session = true;
self
}
#[must_use]
pub fn description(mut self, description: impl Into<Box<str>>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub fn idempotency_key(mut self, key: IdempotencyKey) -> Self {
self.idempotency_key = Some(key);
self
}
pub fn build(self) -> Result<Payment, PaymentError> {
if self.order.as_str().is_empty() {
return Err(PaymentError::EmptyOrderRef);
}
if self.customer.is_empty() {
return Err(PaymentError::EmptyCustomer);
}
if self.instrument.as_str().is_empty() {
return Err(PaymentError::EmptyInstrument);
}
for (field, value) in [
("customer", &*self.customer),
("payment_method", self.instrument.as_str()),
] {
if looks_like_a_card_number(value) {
return Err(PaymentError::CardNumber { field });
}
}
self.amount.require_positive()?;
Ok(Payment {
order: self.order,
amount: self.amount,
customer: self.customer,
instrument: self.instrument,
off_session: self.off_session,
description: self.description,
idempotency_key: self.idempotency_key,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum PaymentError {
#[error("order reference is empty")]
EmptyOrderRef,
#[error("a saved-card payment needs the customer it is saved under")]
EmptyCustomer,
#[error("a saved-card payment needs the PaymentMethod id to charge")]
EmptyInstrument,
#[error(
"{field} was given what is a card number by shape; a saved instrument is named by \
Stripe's own handles, and a card number reaching this process is the thing this refuses"
)]
CardNumber {
field: &'static str,
},
#[error(transparent)]
Amount(#[from] MoneyError),
}
fn looks_like_a_card_number(value: &str) -> bool {
let digits = value.as_bytes();
if !(12..=19).contains(&digits.len()) || !digits.iter().all(u8::is_ascii_digit) {
return false;
}
let sum: u32 = digits
.iter()
.rev()
.enumerate()
.map(|(place, digit)| {
let value = u32::from(*digit - b'0');
if place % 2 == 0 {
value
} else if value > 4 {
value * 2 - 9
} else {
value * 2
}
})
.sum();
sum.is_multiple_of(10)
}
#[cfg(test)]
mod tests {
use kasapay_core::{Currency, InstrumentId, Money, OrderRef};
use super::{Brand, Funding, Payment, PaymentError, looks_like_a_card_number};
fn ten_dollars() -> Money {
Money::parse("10.00", Currency::Usd).expect("valid amount")
}
#[test]
fn every_documented_brand_renders_back_to_what_stripe_sent() {
for name in [
"amex",
"cartes_bancaires",
"diners",
"discover",
"eftpos_au",
"jcb",
"link",
"mastercard",
"unionpay",
"visa",
"unknown",
] {
assert_eq!(Brand::from(name).to_string(), name);
}
let unknown = Brand::from("some_future_brand");
assert_eq!(unknown, Brand::Other("some_future_brand".into()));
assert_eq!(unknown.to_string(), "some_future_brand");
}
#[test]
fn every_documented_funding_renders_back_to_what_stripe_sent() {
for name in ["credit", "debit", "prepaid", "unknown"] {
assert_eq!(Funding::from(name).to_string(), name);
}
assert_eq!(Funding::from("giro"), Funding::Other("giro".into()));
}
#[test]
fn a_card_number_is_not_a_handle_on_one() {
for number in ["4242424242424242", "5555555555554444"] {
assert!(looks_like_a_card_number(number), "{number}");
}
for handle in ["pm_1Pgc75B7WZ01zgkWlHVgdEGJ", "cus_Kasapay1", "424242424"] {
assert!(!looks_like_a_card_number(handle), "{handle}");
}
}
#[test]
fn a_card_number_wired_into_the_instrument_is_refused() {
let err = Payment::builder(
OrderRef::new("ord-1"),
ten_dollars(),
"cus_kasapay1",
InstrumentId::issued("4242424242424242"),
)
.build()
.expect_err("a card number does not name a saved instrument");
assert_eq!(
err,
PaymentError::CardNumber {
field: "payment_method"
}
);
assert!(!err.to_string().contains("4242424242424242"));
}
#[test]
fn an_empty_customer_or_instrument_is_refused_before_a_request_is_built() {
assert_eq!(
Payment::builder(
OrderRef::new("ord-1"),
ten_dollars(),
"",
InstrumentId::issued("pm_1")
)
.build()
.expect_err("no customer"),
PaymentError::EmptyCustomer
);
assert_eq!(
Payment::builder(
OrderRef::new("ord-1"),
ten_dollars(),
"cus_kasapay1",
InstrumentId::issued("")
)
.build()
.expect_err("no instrument"),
PaymentError::EmptyInstrument
);
}
#[test]
fn off_session_defaults_to_false_and_the_builder_can_set_it() {
let on_session = Payment::builder(
OrderRef::new("ord-1"),
ten_dollars(),
"cus_kasapay1",
InstrumentId::issued("pm_1"),
)
.build()
.expect("valid payment");
assert!(!on_session.off_session);
let off_session = Payment::builder(
OrderRef::new("ord-1"),
ten_dollars(),
"cus_kasapay1",
InstrumentId::issued("pm_1"),
)
.off_session()
.build()
.expect("valid payment");
assert!(off_session.off_session);
}
}