use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
use chrono::{DateTime, Utc};
use crate::error::BillingError;
use crate::model::{
Customer, DunningAttempt, DunningState, Invoice, InvoiceStatus, ProviderId, Subscription,
SubscriptionStatus,
};
use crate::money::Money;
use crate::plan::PlanId;
pub mod memory;
pub use memory::MemoryBillingStore;
#[cfg(feature = "db")]
pub mod db;
#[cfg(feature = "db")]
pub use db::DbBillingStore;
pub type StoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, BillingError>> + Send + 'a>>;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum EventClaim {
Claimed,
Applied,
InFlight,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Write<T> {
Applied(T),
Unchanged(T),
Stale(T),
Tie(T),
}
impl<T> Write<T> {
#[must_use]
pub fn into_inner(self) -> T {
match self {
Self::Applied(row) | Self::Unchanged(row) | Self::Stale(row) | Self::Tie(row) => row,
}
}
#[must_use]
pub const fn as_inner(&self) -> &T {
match self {
Self::Applied(row) | Self::Unchanged(row) | Self::Stale(row) | Self::Tie(row) => row,
}
}
#[must_use]
pub const fn is_applied(&self) -> bool {
matches!(self, Self::Applied(_))
}
#[must_use]
pub const fn is_unchanged(&self) -> bool {
matches!(self, Self::Unchanged(_))
}
#[must_use]
pub const fn is_tie(&self) -> bool {
matches!(self, Self::Tie(_))
}
pub(crate) fn try_map<U, E>(self, f: impl FnOnce(T) -> Result<U, E>) -> Result<Write<U>, E> {
Ok(match self {
Self::Applied(row) => Write::Applied(f(row)?),
Self::Unchanged(row) => Write::Unchanged(f(row)?),
Self::Stale(row) => Write::Stale(f(row)?),
Self::Tie(row) => Write::Tie(f(row)?),
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Guard {
Apply,
Unchanged,
Stale,
Tie,
}
#[must_use]
pub(crate) fn guard(
existing_at: DateTime<Utc>,
existing_rank: u8,
existing_terminal: bool,
incoming_at: DateTime<Utc>,
incoming_rank: u8,
) -> Guard {
if incoming_at == existing_at && incoming_rank == existing_rank {
return Guard::Unchanged;
}
if existing_terminal {
return Guard::Stale;
}
if incoming_at > existing_at || (incoming_at == existing_at && incoming_rank > existing_rank) {
Guard::Apply
} else {
Guard::Stale
}
}
#[must_use]
pub(crate) fn guard_subscription(
existing_at: DateTime<Utc>,
existing_status: SubscriptionStatus,
incoming_at: DateTime<Utc>,
incoming_status: SubscriptionStatus,
authoritative: bool,
check_ties: bool,
) -> Guard {
let existing_terminal = existing_status.is_terminal();
if authoritative && !existing_terminal && incoming_at == existing_at {
return Guard::Apply;
}
if check_ties
&& incoming_at == existing_at
&& incoming_status != existing_status
&& !existing_terminal
&& !incoming_status.is_terminal()
{
return Guard::Tie;
}
guard(
existing_at,
existing_status.rank(),
existing_terminal,
incoming_at,
incoming_status.rank(),
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CustomerUpsert {
pub new_id: String,
pub provider: String,
pub provider_customer_id: ProviderId,
pub user_id: Option<String>,
pub email: Option<String>,
pub now: DateTime<Utc>,
}
impl CustomerUpsert {
#[must_use]
pub fn new(
new_id: impl Into<String>,
provider: impl Into<String>,
provider_customer_id: impl Into<ProviderId>,
now: DateTime<Utc>,
) -> Self {
Self {
new_id: new_id.into(),
provider: provider.into(),
provider_customer_id: provider_customer_id.into(),
user_id: None,
email: None,
now,
}
}
#[must_use]
pub fn with_user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
#[must_use]
pub fn with_email(mut self, email: impl Into<String>) -> Self {
self.email = Some(email.into());
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SubscriptionUpsert {
pub new_id: String,
pub customer_id: String,
pub provider_subscription_id: ProviderId,
pub provider_price_id: Option<ProviderId>,
pub plan_id: Option<PlanId>,
pub status: SubscriptionStatus,
pub quantity: i64,
pub current_period_end: Option<DateTime<Utc>>,
pub cancel_at_period_end: bool,
pub occurred_at: DateTime<Utc>,
pub authoritative: bool,
pub check_ties: bool,
pub now: DateTime<Utc>,
}
impl SubscriptionUpsert {
#[must_use]
pub fn new(
new_id: impl Into<String>,
customer_id: impl Into<String>,
provider_subscription_id: impl Into<ProviderId>,
status: SubscriptionStatus,
occurred_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Self {
Self {
new_id: new_id.into(),
customer_id: customer_id.into(),
provider_subscription_id: provider_subscription_id.into(),
provider_price_id: None,
plan_id: None,
status,
quantity: 1,
current_period_end: None,
cancel_at_period_end: false,
occurred_at,
authoritative: false,
check_ties: false,
now,
}
}
#[must_use]
pub const fn with_authoritative(mut self) -> Self {
self.authoritative = true;
self
}
#[must_use]
pub const fn with_tie_check(mut self) -> Self {
self.check_ties = true;
self
}
#[must_use]
pub fn with_price(mut self, price_id: impl Into<ProviderId>) -> Self {
self.provider_price_id = Some(price_id.into());
self
}
#[must_use]
pub fn with_plan(mut self, plan_id: impl Into<PlanId>) -> Self {
self.plan_id = Some(plan_id.into());
self
}
#[must_use]
pub const fn with_quantity(mut self, quantity: i64) -> Self {
self.quantity = quantity;
self
}
#[must_use]
pub const fn with_period_end(mut self, end: DateTime<Utc>) -> Self {
self.current_period_end = Some(end);
self
}
#[must_use]
pub const fn with_cancel_at_period_end(mut self, cancel: bool) -> Self {
self.cancel_at_period_end = cancel;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct InvoiceUpsert {
pub new_id: String,
pub customer_id: String,
pub subscription_id: Option<String>,
pub provider_subscription_id: Option<ProviderId>,
pub provider_invoice_id: ProviderId,
pub status: InvoiceStatus,
pub amount_due: Money,
pub amount_paid: Money,
pub attempt_count: i64,
pub next_payment_attempt: Option<DateTime<Utc>>,
pub occurred_at: DateTime<Utc>,
pub now: DateTime<Utc>,
}
impl InvoiceUpsert {
#[must_use]
#[allow(clippy::too_many_arguments, reason = "every field is required")]
pub fn new(
new_id: impl Into<String>,
customer_id: impl Into<String>,
provider_invoice_id: impl Into<ProviderId>,
status: InvoiceStatus,
amount_due: Money,
amount_paid: Money,
occurred_at: DateTime<Utc>,
now: DateTime<Utc>,
) -> Self {
Self {
new_id: new_id.into(),
customer_id: customer_id.into(),
subscription_id: None,
provider_subscription_id: None,
provider_invoice_id: provider_invoice_id.into(),
status,
amount_due,
amount_paid,
attempt_count: 0,
next_payment_attempt: None,
occurred_at,
now,
}
}
#[must_use]
pub fn with_subscription(mut self, subscription_id: impl Into<String>) -> Self {
self.subscription_id = Some(subscription_id.into());
self
}
#[must_use]
pub fn with_provider_subscription(mut self, id: impl Into<ProviderId>) -> Self {
self.provider_subscription_id = Some(id.into());
self
}
#[must_use]
pub const fn with_attempt_count(mut self, count: i64) -> Self {
self.attempt_count = count;
self
}
#[must_use]
pub const fn with_next_payment_attempt(mut self, at: DateTime<Utc>) -> Self {
self.next_payment_attempt = Some(at);
self
}
}
pub trait BillingStore: Send + Sync + 'static {
fn claim_event<'a>(
&'a self,
event_id: &'a str,
kind: &'a str,
now: DateTime<Utc>,
stale_after: Duration,
) -> StoreFuture<'a, EventClaim>;
fn finish_event<'a>(&'a self, event_id: &'a str, now: DateTime<Utc>) -> StoreFuture<'a, ()>;
fn release_event<'a>(&'a self, event_id: &'a str) -> StoreFuture<'a, ()>;
fn applied_event_count(&self) -> StoreFuture<'_, u64>;
fn upsert_customer(&self, upsert: CustomerUpsert) -> StoreFuture<'_, Customer>;
fn customer_by_id<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Customer>>;
fn customer_by_user<'a>(&'a self, user_id: &'a str) -> StoreFuture<'a, Option<Customer>>;
fn customer_by_provider_id<'a>(
&'a self,
provider_customer_id: &'a ProviderId,
) -> StoreFuture<'a, Option<Customer>>;
fn relink_customer<'a>(
&'a self,
id: &'a str,
user_id: String,
now: DateTime<Utc>,
) -> StoreFuture<'a, Option<Customer>> {
let _ = (id, user_id, now);
Box::pin(std::future::ready(Err(BillingError::Unsupported(
"relink_customer (this BillingStore has not implemented it)",
))))
}
fn upsert_subscription(
&self,
upsert: SubscriptionUpsert,
) -> StoreFuture<'_, Write<Subscription>>;
fn subscription_by_id<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Subscription>>;
fn subscription_by_provider_id<'a>(
&'a self,
provider_subscription_id: &'a ProviderId,
) -> StoreFuture<'a, Option<Subscription>>;
fn subscriptions_for_customer<'a>(
&'a self,
customer_id: &'a str,
) -> StoreFuture<'a, Vec<Subscription>>;
fn set_subscription_status<'a>(
&'a self,
id: &'a str,
status: SubscriptionStatus,
now: DateTime<Utc>,
) -> StoreFuture<'a, Option<Subscription>>;
fn upsert_invoice(&self, upsert: InvoiceUpsert) -> StoreFuture<'_, Write<Invoice>>;
fn invoice_by_id<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Invoice>>;
fn invoice_by_provider_id<'a>(
&'a self,
provider_invoice_id: &'a ProviderId,
) -> StoreFuture<'a, Option<Invoice>>;
fn link_subscription<'a>(
&'a self,
provider_subscription_id: &'a ProviderId,
subscription_id: &'a str,
now: DateTime<Utc>,
) -> StoreFuture<'a, ()> {
let _ = (provider_subscription_id, subscription_id, now);
Box::pin(std::future::ready(Ok(())))
}
fn upsert_dunning(&self, attempt: DunningAttempt) -> StoreFuture<'_, ()>;
fn dunning_by_invoice<'a>(
&'a self,
invoice_id: &'a str,
) -> StoreFuture<'a, Option<DunningAttempt>>;
fn claim_dunning_attempt<'a>(
&'a self,
invoice_id: &'a str,
attempt: i64,
now: DateTime<Utc>,
) -> StoreFuture<'a, bool>;
fn open_dunning(&self) -> StoreFuture<'_, Vec<DunningAttempt>>;
fn open_dunning_for_subscription<'a>(
&'a self,
subscription_id: &'a str,
) -> StoreFuture<'a, Vec<DunningAttempt>>;
fn settle_dunning<'a>(
&'a self,
invoice_id: &'a str,
expected_attempt: i64,
from: &'a [DunningState],
row: DunningAttempt,
) -> StoreFuture<'a, bool>;
fn prune_events(&self, before: DateTime<Utc>) -> StoreFuture<'_, u64>;
}