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