use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use kasapay_core::{
Capabilities, Charge, ChargeRequest, Error, ErrorKind, Instrument, InstrumentId, Money,
NextAction, OrderRef, PaymentId, Provider, ProviderId, Raw, RefundId, RefundReason,
RefundRequest, RefundStatus, Secret,
};
use stripe::{IdempotencyKey, RequestStrategy, StripeRequest};
use stripe_client_core::{RequestBuilder, StripeMethod};
use stripe_core::customer::{ListPaymentMethodsCustomer, ListPaymentMethodsCustomerType};
use stripe_core::payment_intent::{
CancelPaymentIntent, CapturePaymentIntent, CreatePaymentIntent, CreatePaymentIntentOffSession,
RetrievePaymentIntent,
};
use stripe_core::refund::{CreateRefund, CreateRefundReason, ListRefund};
use crate::convert;
use crate::saved;
pub const ORDER_METADATA_KEY: &str = "kasapay_order";
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const REFUND_PAGE_SIZE: i64 = 100;
const STORED_CARDS_PAGE_SIZE: i64 = 100;
#[derive(Debug, Clone)]
pub struct Stripe {
inner: Arc<stripe::Client>,
}
impl Stripe {
#[must_use]
pub fn new(secret_key: &Secret) -> Self {
Self {
inner: Arc::new(stripe::Client::new(secret_key.expose())),
}
}
pub fn at(base_url: &str, secret_key: &Secret) -> Result<Self, Error> {
let base = format!("{}/", base_url.trim_end_matches('/'));
let client = stripe::ClientBuilder::new(secret_key.expose())
.url(base)
.build()
.map_err(|e| convert::error(&e))?;
Ok(Self::with_client(client))
}
#[must_use]
pub fn with_client(client: stripe::Client) -> Self {
Self {
inner: Arc::new(client),
}
}
pub async fn refund(
&self,
payment: &PaymentId,
amount: Option<Money>,
) -> Result<Refund, Error> {
let mut request = RefundRequest::builder(payment.clone());
if let Some(amount) = amount {
request = request.amount(amount);
}
let request = request.build().map_err(|e| {
Error::new(
ErrorKind::InvalidRequest,
convert::PROVIDER,
"a refund takes an amount above zero, or None for the lot",
)
.with_source(e)
})?;
self.create_refund(&request).await
}
async fn create_refund(&self, request: &RefundRequest) -> Result<Refund, Error> {
let mut create = CreateRefund::new().payment_intent(request.payment.as_str().to_owned());
if let Some(amount) = request.amount {
create = create.amount(amount.minor_units());
}
if let Some(reason) = request.reason.as_ref().and_then(refund_reason) {
create = create.reason(reason);
}
let metadata = refund_metadata(request);
if !metadata.is_empty() {
create = create.metadata(metadata);
}
let refund = match &request.idempotency_key {
Some(key) => {
create
.customize()
.request_strategy(RequestStrategy::Idempotent(idempotency_key(key)?))
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
None => {
create
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
}
.map_err(|e| convert::error(&e).with_source(e))?;
let refund = into_refund(refund, &request.payment)?;
let refunded = refund.amount;
if let Some(asked) = request.amount
&& asked.currency() != refunded.currency()
{
return Err(Error::new(
ErrorKind::Malformed,
convert::PROVIDER,
format!(
"asked to refund {asked} and Stripe refunded {refunded}: \
the payment was not in the currency the caller thought"
),
));
}
Ok(refund)
}
pub async fn refunds(&self, payment: &PaymentId) -> Result<Vec<Refund>, Error> {
let mut refunds = Vec::new();
let mut cursor: Option<String> = None;
let mut followed: HashSet<String> = HashSet::new();
loop {
let mut list = ListRefund::new()
.payment_intent(payment.as_str().to_owned())
.limit(REFUND_PAGE_SIZE);
if let Some(after) = cursor.take() {
list = list.starting_after(after);
}
let page = list
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
.map_err(|e| convert::error(&e).with_source(e))?;
cursor = page.data.last().map(|last| last.id.as_str().to_owned());
for refund in page.data {
refunds.push(into_refund(refund, payment)?);
}
if !page.has_more || cursor.is_none() {
return Ok(refunds);
}
if !followed.insert(cursor.clone().unwrap_or_default()) {
return Ok(refunds);
}
}
}
pub async fn cancel(&self, payment: &PaymentId) -> Result<Charge, Error> {
let intent = CancelPaymentIntent::new(payment.as_str().to_owned())
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
.map_err(|e| convert::error(&e).with_source(e))?;
into_charge(&intent)
}
pub async fn stored_cards(&self, customer: &str) -> Result<Vec<saved::StoredCard>, Error> {
let mut cards = Vec::new();
let mut cursor: Option<String> = None;
let mut followed: HashSet<String> = HashSet::new();
loop {
let mut list = ListPaymentMethodsCustomer::new(customer.to_owned())
.type_(ListPaymentMethodsCustomerType::Card)
.limit(STORED_CARDS_PAGE_SIZE);
if let Some(after) = cursor.take() {
list = list.starting_after(after);
}
let page = list
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
.map_err(|e| convert::error(&e).with_source(e))?;
cursor = page.data.last().map(|last| last.id.as_str().to_owned());
for method in page.data {
cards.push(saved::StoredCard::try_from(method)?);
}
if !page.has_more || cursor.is_none() {
return Ok(cards);
}
if !followed.insert(cursor.clone().unwrap_or_default()) {
return Ok(cards);
}
}
}
pub async fn charge_saved_card(&self, payment: &saved::Payment) -> Result<Charge, Error> {
let mut create = CreatePaymentIntent::new(
payment.amount.minor_units(),
convert::currency(payment.amount.currency())?,
)
.customer(payment.customer.to_string())
.payment_method(payment.instrument.as_str().to_owned())
.confirm(true)
.metadata(saved_metadata(payment));
if payment.off_session {
create = create.off_session(CreatePaymentIntentOffSession::Bool(true));
}
if let Some(description) = &payment.description {
create = create.description(description.to_string());
}
let intent = match &payment.idempotency_key {
Some(key) => {
create
.customize()
.request_strategy(RequestStrategy::Idempotent(idempotency_key(key)?))
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
None => {
create
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
}
.map_err(|e| convert::error(&e).with_source(e))?;
into_charge(&intent)
}
pub async fn forget_card(&self, instrument: &InstrumentId) -> Result<(), Error> {
let request = DetachPaymentMethod {
payment_method: instrument.as_str().into(),
};
request
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
.map_err(|e| convert::error(&e).with_source(e))?;
Ok(())
}
#[must_use]
pub fn client(&self) -> &stripe::Client {
&self.inner
}
}
struct DetachPaymentMethod {
payment_method: Box<str>,
}
impl StripeRequest for DetachPaymentMethod {
type Output = stripe_shared::PaymentMethod;
fn build(&self) -> RequestBuilder {
RequestBuilder::new(
StripeMethod::Post,
format!("/payment_methods/{}/detach", self.payment_method),
)
}
}
#[async_trait::async_trait]
impl Provider for Stripe {
fn id(&self) -> ProviderId {
convert::PROVIDER
}
async fn charge(&self, request: &ChargeRequest) -> Result<Charge, Error> {
let mut create = CreatePaymentIntent::new(
request.amount.minor_units(),
convert::currency(request.amount.currency())?,
)
.metadata(metadata(request));
if let Some(description) = &request.description {
create = create.description(description.to_string());
}
if let Some(customer) = &request.customer {
create = create.customer(customer.to_string());
}
if let Some(return_url) = &request.return_url {
create = create.return_url(return_url.to_string());
}
let intent = match &request.idempotency_key {
Some(key) => {
create
.customize()
.request_strategy(RequestStrategy::Idempotent(idempotency_key(key)?))
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
None => {
create
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
}
.map_err(|e| convert::error(&e).with_source(e))?;
into_charge(&intent)
}
async fn resume(&self, _continuation: &str) -> Result<Charge, Error> {
Err(Error::new(
ErrorKind::Unsupported,
convert::PROVIDER,
"Stripe names a PaymentIntent when it opens one; read it back with \
Provider::charge_status",
))
}
async fn charge_status(&self, id: &PaymentId) -> Result<Charge, Error> {
let intent = RetrievePaymentIntent::new(id.as_str().to_owned())
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
.map_err(|e| convert::error(&e).with_source(e))?;
into_charge(&intent)
}
async fn capture(
&self,
id: &PaymentId,
amount: Option<Money>,
idempotency: Option<&kasapay_core::IdempotencyKey>,
) -> Result<Charge, Error> {
let mut capture = CapturePaymentIntent::new(id.as_str().to_owned());
if let Some(amount) = amount {
amount.require_positive().map_err(|e| {
Error::new(
ErrorKind::InvalidRequest,
convert::PROVIDER,
"a capture takes an amount above zero, or None for the lot",
)
.with_source(e)
})?;
capture = capture.amount_to_capture(amount.minor_units());
}
let intent = match idempotency {
Some(key) => {
capture
.customize()
.request_strategy(RequestStrategy::Idempotent(idempotency_key(key)?))
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
None => {
capture
.customize()
.timeout(DEFAULT_TIMEOUT)
.send(self.inner.as_ref())
.await
}
}
.map_err(|e| convert::error(&e).with_source(e))?;
into_charge(&intent)
}
async fn cancel(&self, id: &PaymentId) -> Result<Charge, Error> {
Stripe::cancel(self, id).await
}
async fn refund(&self, request: &RefundRequest) -> Result<kasapay_core::Refund, Error> {
let refund = self.create_refund(request).await?;
Ok(kasapay_core::Refund {
id: Some(RefundId::issued(refund.id)),
payment: refund.payment,
amount: refund.amount,
status: refund_status(&refund.status),
next_action: None,
provider: convert::PROVIDER,
raw: refund.raw,
})
}
async fn lookup(&self, _order: &OrderRef) -> Result<Option<Charge>, Error> {
Err(Error::new(
ErrorKind::Unsupported,
convert::PROVIDER,
"Stripe finds a payment by metadata only through its search API, which it \
documents as too far behind to answer this; retry the charge with the same \
idempotency key instead",
))
}
async fn instruments(&self, customer: &str) -> Result<Vec<Instrument>, Error> {
Ok(self
.stored_cards(customer)
.await?
.into_iter()
.map(instrument_from_stored_card)
.collect())
}
fn capabilities(&self) -> Capabilities {
Capabilities {
separate_capture: true,
partial_capture: true,
partial_refund: true,
repeated_refund: true,
lookup_by_order: false,
resume_by_continuation: false,
saved_instruments: true,
}
}
}
pub const REFUND_REASON_METADATA_KEY: &str = "kasapay_refund_reason";
fn refund_reason(reason: &RefundReason) -> Option<CreateRefundReason> {
match reason {
RefundReason::Duplicate => Some(CreateRefundReason::Duplicate),
RefundReason::Fraudulent => Some(CreateRefundReason::Fraudulent),
RefundReason::RequestedByCustomer => Some(CreateRefundReason::RequestedByCustomer),
RefundReason::Other(_) => None,
}
}
fn refund_metadata(request: &RefundRequest) -> std::collections::HashMap<String, String> {
let mut pairs: std::collections::HashMap<String, String> = request
.metadata
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
if let Some(RefundReason::Other(words)) = &request.reason {
pairs.insert(REFUND_REASON_METADATA_KEY.to_owned(), words.to_string());
}
pairs
}
fn refund_status(state: &RefundState) -> RefundStatus {
match state {
RefundState::Pending | RefundState::Other(_) => RefundStatus::Pending,
RefundState::RequiresAction => RefundStatus::RequiresAction,
RefundState::Succeeded => RefundStatus::Succeeded,
RefundState::Failed => RefundStatus::Failed,
RefundState::Canceled => RefundStatus::Canceled,
}
}
fn idempotency_key(key: &kasapay_core::IdempotencyKey) -> Result<IdempotencyKey, Error> {
IdempotencyKey::new(key.as_str()).map_err(|e| {
Error::new(
ErrorKind::InvalidRequest,
convert::PROVIDER,
"Stripe will not accept this idempotency key",
)
.with_source(e)
})
}
fn metadata(request: &ChargeRequest) -> std::collections::HashMap<String, String> {
let mut pairs: std::collections::HashMap<String, String> = request
.metadata
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
pairs.insert(
ORDER_METADATA_KEY.to_owned(),
request.order.as_str().to_owned(),
);
pairs
}
fn saved_metadata(payment: &saved::Payment) -> std::collections::HashMap<String, String> {
std::collections::HashMap::from([(
ORDER_METADATA_KEY.to_owned(),
payment.order.as_str().to_owned(),
)])
}
fn into_charge(intent: &stripe_shared::PaymentIntent) -> Result<Charge, Error> {
let status = convert::status(&intent.status);
let amount = if status == kasapay_core::Status::Captured && intent.amount_received > 0 {
convert::amount(intent.amount_received, &intent.currency)?
} else {
convert::amount(intent.amount, &intent.currency)?
};
let order = intent
.metadata
.get(ORDER_METADATA_KEY)
.map(|value| OrderRef::new(value.as_str()));
let next_action = if status == kasapay_core::Status::RequiresAction {
intent
.client_secret
.as_deref()
.map(|secret| NextAction::ConfirmOnClient {
client_secret: secret.into(),
})
} else {
None
};
let raw = serde_json::to_value(RawIntent::from(intent))
.map(|value| Raw::from_json(&value))
.map_err(|e| {
Error::new(
ErrorKind::Malformed,
convert::PROVIDER,
"PaymentIntent could not be echoed as JSON",
)
.with_source(e)
})?;
Ok(Charge {
id: Some(PaymentId::issued(intent.id.as_str())),
order,
amount,
order_amount: None,
status,
next_action,
provider: convert::PROVIDER,
raw,
})
}
fn into_refund(refund: stripe_shared::Refund, payment: &PaymentId) -> Result<Refund, Error> {
Ok(Refund {
id: refund.id.as_str().into(),
payment: payment.clone(),
amount: convert::amount(refund.amount, &refund.currency)?,
status: refund
.status
.as_deref()
.map_or(RefundState::Pending, RefundState::from),
raw: Raw::from_json(&serde_json::json!({
"id": refund.id.as_str(),
"amount": refund.amount,
"currency": format!("{:?}", refund.currency),
"status": refund.status,
"reason": refund.reason.map(|r| format!("{r:?}")),
"failure_reason": refund.failure_reason,
})),
})
}
fn instrument_from_stored_card(card: saved::StoredCard) -> Instrument {
let raw = Raw::from_json(&serde_json::json!({
"id": card.token.as_str(),
"brand": card.brand.to_string(),
"last4": &*card.last_four,
"funding": card.funding.to_string(),
"exp_month": card.exp_month,
"exp_year": card.exp_year,
"country": card.country.as_deref(),
}));
let label = Some(format!("{} •••• {}", card.brand, card.last_four).into());
Instrument {
id: card.token,
label,
raw,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RefundState {
Pending,
RequiresAction,
Succeeded,
Failed,
Canceled,
Other(Box<str>),
}
impl From<&str> for RefundState {
fn from(value: &str) -> Self {
match value {
"pending" => Self::Pending,
"requires_action" => Self::RequiresAction,
"succeeded" => Self::Succeeded,
"failed" => Self::Failed,
"canceled" => Self::Canceled,
other => Self::Other(other.into()),
}
}
}
#[derive(Debug, Clone)]
pub struct Refund {
pub id: Box<str>,
pub payment: PaymentId,
pub amount: Money,
pub status: RefundState,
pub raw: Raw,
}
#[derive(serde::Serialize)]
struct RawIntent<'a> {
id: &'a str,
amount: i64,
currency: String,
status: String,
client_secret: Option<&'a str>,
metadata: &'a std::collections::HashMap<String, String>,
}
impl<'a> From<&'a stripe_shared::PaymentIntent> for RawIntent<'a> {
fn from(intent: &'a stripe_shared::PaymentIntent) -> Self {
Self {
id: intent.id.as_str(),
amount: intent.amount,
currency: format!("{:?}", intent.currency),
status: format!("{:?}", intent.status),
client_secret: intent.client_secret.as_deref(),
metadata: &intent.metadata,
}
}
}