#[path = "host_integration/outbox.rs"]
mod outbox;
pub use outbox::{
HostBillingEventAppendOutcomeV1, HostBillingEventEnvelopeV1,
HostBillingEventReplayDecodeErrorV1, HostBillingEventReplayV1, append_host_billing_event_v1,
};
use std::{sync::Arc, time::Duration};
use async_trait::async_trait;
use sqlx::{PgConnection, PgPool, Postgres, Transaction};
use syrup_rail::{
BillingEvent, BillingEventSubject, CancelSubscription, CancelSubscriptionOutcome,
ClearSubscriptionDiscount, EndUserMutationAdmission, EnrollSubscription, EntitlementGuard,
GatewayAccountMode, GatewayResolver, PaymentAttemptId, PaymentAttemptStatus,
RenewalDispatchPage, RenewalDispatchPageCursor, SubscriptionBillingPortalQuery,
SubscriptionBillingPortalSnapshot, SubscriptionDiscountClaim, SubscriptionDiscountClaimOutcome,
SubscriptionDiscountClearOutcome, SubscriptionEnrollmentExpectedTerms, SubscriptionId,
SubscriptionPaymentContext, SubscriptionPaymentHistoryCursor, SubscriptionPaymentHistoryPage,
SubscriptionPaymentHistoryPageLimit,
};
use syrup_rail_postgres::{
AdmittedEntitlementWriteTransaction, BillingEventWriteError, BillingTransaction,
BillingTransactionCoordinator, BillingTransactionError, BillingTransactionSubjectState,
EntitlementGuardError, EntitlementWriteTransaction, HostChargeTargetStore, RenewalStoreError,
SchemaConformanceError, SubscriptionBillingPortalQueryError, SubscriptionBillingService,
SubscriptionBillingServiceError, SubscriptionBillingServiceErrorDisposition,
SubscriptionOfferStore, assert_runtime_schema_v4_compatible, due_renewals_page_for_mode,
require_entitlement_for_update, subscription_billing_portal, subscription_payment_history_page,
};
pub struct HostBillingPorts {
offers: Arc<dyn SubscriptionOfferStore>,
gateways: Arc<dyn GatewayResolver>,
admission: Arc<dyn EndUserMutationAdmission>,
host_charge_targets: Arc<dyn HostChargeTargetStore>,
billing_boundary: Arc<dyn HostBillingBoundary>,
}
impl HostBillingPorts {
pub fn new(
offers: Arc<dyn SubscriptionOfferStore>,
gateways: Arc<dyn GatewayResolver>,
admission: Arc<dyn EndUserMutationAdmission>,
host_charge_targets: Arc<dyn HostChargeTargetStore>,
billing_boundary: Arc<dyn HostBillingBoundary>,
) -> Self {
Self {
offers,
gateways,
admission,
host_charge_targets,
billing_boundary,
}
}
}
pub fn build_subscription_billing_service(
pool: PgPool,
ports: HostBillingPorts,
required_gateway_account_mode: GatewayAccountMode,
) -> SubscriptionBillingService {
let transactions = Arc::new(HostTransactionCoordinator::new(
pool.clone(),
ports.billing_boundary,
));
SubscriptionBillingService::new(
pool,
ports.offers,
ports.gateways,
ports.admission,
transactions,
)
.with_required_gateway_account_mode(required_gateway_account_mode)
.with_host_charge_targets(ports.host_charge_targets)
}
pub async fn assert_host_runtime_schema_compatibility(
pool: &PgPool,
) -> Result<(), SchemaConformanceError> {
assert_runtime_schema_v4_compatible(pool).await
}
pub async fn admit_authorized_protected_write(
transaction: EntitlementWriteTransaction,
guard: &EntitlementGuard,
) -> Result<AdmittedEntitlementWriteTransaction, EntitlementGuardError> {
require_entitlement_for_update(transaction, guard).await
}
#[async_trait]
pub trait HostBillingBoundary: Send + Sync {
async fn lock_billing_subject(
&self,
connection: &mut PgConnection,
subject: BillingEventSubject,
) -> Result<BillingTransactionSubjectState, BillingTransactionError>;
async fn append_outbox_event(
&self,
connection: &mut PgConnection,
subject: BillingEventSubject,
event: &BillingEvent,
) -> Result<(), BillingEventWriteError>;
}
#[derive(Clone)]
pub struct HostTransactionCoordinator {
pool: PgPool,
boundary: Arc<dyn HostBillingBoundary>,
}
impl HostTransactionCoordinator {
pub fn new(pool: PgPool, boundary: Arc<dyn HostBillingBoundary>) -> Self {
Self { pool, boundary }
}
}
#[async_trait]
impl BillingTransactionCoordinator for HostTransactionCoordinator {
async fn begin(
&self,
subject: BillingEventSubject,
lock_timeout: Duration,
) -> Result<Box<dyn BillingTransaction>, BillingTransactionError> {
let mut transaction = self
.pool
.begin()
.await
.map_err(BillingTransactionError::new)?;
sqlx::query("SELECT set_config('lock_timeout', $1, true)")
.bind(format!("{}ms", lock_timeout.as_millis().max(1)))
.execute(&mut *transaction)
.await
.map_err(BillingTransactionError::new)?;
let subject_state = self
.boundary
.lock_billing_subject(&mut transaction, subject)
.await?;
Ok(Box::new(HostTransaction {
transaction,
subject,
subject_state,
boundary: Arc::clone(&self.boundary),
}))
}
}
struct HostTransaction {
transaction: Transaction<'static, Postgres>,
subject: BillingEventSubject,
subject_state: BillingTransactionSubjectState,
boundary: Arc<dyn HostBillingBoundary>,
}
#[async_trait]
impl BillingTransaction for HostTransaction {
fn connection(&mut self) -> &mut PgConnection {
&mut self.transaction
}
fn subject_state(&self) -> BillingTransactionSubjectState {
self.subject_state
}
async fn append_event(&mut self, event: &BillingEvent) -> Result<(), BillingEventWriteError> {
self.boundary
.append_outbox_event(&mut self.transaction, self.subject, event)
.await
}
async fn commit(self: Box<Self>) -> Result<(), BillingTransactionError> {
let Self { transaction, .. } = *self;
transaction
.commit()
.await
.map_err(BillingTransactionError::new)
}
async fn rollback(self: Box<Self>) -> Result<(), BillingTransactionError> {
let Self { transaction, .. } = *self;
transaction
.rollback()
.await
.map_err(BillingTransactionError::new)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EnrollmentDecision {
Activated {
attempt_id: PaymentAttemptId,
subscription_id: SubscriptionId,
},
ConfirmationPending {
attempt_id: PaymentAttemptId,
},
NotActivated {
attempt_id: PaymentAttemptId,
status: PaymentAttemptStatus,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AuthorizedBillingCommandRetry {
DoNotRetry,
ResubmitSameIdempotentCommand { retry_after: Option<Duration> },
}
pub fn retry_action_for_authorized_billing_command(
error: &SubscriptionBillingServiceError,
) -> AuthorizedBillingCommandRetry {
match error.disposition() {
SubscriptionBillingServiceErrorDisposition::TemporarilyUnavailable => {
AuthorizedBillingCommandRetry::ResubmitSameIdempotentCommand {
retry_after: error.retry_after(),
}
}
SubscriptionBillingServiceErrorDisposition::Conflict
| SubscriptionBillingServiceErrorDisposition::Rejected
| SubscriptionBillingServiceErrorDisposition::Misconfigured
| SubscriptionBillingServiceErrorDisposition::Internal => {
AuthorizedBillingCommandRetry::DoNotRetry
}
_ => AuthorizedBillingCommandRetry::DoNotRetry,
}
}
pub async fn enroll_authorized_subscriber(
service: &SubscriptionBillingService,
payment: SubscriptionPaymentContext,
expected_terms: SubscriptionEnrollmentExpectedTerms,
) -> Result<EnrollmentDecision, SubscriptionBillingServiceError> {
let result = service
.enroll(EnrollSubscription::new(payment, expected_terms))
.await?;
let attempt_id = result.attempt().identity().attempt_id();
if let Some(subscription) = result.subscription() {
return Ok(EnrollmentDecision::Activated {
attempt_id,
subscription_id: subscription.id(),
});
}
if result.is_confirmation_pending() {
return Ok(EnrollmentDecision::ConfirmationPending { attempt_id });
}
Ok(EnrollmentDecision::NotActivated {
attempt_id,
status: result.status(),
})
}
pub async fn cancel_authorized_subscription(
service: &SubscriptionBillingService,
command: CancelSubscription,
) -> Result<CancelSubscriptionOutcome, SubscriptionBillingServiceError> {
service.cancel(command).await
}
pub async fn claim_authorized_subscription_discount(
service: &SubscriptionBillingService,
command: SubscriptionDiscountClaim,
) -> Result<SubscriptionDiscountClaimOutcome, SubscriptionBillingServiceError> {
service.claim_discount(command).await
}
pub async fn clear_authorized_subscription_discount(
service: &SubscriptionBillingService,
command: ClearSubscriptionDiscount,
) -> Result<SubscriptionDiscountClearOutcome, SubscriptionBillingServiceError> {
service.clear_discount(command).await
}
pub async fn read_authorized_subscription_billing_portal(
pool: &PgPool,
query: &SubscriptionBillingPortalQuery,
) -> Result<SubscriptionBillingPortalSnapshot, SubscriptionBillingPortalQueryError> {
subscription_billing_portal(pool, query).await
}
pub async fn read_authorized_subscription_payment_history(
pool: &PgPool,
query: &SubscriptionBillingPortalQuery,
cursor: Option<&SubscriptionPaymentHistoryCursor>,
limit: SubscriptionPaymentHistoryPageLimit,
) -> Result<SubscriptionPaymentHistoryPage, SubscriptionBillingPortalQueryError> {
subscription_payment_history_page(pool, query, cursor, limit).await
}
pub async fn read_renewal_dispatch_page(
pool: &PgPool,
required_gateway_account_mode: GatewayAccountMode,
cursor: Option<&RenewalDispatchPageCursor>,
) -> Result<RenewalDispatchPage, RenewalStoreError> {
due_renewals_page_for_mode(pool, required_gateway_account_mode, cursor).await
}
fn main() {}