snippe 0.1.0

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

use serde::{Deserialize, Serialize};

use super::common::{Currency, Metadata};

/// Status of a hosted-checkout session.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SessionStatus {
    /// Created, awaiting payment.
    Pending,
    /// Customer is currently paying.
    Active,
    /// Payment successful.
    Completed,
    /// Lifetime expired before payment.
    Expired,
    /// Cancelled via the cancel endpoint.
    Cancelled,
    /// A status this SDK version doesn't recognise.
    Other(String),
}

impl SessionStatus {
    /// Wire-form string.
    pub fn as_str(&self) -> &str {
        match self {
            Self::Pending => "pending",
            Self::Active => "active",
            Self::Completed => "completed",
            Self::Expired => "expired",
            Self::Cancelled => "cancelled",
            Self::Other(s) => s.as_str(),
        }
    }

    /// True for `completed`, `expired`, or `cancelled` (terminal states).
    pub fn is_terminal(&self) -> bool {
        matches!(self, Self::Completed | Self::Expired | Self::Cancelled)
    }
}

impl<'de> Deserialize<'de> for SessionStatus {
    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,
            "active" => Self::Active,
            "completed" => Self::Completed,
            "expired" => Self::Expired,
            "cancelled" => Self::Cancelled,
            _ => Self::Other(s),
        })
    }
}

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

/// Payment method that can appear on the hosted checkout page.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AllowedMethod {
    /// Mobile money — Airtel Money, M-Pesa, Mixx by Yas, Halotel.
    MobileMoney,
    /// Dynamic QR for scan-to-pay.
    Qr,
    /// Visa, Mastercard, and local debit.
    Card,
}

/// Customer block for session creation. Note this differs from the payments
/// API: sessions use a single `name` field rather than firstname / lastname.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SessionCustomer {
    /// Display name shown on the checkout page.
    pub name: String,
    /// Customer phone number for prefilling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub phone: Option<String>,
    /// Customer email for prefilling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub email: Option<String>,
}

impl SessionCustomer {
    /// Construct a session customer with just a name.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            ..Default::default()
        }
    }
    /// Set the phone number.
    pub fn with_phone(mut self, phone: impl Into<String>) -> Self {
        self.phone = Some(phone.into());
        self
    }
    /// Set the email.
    pub fn with_email(mut self, email: impl Into<String>) -> Self {
        self.email = Some(email.into());
        self
    }
}

/// Request body for `POST /api/v1/sessions`.
///
/// Use [`Self::fixed_amount`] for a known total or [`Self::custom_amount`]
/// for donation / tip-style flows where the customer picks the amount.
#[derive(Debug, Clone, Default, Serialize)]
pub struct CreateSessionRequest {
    /// Fixed amount in TZS. Mutually exclusive with `allow_custom_amount`.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub amount: Option<u64>,
    /// Currency. The API defaults to TZS when omitted.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub currency: Option<Currency>,
    /// Let the customer choose the amount on the checkout page.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub allow_custom_amount: Option<bool>,
    /// Lower bound when `allow_custom_amount` is true.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub min_amount: Option<u64>,
    /// Upper bound when `allow_custom_amount` is true.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub max_amount: Option<u64>,
    /// Restrict which methods appear on the checkout page.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub allowed_methods: Option<Vec<AllowedMethod>>,
    /// Optional customer block to prefill the form.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub customer: Option<SessionCustomer>,
    /// HTTPS URL the customer is redirected to after success.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub redirect_url: Option<String>,
    /// HTTPS URL Snippe POSTs status updates to.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub webhook_url: Option<String>,
    /// Free-text description shown to the customer.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Echoed-back metadata.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub metadata: Option<Metadata>,
    /// Lifetime in seconds. Defaults to 3600 (1 hour) on the server.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expires_in: Option<u64>,
    /// Reference an existing payment profile for branding and defaults.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub profile_id: Option<String>,
}

impl CreateSessionRequest {
    /// Build a session with a fixed amount.
    pub fn fixed_amount(amount: u64) -> Self {
        Self {
            amount: Some(amount),
            currency: Some(Currency::Tzs),
            ..Default::default()
        }
    }

    /// Build a custom-amount session (donation / tip jar).
    pub fn custom_amount(min_amount: u64, max_amount: u64) -> Self {
        Self {
            allow_custom_amount: Some(true),
            min_amount: Some(min_amount),
            max_amount: Some(max_amount),
            currency: Some(Currency::Tzs),
            ..Default::default()
        }
    }

    /// Restrict which methods appear in checkout.
    pub fn with_allowed_methods(mut self, methods: impl IntoIterator<Item = AllowedMethod>) -> Self {
        self.allowed_methods = Some(methods.into_iter().collect());
        self
    }
    /// Attach a customer block.
    pub fn with_customer(mut self, customer: SessionCustomer) -> Self {
        self.customer = Some(customer);
        self
    }
    /// Set the success redirect URL.
    pub fn with_redirect_url(mut self, url: impl Into<String>) -> Self {
        self.redirect_url = Some(url.into());
        self
    }
    /// Set the webhook URL.
    pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
        self.webhook_url = Some(url.into());
        self
    }
    /// Set the description shown to the customer.
    pub fn with_description(mut self, desc: impl Into<String>) -> Self {
        self.description = Some(desc.into());
        self
    }
    /// Attach echoed-back metadata.
    pub fn with_metadata(mut self, metadata: Metadata) -> Self {
        self.metadata = Some(metadata);
        self
    }
    /// Override the session lifetime in seconds.
    pub fn with_expires_in(mut self, seconds: u64) -> Self {
        self.expires_in = Some(seconds);
        self
    }
    /// Reference an existing branding profile.
    pub fn with_profile_id(mut self, profile_id: impl Into<String>) -> Self {
        self.profile_id = Some(profile_id.into());
        self
    }
}

/// Session record returned by the sessions endpoints.
///
/// Note the shape difference vs the Payments API: session responses have
/// `amount` as a top-level scalar, not a nested `{value, currency}` object.
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Session {
    /// Snippe-side reference.
    pub reference: String,
    /// Session status.
    pub status: SessionStatus,
    /// Amount in TZS (smallest unit). Absent when `allow_custom_amount` is set.
    #[serde(default)]
    pub amount: Option<u64>,
    /// Currency.
    #[serde(default)]
    pub currency: Option<Currency>,
    /// Full hosted checkout URL — embed or open in a WebView.
    pub checkout_url: String,
    /// Short vanity URL — preferred for SMS / WhatsApp distribution.
    #[serde(default)]
    pub payment_link_url: Option<String>,
    /// Short alphanumeric handle that builds [`Self::payment_link_url`].
    #[serde(default)]
    pub short_code: Option<String>,
    /// ISO-8601 expiration timestamp.
    pub expires_at: String,
    /// Customer-facing description.
    #[serde(default)]
    pub description: Option<String>,
    /// Echoed-back metadata.
    #[serde(default)]
    pub metadata: Option<Metadata>,
}

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

    #[test]
    fn fixed_amount_session_serialises() {
        let req = CreateSessionRequest::fixed_amount(50_000)
            .with_allowed_methods([AllowedMethod::MobileMoney, AllowedMethod::Qr])
            .with_redirect_url("https://x.com/ok")
            .with_description("Order #1");
        let json = serde_json::to_value(&req).unwrap();
        assert_eq!(json["amount"], 50_000);
        assert_eq!(json["currency"], "TZS");
        assert_eq!(json["allowed_methods"][0], "mobile_money");
        assert_eq!(json["allowed_methods"][1], "qr");
        assert!(json.get("allow_custom_amount").is_none());
    }

    #[test]
    fn custom_amount_session_omits_amount() {
        let req = CreateSessionRequest::custom_amount(1000, 500_000);
        let json = serde_json::to_value(&req).unwrap();
        assert!(json.get("amount").is_none());
        assert_eq!(json["allow_custom_amount"], true);
        assert_eq!(json["min_amount"], 1000);
        assert_eq!(json["max_amount"], 500_000);
    }

    #[test]
    fn session_response_deserialises_with_scalar_amount() {
        let body = serde_json::json!({
            "reference": "sess_abc",
            "status": "pending",
            "amount": 50_000,
            "currency": "TZS",
            "checkout_url": "https://snippe.me/checkout/X",
            "short_code": "Ax7kM2",
            "payment_link_url": "https://snippe.me/p/Ax7kM2",
            "expires_at": "2026-02-26T11:00:00Z"
        });
        let s: Session = serde_json::from_value(body).unwrap();
        assert_eq!(s.reference, "sess_abc");
        assert_eq!(s.amount, Some(50_000));
        assert_eq!(s.short_code.as_deref(), Some("Ax7kM2"));
    }
}