use chrono::{DateTime, Utc};
use sqlx::{PgPool, Postgres, Row, postgres::PgArguments, query::Query};
use syrup_rail::{
BillingPeriod, Entitlement, EntitlementQuery, Money, PaymentAttemptId, PaymentAttemptKind,
PaymentAttemptStatus, SubscriptionBillingPortalQuery, SubscriptionBillingPortalSnapshot,
SubscriptionPaymentHistoryCursor, SubscriptionPaymentHistoryItem,
SubscriptionPaymentHistoryPage, SubscriptionPaymentHistoryPageLimit,
SubscriptionPaymentMethodDisplay,
};
use thiserror::Error;
use crate::entitlement::{EntitlementQueryError, entitlement_on_connection};
const INVALID_BILLING_PORTAL_STATE: &str = "canonical subscription billing portal state is invalid";
const SUBSCRIPTION_PAYMENT_HISTORY_FIRST_PAGE_SQL: &str = concat!(
include_str!("billing_portal/subscription_payment_history_page_head.sql"),
include_str!("billing_portal/subscription_payment_history_page_body.sql"),
"LIMIT $4\n"
);
const SUBSCRIPTION_PAYMENT_HISTORY_CONTINUATION_SQL: &str = concat!(
include_str!("billing_portal/subscription_payment_history_page_head.sql"),
" AND (created_at, id) < ($4::timestamptz, $5::uuid)\n",
include_str!("billing_portal/subscription_payment_history_page_body.sql"),
"LIMIT $6\n"
);
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SubscriptionPaymentHistoryPageQuery {
First,
Continuation(SubscriptionPaymentHistoryCursor),
}
impl SubscriptionPaymentHistoryPageQuery {
fn from_cursor(cursor: Option<&SubscriptionPaymentHistoryCursor>) -> Self {
match cursor {
Some(cursor) => Self::Continuation(*cursor),
None => Self::First,
}
}
const fn sql(self) -> &'static str {
match self {
Self::First => SUBSCRIPTION_PAYMENT_HISTORY_FIRST_PAGE_SQL,
Self::Continuation(_) => SUBSCRIPTION_PAYMENT_HISTORY_CONTINUATION_SQL,
}
}
fn bind<'args>(
self,
identity: &'args SubscriptionBillingPortalQuery,
limit: SubscriptionPaymentHistoryPageLimit,
) -> Query<'args, Postgres, PgArguments> {
let query = sqlx::query(self.sql())
.bind(identity.billing_scope_id().into_uuid())
.bind(identity.subscriber_id().into_uuid())
.bind(identity.plan_key().as_str());
match self {
Self::First => query.bind(limit.get() + 1),
Self::Continuation(cursor) => query
.bind(cursor.created_at())
.bind(cursor.payment_attempt_id().into_uuid())
.bind(limit.get() + 1),
}
}
}
#[derive(Debug, Error)]
pub enum SubscriptionBillingPortalQueryError {
#[error("subscription billing portal query failed")]
Sql(#[from] sqlx::Error),
#[error("{0}")]
InvalidState(&'static str),
}
pub async fn subscription_billing_portal(
pool: &PgPool,
query: &SubscriptionBillingPortalQuery,
) -> Result<SubscriptionBillingPortalSnapshot, SubscriptionBillingPortalQueryError> {
let mut transaction = pool.begin().await?;
sqlx::query("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY")
.execute(&mut *transaction)
.await?;
let entitlement_query = EntitlementQuery::new(
query.billing_scope_id(),
query.subscriber_id(),
query.plan_key().clone(),
);
let entitlement = entitlement_on_connection(&mut transaction, &entitlement_query)
.await
.map_err(map_entitlement_error)?;
let payment_method_display = match payment_method_id(&entitlement) {
Some(payment_method_id) => {
payment_method_display(&mut transaction, query, payment_method_id).await?
}
None => None,
};
transaction.commit().await?;
Ok(SubscriptionBillingPortalSnapshot::new(
entitlement,
payment_method_display,
))
}
pub async fn subscription_payment_history_page(
pool: &PgPool,
query: &SubscriptionBillingPortalQuery,
cursor: Option<&SubscriptionPaymentHistoryCursor>,
limit: SubscriptionPaymentHistoryPageLimit,
) -> Result<SubscriptionPaymentHistoryPage, SubscriptionBillingPortalQueryError> {
let rows = SubscriptionPaymentHistoryPageQuery::from_cursor(cursor)
.bind(query, limit)
.fetch_all(pool)
.await?;
let mut items = rows
.iter()
.map(subscription_payment_history_item_from_row)
.collect::<Result<Vec<_>, _>>()?;
let has_more = items.len() > limit.get() as usize;
if has_more {
items.pop();
}
let next_cursor = has_more.then(|| {
let item = items
.last()
.expect("a page with an extra row always retains one item");
SubscriptionPaymentHistoryCursor::new(item.created_at(), item.payment_attempt_id())
});
Ok(SubscriptionPaymentHistoryPage::new(items, next_cursor))
}
fn map_entitlement_error(error: EntitlementQueryError) -> SubscriptionBillingPortalQueryError {
match error {
EntitlementQueryError::Sql(error) => SubscriptionBillingPortalQueryError::Sql(error),
EntitlementQueryError::InvalidState(_) => {
SubscriptionBillingPortalQueryError::InvalidState(INVALID_BILLING_PORTAL_STATE)
}
}
}
fn payment_method_id(entitlement: &Entitlement) -> Option<syrup_rail::PaymentMethodId> {
match entitlement {
Entitlement::PaidActive { subscription, .. }
| Entitlement::PaidThroughCancellation { subscription, .. }
| Entitlement::PastDue { subscription, .. } => Some(subscription.payment_method_id()),
Entitlement::Missing { .. } | Entitlement::Granted { .. } => None,
}
}
async fn payment_method_display(
connection: &mut sqlx::PgConnection,
query: &SubscriptionBillingPortalQuery,
payment_method_id: syrup_rail::PaymentMethodId,
) -> Result<Option<SubscriptionPaymentMethodDisplay>, SubscriptionBillingPortalQueryError> {
let row = sqlx::query(
r#"
SELECT card_brand, card_last4, card_exp_month, card_exp_year
FROM billing_payment_methods
WHERE id = $1
AND billing_scope_id = $2
AND subscriber_id = $3
AND status = 'active'
"#,
)
.bind(payment_method_id.as_uuid())
.bind(query.billing_scope_id().as_uuid())
.bind(query.subscriber_id().as_uuid())
.fetch_optional(connection)
.await?;
let Some(row) = row else {
return Ok(None);
};
let card_brand: Option<String> = row.try_get("card_brand")?;
let card_last_four: Option<String> = row.try_get("card_last4")?;
let card_expiration_month = row
.try_get::<Option<i16>, _>("card_exp_month")?
.map(|value| u8::try_from(value).map_err(|_| invalid_state()))
.transpose()?;
let card_expiration_year = row
.try_get::<Option<i16>, _>("card_exp_year")?
.map(|value| u16::try_from(value).map_err(|_| invalid_state()))
.transpose()?;
SubscriptionPaymentMethodDisplay::from_provider_parts(
card_brand.as_deref(),
card_last_four,
card_expiration_month,
card_expiration_year,
)
.map_err(|_| invalid_state())
}
fn subscription_payment_history_item_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<SubscriptionPaymentHistoryItem, SubscriptionBillingPortalQueryError> {
let kind = row
.try_get::<String, _>("attempt_kind")?
.parse::<PaymentAttemptKind>()
.map_err(|_| invalid_state())?;
let status = row
.try_get::<String, _>("status")?
.parse::<PaymentAttemptStatus>()
.map_err(|_| invalid_state())?;
let currency = syrup_rail::CurrencyCode::new(&row.try_get::<String, _>("currency")?)
.map_err(|_| invalid_state())?;
let amount = Money::new(row.try_get("amount_cents")?, currency).map_err(|_| invalid_state())?;
let billing_period = billing_period_from_row(row)?;
SubscriptionPaymentHistoryItem::new(
PaymentAttemptId::new(row.try_get("id")?),
kind,
status,
amount,
billing_period,
row.try_get::<Option<DateTime<Utc>>, _>("submitted_at")?,
row.try_get::<Option<DateTime<Utc>>, _>("resolved_at")?,
row.try_get("created_at")?,
)
.map_err(|_| invalid_state())
}
fn billing_period_from_row(
row: &sqlx::postgres::PgRow,
) -> Result<Option<BillingPeriod>, SubscriptionBillingPortalQueryError> {
let start_at = row.try_get::<Option<DateTime<Utc>>, _>("billing_period_start_at")?;
let end_at = row.try_get::<Option<DateTime<Utc>>, _>("billing_period_end_at")?;
match (start_at, end_at) {
(None, None) => Ok(None),
(Some(start_at), Some(end_at)) => BillingPeriod::new(start_at, end_at)
.map(Some)
.map_err(|_| invalid_state()),
_ => Err(invalid_state()),
}
}
fn invalid_state() -> SubscriptionBillingPortalQueryError {
SubscriptionBillingPortalQueryError::InvalidState(INVALID_BILLING_PORTAL_STATE)
}
#[cfg(test)]
mod tests;