snippe 0.1.0

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

use std::collections::HashMap;

use serde::{Deserialize, Serialize};

/// Currency accepted by the Snippe API.
///
/// At present only `TZS` is supported — other values trigger
/// `400 validation_error`. Modelled as a single-variant enum so future
/// additions stay backwards-compatible.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Currency {
    /// Tanzanian Shilling.
    #[serde(rename = "TZS")]
    #[default]
    Tzs,
}

impl std::fmt::Display for Currency {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("TZS")
    }
}

/// A monetary amount as it appears in **response** bodies and webhook payloads.
///
/// Note: request bodies use a flat `amount: u64` plus a sibling `currency`
/// field — see [`PaymentAmount`] for that shape. This split is intentional
/// and matches the API; do not collapse it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money {
    /// Amount in the smallest currency unit. `500` = 500 TZS, not 5.00.
    pub value: u64,
    /// Currency. Always `TZS` today.
    pub currency: Currency,
}

impl Money {
    /// Construct a TZS [`Money`] for the given amount.
    pub const fn tzs(value: u64) -> Self {
        Self { value, currency: Currency::Tzs }
    }
}

/// Amount block used in **request** bodies for `/v1/payments` (mobile money).
///
/// Wire shape: `{ "amount": 500, "currency": "TZS" }`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PaymentAmount {
    /// Amount in the smallest currency unit. Minimum 500 TZS for payments.
    pub amount: u64,
    /// Currency. Must be `TZS`.
    pub currency: Currency,
}

impl PaymentAmount {
    /// Construct a TZS payment amount.
    pub const fn tzs(amount: u64) -> Self {
        Self { amount, currency: Currency::Tzs }
    }
}

/// Customer block accepted by payment requests.
///
/// Mobile-money and dynamic-QR payments only need `firstname`, `lastname`,
/// and `email`. Card payments additionally require the full address block —
/// `address`, `city`, `state`, `postcode`, `country`. Use the `with_*` builders
/// or set the fields directly.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Customer {
    /// Given name.
    pub firstname: String,
    /// Family name.
    pub lastname: String,
    /// Email address.
    pub email: String,
    /// Street address. Required for card payments.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub address: Option<String>,
    /// City. Required for card payments.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub city: Option<String>,
    /// State / region. Required for card payments.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub state: Option<String>,
    /// Postal code. Required for card payments.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub postcode: Option<String>,
    /// ISO 3166-1 alpha-2 country code, e.g. `"TZ"`. Required for card payments.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub country: Option<String>,
}

impl Customer {
    /// Construct the minimal customer block (suitable for mobile and QR payments).
    pub fn new(
        firstname: impl Into<String>,
        lastname: impl Into<String>,
        email: impl Into<String>,
    ) -> Self {
        Self {
            firstname: firstname.into(),
            lastname: lastname.into(),
            email: email.into(),
            ..Default::default()
        }
    }

    /// Set the street address (required for card payments).
    pub fn with_address(mut self, address: impl Into<String>) -> Self {
        self.address = Some(address.into());
        self
    }
    /// Set the city (required for card payments).
    pub fn with_city(mut self, city: impl Into<String>) -> Self {
        self.city = Some(city.into());
        self
    }
    /// Set the state / region (required for card payments).
    pub fn with_state(mut self, state: impl Into<String>) -> Self {
        self.state = Some(state.into());
        self
    }
    /// Set the postcode (required for card payments).
    pub fn with_postcode(mut self, postcode: impl Into<String>) -> Self {
        self.postcode = Some(postcode.into());
        self
    }
    /// Set the ISO country code (required for card payments).
    pub fn with_country(mut self, country: impl Into<String>) -> Self {
        self.country = Some(country.into());
        self
    }
}

/// Free-form key-value bag echoed back in subsequent reads and webhooks.
///
/// Use it to correlate Snippe records with your internal IDs (order numbers,
/// invoice IDs, employee IDs, etc.) without the user-facing fields growing
/// noise.
pub type Metadata = HashMap<String, serde_json::Value>;

/// Channel descriptor returned in payout records and webhook payloads.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Channel {
    /// Channel type: `"mobile_money"`, `"bank"`, `"card"`, etc.
    #[serde(rename = "type")]
    pub r#type: String,
    /// Provider auto-detected by Snippe (e.g. `"mpesa"`, `"airtel"`,
    /// `"mixx"`, `"halotel"`). Mobile money payouts only.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
}

/// Generic list-endpoint query parameters (page, limit, cursor).
///
/// Field semantics depend on the endpoint — Snippe paginates by page on most
/// list endpoints; passing `cursor` is reserved for endpoints that explicitly
/// document cursor pagination.
#[derive(Debug, Clone, Default, Serialize)]
pub struct ListParams {
    /// Items per page.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub limit: Option<u32>,
    /// Page number (1-based).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub page: Option<u32>,
    /// Cursor returned by a previous response, when the endpoint supports it.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<String>,
}

impl ListParams {
    /// Construct empty params.
    pub fn new() -> Self {
        Self::default()
    }
    /// Set the page size limit.
    pub fn limit(mut self, limit: u32) -> Self {
        self.limit = Some(limit);
        self
    }
    /// Set the 1-based page number.
    pub fn page(mut self, page: u32) -> Self {
        self.page = Some(page);
        self
    }
    /// Set the pagination cursor.
    pub fn cursor(mut self, cursor: impl Into<String>) -> Self {
        self.cursor = Some(cursor.into());
        self
    }
}