syrup-rail 0.2.0

Validated domain types and lifecycle policy for Syrup Rail billing
Documentation
use chrono::TimeZone;
use uuid::Uuid;

use super::*;
use crate::{
    ChargeAmount, CumulativeRefundCents, CurrencyCode, DunningExhaustion, DunningSchedule,
    GatewayPaymentDescriptor, GatewayPaymentMethodReference, PastDueAccessPolicy,
    RecurringSubscriptionTerms, RenewalFailurePolicy, SubscriptionPeriodRule, SubscriptionStart,
};

fn subscription(value: u128) -> SubscriptionId {
    SubscriptionId::new(Uuid::from_u128(value))
}

fn method(value: u128) -> PaymentMethodId {
    PaymentMethodId::new(Uuid::from_u128(value))
}

fn target(value: u128) -> HostChargeTargetId {
    HostChargeTargetId::new(Uuid::from_u128(value))
}

fn identity() -> PaymentAttemptIdentity {
    PaymentAttemptIdentity::new(
        PaymentAttemptId::new(Uuid::from_u128(10)),
        BillingScopeId::new(Uuid::from_u128(11)),
        SubscriberId::new(Uuid::from_u128(12)),
        GatewayAccountId::new(Uuid::from_u128(13)),
        GatewayConfigurationId::new(Uuid::from_u128(14)),
    )
}

fn request(target: PaymentAttemptTarget, cents: i32) -> PaymentAttemptRequest {
    PaymentAttemptRequest::new(
        target,
        IdempotencyKey::new("idempotency-secret").unwrap(),
        PaymentAttemptFingerprint::new("fingerprint-secret").unwrap(),
        Money::new(cents, CurrencyCode::new("USD").unwrap()).unwrap(),
        GatewayOrderId::from_correlation("order-secret").unwrap(),
        BillingContactSnapshot::new(
            Some("Sensitive Name".to_owned()),
            Some("secret@example.test".to_owned()),
        ),
    )
}

fn instant(second: u32) -> DateTime<Utc> {
    Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, second).unwrap()
}

fn state(
    status: PaymentAttemptStatus,
    resolved_at: Option<DateTime<Utc>>,
    review_required_at: Option<DateTime<Utc>>,
) -> PaymentAttemptState {
    PaymentAttemptState::new(
        status,
        None,
        ProcessorEvidence::default(),
        PaymentAttemptLifecycle::default(),
        PaymentAttemptTimestamps::new(
            None,
            resolved_at,
            review_required_at,
            instant(0),
            instant(1),
        ),
    )
}

fn initial_target(application: Option<SubscriptionInitialApplication>) -> PaymentAttemptTarget {
    PaymentAttemptTarget::SubscriptionInitial {
        terms_version: SubscriptionEnrollmentTermsVersion::V2,
        offer: SubscriptionOffer::new(
            PlanKey::new("basic").unwrap(),
            RecurringSubscriptionTerms::new(
                ChargeAmount::new(1_000, CurrencyCode::new("USD").unwrap()).unwrap(),
                SubscriptionPeriodRule::calendar_months(1).unwrap(),
            ),
            SubscriptionStart::RecurringImmediately,
            RenewalFailurePolicy::new(
                DunningSchedule::default(),
                DunningExhaustion::RemainPastDue,
                PastDueAccessPolicy::SuspendImmediately,
            ),
        )
        .unwrap(),
        discount: None,
        application,
    }
}

#[test]
fn fingerprints_are_nonempty_and_value_safe_to_format() {
    assert_eq!(
        PaymentAttemptFingerprint::new(" "),
        Err(PaymentAttemptFingerprintError::Empty),
    );
    let fingerprint = PaymentAttemptFingerprint::new("secret:economics").unwrap();
    assert_eq!(fingerprint.expose(), "secret:economics");
    assert!(!format!("{fingerprint:?}").contains("secret:economics"));
    assert_eq!(fingerprint.to_string(), "[redacted]");
}

#[test]
fn payment_state_snapshots_are_typed_and_redact_transaction_identity() {
    let transaction = GatewayTransactionId::new("txn-secret").unwrap();
    let update = PaymentMethodUpdateSnapshot::new(subscription(1), method(2), transaction.clone());
    let state = SubscriptionPaymentStateSnapshot::new(
        subscription(1),
        method(2),
        transaction,
        SubscriptionStatus::PastDue,
    )
    .unwrap();
    assert_eq!(state.status(), SubscriptionStatus::PastDue);
    assert!(!format!("{update:?}").contains("txn-secret"));
    assert!(!format!("{state:?}").contains("txn-secret"));
    assert_eq!(
        SubscriptionPaymentStateSnapshot::new(
            subscription(1),
            method(2),
            GatewayTransactionId::new("txn").unwrap(),
            SubscriptionStatus::Canceled,
        ),
        Err(PaymentAttemptSnapshotError::TerminalSubscription),
    );
    assert_eq!(
        SubscriptionPaymentStateSnapshot::new(
            subscription(1),
            method(2),
            GatewayTransactionId::new("txn").unwrap(),
            SubscriptionStatus::Unpaid,
        ),
        Err(PaymentAttemptSnapshotError::TerminalSubscription),
    );
}

#[test]
fn attempt_timestamps_choose_submission_as_the_economic_boundary() {
    let created = Utc.with_ymd_and_hms(2026, 8, 1, 0, 0, 0).unwrap();
    let submitted = Utc.with_ymd_and_hms(2026, 8, 1, 0, 1, 0).unwrap();
    assert_eq!(
        PaymentAttemptTimestamps::new(Some(submitted), None, None, created, submitted,)
            .submitted_or_created_at(),
        submitted,
    );
    assert_eq!(
        PaymentAttemptTimestamps::new(None, None, None, created, created).submitted_or_created_at(),
        created,
    );
}

#[test]
fn amount_shape_follows_attempt_kind() {
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(
                PaymentAttemptTarget::HostCharge {
                    target_id: target(20),
                },
                0,
            ),
            state(PaymentAttemptStatus::Pending, None, None),
        ),
        Err(PaymentAttemptError::ZeroAmountRequiresPaymentMethodUpdate),
    );

    let update = PaymentAttemptTarget::SubscriptionPaymentMethodUpdate {
        plan_key: PlanKey::new("basic").unwrap(),
        payment_method_id: method(22),
        expected_state: PaymentMethodUpdateSnapshot::new(
            subscription(21),
            method(22),
            GatewayTransactionId::new("txn-initial").unwrap(),
        ),
    };
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(update.clone(), 1),
            state(PaymentAttemptStatus::Pending, None, None),
        ),
        Err(PaymentAttemptError::PaymentMethodUpdateRequiresZeroAmount),
    );
    let accepted = PaymentAttempt::new(
        identity(),
        request(update, 0),
        state(PaymentAttemptStatus::Pending, None, None),
    )
    .unwrap();
    assert_eq!(
        accepted.kind(),
        PaymentAttemptKind::SubscriptionPaymentMethodUpdate
    );
}

#[test]
fn initial_application_identity_follows_resolution_state() {
    let application = SubscriptionInitialApplication::new(Some(subscription(30)), method(31));
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(initial_target(Some(application)), 1_000),
            state(PaymentAttemptStatus::Pending, None, None),
        ),
        Err(PaymentAttemptError::InitialApplicationBeforeResolution),
    );
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(initial_target(None), 1_000),
            state(PaymentAttemptStatus::Approved, Some(instant(2)), None),
        ),
        Err(PaymentAttemptError::ApprovedInitialMissingApplication),
    );
    let approved = PaymentAttempt::new(
        identity(),
        request(initial_target(Some(application)), 1_000),
        state(PaymentAttemptStatus::Approved, Some(instant(2)), None),
    )
    .unwrap();
    assert_eq!(
        approved.request().target().subscription_id(),
        Some(subscription(30))
    );
    assert_eq!(
        approved.request().target().payment_method_id(),
        Some(method(31))
    );
}

#[test]
fn terminal_and_review_states_require_their_durable_boundaries() {
    let host = || PaymentAttemptTarget::HostCharge {
        target_id: target(40),
    };
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(host(), 1_000),
            state(PaymentAttemptStatus::Failed, None, None),
        ),
        Err(PaymentAttemptError::TerminalAttemptMissingResolvedAt),
    );
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(host(), 1_000),
            state(PaymentAttemptStatus::ReviewRequired, None, None),
        ),
        Err(PaymentAttemptError::ReviewAttemptMissingReviewRequiredAt),
    );
    assert!(
        PaymentAttempt::new(
            identity(),
            request(host(), 1_000),
            state(PaymentAttemptStatus::ReviewRequired, None, Some(instant(2)),),
        )
        .is_ok()
    );
}

#[test]
fn lifecycle_refund_amount_must_match_the_attempt_amount() {
    let lifecycle = PaymentAttemptLifecycle::new(
        GatewayLifecycleState::Refunded {
            cumulative_refunded_cents: CumulativeRefundCents::new(999).unwrap(),
        },
        None,
        None,
        None,
    );
    let invalid_state = PaymentAttemptState::new(
        PaymentAttemptStatus::Pending,
        None,
        ProcessorEvidence::default(),
        lifecycle,
        PaymentAttemptTimestamps::new(None, None, None, instant(0), instant(1)),
    );
    assert_eq!(
        PaymentAttempt::new(
            identity(),
            request(
                PaymentAttemptTarget::HostCharge {
                    target_id: target(50),
                },
                1_000,
            ),
            invalid_state,
        ),
        Err(PaymentAttemptError::InvalidLifecycleAmount),
    );
}

#[test]
fn typed_targets_preserve_exact_relationships() {
    let period = BillingPeriod::new(instant(0), instant(2)).unwrap();
    let expected_state = SubscriptionPaymentStateSnapshot::new(
        subscription(60),
        method(61),
        GatewayTransactionId::new("txn-original").unwrap(),
        SubscriptionStatus::PastDue,
    )
    .unwrap();
    let renewal = PaymentAttemptTarget::SubscriptionRenewal {
        plan_key: PlanKey::new("premium").unwrap(),
        payment_method_id: method(62),
        period: period.clone(),
        expected_state: expected_state.clone(),
    };
    assert_eq!(renewal.kind(), PaymentAttemptKind::SubscriptionRenewal);
    assert_eq!(renewal.plan_key().unwrap().as_str(), "premium");
    assert_eq!(renewal.subscription_id(), Some(subscription(60)));
    assert_eq!(renewal.payment_method_id(), Some(method(62)));
    assert_eq!(
        renewal
            .subscription_payment_state_snapshot()
            .unwrap()
            .payment_method_id(),
        method(61)
    );
    assert_eq!(renewal.period(), Some(&period));
    assert_eq!(
        renewal.subscription_payment_state_snapshot(),
        Some(&expected_state)
    );
    assert_eq!(renewal.host_charge_target_id(), None);
}

#[test]
fn durable_attempt_debug_is_value_free() {
    let evidence = ProcessorEvidence::new(
        Some(GatewayTransactionId::new("transaction-secret").unwrap()),
        Some(GatewayPaymentMethodReference::new("method-secret").unwrap()),
        Some(GatewayDiagnostic::new("response-secret")),
        Some(GatewayDiagnostic::new("code-secret")),
        Some(GatewayDiagnostic::new("text-secret")),
        Some(GatewayDiagnostic::new("condition-secret")),
        GatewayPaymentDescriptor::default(),
    );
    let state = PaymentAttemptState::new(
        PaymentAttemptStatus::Pending,
        None,
        evidence,
        PaymentAttemptLifecycle::new(
            GatewayLifecycleState::Unknown,
            Some(GatewayDiagnostic::new("action-secret")),
            None,
            None,
        ),
        PaymentAttemptTimestamps::new(None, None, None, instant(0), instant(1)),
    );
    let attempt = PaymentAttempt::new(
        identity(),
        request(
            PaymentAttemptTarget::HostCharge {
                target_id: target(70),
            },
            1_000,
        ),
        state,
    )
    .unwrap();
    let debug = format!("{attempt:?}");
    for secret in [
        "idempotency-secret",
        "fingerprint-secret",
        "order-secret",
        "Sensitive Name",
        "secret@example.test",
        "transaction-secret",
        "method-secret",
        "response-secret",
        "code-secret",
        "text-secret",
        "condition-secret",
        "action-secret",
    ] {
        assert!(!debug.contains(secret), "debug leaked {secret}");
    }
    assert!(debug.contains("has_idempotency_key"));
    assert!(debug.contains("has_transaction_id"));
}