use super::storage::{BillableEntity, BillingStore};
use crate::error::Result;
pub struct CustomerManager<S: BillingStore, C: StripeClient> {
store: S,
client: C,
}
impl<S: BillingStore, C: StripeClient> CustomerManager<S, C> {
#[must_use]
pub fn new(store: S, client: C) -> Self {
Self { store, client }
}
pub async fn get_or_create_customer(&self, entity: &impl BillableEntity) -> Result<String> {
if let Some(customer_id) = self
.store
.get_stripe_customer_id(entity.billable_id())
.await?
{
return Ok(customer_id);
}
let customer_id = self
.client
.create_customer(CreateCustomerRequest {
email: entity.email().to_string(),
name: entity.name().map(String::from),
metadata: Some(CustomerMetadata {
billable_id: entity.billable_id().to_string(),
billable_type: entity.billable_type().to_string(),
}),
})
.await?;
self.store
.set_stripe_customer_id(entity.billable_id(), entity.billable_type(), &customer_id)
.await?;
Ok(customer_id)
}
pub async fn get_customer_id(&self, billable_id: &str) -> Result<Option<String>> {
self.store.get_stripe_customer_id(billable_id).await
}
pub async fn link_customer(
&self,
entity: &impl BillableEntity,
stripe_customer_id: &str,
) -> Result<()> {
self.store
.set_stripe_customer_id(
entity.billable_id(),
entity.billable_type(),
stripe_customer_id,
)
.await
}
pub async fn update_customer(
&self,
billable_id: &str,
update: UpdateCustomerRequest,
) -> Result<()> {
let customer_id = self
.store
.get_stripe_customer_id(billable_id)
.await?
.ok_or_else(|| {
crate::error::TidewayError::NotFound("No Stripe customer linked".to_string())
})?;
self.client.update_customer(&customer_id, update).await
}
pub async fn delete_customer(&self, billable_id: &str) -> Result<()> {
if let Some(customer_id) = self.store.get_stripe_customer_id(billable_id).await? {
self.client.delete_customer(&customer_id).await?;
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct CreateCustomerRequest {
pub email: String,
pub name: Option<String>,
pub metadata: Option<CustomerMetadata>,
}
#[derive(Debug, Clone)]
pub struct CustomerMetadata {
pub billable_id: String,
pub billable_type: String,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateCustomerRequest {
pub email: Option<String>,
pub name: Option<String>,
}
#[allow(async_fn_in_trait)]
pub trait StripeClient: Send + Sync {
async fn create_customer(&self, request: CreateCustomerRequest) -> Result<String>;
async fn update_customer(
&self,
customer_id: &str,
request: UpdateCustomerRequest,
) -> Result<()>;
async fn delete_customer(&self, customer_id: &str) -> Result<()>;
async fn get_default_payment_method(&self, customer_id: &str) -> Result<Option<String>>;
}
#[cfg(any(test, feature = "test-billing"))]
pub mod test {
use super::*;
use std::collections::HashMap;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering};
#[derive(Default)]
pub struct MockStripeClient {
customer_counter: AtomicU64,
customers: RwLock<HashMap<String, MockCustomer>>,
}
#[derive(Clone)]
struct MockCustomer {
email: String,
name: Option<String>,
}
impl MockStripeClient {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn get_customers(&self) -> Vec<(String, String)> {
self.customers
.read()
.unwrap()
.iter()
.map(|(id, c)| (id.clone(), c.email.clone()))
.collect()
}
}
impl StripeClient for MockStripeClient {
async fn create_customer(&self, request: CreateCustomerRequest) -> Result<String> {
let id = format!(
"cus_test_{}",
self.customer_counter.fetch_add(1, Ordering::SeqCst)
);
self.customers.write().unwrap().insert(
id.clone(),
MockCustomer {
email: request.email,
name: request.name,
},
);
Ok(id)
}
async fn update_customer(
&self,
customer_id: &str,
request: UpdateCustomerRequest,
) -> Result<()> {
let mut customers = self.customers.write().unwrap();
if let Some(customer) = customers.get_mut(customer_id) {
if let Some(email) = request.email {
customer.email = email;
}
if let Some(name) = request.name {
customer.name = Some(name);
}
Ok(())
} else {
Err(crate::error::TidewayError::NotFound(format!(
"Customer not found: {}",
customer_id
)))
}
}
async fn delete_customer(&self, customer_id: &str) -> Result<()> {
self.customers.write().unwrap().remove(customer_id);
Ok(())
}
async fn get_default_payment_method(&self, _customer_id: &str) -> Result<Option<String>> {
Ok(None)
}
}
}
#[cfg(test)]
mod tests {
use super::test::MockStripeClient;
use super::*;
use crate::billing::storage::test::InMemoryBillingStore;
struct TestEntity {
id: String,
email: String,
name: String,
}
impl BillableEntity for TestEntity {
fn billable_id(&self) -> &str {
&self.id
}
fn billable_type(&self) -> &str {
"org"
}
fn email(&self) -> &str {
&self.email
}
fn name(&self) -> Option<&str> {
Some(&self.name)
}
}
#[tokio::test]
async fn test_get_or_create_customer_creates_new() {
let store = InMemoryBillingStore::new();
let client = MockStripeClient::new();
let manager = CustomerManager::new(store, client);
let entity = TestEntity {
id: "org_123".to_string(),
email: "test@example.com".to_string(),
name: "Test Org".to_string(),
};
let customer_id = manager.get_or_create_customer(&entity).await.unwrap();
assert!(customer_id.starts_with("cus_test_"));
}
#[tokio::test]
async fn test_get_or_create_customer_returns_existing() {
let store = InMemoryBillingStore::new();
let client = MockStripeClient::new();
let manager = CustomerManager::new(store, client);
let entity = TestEntity {
id: "org_123".to_string(),
email: "test@example.com".to_string(),
name: "Test Org".to_string(),
};
let id1 = manager.get_or_create_customer(&entity).await.unwrap();
let id2 = manager.get_or_create_customer(&entity).await.unwrap();
assert_eq!(id1, id2);
}
#[tokio::test]
async fn test_link_customer() {
let store = InMemoryBillingStore::new();
let client = MockStripeClient::new();
let manager = CustomerManager::new(store, client);
let entity = TestEntity {
id: "org_456".to_string(),
email: "existing@example.com".to_string(),
name: "Existing Org".to_string(),
};
manager
.link_customer(&entity, "cus_existing_123")
.await
.unwrap();
let customer_id = manager.get_customer_id("org_456").await.unwrap();
assert_eq!(customer_id, Some("cus_existing_123".to_string()));
}
#[tokio::test]
async fn test_update_customer() {
let store = InMemoryBillingStore::new();
let client = MockStripeClient::new();
let manager = CustomerManager::new(store, client);
let entity = TestEntity {
id: "org_789".to_string(),
email: "old@example.com".to_string(),
name: "Old Name".to_string(),
};
manager.get_or_create_customer(&entity).await.unwrap();
manager
.update_customer(
"org_789",
UpdateCustomerRequest {
email: Some("new@example.com".to_string()),
name: Some("New Name".to_string()),
},
)
.await
.unwrap();
}
#[tokio::test]
async fn test_update_customer_not_found() {
let store = InMemoryBillingStore::new();
let client = MockStripeClient::new();
let manager = CustomerManager::new(store, client);
let result = manager
.update_customer("nonexistent", UpdateCustomerRequest::default())
.await;
assert!(result.is_err());
}
}