use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BillingError {
InvalidBillableId { id: String, reason: String },
InvalidPlanId { id: String, reason: String },
PlanNotFound { plan_id: String },
PlanDoesNotSupportSeats { plan_id: String },
FeatureNotIncluded { feature: String, plan_id: String },
PlanHasActiveSubscriptions {
plan_id: String,
subscription_count: u32,
},
InvalidStripePrice { price_id: String, reason: String },
MissingStripePrice { plan_id: String },
DuplicatePlanId { plan_id: String },
NoSubscription { billable_id: String },
SubscriptionInactive { billable_id: String },
SubscriptionCancelling { billable_id: String },
StripeSubscriptionNotFound { subscription_id: String },
NoCustomer { billable_id: String },
InvoiceNotFound { invoice_id: String },
PaymentMethodNotFound { payment_method_id: String },
RefundNotFound { refund_id: String },
RefundFailed { reason: String },
ChargeNotFound { charge_id: String },
InsufficientSeats { requested: u32, available: u32 },
InvalidSeatCount { message: String },
ConcurrentModification { billable_id: String },
SubscriptionNotTrialing { billable_id: String },
SubscriptionNotPaused { billable_id: String },
SubscriptionAlreadyPaused { billable_id: String },
InvalidRedirectUrl { url: String, reason: String },
RedirectDomainNotAllowed { domain: String },
InvalidWebhookSignature,
WebhookTimestampExpired { age_seconds: i64 },
InvalidWebhookPayload { message: String },
StripeApiError {
operation: String,
message: String,
code: Option<String>,
http_status: Option<u16>,
},
RetryLimitExceeded { operation: String },
Internal { message: String },
}
impl fmt::Display for BillingError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidBillableId { id, reason } => {
write!(f, "Invalid billable ID '{}': {}", id, reason)
}
Self::InvalidPlanId { id, reason } => {
write!(f, "Invalid plan ID '{}': {}", id, reason)
}
Self::PlanNotFound { plan_id } => {
write!(f, "Plan not found: {}", plan_id)
}
Self::PlanDoesNotSupportSeats { plan_id } => {
write!(f, "Plan '{}' does not support extra seats", plan_id)
}
Self::FeatureNotIncluded { feature, plan_id } => {
write!(
f,
"Feature '{}' is not included in plan '{}'",
feature, plan_id
)
}
Self::PlanHasActiveSubscriptions {
plan_id,
subscription_count,
} => {
write!(
f,
"Cannot delete plan '{}': {} active subscription(s) exist",
plan_id, subscription_count
)
}
Self::InvalidStripePrice { price_id, reason } => {
write!(f, "Invalid Stripe price '{}': {}", price_id, reason)
}
Self::MissingStripePrice { plan_id } => {
write!(f, "Plan '{}' is missing required stripe_price", plan_id)
}
Self::DuplicatePlanId { plan_id } => {
write!(f, "Duplicate plan ID: '{}'", plan_id)
}
Self::NoSubscription { billable_id } => {
write!(f, "No subscription found for '{}'", billable_id)
}
Self::SubscriptionInactive { billable_id } => {
write!(f, "Subscription for '{}' is not active", billable_id)
}
Self::SubscriptionCancelling { billable_id } => {
write!(
f,
"Subscription for '{}' is scheduled for cancellation",
billable_id
)
}
Self::StripeSubscriptionNotFound { subscription_id } => {
write!(f, "Stripe subscription not found: {}", subscription_id)
}
Self::NoCustomer { billable_id } => {
write!(f, "No Stripe customer found for '{}'", billable_id)
}
Self::InvoiceNotFound { invoice_id } => {
write!(f, "Invoice not found: {}", invoice_id)
}
Self::PaymentMethodNotFound { payment_method_id } => {
write!(f, "Payment method not found: {}", payment_method_id)
}
Self::RefundNotFound { refund_id } => {
write!(f, "Refund not found: {}", refund_id)
}
Self::RefundFailed { reason } => {
write!(f, "Refund failed: {}", reason)
}
Self::ChargeNotFound { charge_id } => {
write!(f, "Charge not found: {}", charge_id)
}
Self::InsufficientSeats {
requested,
available,
} => {
write!(
f,
"Cannot remove {} seats, only {} extra seats available",
requested, available
)
}
Self::InvalidSeatCount { message } => {
write!(f, "Invalid seat count: {}", message)
}
Self::ConcurrentModification { billable_id } => {
write!(
f,
"Concurrent modification detected for '{}', please retry",
billable_id
)
}
Self::SubscriptionNotTrialing { billable_id } => {
write!(
f,
"Subscription for '{}' is not in trialing state",
billable_id
)
}
Self::SubscriptionNotPaused { billable_id } => {
write!(f, "Subscription for '{}' is not paused", billable_id)
}
Self::SubscriptionAlreadyPaused { billable_id } => {
write!(f, "Subscription for '{}' is already paused", billable_id)
}
Self::InvalidRedirectUrl { url, reason } => {
write!(f, "Invalid redirect URL '{}': {}", url, reason)
}
Self::RedirectDomainNotAllowed { domain } => {
write!(f, "Redirect domain '{}' is not allowed", domain)
}
Self::InvalidWebhookSignature => {
write!(f, "Invalid webhook signature")
}
Self::WebhookTimestampExpired { age_seconds } => {
write!(f, "Webhook timestamp expired ({} seconds old)", age_seconds)
}
Self::InvalidWebhookPayload { message } => {
write!(f, "Invalid webhook payload: {}", message)
}
Self::StripeApiError {
operation,
message,
code,
http_status,
} => {
write!(f, "Stripe API error during '{}': {}", operation, message)?;
if let Some(code) = code {
write!(f, " (code: {})", code)?;
}
if let Some(status) = http_status {
write!(f, " [HTTP {}]", status)?;
}
Ok(())
}
Self::RetryLimitExceeded { operation } => {
write!(f, "Operation '{}' failed after multiple retries", operation)
}
Self::Internal { message } => {
write!(f, "Internal billing error: {}", message)
}
}
}
}
impl std::error::Error for BillingError {}
impl From<BillingError> for crate::error::TidewayError {
fn from(err: BillingError) -> Self {
match &err {
BillingError::PlanNotFound { .. }
| BillingError::NoSubscription { .. }
| BillingError::NoCustomer { .. }
| BillingError::StripeSubscriptionNotFound { .. }
| BillingError::InvoiceNotFound { .. }
| BillingError::PaymentMethodNotFound { .. }
| BillingError::RefundNotFound { .. }
| BillingError::ChargeNotFound { .. } => {
crate::error::TidewayError::NotFound(err.to_string())
}
BillingError::SubscriptionInactive { .. }
| BillingError::SubscriptionCancelling { .. }
| BillingError::FeatureNotIncluded { .. } => {
crate::error::TidewayError::Forbidden(err.to_string())
}
BillingError::InvalidBillableId { .. }
| BillingError::InvalidPlanId { .. }
| BillingError::PlanDoesNotSupportSeats { .. }
| BillingError::PlanHasActiveSubscriptions { .. }
| BillingError::InvalidStripePrice { .. }
| BillingError::MissingStripePrice { .. }
| BillingError::DuplicatePlanId { .. }
| BillingError::InsufficientSeats { .. }
| BillingError::InvalidSeatCount { .. }
| BillingError::InvalidRedirectUrl { .. }
| BillingError::RedirectDomainNotAllowed { .. }
| BillingError::InvalidWebhookSignature
| BillingError::WebhookTimestampExpired { .. }
| BillingError::InvalidWebhookPayload { .. }
| BillingError::SubscriptionNotTrialing { .. }
| BillingError::SubscriptionNotPaused { .. }
| BillingError::SubscriptionAlreadyPaused { .. } => {
crate::error::TidewayError::BadRequest(err.to_string())
}
BillingError::ConcurrentModification { .. }
| BillingError::RetryLimitExceeded { .. }
| BillingError::Internal { .. }
| BillingError::RefundFailed { .. } => {
crate::error::TidewayError::Internal(err.to_string())
}
BillingError::StripeApiError { http_status, .. } => match http_status {
Some(400..=499) => crate::error::TidewayError::BadRequest(err.to_string()),
_ => crate::error::TidewayError::Internal(err.to_string()),
},
}
}
}
impl BillingError {
#[must_use]
pub fn is_client_error(&self) -> bool {
match self {
Self::InvalidBillableId { .. }
| Self::InvalidPlanId { .. }
| Self::PlanNotFound { .. }
| Self::NoSubscription { .. }
| Self::NoCustomer { .. }
| Self::StripeSubscriptionNotFound { .. }
| Self::InvoiceNotFound { .. }
| Self::PaymentMethodNotFound { .. }
| Self::RefundNotFound { .. }
| Self::ChargeNotFound { .. }
| Self::SubscriptionInactive { .. }
| Self::SubscriptionCancelling { .. }
| Self::FeatureNotIncluded { .. }
| Self::PlanDoesNotSupportSeats { .. }
| Self::PlanHasActiveSubscriptions { .. }
| Self::InvalidStripePrice { .. }
| Self::MissingStripePrice { .. }
| Self::DuplicatePlanId { .. }
| Self::InsufficientSeats { .. }
| Self::InvalidSeatCount { .. }
| Self::InvalidRedirectUrl { .. }
| Self::RedirectDomainNotAllowed { .. }
| Self::InvalidWebhookSignature
| Self::WebhookTimestampExpired { .. }
| Self::InvalidWebhookPayload { .. }
| Self::SubscriptionNotTrialing { .. }
| Self::SubscriptionNotPaused { .. }
| Self::SubscriptionAlreadyPaused { .. } => true,
Self::StripeApiError { http_status, .. } => {
matches!(http_status, Some(400..=499))
}
_ => false,
}
}
#[must_use]
pub fn is_server_error(&self) -> bool {
match self {
Self::ConcurrentModification { .. }
| Self::RetryLimitExceeded { .. }
| Self::Internal { .. }
| Self::RefundFailed { .. } => true,
Self::StripeApiError { http_status, .. } => {
matches!(http_status, Some(500..=599) | None)
}
_ => false,
}
}
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Self::ConcurrentModification { .. } => true,
Self::StripeApiError { http_status, .. } => {
matches!(http_status, Some(429) | Some(500..=599))
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let err = BillingError::PlanNotFound {
plan_id: "starter".to_string(),
};
assert_eq!(err.to_string(), "Plan not found: starter");
let err = BillingError::InsufficientSeats {
requested: 5,
available: 2,
};
assert_eq!(
err.to_string(),
"Cannot remove 5 seats, only 2 extra seats available"
);
}
#[test]
fn test_error_classification() {
let err = BillingError::PlanNotFound {
plan_id: "test".to_string(),
};
assert!(err.is_client_error());
assert!(!err.is_server_error());
assert!(!err.is_retryable());
let err = BillingError::ConcurrentModification {
billable_id: "org_123".to_string(),
};
assert!(!err.is_client_error());
assert!(err.is_server_error());
assert!(err.is_retryable());
}
#[test]
fn test_convert_to_tideway_error() {
let err = BillingError::NoSubscription {
billable_id: "org_123".to_string(),
};
let tideway_err: crate::error::TidewayError = err.into();
assert!(matches!(
tideway_err,
crate::error::TidewayError::NotFound(_)
));
let err = BillingError::InvalidWebhookSignature;
let tideway_err: crate::error::TidewayError = err.into();
assert!(matches!(
tideway_err,
crate::error::TidewayError::BadRequest(_)
));
let err = BillingError::SubscriptionInactive {
billable_id: "org_123".to_string(),
};
let tideway_err: crate::error::TidewayError = err.into();
assert!(matches!(
tideway_err,
crate::error::TidewayError::Forbidden(_)
));
}
}