Skip to main content

datasynth_output/formats/
netsuite.rs

1//! NetSuite format export.
2//!
3//! Exports data in NetSuite-compatible formats for journal entries
4//! with support for custom fields, subsidiaries, and multi-book accounting.
5
6use chrono::{Datelike, NaiveDate, Utc};
7use rust_decimal::Decimal;
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::fs::File;
11use std::io::{BufWriter, Write};
12use std::path::Path;
13
14use datasynth_core::error::{SynthError, SynthResult};
15use datasynth_core::models::JournalEntry;
16
17/// NetSuite journal entry header.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NetSuiteJournalEntry {
20    /// Internal ID
21    pub internal_id: u64,
22    /// External ID (for import)
23    pub external_id: String,
24    /// Transaction number
25    pub tran_id: String,
26    /// Transaction date
27    pub tran_date: NaiveDate,
28    /// Posting period (internal ID)
29    pub posting_period: String,
30    /// Subsidiary (internal ID)
31    pub subsidiary: u64,
32    /// Currency (internal ID or ISO code)
33    pub currency: String,
34    /// Exchange rate
35    pub exchange_rate: Decimal,
36    /// Memo
37    pub memo: Option<String>,
38    /// Is approved
39    pub approved: bool,
40    /// Created date
41    pub created_date: NaiveDate,
42    /// Last modified date
43    pub last_modified_date: NaiveDate,
44    /// Created by (employee ID)
45    pub created_by: Option<u64>,
46    /// Reversal date (for reversing journals)
47    pub reversal_date: Option<NaiveDate>,
48    /// Reversal defer (if reversal is deferred)
49    pub reversal_defer: bool,
50    /// Department (if header-level)
51    pub department: Option<u64>,
52    /// Class (if header-level)
53    pub class: Option<u64>,
54    /// Location (if header-level)
55    pub location: Option<u64>,
56    /// Custom fields
57    pub custom_fields: HashMap<String, String>,
58    /// Total debits
59    pub total_debit: Decimal,
60    /// Total credits
61    pub total_credit: Decimal,
62}
63
64impl Default for NetSuiteJournalEntry {
65    fn default() -> Self {
66        let now = Utc::now().date_naive();
67        Self {
68            internal_id: 0,
69            external_id: String::new(),
70            tran_id: String::new(),
71            tran_date: now,
72            posting_period: String::new(),
73            subsidiary: 1,
74            currency: "USD".to_string(),
75            exchange_rate: Decimal::ONE,
76            memo: None,
77            approved: true,
78            created_date: now,
79            last_modified_date: now,
80            created_by: None,
81            reversal_date: None,
82            reversal_defer: false,
83            department: None,
84            class: None,
85            location: None,
86            custom_fields: HashMap::new(),
87            total_debit: Decimal::ZERO,
88            total_credit: Decimal::ZERO,
89        }
90    }
91}
92
93/// NetSuite journal entry line.
94#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct NetSuiteJournalLine {
96    /// Line number
97    pub line: u32,
98    /// Account (internal ID)
99    pub account: u64,
100    /// Account name (for reference)
101    pub account_name: Option<String>,
102    /// Debit amount
103    pub debit: Option<Decimal>,
104    /// Credit amount
105    pub credit: Option<Decimal>,
106    /// Line memo
107    pub memo: Option<String>,
108    /// Entity (customer/vendor internal ID)
109    pub entity: Option<u64>,
110    /// Entity type
111    pub entity_type: Option<String>,
112    /// Department
113    pub department: Option<u64>,
114    /// Class
115    pub class: Option<u64>,
116    /// Location
117    pub location: Option<u64>,
118    /// Eliminate intercompany
119    pub eliminate: bool,
120    /// Tax code (if applicable)
121    pub tax_code: Option<String>,
122    /// Tax amount
123    pub tax_amount: Option<Decimal>,
124    /// Custom fields for the line
125    pub custom_fields: HashMap<String, String>,
126}
127
128/// NetSuite export configuration.
129#[derive(Debug, Clone)]
130pub struct NetSuiteExportConfig {
131    /// Default subsidiary ID
132    pub default_subsidiary: u64,
133    /// Subsidiary mapping (company code -> NetSuite subsidiary ID)
134    pub subsidiary_map: HashMap<String, u64>,
135    /// Account mapping (GL account -> NetSuite account ID)
136    pub account_map: HashMap<String, u64>,
137    /// Currency mapping (ISO -> NetSuite currency ID)
138    pub currency_map: HashMap<String, u64>,
139    /// Department mapping
140    pub department_map: HashMap<String, u64>,
141    /// Class mapping
142    pub class_map: HashMap<String, u64>,
143    /// Location mapping
144    pub location_map: HashMap<String, u64>,
145    /// Include custom fields
146    pub include_custom_fields: bool,
147    /// Custom field definitions for fraud/anomaly flags
148    pub fraud_custom_field: Option<String>,
149    /// Custom field for business process
150    pub process_custom_field: Option<String>,
151}
152
153impl Default for NetSuiteExportConfig {
154    fn default() -> Self {
155        Self {
156            default_subsidiary: 1,
157            subsidiary_map: HashMap::new(),
158            account_map: HashMap::new(),
159            currency_map: HashMap::new(),
160            department_map: HashMap::new(),
161            class_map: HashMap::new(),
162            location_map: HashMap::new(),
163            include_custom_fields: true,
164            fraud_custom_field: Some("custbody_fraud_flag".to_string()),
165            process_custom_field: Some("custbody_business_process".to_string()),
166        }
167    }
168}
169
170/// NetSuite format exporter.
171pub struct NetSuiteExporter {
172    config: NetSuiteExportConfig,
173    journal_counter: u64,
174    /// Account ID mapping (generated if not provided)
175    generated_account_ids: HashMap<String, u64>,
176    next_account_id: u64,
177}
178
179impl NetSuiteExporter {
180    /// Create a new NetSuite exporter.
181    pub fn new(config: NetSuiteExportConfig) -> Self {
182        Self {
183            config,
184            journal_counter: 0,
185            generated_account_ids: HashMap::new(),
186            next_account_id: 1000,
187        }
188    }
189
190    /// Get subsidiary ID for a company code.
191    fn get_subsidiary(&self, company_code: &str) -> u64 {
192        self.config
193            .subsidiary_map
194            .get(company_code)
195            .copied()
196            .unwrap_or(self.config.default_subsidiary)
197    }
198
199    /// Get or generate account ID for a GL account.
200    fn get_account_id(&mut self, gl_account: &str) -> u64 {
201        if let Some(&id) = self.config.account_map.get(gl_account) {
202            return id;
203        }
204        if let Some(&id) = self.generated_account_ids.get(gl_account) {
205            return id;
206        }
207        let id = self.next_account_id;
208        self.next_account_id += 1;
209        self.generated_account_ids
210            .insert(gl_account.to_string(), id);
211        id
212    }
213
214    /// Generate posting period from date.
215    fn posting_period(date: NaiveDate) -> String {
216        // NetSuite period format: "Jun 2024"
217        let month = match date.month() {
218            1 => "Jan",
219            2 => "Feb",
220            3 => "Mar",
221            4 => "Apr",
222            5 => "May",
223            6 => "Jun",
224            7 => "Jul",
225            8 => "Aug",
226            9 => "Sep",
227            10 => "Oct",
228            11 => "Nov",
229            12 => "Dec",
230            _ => "Jan",
231        };
232        format!("{} {}", month, date.year())
233    }
234
235    /// Convert JournalEntry to NetSuite format.
236    pub fn convert(
237        &mut self,
238        je: &JournalEntry,
239    ) -> (NetSuiteJournalEntry, Vec<NetSuiteJournalLine>) {
240        self.journal_counter += 1;
241
242        // Calculate totals
243        let mut total_debit = Decimal::ZERO;
244        let mut total_credit = Decimal::ZERO;
245        for line in &je.lines {
246            total_debit += line.debit_amount;
247            total_credit += line.credit_amount;
248        }
249
250        let mut custom_fields = HashMap::new();
251        if self.config.include_custom_fields {
252            if let Some(ref fraud_field) = self.config.fraud_custom_field {
253                if je.header.is_fraud {
254                    custom_fields.insert(fraud_field.clone(), "T".to_string());
255                    if let Some(fraud_type) = je.header.fraud_type {
256                        custom_fields
257                            .insert(format!("{}_type", fraud_field), format!("{:?}", fraud_type));
258                    }
259                }
260            }
261            if let Some(ref process_field) = self.config.process_custom_field {
262                if let Some(business_process) = je.header.business_process {
263                    custom_fields.insert(process_field.clone(), format!("{:?}", business_process));
264                }
265            }
266        }
267
268        let header = NetSuiteJournalEntry {
269            internal_id: self.journal_counter,
270            external_id: format!("JE_{}", je.header.document_id),
271            tran_id: format!("JE{:08}", self.journal_counter),
272            tran_date: je.header.posting_date,
273            posting_period: Self::posting_period(je.header.posting_date),
274            subsidiary: self.get_subsidiary(&je.header.company_code),
275            currency: je.header.currency.clone(),
276            exchange_rate: je.header.exchange_rate,
277            memo: je.header.header_text.clone(),
278            approved: true,
279            created_date: je.header.created_at.date_naive(),
280            last_modified_date: je.header.created_at.date_naive(),
281            created_by: None,
282            reversal_date: None,
283            reversal_defer: false,
284            department: None,
285            class: None,
286            location: None,
287            custom_fields,
288            total_debit,
289            total_credit,
290        };
291
292        let mut lines = Vec::new();
293        for je_line in &je.lines {
294            let account_id = self.get_account_id(&je_line.gl_account);
295
296            let mut line_custom_fields = HashMap::new();
297            if self.config.include_custom_fields {
298                if let Some(ref cost_center) = je_line.cost_center {
299                    line_custom_fields
300                        .insert("custcol_cost_center".to_string(), cost_center.clone());
301                }
302                if let Some(ref profit_center) = je_line.profit_center {
303                    line_custom_fields
304                        .insert("custcol_profit_center".to_string(), profit_center.clone());
305                }
306            }
307
308            let ns_line = NetSuiteJournalLine {
309                line: je_line.line_number,
310                account: account_id,
311                account_name: Some(je_line.gl_account.clone()),
312                debit: if je_line.debit_amount > Decimal::ZERO {
313                    Some(je_line.debit_amount)
314                } else {
315                    None
316                },
317                credit: if je_line.credit_amount > Decimal::ZERO {
318                    Some(je_line.credit_amount)
319                } else {
320                    None
321                },
322                memo: je_line.line_text.clone(),
323                entity: None,
324                entity_type: None,
325                department: je_line
326                    .cost_center
327                    .as_ref()
328                    .and_then(|cc| self.config.department_map.get(cc).copied()),
329                class: je_line
330                    .profit_center
331                    .as_ref()
332                    .and_then(|pc| self.config.class_map.get(pc).copied()),
333                location: None,
334                eliminate: je_line.trading_partner.is_some(),
335                tax_code: je_line.tax_code.clone(),
336                tax_amount: je_line.tax_amount,
337                custom_fields: line_custom_fields,
338            };
339            lines.push(ns_line);
340        }
341
342        (header, lines)
343    }
344
345    /// Export journal entries to NetSuite CSV format.
346    pub fn export_to_files(
347        &mut self,
348        entries: &[JournalEntry],
349        output_dir: &Path,
350    ) -> SynthResult<HashMap<String, String>> {
351        std::fs::create_dir_all(output_dir)?;
352
353        let mut output_files = HashMap::new();
354
355        // Export main journal entries
356        let je_path = output_dir.join("netsuite_journal_entries.csv");
357        let lines_path = output_dir.join("netsuite_journal_lines.csv");
358
359        let je_file = File::create(&je_path)?;
360        let mut je_writer = BufWriter::new(je_file);
361
362        let lines_file = File::create(&lines_path)?;
363        let mut lines_writer = BufWriter::new(lines_file);
364
365        // Write headers
366        let mut je_header = "Internal ID,External ID,Tran ID,Tran Date,Posting Period,Subsidiary,\
367            Currency,Exchange Rate,Memo,Approved,Total Debit,Total Credit"
368            .to_string();
369        if self.config.include_custom_fields {
370            if let Some(ref fraud_field) = self.config.fraud_custom_field {
371                je_header.push_str(&format!(",{},{}_type", fraud_field, fraud_field));
372            }
373            if let Some(ref process_field) = self.config.process_custom_field {
374                je_header.push_str(&format!(",{}", process_field));
375            }
376        }
377        writeln!(je_writer, "{}", je_header)?;
378
379        let mut line_header = "Journal Internal ID,Line,Account,Account Name,Debit,Credit,Memo,\
380            Department,Class,Location,Eliminate,Tax Code,Tax Amount"
381            .to_string();
382        if self.config.include_custom_fields {
383            line_header.push_str(",custcol_cost_center,custcol_profit_center");
384        }
385        writeln!(lines_writer, "{}", line_header)?;
386
387        for je in entries {
388            let (header, lines) = self.convert(je);
389
390            // Write journal entry
391            let mut je_row = format!(
392                "{},{},{},{},{},{},{},{},{},{},{},{}",
393                header.internal_id,
394                escape_csv_field(&header.external_id),
395                escape_csv_field(&header.tran_id),
396                header.tran_date,
397                escape_csv_field(&header.posting_period),
398                header.subsidiary,
399                header.currency,
400                header.exchange_rate,
401                escape_csv_field(&header.memo.unwrap_or_default()),
402                if header.approved { "T" } else { "F" },
403                header.total_debit,
404                header.total_credit,
405            );
406
407            if self.config.include_custom_fields {
408                if let Some(ref fraud_field) = self.config.fraud_custom_field {
409                    je_row.push_str(&format!(
410                        ",{},{}",
411                        header
412                            .custom_fields
413                            .get(fraud_field)
414                            .unwrap_or(&String::new()),
415                        header
416                            .custom_fields
417                            .get(&format!("{}_type", fraud_field))
418                            .unwrap_or(&String::new()),
419                    ));
420                }
421                if let Some(ref process_field) = self.config.process_custom_field {
422                    je_row.push_str(&format!(
423                        ",{}",
424                        header
425                            .custom_fields
426                            .get(process_field)
427                            .unwrap_or(&String::new()),
428                    ));
429                }
430            }
431            writeln!(je_writer, "{}", je_row)?;
432
433            // Write lines
434            for line in lines {
435                let mut line_row = format!(
436                    "{},{},{},{},{},{},{},{},{},{},{},{},{}",
437                    header.internal_id,
438                    line.line,
439                    line.account,
440                    escape_csv_field(&line.account_name.unwrap_or_default()),
441                    line.debit.map(|d| d.to_string()).unwrap_or_default(),
442                    line.credit.map(|d| d.to_string()).unwrap_or_default(),
443                    escape_csv_field(&line.memo.unwrap_or_default()),
444                    line.department.map(|d| d.to_string()).unwrap_or_default(),
445                    line.class.map(|d| d.to_string()).unwrap_or_default(),
446                    line.location.map(|d| d.to_string()).unwrap_or_default(),
447                    if line.eliminate { "T" } else { "F" },
448                    line.tax_code.as_deref().unwrap_or(""),
449                    line.tax_amount.map(|d| d.to_string()).unwrap_or_default(),
450                );
451
452                if self.config.include_custom_fields {
453                    line_row.push_str(&format!(
454                        ",{},{}",
455                        line.custom_fields
456                            .get("custcol_cost_center")
457                            .unwrap_or(&String::new()),
458                        line.custom_fields
459                            .get("custcol_profit_center")
460                            .unwrap_or(&String::new()),
461                    ));
462                }
463                writeln!(lines_writer, "{}", line_row)?;
464            }
465        }
466
467        je_writer.flush()?;
468        lines_writer.flush()?;
469
470        output_files.insert(
471            "journal_entries".to_string(),
472            je_path.to_string_lossy().to_string(),
473        );
474        output_files.insert(
475            "journal_lines".to_string(),
476            lines_path.to_string_lossy().to_string(),
477        );
478
479        // Export account mapping
480        let account_path = output_dir.join("netsuite_accounts.csv");
481        self.export_accounts(&account_path)?;
482        output_files.insert(
483            "accounts".to_string(),
484            account_path.to_string_lossy().to_string(),
485        );
486
487        Ok(output_files)
488    }
489
490    /// Export account mapping.
491    fn export_accounts(&self, filepath: &Path) -> SynthResult<()> {
492        let file = File::create(filepath)?;
493        let mut writer = BufWriter::new(file);
494
495        writeln!(writer, "Internal ID,Account Number,External ID")?;
496
497        // Export configured mappings
498        for (account_num, &account_id) in &self.config.account_map {
499            writeln!(
500                writer,
501                "{},{},ACCT_{}",
502                account_id,
503                escape_csv_field(account_num),
504                account_num,
505            )?;
506        }
507
508        // Export generated mappings
509        for (account_num, &account_id) in &self.generated_account_ids {
510            writeln!(
511                writer,
512                "{},{},ACCT_{}",
513                account_id,
514                escape_csv_field(account_num),
515                account_num,
516            )?;
517        }
518
519        writer.flush()?;
520        Ok(())
521    }
522
523    /// Export to NetSuite SuiteScript-compatible JSON format.
524    pub fn export_to_json(
525        &mut self,
526        entries: &[JournalEntry],
527        output_dir: &Path,
528    ) -> SynthResult<String> {
529        std::fs::create_dir_all(output_dir)?;
530
531        let json_path = output_dir.join("netsuite_journal_entries.json");
532        let file = File::create(&json_path)?;
533        let mut writer = BufWriter::new(file);
534
535        let mut records = Vec::new();
536        for je in entries {
537            let (header, lines) = self.convert(je);
538            records.push(serde_json::json!({
539                "recordType": "journalentry",
540                "externalId": header.external_id,
541                "tranId": header.tran_id,
542                "tranDate": header.tran_date.to_string(),
543                "postingPeriod": header.posting_period,
544                "subsidiary": header.subsidiary,
545                "currency": header.currency,
546                "exchangeRate": header.exchange_rate.to_string(),
547                "memo": header.memo,
548                "approved": header.approved,
549                "customFields": header.custom_fields,
550                "lines": lines.iter().map(|l| serde_json::json!({
551                    "line": l.line,
552                    "account": l.account,
553                    "debit": l.debit.map(|d| d.to_string()),
554                    "credit": l.credit.map(|d| d.to_string()),
555                    "memo": l.memo,
556                    "department": l.department,
557                    "class": l.class,
558                    "location": l.location,
559                    "eliminate": l.eliminate,
560                    "taxCode": l.tax_code,
561                    "customFields": l.custom_fields,
562                })).collect::<Vec<_>>(),
563            }));
564        }
565
566        let json_output = serde_json::to_string_pretty(&records)
567            .map_err(|e| SynthError::generation(format!("JSON serialization error: {}", e)))?;
568        writer.write_all(json_output.as_bytes())?;
569        writer.flush()?;
570
571        Ok(json_path.to_string_lossy().to_string())
572    }
573}
574
575/// Escape a field for CSV output.
576fn escape_csv_field(field: &str) -> String {
577    if field.contains(',') || field.contains('"') || field.contains('\n') {
578        format!("\"{}\"", field.replace('"', "\"\""))
579    } else {
580        field.to_string()
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587    use chrono::NaiveDate;
588    use datasynth_core::models::{JournalEntryHeader, JournalEntryLine};
589    use rust_decimal::Decimal;
590    use tempfile::TempDir;
591
592    fn create_test_je() -> JournalEntry {
593        let header = JournalEntryHeader::new(
594            "1000".to_string(),
595            NaiveDate::from_ymd_opt(2024, 6, 15).unwrap(),
596        );
597        let mut je = JournalEntry::new(header);
598
599        je.add_line(JournalEntryLine::debit(
600            je.header.document_id,
601            1,
602            "100000".to_string(),
603            Decimal::from(5000),
604        ));
605        je.add_line(JournalEntryLine::credit(
606            je.header.document_id,
607            2,
608            "200000".to_string(),
609            Decimal::from(5000),
610        ));
611
612        je
613    }
614
615    #[test]
616    fn test_posting_period_generation() {
617        let date = NaiveDate::from_ymd_opt(2024, 6, 15).unwrap();
618        assert_eq!(NetSuiteExporter::posting_period(date), "Jun 2024");
619
620        let date = NaiveDate::from_ymd_opt(2024, 1, 1).unwrap();
621        assert_eq!(NetSuiteExporter::posting_period(date), "Jan 2024");
622    }
623
624    #[test]
625    fn test_netsuite_exporter_creates_files() {
626        let temp_dir = TempDir::new().unwrap();
627        let config = NetSuiteExportConfig::default();
628        let mut exporter = NetSuiteExporter::new(config);
629
630        let entries = vec![create_test_je()];
631        let result = exporter.export_to_files(&entries, temp_dir.path());
632
633        assert!(result.is_ok());
634        let files = result.unwrap();
635        assert!(files.contains_key("journal_entries"));
636        assert!(files.contains_key("journal_lines"));
637        assert!(files.contains_key("accounts"));
638
639        assert!(temp_dir
640            .path()
641            .join("netsuite_journal_entries.csv")
642            .exists());
643        assert!(temp_dir.path().join("netsuite_journal_lines.csv").exists());
644        assert!(temp_dir.path().join("netsuite_accounts.csv").exists());
645    }
646
647    #[test]
648    fn test_netsuite_json_export() {
649        let temp_dir = TempDir::new().unwrap();
650        let config = NetSuiteExportConfig::default();
651        let mut exporter = NetSuiteExporter::new(config);
652
653        let entries = vec![create_test_je()];
654        let result = exporter.export_to_json(&entries, temp_dir.path());
655
656        assert!(result.is_ok());
657        assert!(temp_dir
658            .path()
659            .join("netsuite_journal_entries.json")
660            .exists());
661    }
662
663    #[test]
664    fn test_conversion_produces_balanced_totals() {
665        let config = NetSuiteExportConfig::default();
666        let mut exporter = NetSuiteExporter::new(config);
667        let je = create_test_je();
668
669        let (header, lines) = exporter.convert(&je);
670
671        assert_eq!(header.total_debit, header.total_credit);
672        assert_eq!(lines.len(), 2);
673    }
674}