use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::Value;
use crate::error::BillingError;
use crate::event::{
BillingEvent, BillingEventKind, CheckoutSnapshot, InvoiceSnapshot, SubscriptionSnapshot,
};
use crate::model::{InvoiceStatus, ProviderId, SubscriptionStatus};
use crate::money::{Currency, Money};
#[derive(Deserialize)]
struct Envelope {
id: String,
#[serde(rename = "type")]
event_type: String,
created: i64,
data: EnvelopeData,
}
#[derive(Deserialize)]
struct EnvelopeData {
object: Value,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum ObjectRef {
Id(String),
Expanded { id: String },
}
impl ObjectRef {
fn into_id(self) -> ProviderId {
match self {
Self::Id(id) | Self::Expanded { id } => ProviderId::new(id),
}
}
}
#[derive(Deserialize)]
struct CheckoutSession {
customer: Option<ObjectRef>,
client_reference_id: Option<String>,
customer_details: Option<CustomerDetails>,
customer_email: Option<String>,
subscription: Option<ObjectRef>,
}
#[derive(Deserialize)]
struct CustomerDetails {
email: Option<String>,
}
#[derive(Deserialize)]
struct Subscription {
id: String,
customer: ObjectRef,
status: String,
#[serde(default)]
cancel_at_period_end: bool,
current_period_end: Option<i64>,
items: Option<ItemList>,
}
#[derive(Deserialize)]
struct ItemList {
#[serde(default)]
data: Vec<SubscriptionItem>,
}
#[derive(Deserialize)]
struct SubscriptionItem {
price: Option<ObjectRef>,
quantity: Option<i64>,
current_period_end: Option<i64>,
}
#[derive(Deserialize)]
struct Invoice {
id: String,
customer: ObjectRef,
status: String,
amount_due: i64,
amount_paid: i64,
currency: String,
#[serde(default)]
attempt_count: i64,
next_payment_attempt: Option<i64>,
subscription: Option<ObjectRef>,
parent: Option<InvoiceParent>,
}
#[derive(Deserialize)]
struct InvoiceParent {
subscription_details: Option<SubscriptionDetails>,
}
#[derive(Deserialize)]
struct SubscriptionDetails {
subscription: Option<ObjectRef>,
}
pub(super) fn parse(raw: &[u8]) -> Result<BillingEvent, BillingError> {
let envelope: Envelope = serde_json::from_slice(raw)
.map_err(|e| BillingError::Malformed(format!("stripe event envelope: {}", position(&e))))?;
let occurred_at = unix(envelope.created).ok_or_else(|| {
BillingError::Malformed(format!(
"stripe event {}: created is out of range",
envelope.id
))
})?;
let context = Context {
event_id: &envelope.id,
event_type: &envelope.event_type,
};
let object = envelope.data.object;
let kind = match envelope.event_type.as_str() {
"checkout.session.completed" => {
BillingEventKind::CheckoutCompleted(checkout(context.decode(object)?, context)?)
}
"customer.subscription.created" | "customer.subscription.updated" => {
BillingEventKind::SubscriptionChanged(subscription(context.decode(object)?, context)?)
}
"customer.subscription.deleted" => {
let mut snapshot = subscription(context.decode(object)?, context)?;
snapshot.status = SubscriptionStatus::Canceled;
BillingEventKind::SubscriptionDeleted(snapshot)
}
"invoice.payment_failed" => {
BillingEventKind::InvoicePaymentFailed(invoice(context.decode(object)?, context)?)
}
"invoice.paid" | "invoice.payment_succeeded" => {
BillingEventKind::InvoicePaid(invoice(context.decode(object)?, context)?)
}
_ => BillingEventKind::Ignored {
event_type: envelope.event_type.clone(),
},
};
Ok(BillingEvent::new(envelope.id, occurred_at, kind))
}
pub(super) fn parse_subscription_object(
object: Value,
) -> Result<SubscriptionSnapshot, BillingError> {
let context = Context {
event_id: "lookup",
event_type: "subscription",
};
subscription(context.decode(object)?, context)
}
#[derive(Clone, Copy)]
struct Context<'a> {
event_id: &'a str,
event_type: &'a str,
}
impl Context<'_> {
fn decode<T: serde::de::DeserializeOwned>(self, object: Value) -> Result<T, BillingError> {
serde_json::from_value(object).map_err(|e| {
self.malformed(format!(
"object does not match the expected shape ({})",
position(&e)
))
})
}
fn malformed(self, detail: impl std::fmt::Display) -> BillingError {
BillingError::Malformed(format!(
"stripe {} {}: {detail}",
self.event_type, self.event_id
))
}
}
fn position(error: &serde_json::Error) -> String {
format!("line {}, column {}", error.line(), error.column())
}
const fn unix(secs: i64) -> Option<DateTime<Utc>> {
DateTime::from_timestamp(secs, 0)
}
fn checkout(
session: CheckoutSession,
context: Context<'_>,
) -> Result<CheckoutSnapshot, BillingError> {
let provider_customer_id = session
.customer
.map(ObjectRef::into_id)
.ok_or_else(|| context.malformed("checkout session has no customer"))?;
let email = session
.customer_details
.and_then(|d| d.email)
.or(session.customer_email);
Ok(CheckoutSnapshot {
provider_customer_id,
local_customer_ref: session.client_reference_id,
email,
provider_subscription_id: session.subscription.map(ObjectRef::into_id),
})
}
fn subscription(
subscription: Subscription,
context: Context<'_>,
) -> Result<SubscriptionSnapshot, BillingError> {
let status = SubscriptionStatus::parse(&subscription.status)
.ok_or_else(|| context.malformed("unknown subscription status"))?;
let first_item = subscription
.items
.and_then(|items| items.data.into_iter().next());
let (provider_price_id, quantity, item_period_end) = match first_item {
Some(item) => (
item.price.map(ObjectRef::into_id),
item.quantity.unwrap_or(1),
item.current_period_end,
),
None => (None, 1, None),
};
let current_period_end = item_period_end
.or(subscription.current_period_end)
.map(|secs| {
unix(secs).ok_or_else(|| context.malformed("current_period_end is out of range"))
})
.transpose()?;
Ok(SubscriptionSnapshot {
provider_subscription_id: ProviderId::new(subscription.id),
provider_customer_id: subscription.customer.into_id(),
provider_price_id,
status,
quantity,
current_period_end,
cancel_at_period_end: subscription.cancel_at_period_end,
})
}
fn invoice(invoice: Invoice, context: Context<'_>) -> Result<InvoiceSnapshot, BillingError> {
let status = InvoiceStatus::parse(&invoice.status)
.ok_or_else(|| context.malformed("unknown invoice status"))?;
let currency = Currency::new(&invoice.currency)
.map_err(|_| context.malformed("currency is not a three-letter code"))?;
let provider_subscription_id = invoice
.parent
.and_then(|parent| parent.subscription_details)
.and_then(|details| details.subscription)
.or(invoice.subscription)
.map(ObjectRef::into_id);
let next_payment_attempt = invoice
.next_payment_attempt
.map(|secs| {
unix(secs).ok_or_else(|| context.malformed("next_payment_attempt is out of range"))
})
.transpose()?;
Ok(InvoiceSnapshot {
provider_invoice_id: ProviderId::new(invoice.id),
provider_customer_id: invoice.customer.into_id(),
provider_subscription_id,
status,
amount_due: Money::from_minor(invoice.amount_due, currency),
amount_paid: Money::from_minor(invoice.amount_paid, currency),
attempt_count: invoice.attempt_count,
next_payment_attempt,
})
}