1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct NetSuiteJournalEntry {
20 pub internal_id: u64,
22 pub external_id: String,
24 pub tran_id: String,
26 pub tran_date: NaiveDate,
28 pub posting_period: String,
30 pub subsidiary: u64,
32 pub currency: String,
34 pub exchange_rate: Decimal,
36 pub memo: Option<String>,
38 pub approved: bool,
40 pub created_date: NaiveDate,
42 pub last_modified_date: NaiveDate,
44 pub created_by: Option<u64>,
46 pub reversal_date: Option<NaiveDate>,
48 pub reversal_defer: bool,
50 pub department: Option<u64>,
52 pub class: Option<u64>,
54 pub location: Option<u64>,
56 pub custom_fields: HashMap<String, String>,
58 pub total_debit: Decimal,
60 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#[derive(Debug, Clone, Serialize, Deserialize, Default)]
95pub struct NetSuiteJournalLine {
96 pub line: u32,
98 pub account: u64,
100 pub account_name: Option<String>,
102 pub debit: Option<Decimal>,
104 pub credit: Option<Decimal>,
106 pub memo: Option<String>,
108 pub entity: Option<u64>,
110 pub entity_type: Option<String>,
112 pub department: Option<u64>,
114 pub class: Option<u64>,
116 pub location: Option<u64>,
118 pub eliminate: bool,
120 pub tax_code: Option<String>,
122 pub tax_amount: Option<Decimal>,
124 pub custom_fields: HashMap<String, String>,
126}
127
128#[derive(Debug, Clone)]
130pub struct NetSuiteExportConfig {
131 pub default_subsidiary: u64,
133 pub subsidiary_map: HashMap<String, u64>,
135 pub account_map: HashMap<String, u64>,
137 pub currency_map: HashMap<String, u64>,
139 pub department_map: HashMap<String, u64>,
141 pub class_map: HashMap<String, u64>,
143 pub location_map: HashMap<String, u64>,
145 pub include_custom_fields: bool,
147 pub fraud_custom_field: Option<String>,
149 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
170pub struct NetSuiteExporter {
172 config: NetSuiteExportConfig,
173 journal_counter: u64,
174 generated_account_ids: HashMap<String, u64>,
176 next_account_id: u64,
177}
178
179impl NetSuiteExporter {
180 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 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 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 fn posting_period(date: NaiveDate) -> String {
216 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 pub fn convert(
237 &mut self,
238 je: &JournalEntry,
239 ) -> (NetSuiteJournalEntry, Vec<NetSuiteJournalLine>) {
240 self.journal_counter += 1;
241
242 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 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 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 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 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 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 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 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 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 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 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
575fn 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}