1use 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#[derive(Debug, Clone)]
22pub struct ARGeneratorConfig {
23 pub avg_invoice_amount: Decimal,
25 pub amount_variation: Decimal,
27 pub on_time_payment_rate: Decimal,
29 pub avg_days_to_payment: u32,
31 pub credit_memo_rate: Decimal,
33 pub tax_rate: Decimal,
35 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
53pub 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 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 pub fn with_seed(config: ARGeneratorConfig, seed: u64) -> Self {
76 Self::new(config, seeded_rng(seed, 0))
77 }
78
79 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 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 let je = self.generate_invoice_je(&invoice);
120
121 (invoice, je)
122 }
123
124 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 let je = self.generate_receipt_je(&receipt, &invoice.gross_amount.document_currency);
156
157 (receipt, je)
158 }
159
160 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 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 let je = self.generate_credit_memo_je(&memo);
205
206 (memo, je)
207 }
208
209 pub fn generate_period_transactions(
211 &mut self,
212 company_code: &str,
213 customers: &[(String, String)], 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
477pub struct ARPeriodTransactions {
478 pub invoices: Vec<ARInvoice>,
480 pub receipts: Vec<ARReceipt>,
482 pub credit_memos: Vec<ARCreditMemo>,
484 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}