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!("{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 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 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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct ReconciliationStatus {
231 pub company_code: String,
233 pub gl_account: String,
235 pub subledger_type: SubledgerType,
237 pub as_of_date: NaiveDate,
239 pub gl_balance: Decimal,
241 pub subledger_balance: Decimal,
243 pub difference: Decimal,
245 pub is_reconciled: bool,
247 pub reconciled_at: DateTime<Utc>,
249 pub unreconciled_items: Vec<UnreconciledItem>,
251}
252
253impl ReconciliationStatus {
254 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 pub fn add_unreconciled_item(&mut self, item: UnreconciledItem) {
283 self.unreconciled_items.push(item);
284 }
285}
286
287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
289pub enum SubledgerType {
290 AR,
292 AP,
294 FA,
296 Inventory,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct UnreconciledItem {
303 pub document_number: String,
305 pub document_type: String,
307 pub subledger_amount: Decimal,
309 pub gl_amount: Decimal,
311 pub difference: Decimal,
313 pub reason: Option<String>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct CurrencyAmount {
320 pub document_amount: Decimal,
322 pub document_currency: String,
324 pub local_amount: Decimal,
326 pub local_currency: String,
328 pub exchange_rate: Decimal,
330}
331
332impl CurrencyAmount {
333 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
364pub enum BaselineDateType {
365 #[default]
367 DocumentDate,
368 PostingDate,
370 EntryDate,
372 GoodsReceiptDate,
374 CustomDate,
376}
377
378#[derive(Debug, Clone, Default, Serialize, Deserialize)]
380pub struct DunningInfo {
381 pub dunning_level: u8,
383 pub max_dunning_level: u8,
385 pub last_dunning_date: Option<NaiveDate>,
387 pub last_dunning_run: Option<String>,
389 pub dunning_blocked: bool,
391 pub block_reason: Option<String>,
393}
394
395impl DunningInfo {
396 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 pub fn block(&mut self, reason: String) {
410 self.dunning_blocked = true;
411 self.block_reason = Some(reason);
412 }
413
414 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 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}