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 line = ARCreditMemoLine::new(
186                (idx + 1) as u32,
187                inv_line.description.clone(),
188                inv_line.quantity * percent_of_invoice,
189                inv_line.unit.clone(),
190                inv_line.unit_price,
191                inv_line.revenue_account.clone(),
192            )
193            .with_tax(
194                inv_line.tax_code.clone().unwrap_or_default(),
195                inv_line.tax_rate,
196            )
197            .with_invoice_reference(inv_line.line_number);
198
199            memo.add_line(line);
200        }
201
202        // Generate corresponding journal entry
203        let je = self.generate_credit_memo_je(&memo);
204
205        (memo, je)
206    }
207
208    /// Generates a batch of AR transactions for a period.
209    pub fn generate_period_transactions(
210        &mut self,
211        company_code: &str,
212        customers: &[(String, String)], // (id, name)
213        start_date: NaiveDate,
214        end_date: NaiveDate,
215        invoices_per_day: u32,
216        currency: &str,
217    ) -> ARPeriodTransactions {
218        debug!(company_code, customer_count = customers.len(), %start_date, %end_date, invoices_per_day, "Generating AR period transactions");
219        let mut invoices = Vec::new();
220        let mut receipts = Vec::new();
221        let mut credit_memos = Vec::new();
222        let mut journal_entries = Vec::new();
223
224        let mut current_date = start_date;
225        while current_date <= end_date {
226            // Generate invoices for this day
227            for _ in 0..invoices_per_day {
228                if customers.is_empty() {
229                    continue;
230                }
231
232                let customer_idx = self.rng.random_range(0..customers.len());
233                let (customer_id, customer_name) = &customers[customer_idx];
234
235                let line_count = self.rng.random_range(1..=5);
236                let (invoice, je) = self.generate_invoice(
237                    company_code,
238                    customer_id,
239                    customer_name,
240                    current_date,
241                    currency,
242                    line_count,
243                );
244
245                journal_entries.push(je);
246                invoices.push(invoice);
247            }
248
249            current_date += chrono::Duration::days(1);
250        }
251
252        // Generate receipts for older invoices
253        let payment_cutoff =
254            end_date - chrono::Duration::days(self.config.avg_days_to_payment as i64);
255        for invoice in &invoices {
256            if invoice.invoice_date <= payment_cutoff {
257                let should_pay: f64 = self.rng.random();
258                if should_pay
259                    < self
260                        .config
261                        .on_time_payment_rate
262                        .to_string()
263                        .parse()
264                        .unwrap_or(0.75)
265                {
266                    let days_to_pay = self.rng.random_range(
267                        (self.config.avg_days_to_payment / 2)
268                            ..(self.config.avg_days_to_payment * 2),
269                    );
270                    let receipt_date =
271                        invoice.invoice_date + chrono::Duration::days(days_to_pay as i64);
272
273                    if receipt_date <= end_date {
274                        let (receipt, je) = self.generate_receipt(invoice, receipt_date, None);
275                        journal_entries.push(je);
276                        receipts.push(receipt);
277                    }
278                }
279            }
280        }
281
282        // Generate some credit memos
283        for invoice in &invoices {
284            let should_credit: f64 = self.rng.random();
285            if should_credit
286                < self
287                    .config
288                    .credit_memo_rate
289                    .to_string()
290                    .parse()
291                    .unwrap_or(0.05)
292            {
293                let days_after = self.rng.random_range(5..30);
294                let memo_date = invoice.invoice_date + chrono::Duration::days(days_after);
295
296                if memo_date <= end_date {
297                    let reason = self.random_credit_reason();
298                    let percent = Decimal::from(self.rng.random_range(10..50)) / dec!(100);
299                    let (memo, je) = self.generate_credit_memo(invoice, memo_date, reason, percent);
300                    journal_entries.push(je);
301                    credit_memos.push(memo);
302                }
303            }
304        }
305
306        ARPeriodTransactions {
307            invoices,
308            receipts,
309            credit_memos,
310            journal_entries,
311        }
312    }
313
314    /// Generates a random line amount.
315    fn generate_line_amount(&mut self) -> Decimal {
316        let base = self.config.avg_invoice_amount;
317        let variation = base * self.config.amount_variation;
318        let random: f64 = self.rng.random_range(-1.0..1.0);
319        let amount = base + variation * Decimal::try_from(random).unwrap_or_default();
320        amount.max(dec!(100)).round_dp(2)
321    }
322
323    /// Generates invoice journal entry.
324    fn generate_invoice_je(&mut self, invoice: &ARInvoice) -> JournalEntry {
325        let mut je = JournalEntry::new_simple(
326            format!("JE-{}", invoice.invoice_number),
327            invoice.company_code.clone(),
328            invoice.posting_date,
329            format!("AR Invoice {}", invoice.invoice_number),
330        );
331
332        // Debit AR (using centralized control account)
333        je.add_line(JournalEntryLine {
334            line_number: 1,
335            gl_account: control_accounts::AR_CONTROL.to_string(),
336            debit_amount: invoice.gross_amount.document_amount,
337            reference: Some(invoice.invoice_number.clone()),
338            assignment: Some(invoice.customer_id.clone()),
339            ..Default::default()
340        });
341
342        // Credit Revenue (using centralized revenue account)
343        je.add_line(JournalEntryLine {
344            line_number: 2,
345            gl_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
346            credit_amount: invoice.net_amount.document_amount,
347            reference: Some(invoice.invoice_number.clone()),
348            ..Default::default()
349        });
350
351        // Credit Tax Payable (using centralized tax account)
352        if invoice.tax_amount.document_amount > Decimal::ZERO {
353            je.add_line(JournalEntryLine {
354                line_number: 3,
355                gl_account: tax_accounts::VAT_PAYABLE.to_string(),
356                credit_amount: invoice.tax_amount.document_amount,
357                reference: Some(invoice.invoice_number.clone()),
358                tax_code: Some("VAT".to_string()),
359                ..Default::default()
360            });
361        }
362
363        je
364    }
365
366    /// Generates receipt journal entry.
367    fn generate_receipt_je(&mut self, receipt: &ARReceipt, _currency: &str) -> JournalEntry {
368        let mut je = JournalEntry::new_simple(
369            format!("JE-{}", receipt.receipt_number),
370            receipt.company_code.clone(),
371            receipt.posting_date,
372            format!("AR Receipt {}", receipt.receipt_number),
373        );
374
375        // Debit Cash (using centralized cash account)
376        je.add_line(JournalEntryLine {
377            line_number: 1,
378            gl_account: cash_accounts::OPERATING_CASH.to_string(),
379            debit_amount: receipt.amount.document_amount,
380            reference: Some(receipt.receipt_number.clone()),
381            ..Default::default()
382        });
383
384        // Credit AR (using centralized control account)
385        let ar_credit = receipt.net_applied + receipt.discount_taken;
386        je.add_line(JournalEntryLine {
387            line_number: 2,
388            gl_account: control_accounts::AR_CONTROL.to_string(),
389            credit_amount: ar_credit,
390            reference: Some(receipt.receipt_number.clone()),
391            assignment: Some(receipt.customer_id.clone()),
392            ..Default::default()
393        });
394
395        // Debit Discount Expense if discount taken
396        if receipt.discount_taken > Decimal::ZERO {
397            je.add_line(JournalEntryLine {
398                line_number: 3,
399                gl_account: revenue_accounts::SALES_DISCOUNTS.to_string(),
400                debit_amount: receipt.discount_taken,
401                reference: Some(receipt.receipt_number.clone()),
402                ..Default::default()
403            });
404        }
405
406        je
407    }
408
409    /// Generates credit memo journal entry.
410    fn generate_credit_memo_je(&mut self, memo: &ARCreditMemo) -> JournalEntry {
411        let mut je = JournalEntry::new_simple(
412            format!("JE-{}", memo.credit_memo_number),
413            memo.company_code.clone(),
414            memo.posting_date,
415            format!("AR Credit Memo {}", memo.credit_memo_number),
416        );
417
418        // Debit Revenue
419        je.add_line(JournalEntryLine {
420            line_number: 1,
421            gl_account: revenue_accounts::PRODUCT_REVENUE.to_string(),
422            debit_amount: memo.net_amount.document_amount,
423            reference: Some(memo.credit_memo_number.clone()),
424            ..Default::default()
425        });
426
427        // Debit Tax
428        if memo.tax_amount.document_amount > Decimal::ZERO {
429            je.add_line(JournalEntryLine {
430                line_number: 2,
431                gl_account: tax_accounts::SALES_TAX_PAYABLE.to_string(),
432                debit_amount: memo.tax_amount.document_amount,
433                reference: Some(memo.credit_memo_number.clone()),
434                tax_code: Some("VAT".to_string()),
435                ..Default::default()
436            });
437        }
438
439        // Credit AR
440        je.add_line(JournalEntryLine {
441            line_number: 3,
442            gl_account: control_accounts::AR_CONTROL.to_string(),
443            credit_amount: memo.gross_amount.document_amount,
444            reference: Some(memo.credit_memo_number.clone()),
445            assignment: Some(memo.customer_id.clone()),
446            ..Default::default()
447        });
448
449        je
450    }
451
452    /// Generates a random payment method.
453    fn random_payment_method(&mut self) -> PaymentMethod {
454        match self.rng.random_range(0..4) {
455            0 => PaymentMethod::WireTransfer,
456            1 => PaymentMethod::Check,
457            2 => PaymentMethod::ACH,
458            _ => PaymentMethod::CreditCard,
459        }
460    }
461
462    /// Generates a random credit memo reason.
463    fn random_credit_reason(&mut self) -> CreditMemoReason {
464        match self.rng.random_range(0..5) {
465            0 => CreditMemoReason::Return,
466            1 => CreditMemoReason::PriceError,
467            2 => CreditMemoReason::QualityIssue,
468            3 => CreditMemoReason::Promotional,
469            _ => CreditMemoReason::Other,
470        }
471    }
472}
473
474/// Result of period AR generation.
475#[derive(Debug, Clone)]
476pub struct ARPeriodTransactions {
477    /// Generated invoices.
478    pub invoices: Vec<ARInvoice>,
479    /// Generated receipts.
480    pub receipts: Vec<ARReceipt>,
481    /// Generated credit memos.
482    pub credit_memos: Vec<ARCreditMemo>,
483    /// Corresponding journal entries.
484    pub journal_entries: Vec<JournalEntry>,
485}
486
487#[cfg(test)]
488#[allow(clippy::unwrap_used)]
489mod tests {
490    use super::*;
491    use rand::SeedableRng;
492
493    #[test]
494    fn test_generate_invoice() {
495        let rng = ChaCha8Rng::seed_from_u64(12345);
496        let mut generator = ARGenerator::new(ARGeneratorConfig::default(), rng);
497
498        let (invoice, je) = generator.generate_invoice(
499            "1000",
500            "CUST001",
501            "Test Customer",
502            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
503            "USD",
504            3,
505        );
506
507        assert_eq!(invoice.lines.len(), 3);
508        assert!(invoice.gross_amount.document_amount > Decimal::ZERO);
509        assert!(je.is_balanced());
510    }
511
512    #[test]
513    fn test_generate_receipt() {
514        let rng = ChaCha8Rng::seed_from_u64(12345);
515        let mut generator = ARGenerator::new(ARGeneratorConfig::default(), rng);
516
517        let (invoice, _) = generator.generate_invoice(
518            "1000",
519            "CUST001",
520            "Test Customer",
521            NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
522            "USD",
523            2,
524        );
525
526        let (receipt, je) = generator.generate_receipt(
527            &invoice,
528            NaiveDate::from_ymd_opt(2024, 2, 10).unwrap(),
529            None,
530        );
531
532        assert!(receipt.net_applied > Decimal::ZERO);
533        assert!(je.is_balanced());
534    }
535}