Skip to main content

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!("{discount_percent}/{discount_days}NET{net_days}"),
166            description: format!(
167                "{discount_percent}% discount if paid within {discount_days} days, net {net_days} days"
168            ),
169            net_due_days: net_days,
170            discount_percent: Some(discount_percent),
171            discount_days: Some(discount_days),
172            discount_percent_2: None,
173            discount_days_2: None,
174        }
175    }
176
177    /// Common payment terms.
178    pub fn net_30() -> Self {
179        Self::net(30)
180    }
181
182    pub fn net_60() -> Self {
183        Self::net(60)
184    }
185
186    pub fn net_90() -> Self {
187        Self::net(90)
188    }
189
190    pub fn two_ten_net_30() -> Self {
191        Self::with_discount(30, dec!(2), 10)
192    }
193
194    pub fn one_ten_net_30() -> Self {
195        Self::with_discount(30, dec!(1), 10)
196    }
197
198    /// Calculates due date from baseline date.
199    pub fn calculate_due_date(&self, baseline_date: NaiveDate) -> NaiveDate {
200        baseline_date + chrono::Duration::days(self.net_due_days as i64)
201    }
202
203    /// Calculates discount due date.
204    pub fn calculate_discount_date(&self, baseline_date: NaiveDate) -> Option<NaiveDate> {
205        self.discount_days
206            .map(|days| baseline_date + chrono::Duration::days(days as i64))
207    }
208
209    /// Calculates discount amount for a given base amount.
210    pub fn calculate_discount(
211        &self,
212        base_amount: Decimal,
213        payment_date: NaiveDate,
214        baseline_date: NaiveDate,
215    ) -> Decimal {
216        if let (Some(discount_percent), Some(discount_days)) =
217            (self.discount_percent, self.discount_days)
218        {
219            let discount_deadline = baseline_date + chrono::Duration::days(discount_days as i64);
220            if payment_date <= discount_deadline {
221                return (base_amount * discount_percent / dec!(100)).round_dp(2);
222            }
223        }
224        Decimal::ZERO
225    }
226}
227
228/// Reconciliation status between subledger and GL.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ReconciliationStatus {
231    /// Company code.
232    pub company_code: String,
233    /// GL control account.
234    pub gl_account: String,
235    /// Subledger type.
236    pub subledger_type: SubledgerType,
237    /// As-of date.
238    pub as_of_date: NaiveDate,
239    /// GL balance.
240    pub gl_balance: Decimal,
241    /// Subledger balance.
242    pub subledger_balance: Decimal,
243    /// Difference.
244    pub difference: Decimal,
245    /// Is reconciled (within tolerance).
246    pub is_reconciled: bool,
247    /// Reconciliation timestamp.
248    pub reconciled_at: DateTime<Utc>,
249    /// Unreconciled items.
250    pub unreconciled_items: Vec<UnreconciledItem>,
251}
252
253impl ReconciliationStatus {
254    /// Creates new reconciliation status.
255    pub fn new(
256        company_code: String,
257        gl_account: String,
258        subledger_type: SubledgerType,
259        as_of_date: NaiveDate,
260        gl_balance: Decimal,
261        subledger_balance: Decimal,
262        tolerance: Decimal,
263    ) -> Self {
264        let difference = gl_balance - subledger_balance;
265        let is_reconciled = difference.abs() <= tolerance;
266
267        Self {
268            company_code,
269            gl_account,
270            subledger_type,
271            as_of_date,
272            gl_balance,
273            subledger_balance,
274            difference,
275            is_reconciled,
276            reconciled_at: Utc::now(),
277            unreconciled_items: Vec::new(),
278        }
279    }
280
281    /// Adds an unreconciled item.
282    pub fn add_unreconciled_item(&mut self, item: UnreconciledItem) {
283        self.unreconciled_items.push(item);
284    }
285}
286
287/// Type of subledger.
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289pub enum SubledgerType {
290    /// Accounts Receivable.
291    AR,
292    /// Accounts Payable.
293    AP,
294    /// Fixed Assets.
295    FA,
296    /// Inventory.
297    Inventory,
298}
299
300/// An item that doesn't reconcile.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct UnreconciledItem {
303    /// Document number.
304    pub document_number: String,
305    /// Document type.
306    pub document_type: String,
307    /// Amount in subledger.
308    pub subledger_amount: Decimal,
309    /// Amount in GL.
310    pub gl_amount: Decimal,
311    /// Difference.
312    pub difference: Decimal,
313    /// Reason for discrepancy.
314    pub reason: Option<String>,
315}
316
317/// Currency amount with original and local currency.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct CurrencyAmount {
320    /// Amount in document currency.
321    pub document_amount: Decimal,
322    /// Document currency code.
323    pub document_currency: String,
324    /// Amount in local currency.
325    pub local_amount: Decimal,
326    /// Local currency code.
327    pub local_currency: String,
328    /// Exchange rate used.
329    pub exchange_rate: Decimal,
330}
331
332impl CurrencyAmount {
333    /// Creates amount in single currency.
334    pub fn single_currency(amount: Decimal, currency: String) -> Self {
335        Self {
336            document_amount: amount,
337            document_currency: currency.clone(),
338            local_amount: amount,
339            local_currency: currency,
340            exchange_rate: Decimal::ONE,
341        }
342    }
343
344    /// Creates amount with currency conversion.
345    pub fn with_conversion(
346        document_amount: Decimal,
347        document_currency: String,
348        local_currency: String,
349        exchange_rate: Decimal,
350    ) -> Self {
351        let local_amount = (document_amount * exchange_rate).round_dp(2);
352        Self {
353            document_amount,
354            document_currency,
355            local_amount,
356            local_currency,
357            exchange_rate,
358        }
359    }
360}
361
362/// Baseline date type for payment terms.
363#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
364pub enum BaselineDateType {
365    /// Document date.
366    #[default]
367    DocumentDate,
368    /// Posting date.
369    PostingDate,
370    /// Entry date.
371    EntryDate,
372    /// Goods receipt date.
373    GoodsReceiptDate,
374    /// Custom date.
375    CustomDate,
376}
377
378/// Dunning information for AR items.
379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
380pub struct DunningInfo {
381    /// Current dunning level (0 = not dunned).
382    pub dunning_level: u8,
383    /// Maximum dunning level reached.
384    pub max_dunning_level: u8,
385    /// Last dunning date.
386    pub last_dunning_date: Option<NaiveDate>,
387    /// Last dunning run ID.
388    pub last_dunning_run: Option<String>,
389    /// Is blocked for dunning.
390    pub dunning_blocked: bool,
391    /// Block reason.
392    pub block_reason: Option<String>,
393}
394
395impl DunningInfo {
396    /// Advances to next dunning level.
397    pub fn advance_level(&mut self, dunning_date: NaiveDate, run_id: String) {
398        if !self.dunning_blocked {
399            self.dunning_level += 1;
400            if self.dunning_level > self.max_dunning_level {
401                self.max_dunning_level = self.dunning_level;
402            }
403            self.last_dunning_date = Some(dunning_date);
404            self.last_dunning_run = Some(run_id);
405        }
406    }
407
408    /// Blocks dunning.
409    pub fn block(&mut self, reason: String) {
410        self.dunning_blocked = true;
411        self.block_reason = Some(reason);
412    }
413
414    /// Unblocks dunning.
415    pub fn unblock(&mut self) {
416        self.dunning_blocked = false;
417        self.block_reason = None;
418    }
419}
420
421#[cfg(test)]
422#[allow(clippy::unwrap_used)]
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}