Skip to main content

datasynth_generators/subledger/
ar_generator.rs

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