tallyprime-sdk 0.1.2

Rust SDK for TallyPrime XML/HTTP integration with typed APIs for masters, vouchers, and financial reports.
Documentation
use crate::errors::{Result, TallyError};
use std::fmt;

#[derive(Debug, Clone)]
pub struct GstRateDetail {
    pub duty_head: String, // CGST, SGST/UTGST, IGST, Cess, State Cess
    pub rate: f32,
    pub valuation_type: Option<String>,
}

/// Multicurrency details embedded in Tally `AMOUNT` values such as
/// `EUR29.37 @ D$1.1675/EUR = D$34.29`.
#[derive(Debug, Clone, PartialEq)]
pub struct ForexDetails {
    /// Amount in the foreign/transaction currency.
    pub foreign_amount: f32,
    /// Foreign/transaction currency symbol, e.g. `EUR`.
    pub foreign_currency: String,
    /// Resolved foreign currency name when available, e.g. `EURO`.
    pub foreign_currency_name: Option<String>,
    /// Company/base currency symbol, e.g. `D$`.
    pub base_currency: String,
    /// Resolved base currency name when available, e.g. `Dollar`.
    pub base_currency_name: Option<String>,
    /// Exchange rate as base-currency units per 1 foreign unit.
    pub exchange_rate: f32,
}

impl ForexDetails {
    /// Prefer the resolved currency name, otherwise the symbol.
    pub fn foreign_currency_label(&self) -> &str {
        self.foreign_currency_name
            .as_deref()
            .unwrap_or(self.foreign_currency.as_str())
    }

    /// Prefer the resolved currency name, otherwise the symbol.
    pub fn base_currency_label(&self) -> &str {
        self.base_currency_name
            .as_deref()
            .unwrap_or(self.base_currency.as_str())
    }
}

#[derive(Debug, Clone)]
pub struct BatchAllocation {
    pub godown_name: String,
    pub batch_name: String,
    pub amount: f32,
    pub forex: Option<ForexDetails>,
    pub actual_qty: Option<f32>,
    pub billed_qty: Option<f32>,
}

#[derive(Debug, Clone)]
pub struct AccountingAllocation {
    pub ledger_name: String,
    pub amount: f32,
    pub forex: Option<ForexDetails>,
    pub is_deemed_positive: bool,
}

/// Bill-wise allocation on a ledger entry (for invoice settlement).
#[derive(Debug, Clone, PartialEq)]
pub struct BillAllocation {
    pub bill_name: String,
    pub bill_type: Option<String>,
    pub amount: f32,
    pub forex: Option<ForexDetails>,
}

#[derive(Debug, Clone)]
pub struct VoucherEntry {
    pub ledger_name: String,
    pub amount: f32,
    pub forex: Option<ForexDetails>,
    pub is_debit: bool,
    pub is_party_ledger: bool,
    pub bill_allocations: Vec<BillAllocation>,
}

impl VoucherEntry {
    pub fn to_map(&self) -> serde_json::Map<String, serde_json::Value> {
        use serde_json::json;
        let mut m = serde_json::Map::new();
        m.insert("LEDGERNAME".into(), json!(self.ledger_name.clone()));
        // Tally import convention: debit = ISDEEMEDPOSITIVE=Yes + negative AMOUNT;
        // credit = ISDEEMEDPOSITIVE=No + positive AMOUNT.
        m.insert(
            "ISDEEMEDPOSITIVE".into(),
            json!(if self.is_debit { "Yes" } else { "No" }),
        );
        m.insert(
            "ISPARTYLEDGER".into(),
            json!(if self.is_party_ledger { "Yes" } else { "No" }),
        );
        // Tally imports are more reliable with fixed 2-decimal string amounts
        // (matches working Payment/Receipt XML samples).
        let amt = if self.is_debit {
            format!("-{:.2}", self.amount.abs())
        } else {
            format!("{:.2}", self.amount.abs())
        };
        m.insert("AMOUNT".into(), json!(amt));
        m
    }
}

#[derive(Debug, Clone)]
pub struct Item {
    pub name: String,
    pub amount: f32,
    pub forex: Option<ForexDetails>,
    pub rate: Option<f32>,
    pub discount: Option<f32>,
    pub actual_qty: Option<f32>,
    pub billed_qty: Option<f32>,
    pub gst_hsn_code: Option<String>,
    pub gst_hsn_description: Option<String>,
    pub gst_taxability: Option<String>,
    pub gst_type_of_supply: Option<String>,
    pub batch_allocations: Vec<BatchAllocation>,
    pub accounting_allocations: Vec<AccountingAllocation>,
    pub gst_rate_details: Vec<GstRateDetail>,
}

#[derive(Debug, Clone)]
pub struct Voucher {
    pub voucher_id: String,
    pub remote_id: Option<String>,
    pub vch_key: Option<String>,
    pub voucher_type: String,
    pub action: Option<String>,
    pub date_yyyymmdd: String,
    pub amount: Option<f32>,
    pub amount_forex: Option<ForexDetails>,
    pub voucher_number: Option<String>,
    pub reference: Option<String>,
    pub party_ledger_name: Option<String>,
    pub cmp_gst_registration_type: Option<String>,
    pub party_gstin: Option<String>,
    pub cmp_gstin: Option<String>,
    pub place_of_supply: Option<String>,
    pub entries: Vec<VoucherEntry>,
    pub items: Vec<Item>,
    pub narration: Option<String>,
    pub reference_date: Option<String>,
    pub effective_date: Option<String>,
    pub is_invoice: bool,
    pub is_cancelled: bool,
    pub is_optional: bool,
    pub entry_mode: Option<String>,
    pub alter_id: Option<i32>,
    pub master_id: Option<i32>,
}

impl Voucher {
    pub fn validate(&self) -> Result<()> {
        if self.voucher_type.trim().is_empty() {
            return Err(TallyError::Validation("Voucher type is required".into()));
        }
        if self.entries.len() < 2 {
            return Err(TallyError::Validation(
                "Voucher must have at least 2 entries".into(),
            ));
        }
        let mut deb = 0.0;
        let mut cred = 0.0;
        for e in &self.entries {
            if e.is_debit {
                deb += e.amount;
            } else {
                cred += e.amount;
            }
        }
        if (deb - cred).abs() > 0.01 {
            return Err(TallyError::Validation(format!(
                "Voucher not balanced. Debits: {}, Credits: {}",
                deb, cred
            )));
        }
        Ok(())
    }

    pub fn to_map(&self) -> serde_json::Map<String, serde_json::Value> {
        use serde_json::{json, Value};
        let mut m = serde_json::Map::new();
        m.insert("VOUCHERTYPENAME".into(), json!(self.voucher_type.clone()));
        m.insert("OBJVIEW".into(), json!("Accounting Voucher View"));
        m.insert("PERSISTEDVIEW".into(), json!("Accounting Voucher View"));
        m.insert(
            "ISINVOICE".into(),
            json!(if self.is_invoice { "Yes" } else { "No" }),
        );
        m.insert("DATE".into(), json!(self.date_yyyymmdd.clone()));
        m.insert(
            "EFFECTIVEDATE".into(),
            json!(self
                .effective_date
                .clone()
                .unwrap_or_else(|| self.date_yyyymmdd.clone())),
        );
        if let Some(n) = &self.narration {
            m.insert("NARRATION".into(), json!(n));
        }
        if let Some(vn) = &self.voucher_number {
            m.insert("VOUCHERNUMBER".into(), json!(vn));
        }
        if let Some(r) = &self.reference {
            m.insert("REFERENCE".into(), json!(r));
        }
        if let Some(p) = &self.party_ledger_name {
            m.insert("PARTYLEDGERNAME".into(), json!(p));
        }
        let arr: Vec<Value> = self
            .entries
            .iter()
            .map(|e| Value::Object(e.to_map()))
            .collect();
        // Accounting Payment/Receipt samples typically use ALLLEDGERENTRIES.LIST.
        m.insert("ALLLEDGERENTRIES.LIST".into(), Value::Array(arr));
        m
    }
}

// Display implementations
impl fmt::Display for GstRateDetail {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}%", self.duty_head, self.rate)?;
        if let Some(vt) = &self.valuation_type {
            write!(f, " ({})", vt)?;
        }
        Ok(())
    }
}

impl fmt::Display for ForexDetails {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{:.4} {} @ {} {:.6}/{} = {:.4} {}",
            self.foreign_amount,
            self.foreign_currency_label(),
            self.base_currency_label(),
            self.exchange_rate,
            self.foreign_currency_label(),
            self.foreign_amount.abs() * self.exchange_rate,
            self.base_currency_label()
        )
    }
}

impl fmt::Display for BatchAllocation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} @ {}: {}",
            self.batch_name, self.godown_name, self.amount
        )?;
        if let Some(forex) = &self.forex {
            write!(f, " ({})", forex)?;
        }
        Ok(())
    }
}

impl fmt::Display for AccountingAllocation {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} = {}", self.ledger_name, self.amount)?;
        if let Some(forex) = &self.forex {
            write!(f, " ({})", forex)?;
        }
        Ok(())
    }
}

impl fmt::Display for VoucherEntry {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let side = if self.is_debit { "Dr" } else { "Cr" };
        write!(f, "{}: {} ({})", self.ledger_name, self.amount, side)?;
        if self.is_party_ledger {
            write!(f, " [Party]")?;
        }
        if let Some(forex) = &self.forex {
            write!(f, " | {}", forex)?;
        }
        Ok(())
    }
}

impl fmt::Display for Item {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.name, self.amount)?;
        if let Some(hsn) = &self.gst_hsn_code {
            write!(f, " (HSN: {})", hsn)?;
        }
        writeln!(f)?;

        if let Some(qty) = self.actual_qty {
            write!(f, "    Qty: {}", qty)?;
            if let Some(rate) = self.rate {
                write!(f, " @ {}", rate)?;
            }
            writeln!(f)?;
        }

        if !self.batch_allocations.is_empty() {
            writeln!(f, "    Batches:")?;
            for batch in &self.batch_allocations {
                writeln!(f, "      - {}", batch)?;
            }
        }

        if !self.accounting_allocations.is_empty() {
            writeln!(f, "    Ledgers:")?;
            for acct in &self.accounting_allocations {
                writeln!(f, "      - {}", acct)?;
            }
        }

        if !self.gst_rate_details.is_empty() {
            let non_zero: Vec<_> = self
                .gst_rate_details
                .iter()
                .filter(|r| r.rate > 0.0)
                .collect();
            if !non_zero.is_empty() {
                writeln!(f, "    GST Rates:")?;
                for rate in non_zero {
                    writeln!(f, "      - {}", rate)?;
                }
            }
        }

        Ok(())
    }
}

impl fmt::Display for Voucher {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        writeln!(f, "=== Voucher ===")?;
        writeln!(f, "ID: {}", self.voucher_id)?;
        if let Some(rid) = &self.remote_id {
            writeln!(f, "Remote ID: {}", rid)?;
        }
        writeln!(f, "Type: {}", self.voucher_type)?;
        if let Some(act) = &self.action {
            writeln!(f, "Action: {}", act)?;
        }
        writeln!(f, "Date: {}", self.date_yyyymmdd)?;
        if let Some(amount) = self.amount {
            writeln!(f, "Amount: {}", amount)?;
        }
        if let Some(forex) = &self.amount_forex {
            writeln!(f, "Amount Forex: {}", forex)?;
        }

        if let Some(vn) = &self.voucher_number {
            writeln!(f, "Number: {}", vn)?;
        }
        if let Some(r) = &self.reference {
            writeln!(f, "Reference: {}", r)?;
        }
        if let Some(p) = &self.party_ledger_name {
            writeln!(f, "Party: {}", p)?;
        }
        if let Some(n) = &self.narration {
            writeln!(f, "Narration: {}", n)?;
        }
        if let Some(em) = &self.entry_mode {
            writeln!(f, "Entry Mode: {}", em)?;
        }

        if self.is_cancelled {
            writeln!(f, "Status: CANCELLED")?;
        }

        if !self.items.is_empty() {
            writeln!(f, "\nItems ({}):", self.items.len())?;
            for item in &self.items {
                write!(f, "  - {}", item)?;
            }
        }

        if !self.entries.is_empty() {
            writeln!(f, "\nLedger Entries ({}):", self.entries.len())?;
            for entry in &self.entries {
                writeln!(f, "  - {}", entry)?;
            }
        }

        Ok(())
    }
}