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