datasynth_core/models/subledger/
common.rs

1//! Common types shared across all subledgers.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7
8/// Status of a subledger document.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10pub enum SubledgerDocumentStatus {
11    /// Document is open/outstanding.
12    #[default]
13    Open,
14    /// Document is partially cleared.
15    PartiallyCleared,
16    /// Document is fully cleared.
17    Cleared,
18    /// Document is reversed/cancelled.
19    Reversed,
20    /// Document is on hold.
21    OnHold,
22    /// Document is in dispute.
23    InDispute,
24}
25
26/// Clearing information for a subledger document.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ClearingInfo {
29    /// Document that cleared this item.
30    pub clearing_document: String,
31    /// Date of clearing.
32    pub clearing_date: NaiveDate,
33    /// Amount cleared.
34    pub clearing_amount: Decimal,
35    /// Clearing type.
36    pub clearing_type: ClearingType,
37}
38
39/// Type of clearing transaction.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ClearingType {
42    /// Payment received/made.
43    Payment,
44    /// Credit/debit memo applied.
45    Memo,
46    /// Write-off.
47    WriteOff,
48    /// Netting between AR and AP.
49    Netting,
50    /// Manual clearing.
51    Manual,
52    /// Reversal.
53    Reversal,
54}
55
56/// Reference to GL posting.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct GLReference {
59    /// Journal entry ID.
60    pub journal_entry_id: String,
61    /// Posting date in GL.
62    pub posting_date: NaiveDate,
63    /// GL account code.
64    pub gl_account: String,
65    /// Amount posted to GL.
66    pub amount: Decimal,
67    /// Debit or credit indicator.
68    pub debit_credit: DebitCredit,
69}
70
71/// Debit or credit indicator.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum DebitCredit {
74    Debit,
75    Credit,
76}
77
78/// Tax information for a document.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct TaxInfo {
81    /// Tax code.
82    pub tax_code: String,
83    /// Tax rate percentage.
84    pub tax_rate: Decimal,
85    /// Tax base amount.
86    pub tax_base: Decimal,
87    /// Tax amount.
88    pub tax_amount: Decimal,
89    /// Tax jurisdiction.
90    pub jurisdiction: Option<String>,
91}
92
93impl TaxInfo {
94    /// Creates new tax info with calculated tax amount.
95    pub fn new(tax_code: String, tax_rate: Decimal, tax_base: Decimal) -> Self {
96        let tax_amount = (tax_base * tax_rate / dec!(100)).round_dp(2);
97        Self {
98            tax_code,
99            tax_rate,
100            tax_base,
101            tax_amount,
102            jurisdiction: None,
103        }
104    }
105
106    /// Creates tax info with explicit tax amount.
107    pub fn with_amount(
108        tax_code: String,
109        tax_rate: Decimal,
110        tax_base: Decimal,
111        tax_amount: Decimal,
112    ) -> Self {
113        Self {
114            tax_code,
115            tax_rate,
116            tax_base,
117            tax_amount,
118            jurisdiction: None,
119        }
120    }
121
122    /// Sets the tax jurisdiction.
123    pub fn with_jurisdiction(mut self, jurisdiction: String) -> Self {
124        self.jurisdiction = Some(jurisdiction);
125        self
126    }
127}
128
129/// Payment terms for AR/AP documents.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PaymentTerms {
132    /// Terms code (e.g., "NET30", "2/10NET30").
133    pub terms_code: String,
134    /// Description.
135    pub description: String,
136    /// Net due days from baseline date.
137    pub net_due_days: u32,
138    /// Discount percentage if paid early.
139    pub discount_percent: Option<Decimal>,
140    /// Days to qualify for discount.
141    pub discount_days: Option<u32>,
142    /// Second discount tier percentage.
143    pub discount_percent_2: Option<Decimal>,
144    /// Days for second discount tier.
145    pub discount_days_2: Option<u32>,
146}
147
148impl PaymentTerms {
149    /// Creates standard net terms.
150    pub fn net(days: u32) -> Self {
151        Self {
152            terms_code: format!("NET{}", days),
153            description: format!("Net {} days", days),
154            net_due_days: days,
155            discount_percent: None,
156            discount_days: None,
157            discount_percent_2: None,
158            discount_days_2: None,
159        }
160    }
161
162    /// Creates terms with early payment discount.
163    pub fn with_discount(net_days: u32, discount_percent: Decimal, discount_days: u32) -> Self {
164        Self {
165            terms_code: format!("{}/{}NET{}", discount_percent, discount_days, net_days),
166            description: format!(
167                "{}% discount if paid within {} days, net {} days",
168                discount_percent, discount_days, net_days
169            ),
170            net_due_days: net_days,
171            discount_percent: Some(discount_percent),
172            discount_days: Some(discount_days),
173            discount_percent_2: None,
174            discount_days_2: None,
175        }
176    }
177
178    /// Common payment terms.
179    pub fn net_30() -> Self {
180        Self::net(30)
181    }
182
183    pub fn net_60() -> Self {
184        Self::net(60)
185    }
186
187    pub fn net_90() -> Self {
188        Self::net(90)
189    }
190
191    pub fn two_ten_net_30() -> Self {
192        Self::with_discount(30, dec!(2), 10)
193    }
194
195    pub fn one_ten_net_30() -> Self {
196        Self::with_discount(30, dec!(1), 10)
197    }
198
199    /// Calculates due date from baseline date.
200    pub fn calculate_due_date(&self, baseline_date: NaiveDate) -> NaiveDate {
201        baseline_date + chrono::Duration::days(self.net_due_days as i64)
202    }
203
204    /// Calculates discount due date.
205    pub fn calculate_discount_date(&self, baseline_date: NaiveDate) -> Option<NaiveDate> {
206        self.discount_days
207            .map(|days| baseline_date + chrono::Duration::days(days as i64))
208    }
209
210    /// Calculates discount amount for a given base amount.
211    pub fn calculate_discount(
212        &self,
213        base_amount: Decimal,
214        payment_date: NaiveDate,
215        baseline_date: NaiveDate,
216    ) -> Decimal {
217        if let (Some(discount_percent), Some(discount_days)) =
218            (self.discount_percent, self.discount_days)
219        {
220            let discount_deadline = baseline_date + chrono::Duration::days(discount_days as i64);
221            if payment_date <= discount_deadline {
222                return (base_amount * discount_percent / dec!(100)).round_dp(2);
223            }
224        }
225        Decimal::ZERO
226    }
227}
228
229/// Reconciliation status between subledger and GL.
230#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ReconciliationStatus {
232    /// Company code.
233    pub company_code: String,
234    /// GL control account.
235    pub gl_account: String,
236    /// Subledger type.
237    pub subledger_type: SubledgerType,
238    /// As-of date.
239    pub as_of_date: NaiveDate,
240    /// GL balance.
241    pub gl_balance: Decimal,
242    /// Subledger balance.
243    pub subledger_balance: Decimal,
244    /// Difference.
245    pub difference: Decimal,
246    /// Is reconciled (within tolerance).
247    pub is_reconciled: bool,
248    /// Reconciliation timestamp.
249    pub reconciled_at: DateTime<Utc>,
250    /// Unreconciled items.
251    pub unreconciled_items: Vec<UnreconciledItem>,
252}
253
254impl ReconciliationStatus {
255    /// Creates new reconciliation status.
256    pub fn new(
257        company_code: String,
258        gl_account: String,
259        subledger_type: SubledgerType,
260        as_of_date: NaiveDate,
261        gl_balance: Decimal,
262        subledger_balance: Decimal,
263        tolerance: Decimal,
264    ) -> Self {
265        let difference = gl_balance - subledger_balance;
266        let is_reconciled = difference.abs() <= tolerance;
267
268        Self {
269            company_code,
270            gl_account,
271            subledger_type,
272            as_of_date,
273            gl_balance,
274            subledger_balance,
275            difference,
276            is_reconciled,
277            reconciled_at: Utc::now(),
278            unreconciled_items: Vec::new(),
279        }
280    }
281
282    /// Adds an unreconciled item.
283    pub fn add_unreconciled_item(&mut self, item: UnreconciledItem) {
284        self.unreconciled_items.push(item);
285    }
286}
287
288/// Type of subledger.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum SubledgerType {
291    /// Accounts Receivable.
292    AR,
293    /// Accounts Payable.
294    AP,
295    /// Fixed Assets.
296    FA,
297    /// Inventory.
298    Inventory,
299}
300
301/// An item that doesn't reconcile.
302#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct UnreconciledItem {
304    /// Document number.
305    pub document_number: String,
306    /// Document type.
307    pub document_type: String,
308    /// Amount in subledger.
309    pub subledger_amount: Decimal,
310    /// Amount in GL.
311    pub gl_amount: Decimal,
312    /// Difference.
313    pub difference: Decimal,
314    /// Reason for discrepancy.
315    pub reason: Option<String>,
316}
317
318/// Currency amount with original and local currency.
319#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct CurrencyAmount {
321    /// Amount in document currency.
322    pub document_amount: Decimal,
323    /// Document currency code.
324    pub document_currency: String,
325    /// Amount in local currency.
326    pub local_amount: Decimal,
327    /// Local currency code.
328    pub local_currency: String,
329    /// Exchange rate used.
330    pub exchange_rate: Decimal,
331}
332
333impl CurrencyAmount {
334    /// Creates amount in single currency.
335    pub fn single_currency(amount: Decimal, currency: String) -> Self {
336        Self {
337            document_amount: amount,
338            document_currency: currency.clone(),
339            local_amount: amount,
340            local_currency: currency,
341            exchange_rate: Decimal::ONE,
342        }
343    }
344
345    /// Creates amount with currency conversion.
346    pub fn with_conversion(
347        document_amount: Decimal,
348        document_currency: String,
349        local_currency: String,
350        exchange_rate: Decimal,
351    ) -> Self {
352        let local_amount = (document_amount * exchange_rate).round_dp(2);
353        Self {
354            document_amount,
355            document_currency,
356            local_amount,
357            local_currency,
358            exchange_rate,
359        }
360    }
361}
362
363/// Baseline date type for payment terms.
364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
365pub enum BaselineDateType {
366    /// Document date.
367    #[default]
368    DocumentDate,
369    /// Posting date.
370    PostingDate,
371    /// Entry date.
372    EntryDate,
373    /// Goods receipt date.
374    GoodsReceiptDate,
375    /// Custom date.
376    CustomDate,
377}
378
379/// Dunning information for AR items.
380#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct DunningInfo {
382    /// Current dunning level (0 = not dunned).
383    pub dunning_level: u8,
384    /// Maximum dunning level reached.
385    pub max_dunning_level: u8,
386    /// Last dunning date.
387    pub last_dunning_date: Option<NaiveDate>,
388    /// Last dunning run ID.
389    pub last_dunning_run: Option<String>,
390    /// Is blocked for dunning.
391    pub dunning_blocked: bool,
392    /// Block reason.
393    pub block_reason: Option<String>,
394}
395
396impl DunningInfo {
397    /// Advances to next dunning level.
398    pub fn advance_level(&mut self, dunning_date: NaiveDate, run_id: String) {
399        if !self.dunning_blocked {
400            self.dunning_level += 1;
401            if self.dunning_level > self.max_dunning_level {
402                self.max_dunning_level = self.dunning_level;
403            }
404            self.last_dunning_date = Some(dunning_date);
405            self.last_dunning_run = Some(run_id);
406        }
407    }
408
409    /// Blocks dunning.
410    pub fn block(&mut self, reason: String) {
411        self.dunning_blocked = true;
412        self.block_reason = Some(reason);
413    }
414
415    /// Unblocks dunning.
416    pub fn unblock(&mut self) {
417        self.dunning_blocked = false;
418        self.block_reason = None;
419    }
420}
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    #[test]
427    fn test_payment_terms_due_date() {
428        let terms = PaymentTerms::net_30();
429        let baseline = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
430        let due_date = terms.calculate_due_date(baseline);
431        assert_eq!(due_date, NaiveDate::from_ymd_opt(2024, 2, 14).unwrap());
432    }
433
434    #[test]
435    fn test_payment_terms_discount() {
436        let terms = PaymentTerms::two_ten_net_30();
437        let baseline = NaiveDate::from_ymd_opt(2024, 1, 15).unwrap();
438        let amount = dec!(1000);
439
440        // Payment within discount period
441        let early_payment = NaiveDate::from_ymd_opt(2024, 1, 20).unwrap();
442        let discount = terms.calculate_discount(amount, early_payment, baseline);
443        assert_eq!(discount, dec!(20)); // 2% of 1000
444
445        // Payment after discount period
446        let late_payment = NaiveDate::from_ymd_opt(2024, 2, 1).unwrap();
447        let no_discount = terms.calculate_discount(amount, late_payment, baseline);
448        assert_eq!(no_discount, Decimal::ZERO);
449    }
450
451    #[test]
452    fn test_tax_info() {
453        let tax = TaxInfo::new("VAT".to_string(), dec!(20), dec!(1000));
454        assert_eq!(tax.tax_amount, dec!(200));
455    }
456
457    #[test]
458    fn test_currency_conversion() {
459        let amount = CurrencyAmount::with_conversion(
460            dec!(1000),
461            "EUR".to_string(),
462            "USD".to_string(),
463            dec!(1.10),
464        );
465        assert_eq!(amount.local_amount, dec!(1100));
466    }
467}