use chrono::{DateTime, Utc};
use crate::{
BillingPeriod, BillingScopeId, CardLastFour, ChargeAmount, GatewayDiagnostic,
HostChargeTargetId, PaymentAttemptId, PlanKey, SubscriberId, SubscriptionId,
};
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct BillingEventSubject {
billing_scope_id: BillingScopeId,
subscriber_id: SubscriberId,
}
impl BillingEventSubject {
pub const fn new(billing_scope_id: BillingScopeId, subscriber_id: SubscriberId) -> Self {
Self {
billing_scope_id,
subscriber_id,
}
}
pub const fn billing_scope_id(self) -> BillingScopeId {
self.billing_scope_id
}
pub const fn subscriber_id(self) -> SubscriberId {
self.subscriber_id
}
}
#[derive(Clone, Eq, PartialEq)]
pub struct PaymentCardDisplay {
brand: GatewayDiagnostic,
last_four: CardLastFour,
}
impl PaymentCardDisplay {
pub const fn new(brand: GatewayDiagnostic, last_four: CardLastFour) -> Self {
Self { brand, last_four }
}
pub const fn brand(&self) -> &GatewayDiagnostic {
&self.brand
}
pub const fn last_four(&self) -> &CardLastFour {
&self.last_four
}
}
impl std::fmt::Debug for PaymentCardDisplay {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("PaymentCardDisplay")
.field("has_brand", &true)
.field("has_last_four", &true)
.finish()
}
}
impl std::fmt::Display for PaymentCardDisplay {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str("[redacted]")
}
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub enum BillingEventKey {
SubscriptionStarted(SubscriptionId),
SubscriptionRenewed(PaymentAttemptId),
SubscriptionPaymentFailed(PaymentAttemptId),
SubscriptionCanceled(SubscriptionId),
PaymentMethodChanged(PaymentAttemptId),
HostChargePaid(HostChargeTargetId),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum BillingEvent {
SubscriptionStarted {
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
plan_key: PlanKey,
charge: ChargeAmount,
period: BillingPeriod,
},
SubscriptionRenewed {
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
plan_key: PlanKey,
charge: ChargeAmount,
period: BillingPeriod,
},
SubscriptionPaymentFailed {
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
plan_key: PlanKey,
retry_at: DateTime<Utc>,
},
SubscriptionCanceled {
subscription_id: SubscriptionId,
plan_key: PlanKey,
access_ends_at: DateTime<Utc>,
},
PaymentMethodChanged {
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
plan_key: PlanKey,
card: Option<PaymentCardDisplay>,
},
HostChargePaid {
attempt_id: PaymentAttemptId,
target_id: HostChargeTargetId,
charge: ChargeAmount,
},
}
impl BillingEvent {
pub const fn semantic_key(&self) -> BillingEventKey {
match self {
Self::SubscriptionStarted {
subscription_id, ..
} => BillingEventKey::SubscriptionStarted(*subscription_id),
Self::SubscriptionRenewed { attempt_id, .. } => {
BillingEventKey::SubscriptionRenewed(*attempt_id)
}
Self::SubscriptionPaymentFailed { attempt_id, .. } => {
BillingEventKey::SubscriptionPaymentFailed(*attempt_id)
}
Self::SubscriptionCanceled {
subscription_id, ..
} => BillingEventKey::SubscriptionCanceled(*subscription_id),
Self::PaymentMethodChanged { attempt_id, .. } => {
BillingEventKey::PaymentMethodChanged(*attempt_id)
}
Self::HostChargePaid { target_id, .. } => BillingEventKey::HostChargePaid(*target_id),
}
}
}
#[cfg(test)]
mod tests {
use chrono::TimeZone;
use uuid::Uuid;
use super::*;
use crate::CurrencyCode;
fn attempt(value: u128) -> PaymentAttemptId {
PaymentAttemptId::new(Uuid::from_u128(value))
}
fn subscription(value: u128) -> SubscriptionId {
SubscriptionId::new(Uuid::from_u128(value))
}
#[test]
fn every_event_derives_the_engine_owned_semantic_key() {
let plan_key = PlanKey::new("base_subscription").unwrap();
let usd = CurrencyCode::new("USD").unwrap();
let charge = ChargeAmount::new(1_000, usd).unwrap();
let start = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
let end = Utc.with_ymd_and_hms(2026, 9, 1, 0, 0, 0).unwrap();
let period = BillingPeriod::new(start, end).unwrap();
let events = [
(
BillingEvent::SubscriptionStarted {
attempt_id: attempt(1),
subscription_id: subscription(2),
plan_key: plan_key.clone(),
charge,
period: period.clone(),
},
BillingEventKey::SubscriptionStarted(subscription(2)),
),
(
BillingEvent::SubscriptionRenewed {
attempt_id: attempt(3),
subscription_id: subscription(2),
plan_key: plan_key.clone(),
charge,
period,
},
BillingEventKey::SubscriptionRenewed(attempt(3)),
),
(
BillingEvent::SubscriptionPaymentFailed {
attempt_id: attempt(4),
subscription_id: subscription(2),
plan_key: plan_key.clone(),
retry_at: end,
},
BillingEventKey::SubscriptionPaymentFailed(attempt(4)),
),
(
BillingEvent::SubscriptionCanceled {
subscription_id: subscription(2),
plan_key: plan_key.clone(),
access_ends_at: end,
},
BillingEventKey::SubscriptionCanceled(subscription(2)),
),
(
BillingEvent::PaymentMethodChanged {
attempt_id: attempt(5),
subscription_id: subscription(2),
plan_key,
card: None,
},
BillingEventKey::PaymentMethodChanged(attempt(5)),
),
(
BillingEvent::HostChargePaid {
attempt_id: attempt(6),
target_id: HostChargeTargetId::new(Uuid::from_u128(7)),
charge,
},
BillingEventKey::HostChargePaid(HostChargeTargetId::new(Uuid::from_u128(7))),
),
];
for (event, expected) in events {
assert_eq!(event.semantic_key(), expected);
}
}
#[test]
fn card_display_formatting_never_reveals_provider_values() {
let card = PaymentCardDisplay::new(
GatewayDiagnostic::new("visa_sentinel"),
CardLastFour::from_provider("1234").unwrap(),
);
let debug = format!("{card:?}");
assert!(!debug.contains("visa_sentinel"));
assert!(!debug.contains("1234"));
assert_eq!(card.to_string(), "[redacted]");
}
}