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.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    /// 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        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    /// Generates a debit memo.
190    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        // Debit Expense
264        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        // Debit Tax Receivable
273        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        // Credit AP
285        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        // Debit AP
306        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        // Credit Cash
317        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        // Credit Discount Income
326        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        // Debit AP
348        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        // Credit Expense
358        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        // Credit Tax
367        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}