use kasapay_core::{Currency, Error, ErrorKind, Money, ProviderId, Status};
pub(crate) const PROVIDER: ProviderId = ProviderId::STRIPE;
pub(crate) fn currency(currency: Currency) -> Result<stripe_types::Currency, Error> {
match currency {
Currency::Try => Ok(stripe_types::Currency::TRY),
Currency::Usd => Ok(stripe_types::Currency::USD),
Currency::Eur => Ok(stripe_types::Currency::EUR),
Currency::Gbp => Ok(stripe_types::Currency::GBP),
Currency::Jpy => Ok(stripe_types::Currency::JPY),
Currency::Rub => Ok(stripe_types::Currency::RUB),
Currency::Chf => Ok(stripe_types::Currency::CHF),
Currency::Nok => Ok(stripe_types::Currency::NOK),
Currency::Kwd => Err(Error::new(
ErrorKind::Unsupported,
PROVIDER,
format!("Stripe does not settle in {currency}"),
)),
}
}
pub(crate) fn currency_back(currency: &stripe_types::Currency) -> Option<Currency> {
match currency {
stripe_types::Currency::TRY => Some(Currency::Try),
stripe_types::Currency::USD => Some(Currency::Usd),
stripe_types::Currency::EUR => Some(Currency::Eur),
stripe_types::Currency::GBP => Some(Currency::Gbp),
stripe_types::Currency::JPY => Some(Currency::Jpy),
stripe_types::Currency::RUB => Some(Currency::Rub),
stripe_types::Currency::CHF => Some(Currency::Chf),
stripe_types::Currency::NOK => Some(Currency::Nok),
_ => None,
}
}
pub(crate) fn amount(minor_units: i64, from: &stripe_types::Currency) -> Result<Money, Error> {
let currency = currency_back(from).ok_or_else(|| {
Error::new(
ErrorKind::Unsupported,
PROVIDER,
format!("kasapay has no Currency for Stripe's {from:?}"),
)
})?;
Ok(Money::from_minor_units(minor_units, currency))
}
#[expect(
clippy::match_same_arms,
reason = "naming Processing is worth more than folding it into the wildcard"
)]
pub(crate) fn status(status: &stripe_shared::PaymentIntentStatus) -> Status {
use stripe_shared::PaymentIntentStatus as S;
match status {
S::Canceled => Status::Canceled,
S::RequiresCapture => Status::Authorized,
S::Succeeded => Status::Captured,
S::RequiresAction | S::RequiresConfirmation | S::RequiresPaymentMethod => {
Status::RequiresAction
}
S::Processing => Status::Pending,
_ => Status::Pending,
}
}
pub(crate) fn error(error: &stripe::StripeError) -> Error {
let stripe::StripeError::Stripe(api, status) = error else {
let kind = match error {
stripe::StripeError::JSONDeserialize(_) => ErrorKind::Malformed,
stripe::StripeError::ClientError(_) | stripe::StripeError::Timeout => {
ErrorKind::Transport
}
stripe::StripeError::ConfigError(_) => ErrorKind::InvalidRequest,
stripe::StripeError::Stripe(..) => unreachable!("matched by the let-else"),
};
return Error::new(kind, PROVIDER, error.to_string());
};
let kind = match &api.type_ {
stripe_shared::ApiErrorsType::CardError => ErrorKind::Declined,
stripe_shared::ApiErrorsType::ApiError => ErrorKind::Provider,
_ => kind_for_status(*status),
};
let message = api
.message
.clone()
.unwrap_or_else(|| format!("Stripe answered {status} with no message"));
let error = Error::new(kind, PROVIDER, message);
match api
.decline_code
.as_deref()
.or_else(|| api.code.as_ref().map(stripe_shared::ApiErrorsCode::as_str))
{
Some(code) => error.with_code(code),
None => error,
}
}
const fn kind_for_status(code: u16) -> ErrorKind {
match code {
401 | 403 => ErrorKind::Auth,
402 => ErrorKind::Declined,
404 => ErrorKind::NotFound,
429 => ErrorKind::RateLimited,
400 | 422 => ErrorKind::InvalidRequest,
_ => ErrorKind::Provider,
}
}
#[cfg(test)]
mod tests {
use kasapay_core::{Currency, ErrorKind, Status};
#[test]
fn a_refused_card_is_a_decline_and_a_bad_request_is_not() {
assert_eq!(super::kind_for_status(402), ErrorKind::Declined);
assert!(!ErrorKind::Declined.is_retryable());
assert_eq!(super::kind_for_status(400), ErrorKind::InvalidRequest);
assert_eq!(super::kind_for_status(401), ErrorKind::Auth);
}
#[test]
fn an_authentication_failure_is_not_a_bad_request() {
assert_eq!(super::kind_for_status(401), ErrorKind::Auth);
assert_eq!(super::kind_for_status(403), ErrorKind::Auth);
assert!(!ErrorKind::Auth.is_retryable());
}
#[test]
fn a_timeout_is_worth_retrying() {
assert!(super::error(&stripe::StripeError::Timeout).is_retryable());
}
#[test]
fn every_currency_stripe_settles_survives_the_round_trip() {
for currency in every_currency() {
if let Ok(there) = super::currency(currency) {
assert_eq!(
super::currency_back(&there),
Some(currency),
"{currency} is sent to Stripe and cannot be read back"
);
}
}
}
fn every_currency() -> Vec<Currency> {
let every = vec![
Currency::Try,
Currency::Usd,
Currency::Eur,
Currency::Gbp,
Currency::Jpy,
Currency::Kwd,
Currency::Rub,
Currency::Chf,
Currency::Nok,
];
for currency in &every {
match currency {
Currency::Try
| Currency::Usd
| Currency::Eur
| Currency::Gbp
| Currency::Jpy
| Currency::Kwd
| Currency::Rub
| Currency::Chf
| Currency::Nok => {}
}
}
every
}
#[test]
fn a_currency_stripe_cannot_settle_is_refused_rather_than_rounded() {
let error = super::currency(Currency::Kwd).expect_err("Stripe has no three-place currency");
assert_eq!(error.kind(), ErrorKind::Unsupported);
}
#[test]
fn the_three_stalled_statuses_collapse_to_one() {
use stripe_shared::PaymentIntentStatus as S;
for stalled in [
S::RequiresAction,
S::RequiresConfirmation,
S::RequiresPaymentMethod,
] {
assert_eq!(super::status(&stalled), Status::RequiresAction);
}
assert_eq!(super::status(&S::Succeeded), Status::Captured);
assert_eq!(super::status(&S::RequiresCapture), Status::Authorized);
}
}