use serde::{Deserialize, Serialize};
use super::common::{Currency, Customer, Metadata, Money, PaymentAmount};
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PaymentStatus {
Pending,
Completed,
Failed,
Voided,
Expired,
Other(String),
}
impl PaymentStatus {
pub fn as_str(&self) -> &str {
match self {
Self::Pending => "pending",
Self::Completed => "completed",
Self::Failed => "failed",
Self::Voided => "voided",
Self::Expired => "expired",
Self::Other(s) => s.as_str(),
}
}
pub fn is_terminal(&self) -> bool {
matches!(
self,
Self::Completed | Self::Failed | Self::Voided | Self::Expired
)
}
}
impl<'de> Deserialize<'de> for PaymentStatus {
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,
"voided" => Self::Voided,
"expired" => Self::Expired,
_ => Self::Other(s),
})
}
}
impl Serialize for PaymentStatus {
fn serialize<S>(&self, s: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
s.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum PaymentType {
Mobile,
Card,
DynamicQr,
Other(String),
}
impl PaymentType {
pub fn as_str(&self) -> &str {
match self {
Self::Mobile => "mobile",
Self::Card => "card",
Self::DynamicQr => "dynamic-qr",
Self::Other(s) => s.as_str(),
}
}
}
impl<'de> Deserialize<'de> for PaymentType {
fn deserialize<D>(d: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(d)?;
Ok(match s.as_str() {
"mobile" => Self::Mobile,
"card" => Self::Card,
"dynamic-qr" => Self::DynamicQr,
_ => Self::Other(s),
})
}
}
impl Serialize for PaymentType {
fn serialize<S>(&self, ser: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
ser.serialize_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "payment_type", rename_all = "kebab-case")]
pub enum CreatePaymentRequest {
Mobile(MobilePayment),
Card(CardPayment),
DynamicQr(QrPayment),
}
#[derive(Debug, Clone, Serialize)]
pub struct HostedPaymentDetails {
pub amount: u64,
pub currency: Currency,
pub redirect_url: String,
pub cancel_url: String,
}
impl HostedPaymentDetails {
pub fn tzs(
amount: u64,
redirect_url: impl Into<String>,
cancel_url: impl Into<String>,
) -> Self {
Self {
amount,
currency: Currency::Tzs,
redirect_url: redirect_url.into(),
cancel_url: cancel_url.into(),
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct MobilePayment {
pub details: PaymentAmount,
pub phone_number: String,
pub customer: Customer,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
}
impl MobilePayment {
pub fn new(amount: u64, phone_number: impl Into<String>, customer: Customer) -> Self {
Self {
details: PaymentAmount::tzs(amount),
phone_number: phone_number.into(),
customer,
webhook_url: None,
metadata: None,
}
}
pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
self.webhook_url = Some(url.into());
self
}
pub fn with_metadata(mut self, metadata: Metadata) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct CardPayment {
pub details: HostedPaymentDetails,
pub phone_number: String,
pub customer: Customer,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
}
impl CardPayment {
pub fn new(
details: HostedPaymentDetails,
phone_number: impl Into<String>,
customer: Customer,
) -> Self {
Self {
details,
phone_number: phone_number.into(),
customer,
webhook_url: None,
metadata: None,
}
}
pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
self.webhook_url = Some(url.into());
self
}
pub fn with_metadata(mut self, metadata: Metadata) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Serialize)]
pub struct QrPayment {
pub details: HostedPaymentDetails,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub customer: Option<Customer>,
#[serde(skip_serializing_if = "Option::is_none")]
pub webhook_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Metadata>,
}
impl QrPayment {
pub fn new(details: HostedPaymentDetails) -> Self {
Self {
details,
phone_number: None,
customer: None,
webhook_url: None,
metadata: None,
}
}
pub fn with_customer(mut self, customer: Customer) -> Self {
self.customer = Some(customer);
self
}
pub fn with_phone_number(mut self, phone: impl Into<String>) -> Self {
self.phone_number = Some(phone.into());
self
}
pub fn with_webhook_url(mut self, url: impl Into<String>) -> Self {
self.webhook_url = Some(url.into());
self
}
pub fn with_metadata(mut self, metadata: Metadata) -> Self {
self.metadata = Some(metadata);
self
}
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Payment {
pub reference: String,
pub status: PaymentStatus,
pub payment_type: PaymentType,
pub amount: Money,
pub expires_at: String,
#[serde(default)]
pub payment_url: Option<String>,
#[serde(default)]
pub payment_token: Option<String>,
#[serde(default)]
pub payment_qr_code: Option<String>,
#[serde(default)]
pub api_version: Option<String>,
#[serde(default)]
pub metadata: Option<Metadata>,
}
#[derive(Debug, Clone, Deserialize)]
#[non_exhaustive]
pub struct Balance {
pub available: Money,
pub balance: Money,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct ListPaymentsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<PaymentStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub payment_type: Option<PaymentType>,
}
impl ListPaymentsParams {
pub fn new() -> Self {
Self::default()
}
pub fn limit(mut self, limit: u32) -> Self {
self.limit = Some(limit);
self
}
pub fn page(mut self, page: u32) -> Self {
self.page = Some(page);
self
}
pub fn status(mut self, status: PaymentStatus) -> Self {
self.status = Some(status);
self
}
pub fn payment_type(mut self, t: PaymentType) -> Self {
self.payment_type = Some(t);
self
}
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct SearchPaymentsParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_reference: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub phone_number: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub limit: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub page: Option<u32>,
}
impl SearchPaymentsParams {
pub fn new() -> Self {
Self::default()
}
pub fn reference(mut self, r: impl Into<String>) -> Self {
self.reference = Some(r.into());
self
}
pub fn external_reference(mut self, r: impl Into<String>) -> Self {
self.external_reference = Some(r.into());
self
}
pub fn phone_number(mut self, p: impl Into<String>) -> Self {
self.phone_number = Some(p.into());
self
}
pub fn email(mut self, e: impl Into<String>) -> Self {
self.email = Some(e.into());
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mobile_payment_serialises_with_payment_type_tag() {
let req = CreatePaymentRequest::Mobile(MobilePayment::new(
500,
"255781000000",
Customer::new("Jane", "Doe", "jane@example.com"),
));
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["payment_type"], "mobile");
assert_eq!(json["details"]["amount"], 500);
assert_eq!(json["details"]["currency"], "TZS");
assert_eq!(json["phone_number"], "255781000000");
assert!(json.get("webhook_url").is_none());
}
#[test]
fn card_payment_serialises_with_redirect_urls() {
let details = HostedPaymentDetails::tzs(
1000,
"https://example.com/done",
"https://example.com/cancel",
);
let customer = Customer::new("Jane", "Doe", "j@d.com")
.with_address("addr")
.with_city("DSM")
.with_state("DSM")
.with_postcode("14101")
.with_country("TZ");
let req =
CreatePaymentRequest::Card(CardPayment::new(details, "255781000000", customer));
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["payment_type"], "card");
assert_eq!(json["details"]["redirect_url"], "https://example.com/done");
assert_eq!(json["customer"]["country"], "TZ");
}
#[test]
fn qr_payment_uses_dynamic_qr_tag() {
let details = HostedPaymentDetails::tzs(500, "https://x/d", "https://x/c");
let req = CreatePaymentRequest::DynamicQr(QrPayment::new(details));
let json = serde_json::to_value(&req).unwrap();
assert_eq!(json["payment_type"], "dynamic-qr");
}
#[test]
fn payment_response_deserialises() {
let body = serde_json::json!({
"reference": "9015c155",
"status": "pending",
"payment_type": "mobile",
"amount": {"value": 500, "currency": "TZS"},
"expires_at": "2026-01-25T05:04:54Z",
"api_version": "2026-01-25"
});
let payment: Payment = serde_json::from_value(body).unwrap();
assert_eq!(payment.reference, "9015c155");
assert_eq!(payment.status, PaymentStatus::Pending);
assert_eq!(payment.amount.value, 500);
assert!(!payment.status.is_terminal());
}
#[test]
fn unknown_status_falls_back_to_other() {
let body = serde_json::json!({
"reference": "x",
"status": "future-status",
"payment_type": "mobile",
"amount": {"value": 500, "currency": "TZS"},
"expires_at": "2026-01-25T05:04:54Z"
});
let p: Payment = serde_json::from_value(body).unwrap();
match p.status {
PaymentStatus::Other(s) => assert_eq!(s, "future-status"),
other => panic!("expected Other, got {:?}", other),
}
}
}