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::{
13 cash_accounts, control_accounts, expense_accounts, revenue_accounts, tax_accounts,
14};
15use datasynth_core::models::subledger::ap::{
16 APDebitMemo, APDebitMemoLine, APInvoice, APInvoiceLine, APPayment, APPaymentMethod,
17 DebitMemoReason, MatchStatus,
18};
19use datasynth_core::models::subledger::PaymentTerms;
20use datasynth_core::models::{JournalEntry, JournalEntryLine};
21
22#[derive(Debug, Clone)]
24pub struct APGeneratorConfig {
25 pub avg_invoice_amount: Decimal,
27 pub amount_variation: Decimal,
29 pub on_time_payment_rate: Decimal,
31 pub avg_days_to_payment: u32,
33 pub debit_memo_rate: Decimal,
35 pub tax_rate: Decimal,
37 pub three_way_match_rate: Decimal,
39 pub default_terms: PaymentTerms,
41}
42
43impl Default for APGeneratorConfig {
44 fn default() -> Self {
45 Self {
46 avg_invoice_amount: dec!(10000),
47 amount_variation: dec!(0.6),
48 avg_days_to_payment: 30,
49 on_time_payment_rate: dec!(0.85),
50 debit_memo_rate: dec!(0.03),
51 tax_rate: dec!(10),
52 three_way_match_rate: dec!(0.95),
53 default_terms: PaymentTerms::net_30(),
54 }
55 }
56}
57
58pub struct APGenerator {
60 config: APGeneratorConfig,
61 rng: ChaCha8Rng,
62 invoice_counter: u64,
63 payment_counter: u64,
64 debit_memo_counter: u64,
65}
66
67impl APGenerator {
68 pub fn new(config: APGeneratorConfig, rng: ChaCha8Rng) -> Self {
70 Self {
71 config,
72 rng,
73 invoice_counter: 0,
74 payment_counter: 0,
75 debit_memo_counter: 0,
76 }
77 }
78
79 pub fn with_seed(config: APGeneratorConfig, seed: u64) -> Self {
81 Self::new(config, seeded_rng(seed, 0))
82 }
83
84 pub fn generate_invoice(
86 &mut self,
87 company_code: &str,
88 vendor_id: &str,
89 vendor_name: &str,
90 vendor_invoice_number: &str,
91 invoice_date: NaiveDate,
92 currency: &str,
93 line_count: usize,
94 po_number: Option<&str>,
95 ) -> (APInvoice, JournalEntry) {
96 debug!(company_code, vendor_id, %invoice_date, line_count, "Generating AP invoice");
97 self.invoice_counter += 1;
98 let invoice_number = format!("APINV{:08}", self.invoice_counter);
99
100 let mut invoice = APInvoice::new(
101 invoice_number.clone(),
102 vendor_invoice_number.to_string(),
103 company_code.to_string(),
104 vendor_id.to_string(),
105 vendor_name.to_string(),
106 invoice_date,
107 self.config.default_terms.clone(),
108 currency.to_string(),
109 );
110
111 if let Some(po) = po_number {
112 invoice.reference_po = Some(po.to_string());
113 invoice.match_status = if self.rng.random::<f64>() < 0.95 {
114 MatchStatus::Matched
115 } else {
116 MatchStatus::MatchedWithVariance {
117 price_variance: self.generate_variance(),
118 quantity_variance: Decimal::ZERO,
119 }
120 };
121 } else {
122 invoice.match_status = MatchStatus::NotRequired;
123 }
124
125 for line_num in 1..=line_count {
126 let amount = self.generate_line_amount();
127 let line = APInvoiceLine::new(
128 line_num as u32,
129 format!("Item/Service {line_num}"),
130 dec!(1),
131 "EA".to_string(),
132 amount,
133 expense_accounts::COGS.to_string(),
134 )
135 .with_tax("VAT".to_string(), self.config.tax_rate);
136
137 invoice.add_line(line);
138 }
139
140 let je = self.generate_invoice_je(&invoice);
141 (invoice, je)
142 }
143
144 pub fn generate_payment(
146 &mut self,
147 invoices: &[&APInvoice],
148 payment_date: NaiveDate,
149 house_bank: &str,
150 bank_account: &str,
151 ) -> (APPayment, JournalEntry) {
152 if invoices.is_empty() {
153 let empty_payment = APPayment::new(
154 format!("APPAY{:08}", self.payment_counter + 1),
155 String::new(),
156 String::new(),
157 String::new(),
158 payment_date,
159 Decimal::ZERO,
160 String::new(),
161 APPaymentMethod::WireTransfer,
162 house_bank.to_string(),
163 bank_account.to_string(),
164 );
165 let empty_je = JournalEntry::new_simple(
166 format!("APPAY{:08}", self.payment_counter + 1),
167 String::new(),
168 payment_date,
169 "Empty AP payment (no invoices)".to_string(),
170 );
171 return (empty_payment, empty_je);
172 }
173 self.payment_counter += 1;
174 let payment_number = format!("APPAY{:08}", self.payment_counter);
175
176 let vendor = invoices.first().expect("At least one invoice required");
177 let total_amount: Decimal = invoices.iter().map(|i| i.amount_remaining).sum();
178 let total_discount: Decimal = invoices
179 .iter()
180 .map(|i| i.available_discount(payment_date))
181 .sum();
182
183 let mut payment = APPayment::new(
184 payment_number.clone(),
185 vendor.company_code.clone(),
186 vendor.vendor_id.clone(),
187 vendor.vendor_name.clone(),
188 payment_date,
189 total_amount - total_discount,
190 vendor.gross_amount.document_currency.clone(),
191 self.random_payment_method(),
192 house_bank.to_string(),
193 bank_account.to_string(),
194 );
195
196 for invoice in invoices {
197 let discount = invoice.available_discount(payment_date);
198 payment.allocate_to_invoice(
199 invoice.invoice_number.clone(),
200 invoice.amount_remaining,
201 discount,
202 Decimal::ZERO,
203 );
204 }
205
206 let je = self.generate_payment_je(&payment);
207 (payment, je)
208 }
209
210 pub fn generate_debit_memo(
212 &mut self,
213 invoice: &APInvoice,
214 memo_date: NaiveDate,
215 reason: DebitMemoReason,
216 percent: Decimal,
217 ) -> (APDebitMemo, JournalEntry) {
218 self.debit_memo_counter += 1;
219 let memo_number = format!("APDM{:08}", self.debit_memo_counter);
220
221 let mut memo = APDebitMemo::for_invoice(
222 memo_number.clone(),
223 invoice.company_code.clone(),
224 invoice.vendor_id.clone(),
225 invoice.vendor_name.clone(),
226 memo_date,
227 invoice.invoice_number.clone(),
228 reason,
229 format!("{reason:?}"),
230 invoice.gross_amount.document_currency.clone(),
231 );
232
233 for (idx, inv_line) in invoice.lines.iter().enumerate() {
234 let line = APDebitMemoLine::new(
235 (idx + 1) as u32,
236 inv_line.description.clone(),
237 inv_line.quantity * percent,
238 inv_line.unit.clone(),
239 inv_line.unit_price,
240 inv_line.gl_account.clone(),
241 )
242 .with_tax(
243 inv_line.tax_code.clone().unwrap_or_default(),
244 inv_line.tax_rate,
245 );
246 memo.add_line(line);
247 }
248
249 let je = self.generate_debit_memo_je(&memo);
250 (memo, je)
251 }
252
253 fn generate_line_amount(&mut self) -> Decimal {
254 let base = self.config.avg_invoice_amount;
255 let variation = base * self.config.amount_variation;
256 let random: f64 = self.rng.random_range(-1.0..1.0);
257 (base + variation * Decimal::try_from(random).unwrap_or_default())
258 .max(dec!(100))
259 .round_dp(2)
260 }
261
262 fn generate_variance(&mut self) -> Decimal {
263 let random: f64 = self.rng.random_range(-100.0..100.0);
264 Decimal::try_from(random).unwrap_or_default().round_dp(2)
265 }
266
267 fn random_payment_method(&mut self) -> APPaymentMethod {
268 match self.rng.random_range(0..4) {
269 0 => APPaymentMethod::WireTransfer,
270 1 => APPaymentMethod::Check,
271 2 => APPaymentMethod::ACH,
272 _ => APPaymentMethod::SEPA,
273 }
274 }
275
276 fn generate_invoice_je(&self, invoice: &APInvoice) -> JournalEntry {
277 let mut je = JournalEntry::new_simple(
278 format!("JE-{}", invoice.invoice_number),
279 invoice.company_code.clone(),
280 invoice.posting_date,
281 format!("AP Invoice {}", invoice.invoice_number),
282 );
283
284 je.add_line(JournalEntryLine {
286 line_number: 1,
287 gl_account: expense_accounts::COGS.to_string(),
288 debit_amount: invoice.net_amount.document_amount,
289 reference: Some(invoice.invoice_number.clone()),
290 ..Default::default()
291 });
292
293 if invoice.tax_amount.document_amount > Decimal::ZERO {
295 je.add_line(JournalEntryLine {
296 line_number: 2,
297 gl_account: tax_accounts::TAX_RECEIVABLE.to_string(),
298 debit_amount: invoice.tax_amount.document_amount,
299 reference: Some(invoice.invoice_number.clone()),
300 tax_code: Some("VAT".to_string()),
301 ..Default::default()
302 });
303 }
304
305 je.add_line(JournalEntryLine {
307 line_number: 3,
308 gl_account: control_accounts::AP_CONTROL.to_string(),
309 credit_amount: invoice.gross_amount.document_amount,
310 reference: Some(invoice.invoice_number.clone()),
311 assignment: Some(invoice.vendor_id.clone()),
312 ..Default::default()
313 });
314
315 je
316 }
317
318 fn generate_payment_je(&self, payment: &APPayment) -> JournalEntry {
319 let mut je = JournalEntry::new_simple(
320 format!("JE-{}", payment.payment_number),
321 payment.company_code.clone(),
322 payment.posting_date,
323 format!("AP Payment {}", payment.payment_number),
324 );
325
326 let ap_debit = payment.net_payment + payment.discount_taken;
328 je.add_line(JournalEntryLine {
329 line_number: 1,
330 gl_account: control_accounts::AP_CONTROL.to_string(),
331 debit_amount: ap_debit,
332 reference: Some(payment.payment_number.clone()),
333 assignment: Some(payment.vendor_id.clone()),
334 ..Default::default()
335 });
336
337 je.add_line(JournalEntryLine {
339 line_number: 2,
340 gl_account: cash_accounts::OPERATING_CASH.to_string(),
341 credit_amount: payment.net_payment,
342 reference: Some(payment.payment_number.clone()),
343 ..Default::default()
344 });
345
346 if payment.discount_taken > Decimal::ZERO {
348 je.add_line(JournalEntryLine {
349 line_number: 3,
350 gl_account: revenue_accounts::PURCHASE_DISCOUNT_INCOME.to_string(),
351 credit_amount: payment.discount_taken,
352 reference: Some(payment.payment_number.clone()),
353 ..Default::default()
354 });
355 }
356
357 je
358 }
359
360 fn generate_debit_memo_je(&self, memo: &APDebitMemo) -> JournalEntry {
361 let mut je = JournalEntry::new_simple(
362 format!("JE-{}", memo.debit_memo_number),
363 memo.company_code.clone(),
364 memo.posting_date,
365 format!("AP Debit Memo {}", memo.debit_memo_number),
366 );
367
368 je.add_line(JournalEntryLine {
370 line_number: 1,
371 gl_account: control_accounts::AP_CONTROL.to_string(),
372 debit_amount: memo.gross_amount.document_amount,
373 reference: Some(memo.debit_memo_number.clone()),
374 assignment: Some(memo.vendor_id.clone()),
375 ..Default::default()
376 });
377
378 je.add_line(JournalEntryLine {
380 line_number: 2,
381 gl_account: expense_accounts::COGS.to_string(),
382 credit_amount: memo.net_amount.document_amount,
383 reference: Some(memo.debit_memo_number.clone()),
384 ..Default::default()
385 });
386
387 if memo.tax_amount.document_amount > Decimal::ZERO {
389 je.add_line(JournalEntryLine {
390 line_number: 3,
391 gl_account: tax_accounts::TAX_RECEIVABLE.to_string(),
392 credit_amount: memo.tax_amount.document_amount,
393 reference: Some(memo.debit_memo_number.clone()),
394 tax_code: Some("VAT".to_string()),
395 ..Default::default()
396 });
397 }
398
399 je
400 }
401}
402
403#[cfg(test)]
404#[allow(clippy::unwrap_used)]
405mod tests {
406 use super::*;
407 use rand::SeedableRng;
408
409 #[test]
410 fn test_generate_invoice() {
411 let rng = ChaCha8Rng::seed_from_u64(12345);
412 let mut generator = APGenerator::new(APGeneratorConfig::default(), rng);
413
414 let (invoice, je) = generator.generate_invoice(
415 "1000",
416 "VEND001",
417 "Test Vendor",
418 "V-INV-001",
419 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
420 "USD",
421 2,
422 Some("PO001"),
423 );
424
425 assert_eq!(invoice.lines.len(), 2);
426 assert!(invoice.gross_amount.document_amount > Decimal::ZERO);
427 assert!(je.is_balanced());
428 }
429}