Skip to main content

datasynth_core/models/subledger/ar/
receipt.rs

1//! AR Receipt (customer payment) model.
2
3use chrono::{DateTime, NaiveDate, Utc};
4use rust_decimal::Decimal;
5use serde::{Deserialize, Serialize};
6
7use crate::models::subledger::{CurrencyAmount, GLReference, SubledgerDocumentStatus};
8
9/// AR Receipt (payment from customer).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ARReceipt {
12    /// Unique receipt number.
13    pub receipt_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    /// Receipt date.
21    pub receipt_date: NaiveDate,
22    /// Posting date.
23    pub posting_date: NaiveDate,
24    /// Value date (bank value date).
25    pub value_date: NaiveDate,
26    /// Receipt type.
27    pub receipt_type: ARReceiptType,
28    /// Receipt status.
29    pub status: SubledgerDocumentStatus,
30    /// Receipt amount.
31    pub amount: CurrencyAmount,
32    /// Bank charges deducted.
33    pub bank_charges: Decimal,
34    /// Discount taken.
35    pub discount_taken: Decimal,
36    /// Write-off amount.
37    pub write_off_amount: Decimal,
38    /// Net amount applied to invoices.
39    pub net_applied: Decimal,
40    /// Unapplied amount.
41    pub unapplied_amount: Decimal,
42    /// Payment method.
43    pub payment_method: PaymentMethod,
44    /// Bank account.
45    pub bank_account: String,
46    /// Bank reference.
47    pub bank_reference: Option<String>,
48    /// Check number (if check payment).
49    pub check_number: Option<String>,
50    /// Applied invoices.
51    pub applied_invoices: Vec<ReceiptApplication>,
52    /// GL references.
53    pub gl_references: Vec<GLReference>,
54    /// Created timestamp.
55    pub created_at: DateTime<Utc>,
56    /// Created by user.
57    pub created_by: Option<String>,
58    /// Notes.
59    pub notes: Option<String>,
60}
61
62impl ARReceipt {
63    /// Creates a new AR receipt.
64    #[allow(clippy::too_many_arguments)]
65    pub fn new(
66        receipt_number: String,
67        company_code: String,
68        customer_id: String,
69        customer_name: String,
70        receipt_date: NaiveDate,
71        amount: Decimal,
72        currency: String,
73        payment_method: PaymentMethod,
74        bank_account: String,
75    ) -> Self {
76        Self {
77            receipt_number,
78            company_code,
79            customer_id,
80            customer_name,
81            receipt_date,
82            posting_date: receipt_date,
83            value_date: receipt_date,
84            receipt_type: ARReceiptType::Standard,
85            status: SubledgerDocumentStatus::Open,
86            amount: CurrencyAmount::single_currency(amount, currency),
87            bank_charges: Decimal::ZERO,
88            discount_taken: Decimal::ZERO,
89            write_off_amount: Decimal::ZERO,
90            net_applied: Decimal::ZERO,
91            unapplied_amount: amount,
92            payment_method,
93            bank_account,
94            bank_reference: None,
95            check_number: None,
96            applied_invoices: Vec::new(),
97            gl_references: Vec::new(),
98            created_at: Utc::now(),
99            created_by: None,
100            notes: None,
101        }
102    }
103
104    /// Applies receipt to an invoice.
105    pub fn apply_to_invoice(
106        &mut self,
107        invoice_number: String,
108        amount_applied: Decimal,
109        discount: Decimal,
110    ) {
111        let application = ReceiptApplication {
112            invoice_number,
113            amount_applied,
114            discount_taken: discount,
115            write_off: Decimal::ZERO,
116            application_date: self.receipt_date,
117        };
118
119        self.applied_invoices.push(application);
120        self.net_applied += amount_applied;
121        self.discount_taken += discount;
122        self.unapplied_amount = self.amount.document_amount - self.net_applied;
123
124        if self.unapplied_amount <= Decimal::ZERO {
125            self.status = SubledgerDocumentStatus::Cleared;
126        }
127    }
128
129    /// Sets bank charges.
130    pub fn with_bank_charges(mut self, charges: Decimal) -> Self {
131        self.bank_charges = charges;
132        self.unapplied_amount -= charges;
133        self
134    }
135
136    /// Sets check number.
137    pub fn with_check(mut self, check_number: String) -> Self {
138        self.check_number = Some(check_number);
139        self.payment_method = PaymentMethod::Check;
140        self
141    }
142
143    /// Sets bank reference.
144    pub fn with_bank_reference(mut self, reference: String) -> Self {
145        self.bank_reference = Some(reference);
146        self
147    }
148
149    /// Adds a GL reference.
150    pub fn add_gl_reference(&mut self, reference: GLReference) {
151        self.gl_references.push(reference);
152    }
153
154    /// Gets total amount including discount.
155    pub fn total_settlement(&self) -> Decimal {
156        self.net_applied + self.discount_taken + self.write_off_amount
157    }
158
159    /// Reverses the receipt.
160    pub fn reverse(&mut self, reason: String) {
161        self.status = SubledgerDocumentStatus::Reversed;
162        self.notes = Some(format!(
163            "{}Reversed: {}",
164            self.notes
165                .as_ref()
166                .map(|n| format!("{}. ", n))
167                .unwrap_or_default(),
168            reason
169        ));
170    }
171
172    /// Creates on-account receipt (not applied to specific invoices).
173    #[allow(clippy::too_many_arguments)]
174    pub fn on_account(
175        receipt_number: String,
176        company_code: String,
177        customer_id: String,
178        customer_name: String,
179        receipt_date: NaiveDate,
180        amount: Decimal,
181        currency: String,
182        payment_method: PaymentMethod,
183        bank_account: String,
184    ) -> Self {
185        let mut receipt = Self::new(
186            receipt_number,
187            company_code,
188            customer_id,
189            customer_name,
190            receipt_date,
191            amount,
192            currency,
193            payment_method,
194            bank_account,
195        );
196        receipt.receipt_type = ARReceiptType::OnAccount;
197        receipt
198    }
199}
200
201/// Type of AR receipt.
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
203pub enum ARReceiptType {
204    /// Standard receipt applied to invoices.
205    #[default]
206    Standard,
207    /// On-account receipt (unapplied).
208    OnAccount,
209    /// Down payment receipt.
210    DownPayment,
211    /// Refund (negative receipt).
212    Refund,
213    /// Write-off receipt.
214    WriteOff,
215    /// Netting (AR against AP).
216    Netting,
217}
218
219/// Payment method for receipts.
220#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
221pub enum PaymentMethod {
222    /// Wire transfer.
223    #[default]
224    WireTransfer,
225    /// Check.
226    Check,
227    /// ACH/Direct debit.
228    ACH,
229    /// Credit card.
230    CreditCard,
231    /// Cash.
232    Cash,
233    /// Letter of credit.
234    LetterOfCredit,
235    /// Netting.
236    Netting,
237    /// Other.
238    Other,
239}
240
241/// Application of receipt to an invoice.
242#[derive(Debug, Clone, Serialize, Deserialize)]
243pub struct ReceiptApplication {
244    /// Invoice number.
245    pub invoice_number: String,
246    /// Amount applied.
247    pub amount_applied: Decimal,
248    /// Discount taken.
249    pub discount_taken: Decimal,
250    /// Write-off amount.
251    pub write_off: Decimal,
252    /// Application date.
253    pub application_date: NaiveDate,
254}
255
256impl ReceiptApplication {
257    /// Total settlement for this application.
258    pub fn total_settlement(&self) -> Decimal {
259        self.amount_applied + self.discount_taken + self.write_off
260    }
261}
262
263/// Batch of receipts for processing.
264#[derive(Debug, Clone, Serialize, Deserialize)]
265pub struct ARReceiptBatch {
266    /// Batch ID.
267    pub batch_id: String,
268    /// Company code.
269    pub company_code: String,
270    /// Batch date.
271    pub batch_date: NaiveDate,
272    /// Receipts in batch.
273    pub receipts: Vec<ARReceipt>,
274    /// Total batch amount.
275    pub total_amount: Decimal,
276    /// Batch status.
277    pub status: BatchStatus,
278    /// Created by.
279    pub created_by: String,
280    /// Created at.
281    pub created_at: DateTime<Utc>,
282}
283
284impl ARReceiptBatch {
285    /// Creates a new receipt batch.
286    pub fn new(
287        batch_id: String,
288        company_code: String,
289        batch_date: NaiveDate,
290        created_by: String,
291    ) -> Self {
292        Self {
293            batch_id,
294            company_code,
295            batch_date,
296            receipts: Vec::new(),
297            total_amount: Decimal::ZERO,
298            status: BatchStatus::Open,
299            created_by,
300            created_at: Utc::now(),
301        }
302    }
303
304    /// Adds a receipt to the batch.
305    pub fn add_receipt(&mut self, receipt: ARReceipt) {
306        self.total_amount += receipt.amount.document_amount;
307        self.receipts.push(receipt);
308    }
309
310    /// Posts the batch.
311    pub fn post(&mut self) {
312        self.status = BatchStatus::Posted;
313    }
314
315    /// Gets receipt count.
316    pub fn count(&self) -> usize {
317        self.receipts.len()
318    }
319}
320
321/// Status of a batch.
322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
323pub enum BatchStatus {
324    /// Batch is open for additions.
325    Open,
326    /// Batch is submitted for approval.
327    Submitted,
328    /// Batch is approved.
329    Approved,
330    /// Batch is posted.
331    Posted,
332    /// Batch is cancelled.
333    Cancelled,
334}
335
336/// Bank statement line for automatic matching.
337#[derive(Debug, Clone, Serialize, Deserialize)]
338pub struct BankStatementLine {
339    /// Statement line ID.
340    pub line_id: String,
341    /// Bank account.
342    pub bank_account: String,
343    /// Statement date.
344    pub statement_date: NaiveDate,
345    /// Value date.
346    pub value_date: NaiveDate,
347    /// Amount (positive = receipt, negative = payment).
348    pub amount: Decimal,
349    /// Currency.
350    pub currency: String,
351    /// Bank reference.
352    pub bank_reference: String,
353    /// Payer/payee name.
354    pub counterparty_name: Option<String>,
355    /// Payer/payee account.
356    pub counterparty_account: Option<String>,
357    /// Payment reference/remittance info.
358    pub payment_reference: Option<String>,
359    /// Is matched.
360    pub is_matched: bool,
361    /// Matched receipt number.
362    pub matched_receipt: Option<String>,
363}
364
365impl BankStatementLine {
366    /// Checks if this is an incoming payment.
367    pub fn is_receipt(&self) -> bool {
368        self.amount > Decimal::ZERO
369    }
370
371    /// Marks as matched.
372    pub fn match_to_receipt(&mut self, receipt_number: String) {
373        self.is_matched = true;
374        self.matched_receipt = Some(receipt_number);
375    }
376}
377
378#[cfg(test)]
379#[allow(clippy::unwrap_used)]
380mod tests {
381    use super::*;
382    use rust_decimal_macros::dec;
383
384    #[test]
385    fn test_receipt_creation() {
386        let receipt = ARReceipt::new(
387            "REC001".to_string(),
388            "1000".to_string(),
389            "CUST001".to_string(),
390            "Test Customer".to_string(),
391            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
392            dec!(1000),
393            "USD".to_string(),
394            PaymentMethod::WireTransfer,
395            "1000".to_string(),
396        );
397
398        assert_eq!(receipt.amount.document_amount, dec!(1000));
399        assert_eq!(receipt.unapplied_amount, dec!(1000));
400        assert_eq!(receipt.status, SubledgerDocumentStatus::Open);
401    }
402
403    #[test]
404    fn test_apply_to_invoice() {
405        let mut receipt = ARReceipt::new(
406            "REC001".to_string(),
407            "1000".to_string(),
408            "CUST001".to_string(),
409            "Test Customer".to_string(),
410            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
411            dec!(1000),
412            "USD".to_string(),
413            PaymentMethod::WireTransfer,
414            "1000".to_string(),
415        );
416
417        receipt.apply_to_invoice("INV001".to_string(), dec!(800), dec!(20));
418
419        assert_eq!(receipt.net_applied, dec!(800));
420        assert_eq!(receipt.discount_taken, dec!(20));
421        assert_eq!(receipt.unapplied_amount, dec!(200));
422        assert_eq!(receipt.applied_invoices.len(), 1);
423    }
424
425    #[test]
426    fn test_receipt_fully_applied() {
427        let mut receipt = ARReceipt::new(
428            "REC001".to_string(),
429            "1000".to_string(),
430            "CUST001".to_string(),
431            "Test Customer".to_string(),
432            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
433            dec!(1000),
434            "USD".to_string(),
435            PaymentMethod::WireTransfer,
436            "1000".to_string(),
437        );
438
439        receipt.apply_to_invoice("INV001".to_string(), dec!(1000), Decimal::ZERO);
440
441        assert_eq!(receipt.status, SubledgerDocumentStatus::Cleared);
442        assert_eq!(receipt.unapplied_amount, Decimal::ZERO);
443    }
444
445    #[test]
446    fn test_batch_totals() {
447        let mut batch = ARReceiptBatch::new(
448            "BATCH001".to_string(),
449            "1000".to_string(),
450            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
451            "USER1".to_string(),
452        );
453
454        let receipt1 = ARReceipt::new(
455            "REC001".to_string(),
456            "1000".to_string(),
457            "CUST001".to_string(),
458            "Customer 1".to_string(),
459            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
460            dec!(500),
461            "USD".to_string(),
462            PaymentMethod::WireTransfer,
463            "1000".to_string(),
464        );
465
466        let receipt2 = ARReceipt::new(
467            "REC002".to_string(),
468            "1000".to_string(),
469            "CUST002".to_string(),
470            "Customer 2".to_string(),
471            NaiveDate::from_ymd_opt(2024, 2, 1).unwrap(),
472            dec!(750),
473            "USD".to_string(),
474            PaymentMethod::Check,
475            "1000".to_string(),
476        );
477
478        batch.add_receipt(receipt1);
479        batch.add_receipt(receipt2);
480
481        assert_eq!(batch.count(), 2);
482        assert_eq!(batch.total_amount, dec!(1250));
483    }
484}