syrup-rail-postgres 0.5.0

Canonical provider-neutral PostgreSQL schema contract and SQLx orchestration for Syrup Rail
Documentation
use super::*;

#[tokio::test]
async fn cooldown_persistence_distinguishes_rotation_from_missing_provider_state()
-> Result<(), Box<dyn std::error::Error>> {
    let database = TestDatabase::start("cooldown_binding").await?;
    let result = async {
        let account = create_gateway_account(&database.pool, "nmi").await?;
        create_gateway_account(&database.pool, "other_gateway").await?;
        let identity = PaymentAttemptIdentity::new(
            PaymentAttemptId::new(Uuid::now_v7()),
            BillingScopeId::new(account.billing_scope_id),
            SubscriberId::new(Uuid::now_v7()),
            GatewayAccountId::new(account.gateway_account_id),
            GatewayConfigurationId::new(account.gateway_configuration_id),
            GatewayAccountMode::Live,
        );
        assert_eq!(
            commit_rate_limit_cooldown_for_identity(
                &database.pool,
                identity,
                &GatewayProviderKey::new("nmi")?,
                RateLimitCooldown::Provider,
            )
            .await?,
            RateLimitCooldownCommitDisposition::IdentityNotDurable,
        );
        let mut connection = database.pool.acquire().await?;
        assert_eq!(
            persist_rate_limit_cooldown(
                &mut connection,
                identity,
                &GatewayProviderKey::new("other_gateway")?,
                RateLimitCooldown::Provider,
            )
            .await?,
            RateLimitCooldownPersistence::IdentityChanged,
        );
        let incorrectly_throttled: bool = sqlx::query_scalar(
            "SELECT rate_limited_until > clock_timestamp() \
             FROM billing_gateway_provider_rate_limits WHERE provider_key = 'other_gateway'",
        )
        .fetch_one(&mut *connection)
        .await?;
        assert!(!incorrectly_throttled);
        assert_eq!(
            persist_rate_limit_cooldown(
                &mut connection,
                identity,
                &GatewayProviderKey::new("nmi")?,
                RateLimitCooldown::Account,
            )
            .await?,
            RateLimitCooldownPersistence::Applied,
        );

        sqlx::query(
            "UPDATE billing_gateway_accounts SET gateway_configuration_id = $2 WHERE id = $1",
        )
        .bind(account.gateway_account_id)
        .bind(Uuid::now_v7())
        .execute(&mut *connection)
        .await?;

        assert_eq!(
            persist_rate_limit_cooldown(
                &mut connection,
                identity,
                &GatewayProviderKey::new("nmi")?,
                RateLimitCooldown::Provider,
            )
            .await?,
            RateLimitCooldownPersistence::Applied,
        );
        assert_eq!(
            persist_rate_limit_cooldown(
                &mut connection,
                identity,
                &GatewayProviderKey::new("nmi")?,
                RateLimitCooldown::Account,
            )
            .await?,
            RateLimitCooldownPersistence::IdentityChanged,
        );
        let correctly_throttled: bool = sqlx::query_scalar(
            "SELECT rate_limited_until > clock_timestamp() \
             FROM billing_gateway_provider_rate_limits WHERE provider_key = 'nmi'",
        )
        .fetch_one(&mut *connection)
        .await?;
        assert!(correctly_throttled);

        sqlx::query(
            "ALTER TABLE billing_gateway_accounts DROP CONSTRAINT billing_gateway_accounts_provider_fk",
        )
        .execute(&mut *connection)
        .await?;
        sqlx::query("DELETE FROM billing_gateway_provider_rate_limits WHERE provider_key = 'nmi'")
            .execute(&mut *connection)
            .await?;
        assert_eq!(
            persist_rate_limit_cooldown(
                &mut connection,
                identity,
                &GatewayProviderKey::new("nmi")?,
                RateLimitCooldown::Provider,
            )
            .await?,
            RateLimitCooldownPersistence::MissingProviderCooldown,
        );
        Ok::<_, Box<dyn std::error::Error>>(())
    }
    .await;
    let cleanup = database.cleanup().await;
    result?;
    cleanup
}

fn initial_attempt_for_matching(
    identity: PaymentAttemptIdentity,
    plan_key: PlanKey,
    gateway_order_id: GatewayOrderId,
    idempotency_key: &str,
) -> PaymentAttempt {
    let timestamp = Utc::now();
    let request = PaymentAttemptRequest::new(
        PaymentAttemptTarget::SubscriptionInitial {
            terms_version: syrup_rail::SubscriptionEnrollmentTermsVersion::V2,
            offer: immediate_offer(
                plan_key,
                ChargeAmount::new(1_000, CurrencyCode::new("USD").unwrap()).unwrap(),
            ),
            discount: None,
            application: None,
        },
        IdempotencyKey::new(idempotency_key).expect("test idempotency key"),
        PaymentAttemptFingerprint::new(format!("fingerprint-{idempotency_key}"))
            .expect("test fingerprint"),
        Money::new(1_000, CurrencyCode::new("USD").expect("test currency")).expect("test amount"),
        gateway_order_id,
        BillingContactSnapshot::new(None, None),
    );
    PaymentAttempt::new(
        identity,
        request,
        PaymentAttemptState::new(
            PaymentAttemptStatus::Pending,
            None,
            ProcessorEvidence::default(),
            PaymentAttemptLifecycle::default(),
            PaymentAttemptTimestamps::new(None, None, None, timestamp, timestamp),
        ),
    )
    .expect("valid test initial attempt")
}

#[test]
fn reservation_attempt_matching_preserves_initial_and_exact_rules() {
    assert_eq!(
        ReservationOperation::Initial.expected_kind(),
        PaymentAttemptKind::SubscriptionInitial
    );
    assert_eq!(
        ReservationOperation::Recovery.expected_kind(),
        PaymentAttemptKind::SubscriptionRecovery
    );
    assert_eq!(
        ReservationOperation::Renewal.expected_kind(),
        PaymentAttemptKind::SubscriptionRenewal
    );
    assert_eq!(
        ReservationOperation::PaymentMethodReplacement.expected_kind(),
        PaymentAttemptKind::SubscriptionPaymentMethodUpdate
    );
    let identity = PaymentAttemptIdentity::new(
        PaymentAttemptId::new(Uuid::from_u128(1)),
        BillingScopeId::new(Uuid::from_u128(2)),
        SubscriberId::new(Uuid::from_u128(3)),
        GatewayAccountId::new(Uuid::from_u128(4)),
        GatewayConfigurationId::new(Uuid::from_u128(5)),
        GatewayAccountMode::Live,
    );
    let plan_key = PlanKey::new("base_subscription").expect("test plan key");
    let gateway_order_id =
        GatewayOrderId::from_correlation("matching-order").expect("test gateway order");
    let original = initial_attempt_for_matching(
        identity,
        plan_key.clone(),
        gateway_order_id.clone(),
        "matching-key-one",
    );
    let changed_request = initial_attempt_for_matching(
        identity,
        plan_key.clone(),
        gateway_order_id.clone(),
        "matching-key-two",
    );
    let different_order = initial_attempt_for_matching(
        identity,
        plan_key.clone(),
        GatewayOrderId::from_correlation("other-order").expect("test gateway order"),
        "matching-key-three",
    );

    let initial = ReservationAttemptExpectation::Initial {
        identity,
        plan_key: &plan_key,
        gateway_order_id: &gateway_order_id,
    };
    assert!(initial.matches(&original));
    assert!(initial.matches(&changed_request));
    assert!(!initial.matches(&different_order));

    let exact = ReservationAttemptExpectation::Exact {
        identity,
        kind: PaymentAttemptKind::SubscriptionInitial,
        request: original.request(),
    };
    assert!(exact.matches(&original));
    assert!(!exact.matches(&changed_request));
}

#[test]
fn resolution_command_keeps_boundaries_and_replacement_review_typed() {
    let prepared = OutcomeResolutionCommand::non_approved(
        AttemptResolutionStatus::Failed,
        None,
        Some(RateLimitCooldown::Account),
        OutcomeResolutionBoundary::Prepared,
    );
    assert!(prepared.may_resolve(PaymentAttemptStatus::Pending, false));
    assert!(!prepared.may_resolve(PaymentAttemptStatus::Pending, true));
    assert!(!prepared.may_resolve(PaymentAttemptStatus::Declined, false));
    assert!(!prepared.clears_submitted_at());

    let admitted = OutcomeResolutionCommand::non_approved(
        AttemptResolutionStatus::Failed,
        None,
        None,
        OutcomeResolutionBoundary::AdmittedNotSubmitted,
    );
    assert!(!admitted.may_resolve(PaymentAttemptStatus::Pending, false));
    assert!(admitted.may_resolve(PaymentAttemptStatus::Pending, true));
    assert!(admitted.clears_submitted_at());

    let unknown = OutcomeResolutionCommand::unknown(Some(RateLimitCooldown::Provider));
    assert_eq!(
        unknown.resolved_status(
            ReservationOperation::PaymentMethodReplacement,
            PaymentAttemptStatus::ReviewRequired,
        ),
        AttemptResolutionStatus::ReviewRequired,
    );
    assert_eq!(
        unknown.resolved_status(
            ReservationOperation::Recovery,
            PaymentAttemptStatus::ReviewRequired,
        ),
        AttemptResolutionStatus::Unknown,
    );
    assert!(unknown.records_pending_evidence(AttemptResolutionStatus::Unknown));
    assert!(!unknown.records_pending_evidence(AttemptResolutionStatus::ReviewRequired));
}

#[test]
fn not_submitted_policy_classifies_every_durable_consequence_together() {
    for (error, resolution_code, cooldown, restores_prepared) in [
        (
            GatewayNotSubmittedError::RequestRejected(GatewayDiagnostic::new("request rejected")),
            PaymentResolutionCode::GatewayRequestRejectedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::Malformed(GatewayDiagnostic::new("malformed")),
            PaymentResolutionCode::GatewayMalformedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::Configuration(GatewayDiagnostic::new("configuration")),
            PaymentResolutionCode::GatewayConfigurationBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::NotTransmitted(GatewayDiagnostic::new("unavailable")),
            PaymentResolutionCode::GatewayUnavailableBeforeSubmission,
            None,
            true,
        ),
        (
            GatewayNotSubmittedError::RateLimited(GatewayDiagnostic::new("rate limited")),
            PaymentResolutionCode::GatewayAccountRateLimitedBeforeSubmission,
            Some(RateLimitCooldown::Account),
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeMismatch {
                required: GatewayAccountMode::Live,
                observed: GatewayAccountMode::Test,
                detail: GatewayDiagnostic::new("live mode mismatch"),
            },
            PaymentResolutionCode::GatewayLiveReadinessFailedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeMismatch {
                required: GatewayAccountMode::Test,
                observed: GatewayAccountMode::Live,
                detail: GatewayDiagnostic::new("test mode mismatch"),
            },
            PaymentResolutionCode::GatewayTestReadinessFailedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeVerification(GatewayError::RequestRejected(
                GatewayDiagnostic::new("wrapped request rejected"),
            )),
            PaymentResolutionCode::GatewayRequestRejectedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeVerification(GatewayError::Malformed(
                GatewayDiagnostic::new("wrapped malformed"),
            )),
            PaymentResolutionCode::GatewayMalformedBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeVerification(GatewayError::Configuration(
                GatewayDiagnostic::new("wrapped configuration"),
            )),
            PaymentResolutionCode::GatewayConfigurationBeforeSubmission,
            None,
            false,
        ),
        (
            GatewayNotSubmittedError::AccountModeVerification(GatewayError::Unavailable(
                GatewayDiagnostic::new("wrapped unavailable"),
            )),
            PaymentResolutionCode::GatewayUnavailableBeforeSubmission,
            None,
            true,
        ),
        (
            GatewayNotSubmittedError::AccountModeVerification(GatewayError::RateLimited(
                GatewayDiagnostic::new("wrapped rate limited"),
            )),
            PaymentResolutionCode::GatewayProviderRateLimitedBeforeSubmission,
            Some(RateLimitCooldown::Provider),
            false,
        ),
    ] {
        let policy = GatewayNotSubmittedPolicy::for_error(&error);
        assert_eq!(policy.resolution_code(), resolution_code);
        assert_eq!(policy.cooldown(), cooldown);
        assert_eq!(
            policy.restores_prepared_attempt_when_supported(),
            restores_prepared
        );
    }
}

#[test]
fn not_submitted_surface_requires_a_flow_with_prepared_replay() {
    let policy = GatewayNotSubmittedPolicy::for_error(&GatewayNotSubmittedError::NotTransmitted(
        GatewayDiagnostic::new("not transmitted"),
    ));
    let attempt = initial_attempt_for_matching(
        PaymentAttemptIdentity::new(
            PaymentAttemptId::new(Uuid::from_u128(11)),
            BillingScopeId::new(Uuid::from_u128(12)),
            SubscriberId::new(Uuid::from_u128(13)),
            GatewayAccountId::new(Uuid::from_u128(14)),
            GatewayConfigurationId::new(Uuid::from_u128(15)),
            GatewayAccountMode::Live,
        ),
        PlanKey::new("base_subscription").expect("test plan key"),
        GatewayOrderId::from_correlation("not-submitted-surface").expect("test gateway order"),
        "not-submitted-surface",
    );

    assert!(should_surface_not_submitted_application(
        false,
        &attempt,
        policy,
        PreparedAttemptReplay::Supported,
    ));
    assert!(!should_surface_not_submitted_application(
        false,
        &attempt,
        policy,
        PreparedAttemptReplay::Unsupported,
    ));
}