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 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 let je = self.generate_credit_memo_je(&memo);
204
205 (memo, je)
206 }
207
208 pub fn generate_period_transactions(
210 &mut self,
211 company_code: &str,
212 customers: &[(String, String)], 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
476pub struct ARPeriodTransactions {
477 pub invoices: Vec<ARInvoice>,
479 pub receipts: Vec<ARReceipt>,
481 pub credit_memos: Vec<ARCreditMemo>,
483 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}