use async_trait::async_trait;
use serde_json::Value;
use std::collections::BTreeMap;
use thiserror::Error;
use time::{OffsetDateTime, UtcOffset};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Money {
pub minor_units: i64,
pub currency: String,
}
impl Money {
#[must_use]
pub fn new(minor_units: i64, currency: impl Into<String>) -> Self {
Self {
minor_units,
currency: currency.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LineItem {
pub name: String,
pub amount: Money,
pub quantity: u32,
}
#[derive(Debug, Clone)]
pub struct CheckoutRequest {
pub customer_ref: Option<String>,
pub customer_email: Option<String>,
pub line: LineItem,
pub success_url: String,
pub cancel_url: String,
pub metadata: BTreeMap<String, String>,
pub idempotency_key: String,
}
#[derive(Debug, Clone)]
pub struct SubscriptionCheckoutRequest {
pub customer_ref: Option<String>,
pub customer_email: Option<String>,
pub price_ref: String,
pub trial_days: Option<u32>,
pub success_url: String,
pub cancel_url: String,
pub metadata: BTreeMap<String, String>,
pub idempotency_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CheckoutSession {
pub id: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct ConnectAccountLinkRequest {
pub account_ref: Option<String>,
pub refresh_url: String,
pub return_url: String,
pub idempotency_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConnectAccountLink {
pub account_id: String,
pub url: String,
}
#[derive(Debug, Clone)]
pub struct TransferCharge {
pub customer_ref: Option<String>,
pub amount: Money,
pub destination_account: String,
pub application_fee: Money,
pub metadata: BTreeMap<String, String>,
pub idempotency_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Charge {
pub id: String,
pub status: String,
}
#[derive(Debug, Clone)]
pub struct RefundRequest {
pub payment_ref: String,
pub amount: Option<Money>,
pub idempotency_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Refund {
pub id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageReport {
pub meter_event_name: String,
pub customer_ref: String,
pub value: u64,
pub identifier: String,
pub timestamp: OffsetDateTime,
}
impl UsageReport {
#[must_use]
pub fn hourly(
subject: &str,
meter_event_name: &str,
customer_ref: &str,
value: u64,
at: OffsetDateTime,
) -> Self {
let at = at.to_offset(UtcOffset::UTC);
let start = at
.replace_minute(0)
.and_then(|t| t.replace_second(0))
.and_then(|t| t.replace_nanosecond(0))
.unwrap_or(at);
Self {
meter_event_name: meter_event_name.to_owned(),
customer_ref: customer_ref.to_owned(),
value,
identifier: format!("{subject}:{meter_event_name}:{}", start.unix_timestamp()),
timestamp: start,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UsageReported {
pub identifier: String,
pub already_reported: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebhookEvent {
pub id: String,
pub kind: String,
pub data: Value,
}
#[derive(Debug, Clone)]
pub struct PortalSessionRequest {
pub customer_ref: String,
pub return_url: String,
pub idempotency_key: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PortalSession {
pub url: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SubscriptionStatus {
Trialing,
Active,
PastDue,
Canceled,
Unpaid,
Incomplete,
IncompleteExpired,
Paused,
Other(String),
}
impl SubscriptionStatus {
#[must_use]
pub fn from_provider(status: &str) -> Self {
match status {
"trialing" => Self::Trialing,
"active" => Self::Active,
"past_due" => Self::PastDue,
"canceled" => Self::Canceled,
"unpaid" => Self::Unpaid,
"incomplete" => Self::Incomplete,
"incomplete_expired" => Self::IncompleteExpired,
"paused" => Self::Paused,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
match self {
Self::Trialing => "trialing",
Self::Active => "active",
Self::PastDue => "past_due",
Self::Canceled => "canceled",
Self::Unpaid => "unpaid",
Self::Incomplete => "incomplete",
Self::IncompleteExpired => "incomplete_expired",
Self::Paused => "paused",
Self::Other(other) => other,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Subscription {
pub id: String,
pub customer_ref: String,
pub status: SubscriptionStatus,
pub price_ref: Option<String>,
pub quantity: u32,
pub current_period_end: Option<OffsetDateTime>,
pub cancel_at_period_end: bool,
pub metadata: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DisputeStatus {
EarlyWarning,
WarningNeedsResponse,
WarningUnderReview,
WarningClosed,
NeedsResponse,
UnderReview,
Won,
Lost,
Prevented,
Other(String),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DisputePhase {
Open,
Won,
Lost,
Closed,
}
impl DisputeStatus {
#[must_use]
pub fn from_provider(status: &str) -> Self {
match status {
"early_warning" => Self::EarlyWarning,
"warning_needs_response" => Self::WarningNeedsResponse,
"warning_under_review" => Self::WarningUnderReview,
"warning_closed" => Self::WarningClosed,
"needs_response" => Self::NeedsResponse,
"under_review" => Self::UnderReview,
"won" => Self::Won,
"lost" => Self::Lost,
"prevented" => Self::Prevented,
other => Self::Other(other.to_owned()),
}
}
#[must_use]
pub fn phase(&self) -> DisputePhase {
match self {
Self::EarlyWarning
| Self::WarningNeedsResponse
| Self::WarningUnderReview
| Self::NeedsResponse
| Self::UnderReview
| Self::Other(_) => DisputePhase::Open,
Self::Won => DisputePhase::Won,
Self::Lost => DisputePhase::Lost,
Self::WarningClosed | Self::Prevented => DisputePhase::Closed,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Dispute {
pub id: String,
pub charge_ref: Option<String>,
pub payment_ref: String,
pub customer_ref: Option<String>,
pub amount: Money,
pub reason: Option<String>,
pub status: DisputeStatus,
pub provider_status: String,
pub evidence_due_by: Option<OffsetDateTime>,
pub is_charge_refundable: Option<bool>,
pub balance_transactions: Vec<String>,
}
impl Dispute {
#[must_use]
pub fn event_key(&self) -> String {
format!("dispute:{}:{}", self.id, self.provider_status)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DisputeListRequest {
pub open_only: bool,
pub cursor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DisputePage {
pub disputes: Vec<Dispute>,
pub next: Option<String>,
}
#[derive(Debug, Clone, Error)]
pub enum PaymentsError {
#[error("payments are not configured")]
NotConfigured,
#[error("webhook signature verification failed: {0}")]
SignatureInvalid(String),
#[error("payments request rejected: {0}")]
Rejected(String),
#[error("payments request failed, retryable: {0}")]
Transient(String),
#[error("not supported by this payments adapter: {0}")]
Unsupported(&'static str),
}
#[async_trait]
pub trait Payments: Send + Sync {
async fn create_checkout(
&self,
request: &CheckoutRequest,
) -> Result<CheckoutSession, PaymentsError>;
async fn create_subscription_checkout(
&self,
request: &SubscriptionCheckoutRequest,
) -> Result<CheckoutSession, PaymentsError>;
async fn create_connect_account_link(
&self,
request: &ConnectAccountLinkRequest,
) -> Result<ConnectAccountLink, PaymentsError>;
async fn charge_with_transfer(&self, request: &TransferCharge)
-> Result<Charge, PaymentsError>;
async fn refund(&self, request: &RefundRequest) -> Result<Refund, PaymentsError>;
async fn verify_webhook(
&self,
signature_header: &str,
body: &[u8],
) -> Result<WebhookEvent, PaymentsError>;
async fn report_usage(&self, _report: &UsageReport) -> Result<UsageReported, PaymentsError> {
Err(PaymentsError::Unsupported("usage reporting"))
}
async fn verify_webhook_request(
&self,
headers: &http::HeaderMap,
body: &[u8],
) -> Result<WebhookEvent, PaymentsError> {
let signature = headers
.get("stripe-signature")
.and_then(|value| value.to_str().ok())
.ok_or_else(|| PaymentsError::SignatureInvalid("no signature header".to_owned()))?;
self.verify_webhook(signature, body).await
}
async fn create_portal_session(
&self,
_request: &PortalSessionRequest,
) -> Result<PortalSession, PaymentsError> {
Err(PaymentsError::Unsupported("customer portal sessions"))
}
async fn get_subscription(
&self,
_subscription_ref: &str,
) -> Result<Subscription, PaymentsError> {
Err(PaymentsError::Unsupported("subscriptions"))
}
async fn list_subscriptions(
&self,
_customer_ref: &str,
) -> Result<Vec<Subscription>, PaymentsError> {
Err(PaymentsError::Unsupported("subscriptions"))
}
async fn get_dispute(&self, _dispute_ref: &str) -> Result<Dispute, PaymentsError> {
Err(PaymentsError::Unsupported("disputes"))
}
async fn list_disputes(
&self,
_request: &DisputeListRequest,
) -> Result<DisputePage, PaymentsError> {
Err(PaymentsError::Unsupported("disputes"))
}
async fn close_dispute(
&self,
_dispute_ref: &str,
_idempotency_key: &str,
) -> Result<Dispute, PaymentsError> {
Err(PaymentsError::Unsupported("disputes"))
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::format_description::well_known::Rfc3339;
fn at(text: &str) -> OffsetDateTime {
OffsetDateTime::parse(text, &Rfc3339).expect("test instant parses")
}
struct Minimal;
#[async_trait]
impl Payments for Minimal {
async fn create_checkout(
&self,
_: &CheckoutRequest,
) -> Result<CheckoutSession, PaymentsError> {
Err(PaymentsError::NotConfigured)
}
async fn create_subscription_checkout(
&self,
_: &SubscriptionCheckoutRequest,
) -> Result<CheckoutSession, PaymentsError> {
Err(PaymentsError::NotConfigured)
}
async fn create_connect_account_link(
&self,
_: &ConnectAccountLinkRequest,
) -> Result<ConnectAccountLink, PaymentsError> {
Err(PaymentsError::NotConfigured)
}
async fn charge_with_transfer(&self, _: &TransferCharge) -> Result<Charge, PaymentsError> {
Err(PaymentsError::NotConfigured)
}
async fn refund(&self, _: &RefundRequest) -> Result<Refund, PaymentsError> {
Err(PaymentsError::NotConfigured)
}
async fn verify_webhook(
&self,
signature_header: &str,
_: &[u8],
) -> Result<WebhookEvent, PaymentsError> {
Ok(WebhookEvent {
id: signature_header.to_owned(),
kind: "seen".to_owned(),
data: Value::Null,
})
}
}
#[test]
fn new_optional_methods_default_without_breaking_an_adapter() {
let mut headers = http::HeaderMap::new();
headers.insert("stripe-signature", "t=1,v1=ab".parse().unwrap());
let event = pollster::block_on(Minimal.verify_webhook_request(&headers, b"{}")).unwrap();
assert_eq!(event.id, "t=1,v1=ab");
assert!(matches!(
pollster::block_on(Minimal.verify_webhook_request(&http::HeaderMap::new(), b"{}")),
Err(PaymentsError::SignatureInvalid(_))
));
let portal = PortalSessionRequest {
customer_ref: "c".to_owned(),
return_url: "https://x".to_owned(),
idempotency_key: "k".to_owned(),
};
assert!(matches!(
pollster::block_on(Minimal.create_portal_session(&portal)),
Err(PaymentsError::Unsupported(_))
));
assert!(matches!(
pollster::block_on(Minimal.get_dispute("d")),
Err(PaymentsError::Unsupported(_))
));
assert!(matches!(
pollster::block_on(Minimal.list_disputes(&DisputeListRequest::default())),
Err(PaymentsError::Unsupported(_))
));
assert!(matches!(
pollster::block_on(Minimal.close_dispute("d", "k")),
Err(PaymentsError::Unsupported(_))
));
assert!(matches!(
pollster::block_on(Minimal.get_subscription("sub_1")),
Err(PaymentsError::Unsupported(_))
));
assert!(matches!(
pollster::block_on(Minimal.list_subscriptions("cus_1")),
Err(PaymentsError::Unsupported(_))
));
}
#[test]
fn subscription_status_round_trips_stripe_spellings() {
for status in [
"trialing",
"active",
"past_due",
"canceled",
"unpaid",
"incomplete",
"incomplete_expired",
"paused",
] {
assert_eq!(SubscriptionStatus::from_provider(status).as_str(), status);
}
let other = SubscriptionStatus::from_provider("new_thing");
assert_eq!(other, SubscriptionStatus::Other("new_thing".to_owned()));
assert_eq!(other.as_str(), "new_thing");
}
#[test]
fn dispute_phases_and_keys() {
assert_eq!(
DisputeStatus::from_provider("needs_response").phase(),
DisputePhase::Open
);
assert_eq!(
DisputeStatus::from_provider("won").phase(),
DisputePhase::Won
);
assert_eq!(
DisputeStatus::from_provider("lost").phase(),
DisputePhase::Lost
);
assert_eq!(
DisputeStatus::from_provider("prevented").phase(),
DisputePhase::Closed
);
assert_eq!(
DisputeStatus::from_provider("new_thing").phase(),
DisputePhase::Open
);
let dispute = Dispute {
id: "dp_1".to_owned(),
charge_ref: None,
payment_ref: "pi_1".to_owned(),
customer_ref: None,
amount: Money::new(100, "usd"),
reason: None,
status: DisputeStatus::Won,
provider_status: "won".to_owned(),
evidence_due_by: None,
is_charge_refundable: None,
balance_transactions: Vec::new(),
};
assert_eq!(dispute.event_key(), "dispute:dp_1:won");
}
#[test]
fn hourly_identifier_names_the_utc_hour() {
let report = UsageReport::hourly(
"sub_1",
"extra_avatar_minutes",
"cus_1",
5,
at("2026-05-04T13:59:59Z"),
);
assert_eq!(report.timestamp, at("2026-05-04T13:00:00Z"));
assert_eq!(
report.identifier,
format!(
"sub_1:extra_avatar_minutes:{}",
report.timestamp.unix_timestamp()
)
);
let same_hour = UsageReport::hourly(
"sub_1",
"extra_avatar_minutes",
"cus_1",
9,
at("2026-05-04T13:00:01Z"),
);
assert_eq!(report.identifier, same_hour.identifier);
assert_eq!(report.timestamp, same_hour.timestamp);
let local = UsageReport::hourly(
"sub_1",
"extra_avatar_minutes",
"cus_1",
5,
at("2026-05-04T18:45:00+05:30"),
);
assert_eq!(report.timestamp, local.timestamp);
assert_eq!(report.identifier, local.identifier);
let next_hour = UsageReport::hourly(
"sub_1",
"extra_avatar_minutes",
"cus_1",
5,
at("2026-05-04T14:00:00Z"),
);
assert_ne!(report.identifier, next_hour.identifier);
assert_ne!(report.timestamp, next_hour.timestamp);
}
}