reevit 0.2.0

Official Rust SDK for the Reevit payments API
Documentation
use std::collections::HashMap;
use std::time::Duration;

use reevit::{
    Client, ConnectionAuditEntry, ConnectionRequest, Error, FraudPolicy, PaymentIntent,
    PaymentIntentRequest, PaymentLinkStats, PaymentStats, PaymentStatsBreakdown,
    PaymentStatsTotals, RequestOptions, RoutingHints, WebhookConfig,
};
use serde_json::json;
use wiremock::matchers::{body_json, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

#[tokio::test]
async fn create_payment_intent_sends_auth_and_idempotency_headers() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments/intents"))
        .and(header("x-reevit-key", "pfk_test_example.secret"))
        .and(header("x-org-id", "org_123"))
        .and(header("x-reevit-client", "@reevit/rust"))
        .and(header(
            "user-agent",
            format!("reevit-rust/{}", env!("CARGO_PKG_VERSION")),
        ))
        .and(header("idempotency-key", "order_123"))
        .and(body_json(json!({
            "amount": 5000,
            "currency": "GHS",
            "country": "GH",
            "reference": "order_123"
        })))
        .respond_with(ResponseTemplate::new(201).set_body_json(json!({
            "id": "pay_123",
            "connection_id": "conn_123",
            "provider": "paystack",
            "status": "pending",
            "amount": 5000,
            "currency": "GHS",
            "fee_amount": 100,
            "fee_currency": "GHS",
            "net_amount": 4900
        })))
        .mount(&server)
        .await;

    let client = Client::builder("pfk_test_example.secret", "org_123")
        .base_url(server.uri())
        .http_client(reqwest::Client::new())
        .build()
        .expect("client configuration should be valid");
    let request = PaymentIntentRequest {
        amount: 5000,
        currency: "GHS".into(),
        country: "GH".into(),
        reference: Some("order_123".into()),
        ..PaymentIntentRequest::default()
    };

    let payment = client
        .payments()
        .create_intent(
            &request,
            RequestOptions::default().idempotency_key("order_123"),
        )
        .await
        .expect("payment intent should be created");

    assert_eq!(payment.id, "pay_123");
    assert_eq!(payment.amount, 5000);
}

#[tokio::test]
async fn api_errors_preserve_status_code_details_and_request_id() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments/intents"))
        .respond_with(
            ResponseTemplate::new(422)
                .insert_header("x-request-id", "req_123")
                .set_body_json(json!({
                    "code": "invalid_currency",
                    "message": "currency is not supported",
                    "details": {"currency": "ZZZ"}
                })),
        )
        .mount(&server)
        .await;
    let client = Client::builder("pfk_test_example.secret", "org_123")
        .base_url(server.uri())
        .build()
        .unwrap();

    let error = client
        .payments()
        .create_intent(
            &PaymentIntentRequest {
                amount: 5000,
                currency: "ZZZ".into(),
                country: "GH".into(),
                ..PaymentIntentRequest::default()
            },
            RequestOptions::default(),
        )
        .await
        .unwrap_err();

    let Error::Api(api_error) = error else {
        panic!("expected a structured API error");
    };
    assert_eq!(api_error.status.as_u16(), 422);
    assert_eq!(api_error.code.as_deref(), Some("invalid_currency"));
    assert_eq!(api_error.request_id.as_deref(), Some("req_123"));
    assert_eq!(api_error.details, Some(json!({"currency": "ZZZ"})));
    assert!(!api_error.is_recoverable());
}

#[test]
fn debug_output_redacts_credentials_and_signing_secrets() {
    let client = Client::builder("pfk_test_super_secret", "org_123")
        .build()
        .unwrap();
    let connection = ConnectionRequest {
        provider: "paystack".into(),
        mode: "test".into(),
        credentials: HashMap::from([("secret_key".into(), json!("sk_super_secret"))]),
        ..ConnectionRequest::default()
    };
    let intent = PaymentIntent {
        client_secret: Some("client_super_secret".into()),
        psp_credentials: HashMap::from([("private".into(), json!("psp_super_secret"))]),
        ..PaymentIntent::default()
    };
    let webhook = WebhookConfig {
        signing_secret: Some("whsec_super_secret".into()),
        ..WebhookConfig::default()
    };

    let output = format!("{client:?} {connection:?} {intent:?} {webhook:?}");
    assert!(!output.contains("pfk_test_super_secret"));
    assert!(!output.contains("sk_super_secret"));
    assert!(!output.contains("client_super_secret"));
    assert!(!output.contains("psp_super_secret"));
    assert!(!output.contains("whsec_super_secret"));
    assert!(output.contains("[REDACTED]"));
}

#[test]
fn response_models_do_not_accept_empty_objects() {
    assert!(serde_json::from_str::<PaymentIntent>("{}").is_err());
    assert!(serde_json::from_str::<PaymentStats>("{}").is_err());
    assert!(serde_json::from_str::<PaymentStatsTotals>("{}").is_err());
    assert!(serde_json::from_str::<PaymentStatsBreakdown>("{}").is_err());
    assert!(serde_json::from_str::<ConnectionAuditEntry>("{}").is_err());
    assert!(serde_json::from_str::<RoutingHints>("{}").is_err());
    assert!(serde_json::from_str::<FraudPolicy>("{}").is_err());
    assert!(serde_json::from_str::<PaymentLinkStats>("{}").is_err());
}

#[tokio::test]
async fn successful_but_malformed_resource_responses_are_decode_errors() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments/intents"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
        .mount(&server)
        .await;
    let client = Client::builder("pfk_test_example.secret", "org_123")
        .base_url(server.uri())
        .build()
        .unwrap();

    let error = client
        .payments()
        .create_intent(
            &PaymentIntentRequest {
                amount: 5000,
                currency: "GHS".into(),
                country: "GH".into(),
                ..PaymentIntentRequest::default()
            },
            RequestOptions::default(),
        )
        .await
        .unwrap_err();

    assert!(matches!(error, Error::Decode { .. }));
}

#[tokio::test]
async fn injected_clients_honor_sdk_timeout_without_leaking_url_secrets() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payments/pay_123/confirm-intent"))
        .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(1)))
        .mount(&server)
        .await;
    let client = Client::builder("pfk_test_example.secret", "org_123")
        .base_url(server.uri())
        .timeout(Duration::from_millis(20))
        .http_client(reqwest::Client::new())
        .build()
        .unwrap();

    let error = client
        .payments()
        .confirm_intent("pay_123", "client_super_secret", RequestOptions::default())
        .await
        .unwrap_err();

    let Error::Transport(transport) = &error else {
        panic!("expected a transport error");
    };
    assert!(transport.is_timeout());
    let output = format!("{error:?} {error}");
    assert!(!output.contains("client_super_secret"));
    assert!(!output.contains("client_secret="));
}