1use 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#[derive(Debug, Clone)]
19pub struct ARGeneratorConfig {
20 pub avg_invoice_amount: Decimal,
22 pub amount_variation: Decimal,
24 pub on_time_payment_rate: Decimal,
26 pub avg_days_to_payment: u32,
28 pub credit_memo_rate: Decimal,
30 pub tax_rate: Decimal,
32 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
50pub 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 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 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 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 let je = self.generate_invoice_je(&invoice);
112
113 (invoice, je)
114 }
115
116 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(), );
143
144 receipt.apply_to_invoice(invoice.invoice_number.clone(), payment_amount, discount);
145
146 let je = self.generate_receipt_je(&receipt, &invoice.gross_amount.document_currency);
148
149 (receipt, je)
150 }
151
152 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 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 let je = self.generate_credit_memo_je(&memo);
197
198 (memo, je)
199 }
200
201 pub fn generate_period_transactions(
203 &mut self,
204 company_code: &str,
205 customers: &[(String, String)], 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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#[derive(Debug, Clone)]
468pub struct ARPeriodTransactions {
469 pub invoices: Vec<ARInvoice>,
471 pub receipts: Vec<ARReceipt>,
473 pub credit_memos: Vec<ARCreditMemo>,
475 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}