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.gen::<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 self.payment_counter += 1;
153 let payment_number = format!("APPAY{:08}", self.payment_counter);
154
155 let vendor = invoices.first().expect("At least one invoice required");
156 let total_amount: Decimal = invoices.iter().map(|i| i.amount_remaining).sum();
157 let total_discount: Decimal = invoices
158 .iter()
159 .map(|i| i.available_discount(payment_date))
160 .sum();
161
162 let mut payment = APPayment::new(
163 payment_number.clone(),
164 vendor.company_code.clone(),
165 vendor.vendor_id.clone(),
166 vendor.vendor_name.clone(),
167 payment_date,
168 total_amount - total_discount,
169 vendor.gross_amount.document_currency.clone(),
170 self.random_payment_method(),
171 house_bank.to_string(),
172 bank_account.to_string(),
173 );
174
175 for invoice in invoices {
176 let discount = invoice.available_discount(payment_date);
177 payment.allocate_to_invoice(
178 invoice.invoice_number.clone(),
179 invoice.amount_remaining,
180 discount,
181 Decimal::ZERO,
182 );
183 }
184
185 let je = self.generate_payment_je(&payment);
186 (payment, je)
187 }
188
189 pub fn generate_debit_memo(
191 &mut self,
192 invoice: &APInvoice,
193 memo_date: NaiveDate,
194 reason: DebitMemoReason,
195 percent: Decimal,
196 ) -> (APDebitMemo, JournalEntry) {
197 self.debit_memo_counter += 1;
198 let memo_number = format!("APDM{:08}", self.debit_memo_counter);
199
200 let mut memo = APDebitMemo::for_invoice(
201 memo_number.clone(),
202 invoice.company_code.clone(),
203 invoice.vendor_id.clone(),
204 invoice.vendor_name.clone(),
205 memo_date,
206 invoice.invoice_number.clone(),
207 reason,
208 format!("{:?}", reason),
209 invoice.gross_amount.document_currency.clone(),
210 );
211
212 for (idx, inv_line) in invoice.lines.iter().enumerate() {
213 let line = APDebitMemoLine::new(
214 (idx + 1) as u32,
215 inv_line.description.clone(),
216 inv_line.quantity * percent,
217 inv_line.unit.clone(),
218 inv_line.unit_price,
219 inv_line.gl_account.clone(),
220 )
221 .with_tax(
222 inv_line.tax_code.clone().unwrap_or_default(),
223 inv_line.tax_rate,
224 );
225 memo.add_line(line);
226 }
227
228 let je = self.generate_debit_memo_je(&memo);
229 (memo, je)
230 }
231
232 fn generate_line_amount(&mut self) -> Decimal {
233 let base = self.config.avg_invoice_amount;
234 let variation = base * self.config.amount_variation;
235 let random: f64 = self.rng.gen_range(-1.0..1.0);
236 (base + variation * Decimal::try_from(random).unwrap_or_default())
237 .max(dec!(100))
238 .round_dp(2)
239 }
240
241 fn generate_variance(&mut self) -> Decimal {
242 let random: f64 = self.rng.gen_range(-100.0..100.0);
243 Decimal::try_from(random).unwrap_or_default().round_dp(2)
244 }
245
246 fn random_payment_method(&mut self) -> APPaymentMethod {
247 match self.rng.gen_range(0..4) {
248 0 => APPaymentMethod::WireTransfer,
249 1 => APPaymentMethod::Check,
250 2 => APPaymentMethod::ACH,
251 _ => APPaymentMethod::SEPA,
252 }
253 }
254
255 fn generate_invoice_je(&self, invoice: &APInvoice) -> JournalEntry {
256 let mut je = JournalEntry::new_simple(
257 format!("JE-{}", invoice.invoice_number),
258 invoice.company_code.clone(),
259 invoice.posting_date,
260 format!("AP Invoice {}", invoice.invoice_number),
261 );
262
263 je.add_line(JournalEntryLine {
265 line_number: 1,
266 gl_account: expense_accounts::COGS.to_string(),
267 debit_amount: invoice.net_amount.document_amount,
268 reference: Some(invoice.invoice_number.clone()),
269 ..Default::default()
270 });
271
272 if invoice.tax_amount.document_amount > Decimal::ZERO {
274 je.add_line(JournalEntryLine {
275 line_number: 2,
276 gl_account: tax_accounts::TAX_RECEIVABLE.to_string(),
277 debit_amount: invoice.tax_amount.document_amount,
278 reference: Some(invoice.invoice_number.clone()),
279 tax_code: Some("VAT".to_string()),
280 ..Default::default()
281 });
282 }
283
284 je.add_line(JournalEntryLine {
286 line_number: 3,
287 gl_account: control_accounts::AP_CONTROL.to_string(),
288 credit_amount: invoice.gross_amount.document_amount,
289 reference: Some(invoice.invoice_number.clone()),
290 assignment: Some(invoice.vendor_id.clone()),
291 ..Default::default()
292 });
293
294 je
295 }
296
297 fn generate_payment_je(&self, payment: &APPayment) -> JournalEntry {
298 let mut je = JournalEntry::new_simple(
299 format!("JE-{}", payment.payment_number),
300 payment.company_code.clone(),
301 payment.posting_date,
302 format!("AP Payment {}", payment.payment_number),
303 );
304
305 let ap_debit = payment.net_payment + payment.discount_taken;
307 je.add_line(JournalEntryLine {
308 line_number: 1,
309 gl_account: control_accounts::AP_CONTROL.to_string(),
310 debit_amount: ap_debit,
311 reference: Some(payment.payment_number.clone()),
312 assignment: Some(payment.vendor_id.clone()),
313 ..Default::default()
314 });
315
316 je.add_line(JournalEntryLine {
318 line_number: 2,
319 gl_account: cash_accounts::OPERATING_CASH.to_string(),
320 credit_amount: payment.net_payment,
321 reference: Some(payment.payment_number.clone()),
322 ..Default::default()
323 });
324
325 if payment.discount_taken > Decimal::ZERO {
327 je.add_line(JournalEntryLine {
328 line_number: 3,
329 gl_account: revenue_accounts::PURCHASE_DISCOUNT_INCOME.to_string(),
330 credit_amount: payment.discount_taken,
331 reference: Some(payment.payment_number.clone()),
332 ..Default::default()
333 });
334 }
335
336 je
337 }
338
339 fn generate_debit_memo_je(&self, memo: &APDebitMemo) -> JournalEntry {
340 let mut je = JournalEntry::new_simple(
341 format!("JE-{}", memo.debit_memo_number),
342 memo.company_code.clone(),
343 memo.posting_date,
344 format!("AP Debit Memo {}", memo.debit_memo_number),
345 );
346
347 je.add_line(JournalEntryLine {
349 line_number: 1,
350 gl_account: control_accounts::AP_CONTROL.to_string(),
351 debit_amount: memo.gross_amount.document_amount,
352 reference: Some(memo.debit_memo_number.clone()),
353 assignment: Some(memo.vendor_id.clone()),
354 ..Default::default()
355 });
356
357 je.add_line(JournalEntryLine {
359 line_number: 2,
360 gl_account: expense_accounts::COGS.to_string(),
361 credit_amount: memo.net_amount.document_amount,
362 reference: Some(memo.debit_memo_number.clone()),
363 ..Default::default()
364 });
365
366 if memo.tax_amount.document_amount > Decimal::ZERO {
368 je.add_line(JournalEntryLine {
369 line_number: 3,
370 gl_account: tax_accounts::TAX_RECEIVABLE.to_string(),
371 credit_amount: memo.tax_amount.document_amount,
372 reference: Some(memo.debit_memo_number.clone()),
373 tax_code: Some("VAT".to_string()),
374 ..Default::default()
375 });
376 }
377
378 je
379 }
380}
381
382#[cfg(test)]
383#[allow(clippy::unwrap_used)]
384mod tests {
385 use super::*;
386 use rand::SeedableRng;
387
388 #[test]
389 fn test_generate_invoice() {
390 let rng = ChaCha8Rng::seed_from_u64(12345);
391 let mut generator = APGenerator::new(APGeneratorConfig::default(), rng);
392
393 let (invoice, je) = generator.generate_invoice(
394 "1000",
395 "VEND001",
396 "Test Vendor",
397 "V-INV-001",
398 NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
399 "USD",
400 2,
401 Some("PO001"),
402 );
403
404 assert_eq!(invoice.lines.len(), 2);
405 assert!(invoice.gross_amount.document_amount > Decimal::ZERO);
406 assert!(je.is_balanced());
407 }
408}