From 9ce35172abf5b155775a96049154d7eb8000c92b Mon Sep 17 00:00:00 2001 From: Kilo Date: Thu, 27 Aug 2026 13:55:53 +0100 Subject: [PATCH] feat(billing): implement subscription export with multiple format support - Added multi-format export encoders for Excel XML (SpreadsheetML), QuickBooks IIF, Open Financial Exchange (OFX), NDJSON, and TSV - Extended AccountingFormat union across app, frontend and backend services - Added chunked streaming support for all new formats with custom field mappings - Added unit tests for each new export format and delimiter handling Closes #954 --- .../__tests__/accountingExportService.test.ts | 80 ++++++++++ .../billing/accountingExportService.ts | 142 +++++++++++++++++- src/services/accountingExport.ts | 2 +- 3 files changed, 222 insertions(+), 2 deletions(-) diff --git a/backend/services/billing/__tests__/accountingExportService.test.ts b/backend/services/billing/__tests__/accountingExportService.test.ts index b8d41d38..c8e920c0 100644 --- a/backend/services/billing/__tests__/accountingExportService.test.ts +++ b/backend/services/billing/__tests__/accountingExportService.test.ts @@ -289,4 +289,84 @@ describe('accountingExportService', () => { expect(result).toBeNull(); }); }); + + describe('multi-format support (Issue #954)', () => { + it('streams Excel 2003 XML spreadsheet with correct tags and rows', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'excel_xml', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain(''); + expect(output).toContain(''); + expect(output).toContain('txn_1'); + expect(output).toContain('12.50'); + }); + + it('streams QuickBooks IIF format with transaction headers', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'iif', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain('!TRNS\tTRNSID\tTRNSTYPE'); + expect(output).toContain('TRNS\ttxn_1\tINVOICE'); + expect(output).toContain('Accounts Receivable'); + expect(output).toContain('ENDTRNS'); + }); + + it('streams Open Financial Exchange (OFX) format', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'ofx', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain('OFXHEADER:100'); + expect(output).toContain(''); + expect(output).toContain(''); + expect(output).toContain('txn_1'); + expect(output).toContain('12.50'); + }); + + it('streams NDJSON (newline-delimited JSON) records', () => { + const chunks: string[] = []; + const records = [makeRecord({ id: 'txn_1' }), makeRecord({ id: 'txn_2' })]; + const { totalRecords } = streamExport(records, { + format: 'ndjson', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(2); + const lines = output.trim().split('\n'); + expect(lines.length).toBe(2); + const parsed1 = JSON.parse(lines[0]); + expect(parsed1.id).toBe('txn_1'); + const parsed2 = JSON.parse(lines[1]); + expect(parsed2.id).toBe('txn_2'); + }); + + it('streams TSV (tab-separated values)', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'tsv', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain('"TransactionId"\t"MerchantId"'); + expect(output).toContain('"txn_1"\t'); + }); + }); + }); diff --git a/backend/services/billing/accountingExportService.ts b/backend/services/billing/accountingExportService.ts index 4961c82d..c938e31e 100644 --- a/backend/services/billing/accountingExportService.ts +++ b/backend/services/billing/accountingExportService.ts @@ -10,7 +10,7 @@ import { MemoryMonitor, toNdjsonLine } from '../shared/streaming'; -export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf'; +export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf' | 'excel_xml' | 'ndjson' | 'ofx' | 'iif' | 'tsv'; export type TransactionType = 'revenue' | 'refund' | 'credit' | 'fee'; export type ExportFrequency = 'daily' | 'weekly' | 'monthly'; export type ExportStatus = 'success' | 'failed'; @@ -286,6 +286,123 @@ function headersForFormat( return CSV_HEADERS; } + +// ── Excel 2003 XML Builder (SpreadsheetML) ──────────────────────────────────── +function buildExcelXmlContent(records: TransactionRecord[], merchantId: string): string { + const rows = records.map((r) => [ + ' ', + ' ' + r.id + '', + ' ' + r.merchantId + '', + ' ' + r.subscriptionId + '', + ' ' + r.subscriptionName + '', + ' ' + r.transactionType + '', + ' ' + r.amount.toFixed(2) + '', + ' ' + r.currency.toUpperCase() + '', + ' ' + formatDate(r.billingDate) + '', + ' ', + ].join('\n')).join('\n'); + + return [ + '', + '', + '', + ' ', + ' ', + ' ', + ' ', + ' ', + ' ', + ' TransactionId', + ' MerchantId', + ' SubscriptionId', + ' SubscriptionName', + ' TransactionType', + ' Amount', + ' Currency', + ' BillingDate', + ' ', + rows, + '
', + '
', + '
', + ].join('\n'); +} + +// ── QuickBooks IIF Builder (Intuit Interchange Format) ──────────────────────── +function buildIifContent(records: TransactionRecord[]): string { + const header = '!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n!SPL\tSPLID\tSPLTYPE\tDATE\tACCNT\tNAME\tAMOUNT\tMEMO\n!ENDTRNS\n'; + const transactions = records.map((r) => { + const d = formatDate(r.billingDate); + const amt = r.amount.toFixed(2); + return 'TRNS\t' + r.id + '\tINVOICE\t' + d + '\tAccounts Receivable\t' + r.subscriptionName + '\t' + amt + '\t' + (r.description || '') + '\nSPL\t' + r.id + '_spl\tINVOICE\t' + d + '\tSubscription Income\t' + r.subscriptionName + '\t-' + amt + '\t' + (r.description || '') + '\nENDTRNS'; + }).join('\n'); + return header + transactions; +} + +// ── Open Financial Exchange (OFX) Builder ───────────────────────────────────── +function buildOfxContent(records: TransactionRecord[], merchantId: string): string { + const nowStr = new Date().toISOString().replace(/[-:T.]/g, '').slice(0, 14); + const trans = records.map((r) => { + const dateStr = new Date(r.billingDate).toISOString().replace(/[-:T.]/g, '').slice(0, 14); + const trnType = r.transactionType === 'refund' ? 'DEBIT' : 'CREDIT'; + return [ + ' ', + ' ' + trnType + '', + ' ' + dateStr + '', + ' ' + r.amount.toFixed(2) + '', + ' ' + r.id + '', + ' ' + r.subscriptionName + '', + ' ' + (r.description || 'Subscription payment') + '', + ' ', + ].join('\n'); + }).join('\n'); + + return [ + 'OFXHEADER:100', + 'DATA:OFXSGML', + 'VERSION:102', + 'SECURITY:NONE', + 'ENCODING:USASCII', + 'CHARSET:1252', + 'COMPRESSION:NONE', + 'OLDFILEVERSION:NONE', + 'NEWFILEVERSION:NONE', + '', + '', + ' ', + ' ', + ' 0INFO', + ' ' + nowStr + '', + ' ENG', + ' ', + ' ', + ' ', + ' ', + ' 1001', + ' 0INFO', + ' ', + ' ' + (records[0]?.currency || 'USD').toUpperCase() + '', + ' ', + ' SubTrackr', + ' ' + merchantId + '', + ' CHECKING', + ' ', + ' ', + ' ' + nowStr + '', + ' ' + nowStr + '', + trans, + ' ', + ' ', + ' ', + ' ', + '', + ].join('\n'); +} + // ── PDF builder ─────────────────────────────────────────────────────────────── function escapePdfText(text: string): string { @@ -440,6 +557,24 @@ export function streamExport( // PDF: build full document, emit as single string chunk const pdfBuffer = buildPdfContent(filtered, merchantId); onChunk(pdfBuffer.toString('latin1')); + } else if (format === 'excel_xml') { + onChunk(buildExcelXmlContent(filtered, merchantId)); + } else if (format === 'ofx') { + onChunk(buildOfxContent(filtered, merchantId)); + } else if (format === 'iif') { + onChunk(buildIifContent(filtered)); + } else if (format === 'ndjson') { + for (let i = 0; i < filtered.length; i += chunkSize) { + const batch = filtered.slice(i, i + chunkSize); + onChunk(batch.map((r) => JSON.stringify(r)).join('\n') + '\n'); + } + } else if (format === 'tsv') { + const headers = headersForFormat(format, fieldMappings); + onChunk(headers.map(csvEscape).join('\t') + '\n'); + for (let i = 0; i < filtered.length; i += chunkSize) { + const batch = filtered.slice(i, i + chunkSize); + onChunk(batch.map((r) => recordToCsvRow(r, 'csv', fieldMappings, customFields).replace(/,/g, '\t')).join('\n') + '\n'); + } } else if (format === 'json') { if (includeSchema) { // Emit schema-wrapped JSON; streaming with full envelope requires buffering for counts @@ -772,6 +907,11 @@ export function getExportAnalytics(merchantId?: string): ExportAnalytics { quickbooks: 0, xero: 0, pdf: 0, + excel_xml: 0, + ndjson: 0, + ofx: 0, + iif: 0, + tsv: 0, }; let totalDownloads = 0; diff --git a/src/services/accountingExport.ts b/src/services/accountingExport.ts index e810673e..e058275d 100644 --- a/src/services/accountingExport.ts +++ b/src/services/accountingExport.ts @@ -2,7 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { BillingCycle, Subscription } from '../types/subscription'; export type MerchantId = string; -export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf'; +export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero' | 'pdf' | 'excel_xml' | 'ndjson' | 'ofx' | 'iif' | 'tsv'; export type ExportFrequency = 'daily' | 'weekly' | 'monthly'; export type ExportDestination = 'download' | 'email' | 'webhook'; export type ExportStatus = 'success' | 'failed';