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