tapsilat 2026.10.21

The Tapsilat SDK for Rust
Documentation
use crate::error::{Result, TapsilatError};
use regex::Regex;

pub struct Validators;

impl Validators {
    /// Validates a phone number and returns it without spaces, dashes and parentheses.
    /// An empty number is returned unchanged. International numbers ("+..." or "00...")
    /// and national numbers ("0...") are accepted; use [`Validators::validate_gsm`] to
    /// require a Turkish mobile number.
    pub fn validate_gsm_number(phone: &str) -> Result<String> {
        if phone.is_empty() {
            return Ok(String::new());
        }

        let clean: String = phone
            .chars()
            .filter(|c| !matches!(c, ' ' | '-' | '(' | ')'))
            .collect();
        let invalid =
            || TapsilatError::ValidationError(format!("Invalid phone number format: {}", phone));

        if clean.is_empty() || !clean.chars().all(|c| c == '+' || c.is_ascii_digit()) {
            return Err(invalid());
        }
        let digits = clean.replace('+', "");
        if digits.is_empty() {
            return Err(invalid());
        }

        let too_short = if clean.starts_with('+') {
            (clean.len() < 8).then_some("International phone number too short")
        } else if clean.starts_with("00") {
            (clean.len() < 9).then_some("International phone number (00 format) too short")
        } else if clean.starts_with('0') {
            (clean.len() < 7).then_some("National phone number too short")
        } else {
            (clean.len() < 6).then_some("Local phone number too short")
        };
        if let Some(message) = too_short {
            return Err(TapsilatError::ValidationError(format!(
                "{}: {}",
                message, phone
            )));
        }

        Ok(clean)
    }

    /// Parses a comma-separated installment list such as "1,2,3". Every value must be
    /// between 1 and 12; an empty list means a single installment.
    pub fn parse_installments(installments: &str) -> Result<Vec<u8>> {
        let mut parsed = Vec::new();
        for part in installments
            .split(',')
            .map(str::trim)
            .filter(|p| !p.is_empty())
        {
            let value: i64 = part.parse().map_err(|_| {
                TapsilatError::ValidationError(
                    "Enabled installments must be comma-separated integers (e.g., 1,2,3 or 2,4,6)"
                        .to_string(),
                )
            })?;
            if !(1..=12).contains(&value) {
                return Err(TapsilatError::ValidationError(format!(
                    "Installment value '{}' is invalid. All installment values must be between 1 and 12 (inclusive).",
                    value
                )));
            }
            parsed.push(value as u8);
        }
        if parsed.is_empty() {
            parsed.push(1);
        }
        Ok(parsed)
    }

    /// Validates Turkish GSM numbers
    /// Accepts formats: +90XXXXXXXXXX, 90XXXXXXXXXX, 0XXXXXXXXXX, XXXXXXXXXX
    pub fn validate_gsm(gsm: &str) -> Result<String> {
        let gsm = gsm.trim().replace(" ", "").replace("-", "");

        // Remove country code variations
        let normalized = if gsm.starts_with("+90") {
            gsm.strip_prefix("+90").unwrap()
        } else if gsm.starts_with("90") {
            gsm.strip_prefix("90").unwrap()
        } else if gsm.starts_with("0") {
            gsm.strip_prefix("0").unwrap()
        } else {
            &gsm
        };

        // Check if it's exactly 10 digits and starts with 5
        if normalized.len() != 10 {
            return Err(TapsilatError::ValidationError(
                "GSM number must be 10 digits long".to_string(),
            ));
        }

        if !normalized.starts_with("5") {
            return Err(TapsilatError::ValidationError(
                "Turkish mobile numbers must start with 5".to_string(),
            ));
        }

        // Check if all characters are digits
        if !normalized.chars().all(|c| c.is_ascii_digit()) {
            return Err(TapsilatError::ValidationError(
                "GSM number must contain only digits".to_string(),
            ));
        }

        Ok(format!("90{}", normalized))
    }

    /// Validates installment count
    pub fn validate_installments(installments: u8) -> Result<()> {
        const VALID_INSTALLMENTS: &[u8] = &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];

        if !VALID_INSTALLMENTS.contains(&installments) {
            return Err(TapsilatError::ValidationError(format!(
                "Invalid installment count: {}. Valid values are 1-12",
                installments
            )));
        }

        Ok(())
    }

    /// Validates email address
    pub fn validate_email(email: &str) -> Result<()> {
        let email_regex = Regex::new(r"^[^\s@]+@[^\s@]+\.[^\s@]+$")
            .map_err(|e| TapsilatError::ValidationError(format!("Email regex error: {}", e)))?;

        if !email_regex.is_match(email) {
            return Err(TapsilatError::ValidationError(
                "Invalid email format".to_string(),
            ));
        }

        Ok(())
    }

    /// Validates Turkish identity number (TC Kimlik No)
    pub fn validate_identity_number(identity: &str) -> Result<()> {
        let identity = identity.trim();

        if identity.len() != 11 {
            return Err(TapsilatError::ValidationError(
                "Identity number must be 11 digits".to_string(),
            ));
        }

        if !identity.chars().all(|c| c.is_ascii_digit()) {
            return Err(TapsilatError::ValidationError(
                "Identity number must contain only digits".to_string(),
            ));
        }

        let digits: Vec<u8> = identity
            .chars()
            .map(|c| c.to_digit(10).unwrap() as u8)
            .collect();

        // First digit cannot be 0
        if digits[0] == 0 {
            return Err(TapsilatError::ValidationError(
                "Identity number cannot start with 0".to_string(),
            ));
        }

        // Validate checksum algorithm
        let sum_odd: u8 = digits[0] + digits[2] + digits[4] + digits[6] + digits[8];
        let sum_even: u8 = digits[1] + digits[3] + digits[5] + digits[7];

        let check_digit_10 = (sum_odd * 7 - sum_even) % 10;
        if check_digit_10 != digits[9] {
            return Err(TapsilatError::ValidationError(
                "Invalid identity number checksum".to_string(),
            ));
        }

        let total_sum: u8 = digits[0..10].iter().sum();
        let check_digit_11 = total_sum % 10;
        if check_digit_11 != digits[10] {
            return Err(TapsilatError::ValidationError(
                "Invalid identity number checksum".to_string(),
            ));
        }

        Ok(())
    }

    /// Validates amount (must be positive and have max 2 decimal places)
    pub fn validate_amount(amount: f64) -> Result<()> {
        if amount <= 0.0 {
            return Err(TapsilatError::ValidationError(
                "Amount must be greater than 0".to_string(),
            ));
        }

        // Check decimal places
        let decimal_places = format!("{:.10}", amount)
            .trim_end_matches('0')
            .split('.')
            .nth(1)
            .map(|s| s.len())
            .unwrap_or(0);

        if decimal_places > 2 {
            return Err(TapsilatError::ValidationError(
                "Amount cannot have more than 2 decimal places".to_string(),
            ));
        }

        Ok(())
    }
}

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

    #[test]
    fn test_gsm_validation() {
        assert!(Validators::validate_gsm("+905551234567").is_ok());
        assert!(Validators::validate_gsm("905551234567").is_ok());
        assert!(Validators::validate_gsm("05551234567").is_ok());
        assert!(Validators::validate_gsm("5551234567").is_ok());

        assert!(Validators::validate_gsm("123456789").is_err()); // Too short
        assert!(Validators::validate_gsm("4551234567").is_err()); // Doesn't start with 5
    }

    #[test]
    fn test_gsm_number_validation_matches_go() {
        assert_eq!(
            Validators::validate_gsm_number("+90 (555) 123-45-67").unwrap(),
            "+905551234567"
        );
        assert_eq!(
            Validators::validate_gsm_number("05551234567").unwrap(),
            "05551234567"
        );
        assert_eq!(Validators::validate_gsm_number("").unwrap(), "");
        assert!(Validators::validate_gsm_number("+12345").is_err());
        assert!(Validators::validate_gsm_number("0012345").is_err());
        assert!(Validators::validate_gsm_number("abc123456").is_err());
        assert!(Validators::validate_gsm_number("+++").is_err());
    }

    #[test]
    fn test_parse_installments() {
        assert_eq!(Validators::parse_installments("").unwrap(), vec![1]);
        assert_eq!(
            Validators::parse_installments("1, 3,6").unwrap(),
            vec![1, 3, 6]
        );
        assert!(Validators::parse_installments("0,3").is_err());
        assert!(Validators::parse_installments("13").is_err());
        assert!(Validators::parse_installments("two").is_err());
    }

    #[test]
    fn test_installment_validation() {
        assert!(Validators::validate_installments(1).is_ok());
        assert!(Validators::validate_installments(12).is_ok());
        assert!(Validators::validate_installments(13).is_err());
        assert!(Validators::validate_installments(0).is_err());
    }

    #[test]
    fn test_email_validation() {
        assert!(Validators::validate_email("test@example.com").is_ok());
        assert!(Validators::validate_email("invalid-email").is_err());
        assert!(Validators::validate_email("@invalid.com").is_err());
    }

    #[test]
    fn test_amount_validation() {
        assert!(Validators::validate_amount(10.50).is_ok());
        assert!(Validators::validate_amount(0.01).is_ok());
        assert!(Validators::validate_amount(-5.0).is_err());
        assert!(Validators::validate_amount(0.0).is_err());
        assert!(Validators::validate_amount(10.555).is_err()); // Too many decimals
    }
}