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}