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