1use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use rust_decimal_macros::dec;
6use serde::{Deserialize, Serialize};
7
8use crate::models::subledger::{
9 ClearingInfo, CurrencyAmount, DunningInfo, GLReference, PaymentTerms, SubledgerDocumentStatus,
10 TaxInfo,
11};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct ARInvoice {
16 pub invoice_number: String,
18 pub company_code: String,
20 pub customer_id: String,
22 pub customer_name: String,
24 pub invoice_date: NaiveDate,
26 pub posting_date: NaiveDate,
28 pub due_date: NaiveDate,
30 pub baseline_date: NaiveDate,
32 pub invoice_type: ARInvoiceType,
34 pub status: SubledgerDocumentStatus,
36 pub lines: Vec<ARInvoiceLine>,
38 pub net_amount: CurrencyAmount,
40 pub tax_amount: CurrencyAmount,
42 pub gross_amount: CurrencyAmount,
44 pub amount_paid: Decimal,
46 pub amount_remaining: Decimal,
48 pub payment_terms: PaymentTerms,
50 pub tax_details: Vec<TaxInfo>,
52 pub gl_reference: Option<GLReference>,
54 pub clearing_info: Vec<ClearingInfo>,
56 pub dunning_info: DunningInfo,
58 pub reference_documents: Vec<ARDocumentReference>,
60 pub cost_center: Option<String>,
62 pub profit_center: Option<String>,
64 pub sales_org: Option<String>,
66 pub distribution_channel: Option<String>,
68 pub division: Option<String>,
70 #[serde(with = "crate::serde_timestamp::utc")]
72 pub created_at: DateTime<Utc>,
73 pub created_by: Option<String>,
75 #[serde(default, with = "crate::serde_timestamp::utc::option")]
77 pub modified_at: Option<DateTime<Utc>>,
78 pub notes: Option<String>,
80}
81
82impl ARInvoice {
83 pub fn new(
85 invoice_number: String,
86 company_code: String,
87 customer_id: String,
88 customer_name: String,
89 invoice_date: NaiveDate,
90 payment_terms: PaymentTerms,
91 currency: String,
92 ) -> Self {
93 let due_date = payment_terms.calculate_due_date(invoice_date);
94
95 Self {
96 invoice_number,
97 company_code,
98 customer_id,
99 customer_name,
100 invoice_date,
101 posting_date: invoice_date,
102 due_date,
103 baseline_date: invoice_date,
104 invoice_type: ARInvoiceType::Standard,
105 status: SubledgerDocumentStatus::Open,
106 lines: Vec::new(),
107 net_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
108 tax_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
109 gross_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency),
110 amount_paid: Decimal::ZERO,
111 amount_remaining: Decimal::ZERO,
112 payment_terms,
113 tax_details: Vec::new(),
114 gl_reference: None,
115 clearing_info: Vec::new(),
116 dunning_info: DunningInfo::default(),
117 reference_documents: Vec::new(),
118 cost_center: None,
119 profit_center: None,
120 sales_org: None,
121 distribution_channel: None,
122 division: None,
123 created_at: Utc::now(),
124 created_by: None,
125 modified_at: None,
126 notes: None,
127 }
128 }
129
130 pub fn add_line(&mut self, line: ARInvoiceLine) {
132 self.lines.push(line);
133 self.recalculate_totals();
134 }
135
136 pub fn recalculate_totals(&mut self) {
138 let net_total: Decimal = self.lines.iter().map(|l| l.net_amount).sum();
139 let tax_total: Decimal = self.lines.iter().map(|l| l.tax_amount).sum();
140 let gross_total = net_total + tax_total;
141
142 self.net_amount.document_amount = net_total;
143 self.net_amount.local_amount = net_total * self.net_amount.exchange_rate;
144 self.tax_amount.document_amount = tax_total;
145 self.tax_amount.local_amount = tax_total * self.tax_amount.exchange_rate;
146 self.gross_amount.document_amount = gross_total;
147 self.gross_amount.local_amount = gross_total * self.gross_amount.exchange_rate;
148 self.amount_remaining = gross_total - self.amount_paid;
149 }
150
151 pub fn apply_payment(&mut self, amount: Decimal, clearing: ClearingInfo) {
153 self.amount_paid += amount;
154 self.amount_remaining = self.gross_amount.document_amount - self.amount_paid;
155 self.clearing_info.push(clearing);
156
157 self.status = if self.amount_remaining <= Decimal::ZERO {
158 SubledgerDocumentStatus::Cleared
159 } else {
160 SubledgerDocumentStatus::PartiallyCleared
161 };
162
163 self.modified_at = Some(Utc::now());
164 }
165
166 pub fn is_overdue(&self, as_of_date: NaiveDate) -> bool {
168 self.status == SubledgerDocumentStatus::Open && as_of_date > self.due_date
169 }
170
171 pub fn days_overdue(&self, as_of_date: NaiveDate) -> i64 {
173 if self.is_overdue(as_of_date) {
174 (as_of_date - self.due_date).num_days()
175 } else {
176 0
177 }
178 }
179
180 pub fn available_discount(&self, payment_date: NaiveDate) -> Decimal {
182 self.payment_terms.calculate_discount(
183 self.gross_amount.document_amount,
184 payment_date,
185 self.baseline_date,
186 )
187 }
188
189 pub fn set_gl_reference(&mut self, reference: GLReference) {
191 self.gl_reference = Some(reference);
192 }
193
194 pub fn add_reference(&mut self, reference: ARDocumentReference) {
196 self.reference_documents.push(reference);
197 }
198
199 pub fn reverse(&mut self, reversal_date: NaiveDate, reason: String) {
201 self.status = SubledgerDocumentStatus::Reversed;
202 self.notes = Some(format!(
203 "{}Reversed on {}: {}",
204 self.notes
205 .as_ref()
206 .map(|n| format!("{n}. "))
207 .unwrap_or_default(),
208 reversal_date,
209 reason
210 ));
211 self.modified_at = Some(Utc::now());
212 }
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
217pub enum ARInvoiceType {
218 #[default]
220 Standard,
221 DownPaymentRequest,
223 Recurring,
225 CreditInvoice,
227 DebitInvoice,
229 ProForma,
231 Intercompany,
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct ARInvoiceLine {
238 pub line_number: u32,
240 pub material_id: Option<String>,
242 pub description: String,
244 pub quantity: Decimal,
246 pub unit: String,
248 pub unit_price: Decimal,
250 pub net_amount: Decimal,
252 pub tax_code: Option<String>,
254 pub tax_rate: Decimal,
256 pub tax_amount: Decimal,
258 pub gross_amount: Decimal,
260 pub revenue_account: String,
262 pub cost_center: Option<String>,
264 pub profit_center: Option<String>,
266 pub reference: Option<String>,
268}
269
270impl ARInvoiceLine {
271 pub fn new(
273 line_number: u32,
274 description: String,
275 quantity: Decimal,
276 unit: String,
277 unit_price: Decimal,
278 revenue_account: String,
279 ) -> Self {
280 let net_amount = (quantity * unit_price).round_dp(2);
281 Self {
282 line_number,
283 material_id: None,
284 description,
285 quantity,
286 unit,
287 unit_price,
288 net_amount,
289 tax_code: None,
290 tax_rate: Decimal::ZERO,
291 tax_amount: Decimal::ZERO,
292 gross_amount: net_amount,
293 revenue_account,
294 cost_center: None,
295 profit_center: None,
296 reference: None,
297 }
298 }
299
300 pub fn with_tax(mut self, tax_code: String, tax_rate: Decimal) -> Self {
302 self.tax_code = Some(tax_code);
303 self.tax_rate = tax_rate;
304 self.tax_amount = (self.net_amount * tax_rate / dec!(100)).round_dp(2);
305 self.gross_amount = self.net_amount + self.tax_amount;
306 self
307 }
308
309 pub fn with_material(mut self, material_id: String) -> Self {
311 self.material_id = Some(material_id);
312 self
313 }
314
315 pub fn with_cost_center(mut self, cost_center: String, profit_center: Option<String>) -> Self {
317 self.cost_center = Some(cost_center);
318 self.profit_center = profit_center;
319 self
320 }
321}
322
323#[derive(Debug, Clone, Serialize, Deserialize)]
325pub struct ARDocumentReference {
326 pub document_type: ARReferenceDocType,
328 pub document_number: String,
330 pub document_date: NaiveDate,
332}
333
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
336pub enum ARReferenceDocType {
337 SalesOrder,
339 Delivery,
341 Contract,
343 Quotation,
345 ReturnOrder,
347}
348
349#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct CustomerARSummary {
352 pub customer_id: String,
354 pub customer_name: String,
356 pub total_open: Decimal,
358 pub total_overdue: Decimal,
360 pub open_invoice_count: u32,
362 pub overdue_invoice_count: u32,
364 pub oldest_open_date: Option<NaiveDate>,
366 pub credit_limit: Option<Decimal>,
368 pub credit_utilization: Option<Decimal>,
370 pub payment_score: Option<Decimal>,
372}
373
374impl CustomerARSummary {
375 pub fn from_invoices(
377 customer_id: String,
378 customer_name: String,
379 invoices: &[ARInvoice],
380 as_of_date: NaiveDate,
381 credit_limit: Option<Decimal>,
382 ) -> Self {
383 let open_invoices: Vec<_> = invoices
384 .iter()
385 .filter(|i| {
386 i.customer_id == customer_id
387 && matches!(
388 i.status,
389 SubledgerDocumentStatus::Open | SubledgerDocumentStatus::PartiallyCleared
390 )
391 })
392 .collect();
393
394 let total_open: Decimal = open_invoices.iter().map(|i| i.amount_remaining).sum();
395 let overdue_invoices: Vec<_> = open_invoices
396 .iter()
397 .filter(|i| i.is_overdue(as_of_date))
398 .collect();
399 let total_overdue: Decimal = overdue_invoices.iter().map(|i| i.amount_remaining).sum();
400
401 let oldest_open_date = open_invoices.iter().map(|i| i.invoice_date).min();
402
403 let credit_utilization = credit_limit.map(|limit| {
404 if limit > Decimal::ZERO {
405 (total_open / limit * dec!(100)).round_dp(2)
406 } else {
407 Decimal::ZERO
408 }
409 });
410
411 Self {
412 customer_id,
413 customer_name,
414 total_open,
415 total_overdue,
416 open_invoice_count: open_invoices.len() as u32,
417 overdue_invoice_count: overdue_invoices.len() as u32,
418 oldest_open_date,
419 credit_limit,
420 credit_utilization,
421 payment_score: None,
422 }
423 }
424}
425
426#[cfg(test)]
427#[allow(clippy::unwrap_used)]
428mod tests {
429 use super::*;
430
431 fn create_test_invoice() -> ARInvoice {
432 let mut invoice = ARInvoice::new(
433 "INV001".to_string(),
434 "1000".to_string(),
435 "CUST001".to_string(),
436 "Test Customer".to_string(),
437 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
438 PaymentTerms::net_30(),
439 "USD".to_string(),
440 );
441
442 let line = ARInvoiceLine::new(
443 1,
444 "Product A".to_string(),
445 dec!(10),
446 "EA".to_string(),
447 dec!(100),
448 "4000".to_string(),
449 )
450 .with_tax("VAT".to_string(), dec!(20));
451
452 invoice.add_line(line);
453 invoice
454 }
455
456 #[test]
457 fn test_invoice_totals() {
458 let invoice = create_test_invoice();
459 assert_eq!(invoice.net_amount.document_amount, dec!(1000));
460 assert_eq!(invoice.tax_amount.document_amount, dec!(200));
461 assert_eq!(invoice.gross_amount.document_amount, dec!(1200));
462 }
463
464 #[test]
465 fn test_invoice_due_date() {
466 let invoice = create_test_invoice();
467 assert_eq!(
468 invoice.due_date,
469 NaiveDate::from_ymd_opt(2024, 2, 14).unwrap()
470 );
471 }
472
473 #[test]
474 fn test_invoice_overdue() {
475 let invoice = create_test_invoice();
476 let before_due = NaiveDate::from_ymd_opt(2024, 2, 10).unwrap();
477 let after_due = NaiveDate::from_ymd_opt(2024, 2, 20).unwrap();
478
479 assert!(!invoice.is_overdue(before_due));
480 assert!(invoice.is_overdue(after_due));
481 assert_eq!(invoice.days_overdue(after_due), 6);
482 }
483
484 #[test]
485 fn test_apply_payment() {
486 let mut invoice = create_test_invoice();
487 let clearing = ClearingInfo {
488 clearing_document: "PAY001".to_string(),
489 clearing_date: NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
490 clearing_amount: dec!(600),
491 clearing_type: crate::models::subledger::ClearingType::Payment,
492 };
493
494 invoice.apply_payment(dec!(600), clearing);
495 assert_eq!(invoice.amount_paid, dec!(600));
496 assert_eq!(invoice.amount_remaining, dec!(600));
497 assert_eq!(invoice.status, SubledgerDocumentStatus::PartiallyCleared);
498 }
499}