Skip to main content

datasynth_generators/subledger/
ar_generator.rs

1//! AR (Accounts Receivable) generator.
2
3use chrono::NaiveDate;
4use rand::Rng;
5use rand_chacha::ChaCha8Rng;
6use rust_decimal::Decimal;
7use rust_decimal_macros::dec;
8
9use datasynth_core::accounts::{cash_accounts, control_accounts, revenue_accounts, tax_accounts};
10use datasynth_core::models::subledger::ar::{
11    ARCreditMemo, ARCreditMemoLine, ARInvoice, ARInvoiceLine, ARReceipt, CreditMemoReason,
12    PaymentMethod,
13};
14use datasynth_core::models::subledger::PaymentTerms;
15use datasynth_core::models::{JournalEntry, JournalEntryLine};
16
17/// Configuration for AR generation.
18#[derive(Debug, Clone)]
19pub struct ARGeneratorConfig {
20    /// Average invoice amount.
21    pub avg_invoice_amount: Decimal,
22    /// Invoice amount variation (0.0 to 1.0).
23    pub amount_variation: Decimal,
24    /// Percentage of invoices paid on time.
25    pub on_time_payment_rate: Decimal,
26    /// Average days to payment.
27    pub avg_days_to_payment: u32,
28    /// Percentage of invoices that get credit memos.
29    pub credit_memo_rate: Decimal,
30    /// Default tax rate.
31    pub tax_rate: Decimal,
32    /// Default payment terms.
33    pub default_terms: PaymentTerms,
34}
35
36impl Default for ARGeneratorConfig {
37    fn default() -> Self {
38        Self {
39            avg_invoice_amount: dec!(5000),
40            amount_variation: dec!(0.5),
41            avg_days_to_payment: 35,
42            on_time_payment_rate: dec!(0.75),
43            credit_memo_rate: dec!(0.05),
44            tax_rate: dec!(10),
45            default_terms: PaymentTerms::net_30(),
46        }
47    }
48}
49
50/// Generator for AR transactions.
51pub struct ARGenerator {
52    config: ARGeneratorConfig,
53    rng: ChaCha8Rng,
54    invoice_counter: u64,
55    receipt_counter: u64,
56    credit_memo_counter: u64,
57}
58
59impl ARGenerator {
60    /// Creates a new AR generator.
61    pub fn new(config: ARGeneratorConfig, rng: ChaCha8Rng) -> Self {
62        Self {
63            config,
64            rng,
65            invoice_counter: 0,
66            receipt_counter: 0,
67            credit_memo_counter: 0,
68        }
69    }
70
71    /// Generates an AR invoice.
72    pub fn generate_invoice(
73        &mut self,
74        company_code: &str,
75        customer_id: &str,
76        customer_name: &str,
77        invoice_date: NaiveDate,
78        currency: &str,
79        line_count: usize,
80    ) -> (ARInvoice, JournalEntry) {
81        self.invoice_counter += 1;
82        let invoice_number = format!("ARINV{:08}", self.invoice_counter);
83
84        let mut invoice = ARInvoice::new(
85            invoice_number.clone(),
86            company_code.to_string(),
87            customer_id.to_string(),
88            customer_name.to_string(),
89            invoice_date,
90            self.config.default_terms.clone(),
91            currency.to_string(),
92        );
93
94        // Generate invoice lines
95        for line_num in 1..=line_count {
96            let amount = self.generate_line_amount();
97            let line = ARInvoiceLine::new(
98                line_num as u32,
99                format!("Product/Service {}", line_num),
100                dec!(1),
101                "EA".to_string(),
102                amount,
103                "4000".to_string(),
104            )
105            .with_tax("VAT".to_string(), self.config.tax_rate);
106
107            invoice.add_line(line);
108        }
109
110        // Generate corresponding journal entry
111        let je = self.generate_invoice_je(&invoice);
112
113        (invoice, je)
114    }
115
116    /// Generates a receipt for an invoice.
117    pub fn generate_receipt(
118        &mut self,
119        invoice: &ARInvoice,
120        receipt_date: NaiveDate,
121        amount: Option<Decimal>,
122    ) -> (ARReceipt, JournalEntry) {
123        self.receipt_counter += 1;
124        let receipt_number = format!("ARREC{:08}", self.receipt_counter);
125
126        let payment_amount = amount.unwrap_or(invoice.amount_remaining);
127        let discount = invoice.available_discount(receipt_date);
128        let net_payment = payment_amount - discount;
129
130        let payment_method = self.random_payment_method();
131
132        let mut receipt = ARReceipt::new(
133            receipt_number.clone(),
134            invoice.company_code.clone(),
135            invoice.customer_id.clone(),
136            invoice.customer_name.clone(),
137            receipt_date,
138            net_payment,
139            invoice.gross_amount.document_currency.clone(),
140            payment_method,
141            "1000".to_string(), // Bank account
142        );
143
144        receipt.apply_to_invoice(invoice.invoice_number.clone(), payment_amount, discount);
145
146        // Generate corresponding journal entry
147        let je = self.generate_receipt_je(&receipt, &invoice.gross_amount.document_currency);
148
149        (receipt, je)
150    }
151
152    /// Generates a credit memo.
153    pub fn generate_credit_memo(
154        &mut self,
155        invoice: &ARInvoice,
156        memo_date: NaiveDate,
157        reason: CreditMemoReason,
158        percent_of_invoice: Decimal,
159    ) -> (ARCreditMemo, JournalEntry) {
160        self.credit_memo_counter += 1;
161        let memo_number = format!("ARCM{:08}", self.credit_memo_counter);
162
163        let mut memo = ARCreditMemo::for_invoice(
164            memo_number.clone(),
165            invoice.company_code.clone(),
166            invoice.customer_id.clone(),
167            invoice.customer_name.clone(),
168            memo_date,
169            invoice.invoice_number.clone(),
170            reason,
171            format!("{:?}", reason),
172            invoice.gross_amount.document_currency.clone(),
173        );
174
175        // Add credit memo lines proportional to original invoice
176        for (idx, inv_line) in invoice.lines.iter().enumerate() {
177            let _credit_amount = (inv_line.net_amount * percent_of_invoice).round_dp(2);
178            let line = ARCreditMemoLine::new(
179                (idx + 1) as u32,
180                inv_line.description.clone(),
181                inv_line.quantity * percent_of_invoice,
182                inv_line.unit.clone(),
183                inv_line.unit_price,
184                inv_line.revenue_account.clone(),
185            )
186            .with_tax(
187                inv_line.tax_code.clone().unwrap_or_default(),
188                inv_line.tax_rate,
189            )
190            .with_invoice_reference(inv_line.line_number);
191
192            memo.add_line(line);
193        }
194
195        // Generate corresponding journal entry
196        let je = self.generate_credit_memo_je(&memo);
197
198        (memo, je)
199    }
200
201    /// Generates a batch of AR transactions for a period.
202    pub fn generate_period_transactions(
203        &mut self,
204        company_code: &str,
205        customers: &[(String, String)], // (id, name)
206        start_date: NaiveDate,
207        end_date: NaiveDate,
208        invoices_per_day: u32,
209        currency: &str,
210    ) -> ARPeriodTransactions {
211        let mut invoices = Vec::new();
212        let mut receipts = Vec::new();
213        let mut credit_memos = Vec::new();
214        let mut journal_entries = Vec::new();
215
216        let mut current_date = start_date;
217        while current_date <= end_date {
218            // Generate invoices for this day
219            for _ in 0..invoices_per_day {
220                if customers.is_empty() {
221                    continue;
222                }
223
224                let customer_idx = self.rng.gen_range(0..customers.len());
225                let (customer_id, customer_name) = &customers[customer_idx];
226
227                let line_count = self.rng.gen_range(1..=5);
228                let (invoice, je) = self.generate_invoice(
229                    company_code,
230                    customer_id,
231                    customer_name,
232                    current_date,
233                    currency,
234                    line_count,
235                );
236
237                journal_entries.push(je);
238                invoices.push(invoice);
239            }
240
241            current_date += chrono::Duration::days(1);
242        }
243
244        // Generate receipts for older invoices
245        let payment_cutoff =
246            end_date - chrono::Duration::days(self.config.avg_days_to_payment as i64);
247        for invoice in &invoices {
248            if invoice.invoice_date <= payment_cutoff {
249                let should_pay: f64 = self.rng.gen();
250                if should_pay
251                    < self
252                        .config
253                        .on_time_payment_rate
254                        .to_string()
255                        .parse()
256                        .unwrap_or(0.75)
257                {
258                    let days_to_pay = self.rng.gen_range(
259                        (self.config.avg_days_to_payment / 2)
260                            ..(self.config.avg_days_to_payment * 2),
261                    );
262                    let receipt_date =
263                        invoice.invoice_date + chrono::Duration::days(days_to_pay as i64);
264
265                    if receipt_date <= end_date {
266                        let (receipt, je) = self.generate_receipt(invoice, receipt_date, None);
267                        journal_entries.push(je);
268                        receipts.push(receipt);
269                    }
270                }
271            }
272        }
273
274        // Generate some credit memos
275        for invoice in &invoices {
276            let should_credit: f64 = self.rng.gen();
277            if should_credit
278                < self
279                    .config
280                    .credit_memo_rate
281                    .to_string()
282                    .parse()
283                    .unwrap_or(0.05)
284            {
285                let days_after = self.rng.gen_range(5..30);
286                let memo_date = invoice.invoice_date + chrono::Duration::days(days_after);
287
288                if memo_date <= end_date {
289                    let reason = self.random_credit_reason();
290                    let percent = Decimal::from(self.rng.gen_range(10..50)) / dec!(100);
291                    let (memo, je) = self.generate_credit_memo(invoice, memo_date, reason, percent);
292                    journal_entries.push(je);
293                    credit_memos.push(memo);
294                }
295            }
296        }
297
298        ARPeriodTransactions {
299            invoices,
300            receipts,
301            credit_memos,
302            journal_entries,
303        }
304    }
305
306    /// Generates a random line amount.
307    fn generate_line_amount(&mut self) -> Decimal {
308        let base = self.config.avg_invoice_amount;
309        let variation = base * self.config.amount_variation;
310        let random: f64 = self.rng.gen_range(-1.0..1.0);
311        let amount = base + variation * Decimal::try_from(random).unwrap_or_default();
312        amount.max(dec!(100)).round_dp(2)
313    }
314
315    /// Generates invoice journal entry.
316    fn generate_invoice_je(&mut self, invoice: &ARInvoice) -> JournalEntry {
317        let mut je = JournalEntry::new_simple(
318            format!("JE-{}", invoice.invoice_number),
319            invoice.company_code.clone(),
320            invoice.posting_date,
321            format!("AR Invoice {}", invoice.invoice_number),
322        );
323
324        // Debit AR (using centralized control account)
325        je.add_line(JournalEntryLine {
326            line_number: 1,
327            gl_account: control_accounts::AR_CONTROL.to_string(),
328            debit_amount: invoice.gross_amount.document_amount,
329            reference: Some(invoice.invoice_number.clone()),
330            assignment: Some(invoice.customer_id.clone()),
331            ..Default::default()
332        });
333
334        // Credit Revenue (using centralized revenue account)
335        je.add_line(JournalEntryLine {
336            line_number: 2,
337            gl_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
338            credit_amount: invoice.net_amount.document_amount,
339            reference: Some(invoice.invoice_number.clone()),
340            ..Default::default()
341        });
342
343        // Credit Tax Payable (using centralized tax account)
344        if invoice.tax_amount.document_amount > Decimal::ZERO {
345            je.add_line(JournalEntryLine {
346                line_number: 3,
347                gl_account: tax_accounts::VAT_PAYABLE.to_string(),
348                credit_amount: invoice.tax_amount.document_amount,
349                reference: Some(invoice.invoice_number.clone()),
350                tax_code: Some("VAT".to_string()),
351                ..Default::default()
352            });
353        }
354
355        je
356    }
357
358    /// Generates receipt journal entry.
359    fn generate_receipt_je(&mut self, receipt: &ARReceipt, _currency: &str) -> JournalEntry {
360        let mut je = JournalEntry::new_simple(
361            format!("JE-{}", receipt.receipt_number),
362            receipt.company_code.clone(),
363            receipt.posting_date,
364            format!("AR Receipt {}", receipt.receipt_number),
365        );
366
367        // Debit Cash (using centralized cash account)
368        je.add_line(JournalEntryLine {
369            line_number: 1,
370            gl_account: cash_accounts::OPERATING_CASH.to_string(),
371            debit_amount: receipt.amount.document_amount,
372            reference: Some(receipt.receipt_number.clone()),
373            ..Default::default()
374        });
375
376        // Credit AR (using centralized control account)
377        let ar_credit = receipt.net_applied + receipt.discount_taken;
378        je.add_line(JournalEntryLine {
379            line_number: 2,
380            gl_account: control_accounts::AR_CONTROL.to_string(),
381            credit_amount: ar_credit,
382            reference: Some(receipt.receipt_number.clone()),
383            assignment: Some(receipt.customer_id.clone()),
384            ..Default::default()
385        });
386
387        // Debit Discount Expense if discount taken
388        if receipt.discount_taken > Decimal::ZERO {
389            je.add_line(JournalEntryLine {
390                line_number: 3,
391                gl_account: revenue_accounts::SALES_DISCOUNTS.to_string(),
392                debit_amount: receipt.discount_taken,
393                reference: Some(receipt.receipt_number.clone()),
394                ..Default::default()
395            });
396        }
397
398        je
399    }
400
401    /// Generates credit memo journal entry.
402    fn generate_credit_memo_je(&mut self, memo: &ARCreditMemo) -> JournalEntry {
403        let mut je = JournalEntry::new_simple(
404            format!("JE-{}", memo.credit_memo_number),
405            memo.company_code.clone(),
406            memo.posting_date,
407            format!("AR Credit Memo {}", memo.credit_memo_number),
408        );
409
410        // Debit Revenue
411        je.add_line(JournalEntryLine {
412            line_number: 1,
413            gl_account: "4000".to_string(),
414            debit_amount: memo.net_amount.document_amount,
415            reference: Some(memo.credit_memo_number.clone()),
416            ..Default::default()
417        });
418
419        // Debit Tax
420        if memo.tax_amount.document_amount > Decimal::ZERO {
421            je.add_line(JournalEntryLine {
422                line_number: 2,
423                gl_account: "2300".to_string(),
424                debit_amount: memo.tax_amount.document_amount,
425                reference: Some(memo.credit_memo_number.clone()),
426                tax_code: Some("VAT".to_string()),
427                ..Default::default()
428            });
429        }
430
431        // Credit AR
432        je.add_line(JournalEntryLine {
433            line_number: 3,
434            gl_account: "1100".to_string(),
435            credit_amount: memo.gross_amount.document_amount,
436            reference: Some(memo.credit_memo_number.clone()),
437            assignment: Some(memo.customer_id.clone()),
438            ..Default::default()
439        });
440
441        je
442    }
443
444    /// Generates a random payment method.
445    fn random_payment_method(&mut self) -> PaymentMethod {
446        match self.rng.gen_range(0..4) {
447            0 => PaymentMethod::WireTransfer,
448            1 => PaymentMethod::Check,
449            2 => PaymentMethod::ACH,
450            _ => PaymentMethod::CreditCard,
451        }
452    }
453
454    /// Generates a random credit memo reason.
455    fn random_credit_reason(&mut self) -> CreditMemoReason {
456        match self.rng.gen_range(0..5) {
457            0 => CreditMemoReason::Return,
458            1 => CreditMemoReason::PriceError,
459            2 => CreditMemoReason::QualityIssue,
460            3 => CreditMemoReason::Promotional,
461            _ => CreditMemoReason::Other,
462        }
463    }
464}
465
466/// Result of period AR generation.
467#[derive(Debug, Clone)]
468pub struct ARPeriodTransactions {
469    /// Generated invoices.
470    pub invoices: Vec<ARInvoice>,
471    /// Generated receipts.
472    pub receipts: Vec<ARReceipt>,
473    /// Generated credit memos.
474    pub credit_memos: Vec<ARCreditMemo>,
475    /// Corresponding journal entries.
476    pub journal_entries: Vec<JournalEntry>,
477}
478
479#[cfg(test)]
480mod tests {
481    use super::*;
482    use rand::SeedableRng;
483
484    #[test]
485    fn test_generate_invoice() {
486        let rng = ChaCha8Rng::seed_from_u64(12345);
487        let mut generator = ARGenerator::new(ARGeneratorConfig::default(), rng);
488
489        let (invoice, je) = generator.generate_invoice(
490            "1000",
491            "CUST001",
492            "Test Customer",
493            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
494            "USD",
495            3,
496        );
497
498        assert_eq!(invoice.lines.len(), 3);
499        assert!(invoice.gross_amount.document_amount > Decimal::ZERO);
500        assert!(je.is_balanced());
501    }
502
503    #[test]
504    fn test_generate_receipt() {
505        let rng = ChaCha8Rng::seed_from_u64(12345);
506        let mut generator = ARGenerator::new(ARGeneratorConfig::default(), rng);
507
508        let (invoice, _) = generator.generate_invoice(
509            "1000",
510            "CUST001",
511            "Test Customer",
512            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
513            "USD",
514            2,
515        );
516
517        let (receipt, je) = generator.generate_receipt(
518            &invoice,
519            NaiveDate::from_ymd_opt(2024, 2, 10).unwrap(),
520            None,
521        );
522
523        assert!(receipt.net_applied > Decimal::ZERO);
524        assert!(je.is_balanced());
525    }
526}