1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10pub enum SubledgerDocumentStatus {
11 #[default]
13 Open,
14 PartiallyCleared,
16 Cleared,
18 Reversed,
20 OnHold,
22 InDispute,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ClearingInfo {
29 pub clearing_document: String,
31 pub clearing_date: NaiveDate,
33 pub clearing_amount: Decimal,
35 pub clearing_type: ClearingType,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ClearingType {
42 Payment,
44 Memo,
46 WriteOff,
48 Netting,
50 Manual,
52 Reversal,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct GLReference {
59 pub journal_entry_id: String,
61 pub posting_date: NaiveDate,
63 pub gl_account: String,
65 pub amount: Decimal,
67 pub debit_credit: DebitCredit,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
73pub enum DebitCredit {
74 Debit,
75 Credit,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct TaxInfo {
81 pub tax_code: String,
83 pub tax_rate: Decimal,
85 pub tax_base: Decimal,
87 pub tax_amount: Decimal,
89 pub jurisdiction: Option<String>,
91}
92
93impl TaxInfo {
94 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 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 pub fn with_jurisdiction(mut self, jurisdiction: String) -> Self {
124 self.jurisdiction = Some(jurisdiction);
125 self
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct PaymentTerms {
132 pub terms_code: String,
134 pub description: String,
136 pub net_due_days: u32,
138 pub discount_percent: Option<Decimal>,
140 pub discount_days: Option<u32>,
142 pub discount_percent_2: Option<Decimal>,
144 pub discount_days_2: Option<u32>,
146}
147
148impl PaymentTerms {
149 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 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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct ReconciliationStatus {
232 pub company_code: String,
234 pub gl_account: String,
236 pub subledger_type: SubledgerType,
238 pub as_of_date: NaiveDate,
240 pub gl_balance: Decimal,
242 pub subledger_balance: Decimal,
244 pub difference: Decimal,
246 pub is_reconciled: bool,
248 pub reconciled_at: DateTime<Utc>,
250 pub unreconciled_items: Vec<UnreconciledItem>,
252}
253
254impl ReconciliationStatus {
255 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 pub fn add_unreconciled_item(&mut self, item: UnreconciledItem) {
284 self.unreconciled_items.push(item);
285 }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290pub enum SubledgerType {
291 AR,
293 AP,
295 FA,
297 Inventory,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
303pub struct UnreconciledItem {
304 pub document_number: String,
306 pub document_type: String,
308 pub subledger_amount: Decimal,
310 pub gl_amount: Decimal,
312 pub difference: Decimal,
314 pub reason: Option<String>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
320pub struct CurrencyAmount {
321 pub document_amount: Decimal,
323 pub document_currency: String,
325 pub local_amount: Decimal,
327 pub local_currency: String,
329 pub exchange_rate: Decimal,
331}
332
333impl CurrencyAmount {
334 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
365pub enum BaselineDateType {
366 #[default]
368 DocumentDate,
369 PostingDate,
371 EntryDate,
373 GoodsReceiptDate,
375 CustomDate,
377}
378
379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct DunningInfo {
382 pub dunning_level: u8,
384 pub max_dunning_level: u8,
386 pub last_dunning_date: Option<NaiveDate>,
388 pub last_dunning_run: Option<String>,
390 pub dunning_blocked: bool,
392 pub block_reason: Option<String>,
394}
395
396impl DunningInfo {
397 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 pub fn block(&mut self, reason: String) {
411 self.dunning_blocked = true;
412 self.block_reason = Some(reason);
413 }
414
415 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 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)); 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}