Skip to main content

datasynth_generators/subledger/
ap_generator.rs

1//! AP (Accounts Payable) generator.
2
3use 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/// Configuration for AP generation.
23#[derive(Debug, Clone)]
24pub struct APGeneratorConfig {
25    /// Average invoice amount.
26    pub avg_invoice_amount: Decimal,
27    /// Invoice amount variation.
28    pub amount_variation: Decimal,
29    /// Percentage of invoices paid on time.
30    pub on_time_payment_rate: Decimal,
31    /// Average days to payment.
32    pub avg_days_to_payment: u32,
33    /// Debit memo rate.
34    pub debit_memo_rate: Decimal,
35    /// Default tax rate.
36    pub tax_rate: Decimal,
37    /// Three-way match rate.
38    pub three_way_match_rate: Decimal,
39    /// Default payment terms.
40    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
58/// Generator for AP transactions.
59pub 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    /// Creates a new AP generator.
69    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    /// Creates a new AP generator from a seed, constructing the RNG internally.
80    pub fn with_seed(config: APGeneratorConfig, seed: u64) -> Self {
81        Self::new(config, seeded_rng(seed, 0))
82    }
83
84    /// Generates an AP invoice.
85    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    /// Generates a payment.
145    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    /// Generates a debit memo.
211    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        // Debit Expense
285        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        // Debit Tax Receivable
294        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        // Credit AP
306        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        // Debit AP
327        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        // Credit Cash
338        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        // Credit Discount Income
347        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        // Debit AP
369        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        // Credit Expense
379        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        // Credit Tax
388        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}