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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
317    pub taxable_amount: Decimal,
318    /// Computed tax amount
319    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
407    pub total_output_tax: Decimal,
408    /// Total input tax (tax paid / reclaimable)
409    #[serde(with = "crate::serde_decimal")]
410    pub total_input_tax: Decimal,
411    /// Net amount payable to the authority (output - input)
412    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
498    pub current_tax_expense: Decimal,
499    /// Deferred tax asset balance
500    #[serde(with = "crate::serde_decimal")]
501    pub deferred_tax_asset: Decimal,
502    /// Deferred tax liability balance
503    #[serde(with = "crate::serde_decimal")]
504    pub deferred_tax_liability: Decimal,
505    /// Statutory tax rate
506    #[serde(with = "crate::serde_decimal")]
507    pub statutory_rate: Decimal,
508    /// Effective tax rate after permanent and temporary differences
509    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal")]
574    pub tax_benefit: Decimal,
575    /// Recognition threshold (typically 0.50 for "more-likely-than-not")
576    #[serde(with = "crate::serde_decimal")]
577    pub recognition_threshold: Decimal,
578    /// Amount recognized in the financial statements
579    #[serde(with = "crate::serde_decimal")]
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 = "crate::serde_decimal::option")]
627    pub treaty_rate: Option<Decimal>,
628    /// Domestic statutory withholding rate
629    #[serde(with = "crate::serde_decimal")]
630    pub statutory_rate: Decimal,
631    /// Rate actually applied (may equal treaty_rate or statutory_rate)
632    #[serde(with = "crate::serde_decimal")]
633    pub applied_rate: Decimal,
634    /// Gross payment amount subject to withholding
635    #[serde(with = "crate::serde_decimal")]
636    pub base_amount: Decimal,
637    /// Amount withheld (base_amount * applied_rate)
638    #[serde(with = "crate::serde_decimal")]
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)]
987mod tests {
988    use super::*;
989    use rust_decimal_macros::dec;
990
991    #[test]
992    fn test_tax_code_creation() {
993        let code = TaxCode::new(
994            "TC-001",
995            "VAT-STD-20",
996            "Standard VAT 20%",
997            TaxType::Vat,
998            dec!(0.20),
999            "JUR-UK",
1000            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1001        )
1002        .with_expiry_date(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap());
1003
1004        // tax_amount computation
1005        assert_eq!(code.tax_amount(dec!(1000.00)), dec!(200.00));
1006        assert_eq!(code.tax_amount(dec!(0)), dec!(0.00));
1007
1008        // is_active within range
1009        assert!(code.is_active(NaiveDate::from_ymd_opt(2024, 6, 15).unwrap()));
1010        // before effective date
1011        assert!(!code.is_active(NaiveDate::from_ymd_opt(2023, 12, 31).unwrap()));
1012        // on expiry date (exclusive)
1013        assert!(!code.is_active(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap()));
1014        // well after expiry
1015        assert!(!code.is_active(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()));
1016    }
1017
1018    #[test]
1019    fn test_tax_code_exempt() {
1020        let code = TaxCode::new(
1021            "TC-002",
1022            "VAT-EX",
1023            "VAT Exempt",
1024            TaxType::Vat,
1025            dec!(0.20),
1026            "JUR-UK",
1027            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1028        )
1029        .with_exempt(true);
1030
1031        assert_eq!(code.tax_amount(dec!(5000.00)), dec!(0));
1032    }
1033
1034    #[test]
1035    fn test_tax_line_creation() {
1036        let line = TaxLine::new(
1037            "TL-001",
1038            TaxableDocumentType::VendorInvoice,
1039            "INV-001",
1040            1,
1041            "TC-001",
1042            "JUR-UK",
1043            dec!(1000.00),
1044            dec!(200.00),
1045        );
1046
1047        assert_eq!(line.effective_rate(), dec!(0.200000));
1048
1049        // Zero taxable amount should return zero rate
1050        let zero_line = TaxLine::new(
1051            "TL-002",
1052            TaxableDocumentType::VendorInvoice,
1053            "INV-002",
1054            1,
1055            "TC-001",
1056            "JUR-UK",
1057            dec!(0),
1058            dec!(0),
1059        );
1060        assert_eq!(zero_line.effective_rate(), dec!(0));
1061    }
1062
1063    #[test]
1064    fn test_tax_return_net_payable() {
1065        let ret = TaxReturn::new(
1066            "TR-001",
1067            "ENT-001",
1068            "JUR-UK",
1069            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1070            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
1071            TaxReturnType::VatReturn,
1072            dec!(50000),
1073            dec!(30000),
1074            NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(),
1075        );
1076
1077        // Draft is not filed
1078        assert!(!ret.is_filed());
1079        assert_eq!(ret.net_payable, dec!(20000));
1080
1081        // Filed
1082        let filed = ret.with_filing(NaiveDate::from_ymd_opt(2024, 4, 15).unwrap());
1083        assert!(filed.is_filed());
1084        assert!(!filed.is_late);
1085
1086        // Assessed
1087        let assessed = TaxReturn::new(
1088            "TR-002",
1089            "ENT-001",
1090            "JUR-UK",
1091            NaiveDate::from_ymd_opt(2024, 4, 1).unwrap(),
1092            NaiveDate::from_ymd_opt(2024, 6, 30).unwrap(),
1093            TaxReturnType::VatReturn,
1094            dec!(60000),
1095            dec!(40000),
1096            NaiveDate::from_ymd_opt(2024, 7, 31).unwrap(),
1097        )
1098        .with_status(TaxReturnStatus::Assessed);
1099        assert!(assessed.is_filed());
1100
1101        // Paid
1102        let paid = TaxReturn::new(
1103            "TR-003",
1104            "ENT-001",
1105            "JUR-UK",
1106            NaiveDate::from_ymd_opt(2024, 7, 1).unwrap(),
1107            NaiveDate::from_ymd_opt(2024, 9, 30).unwrap(),
1108            TaxReturnType::IncomeTax,
1109            dec!(100000),
1110            dec!(0),
1111            NaiveDate::from_ymd_opt(2024, 10, 31).unwrap(),
1112        )
1113        .with_status(TaxReturnStatus::Paid);
1114        assert!(paid.is_filed());
1115
1116        // Amended is not in the "filed" set
1117        let amended = TaxReturn::new(
1118            "TR-004",
1119            "ENT-001",
1120            "JUR-UK",
1121            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1122            NaiveDate::from_ymd_opt(2024, 3, 31).unwrap(),
1123            TaxReturnType::VatReturn,
1124            dec!(50000),
1125            dec!(30000),
1126            NaiveDate::from_ymd_opt(2024, 4, 30).unwrap(),
1127        )
1128        .with_status(TaxReturnStatus::Amended);
1129        assert!(!amended.is_filed());
1130    }
1131
1132    #[test]
1133    fn test_tax_provision() {
1134        let provision = TaxProvision::new(
1135            "TP-001",
1136            "ENT-001",
1137            NaiveDate::from_ymd_opt(2024, 12, 31).unwrap(),
1138            dec!(250000),
1139            dec!(80000),
1140            dec!(120000),
1141            dec!(0.21),
1142            dec!(0.245),
1143        )
1144        .with_reconciliation_item("State taxes", dec!(0.03))
1145        .with_reconciliation_item("R&D credits", dec!(-0.015));
1146
1147        // net deferred tax: 80,000 - 120,000 = -40,000 (net liability)
1148        assert_eq!(provision.net_deferred_tax(), dec!(-40000));
1149        assert_eq!(provision.rate_reconciliation.len(), 2);
1150    }
1151
1152    #[test]
1153    fn test_withholding_tax_record() {
1154        let wht = WithholdingTaxRecord::new(
1155            "WHT-001",
1156            "PAY-001",
1157            "V-100",
1158            WithholdingType::RoyaltyWithholding,
1159            dec!(0.30),   // statutory 30%
1160            dec!(0.10),   // applied 10% (treaty)
1161            dec!(100000), // base amount
1162        )
1163        .with_treaty_rate(dec!(0.10))
1164        .with_certificate_number("CERT-2024-001");
1165
1166        assert!(wht.has_treaty_benefit());
1167        // savings: (0.30 - 0.10) * 100,000 = 20,000
1168        assert_eq!(wht.treaty_savings(), dec!(20000.00));
1169        assert_eq!(wht.withheld_amount, dec!(10000.00));
1170        assert_eq!(wht.certificate_number, Some("CERT-2024-001".to_string()));
1171    }
1172
1173    #[test]
1174    fn test_withholding_no_treaty() {
1175        let wht = WithholdingTaxRecord::new(
1176            "WHT-002",
1177            "PAY-002",
1178            "V-200",
1179            WithholdingType::ServiceWithholding,
1180            dec!(0.25),
1181            dec!(0.25),
1182            dec!(50000),
1183        );
1184
1185        assert!(!wht.has_treaty_benefit());
1186        // No savings when applied == statutory
1187        assert_eq!(wht.treaty_savings(), dec!(0.00));
1188    }
1189
1190    #[test]
1191    fn test_uncertain_tax_position() {
1192        let utp = UncertainTaxPosition::new(
1193            "UTP-001",
1194            "ENT-001",
1195            "R&D credit claim for software development",
1196            dec!(500000), // total benefit claimed
1197            dec!(0.50),   // more-likely-than-not threshold
1198            dec!(350000), // recognized
1199            TaxMeasurementMethod::MostLikelyAmount,
1200        );
1201
1202        // unrecognized: 500,000 - 350,000 = 150,000
1203        assert_eq!(utp.unrecognized_amount(), dec!(150000));
1204    }
1205
1206    #[test]
1207    fn test_jurisdiction_hierarchy() {
1208        let federal = TaxJurisdiction::new(
1209            "JUR-US",
1210            "United States - Federal",
1211            "US",
1212            JurisdictionType::Federal,
1213        );
1214        assert!(!federal.is_subnational());
1215
1216        let state = TaxJurisdiction::new("JUR-US-CA", "California", "US", JurisdictionType::State)
1217            .with_region_code("CA")
1218            .with_parent_jurisdiction_id("JUR-US");
1219        assert!(state.is_subnational());
1220        assert_eq!(state.region_code, Some("CA".to_string()));
1221        assert_eq!(state.parent_jurisdiction_id, Some("JUR-US".to_string()));
1222
1223        let local = TaxJurisdiction::new(
1224            "JUR-US-CA-SF",
1225            "San Francisco",
1226            "US",
1227            JurisdictionType::Local,
1228        )
1229        .with_parent_jurisdiction_id("JUR-US-CA");
1230        assert!(local.is_subnational());
1231
1232        let municipal = TaxJurisdiction::new(
1233            "JUR-US-NY-NYC",
1234            "New York City",
1235            "US",
1236            JurisdictionType::Municipal,
1237        )
1238        .with_parent_jurisdiction_id("JUR-US-NY");
1239        assert!(municipal.is_subnational());
1240
1241        let supra = TaxJurisdiction::new(
1242            "JUR-EU",
1243            "European Union",
1244            "EU",
1245            JurisdictionType::Supranational,
1246        );
1247        assert!(!supra.is_subnational());
1248    }
1249
1250    #[test]
1251    fn test_serde_roundtrip() {
1252        let code = TaxCode::new(
1253            "TC-SERDE",
1254            "VAT-STD-20",
1255            "Standard VAT 20%",
1256            TaxType::Vat,
1257            dec!(0.20),
1258            "JUR-UK",
1259            NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
1260        )
1261        .with_expiry_date(NaiveDate::from_ymd_opt(2025, 12, 31).unwrap())
1262        .with_reverse_charge(true);
1263
1264        let json = serde_json::to_string_pretty(&code).unwrap();
1265        let deserialized: TaxCode = serde_json::from_str(&json).unwrap();
1266
1267        assert_eq!(deserialized.id, code.id);
1268        assert_eq!(deserialized.code, code.code);
1269        assert_eq!(deserialized.rate, code.rate);
1270        assert_eq!(deserialized.tax_type, code.tax_type);
1271        assert_eq!(deserialized.is_reverse_charge, code.is_reverse_charge);
1272        assert_eq!(deserialized.effective_date, code.effective_date);
1273        assert_eq!(deserialized.expiry_date, code.expiry_date);
1274    }
1275
1276    #[test]
1277    fn test_withholding_serde_roundtrip() {
1278        // With treaty rate (Some)
1279        let wht = WithholdingTaxRecord::new(
1280            "WHT-SERDE-1",
1281            "PAY-001",
1282            "V-001",
1283            WithholdingType::RoyaltyWithholding,
1284            dec!(0.30),
1285            dec!(0.15),
1286            dec!(50000),
1287        )
1288        .with_treaty_rate(dec!(0.10));
1289
1290        let json = serde_json::to_string_pretty(&wht).unwrap();
1291        let deserialized: WithholdingTaxRecord = serde_json::from_str(&json).unwrap();
1292        assert_eq!(deserialized.treaty_rate, Some(dec!(0.10)));
1293        assert_eq!(deserialized.statutory_rate, dec!(0.30));
1294        assert_eq!(deserialized.applied_rate, dec!(0.15));
1295        assert_eq!(deserialized.base_amount, dec!(50000));
1296        assert_eq!(deserialized.withheld_amount, wht.withheld_amount);
1297
1298        // Without treaty rate (None)
1299        let wht_no_treaty = WithholdingTaxRecord::new(
1300            "WHT-SERDE-2",
1301            "PAY-002",
1302            "V-002",
1303            WithholdingType::ServiceWithholding,
1304            dec!(0.30),
1305            dec!(0.30),
1306            dec!(10000),
1307        );
1308
1309        let json2 = serde_json::to_string_pretty(&wht_no_treaty).unwrap();
1310        let deserialized2: WithholdingTaxRecord = serde_json::from_str(&json2).unwrap();
1311        assert_eq!(deserialized2.treaty_rate, None);
1312        assert_eq!(deserialized2.statutory_rate, dec!(0.30));
1313    }
1314}