khqr 0.1.0

Unofficial Rust SDK for Bakong KHQR
Documentation
//! Individual account information for KHQR generation

use serde::{Deserialize, Serialize};

use crate::constants::currency;
use crate::constants::defaults::*;
use crate::constants::limits::*;
use crate::constants::poi;
use crate::error::{BakongError, Result};

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IndividualInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bakong_account_id: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub merchant_name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub merchant_city: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub account_information: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub currency: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub amount: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bill_number: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mobile_number: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub store_label: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub terminal_label: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub purpose_of_transaction: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub language_preference: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub merchant_name_alternate_language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub merchant_city_alternate_language: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub expiration_timestamp: Option<i64>,
}

impl IndividualInfo {
    pub fn builder() -> IndividualInfoBuilder {
        IndividualInfoBuilder::new()
    }
}

pub struct IndividualInfoBuilder {
    bakong_account_id: Option<String>,
    merchant_name: Option<String>,
    merchant_city: Option<String>,
    account_information: Option<String>,
    currency: Option<String>,
    amount: Option<f64>,
    bill_number: Option<String>,
    mobile_number: Option<String>,
    store_label: Option<String>,
    terminal_label: Option<String>,
    purpose_of_transaction: Option<String>,
    language_preference: Option<String>,
    merchant_name_alternate_language: Option<String>,
    merchant_city_alternate_language: Option<String>,
    expiration_timestamp: Option<i64>,
}

impl IndividualInfoBuilder {
    pub fn new() -> Self {
        Self {
            bakong_account_id: None,
            merchant_name: None,
            merchant_city: None,
            account_information: None,
            currency: Some(DEFAULT_CURRENCY.to_string()),
            amount: None,
            bill_number: None,
            mobile_number: None,
            store_label: None,
            terminal_label: None,
            purpose_of_transaction: None,
            language_preference: None,
            merchant_name_alternate_language: None,
            merchant_city_alternate_language: None,
            expiration_timestamp: None,
        }
    }

    pub fn bakong_account_id(mut self, id: impl Into<String>) -> Self {
        self.bakong_account_id = Some(id.into());
        self
    }

    pub fn merchant_name(mut self, name: impl Into<String>) -> Self {
        self.merchant_name = Some(name.into());
        self
    }

    pub fn merchant_city(mut self, city: impl Into<String>) -> Self {
        self.merchant_city = Some(city.into());
        self
    }

    pub fn account_information(mut self, info: impl Into<String>) -> Self {
        self.account_information = Some(info.into());
        self
    }

    pub fn currency(mut self, currency: impl Into<String>) -> Self {
        self.currency = Some(currency.into());
        self
    }

    pub fn amount(mut self, amount: f64) -> Self {
        self.amount = Some(amount);
        self
    }

    pub fn bill_number(mut self, bill: impl Into<String>) -> Self {
        self.bill_number = Some(bill.into());
        self
    }

    pub fn mobile_number(mut self, phone: impl Into<String>) -> Self {
        self.mobile_number = Some(phone.into());
        self
    }

    pub fn store_label(mut self, label: impl Into<String>) -> Self {
        self.store_label = Some(label.into());
        self
    }

    pub fn terminal_label(mut self, label: impl Into<String>) -> Self {
        self.terminal_label = Some(label.into());
        self
    }

    pub fn purpose_of_transaction(mut self, purpose: impl Into<String>) -> Self {
        self.purpose_of_transaction = Some(purpose.into());
        self
    }

    pub fn language_preference(mut self, lang: impl Into<String>) -> Self {
        self.language_preference = Some(lang.into());
        self
    }

    pub fn merchant_name_alternate_language(mut self, name: impl Into<String>) -> Self {
        self.merchant_name_alternate_language = Some(name.into());
        self
    }

    pub fn merchant_city_alternate_language(mut self, city: impl Into<String>) -> Self {
        self.merchant_city_alternate_language = Some(city.into());
        self
    }

    pub fn expiration_timestamp(mut self, timestamp: i64) -> Self {
        self.expiration_timestamp = Some(timestamp);
        self
    }

    pub fn build(self) -> Result<IndividualInfo> {
        let bakong_account_id = self
            .bakong_account_id
            .ok_or_else(|| BakongError::RequiredField("bakong_account_id".to_string()))?;

        if bakong_account_id.len() > BAKONG_ACCOUNT_ID_MAX {
            return Err(BakongError::InvalidFormat(format!(
                "bakong_account_id exceeds max length {}",
                BAKONG_ACCOUNT_ID_MAX
            )));
        }

        let merchant_name = self
            .merchant_name
            .ok_or_else(|| BakongError::RequiredField("merchant_name".to_string()))?;

        if merchant_name.len() > MERCHANT_NAME_MAX {
            return Err(BakongError::InvalidFormat(format!(
                "merchant_name exceeds max length {}",
                MERCHANT_NAME_MAX
            )));
        }

        let currency = self
            .currency
            .unwrap_or_else(|| DEFAULT_CURRENCY.to_string());
        let _currency_code = currency::code_for(&currency);

        if let Some(amount) = self.amount {
            if currency == "KHR" {
                if amount.fract() != 0.0 {
                    return Err(BakongError::InvalidAmount(
                        "KHR amount must be a whole number".to_string(),
                    ));
                }
            } else if currency == "USD" {
                let decimals = (amount.fract() * 100.0).round();
                if decimals > 99.0 {
                    return Err(BakongError::InvalidAmount(
                        "USD amount can have max 2 decimal places".to_string(),
                    ));
                }
            }
        }

        Ok(IndividualInfo {
            bakong_account_id: Some(bakong_account_id),
            merchant_name: Some(merchant_name),
            merchant_city: Some(
                self.merchant_city
                    .unwrap_or_else(|| DEFAULT_MERCHANT_CITY.to_string()),
            ),
            account_information: self.account_information,
            currency: Some(currency),
            amount: self.amount,
            bill_number: self.bill_number,
            mobile_number: self.mobile_number,
            store_label: self.store_label,
            terminal_label: self.terminal_label,
            purpose_of_transaction: self.purpose_of_transaction,
            language_preference: self.language_preference,
            merchant_name_alternate_language: self.merchant_name_alternate_language,
            merchant_city_alternate_language: self.merchant_city_alternate_language,
            expiration_timestamp: self.expiration_timestamp,
        })
    }
}

impl Default for IndividualInfoBuilder {
    fn default() -> Self {
        Self::new()
    }
}

pub fn is_dynamic(info: &IndividualInfo) -> bool {
    info.amount.map(|a| a > 0.0).unwrap_or(false)
}

pub fn get_poi(info: &IndividualInfo) -> &'static str {
    if is_dynamic(info) {
        poi::DYNAMIC_QR
    } else {
        poi::STATIC_QR
    }
}