Skip to main content

datasynth_core/models/
tax.rs

1//! Tax accounting data models.
2//!
3//! This module provides comprehensive tax data models including:
4//! - Tax jurisdictions (federal, state, local, municipal, supranational)
5//! - Tax codes with rates and effective date ranges
6//! - Tax lines attached to source documents (invoices, JEs, payments)
7//! - Tax returns (VAT, income tax, withholding remittance, payroll)
8//! - Tax provisions under ASC 740 / IAS 12
9//! - Uncertain tax positions under FIN 48 / IFRIC 23
10//! - Withholding tax records with treaty benefit tracking
11
12use std::collections::HashMap;
13
14use chrono::NaiveDate;
15use rust_decimal::Decimal;
16use serde::{Deserialize, Serialize};
17
18use super::graph_properties::{GraphPropertyValue, ToNodeProperties};
19
20// ---------------------------------------------------------------------------
21// Enums
22// ---------------------------------------------------------------------------
23
24/// Classification of a tax jurisdiction within a governmental hierarchy.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum JurisdictionType {
28    /// National / federal level (e.g., IRS, HMRC)
29    #[default]
30    Federal,
31    /// State / province level
32    State,
33    /// County / city level
34    Local,
35    /// Municipal / district level
36    Municipal,
37    /// Supranational body (e.g., EU VAT)
38    Supranational,
39}
40
41/// High-level classification of a tax.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
43#[serde(rename_all = "snake_case")]
44pub enum TaxType {
45    /// Value Added Tax
46    #[default]
47    Vat,
48    /// Goods and Services Tax
49    Gst,
50    /// Sales Tax (destination-based)
51    SalesTax,
52    /// Corporate / individual income tax
53    IncomeTax,
54    /// Withholding tax on cross-border payments
55    WithholdingTax,
56    /// Employer / employee payroll taxes
57    PayrollTax,
58    /// Excise / duty taxes
59    ExciseTax,
60}
61
62/// The type of source document a tax line is attached to.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
64#[serde(rename_all = "snake_case")]
65pub enum TaxableDocumentType {
66    /// Accounts-payable vendor invoice
67    #[default]
68    VendorInvoice,
69    /// Accounts-receivable customer invoice
70    CustomerInvoice,
71    /// Manual journal entry
72    JournalEntry,
73    /// Cash disbursement / receipt
74    Payment,
75    /// Payroll run
76    PayrollRun,
77}
78
79/// Type of periodic tax return filed with an authority.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
81#[serde(rename_all = "snake_case")]
82pub enum TaxReturnType {
83    /// VAT / GST return
84    #[default]
85    VatReturn,
86    /// Corporate income tax return
87    IncomeTax,
88    /// Withholding tax remittance
89    WithholdingRemittance,
90    /// Payroll tax return
91    PayrollTax,
92}
93
94/// Lifecycle status of a tax return.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
96#[serde(rename_all = "snake_case")]
97pub enum TaxReturnStatus {
98    /// Return is being prepared
99    #[default]
100    Draft,
101    /// Return has been submitted to the authority
102    Filed,
103    /// Authority has reviewed and issued assessment
104    Assessed,
105    /// Tax liability has been settled
106    Paid,
107    /// An amendment has been filed
108    Amended,
109}
110
111/// Category of withholding tax applied to a payment.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113#[serde(rename_all = "snake_case")]
114pub enum WithholdingType {
115    /// Withholding on dividend distributions
116    DividendWithholding,
117    /// Withholding on royalty payments
118    RoyaltyWithholding,
119    /// Withholding on service fees
120    #[default]
121    ServiceWithholding,
122}
123
124/// Measurement method for uncertain tax positions (FIN 48 / IFRIC 23).
125#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
126#[serde(rename_all = "snake_case")]
127pub enum TaxMeasurementMethod {
128    /// Single most-likely outcome
129    #[default]
130    MostLikelyAmount,
131    /// Probability-weighted expected value
132    ExpectedValue,
133}
134
135// ---------------------------------------------------------------------------
136// Structs
137// ---------------------------------------------------------------------------
138
139/// A taxing authority at a specific level of government.
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct TaxJurisdiction {
142    /// Unique jurisdiction identifier
143    pub id: String,
144    /// Human-readable name (e.g., "United States - Federal")
145    pub name: String,
146    /// ISO 3166-1 alpha-2 country code
147    pub country_code: String,
148    /// State / province / region code (ISO 3166-2 subdivision)
149    pub region_code: Option<String>,
150    /// Tier within the governmental hierarchy
151    pub jurisdiction_type: JurisdictionType,
152    /// Parent jurisdiction (e.g., state's parent is the federal jurisdiction)
153    pub parent_jurisdiction_id: Option<String>,
154    /// Whether the entity is VAT-registered in this jurisdiction
155    pub vat_registered: bool,
156}
157
158impl TaxJurisdiction {
159    /// Creates a new tax jurisdiction.
160    pub fn new(
161        id: impl Into<String>,
162        name: impl Into<String>,
163        country_code: impl Into<String>,
164        jurisdiction_type: JurisdictionType,
165    ) -> Self {
166        Self {
167            id: id.into(),
168            name: name.into(),
169            country_code: country_code.into(),
170            region_code: None,
171            jurisdiction_type,
172            parent_jurisdiction_id: None,
173            vat_registered: false,
174        }
175    }
176
177    /// Sets the region code.
178    pub fn with_region_code(mut self, region_code: impl Into<String>) -> Self {
179        self.region_code = Some(region_code.into());
180        self
181    }
182
183    /// Sets the parent jurisdiction ID.
184    pub fn with_parent_jurisdiction_id(mut self, parent_id: impl Into<String>) -> Self {
185        self.parent_jurisdiction_id = Some(parent_id.into());
186        self
187    }
188
189    /// Sets the VAT registration flag.
190    pub fn with_vat_registered(mut self, registered: bool) -> Self {
191        self.vat_registered = registered;
192        self
193    }
194
195    /// Returns `true` if the jurisdiction is sub-national (state, local, or municipal).
196    pub fn is_subnational(&self) -> bool {
197        matches!(
198            self.jurisdiction_type,
199            JurisdictionType::State | JurisdictionType::Local | JurisdictionType::Municipal
200        )
201    }
202}
203
204/// A tax code defining a rate for a specific tax type and jurisdiction.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206pub struct TaxCode {
207    /// Unique tax code identifier
208    pub id: String,
209    /// Short mnemonic (e.g., "VAT-STD-20", "WHT-SVC-15")
210    pub code: String,
211    /// Human-readable description
212    pub description: String,
213    /// Category of tax
214    pub tax_type: TaxType,
215    /// Tax rate as a decimal fraction (e.g., 0.20 for 20%)
216    #[serde(with = "rust_decimal::serde::str")]
217    pub rate: Decimal,
218    /// Jurisdiction this code applies to
219    pub jurisdiction_id: String,
220    /// Date from which the code is effective (inclusive)
221    pub effective_date: NaiveDate,
222    /// Date after which the code is no longer effective (exclusive)
223    pub expiry_date: Option<NaiveDate>,
224    /// Whether the reverse-charge mechanism applies
225    pub is_reverse_charge: bool,
226    /// Whether transactions under this code are tax-exempt
227    pub is_exempt: bool,
228}
229
230impl TaxCode {
231    /// Creates a new tax code.
232    #[allow(clippy::too_many_arguments)]
233    pub fn new(
234        id: impl Into<String>,
235        code: impl Into<String>,
236        description: impl Into<String>,
237        tax_type: TaxType,
238        rate: Decimal,
239        jurisdiction_id: impl Into<String>,
240        effective_date: NaiveDate,
241    ) -> Self {
242        Self {
243            id: id.into(),
244            code: code.into(),
245            description: description.into(),
246            tax_type,
247            rate,
248            jurisdiction_id: jurisdiction_id.into(),
249            effective_date,
250            expiry_date: None,
251            is_reverse_charge: false,
252            is_exempt: false,
253        }
254    }
255
256    /// Sets the expiry date.
257    pub fn with_expiry_date(mut self, expiry: NaiveDate) -> Self {
258        self.expiry_date = Some(expiry);
259        self
260    }
261
262    /// Sets the reverse-charge flag.
263    pub fn with_reverse_charge(mut self, reverse_charge: bool) -> Self {
264        self.is_reverse_charge = reverse_charge;
265        self
266    }
267
268    /// Sets the exempt flag.
269    pub fn with_exempt(mut self, exempt: bool) -> Self {
270        self.is_exempt = exempt;
271        self
272    }
273
274    /// Computes the tax amount for a given taxable base.
275    ///
276    /// Returns `taxable_amount * rate`, rounded to 2 decimal places.
277    /// Exempt codes always return zero.
278    pub fn tax_amount(&self, taxable_amount: Decimal) -> Decimal {
279        if self.is_exempt {
280            return Decimal::ZERO;
281        }
282        (taxable_amount * self.rate).round_dp(2)
283    }
284
285    /// Returns `true` if the tax code is active on the given `date`.
286    ///
287    /// A code is active when `effective_date <= date` and either no expiry
288    /// is set or `date < expiry_date`.
289    pub fn is_active(&self, date: NaiveDate) -> bool {
290        if date < self.effective_date {
291            return false;
292        }
293        match self.expiry_date {
294            Some(expiry) => date < expiry,
295            None => true,
296        }
297    }
298}
299
300/// A single tax line attached to a source document.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct TaxLine {
303    /// Unique tax line identifier
304    pub id: String,
305    /// Type of the source document
306    pub document_type: TaxableDocumentType,
307    /// Source document identifier
308    pub document_id: String,
309    /// Line number within the document
310    pub line_number: u32,
311    /// Tax code applied
312    pub tax_code_id: String,
313    /// Jurisdiction the tax is assessed in
314    pub jurisdiction_id: String,
315    /// Base amount subject to tax
316    #[serde(with = "rust_decimal::serde::str")]
317    pub taxable_amount: Decimal,
318    /// Computed tax amount
319    #[serde(with = "rust_decimal::serde::str")]
320    pub tax_amount: Decimal,
321    /// Whether the input tax is deductible (reclaimable)
322    pub is_deductible: bool,
323    /// Whether the reverse-charge mechanism applies
324    pub is_reverse_charge: bool,
325    /// Whether the tax was self-assessed by the buyer
326    pub is_self_assessed: bool,
327}
328
329impl TaxLine {
330    /// Creates a new tax line.
331    #[allow(clippy::too_many_arguments)]
332    pub fn new(
333        id: impl Into<String>,
334        document_type: TaxableDocumentType,
335        document_id: impl Into<String>,
336        line_number: u32,
337        tax_code_id: impl Into<String>,
338        jurisdiction_id: impl Into<String>,
339        taxable_amount: Decimal,
340        tax_amount: Decimal,
341    ) -> Self {
342        Self {
343            id: id.into(),
344            document_type,
345            document_id: document_id.into(),
346            line_number,
347            tax_code_id: tax_code_id.into(),
348            jurisdiction_id: jurisdiction_id.into(),
349            taxable_amount,
350            tax_amount,
351            is_deductible: true,
352            is_reverse_charge: false,
353            is_self_assessed: false,
354        }
355    }
356
357    /// Sets the deductible flag.
358    pub fn with_deductible(mut self, deductible: bool) -> Self {
359        self.is_deductible = deductible;
360        self
361    }
362
363    /// Sets the reverse-charge flag.
364    pub fn with_reverse_charge(mut self, reverse_charge: bool) -> Self {
365        self.is_reverse_charge = reverse_charge;
366        self
367    }
368
369    /// Sets the self-assessed flag.
370    pub fn with_self_assessed(mut self, self_assessed: bool) -> Self {
371        self.is_self_assessed = self_assessed;
372        self
373    }
374
375    /// Computes the effective tax rate for this line.
376    ///
377    /// Returns `tax_amount / taxable_amount`, or `Decimal::ZERO` when the
378    /// taxable amount is zero (avoids division by zero).
379    pub fn effective_rate(&self) -> Decimal {
380        if self.taxable_amount.is_zero() {
381            Decimal::ZERO
382        } else {
383            (self.tax_amount / self.taxable_amount).round_dp(6)
384        }
385    }
386}
387
388/// A periodic tax return filed with a jurisdiction.
389#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct TaxReturn {
391    /// Unique return identifier
392    pub id: String,
393    /// Legal entity filing the return
394    pub entity_id: String,
395    /// Jurisdiction the return is filed with
396    pub jurisdiction_id: String,
397    /// Start of the reporting period
398    pub period_start: NaiveDate,
399    /// End of the reporting period
400    pub period_end: NaiveDate,
401    /// Type of return
402    pub return_type: TaxReturnType,
403    /// Current lifecycle status
404    pub status: TaxReturnStatus,
405    /// Total output tax (tax collected / charged)
406    #[serde(with = "rust_decimal::serde::str")]
407    pub total_output_tax: Decimal,
408    /// Total input tax (tax paid / reclaimable)
409    #[serde(with = "rust_decimal::serde::str")]
410    pub total_input_tax: Decimal,
411    /// Net amount payable to the authority (output - input)
412    #[serde(with = "rust_decimal::serde::str")]
413    pub net_payable: Decimal,
414    /// Statutory filing deadline
415    pub filing_deadline: NaiveDate,
416    /// Actual date the return was submitted
417    pub actual_filing_date: Option<NaiveDate>,
418    /// Whether the return was filed after the deadline
419    pub is_late: bool,
420}
421
422impl TaxReturn {
423    /// Creates a new tax return.
424    #[allow(clippy::too_many_arguments)]
425    pub fn new(
426        id: impl Into<String>,
427        entity_id: impl Into<String>,
428        jurisdiction_id: impl Into<String>,
429        period_start: NaiveDate,
430        period_end: NaiveDate,
431        return_type: TaxReturnType,
432        total_output_tax: Decimal,
433        total_input_tax: Decimal,
434        filing_deadline: NaiveDate,
435    ) -> Self {
436        let net_payable = (total_output_tax - total_input_tax).round_dp(2);
437        Self {
438            id: id.into(),
439            entity_id: entity_id.into(),
440            jurisdiction_id: jurisdiction_id.into(),
441            period_start,
442            period_end,
443            return_type,
444            status: TaxReturnStatus::Draft,
445            total_output_tax,
446            total_input_tax,
447            net_payable,
448            filing_deadline,
449            actual_filing_date: None,
450            is_late: false,
451        }
452    }
453
454    /// Records the actual filing date and derives lateness.
455    pub fn with_filing(mut self, filing_date: NaiveDate) -> Self {
456        self.actual_filing_date = Some(filing_date);
457        self.is_late = filing_date > self.filing_deadline;
458        self.status = TaxReturnStatus::Filed;
459        self
460    }
461
462    /// Sets the return status.
463    pub fn with_status(mut self, status: TaxReturnStatus) -> Self {
464        self.status = status;
465        self
466    }
467
468    /// Returns `true` if the return has been submitted (Filed, Assessed, or Paid).
469    pub fn is_filed(&self) -> bool {
470        matches!(
471            self.status,
472            TaxReturnStatus::Filed | TaxReturnStatus::Assessed | TaxReturnStatus::Paid
473        )
474    }
475}
476
477/// An item in the statutory-to-effective rate reconciliation.
478#[derive(Debug, Clone, Serialize, Deserialize)]
479pub struct RateReconciliationItem {
480    /// Description of the reconciling item (e.g., "State taxes", "R&D credits")
481    pub description: String,
482    /// Impact on the effective rate (positive increases, negative decreases)
483    #[serde(with = "rust_decimal::serde::str")]
484    pub rate_impact: Decimal,
485}
486
487/// Income tax provision computed under ASC 740 / IAS 12.
488#[derive(Debug, Clone, Serialize, Deserialize)]
489pub struct TaxProvision {
490    /// Unique provision identifier
491    pub id: String,
492    /// Legal entity the provision relates to
493    pub entity_id: String,
494    /// Period end date
495    pub period: NaiveDate,
496    /// Current period income tax expense
497    #[serde(with = "rust_decimal::serde::str")]
498    pub current_tax_expense: Decimal,
499    /// Deferred tax asset balance
500    #[serde(with = "rust_decimal::serde::str")]
501    pub deferred_tax_asset: Decimal,
502    /// Deferred tax liability balance
503    #[serde(with = "rust_decimal::serde::str")]
504    pub deferred_tax_liability: Decimal,
505    /// Statutory tax rate
506    #[serde(with = "rust_decimal::serde::str")]
507    pub statutory_rate: Decimal,
508    /// Effective tax rate after permanent and temporary differences
509    #[serde(with = "rust_decimal::serde::str")]
510    pub effective_rate: Decimal,
511    /// Rate reconciliation from statutory to effective rate
512    pub rate_reconciliation: Vec<RateReconciliationItem>,
513}
514
515impl TaxProvision {
516    /// Creates a new tax provision.
517    #[allow(clippy::too_many_arguments)]
518    pub fn new(
519        id: impl Into<String>,
520        entity_id: impl Into<String>,
521        period: NaiveDate,
522        current_tax_expense: Decimal,
523        deferred_tax_asset: Decimal,
524        deferred_tax_liability: Decimal,
525        statutory_rate: Decimal,
526        effective_rate: Decimal,
527    ) -> Self {
528        Self {
529            id: id.into(),
530            entity_id: entity_id.into(),
531            period,
532            current_tax_expense,
533            deferred_tax_asset,
534            deferred_tax_liability,
535            statutory_rate,
536            effective_rate,
537            rate_reconciliation: Vec::new(),
538        }
539    }
540
541    /// Adds a rate reconciliation item.
542    pub fn with_reconciliation_item(
543        mut self,
544        description: impl Into<String>,
545        rate_impact: Decimal,
546    ) -> Self {
547        self.rate_reconciliation.push(RateReconciliationItem {
548            description: description.into(),
549            rate_impact,
550        });
551        self
552    }
553
554    /// Computes the net deferred tax position.
555    ///
556    /// Positive value indicates a net deferred tax asset; negative indicates
557    /// a net deferred tax liability.
558    pub fn net_deferred_tax(&self) -> Decimal {
559        self.deferred_tax_asset - self.deferred_tax_liability
560    }
561}
562
563/// An uncertain tax position evaluated under FIN 48 / IFRIC 23.
564#[derive(Debug, Clone, Serialize, Deserialize)]
565pub struct UncertainTaxPosition {
566    /// Unique UTP identifier
567    pub id: String,
568    /// Legal entity
569    pub entity_id: String,
570    /// Description of the tax position
571    pub description: String,
572    /// Total gross tax benefit claimed
573    #[serde(with = "rust_decimal::serde::str")]
574    pub tax_benefit: Decimal,
575    /// Recognition threshold (typically 0.50 for "more-likely-than-not")
576    #[serde(with = "rust_decimal::serde::str")]
577    pub recognition_threshold: Decimal,
578    /// Amount recognized in the financial statements
579    #[serde(with = "rust_decimal::serde::str")]
580    pub recognized_amount: Decimal,
581    /// Measurement method used to determine the recognized amount
582    pub measurement_method: TaxMeasurementMethod,
583}
584
585impl UncertainTaxPosition {
586    /// Creates a new uncertain tax position.
587    #[allow(clippy::too_many_arguments)]
588    pub fn new(
589        id: impl Into<String>,
590        entity_id: impl Into<String>,
591        description: impl Into<String>,
592        tax_benefit: Decimal,
593        recognition_threshold: Decimal,
594        recognized_amount: Decimal,
595        measurement_method: TaxMeasurementMethod,
596    ) -> Self {
597        Self {
598            id: id.into(),
599            entity_id: entity_id.into(),
600            description: description.into(),
601            tax_benefit,
602            recognition_threshold,
603            recognized_amount,
604            measurement_method,
605        }
606    }
607
608    /// Returns the portion of the tax benefit that has **not** been recognized.
609    pub fn unrecognized_amount(&self) -> Decimal {
610        self.tax_benefit - self.recognized_amount
611    }
612}
613
614/// A withholding tax record associated with a cross-border payment.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct WithholdingTaxRecord {
617    /// Unique record identifier
618    pub id: String,
619    /// Payment document this withholding relates to
620    pub payment_id: String,
621    /// Vendor / payee subject to withholding
622    pub vendor_id: String,
623    /// Category of withholding
624    pub withholding_type: WithholdingType,
625    /// Reduced rate under an applicable tax treaty
626    #[serde(default, with = "rust_decimal::serde::str_option")]
627    pub treaty_rate: Option<Decimal>,
628    /// Domestic statutory withholding rate
629    #[serde(with = "rust_decimal::serde::str")]
630    pub statutory_rate: Decimal,
631    /// Rate actually applied (may equal treaty_rate or statutory_rate)
632    #[serde(with = "rust_decimal::serde::str")]
633    pub applied_rate: Decimal,
634    /// Gross payment amount subject to withholding
635    #[serde(with = "rust_decimal::serde::str")]
636    pub base_amount: Decimal,
637    /// Amount withheld (base_amount * applied_rate)
638    #[serde(with = "rust_decimal::serde::str")]
639    pub withheld_amount: Decimal,
640    /// Tax certificate / receipt number from the authority
641    pub certificate_number: Option<String>,
642}
643
644impl WithholdingTaxRecord {
645    /// Creates a new withholding tax record.
646    #[allow(clippy::too_many_arguments)]
647    pub fn new(
648        id: impl Into<String>,
649        payment_id: impl Into<String>,
650        vendor_id: impl Into<String>,
651        withholding_type: WithholdingType,
652        statutory_rate: Decimal,
653        applied_rate: Decimal,
654        base_amount: Decimal,
655    ) -> Self {
656        let withheld_amount = (base_amount * applied_rate).round_dp(2);
657        Self {
658            id: id.into(),
659            payment_id: payment_id.into(),
660            vendor_id: vendor_id.into(),
661            withholding_type,
662            treaty_rate: None,
663            statutory_rate,
664            applied_rate,
665            base_amount,
666            withheld_amount,
667            certificate_number: None,
668        }
669    }
670
671    /// Sets the treaty rate.
672    pub fn with_treaty_rate(mut self, rate: Decimal) -> Self {
673        self.treaty_rate = Some(rate);
674        self
675    }
676
677    /// Sets the certificate number.
678    pub fn with_certificate_number(mut self, number: impl Into<String>) -> Self {
679        self.certificate_number = Some(number.into());
680        self
681    }
682
683    /// Returns `true` if a tax-treaty benefit has been applied.
684    ///
685    /// A treaty benefit exists when a treaty rate is present **and** the
686    /// applied rate is strictly less than the statutory rate.
687    pub fn has_treaty_benefit(&self) -> bool {
688        self.treaty_rate.is_some() && self.applied_rate < self.statutory_rate
689    }
690
691    /// Computes the savings achieved through the treaty benefit.
692    ///
693    /// `(statutory_rate - applied_rate) * base_amount`, rounded to 2 dp.
694    pub fn treaty_savings(&self) -> Decimal {
695        ((self.statutory_rate - self.applied_rate) * self.base_amount).round_dp(2)
696    }
697}
698
699// ---------------------------------------------------------------------------
700// ToNodeProperties implementations
701// ---------------------------------------------------------------------------
702
703impl ToNodeProperties for TaxJurisdiction {
704    fn node_type_name(&self) -> &'static str {
705        "tax_jurisdiction"
706    }
707    fn node_type_code(&self) -> u16 {
708        410
709    }
710    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
711        let mut p = HashMap::new();
712        p.insert("code".into(), GraphPropertyValue::String(self.id.clone()));
713        p.insert("name".into(), GraphPropertyValue::String(self.name.clone()));
714        p.insert(
715            "country".into(),
716            GraphPropertyValue::String(self.country_code.clone()),
717        );
718        if let Some(ref rc) = self.region_code {
719            p.insert("region".into(), GraphPropertyValue::String(rc.clone()));
720        }
721        p.insert(
722            "jurisdictionType".into(),
723            GraphPropertyValue::String(format!("{:?}", self.jurisdiction_type)),
724        );
725        p.insert(
726            "isActive".into(),
727            GraphPropertyValue::Bool(self.vat_registered),
728        );
729        p
730    }
731}
732
733impl ToNodeProperties for TaxCode {
734    fn node_type_name(&self) -> &'static str {
735        "tax_code"
736    }
737    fn node_type_code(&self) -> u16 {
738        411
739    }
740    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
741        let mut p = HashMap::new();
742        p.insert("code".into(), GraphPropertyValue::String(self.code.clone()));
743        p.insert(
744            "description".into(),
745            GraphPropertyValue::String(self.description.clone()),
746        );
747        p.insert("rate".into(), GraphPropertyValue::Decimal(self.rate));
748        p.insert(
749            "taxType".into(),
750            GraphPropertyValue::String(format!("{:?}", self.tax_type)),
751        );
752        p.insert(
753            "jurisdiction".into(),
754            GraphPropertyValue::String(self.jurisdiction_id.clone()),
755        );
756        p.insert("isActive".into(), GraphPropertyValue::Bool(!self.is_exempt));
757        p.insert(
758            "isReverseCharge".into(),
759            GraphPropertyValue::Bool(self.is_reverse_charge),
760        );
761        p
762    }
763}
764
765impl ToNodeProperties for TaxLine {
766    fn node_type_name(&self) -> &'static str {
767        "tax_line"
768    }
769    fn node_type_code(&self) -> u16 {
770        412
771    }
772    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
773        let mut p = HashMap::new();
774        p.insert(
775            "returnId".into(),
776            GraphPropertyValue::String(self.document_id.clone()),
777        );
778        p.insert(
779            "lineNumber".into(),
780            GraphPropertyValue::Int(self.line_number as i64),
781        );
782        p.insert(
783            "description".into(),
784            GraphPropertyValue::String(format!("{:?}", self.document_type)),
785        );
786        p.insert(
787            "amount".into(),
788            GraphPropertyValue::Decimal(self.tax_amount),
789        );
790        p.insert(
791            "taxableAmount".into(),
792            GraphPropertyValue::Decimal(self.taxable_amount),
793        );
794        p.insert(
795            "taxCode".into(),
796            GraphPropertyValue::String(self.tax_code_id.clone()),
797        );
798        p.insert(
799            "jurisdiction".into(),
800            GraphPropertyValue::String(self.jurisdiction_id.clone()),
801        );
802        p.insert(
803            "isDeductible".into(),
804            GraphPropertyValue::Bool(self.is_deductible),
805        );
806        p
807    }
808}
809
810impl ToNodeProperties for TaxReturn {
811    fn node_type_name(&self) -> &'static str {
812        "tax_return"
813    }
814    fn node_type_code(&self) -> u16 {
815        413
816    }
817    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
818        let mut p = HashMap::new();
819        p.insert(
820            "entityCode".into(),
821            GraphPropertyValue::String(self.entity_id.clone()),
822        );
823        p.insert(
824            "period".into(),
825            GraphPropertyValue::String(format!("{}..{}", self.period_start, self.period_end)),
826        );
827        p.insert(
828            "jurisdiction".into(),
829            GraphPropertyValue::String(self.jurisdiction_id.clone()),
830        );
831        p.insert(
832            "filingType".into(),
833            GraphPropertyValue::String(format!("{:?}", self.return_type)),
834        );
835        p.insert(
836            "status".into(),
837            GraphPropertyValue::String(format!("{:?}", self.status)),
838        );
839        p.insert(
840            "totalTax".into(),
841            GraphPropertyValue::Decimal(self.total_output_tax),
842        );
843        p.insert(
844            "taxPaid".into(),
845            GraphPropertyValue::Decimal(self.total_input_tax),
846        );
847        p.insert(
848            "balanceDue".into(),
849            GraphPropertyValue::Decimal(self.net_payable),
850        );
851        p.insert(
852            "dueDate".into(),
853            GraphPropertyValue::Date(self.filing_deadline),
854        );
855        p.insert("isLate".into(), GraphPropertyValue::Bool(self.is_late));
856        p
857    }
858}
859
860impl ToNodeProperties for TaxProvision {
861    fn node_type_name(&self) -> &'static str {
862        "tax_provision"
863    }
864    fn node_type_code(&self) -> u16 {
865        414
866    }
867    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
868        let mut p = HashMap::new();
869        p.insert(
870            "entityCode".into(),
871            GraphPropertyValue::String(self.entity_id.clone()),
872        );
873        p.insert("period".into(), GraphPropertyValue::Date(self.period));
874        p.insert(
875            "totalProvision".into(),
876            GraphPropertyValue::Decimal(self.current_tax_expense),
877        );
878        p.insert(
879            "deferredAsset".into(),
880            GraphPropertyValue::Decimal(self.deferred_tax_asset),
881        );
882        p.insert(
883            "deferredLiability".into(),
884            GraphPropertyValue::Decimal(self.deferred_tax_liability),
885        );
886        p.insert(
887            "statutoryRate".into(),
888            GraphPropertyValue::Decimal(self.statutory_rate),
889        );
890        p.insert(
891            "effectiveRate".into(),
892            GraphPropertyValue::Decimal(self.effective_rate),
893        );
894        p
895    }
896}
897
898impl ToNodeProperties for WithholdingTaxRecord {
899    fn node_type_name(&self) -> &'static str {
900        "withholding_tax_record"
901    }
902    fn node_type_code(&self) -> u16 {
903        415
904    }
905    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
906        let mut p = HashMap::new();
907        p.insert(
908            "paymentId".into(),
909            GraphPropertyValue::String(self.payment_id.clone()),
910        );
911        p.insert(
912            "vendorId".into(),
913            GraphPropertyValue::String(self.vendor_id.clone()),
914        );
915        p.insert(
916            "taxCode".into(),
917            GraphPropertyValue::String(format!("{:?}", self.withholding_type)),
918        );
919        p.insert(
920            "grossAmount".into(),
921            GraphPropertyValue::Decimal(self.base_amount),
922        );
923        p.insert(
924            "withholdingRate".into(),
925            GraphPropertyValue::Decimal(self.applied_rate),
926        );
927        p.insert(
928            "withholdingAmount".into(),
929            GraphPropertyValue::Decimal(self.withheld_amount),
930        );
931        p.insert(
932            "treatyApplied".into(),
933            GraphPropertyValue::Bool(self.treaty_rate.is_some()),
934        );
935        if let Some(ref cn) = self.certificate_number {
936            p.insert(
937                "certificateNumber".into(),
938                GraphPropertyValue::String(cn.clone()),
939            );
940        }
941        p
942    }
943}
944
945impl ToNodeProperties for UncertainTaxPosition {
946    fn node_type_name(&self) -> &'static str {
947        "uncertain_tax_position"
948    }
949    fn node_type_code(&self) -> u16 {
950        416
951    }
952    fn to_node_properties(&self) -> HashMap<String, GraphPropertyValue> {
953        let mut p = HashMap::new();
954        p.insert(
955            "entityCode".into(),
956            GraphPropertyValue::String(self.entity_id.clone()),
957        );
958        p.insert(
959            "description".into(),
960            GraphPropertyValue::String(self.description.clone()),
961        );
962        p.insert(
963            "amount".into(),
964            GraphPropertyValue::Decimal(self.tax_benefit),
965        );
966        p.insert(
967            "probability".into(),
968            GraphPropertyValue::Decimal(self.recognition_threshold),
969        );
970        p.insert(
971            "reserveAmount".into(),
972            GraphPropertyValue::Decimal(self.recognized_amount),
973        );
974        p.insert(
975            "measurementMethod".into(),
976            GraphPropertyValue::String(format!("{:?}", self.measurement_method)),
977        );
978        p
979    }
980}
981
982// ---------------------------------------------------------------------------
983// Tests
984// ---------------------------------------------------------------------------
985
986#[cfg(test)]
987#[allow(clippy::unwrap_used)]
988mod tests {
989    use super::*;
990    use rust_decimal_macros::dec;
991
992    #[test]
993    fn test_tax_code_creation() {
994        let code = TaxCode::new(
995            "TC-001",
996            "VAT-STD-20",
997            "Standard VAT 20%",
998            TaxType::Vat,
999            dec!(0.20),
1000            "JUR-UK",
1001            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1002        )
1003        .with_expiry_date(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
1004
1005        // tax_amount computation
1006        assert_eq!(code.tax_amount(dec!(1000.00)), dec!(200.00));
1007        assert_eq!(code.tax_amount(dec!(0)), dec!(0.00));
1008
1009        // is_active within range
1010        assert!(code.is_active(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()));
1011        // before effective date
1012        assert!(!code.is_active(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()));
1013        // on expiry date (exclusive)
1014        assert!(!code.is_active(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1015        // well after expiry
1016        assert!(!code.is_active(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1017    }
1018
1019    #[test]
1020    fn test_tax_code_exempt() {
1021        let code = TaxCode::new(
1022            "TC-002",
1023            "VAT-EX",
1024            "VAT Exempt",
1025            TaxType::Vat,
1026            dec!(0.20),
1027            "JUR-UK",
1028            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1029        )
1030        .with_exempt(true);
1031
1032        assert_eq!(code.tax_amount(dec!(5000.00)), dec!(0));
1033    }
1034
1035    #[test]
1036    fn test_tax_line_creation() {
1037        let line = TaxLine::new(
1038            "TL-001",
1039            TaxableDocumentType::VendorInvoice,
1040            "INV-001",
1041            1,
1042            "TC-001",
1043            "JUR-UK",
1044            dec!(1000.00),
1045            dec!(200.00),
1046        );
1047
1048        assert_eq!(line.effective_rate(), dec!(0.200000));
1049
1050        // Zero taxable amount should return zero rate
1051        let zero_line = TaxLine::new(
1052            "TL-002",
1053            TaxableDocumentType::VendorInvoice,
1054            "INV-002",
1055            1,
1056            "TC-001",
1057            "JUR-UK",
1058            dec!(0),
1059            dec!(0),
1060        );
1061        assert_eq!(zero_line.effective_rate(), dec!(0));
1062    }
1063
1064    #[test]
1065    fn test_tax_return_net_payable() {
1066        let ret = TaxReturn::new(
1067            "TR-001",
1068            "ENT-001",
1069            "JUR-UK",
1070            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1071            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
1072            TaxReturnType::VatReturn,
1073            dec!(50000),
1074            dec!(30000),
1075            NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(),
1076        );
1077
1078        // Draft is not filed
1079        assert!(!ret.is_filed());
1080        assert_eq!(ret.net_payable, dec!(20000));
1081
1082        // Filed
1083        let filed = ret.with_filing(NaiveDate::from_ymd_opt(2024, 4, 15).unwrap());
1084        assert!(filed.is_filed());
1085        assert!(!filed.is_late);
1086
1087        // Assessed
1088        let assessed = TaxReturn::new(
1089            "TR-002",
1090            "ENT-001",
1091            "JUR-UK",
1092            NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
1093            NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
1094            TaxReturnType::VatReturn,
1095            dec!(60000),
1096            dec!(40000),
1097            NaiveDate::from_ymd_opt(2024, 7, 31).unwrap(),
1098        )
1099        .with_status(TaxReturnStatus::Assessed);
1100        assert!(assessed.is_filed());
1101
1102        // Paid
1103        let paid = TaxReturn::new(
1104            "TR-003",
1105            "ENT-001",
1106            "JUR-UK",
1107            NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
1108            NaiveDate::from_ymd_opt(2024, 9, 30).unwrap(),
1109            TaxReturnType::IncomeTax,
1110            dec!(100000),
1111            dec!(0),
1112            NaiveDate::from_ymd_opt(2024, 10, 31).unwrap(),
1113        )
1114        .with_status(TaxReturnStatus::Paid);
1115        assert!(paid.is_filed());
1116
1117        // Amended is not in the "filed" set
1118        let amended = TaxReturn::new(
1119            "TR-004",
1120            "ENT-001",
1121            "JUR-UK",
1122            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1123            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
1124            TaxReturnType::VatReturn,
1125            dec!(50000),
1126            dec!(30000),
1127            NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(),
1128        )
1129        .with_status(TaxReturnStatus::Amended);
1130        assert!(!amended.is_filed());
1131    }
1132
1133    #[test]
1134    fn test_tax_provision() {
1135        let provision = TaxProvision::new(
1136            "TP-001",
1137            "ENT-001",
1138            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1139            dec!(250000),
1140            dec!(80000),
1141            dec!(120000),
1142            dec!(0.21),
1143            dec!(0.245),
1144        )
1145        .with_reconciliation_item("State taxes", dec!(0.03))
1146        .with_reconciliation_item("R&D credits", dec!(-0.015));
1147
1148        // net deferred tax: 80,000 - 120,000 = -40,000 (net liability)
1149        assert_eq!(provision.net_deferred_tax(), dec!(-40000));
1150        assert_eq!(provision.rate_reconciliation.len(), 2);
1151    }
1152
1153    #[test]
1154    fn test_withholding_tax_record() {
1155        let wht = WithholdingTaxRecord::new(
1156            "WHT-001",
1157            "PAY-001",
1158            "V-100",
1159            WithholdingType::RoyaltyWithholding,
1160            dec!(0.30),   // statutory 30%
1161            dec!(0.10),   // applied 10% (treaty)
1162            dec!(100000), // base amount
1163        )
1164        .with_treaty_rate(dec!(0.10))
1165        .with_certificate_number("CERT-2024-001");
1166
1167        assert!(wht.has_treaty_benefit());
1168        // savings: (0.30 - 0.10) * 100,000 = 20,000
1169        assert_eq!(wht.treaty_savings(), dec!(20000.00));
1170        assert_eq!(wht.withheld_amount, dec!(10000.00));
1171        assert_eq!(wht.certificate_number, Some("CERT-2024-001".to_string()));
1172    }
1173
1174    #[test]
1175    fn test_withholding_no_treaty() {
1176        let wht = WithholdingTaxRecord::new(
1177            "WHT-002",
1178            "PAY-002",
1179            "V-200",
1180            WithholdingType::ServiceWithholding,
1181            dec!(0.25),
1182            dec!(0.25),
1183            dec!(50000),
1184        );
1185
1186        assert!(!wht.has_treaty_benefit());
1187        // No savings when applied == statutory
1188        assert_eq!(wht.treaty_savings(), dec!(0.00));
1189    }
1190
1191    #[test]
1192    fn test_uncertain_tax_position() {
1193        let utp = UncertainTaxPosition::new(
1194            "UTP-001",
1195            "ENT-001",
1196            "R&D credit claim for software development",
1197            dec!(500000), // total benefit claimed
1198            dec!(0.50),   // more-likely-than-not threshold
1199            dec!(350000), // recognized
1200            TaxMeasurementMethod::MostLikelyAmount,
1201        );
1202
1203        // unrecognized: 500,000 - 350,000 = 150,000
1204        assert_eq!(utp.unrecognized_amount(), dec!(150000));
1205    }
1206
1207    #[test]
1208    fn test_jurisdiction_hierarchy() {
1209        let federal = TaxJurisdiction::new(
1210            "JUR-US",
1211            "United States - Federal",
1212            "US",
1213            JurisdictionType::Federal,
1214        );
1215        assert!(!federal.is_subnational());
1216
1217        let state = TaxJurisdiction::new("JUR-US-CA", "California", "US", JurisdictionType::State)
1218            .with_region_code("CA")
1219            .with_parent_jurisdiction_id("JUR-US");
1220        assert!(state.is_subnational());
1221        assert_eq!(state.region_code, Some("CA".to_string()));
1222        assert_eq!(state.parent_jurisdiction_id, Some("JUR-US".to_string()));
1223
1224        let local = TaxJurisdiction::new(
1225            "JUR-US-CA-SF",
1226            "San Francisco",
1227            "US",
1228            JurisdictionType::Local,
1229        )
1230        .with_parent_jurisdiction_id("JUR-US-CA");
1231        assert!(local.is_subnational());
1232
1233        let municipal = TaxJurisdiction::new(
1234            "JUR-US-NY-NYC",
1235            "New York City",
1236            "US",
1237            JurisdictionType::Municipal,
1238        )
1239        .with_parent_jurisdiction_id("JUR-US-NY");
1240        assert!(municipal.is_subnational());
1241
1242        let supra = TaxJurisdiction::new(
1243            "JUR-EU",
1244            "European Union",
1245            "EU",
1246            JurisdictionType::Supranational,
1247        );
1248        assert!(!supra.is_subnational());
1249    }
1250
1251    #[test]
1252    fn test_serde_roundtrip() {
1253        let code = TaxCode::new(
1254            "TC-SERDE",
1255            "VAT-STD-20",
1256            "Standard VAT 20%",
1257            TaxType::Vat,
1258            dec!(0.20),
1259            "JUR-UK",
1260            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1261        )
1262        .with_expiry_date(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap())
1263        .with_reverse_charge(true);
1264
1265        let json = serde_json::to_string_pretty(&code).unwrap();
1266        let deserialized: TaxCode = serde_json::from_str(&json).unwrap();
1267
1268        assert_eq!(deserialized.id, code.id);
1269        assert_eq!(deserialized.code, code.code);
1270        assert_eq!(deserialized.rate, code.rate);
1271        assert_eq!(deserialized.tax_type, code.tax_type);
1272        assert_eq!(deserialized.is_reverse_charge, code.is_reverse_charge);
1273        assert_eq!(deserialized.effective_date, code.effective_date);
1274        assert_eq!(deserialized.expiry_date, code.expiry_date);
1275    }
1276
1277    #[test]
1278    fn test_withholding_serde_roundtrip() {
1279        // With treaty rate (Some)
1280        let wht = WithholdingTaxRecord::new(
1281            "WHT-SERDE-1",
1282            "PAY-001",
1283            "V-001",
1284            WithholdingType::RoyaltyWithholding,
1285            dec!(0.30),
1286            dec!(0.15),
1287            dec!(50000),
1288        )
1289        .with_treaty_rate(dec!(0.10));
1290
1291        let json = serde_json::to_string_pretty(&wht).unwrap();
1292        let deserialized: WithholdingTaxRecord = serde_json::from_str(&json).unwrap();
1293        assert_eq!(deserialized.treaty_rate, Some(dec!(0.10)));
1294        assert_eq!(deserialized.statutory_rate, dec!(0.30));
1295        assert_eq!(deserialized.applied_rate, dec!(0.15));
1296        assert_eq!(deserialized.base_amount, dec!(50000));
1297        assert_eq!(deserialized.withheld_amount, wht.withheld_amount);
1298
1299        // Without treaty rate (None)
1300        let wht_no_treaty = WithholdingTaxRecord::new(
1301            "WHT-SERDE-2",
1302            "PAY-002",
1303            "V-002",
1304            WithholdingType::ServiceWithholding,
1305            dec!(0.30),
1306            dec!(0.30),
1307            dec!(10000),
1308        );
1309
1310        let json2 = serde_json::to_string_pretty(&wht_no_treaty).unwrap();
1311        let deserialized2: WithholdingTaxRecord = serde_json::from_str(&json2).unwrap();
1312        assert_eq!(deserialized2.treaty_rate, None);
1313        assert_eq!(deserialized2.statutory_rate, dec!(0.30));
1314    }
1315}