Skip to main content

datasynth_generators/tax/
tax_posting_generator.rs

1//! Tax GL Posting Journal Entry Generator.
2//!
3//! Converts TaxLine records into balanced GL journal entries:
4//! - Customer invoices → Output VAT posting (DR AR / CR VAT Payable)
5//! - Deductible vendor invoices → Input VAT posting (DR Input VAT / CR AP)
6//! - Non-deductible vendor invoices → skipped (expense already absorbs tax)
7//! - Other document types (JournalEntry, Payment, PayrollRun) → skipped
8
9use std::collections::HashMap;
10
11use chrono::NaiveDate;
12
13use datasynth_core::accounts::{control_accounts, tax_accounts};
14use datasynth_core::models::journal_entry::{
15    BusinessProcess, JournalEntry, JournalEntryHeader, JournalEntryLine, TransactionSource,
16};
17use datasynth_core::models::{TaxLine, TaxableDocumentType};
18
19// ---------------------------------------------------------------------------
20// Generator
21// ---------------------------------------------------------------------------
22
23/// Generates GL journal entries for tax postings derived from TaxLine records.
24pub struct TaxPostingGenerator;
25
26impl TaxPostingGenerator {
27    /// Generate GL journal entries for each eligible TaxLine.
28    ///
29    /// # Rules
30    ///
31    /// | Document type       | Deductible | Action                                    |
32    /// |---------------------|------------|-------------------------------------------|
33    /// | CustomerInvoice     | any        | DR AR Control / CR VAT Payable            |
34    /// | VendorInvoice       | true       | DR Input VAT / CR AP Control              |
35    /// | VendorInvoice       | false      | Skipped – tax absorbed into expense       |
36    /// | JournalEntry        | any        | Skipped                                   |
37    /// | Payment             | any        | Skipped                                   |
38    /// | PayrollRun          | any        | Skipped                                   |
39    /// Generate GL journal entries for each eligible TaxLine.
40    ///
41    /// Each JE is dated using the source document's date from `doc_dates`
42    /// (keyed by `TaxLine::document_id`), falling back to `fallback_date`
43    /// when no mapping is found. This prevents all tax JEs from clustering
44    /// at period-end when a single blanket posting date was previously used.
45    pub fn generate_tax_posting_jes(
46        tax_lines: &[TaxLine],
47        company_code: &str,
48        doc_dates: &HashMap<String, NaiveDate>,
49        fallback_date: NaiveDate,
50    ) -> Vec<JournalEntry> {
51        let mut jes = Vec::new();
52
53        for tax_line in tax_lines {
54            // Skip zero-amount lines – nothing to post.
55            if tax_line.tax_amount.is_zero() {
56                continue;
57            }
58
59            let posting_date = doc_dates
60                .get(&tax_line.document_id)
61                .copied()
62                .unwrap_or(fallback_date);
63
64            match tax_line.document_type {
65                TaxableDocumentType::CustomerInvoice => {
66                    jes.push(Self::output_vat_je(tax_line, company_code, posting_date));
67                }
68                TaxableDocumentType::VendorInvoice if tax_line.is_deductible => {
69                    jes.push(Self::input_vat_je(tax_line, company_code, posting_date));
70                }
71                // Non-deductible vendor tax or unsupported doc types → skip.
72                TaxableDocumentType::VendorInvoice
73                | TaxableDocumentType::JournalEntry
74                | TaxableDocumentType::Payment
75                | TaxableDocumentType::PayrollRun => {}
76            }
77        }
78
79        jes
80    }
81
82    // -----------------------------------------------------------------------
83    // Private helpers
84    // -----------------------------------------------------------------------
85
86    /// Output VAT posting for a customer invoice tax line.
87    ///
88    /// DR AR Control ("1100") / CR VAT Payable ("2110")
89    fn output_vat_je(
90        tax_line: &TaxLine,
91        company_code: &str,
92        posting_date: NaiveDate,
93    ) -> JournalEntry {
94        let doc_id_str = format!("JE-TAX-OUT-{}", tax_line.id);
95        let mut je = Self::build_je(
96            doc_id_str.clone(),
97            company_code,
98            posting_date,
99            format!(
100                "Output VAT posting for customer invoice {}",
101                tax_line.document_id
102            ),
103        );
104
105        let uuid = je.header.document_id;
106        let amount = tax_line.tax_amount;
107        je.add_line(JournalEntryLine::debit(
108            uuid,
109            1,
110            control_accounts::AR_CONTROL.to_string(),
111            amount,
112        ));
113        je.add_line(JournalEntryLine::credit(
114            uuid,
115            2,
116            tax_accounts::VAT_PAYABLE.to_string(),
117            amount,
118        ));
119        je
120    }
121
122    /// Input VAT posting for a deductible vendor invoice tax line.
123    ///
124    /// DR Input VAT ("1160") / CR AP Control ("2000")
125    fn input_vat_je(
126        tax_line: &TaxLine,
127        company_code: &str,
128        posting_date: NaiveDate,
129    ) -> JournalEntry {
130        let doc_id_str = format!("JE-TAX-IN-{}", tax_line.id);
131        let mut je = Self::build_je(
132            doc_id_str.clone(),
133            company_code,
134            posting_date,
135            format!(
136                "Input VAT posting for vendor invoice {}",
137                tax_line.document_id
138            ),
139        );
140
141        let uuid = je.header.document_id;
142        let amount = tax_line.tax_amount;
143        je.add_line(JournalEntryLine::debit(
144            uuid,
145            1,
146            tax_accounts::INPUT_VAT.to_string(),
147            amount,
148        ));
149        je.add_line(JournalEntryLine::credit(
150            uuid,
151            2,
152            control_accounts::AP_CONTROL.to_string(),
153            amount,
154        ));
155        je
156    }
157
158    /// Build a base `JournalEntry` with tax-posting metadata.
159    fn build_je(
160        _doc_id_str: String,
161        company_code: &str,
162        posting_date: NaiveDate,
163        description: String,
164    ) -> JournalEntry {
165        let mut header = JournalEntryHeader::new(company_code.to_string(), posting_date);
166        header.document_type = "TAX_POSTING".to_string();
167        header.created_by = "TAX_POSTING_ENGINE".to_string();
168        header.source = TransactionSource::Automated;
169        header.business_process = Some(BusinessProcess::R2R);
170        header.header_text = Some(description);
171        JournalEntry::new(header)
172    }
173}