use chrono::{Datelike, NaiveDate};
use datasynth_core::utils::{seeded_rng, weighted_select};
use datasynth_core::FrameworkAccounts;
use rand::prelude::*;
use rand_chacha::ChaCha8Rng;
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
use std::collections::HashMap;
use tracing::debug;
use datasynth_core::models::documents::{
CustomerInvoice, CustomerInvoiceItem, CustomerInvoiceType, GoodsReceipt, GoodsReceiptItem,
PurchaseOrder, PurchaseOrderItem, VendorInvoice, VendorInvoiceItem,
};
use datasynth_core::models::intercompany::{
ICLoan, ICMatchedPair, ICTransactionType, OwnershipStructure, RecurringFrequency,
TransferPricingMethod, TransferPricingPolicy,
};
use datasynth_core::models::{JournalEntry, JournalEntryLine};
#[derive(Debug, Clone)]
pub struct ICGeneratorConfig {
pub ic_transaction_rate: f64,
pub transfer_pricing_method: TransferPricingMethod,
pub markup_percent: Decimal,
pub generate_matched_pairs: bool,
pub transaction_type_weights: HashMap<ICTransactionType, f64>,
pub generate_netting: bool,
pub netting_frequency: RecurringFrequency,
pub generate_loans: bool,
pub loan_amount_range: (Decimal, Decimal),
pub loan_interest_rate_range: (Decimal, Decimal),
pub default_currency: String,
}
impl Default for ICGeneratorConfig {
fn default() -> Self {
let mut weights = HashMap::new();
weights.insert(ICTransactionType::GoodsSale, 0.35);
weights.insert(ICTransactionType::ServiceProvided, 0.20);
weights.insert(ICTransactionType::ManagementFee, 0.15);
weights.insert(ICTransactionType::Royalty, 0.10);
weights.insert(ICTransactionType::CostSharing, 0.10);
weights.insert(ICTransactionType::LoanInterest, 0.05);
weights.insert(ICTransactionType::ExpenseRecharge, 0.05);
Self {
ic_transaction_rate: 0.15,
transfer_pricing_method: TransferPricingMethod::CostPlus,
markup_percent: dec!(5),
generate_matched_pairs: true,
transaction_type_weights: weights,
generate_netting: true,
netting_frequency: RecurringFrequency::Monthly,
generate_loans: true,
loan_amount_range: (dec!(100000), dec!(10000000)),
loan_interest_rate_range: (dec!(2), dec!(8)),
default_currency: "USD".to_string(),
}
}
}
pub struct ICGenerator {
config: ICGeneratorConfig,
rng: ChaCha8Rng,
ownership_structure: OwnershipStructure,
transfer_pricing_policies: HashMap<String, TransferPricingPolicy>,
active_loans: Vec<ICLoan>,
matched_pairs: Vec<ICMatchedPair>,
ic_counter: u64,
doc_counter: u64,
framework_accounts: FrameworkAccounts,
}
impl ICGenerator {
pub fn new_with_framework(
config: ICGeneratorConfig,
ownership_structure: OwnershipStructure,
seed: u64,
framework: &str,
) -> Self {
Self {
config,
rng: seeded_rng(seed, 0),
ownership_structure,
transfer_pricing_policies: HashMap::new(),
active_loans: Vec::new(),
matched_pairs: Vec::new(),
ic_counter: 0,
doc_counter: 0,
framework_accounts: FrameworkAccounts::for_framework(framework),
}
}
pub fn new(
config: ICGeneratorConfig,
ownership_structure: OwnershipStructure,
seed: u64,
) -> Self {
Self::new_with_framework(config, ownership_structure, seed, "us_gaap")
}
pub fn add_transfer_pricing_policy(
&mut self,
relationship_id: String,
policy: TransferPricingPolicy,
) {
self.transfer_pricing_policies
.insert(relationship_id, policy);
}
fn generate_ic_reference(&mut self, date: NaiveDate) -> String {
self.ic_counter += 1;
format!("IC{}{:06}", date.format("%Y%m"), self.ic_counter)
}
fn generate_doc_number(&mut self, prefix: &str) -> String {
self.doc_counter += 1;
format!("{}{:08}", prefix, self.doc_counter)
}
fn select_transaction_type(&mut self) -> ICTransactionType {
let options: Vec<(ICTransactionType, f64)> = self
.config
.transaction_type_weights
.iter()
.map(|(&tx_type, &weight)| (tx_type, weight))
.collect();
if options.is_empty() {
return ICTransactionType::GoodsSale;
}
*weighted_select(&mut self.rng, &options)
}
fn select_company_pair(&mut self) -> Option<(String, String)> {
let relationships = self.ownership_structure.relationships.clone();
if relationships.is_empty() {
return None;
}
let rel = relationships.choose(&mut self.rng)?;
if self.rng.random_bool(0.5) {
Some((rel.parent_company.clone(), rel.subsidiary_company.clone()))
} else {
Some((rel.subsidiary_company.clone(), rel.parent_company.clone()))
}
}
fn generate_base_amount(&mut self, tx_type: ICTransactionType) -> Decimal {
let (min, max) = match tx_type {
ICTransactionType::GoodsSale => (dec!(1000), dec!(500000)),
ICTransactionType::ServiceProvided => (dec!(5000), dec!(200000)),
ICTransactionType::ManagementFee => (dec!(10000), dec!(100000)),
ICTransactionType::Royalty => (dec!(5000), dec!(150000)),
ICTransactionType::CostSharing => (dec!(2000), dec!(50000)),
ICTransactionType::LoanInterest => (dec!(1000), dec!(50000)),
ICTransactionType::ExpenseRecharge => (dec!(500), dec!(20000)),
ICTransactionType::Dividend => (dec!(50000), dec!(1000000)),
_ => (dec!(1000), dec!(100000)),
};
let range = max - min;
let random_factor = Decimal::from_f64_retain(self.rng.random::<f64>()).unwrap_or(dec!(0.5));
(min + range * random_factor).round_dp(2)
}
fn apply_transfer_pricing(&self, base_amount: Decimal, relationship_id: &str) -> Decimal {
if let Some(policy) = self.transfer_pricing_policies.get(relationship_id) {
policy.calculate_transfer_price(base_amount)
} else {
base_amount * (Decimal::ONE + self.config.markup_percent / dec!(100))
}
}
pub fn generate_ic_transaction(
&mut self,
date: NaiveDate,
_fiscal_period: &str,
) -> Option<ICMatchedPair> {
if !self.rng.random_bool(self.config.ic_transaction_rate) {
return None;
}
let (seller, buyer) = self.select_company_pair()?;
let tx_type = self.select_transaction_type();
let base_amount = self.generate_base_amount(tx_type);
let relationship_id = format!("{seller}-{buyer}");
let transfer_price = self.apply_transfer_pricing(base_amount, &relationship_id);
let ic_reference = self.generate_ic_reference(date);
let seller_doc = self.generate_doc_number("ICS");
let buyer_doc = self.generate_doc_number("ICB");
let mut pair = ICMatchedPair::new(
ic_reference,
tx_type,
seller.clone(),
buyer.clone(),
transfer_price,
self.config.default_currency.clone(),
date,
);
pair.seller_document = seller_doc;
pair.buyer_document = buyer_doc;
if tx_type.has_withholding_tax() {
pair.calculate_withholding_tax();
}
self.matched_pairs.push(pair.clone());
Some(pair)
}
pub fn generate_journal_entries(
&mut self,
pair: &ICMatchedPair,
fiscal_year: i32,
fiscal_period: u32,
) -> (JournalEntry, JournalEntry) {
let (seller_dr_desc, seller_cr_desc) = pair.transaction_type.seller_accounts();
let (buyer_dr_desc, buyer_cr_desc) = pair.transaction_type.buyer_accounts();
let seller_entry = self.create_seller_entry(
pair,
fiscal_year,
fiscal_period,
seller_dr_desc,
seller_cr_desc,
);
let buyer_entry = self.create_buyer_entry(
pair,
fiscal_year,
fiscal_period,
buyer_dr_desc,
buyer_cr_desc,
);
(seller_entry, buyer_entry)
}
fn create_seller_entry(
&mut self,
pair: &ICMatchedPair,
_fiscal_year: i32,
_fiscal_period: u32,
dr_desc: &str,
cr_desc: &str,
) -> JournalEntry {
let mut je = JournalEntry::new_simple(
pair.seller_document.clone(),
pair.seller_company.clone(),
pair.posting_date,
format!(
"IC {} to {}",
pair.transaction_type.seller_accounts().1,
pair.buyer_company
),
);
je.header.reference = Some(pair.ic_reference.clone());
je.header.document_type = "IC".to_string();
je.header.currency = pair.currency.clone();
je.header.exchange_rate = Decimal::ONE;
je.header.created_by = "IC_GENERATOR".to_string();
let mut debit_amount = pair.amount;
if pair.withholding_tax.is_some() {
debit_amount = pair.net_amount();
}
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: self.get_seller_receivable_account(&pair.buyer_company),
debit_amount,
text: Some(format!("{} - {}", dr_desc, pair.description)),
assignment: Some(pair.ic_reference.clone()),
reference: Some(pair.buyer_document.clone()),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: self.get_seller_revenue_account(pair.transaction_type),
credit_amount: pair.amount,
text: Some(format!("{} - {}", cr_desc, pair.description)),
assignment: Some(pair.ic_reference.clone()),
..Default::default()
});
if let Some(wht) = pair.withholding_tax {
je.add_line(JournalEntryLine {
line_number: 3,
gl_account: self.framework_accounts.sales_tax_payable.clone(), debit_amount: wht,
text: Some("Withholding tax on IC transaction".to_string()),
assignment: Some(pair.ic_reference.clone()),
..Default::default()
});
}
je
}
fn create_buyer_entry(
&mut self,
pair: &ICMatchedPair,
_fiscal_year: i32,
_fiscal_period: u32,
dr_desc: &str,
cr_desc: &str,
) -> JournalEntry {
let mut je = JournalEntry::new_simple(
pair.buyer_document.clone(),
pair.buyer_company.clone(),
pair.posting_date,
format!(
"IC {} from {}",
pair.transaction_type.buyer_accounts().0,
pair.seller_company
),
);
je.header.reference = Some(pair.ic_reference.clone());
je.header.document_type = "IC".to_string();
je.header.currency = pair.currency.clone();
je.header.exchange_rate = Decimal::ONE;
je.header.created_by = "IC_GENERATOR".to_string();
je.add_line(JournalEntryLine {
line_number: 1,
gl_account: self.get_buyer_expense_account(pair.transaction_type),
debit_amount: pair.amount,
cost_center: Some("CC100".to_string()),
text: Some(format!("{} - {}", dr_desc, pair.description)),
assignment: Some(pair.ic_reference.clone()),
reference: Some(pair.seller_document.clone()),
..Default::default()
});
je.add_line(JournalEntryLine {
line_number: 2,
gl_account: self.get_buyer_payable_account(&pair.seller_company),
credit_amount: pair.amount,
text: Some(format!("{} - {}", cr_desc, pair.description)),
assignment: Some(pair.ic_reference.clone()),
..Default::default()
});
je
}
fn get_seller_receivable_account(&self, buyer_company: &str) -> String {
let suffix: String = buyer_company.chars().take(2).collect();
format!("{}{}", self.framework_accounts.ic_ar_clearing, suffix)
}
fn get_seller_revenue_account(&self, tx_type: ICTransactionType) -> String {
let fa = &self.framework_accounts;
match tx_type {
ICTransactionType::GoodsSale => fa.product_revenue.clone(),
ICTransactionType::ServiceProvided => fa.service_revenue.clone(),
ICTransactionType::ManagementFee => fa.ic_revenue.clone(),
ICTransactionType::Royalty => fa.other_revenue.clone(),
ICTransactionType::LoanInterest => fa.other_revenue.clone(),
ICTransactionType::Dividend => fa.other_revenue.clone(),
_ => fa.ic_revenue.clone(),
}
}
fn get_buyer_expense_account(&self, tx_type: ICTransactionType) -> String {
let fa = &self.framework_accounts;
match tx_type {
ICTransactionType::GoodsSale => fa.cogs.clone(),
ICTransactionType::ServiceProvided => fa.rent.clone(), ICTransactionType::ManagementFee => fa.rent.clone(), ICTransactionType::Royalty => fa.rent.clone(), ICTransactionType::LoanInterest => fa.interest_expense.clone(),
ICTransactionType::Dividend => fa.retained_earnings.clone(),
_ => fa.cogs.clone(),
}
}
fn get_buyer_payable_account(&self, seller_company: &str) -> String {
let suffix: String = seller_company.chars().take(2).collect();
format!("{}{}", self.framework_accounts.ic_ap_clearing, suffix)
}
pub fn generate_ic_loan(
&mut self,
lender: String,
borrower: String,
start_date: NaiveDate,
term_months: u32,
) -> ICLoan {
let (min_amount, max_amount) = self.config.loan_amount_range;
let range = max_amount - min_amount;
let random_factor = Decimal::from_f64_retain(self.rng.random::<f64>()).unwrap_or(dec!(0.5));
let principal = (min_amount + range * random_factor).round_dp(0);
let (min_rate, max_rate) = self.config.loan_interest_rate_range;
let rate_range = max_rate - min_rate;
let rate_factor = Decimal::from_f64_retain(self.rng.random::<f64>()).unwrap_or(dec!(0.5));
let interest_rate = (min_rate + rate_range * rate_factor).round_dp(2);
let maturity_date = start_date
.checked_add_months(chrono::Months::new(term_months))
.unwrap_or(start_date);
let loan_id = format!(
"LOAN{}{:04}",
start_date.format("%Y"),
self.active_loans.len() + 1
);
let loan = ICLoan::new(
loan_id,
lender,
borrower,
principal,
self.config.default_currency.clone(),
interest_rate,
start_date,
maturity_date,
);
self.active_loans.push(loan.clone());
loan
}
pub fn generate_loan_interest_entries(
&mut self,
as_of_date: NaiveDate,
fiscal_year: i32,
fiscal_period: u32,
) -> Vec<(JournalEntry, JournalEntry)> {
let loans_data: Vec<_> = self
.active_loans
.iter()
.filter(|loan| !loan.is_repaid())
.map(|loan| {
let period_start = NaiveDate::from_ymd_opt(
if fiscal_period == 1 {
fiscal_year - 1
} else {
fiscal_year
},
if fiscal_period == 1 {
12
} else {
fiscal_period - 1
},
1,
)
.unwrap_or(as_of_date);
let interest = loan.calculate_interest(period_start, as_of_date);
(
loan.loan_id.clone(),
loan.lender_company.clone(),
loan.borrower_company.clone(),
loan.currency.clone(),
interest,
)
})
.filter(|(_, _, _, _, interest)| *interest > Decimal::ZERO)
.collect();
let mut entries = Vec::new();
for (loan_id, lender, borrower, currency, interest) in loans_data {
let ic_ref = self.generate_ic_reference(as_of_date);
let seller_doc = self.generate_doc_number("INT");
let buyer_doc = self.generate_doc_number("INT");
let mut pair = ICMatchedPair::new(
ic_ref,
ICTransactionType::LoanInterest,
lender,
borrower,
interest,
currency,
as_of_date,
);
pair.seller_document = seller_doc;
pair.buyer_document = buyer_doc;
pair.description = format!("Interest on loan {loan_id}");
let (seller_je, buyer_je) =
self.generate_journal_entries(&pair, fiscal_year, fiscal_period);
entries.push((seller_je, buyer_je));
}
entries
}
pub fn get_matched_pairs(&self) -> &[ICMatchedPair] {
&self.matched_pairs
}
pub fn get_open_pairs(&self) -> Vec<&ICMatchedPair> {
self.matched_pairs.iter().filter(|p| p.is_open()).collect()
}
pub fn get_active_loans(&self) -> &[ICLoan] {
&self.active_loans
}
pub fn generate_transactions_for_period(
&mut self,
start_date: NaiveDate,
end_date: NaiveDate,
transactions_per_day: usize,
) -> Vec<ICMatchedPair> {
debug!(%start_date, %end_date, transactions_per_day, "Generating intercompany transactions");
let mut pairs = Vec::new();
let mut current_date = start_date;
while current_date <= end_date {
let fiscal_period = format!("{}{:02}", current_date.year(), current_date.month());
for _ in 0..transactions_per_day {
if let Some(pair) = self.generate_ic_transaction(current_date, &fiscal_period) {
pairs.push(pair);
}
}
current_date = current_date.succ_opt().unwrap_or(current_date);
}
pairs
}
pub fn reset_counters(&mut self) {
self.ic_counter = 0;
self.doc_counter = 0;
self.matched_pairs.clear();
}
pub fn generate_ic_document_chains(&mut self, pairs: &[ICMatchedPair]) -> ICDocumentChains {
let eligible_types = [
ICTransactionType::GoodsSale,
ICTransactionType::ServiceProvided,
ICTransactionType::ManagementFee,
ICTransactionType::Royalty,
ICTransactionType::ExpenseRecharge,
];
let mut chains = ICDocumentChains {
seller_invoices: Vec::new(),
buyer_orders: Vec::new(),
buyer_goods_receipts: Vec::new(),
buyer_invoices: Vec::new(),
};
for pair in pairs {
if !eligible_types.contains(&pair.transaction_type) {
continue;
}
let date = pair.posting_date;
let fiscal_year = date.year() as u16;
let fiscal_period = date.month() as u8;
let ci_doc_id = self.generate_doc_number("IC-CI");
let due_date = date + chrono::Duration::days(30);
let mut ci = CustomerInvoice::new(
&ci_doc_id,
&pair.seller_company,
&pair.buyer_company,
fiscal_year,
fiscal_period,
date,
due_date,
"IC_GENERATOR",
);
ci.invoice_type = CustomerInvoiceType::Intercompany;
ci.is_intercompany = true;
ci.ic_partner = Some(pair.buyer_company.clone());
ci.header.reference = Some(pair.ic_reference.clone());
ci.header.currency = pair.currency.clone();
ci.header.posting_date = Some(date);
let description = format!("IC {:?} to {}", pair.transaction_type, pair.buyer_company);
ci.add_item(CustomerInvoiceItem::new(
1,
&description,
Decimal::ONE,
pair.amount,
));
chains.seller_invoices.push(ci);
let po_doc_id = self.generate_doc_number("IC-PO");
let mut po = PurchaseOrder::new(
&po_doc_id,
&pair.buyer_company,
&pair.seller_company,
fiscal_year,
fiscal_period,
date,
"IC_GENERATOR",
);
po.header.reference = Some(pair.ic_reference.clone());
po.header.currency = pair.currency.clone();
let po_desc = format!(
"IC {:?} from {}",
pair.transaction_type, pair.seller_company
);
po.add_item(PurchaseOrderItem::new(
1,
&po_desc,
Decimal::ONE,
pair.amount,
));
chains.buyer_orders.push(po);
let gr_doc_id = self.generate_doc_number("IC-GR");
let mut gr = GoodsReceipt::from_purchase_order(
&gr_doc_id,
&pair.buyer_company,
&po_doc_id,
&pair.seller_company,
"1000", "0001", fiscal_year,
fiscal_period,
date,
"IC_GENERATOR",
);
gr.header.reference = Some(pair.ic_reference.clone());
gr.header.currency = pair.currency.clone();
let gr_desc = format!(
"IC {:?} receipt from {}",
pair.transaction_type, pair.seller_company
);
gr.add_item(GoodsReceiptItem::from_po(
1,
&gr_desc,
Decimal::ONE,
pair.amount,
&po_doc_id,
1,
));
chains.buyer_goods_receipts.push(gr);
let vi_doc_id = self.generate_doc_number("IC-VI");
let vendor_inv_number = format!("EXT-{}", pair.ic_reference);
let mut vi = VendorInvoice::from_po_gr(
&vi_doc_id,
&pair.buyer_company,
&pair.seller_company,
&vendor_inv_number,
&po_doc_id,
&gr_doc_id,
fiscal_year,
fiscal_period,
date,
"IC_GENERATOR",
);
vi.header.reference = Some(pair.ic_reference.clone());
vi.header.currency = pair.currency.clone();
let vi_desc = format!(
"IC {:?} invoice from {}",
pair.transaction_type, pair.seller_company
);
vi.add_item(VendorInvoiceItem::from_po_gr(
1,
&vi_desc,
Decimal::ONE,
pair.amount,
&po_doc_id,
1,
Some(gr_doc_id.clone()),
Some(1),
));
chains.buyer_invoices.push(vi);
}
chains
}
}
#[derive(Debug, Clone)]
pub struct ICDocumentChains {
pub seller_invoices: Vec<CustomerInvoice>,
pub buyer_orders: Vec<PurchaseOrder>,
pub buyer_goods_receipts: Vec<GoodsReceipt>,
pub buyer_invoices: Vec<VendorInvoice>,
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
use chrono::NaiveDate;
use datasynth_core::models::intercompany::IntercompanyRelationship;
use rust_decimal_macros::dec;
fn create_test_ownership_structure() -> OwnershipStructure {
let mut structure = OwnershipStructure::new("1000".to_string());
structure.add_relationship(IntercompanyRelationship::new(
"REL001".to_string(),
"1000".to_string(),
"1100".to_string(),
dec!(100),
NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
));
structure.add_relationship(IntercompanyRelationship::new(
"REL002".to_string(),
"1000".to_string(),
"1200".to_string(),
dec!(100),
NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
));
structure
}
#[test]
fn test_ic_generator_creation() {
let config = ICGeneratorConfig::default();
let structure = create_test_ownership_structure();
let generator = ICGenerator::new(config, structure, 12345);
assert!(generator.matched_pairs.is_empty());
assert!(generator.active_loans.is_empty());
}
#[test]
fn test_generate_ic_transaction() {
let config = ICGeneratorConfig {
ic_transaction_rate: 1.0, ..Default::default()
};
let structure = create_test_ownership_structure();
let mut generator = ICGenerator::new(config, structure, 12345);
let date = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
let pair = generator.generate_ic_transaction(date, "202206");
assert!(pair.is_some());
let pair = pair.unwrap();
assert!(!pair.ic_reference.is_empty());
assert!(pair.amount > Decimal::ZERO);
}
#[test]
fn test_generate_journal_entries() {
let config = ICGeneratorConfig {
ic_transaction_rate: 1.0,
..Default::default()
};
let structure = create_test_ownership_structure();
let mut generator = ICGenerator::new(config, structure, 12345);
let date = NaiveDate::from_ymd_opt(2022, 6, 15).unwrap();
let pair = generator.generate_ic_transaction(date, "202206").unwrap();
let (seller_je, buyer_je) = generator.generate_journal_entries(&pair, 2022, 6);
assert_eq!(seller_je.company_code(), pair.seller_company);
assert_eq!(buyer_je.company_code(), pair.buyer_company);
assert_eq!(seller_je.header.reference, Some(pair.ic_reference.clone()));
assert_eq!(buyer_je.header.reference, Some(pair.ic_reference));
}
#[test]
fn test_generate_ic_loan() {
let config = ICGeneratorConfig::default();
let structure = create_test_ownership_structure();
let mut generator = ICGenerator::new(config, structure, 12345);
let loan = generator.generate_ic_loan(
"1000".to_string(),
"1100".to_string(),
NaiveDate::from_ymd_opt(2022, 1, 1).unwrap(),
24,
);
assert!(!loan.loan_id.is_empty());
assert!(loan.principal > Decimal::ZERO);
assert!(loan.interest_rate > Decimal::ZERO);
assert_eq!(generator.active_loans.len(), 1);
}
#[test]
fn test_generate_transactions_for_period() {
let config = ICGeneratorConfig {
ic_transaction_rate: 1.0,
..Default::default()
};
let structure = create_test_ownership_structure();
let mut generator = ICGenerator::new(config, structure, 12345);
let start = NaiveDate::from_ymd_opt(2022, 6, 1).unwrap();
let end = NaiveDate::from_ymd_opt(2022, 6, 5).unwrap();
let pairs = generator.generate_transactions_for_period(start, end, 2);
assert_eq!(pairs.len(), 10);
}
}