Skip to main content

datasynth_graph/builders/
transaction_graph.rs

1//! Transaction graph builder.
2//!
3//! Builds a graph where:
4//! - Nodes are GL accounts (or entities like vendors, customers)
5//! - Edges are transactions (journal entry lines)
6
7use std::collections::HashMap;
8
9use rust_decimal::Decimal;
10
11use datasynth_core::models::JournalEntry;
12
13use crate::models::{
14    AccountNode, EdgeType, Graph, GraphEdge, GraphNode, GraphType, NodeId, NodeType,
15    TransactionEdge,
16};
17
18/// Configuration for transaction graph building.
19#[derive(Debug, Clone)]
20pub struct TransactionGraphConfig {
21    /// Whether to include vendor nodes.
22    pub include_vendors: bool,
23    /// Whether to include customer nodes.
24    pub include_customers: bool,
25    /// Whether to create edges between debit and credit accounts.
26    pub create_debit_credit_edges: bool,
27    /// Whether to create edges from/to document nodes.
28    pub include_document_nodes: bool,
29    /// Minimum edge weight to include.
30    pub min_edge_weight: f64,
31    /// Whether to aggregate parallel edges.
32    pub aggregate_parallel_edges: bool,
33}
34
35impl Default for TransactionGraphConfig {
36    fn default() -> Self {
37        Self {
38            include_vendors: false,
39            include_customers: false,
40            create_debit_credit_edges: true,
41            include_document_nodes: false,
42            min_edge_weight: 0.0,
43            aggregate_parallel_edges: false,
44        }
45    }
46}
47
48/// Builder for transaction graphs.
49pub struct TransactionGraphBuilder {
50    config: TransactionGraphConfig,
51    graph: Graph,
52    /// Map from account code to node ID.
53    account_nodes: HashMap<String, NodeId>,
54    /// Map from document number to node ID (if document nodes enabled).
55    document_nodes: HashMap<String, NodeId>,
56    /// For edge aggregation: (source, target) -> aggregated amount.
57    edge_aggregation: HashMap<(NodeId, NodeId), AggregatedEdge>,
58}
59
60impl TransactionGraphBuilder {
61    /// Creates a new transaction graph builder.
62    pub fn new(config: TransactionGraphConfig) -> Self {
63        Self {
64            config,
65            graph: Graph::new("transaction_network", GraphType::Transaction),
66            account_nodes: HashMap::new(),
67            document_nodes: HashMap::new(),
68            edge_aggregation: HashMap::new(),
69        }
70    }
71
72    /// Adds a journal entry to the graph.
73    pub fn add_journal_entry(&mut self, entry: &JournalEntry) {
74        if self.config.include_document_nodes {
75            self.add_journal_entry_with_document(entry);
76        } else if self.config.create_debit_credit_edges {
77            self.add_journal_entry_debit_credit(entry);
78        }
79    }
80
81    /// Adds journal entry creating edges between debit and credit accounts.
82    fn add_journal_entry_debit_credit(&mut self, entry: &JournalEntry) {
83        // Collect debit and credit lines
84        let debits: Vec<_> = entry
85            .lines
86            .iter()
87            .filter(|l| l.debit_amount > Decimal::ZERO)
88            .collect();
89
90        let credits: Vec<_> = entry
91            .lines
92            .iter()
93            .filter(|l| l.credit_amount > Decimal::ZERO)
94            .collect();
95
96        // Create edges from debit accounts to credit accounts
97        for debit in &debits {
98            let source_id = self.get_or_create_account_node(
99                debit.account_code(),
100                debit.account_description(),
101                entry.company_code(),
102            );
103
104            for credit in &credits {
105                let target_id = self.get_or_create_account_node(
106                    credit.account_code(),
107                    credit.account_description(),
108                    entry.company_code(),
109                );
110
111                // Calculate edge weight (proportional allocation)
112                let total_debit: Decimal = debits.iter().map(|d| d.debit_amount).sum();
113                let total_credit: Decimal = credits.iter().map(|c| c.credit_amount).sum();
114
115                let proportion =
116                    (debit.debit_amount / total_debit) * (credit.credit_amount / total_credit);
117                let edge_amount = debit.debit_amount * proportion;
118                let edge_weight: f64 = edge_amount.try_into().unwrap_or(0.0);
119
120                if edge_weight < self.config.min_edge_weight {
121                    continue;
122                }
123
124                if self.config.aggregate_parallel_edges {
125                    self.aggregate_edge(source_id, target_id, edge_weight, entry);
126                } else {
127                    let mut tx_edge = TransactionEdge::new(
128                        0,
129                        source_id,
130                        target_id,
131                        entry.document_number(),
132                        entry.posting_date(),
133                        edge_amount,
134                        true,
135                    );
136                    tx_edge.company_code = entry.company_code().to_string();
137                    tx_edge.cost_center = debit.cost_center.clone();
138                    tx_edge.compute_features();
139
140                    // Propagate anomaly flag from journal entry to graph edge
141                    if entry.header.is_anomaly {
142                        tx_edge.edge.is_anomaly = true;
143                        if let Some(ref anomaly_type) = entry.header.anomaly_type {
144                            tx_edge.edge.anomaly_type = Some(format!("{:?}", anomaly_type));
145                        }
146                    }
147
148                    self.graph.add_edge(tx_edge.edge);
149                }
150            }
151        }
152    }
153
154    /// Adds journal entry with document nodes.
155    fn add_journal_entry_with_document(&mut self, entry: &JournalEntry) {
156        // Create or get document node
157        let doc_id =
158            self.get_or_create_document_node(&entry.document_number(), entry.company_code());
159
160        // Create edges from document to each account
161        for line in &entry.lines {
162            let account_id = self.get_or_create_account_node(
163                line.account_code(),
164                line.account_description(),
165                entry.company_code(),
166            );
167
168            let is_debit = line.debit_amount > Decimal::ZERO;
169            let amount = if is_debit {
170                line.debit_amount
171            } else {
172                line.credit_amount
173            };
174
175            let mut tx_edge = TransactionEdge::new(
176                0,
177                doc_id,
178                account_id,
179                entry.document_number(),
180                entry.posting_date(),
181                amount,
182                is_debit,
183            );
184            tx_edge.company_code = entry.company_code().to_string();
185            tx_edge.cost_center = line.cost_center.clone();
186            tx_edge.compute_features();
187
188            // Propagate anomaly flag from journal entry to graph edge
189            if entry.header.is_anomaly {
190                tx_edge.edge.is_anomaly = true;
191                if let Some(ref anomaly_type) = entry.header.anomaly_type {
192                    tx_edge.edge.anomaly_type = Some(format!("{:?}", anomaly_type));
193                }
194            }
195
196            self.graph.add_edge(tx_edge.edge);
197        }
198    }
199
200    /// Gets or creates an account node.
201    fn get_or_create_account_node(
202        &mut self,
203        account_code: &str,
204        account_name: &str,
205        company_code: &str,
206    ) -> NodeId {
207        let key = format!("{}_{}", company_code, account_code);
208
209        if let Some(&id) = self.account_nodes.get(&key) {
210            return id;
211        }
212
213        let mut account = AccountNode::new(
214            0,
215            account_code.to_string(),
216            account_name.to_string(),
217            Self::infer_account_type(account_code),
218            company_code.to_string(),
219        );
220        account.is_balance_sheet = Self::is_balance_sheet_account(account_code);
221        account.normal_balance = Self::infer_normal_balance(account_code);
222        account.compute_features();
223
224        let id = self.graph.add_node(account.node);
225        self.account_nodes.insert(key, id);
226        id
227    }
228
229    /// Gets or creates a document node.
230    fn get_or_create_document_node(&mut self, document_number: &str, company_code: &str) -> NodeId {
231        let key = format!("{}_{}", company_code, document_number);
232
233        if let Some(&id) = self.document_nodes.get(&key) {
234            return id;
235        }
236
237        let node = GraphNode::new(
238            0,
239            NodeType::JournalEntry,
240            document_number.to_string(),
241            document_number.to_string(),
242        );
243
244        let id = self.graph.add_node(node);
245        self.document_nodes.insert(key, id);
246        id
247    }
248
249    /// Aggregates edges between the same source and target.
250    fn aggregate_edge(
251        &mut self,
252        source: NodeId,
253        target: NodeId,
254        weight: f64,
255        entry: &JournalEntry,
256    ) {
257        let key = (source, target);
258        let agg = self.edge_aggregation.entry(key).or_insert(AggregatedEdge {
259            source,
260            target,
261            total_weight: 0.0,
262            count: 0,
263            first_date: entry.posting_date(),
264            last_date: entry.posting_date(),
265        });
266
267        agg.total_weight += weight;
268        agg.count += 1;
269        if entry.posting_date() < agg.first_date {
270            agg.first_date = entry.posting_date();
271        }
272        if entry.posting_date() > agg.last_date {
273            agg.last_date = entry.posting_date();
274        }
275    }
276
277    /// Infers account type from account code.
278    fn infer_account_type(account_code: &str) -> String {
279        if account_code.is_empty() {
280            return "Unknown".to_string();
281        }
282
283        match account_code.chars().next().unwrap() {
284            '1' => "Asset".to_string(),
285            '2' => "Liability".to_string(),
286            '3' => "Equity".to_string(),
287            '4' => "Revenue".to_string(),
288            '5' | '6' | '7' => "Expense".to_string(),
289            _ => "Unknown".to_string(),
290        }
291    }
292
293    /// Checks if account is balance sheet.
294    fn is_balance_sheet_account(account_code: &str) -> bool {
295        if account_code.is_empty() {
296            return false;
297        }
298
299        matches!(account_code.chars().next().unwrap(), '1' | '2' | '3')
300    }
301
302    /// Infers normal balance from account code.
303    fn infer_normal_balance(account_code: &str) -> String {
304        if account_code.is_empty() {
305            return "Debit".to_string();
306        }
307
308        match account_code.chars().next().unwrap() {
309            '1' | '5' | '6' | '7' => "Debit".to_string(),
310            '2' | '3' | '4' => "Credit".to_string(),
311            _ => "Debit".to_string(),
312        }
313    }
314
315    /// Builds the final graph.
316    pub fn build(mut self) -> Graph {
317        // If aggregating, create the aggregated edges now
318        if self.config.aggregate_parallel_edges {
319            for ((source, target), agg) in self.edge_aggregation {
320                let mut edge = GraphEdge::new(0, source, target, EdgeType::Transaction)
321                    .with_weight(agg.total_weight)
322                    .with_timestamp(agg.last_date);
323
324                // Add aggregation features
325                edge.features.push((agg.total_weight + 1.0).ln());
326                edge.features.push(agg.count as f64);
327
328                let duration = (agg.last_date - agg.first_date).num_days() as f64;
329                edge.features.push(duration);
330
331                self.graph.add_edge(edge);
332            }
333        }
334
335        self.graph.compute_statistics();
336        self.graph
337    }
338
339    /// Adds multiple journal entries.
340    pub fn add_journal_entries(&mut self, entries: &[JournalEntry]) {
341        for entry in entries {
342            self.add_journal_entry(entry);
343        }
344    }
345}
346
347/// Aggregated edge data.
348#[allow(dead_code)]
349struct AggregatedEdge {
350    source: NodeId,
351    target: NodeId,
352    total_weight: f64,
353    count: usize,
354    first_date: chrono::NaiveDate,
355    last_date: chrono::NaiveDate,
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use datasynth_core::models::JournalEntryLine;
362    use rust_decimal_macros::dec;
363
364    fn create_test_entry() -> JournalEntry {
365        let mut entry = JournalEntry::new_simple(
366            "JE001".to_string(),
367            "1000".to_string(),
368            chrono::NaiveDate::from_ymd_opt(2024, 1, 15).unwrap(),
369            "Test Entry".to_string(),
370        );
371
372        let doc_id = entry.header.document_id;
373
374        entry.add_line(JournalEntryLine::debit(
375            doc_id,
376            1,
377            "1000".to_string(),
378            dec!(1000),
379        ));
380
381        entry.add_line(JournalEntryLine::credit(
382            doc_id,
383            2,
384            "4000".to_string(),
385            dec!(1000),
386        ));
387
388        entry
389    }
390
391    #[test]
392    fn test_build_transaction_graph() {
393        let mut builder = TransactionGraphBuilder::new(TransactionGraphConfig::default());
394        builder.add_journal_entry(&create_test_entry());
395
396        let graph = builder.build();
397
398        assert_eq!(graph.node_count(), 2); // Cash and Revenue
399        assert_eq!(graph.edge_count(), 1); // One transaction edge
400    }
401
402    #[test]
403    fn test_with_document_nodes() {
404        let config = TransactionGraphConfig {
405            include_document_nodes: true,
406            create_debit_credit_edges: false,
407            ..Default::default()
408        };
409
410        let mut builder = TransactionGraphBuilder::new(config);
411        builder.add_journal_entry(&create_test_entry());
412
413        let graph = builder.build();
414
415        assert_eq!(graph.node_count(), 3); // Document + Cash + Revenue
416        assert_eq!(graph.edge_count(), 2); // Document to each account
417    }
418}