Skip to main content

datasynth_core/models/subledger/ar/
credit_memo.rs

1//! AR Credit Memo model.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::models::subledger::{CurrencyAmount, GLReference, SubledgerDocumentStatus, TaxInfo};
8
9/// AR Credit Memo (reduces customer balance).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ARCreditMemo {
12    /// Unique credit memo number.
13    pub credit_memo_number: String,
14    /// Company code.
15    pub company_code: String,
16    /// Customer ID.
17    pub customer_id: String,
18    /// Customer name.
19    pub customer_name: String,
20    /// Credit memo date.
21    pub memo_date: NaiveDate,
22    /// Posting date.
23    pub posting_date: NaiveDate,
24    /// Credit memo type.
25    pub memo_type: ARCreditMemoType,
26    /// Credit memo status.
27    pub status: SubledgerDocumentStatus,
28    /// Reason code.
29    pub reason_code: CreditMemoReason,
30    /// Reason description.
31    pub reason_description: String,
32    /// Credit memo lines.
33    pub lines: Vec<ARCreditMemoLine>,
34    /// Net amount (before tax).
35    pub net_amount: CurrencyAmount,
36    /// Tax amount.
37    pub tax_amount: CurrencyAmount,
38    /// Gross amount (total credit).
39    pub gross_amount: CurrencyAmount,
40    /// Amount applied to invoices.
41    pub amount_applied: Decimal,
42    /// Amount remaining.
43    pub amount_remaining: Decimal,
44    /// Tax details.
45    pub tax_details: Vec<TaxInfo>,
46    /// Reference invoice (if applicable).
47    pub reference_invoice: Option<String>,
48    /// Reference return order.
49    pub reference_return: Option<String>,
50    /// Applied to invoices.
51    pub applied_invoices: Vec<CreditMemoApplication>,
52    /// GL reference.
53    pub gl_reference: Option<GLReference>,
54    /// Approval status.
55    pub approval_status: ApprovalStatus,
56    /// Approved by.
57    pub approved_by: Option<String>,
58    /// Approval date.
59    pub approved_date: Option<NaiveDate>,
60    /// Created timestamp.
61    pub created_at: DateTime<Utc>,
62    /// Created by user.
63    pub created_by: Option<String>,
64    /// Notes.
65    pub notes: Option<String>,
66}
67
68impl ARCreditMemo {
69    /// Creates a new credit memo.
70    #[allow(clippy::too_many_arguments)]
71    pub fn new(
72        credit_memo_number: String,
73        company_code: String,
74        customer_id: String,
75        customer_name: String,
76        memo_date: NaiveDate,
77        reason_code: CreditMemoReason,
78        reason_description: String,
79        currency: String,
80    ) -> Self {
81        Self {
82            credit_memo_number,
83            company_code,
84            customer_id,
85            customer_name,
86            memo_date,
87            posting_date: memo_date,
88            memo_type: ARCreditMemoType::Standard,
89            status: SubledgerDocumentStatus::Open,
90            reason_code,
91            reason_description,
92            lines: Vec::new(),
93            net_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
94            tax_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency.clone()),
95            gross_amount: CurrencyAmount::single_currency(Decimal::ZERO, currency),
96            amount_applied: Decimal::ZERO,
97            amount_remaining: Decimal::ZERO,
98            tax_details: Vec::new(),
99            reference_invoice: None,
100            reference_return: None,
101            applied_invoices: Vec::new(),
102            gl_reference: None,
103            approval_status: ApprovalStatus::Pending,
104            approved_by: None,
105            approved_date: None,
106            created_at: Utc::now(),
107            created_by: None,
108            notes: None,
109        }
110    }
111
112    /// Creates credit memo for a specific invoice.
113    #[allow(clippy::too_many_arguments)]
114    pub fn for_invoice(
115        credit_memo_number: String,
116        company_code: String,
117        customer_id: String,
118        customer_name: String,
119        memo_date: NaiveDate,
120        invoice_number: String,
121        reason_code: CreditMemoReason,
122        reason_description: String,
123        currency: String,
124    ) -> Self {
125        let mut memo = Self::new(
126            credit_memo_number,
127            company_code,
128            customer_id,
129            customer_name,
130            memo_date,
131            reason_code,
132            reason_description,
133            currency,
134        );
135        memo.reference_invoice = Some(invoice_number);
136        memo
137    }
138
139    /// Adds a credit memo line.
140    pub fn add_line(&mut self, line: ARCreditMemoLine) {
141        self.lines.push(line);
142        self.recalculate_totals();
143    }
144
145    /// Recalculates totals from lines.
146    pub fn recalculate_totals(&mut self) {
147        let net_total: Decimal = self.lines.iter().map(|l| l.net_amount).sum();
148        let tax_total: Decimal = self.lines.iter().map(|l| l.tax_amount).sum();
149        let gross_total = net_total + tax_total;
150
151        self.net_amount.document_amount = net_total;
152        self.net_amount.local_amount = net_total * self.net_amount.exchange_rate;
153        self.tax_amount.document_amount = tax_total;
154        self.tax_amount.local_amount = tax_total * self.tax_amount.exchange_rate;
155        self.gross_amount.document_amount = gross_total;
156        self.gross_amount.local_amount = gross_total * self.gross_amount.exchange_rate;
157        self.amount_remaining = gross_total - self.amount_applied;
158    }
159
160    /// Applies credit memo to an invoice.
161    pub fn apply_to_invoice(&mut self, invoice_number: String, amount: Decimal) {
162        let application = CreditMemoApplication {
163            invoice_number,
164            amount_applied: amount,
165            application_date: chrono::Local::now().date_naive(),
166        };
167
168        self.applied_invoices.push(application);
169        self.amount_applied += amount;
170        self.amount_remaining = self.gross_amount.document_amount - self.amount_applied;
171
172        if self.amount_remaining <= Decimal::ZERO {
173            self.status = SubledgerDocumentStatus::Cleared;
174        } else {
175            self.status = SubledgerDocumentStatus::PartiallyCleared;
176        }
177    }
178
179    /// Approves the credit memo.
180    pub fn approve(&mut self, approver: String, approval_date: NaiveDate) {
181        self.approval_status = ApprovalStatus::Approved;
182        self.approved_by = Some(approver);
183        self.approved_date = Some(approval_date);
184    }
185
186    /// Rejects the credit memo.
187    pub fn reject(&mut self, reason: String) {
188        self.approval_status = ApprovalStatus::Rejected;
189        self.notes = Some(format!(
190            "{}Rejected: {}",
191            self.notes
192                .as_ref()
193                .map(|n| format!("{}. ", n))
194                .unwrap_or_default(),
195            reason
196        ));
197    }
198
199    /// Sets the GL reference.
200    pub fn set_gl_reference(&mut self, reference: GLReference) {
201        self.gl_reference = Some(reference);
202    }
203
204    /// Sets reference return order.
205    pub fn with_return_order(mut self, return_order: String) -> Self {
206        self.reference_return = Some(return_order);
207        self.memo_type = ARCreditMemoType::Return;
208        self
209    }
210
211    /// Requires approval above threshold.
212    pub fn requires_approval(&self, threshold: Decimal) -> bool {
213        self.gross_amount.document_amount > threshold
214    }
215}
216
217/// Type of credit memo.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
219pub enum ARCreditMemoType {
220    /// Standard credit memo.
221    #[default]
222    Standard,
223    /// Return credit memo.
224    Return,
225    /// Price adjustment.
226    PriceAdjustment,
227    /// Quantity adjustment.
228    QuantityAdjustment,
229    /// Rebate/volume discount.
230    Rebate,
231    /// Promotional credit.
232    Promotional,
233    /// Cancellation credit.
234    Cancellation,
235}
236
237/// Reason code for credit memo.
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
239pub enum CreditMemoReason {
240    /// Goods returned.
241    Return,
242    /// Damaged goods.
243    Damaged,
244    /// Wrong item shipped.
245    WrongItem,
246    /// Price error.
247    PriceError,
248    /// Quantity error.
249    QuantityError,
250    /// Quality issue.
251    QualityIssue,
252    /// Late delivery.
253    LateDelivery,
254    /// Promotional discount.
255    Promotional,
256    /// Volume rebate.
257    VolumeRebate,
258    /// Customer goodwill.
259    Goodwill,
260    /// Billing error.
261    BillingError,
262    /// Contract adjustment.
263    ContractAdjustment,
264    /// Other.
265    #[default]
266    Other,
267}
268
269/// Credit memo line item.
270#[derive(Debug, Clone, Serialize, Deserialize)]
271pub struct ARCreditMemoLine {
272    /// Line number.
273    pub line_number: u32,
274    /// Material/product ID.
275    pub material_id: Option<String>,
276    /// Description.
277    pub description: String,
278    /// Quantity credited.
279    pub quantity: Decimal,
280    /// Unit of measure.
281    pub unit: String,
282    /// Unit price.
283    pub unit_price: Decimal,
284    /// Net amount.
285    pub net_amount: Decimal,
286    /// Tax code.
287    pub tax_code: Option<String>,
288    /// Tax rate.
289    pub tax_rate: Decimal,
290    /// Tax amount.
291    pub tax_amount: Decimal,
292    /// Gross amount.
293    pub gross_amount: Decimal,
294    /// Revenue account (credit).
295    pub revenue_account: String,
296    /// Reference invoice line.
297    pub reference_invoice_line: Option<u32>,
298    /// Cost center.
299    pub cost_center: Option<String>,
300    /// Profit center.
301    pub profit_center: Option<String>,
302}
303
304impl ARCreditMemoLine {
305    /// Creates a new credit memo line.
306    pub fn new(
307        line_number: u32,
308        description: String,
309        quantity: Decimal,
310        unit: String,
311        unit_price: Decimal,
312        revenue_account: String,
313    ) -> Self {
314        let net_amount = (quantity * unit_price).round_dp(2);
315        Self {
316            line_number,
317            material_id: None,
318            description,
319            quantity,
320            unit,
321            unit_price,
322            net_amount,
323            tax_code: None,
324            tax_rate: Decimal::ZERO,
325            tax_amount: Decimal::ZERO,
326            gross_amount: net_amount,
327            revenue_account,
328            reference_invoice_line: None,
329            cost_center: None,
330            profit_center: None,
331        }
332    }
333
334    /// Sets tax information.
335    pub fn with_tax(mut self, tax_code: String, tax_rate: Decimal) -> Self {
336        self.tax_code = Some(tax_code);
337        self.tax_rate = tax_rate;
338        self.tax_amount = (self.net_amount * tax_rate / rust_decimal_macros::dec!(100)).round_dp(2);
339        self.gross_amount = self.net_amount + self.tax_amount;
340        self
341    }
342
343    /// Sets reference to original invoice line.
344    pub fn with_invoice_reference(mut self, line_number: u32) -> Self {
345        self.reference_invoice_line = Some(line_number);
346        self
347    }
348}
349
350/// Application of credit memo to invoice.
351#[derive(Debug, Clone, Serialize, Deserialize)]
352pub struct CreditMemoApplication {
353    /// Invoice number.
354    pub invoice_number: String,
355    /// Amount applied.
356    pub amount_applied: Decimal,
357    /// Application date.
358    pub application_date: NaiveDate,
359}
360
361/// Approval status.
362#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
363pub enum ApprovalStatus {
364    /// Pending approval.
365    #[default]
366    Pending,
367    /// Approved.
368    Approved,
369    /// Rejected.
370    Rejected,
371    /// Not required (under threshold).
372    NotRequired,
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use rust_decimal_macros::dec;
379
380    #[test]
381    fn test_credit_memo_creation() {
382        let memo = ARCreditMemo::new(
383            "CM001".to_string(),
384            "1000".to_string(),
385            "CUST001".to_string(),
386            "Test Customer".to_string(),
387            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
388            CreditMemoReason::Return,
389            "Goods returned".to_string(),
390            "USD".to_string(),
391        );
392
393        assert_eq!(memo.status, SubledgerDocumentStatus::Open);
394        assert_eq!(memo.approval_status, ApprovalStatus::Pending);
395    }
396
397    #[test]
398    fn test_credit_memo_totals() {
399        let mut memo = ARCreditMemo::new(
400            "CM001".to_string(),
401            "1000".to_string(),
402            "CUST001".to_string(),
403            "Test Customer".to_string(),
404            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
405            CreditMemoReason::PriceError,
406            "Price correction".to_string(),
407            "USD".to_string(),
408        );
409
410        let line = ARCreditMemoLine::new(
411            1,
412            "Product A".to_string(),
413            dec!(5),
414            "EA".to_string(),
415            dec!(100),
416            "4000".to_string(),
417        )
418        .with_tax("VAT".to_string(), dec!(20));
419
420        memo.add_line(line);
421
422        assert_eq!(memo.net_amount.document_amount, dec!(500));
423        assert_eq!(memo.tax_amount.document_amount, dec!(100));
424        assert_eq!(memo.gross_amount.document_amount, dec!(600));
425    }
426
427    #[test]
428    fn test_apply_to_invoice() {
429        let mut memo = ARCreditMemo::new(
430            "CM001".to_string(),
431            "1000".to_string(),
432            "CUST001".to_string(),
433            "Test Customer".to_string(),
434            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
435            CreditMemoReason::Return,
436            "Goods returned".to_string(),
437            "USD".to_string(),
438        );
439
440        let line = ARCreditMemoLine::new(
441            1,
442            "Product A".to_string(),
443            dec!(10),
444            "EA".to_string(),
445            dec!(50),
446            "4000".to_string(),
447        );
448        memo.add_line(line);
449
450        memo.apply_to_invoice("INV001".to_string(), dec!(300));
451
452        assert_eq!(memo.amount_applied, dec!(300));
453        assert_eq!(memo.amount_remaining, dec!(200));
454        assert_eq!(memo.status, SubledgerDocumentStatus::PartiallyCleared);
455    }
456
457    #[test]
458    fn test_approval_workflow() {
459        let mut memo = ARCreditMemo::new(
460            "CM001".to_string(),
461            "1000".to_string(),
462            "CUST001".to_string(),
463            "Test Customer".to_string(),
464            NaiveDate::from_ymd_opt(2024, 2, 15).unwrap(),
465            CreditMemoReason::Return,
466            "Goods returned".to_string(),
467            "USD".to_string(),
468        );
469
470        memo.approve(
471            "MANAGER1".to_string(),
472            NaiveDate::from_ymd_opt(2024, 2, 16).unwrap(),
473        );
474
475        assert_eq!(memo.approval_status, ApprovalStatus::Approved);
476        assert_eq!(memo.approved_by, Some("MANAGER1".to_string()));
477    }
478}