flowglad 0.1.1

(Unofficial) Rust SDK for FlowGlad - Open source billing infrastructure
Documentation
//! Common types used across the API
//!
//! This module contains shared types like Money, Currency, Timestamps, etc.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// Represents a monetary amount with a currency
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Money {
    /// Amount in the smallest currency unit (e.g., cents for USD)
    pub amount: i64,
    /// Currency code
    pub currency: Currency,
}

impl Money {
    /// Create a new Money instance
    pub fn new(amount: i64, currency: Currency) -> Self {
        Self { amount, currency }
    }

    /// Create a Money instance in USD
    pub fn usd(cents: i64) -> Self {
        Self::new(cents, Currency::Usd)
    }

    /// Convert to a float representing the major currency unit (e.g., dollars)
    pub fn to_major_unit(&self) -> f64 {
        self.amount as f64 / 100.0
    }

    /// Create from a major unit amount (e.g., dollars)
    pub fn from_major_unit(amount: f64, currency: Currency) -> Self {
        Self::new((amount * 100.0).round() as i64, currency)
    }
}

/// Currency codes
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Currency {
    /// United States Dollar
    #[serde(rename = "usd")]
    Usd,
    /// Euro
    #[serde(rename = "eur")]
    Eur,
    /// British Pound Sterling
    #[serde(rename = "gbp")]
    Gbp,
    /// Canadian Dollar
    #[serde(rename = "cad")]
    Cad,
    /// Australian Dollar
    #[serde(rename = "aud")]
    Aud,
    /// Japanese Yen
    #[serde(rename = "jpy")]
    Jpy,
}

impl Currency {
    /// Get the number of decimal places for this currency
    pub fn decimal_places(&self) -> u8 {
        match self {
            Currency::Jpy => 0, // Yen has no decimal places
            _ => 2,
        }
    }

    /// Get the currency symbol
    pub fn symbol(&self) -> &'static str {
        match self {
            Currency::Usd => "$",
            Currency::Eur => "",
            Currency::Gbp => "£",
            Currency::Cad => "CA$",
            Currency::Aud => "A$",
            Currency::Jpy => "¥",
        }
    }
}

/// A timestamp represented as seconds since Unix epoch
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Timestamp(#[serde(with = "chrono::serde::ts_seconds")] pub DateTime<Utc>);

impl Timestamp {
    /// Create a new timestamp for the current time
    pub fn now() -> Self {
        Self(Utc::now())
    }

    /// Create a timestamp from a DateTime
    pub fn from_datetime(dt: DateTime<Utc>) -> Self {
        Self(dt)
    }

    /// Get the inner DateTime
    pub fn datetime(&self) -> DateTime<Utc> {
        self.0
    }

    /// Get the timestamp as seconds since Unix epoch
    pub fn as_secs(&self) -> i64 {
        self.0.timestamp()
    }
}

impl From<DateTime<Utc>> for Timestamp {
    fn from(dt: DateTime<Utc>) -> Self {
        Self(dt)
    }
}

/// Metadata is a flexible key-value store
pub type Metadata = HashMap<String, serde_json::Value>;

/// Response wrapper for paginated list endpoints
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResponse<T> {
    /// The list of items
    pub data: Vec<T>,
    /// Whether there are more items available
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub has_more: Option<bool>,
    /// Total count (if available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub total: Option<usize>,
    /// Cursor for the next page (if available)
    #[serde(skip_serializing_if = "Option::is_none")]
    pub next_cursor: Option<String>,
}

impl<T> ListResponse<T> {
    /// Create a new list response
    pub fn new(data: Vec<T>, has_more: bool) -> Self {
        Self {
            data,
            has_more: Some(has_more),
            total: None,
            next_cursor: None,
        }
    }

    /// Check if the list is empty
    pub fn is_empty(&self) -> bool {
        self.data.is_empty()
    }

    /// Get the number of items in this response
    pub fn len(&self) -> usize {
        self.data.len()
    }
}

/// Interval for recurring prices
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum Interval {
    /// Daily recurring
    Day,
    /// Weekly recurring
    Week,
    /// Monthly recurring
    Month,
    /// Yearly recurring
    Year,
}

/// Status values that appear across multiple resources
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Status {
    /// Active status
    Active,
    /// Inactive status
    Inactive,
    /// Pending status
    Pending,
    /// Cancelled status
    Cancelled,
    /// Expired status
    Expired,
}

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

    #[test]
    fn test_money_creation() {
        let money = Money::new(1000, Currency::Usd);
        assert_eq!(money.amount, 1000);
        assert_eq!(money.currency, Currency::Usd);
    }

    #[test]
    fn test_money_usd() {
        let money = Money::usd(2500);
        assert_eq!(money.amount, 2500);
        assert_eq!(money.currency, Currency::Usd);
        assert_eq!(money.to_major_unit(), 25.0);
    }

    #[test]
    fn test_money_from_major_unit() {
        let money = Money::from_major_unit(49.99, Currency::Usd);
        assert_eq!(money.amount, 4999);
        assert_eq!(money.currency, Currency::Usd);
    }

    #[test]
    fn test_currency_serialization() {
        let usd = Currency::Usd;
        let json = serde_json::to_string(&usd).unwrap();
        assert_eq!(json, r#""usd""#);

        let deserialized: Currency = serde_json::from_str(&json).unwrap();
        assert_eq!(usd, deserialized);
    }

    #[test]
    fn test_currency_symbols() {
        assert_eq!(Currency::Usd.symbol(), "$");
        assert_eq!(Currency::Eur.symbol(), "");
        assert_eq!(Currency::Gbp.symbol(), "£");
    }

    #[test]
    fn test_currency_decimal_places() {
        assert_eq!(Currency::Usd.decimal_places(), 2);
        assert_eq!(Currency::Jpy.decimal_places(), 0);
    }

    #[test]
    fn test_timestamp() {
        let now = Timestamp::now();
        assert!(now.as_secs() > 0);
    }

    #[test]
    fn test_timestamp_serialization() {
        let dt = DateTime::parse_from_rfc3339("2024-01-01T00:00:00Z")
            .unwrap()
            .with_timezone(&Utc);
        let ts = Timestamp::from_datetime(dt);

        let json = serde_json::to_string(&ts).unwrap();
        let deserialized: Timestamp = serde_json::from_str(&json).unwrap();

        assert_eq!(ts, deserialized);
    }

    #[test]
    fn test_list_response() {
        let response = ListResponse::new(vec![1, 2, 3], true);
        assert_eq!(response.len(), 3);
        assert!(!response.is_empty());
        assert_eq!(response.has_more, Some(true));
    }

    #[test]
    fn test_interval_serialization() {
        let interval = Interval::Month;
        let json = serde_json::to_string(&interval).unwrap();
        assert_eq!(json, r#""month""#);
    }
}