snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! Disbursement (payout) request and response types.

use serde::{Deserialize, Serialize};

use super::bank::BankCode;
use super::common::{Channel, Currency, Metadata, Money};

/// Payout lifecycle status.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PayoutStatus {
    /// Created, awaiting processing.
    Pending,
    /// Recipient received funds.
    Completed,
    /// Failed — see `failure_reason` on the payout.
    Failed,
    /// Reversed after completion.
    Reversed,
    /// A status this SDK version doesn't recognise.
    Other(String),
}

impl PayoutStatus {
    /// Wire-form string.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Pending => "pending",
            Self::Completed => "completed",
            Self::Failed => "failed",
            Self::Reversed => "reversed",
            Self::Other(s) => s.as_str(),
        }
    }

    /// True for `completed`, `failed`, or `reversed`.
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Completed | Self::Failed | Self::Reversed)
    }
}

impl<'de> Deserialize<'de> for PayoutStatus {
    fn deserialize<D>(d: D) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        let s = String::deserialize(d)?;
        Ok(match s.as_str() {
            "pending" => Self::Pending,
            "completed" => Self::Completed,
            "failed" => Self::Failed,
            "reversed" => Self::Reversed,
            _ => Self::Other(s),
        })
    }
}

impl Serialize for PayoutStatus {
    fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
    where
        S: serde::Serializer,
    {
        ser.serialize_str(self.as_str())
    }
}

/// Request body for `POST /v1/payouts/send`. Tagged on `channel`.
///
/// Pick the variant matching the destination — Snippe auto-detects the mobile
/// money provider (M-Pesa, Airtel, etc.) from the phone number, so you don't
/// need to specify it for [`SendPayoutRequest::Mobile`].
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "channel", rename_all = "snake_case")]
pub enum SendPayoutRequest {
    /// Mobile money payout — Airtel, M-Pesa, Mixx, HaloPesa (auto-detected).
    Mobile(MobilePayout),
    /// Bank transfer — see [`BankCode`] for accepted codes.
    Bank(BankPayout),
}

/// Mobile-money payout body.
///
/// Minimum amount is **5,000 TZS**.
#[derive(Debug, Clone, Serialize)]
pub struct MobilePayout {
    /// Amount in TZS (smallest unit). Minimum 5,000.
    pub amount: u64,
    /// Recipient phone number, e.g. `"255781000000"` or `"+255781000000"`.
    pub recipient_phone: String,
    /// Recipient name. Appears in Snippe's records and sometimes on the
    /// recipient's mobile money SMS.
    pub recipient_name: String,
    /// Free-text note that may appear on the recipient's statement.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub narration: Option<String>,
    /// Per-payout HTTPS webhook URL override.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub webhook_url: Option<String>,
    /// Echoed-back metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<Metadata>,
}

impl MobilePayout {
    /// Construct a mobile-money payout with the required fields.
    pub fn new(
        amount: u64,
        recipient_phone: impl Into<String>,
        recipient_name: impl Into<String>,
    ) -> Self {
        Self {
            amount,
            recipient_phone: recipient_phone.into(),
            recipient_name: recipient_name.into(),
            narration: None,
            webhook_url: None,
            metadata: None,
        }
    }

    /// Add a narration / statement note.
    pub fn with_narration(mut self, narration: impl Into<String>) -> Self {
        self.narration = Some(narration.into());
        self
    }
    /// Set the per-payout webhook URL.
    pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
        self.webhook_url = Some(url.into());
        self
    }
    /// Attach echoed-back metadata.
    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
        self.metadata = Some(metadata);
        self
    }
}

/// Bank transfer payout body.
///
/// Minimum amount is **5,000 TZS**. The `recipient_name` should match the
/// account holder on file at the bank — mismatches may cause the transfer to
/// be rejected.
#[derive(Debug, Clone, Serialize)]
pub struct BankPayout {
    /// Amount in TZS (smallest unit). Minimum 5,000.
    pub amount: u64,
    /// Bank code — see [`BankCode`].
    pub recipient_bank: BankCode,
    /// Destination account number.
    pub recipient_account: String,
    /// Account holder's name (must match what the bank has on file).
    pub recipient_name: String,
    /// Free-text statement note.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub narration: Option<String>,
    /// Per-payout HTTPS webhook URL override.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub webhook_url: Option<String>,
    /// Echoed-back metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<Metadata>,
}

impl BankPayout {
    /// Construct a bank payout with the required fields.
    pub fn new(
        amount: u64,
        recipient_bank: BankCode,
        recipient_account: impl Into<String>,
        recipient_name: impl Into<String>,
    ) -> Self {
        Self {
            amount,
            recipient_bank,
            recipient_account: recipient_account.into(),
            recipient_name: recipient_name.into(),
            narration: None,
            webhook_url: None,
            metadata: None,
        }
    }

    /// Add a narration / statement note.
    pub fn with_narration(mut self, narration: impl Into<String>) -> Self {
        self.narration = Some(narration.into());
        self
    }
    /// Set the per-payout webhook URL.
    pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
        self.webhook_url = Some(url.into());
        self
    }
    /// Attach echoed-back metadata.
    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
        self.metadata = Some(metadata);
        self
    }
}

/// Payout record returned by `POST /v1/payouts/send` and `GET /v1/payouts/{ref}`.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Payout {
    /// Snippe-side reference.
    pub reference: String,
    /// Current status.
    pub status: PayoutStatus,
    /// Net amount sent to the recipient.
    pub amount: Money,
    /// Fee charged by Snippe.
    pub fees: Money,
    /// Total deducted from your balance (`amount + fees`).
    pub total: Money,
    /// Channel descriptor — `{type, provider}`.
    pub channel: Channel,
    /// Recipient block.
    pub recipient: Recipient,
    /// Upstream processor reference (useful for support / reconciliation).
    #[serde(default)]
    pub external_reference: Option<String>,
    /// Statement narration sent on the transfer.
    #[serde(default)]
    pub narration: Option<String>,
    /// Failure reason — populated when `status` is `failed`.
    #[serde(default)]
    pub failure_reason: Option<String>,
    /// Echoed-back metadata.
    #[serde(default)]
    pub metadata: Option<Metadata>,
}

/// Recipient details on a payout record.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Recipient {
    /// Recipient name.
    pub name: String,
    /// Phone number (mobile money).
    #[serde(default)]
    pub phone: Option<String>,
    /// Bank account number (bank payouts).
    #[serde(default)]
    pub account: Option<String>,
    /// Bank code (bank payouts).
    #[serde(default)]
    pub bank: Option<String>,
}

/// Result of `GET /v1/payouts/fee?amount=X`.
///
/// Always preflight a payout: `fee()` then check
/// [`crate::api::Payments::balance`] to ensure `available.value ≥ total_amount`
/// before calling `send()`.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct PayoutFee {
    /// The amount you'd send.
    pub amount: u64,
    /// Fee charged by Snippe.
    pub fee_amount: u64,
    /// Total deducted from your balance.
    pub total_amount: u64,
    /// Currency.
    pub currency: Currency,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn mobile_payout_serialises_with_channel_tag() {
        let req = SendPayoutRequest::Mobile(
            MobilePayout::new(5000, "255781000000", "Recipient")
                .with_narration("Salary"),
        );
        let json = serde_json::to_value(&req).unwrap();
        assert_eq!(json["channel"], "mobile");
        assert_eq!(json["amount"], 5000);
        assert_eq!(json["recipient_phone"], "255781000000");
        assert_eq!(json["narration"], "Salary");
    }

    #[test]
    fn bank_payout_serialises_with_bank_code() {
        let req = SendPayoutRequest::Bank(BankPayout::new(
            5000,
            BankCode::Crdb,
            "0150000000",
            "Recipient",
        ));
        let json = serde_json::to_value(&req).unwrap();
        assert_eq!(json["channel"], "bank");
        assert_eq!(json["recipient_bank"], "CRDB");
    }

    #[test]
    fn payout_response_deserialises() {
        let body = serde_json::json!({
            "reference": "667c9279-846f-4001-b046-fdecab204f4f",
            "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", "phone": "255781000000"},
            "external_reference": "fVJQRPGYbtN3"
        });
        let payout: Payout = serde_json::from_value(body).unwrap();
        assert_eq!(payout.status, PayoutStatus::Pending);
        assert_eq!(payout.total.value, 6500);
        assert_eq!(payout.channel.provider.as_deref(), Some("airtel"));
    }
}