Skip to main content

datasynth_core/models/subledger/ap/
debit_memo.rs

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