snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! Integration tests for the Disbursements API.

use serde_json::json;
use snippe::models::bank::BankCode;
use snippe::models::payout::{BankPayout, MobilePayout, PayoutStatus, SendPayoutRequest};
use snippe::{Client, IdempotencyKey};
use wiremock::matchers::{body_json, header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};

fn client_for(server: &MockServer) -> Client {
    Client::builder()
        .api_key("snp_test_key")
        .base_url(server.uri())
        .build()
        .unwrap()
}

#[tokio::test]
async fn fee_endpoint() {
    let server = MockServer::start().await;
    Mock::given(method("GET"))
        .and(path("/v1/payouts/fee"))
        .and(query_param("amount", "50000"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({
            "data": {
                "amount": 50000,
                "fee_amount": 1000,
                "total_amount": 51000,
                "currency": "TZS"
            }
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let fee = client.payouts().fee(50_000).await.unwrap();
    assert_eq!(fee.fee_amount, 1000);
    assert_eq!(fee.total_amount, 51_000);
}

#[tokio::test]
async fn send_mobile_payout() {
    let server = MockServer::start().await;

    let expected = json!({
        "channel": "mobile",
        "amount": 5000,
        "recipient_phone": "255781000000",
        "recipient_name": "Recipient Name",
        "narration": "Salary January 2026"
    });

    let response = json!({
        "data": {
            "reference": "667c9279",
            "status": "pending",
            "amount":   {"value": 5000, "currency": "TZS"},
            "fees":     {"value": 1500, "currency": "TZS"},
            "total":    {"value": 6500, "currency": "TZS"},
            "channel":  {"type": "mobile_money", "provider": "airtel"},
            "recipient": {"name": "Recipient Name", "phone": "255781000000"},
            "external_reference": "fVJQRPGYbtN3"
        }
    });

    Mock::given(method("POST"))
        .and(path("/v1/payouts/send"))
        .and(header("Idempotency-Key", "po-jan26-1"))
        .and(body_json(expected))
        .respond_with(ResponseTemplate::new(201).set_body_json(response))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = SendPayoutRequest::Mobile(
        MobilePayout::new(5000, "255781000000", "Recipient Name")
            .with_narration("Salary January 2026"),
    );
    let key = IdempotencyKey::new("po-jan26-1").unwrap();
    let payout = client.payouts().send(&req, Some(&key)).await.unwrap();

    assert_eq!(payout.reference, "667c9279");
    assert_eq!(payout.status, PayoutStatus::Pending);
    assert_eq!(payout.total.value, 6500);
    assert_eq!(payout.channel.r#type, "mobile_money");
    assert_eq!(payout.channel.provider.as_deref(), Some("airtel"));
}

#[tokio::test]
async fn send_bank_payout_uses_bank_code() {
    let server = MockServer::start().await;

    let expected = json!({
        "channel": "bank",
        "amount": 10000,
        "recipient_bank": "CRDB",
        "recipient_account": "0150000000",
        "recipient_name": "Vendor LLC"
    });

    let response = json!({
        "data": {
            "reference": "bo_1",
            "status": "pending",
            "amount":  {"value": 10000, "currency": "TZS"},
            "fees":    {"value": 2000, "currency": "TZS"},
            "total":   {"value": 12000, "currency": "TZS"},
            "channel": {"type": "bank"},
            "recipient": {"name": "Vendor LLC", "account": "0150000000", "bank": "CRDB"}
        }
    });

    Mock::given(method("POST"))
        .and(path("/v1/payouts/send"))
        .and(body_json(expected))
        .respond_with(ResponseTemplate::new(201).set_body_json(response))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = SendPayoutRequest::Bank(BankPayout::new(
        10_000,
        BankCode::Crdb,
        "0150000000",
        "Vendor LLC",
    ));
    let payout = client.payouts().send(&req, None).await.unwrap();
    assert_eq!(payout.recipient.bank.as_deref(), Some("CRDB"));
}

#[tokio::test]
async fn insufficient_balance_error() {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .and(path("/v1/payouts/send"))
        .respond_with(ResponseTemplate::new(500).set_body_json(json!({
            "status": "error",
            "code": 500,
            "error_code": "payment_failed",
            "message": "insufficient balance: available 5000, required 6500"
        })))
        .mount(&server)
        .await;

    let client = client_for(&server);
    let req = SendPayoutRequest::Mobile(MobilePayout::new(5000, "255781000000", "X"));
    let err = client.payouts().send(&req, None).await.unwrap_err();

    if let snippe::Error::Api(e) = err {
        assert_eq!(e.error_code, snippe::ErrorCode::PaymentFailed);
        assert_eq!(e.status, 500);
    } else {
        panic!("expected Api error");
    }
}