Skip to main content

datasynth_generators/subledger/
ap_generator.rs

1//! AP (Accounts Payable) generator.
2
3use 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/// Configuration for AP generation.
17#[derive(Debug, Clone)]
18pub struct APGeneratorConfig {
19    /// Average invoice amount.
20    pub avg_invoice_amount: Decimal,
21    /// Invoice amount variation.
22    pub amount_variation: Decimal,
23    /// Percentage of invoices paid on time.
24    pub on_time_payment_rate: Decimal,
25    /// Average days to payment.
26    pub avg_days_to_payment: u32,
27    /// Debit memo rate.
28    pub debit_memo_rate: Decimal,
29    /// Default tax rate.
30    pub tax_rate: Decimal,
31    /// Three-way match rate.
32    pub three_way_match_rate: Decimal,
33    /// Default payment terms.
34    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
52/// Generator for AP transactions.
53pub 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    /// Creates a new AP generator.
63    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    /// Generates an AP invoice.
74    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    /// Generates a payment.
133    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    /// Generates a debit memo.
178    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        // Debit Expense
252        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        // Debit Tax Receivable
261        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        // Credit AP
273        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        // Debit AP
294        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        // Credit Cash
305        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        // Credit Discount Income
314        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        // Debit AP
336        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        // Credit Expense
346        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        // Credit Tax
355        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}