use std::{error::Error, fmt};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use syrup_rail::{
BillingEvent, BillingEventKey, BillingEventSubject, BillingPeriod, ChargeAmount,
PaymentCardBrand, SubscriptionEndReason, SubscriptionPaymentFailureAccess,
SubscriptionPaymentFailureDisposition, SubscriptionPhase,
};
use uuid::Uuid;
#[cfg(test)]
use self::persistence::HostBillingEventReplayConflictV1;
pub use self::persistence::append_host_billing_event_v1;
#[path = "outbox/persistence.rs"]
mod persistence;
pub const HOST_BILLING_EVENT_SCHEMA_VERSION: u16 = 1;
#[derive(Clone, Serialize)]
pub struct HostBillingEventEnvelopeV1 {
event_id: Uuid,
occurred_at: DateTime<Utc>,
#[serde(flatten)]
replay: HostBillingEventReplayV1,
}
#[derive(Clone, Eq, PartialEq, Serialize)]
pub struct HostBillingEventReplayV1 {
schema_version: u16,
billing_scope_id: Uuid,
subscriber_id: Uuid,
kind: HostBillingEventKindV1,
semantic_key: HostBillingEventSemanticKeyV1,
payload: HostBillingEventPayloadV1,
}
#[derive(Clone, Debug)]
pub enum HostBillingEventAppendOutcomeV1 {
Inserted(HostBillingEventEnvelopeV1),
Replayed(HostBillingEventEnvelopeV1),
}
impl HostBillingEventAppendOutcomeV1 {
pub const fn envelope(&self) -> &HostBillingEventEnvelopeV1 {
match self {
Self::Inserted(envelope) | Self::Replayed(envelope) => envelope,
}
}
pub const fn was_inserted(&self) -> bool {
matches!(self, Self::Inserted(_))
}
}
#[non_exhaustive]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum HostBillingEventReplayDecodeErrorV1 {
UnsupportedVersion,
UnknownEventKind,
UnknownSemanticKind,
InvalidPayload,
}
impl fmt::Display for HostBillingEventReplayDecodeErrorV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let message = match self {
Self::UnsupportedVersion => "unsupported host billing event version",
Self::UnknownEventKind => "unknown host billing event kind",
Self::UnknownSemanticKind => "unknown host billing event semantic kind",
Self::InvalidPayload => "invalid host billing event payload",
};
formatter.write_str(message)
}
}
impl Error for HostBillingEventReplayDecodeErrorV1 {}
impl HostBillingEventEnvelopeV1 {
pub fn from_domain(
event_id: Uuid,
occurred_at: DateTime<Utc>,
subject: BillingEventSubject,
event: &BillingEvent,
) -> Self {
Self {
event_id,
occurred_at,
replay: HostBillingEventReplayV1::from_domain(subject, event),
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_persisted_parts(
event_id: Uuid,
occurred_at: DateTime<Utc>,
event_version: i16,
billing_scope_id: Uuid,
subscriber_id: Uuid,
event_kind: &str,
semantic_kind: &str,
semantic_id: Uuid,
payload: serde_json::Value,
) -> Result<Self, HostBillingEventReplayDecodeErrorV1> {
Ok(Self {
event_id,
occurred_at,
replay: HostBillingEventReplayV1::from_persisted_parts(
event_version,
billing_scope_id,
subscriber_id,
event_kind,
semantic_kind,
semantic_id,
payload,
)?,
})
}
pub const fn event_id(&self) -> Uuid {
self.event_id
}
pub const fn occurred_at(&self) -> DateTime<Utc> {
self.occurred_at
}
pub const fn schema_version(&self) -> u16 {
self.replay.schema_version
}
pub const fn event_version(&self) -> i16 {
HOST_BILLING_EVENT_SCHEMA_VERSION as i16
}
pub const fn billing_scope_id(&self) -> Uuid {
self.replay.billing_scope_id
}
pub const fn subscriber_id(&self) -> Uuid {
self.replay.subscriber_id
}
pub const fn kind(&self) -> &'static str {
self.replay.kind.as_str()
}
pub const fn semantic_kind(&self) -> &'static str {
self.replay.semantic_key.kind.as_str()
}
pub const fn semantic_id(&self) -> Uuid {
self.replay.semantic_key.identity
}
pub const fn replay_contract(&self) -> &HostBillingEventReplayV1 {
&self.replay
}
pub fn persisted_payload(&self) -> Result<serde_json::Value, serde_json::Error> {
self.replay.persisted_payload()
}
pub fn replay_matches(&self, existing: &Self) -> bool {
self.replay == existing.replay
}
}
impl HostBillingEventReplayV1 {
pub fn from_domain(subject: BillingEventSubject, event: &BillingEvent) -> Self {
Self {
schema_version: HOST_BILLING_EVENT_SCHEMA_VERSION,
billing_scope_id: *subject.billing_scope_id().as_uuid(),
subscriber_id: *subject.subscriber_id().as_uuid(),
kind: HostBillingEventKindV1::from(event),
semantic_key: HostBillingEventSemanticKeyV1::from(event.semantic_key()),
payload: HostBillingEventPayloadV1::from(event),
}
}
#[allow(clippy::too_many_arguments)]
pub fn from_persisted_parts(
event_version: i16,
billing_scope_id: Uuid,
subscriber_id: Uuid,
event_kind: &str,
semantic_kind: &str,
semantic_id: Uuid,
payload: serde_json::Value,
) -> Result<Self, HostBillingEventReplayDecodeErrorV1> {
if event_version != HOST_BILLING_EVENT_SCHEMA_VERSION as i16 {
return Err(HostBillingEventReplayDecodeErrorV1::UnsupportedVersion);
}
let kind = HostBillingEventKindV1::parse(event_kind)
.ok_or(HostBillingEventReplayDecodeErrorV1::UnknownEventKind)?;
let semantic_kind = HostBillingEventKindV1::parse(semantic_kind)
.ok_or(HostBillingEventReplayDecodeErrorV1::UnknownSemanticKind)?;
let persisted_payload = payload;
let payload = serde_json::from_value(persisted_payload.clone())
.map_err(|_| HostBillingEventReplayDecodeErrorV1::InvalidPayload)?;
let reconstructed_payload = serde_json::to_value(&payload)
.map_err(|_| HostBillingEventReplayDecodeErrorV1::InvalidPayload)?;
if reconstructed_payload != persisted_payload {
return Err(HostBillingEventReplayDecodeErrorV1::InvalidPayload);
}
Ok(Self {
schema_version: HOST_BILLING_EVENT_SCHEMA_VERSION,
billing_scope_id,
subscriber_id,
kind,
semantic_key: HostBillingEventSemanticKeyV1 {
kind: semantic_kind,
identity: semantic_id,
},
payload,
})
}
pub const fn schema_version(&self) -> u16 {
self.schema_version
}
pub const fn event_version(&self) -> i16 {
HOST_BILLING_EVENT_SCHEMA_VERSION as i16
}
pub const fn billing_scope_id(&self) -> Uuid {
self.billing_scope_id
}
pub const fn subscriber_id(&self) -> Uuid {
self.subscriber_id
}
pub const fn kind(&self) -> &'static str {
self.kind.as_str()
}
pub const fn semantic_kind(&self) -> &'static str {
self.semantic_key.kind.as_str()
}
pub const fn semantic_id(&self) -> Uuid {
self.semantic_key.identity
}
pub fn persisted_payload(&self) -> Result<serde_json::Value, serde_json::Error> {
serde_json::to_value(&self.payload)
}
pub fn replay_matches(&self, existing: &Self) -> bool {
self == existing
}
}
impl fmt::Debug for HostBillingEventEnvelopeV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HostBillingEventEnvelopeV1")
.field("schema_version", &self.replay.schema_version)
.field("kind", &self.replay.kind)
.finish_non_exhaustive()
}
}
impl fmt::Debug for HostBillingEventReplayV1 {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HostBillingEventReplayV1")
.field("schema_version", &self.schema_version)
.field("kind", &self.kind)
.finish_non_exhaustive()
}
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum HostBillingEventKindV1 {
SubscriptionStarted,
SubscriptionRenewed,
SubscriptionPaymentFailed,
SubscriptionEnded,
SubscriptionCanceled,
PaymentMethodChanged,
HostChargePaid,
}
impl HostBillingEventKindV1 {
const fn as_str(self) -> &'static str {
match self {
Self::SubscriptionStarted => "subscription_started",
Self::SubscriptionRenewed => "subscription_renewed",
Self::SubscriptionPaymentFailed => "subscription_payment_failed",
Self::SubscriptionEnded => "subscription_ended",
Self::SubscriptionCanceled => "subscription_canceled",
Self::PaymentMethodChanged => "payment_method_changed",
Self::HostChargePaid => "host_charge_paid",
}
}
fn parse(value: &str) -> Option<Self> {
match value {
"subscription_started" => Some(Self::SubscriptionStarted),
"subscription_renewed" => Some(Self::SubscriptionRenewed),
"subscription_payment_failed" => Some(Self::SubscriptionPaymentFailed),
"subscription_ended" => Some(Self::SubscriptionEnded),
"subscription_canceled" => Some(Self::SubscriptionCanceled),
"payment_method_changed" => Some(Self::PaymentMethodChanged),
"host_charge_paid" => Some(Self::HostChargePaid),
_ => None,
}
}
}
impl From<&BillingEvent> for HostBillingEventKindV1 {
fn from(event: &BillingEvent) -> Self {
match event {
BillingEvent::SubscriptionStarted { .. } => Self::SubscriptionStarted,
BillingEvent::SubscriptionRenewed { .. } => Self::SubscriptionRenewed,
BillingEvent::SubscriptionPaymentFailed { .. } => Self::SubscriptionPaymentFailed,
BillingEvent::SubscriptionEnded { .. } => Self::SubscriptionEnded,
BillingEvent::SubscriptionCanceled { .. } => Self::SubscriptionCanceled,
BillingEvent::PaymentMethodChanged { .. } => Self::PaymentMethodChanged,
BillingEvent::HostChargePaid { .. } => Self::HostChargePaid,
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
struct HostBillingEventSemanticKeyV1 {
kind: HostBillingEventKindV1,
identity: Uuid,
}
impl From<BillingEventKey> for HostBillingEventSemanticKeyV1 {
fn from(key: BillingEventKey) -> Self {
match key {
BillingEventKey::SubscriptionStarted(id) => Self {
kind: HostBillingEventKindV1::SubscriptionStarted,
identity: *id.as_uuid(),
},
BillingEventKey::SubscriptionRenewed(id) => Self {
kind: HostBillingEventKindV1::SubscriptionRenewed,
identity: *id.as_uuid(),
},
BillingEventKey::SubscriptionPaymentFailed(id) => Self {
kind: HostBillingEventKindV1::SubscriptionPaymentFailed,
identity: *id.as_uuid(),
},
BillingEventKey::SubscriptionEnded(id) => Self {
kind: HostBillingEventKindV1::SubscriptionEnded,
identity: *id.as_uuid(),
},
BillingEventKey::SubscriptionCanceled(id) => Self {
kind: HostBillingEventKindV1::SubscriptionCanceled,
identity: *id.as_uuid(),
},
BillingEventKey::PaymentMethodChanged(id) => Self {
kind: HostBillingEventKindV1::PaymentMethodChanged,
identity: *id.as_uuid(),
},
BillingEventKey::HostChargePaid(id) => Self {
kind: HostBillingEventKindV1::HostChargePaid,
identity: *id.as_uuid(),
},
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "type", content = "data", rename_all = "snake_case")]
enum HostBillingEventPayloadV1 {
SubscriptionStarted {
attempt_id: Uuid,
subscription_id: Uuid,
plan_key: String,
charge: HostChargeAmountV1,
period: HostBillingPeriodV1,
phase: HostSubscriptionPhaseV1,
},
SubscriptionRenewed {
attempt_id: Uuid,
subscription_id: Uuid,
plan_key: String,
charge: HostChargeAmountV1,
period: HostBillingPeriodV1,
},
SubscriptionPaymentFailed {
attempt_id: Uuid,
subscription_id: Uuid,
plan_key: String,
disposition: HostSubscriptionPaymentFailureDispositionV1,
access: HostSubscriptionPaymentFailureAccessV1,
},
SubscriptionEnded {
attempt_id: Uuid,
subscription_id: Uuid,
plan_key: String,
reason: HostSubscriptionEndReasonV1,
ended_at: DateTime<Utc>,
access_ends_at: DateTime<Utc>,
},
SubscriptionCanceled {
subscription_id: Uuid,
plan_key: String,
access_ends_at: DateTime<Utc>,
},
PaymentMethodChanged {
attempt_id: Uuid,
subscription_id: Uuid,
plan_key: String,
card: Option<HostPaymentCardDisplayV1>,
},
HostChargePaid {
attempt_id: Uuid,
target_id: Uuid,
charge: HostChargeAmountV1,
},
}
impl From<&BillingEvent> for HostBillingEventPayloadV1 {
fn from(event: &BillingEvent) -> Self {
match event {
BillingEvent::SubscriptionStarted {
attempt_id,
subscription_id,
plan_key,
charge,
period,
phase,
} => Self::SubscriptionStarted {
attempt_id: *attempt_id.as_uuid(),
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
charge: (*charge).into(),
period: period.into(),
phase: (*phase).into(),
},
BillingEvent::SubscriptionRenewed {
attempt_id,
subscription_id,
plan_key,
charge,
period,
} => Self::SubscriptionRenewed {
attempt_id: *attempt_id.as_uuid(),
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
charge: (*charge).into(),
period: period.into(),
},
BillingEvent::SubscriptionPaymentFailed {
attempt_id,
subscription_id,
plan_key,
disposition,
access,
} => Self::SubscriptionPaymentFailed {
attempt_id: *attempt_id.as_uuid(),
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
disposition: (*disposition).into(),
access: (*access).into(),
},
BillingEvent::SubscriptionEnded {
attempt_id,
subscription_id,
plan_key,
reason,
ended_at,
access_ends_at,
} => Self::SubscriptionEnded {
attempt_id: *attempt_id.as_uuid(),
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
reason: (*reason).into(),
ended_at: *ended_at,
access_ends_at: *access_ends_at,
},
BillingEvent::SubscriptionCanceled {
subscription_id,
plan_key,
access_ends_at,
} => Self::SubscriptionCanceled {
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
access_ends_at: *access_ends_at,
},
BillingEvent::PaymentMethodChanged {
attempt_id,
subscription_id,
plan_key,
card,
} => Self::PaymentMethodChanged {
attempt_id: *attempt_id.as_uuid(),
subscription_id: *subscription_id.as_uuid(),
plan_key: plan_key.as_str().to_owned(),
card: card.as_ref().map(|card| HostPaymentCardDisplayV1 {
brand: card.brand().into(),
last_four: card.last_four().expose().to_owned(),
}),
},
BillingEvent::HostChargePaid {
attempt_id,
target_id,
charge,
} => Self::HostChargePaid {
attempt_id: *attempt_id.as_uuid(),
target_id: *target_id.as_uuid(),
charge: (*charge).into(),
},
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
struct HostChargeAmountV1 {
cents: i32,
currency: String,
}
impl From<ChargeAmount> for HostChargeAmountV1 {
fn from(charge: ChargeAmount) -> Self {
Self {
cents: charge.cents(),
currency: charge.currency().as_str().to_owned(),
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
struct HostBillingPeriodV1 {
start_at: DateTime<Utc>,
end_at: DateTime<Utc>,
}
impl From<&BillingPeriod> for HostBillingPeriodV1 {
fn from(period: &BillingPeriod) -> Self {
Self {
start_at: *period.start_at(),
end_at: *period.end_at(),
}
}
}
#[derive(Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum HostSubscriptionPhaseV1 {
PaidTrial,
Recurring,
}
impl From<SubscriptionPhase> for HostSubscriptionPhaseV1 {
fn from(phase: SubscriptionPhase) -> Self {
match phase {
SubscriptionPhase::PaidTrial => Self::PaidTrial,
SubscriptionPhase::Recurring => Self::Recurring,
}
}
}
#[derive(Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum HostSubscriptionEndReasonV1 {
NonPayment,
}
impl From<SubscriptionEndReason> for HostSubscriptionEndReasonV1 {
fn from(reason: SubscriptionEndReason) -> Self {
match reason {
SubscriptionEndReason::NonPayment => Self::NonPayment,
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum HostSubscriptionPaymentFailureDispositionV1 {
RetryScheduled { retry_at: DateTime<Utc> },
DunningExhausted { exhausted_at: DateTime<Utc> },
SubscriptionEnded { ended_at: DateTime<Utc> },
}
impl From<SubscriptionPaymentFailureDisposition> for HostSubscriptionPaymentFailureDispositionV1 {
fn from(disposition: SubscriptionPaymentFailureDisposition) -> Self {
match disposition {
SubscriptionPaymentFailureDisposition::RetryScheduled { retry_at } => {
Self::RetryScheduled { retry_at }
}
SubscriptionPaymentFailureDisposition::DunningExhausted { exhausted_at } => {
Self::DunningExhausted { exhausted_at }
}
SubscriptionPaymentFailureDisposition::SubscriptionEnded { ended_at } => {
Self::SubscriptionEnded { ended_at }
}
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum HostSubscriptionPaymentFailureAccessV1 {
ContinuesDuringDunning,
Ended { access_ended_at: DateTime<Utc> },
}
impl From<SubscriptionPaymentFailureAccess> for HostSubscriptionPaymentFailureAccessV1 {
fn from(access: SubscriptionPaymentFailureAccess) -> Self {
match access {
SubscriptionPaymentFailureAccess::ContinuesDuringDunning => {
Self::ContinuesDuringDunning
}
SubscriptionPaymentFailureAccess::Ended { access_ended_at } => {
Self::Ended { access_ended_at }
}
}
}
}
#[derive(Clone, Copy, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
enum HostPaymentCardBrandV1 {
Visa,
Mastercard,
AmericanExpress,
Discover,
Jcb,
DinersClub,
UnionPay,
Maestro,
Other,
}
impl From<&PaymentCardBrand> for HostPaymentCardBrandV1 {
fn from(brand: &PaymentCardBrand) -> Self {
match brand {
PaymentCardBrand::Visa => Self::Visa,
PaymentCardBrand::Mastercard => Self::Mastercard,
PaymentCardBrand::AmericanExpress => Self::AmericanExpress,
PaymentCardBrand::Discover => Self::Discover,
PaymentCardBrand::Jcb => Self::Jcb,
PaymentCardBrand::DinersClub => Self::DinersClub,
PaymentCardBrand::UnionPay => Self::UnionPay,
PaymentCardBrand::Maestro => Self::Maestro,
PaymentCardBrand::Other => Self::Other,
_ => Self::Other,
}
}
}
#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)]
struct HostPaymentCardDisplayV1 {
brand: HostPaymentCardBrandV1,
last_four: String,
}
#[cfg(test)]
#[path = "outbox/tests.rs"]
mod tests;