diff --git a/backend/services/__tests__/accountingExportService.test.ts b/backend/services/__tests__/accountingExportService.test.ts new file mode 100644 index 0000000..3659182 --- /dev/null +++ b/backend/services/__tests__/accountingExportService.test.ts @@ -0,0 +1,144 @@ +import { + streamExport, + reconcile, + TransactionRecord, + TransactionType, +} from '../accountingExportService'; + +function makeRecord(overrides: Partial = {}): TransactionRecord { + return { + id: 'txn_1', + merchantId: 'merchant-1', + subscriptionId: 'sub_1', + subscriptionName: 'Slack', + description: 'Team chat', + category: 'software', + transactionType: 'revenue', + amount: 12.5, + currency: 'usd', + billingCycle: 'monthly', + billingDate: Date.UTC(2026, 1, 1), + deferredRevenue: 0, + createdAt: Date.UTC(2025, 11, 1), + ...overrides, + }; +} + +describe('accountingExportService', () => { + describe('streamExport', () => { + it('streams CSV with correct headers and rows', () => { + const chunks: string[] = []; + const { totalRecords } = streamExport([makeRecord()], { + format: 'csv', + onChunk: (c) => chunks.push(c), + }); + + const output = chunks.join(''); + expect(totalRecords).toBe(1); + expect(output).toContain('"TransactionId"'); + expect(output).toContain('"txn_1"'); + expect(output).toContain('"12.50"'); + }); + + it('streams QuickBooks CSV format', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { format: 'quickbooks', onChunk: (c) => chunks.push(c) }); + const output = chunks.join(''); + expect(output).toContain('"Customer"'); + expect(output).toContain('"Product/Service"'); + expect(output).toContain('"merchant-1"'); + }); + + it('streams Xero CSV format', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { format: 'xero', onChunk: (c) => chunks.push(c) }); + const output = chunks.join(''); + expect(output).toContain('"ContactName"'); + expect(output).toContain('"InvoiceNumber"'); + }); + + it('streams JSON format', () => { + const chunks: string[] = []; + streamExport([makeRecord()], { format: 'json', onChunk: (c) => chunks.push(c) }); + const output = chunks.join(''); + const parsed = JSON.parse(output); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0].id).toBe('txn_1'); + }); + + it('filters by date range', () => { + const inRange = makeRecord({ id: 'in', billingDate: Date.UTC(2026, 1, 15) }); + const outRange = makeRecord({ id: 'out', billingDate: Date.UTC(2026, 6, 1) }); + const chunks: string[] = []; + const { totalRecords } = streamExport([inRange, outRange], { + format: 'csv', + filter: { dateFrom: Date.UTC(2026, 0, 1), dateTo: Date.UTC(2026, 2, 31) }, + onChunk: (c) => chunks.push(c), + }); + expect(totalRecords).toBe(1); + expect(chunks.join('')).toContain('"in"'); + expect(chunks.join('')).not.toContain('"out"'); + }); + + it('filters by transaction type', () => { + const revenue = makeRecord({ id: 'rev', transactionType: 'revenue' }); + const refund = makeRecord({ id: 'ref', transactionType: 'refund' }); + const chunks: string[] = []; + const { totalRecords } = streamExport([revenue, refund], { + format: 'csv', + filter: { transactionTypes: ['revenue'] as TransactionType[] }, + onChunk: (c) => chunks.push(c), + }); + expect(totalRecords).toBe(1); + expect(chunks.join('')).toContain('"rev"'); + }); + + it('handles large datasets in chunks', () => { + const records = Array.from({ length: 1200 }, (_, i) => + makeRecord({ id: `txn_${i}` }) + ); + const chunkCount: number[] = []; + streamExport(records, { + format: 'csv', + chunkSize: 500, + onChunk: () => chunkCount.push(1), + }); + // header + 3 data chunks (500, 500, 200) + expect(chunkCount.length).toBe(4); + }); + }); + + describe('reconcile', () => { + it('returns balanced when all records match', () => { + const records = [makeRecord()]; + const result = reconcile(records, [ + { id: 'txn_1', amount: 12.5, transactionType: 'revenue' }, + ]); + expect(result.isBalanced).toBe(true); + expect(result.mismatches).toHaveLength(0); + expect(result.totalAmount).toBeCloseTo(12.5); + }); + + it('detects missing records', () => { + const result = reconcile([], [{ id: 'txn_missing', amount: 10, transactionType: 'revenue' }]); + expect(result.isBalanced).toBe(false); + expect(result.mismatches[0]?.reason).toContain('missing'); + }); + + it('detects amount mismatches', () => { + const result = reconcile([makeRecord({ amount: 15 })], [ + { id: 'txn_1', amount: 12.5, transactionType: 'revenue' }, + ]); + expect(result.isBalanced).toBe(false); + expect(result.mismatches[0]?.reason).toContain('amount mismatch'); + }); + + it('detects transaction type mismatches', () => { + const result = reconcile([makeRecord({ transactionType: 'refund' })], [ + { id: 'txn_1', amount: 12.5, transactionType: 'revenue' }, + ]); + expect(result.isBalanced).toBe(false); + expect(result.mismatches[0]?.reason).toContain('type mismatch'); + }); + }); +}); diff --git a/backend/services/accountingExportService.ts b/backend/services/accountingExportService.ts new file mode 100644 index 0000000..096e84d --- /dev/null +++ b/backend/services/accountingExportService.ts @@ -0,0 +1,250 @@ +/** + * Backend accounting export service. + * + * Handles large-dataset streaming exports, reconciliation checks, + * and encoding-safe output for CSV/JSON/QuickBooks/Xero formats. + */ + +export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero'; +export type TransactionType = 'revenue' | 'refund' | 'credit' | 'fee'; + +export interface TransactionRecord { + id: string; + merchantId: string; + subscriptionId: string; + subscriptionName: string; + description?: string; + category?: string; + transactionType: TransactionType; + amount: number; + currency: string; + billingCycle?: string; + billingDate: number; // Unix ms + deferredRevenue?: number; + createdAt: number; +} + +export interface ExportFilter { + merchantId?: string; + dateFrom?: number; + dateTo?: number; + transactionTypes?: TransactionType[]; + includeInactive?: boolean; +} + +export interface StreamExportOptions { + format: AccountingFormat; + filter?: ExportFilter; + /** Called with each chunk of output (for streaming large datasets). */ + onChunk: (chunk: string) => void; + /** Chunk size in number of records. Default: 500. */ + chunkSize?: number; +} + +export interface ReconciliationResult { + totalRecords: number; + totalAmount: number; + mismatches: Array<{ id: string; reason: string }>; + isBalanced: boolean; +} + +// ── Encoding helpers ────────────────────────────────────────────────────────── + +/** Escape a value for CSV, handling commas, quotes, and non-ASCII safely. */ +function csvEscape(value: string | number | null | undefined): string { + const text = value === null || value === undefined ? '' : String(value); + // Normalize to NFC to avoid encoding mismatches + const normalized = text.normalize('NFC'); + return `"${normalized.replace(/"/g, '""')}"`; +} + +function formatDate(ts: number): string { + return new Date(ts).toISOString().slice(0, 10); +} + +// ── Format builders ─────────────────────────────────────────────────────────── + +const CSV_HEADERS = [ + 'TransactionId', + 'MerchantId', + 'SubscriptionId', + 'Name', + 'Description', + 'Category', + 'Type', + 'Amount', + 'Currency', + 'BillingCycle', + 'BillingDate', + 'DeferredRevenue', + 'CreatedAt', +]; + +const QB_HEADERS = [ + 'Customer', + 'Product/Service', + 'Description', + 'Qty', + 'Rate', + 'Amount', + 'Currency', + 'Service Date', + 'Memo', +]; + +const XERO_HEADERS = [ + 'ContactName', + 'InvoiceNumber', + 'InvoiceDate', + 'DueDate', + 'Description', + 'Quantity', + 'UnitAmount', + 'AccountCode', + 'TaxType', + 'Currency', +]; + +function recordToCsvRow(r: TransactionRecord, format: AccountingFormat): string { + if (format === 'quickbooks') { + return [ + csvEscape(r.merchantId), + csvEscape(r.subscriptionName), + csvEscape(r.description ?? ''), + csvEscape('1'), + csvEscape(r.amount.toFixed(2)), + csvEscape(r.amount.toFixed(2)), + csvEscape(r.currency.toUpperCase()), + csvEscape(formatDate(r.billingDate)), + csvEscape(r.billingCycle ?? ''), + ].join(','); + } + if (format === 'xero') { + return [ + csvEscape(r.merchantId), + csvEscape(r.subscriptionId), + csvEscape(formatDate(r.createdAt)), + csvEscape(formatDate(r.billingDate)), + csvEscape(r.subscriptionName), + csvEscape('1'), + csvEscape(r.amount.toFixed(2)), + csvEscape('400'), + csvEscape('NONE'), + csvEscape(r.currency.toUpperCase()), + ].join(','); + } + // csv (generic) + return [ + csvEscape(r.id), + csvEscape(r.merchantId), + csvEscape(r.subscriptionId), + csvEscape(r.subscriptionName), + csvEscape(r.description ?? ''), + csvEscape(r.category ?? ''), + csvEscape(r.transactionType), + csvEscape(r.amount.toFixed(2)), + csvEscape(r.currency.toUpperCase()), + csvEscape(r.billingCycle ?? ''), + csvEscape(formatDate(r.billingDate)), + csvEscape((r.deferredRevenue ?? 0).toFixed(2)), + csvEscape(formatDate(r.createdAt)), + ].join(','); +} + +function headersForFormat(format: AccountingFormat): string[] { + if (format === 'quickbooks') return QB_HEADERS; + if (format === 'xero') return XERO_HEADERS; + return CSV_HEADERS; +} + +// ── Filtering ───────────────────────────────────────────────────────────────── + +function applyFilter(records: TransactionRecord[], filter: ExportFilter): TransactionRecord[] { + return records.filter((r) => { + if (filter.merchantId && r.merchantId !== filter.merchantId) return false; + if (filter.dateFrom !== undefined && r.billingDate < filter.dateFrom) return false; + if (filter.dateTo !== undefined && r.billingDate > filter.dateTo) return false; + if (filter.transactionTypes?.length && !filter.transactionTypes.includes(r.transactionType)) + return false; + return true; + }); +} + +// ── Streaming export ────────────────────────────────────────────────────────── + +/** + * Stream-export a large set of transaction records in chunks. + * Emits header first, then rows in batches to avoid memory pressure. + */ +export function streamExport( + records: TransactionRecord[], + options: StreamExportOptions +): { totalRecords: number; checksum: string } { + const { format, filter = {}, onChunk, chunkSize = 500 } = options; + const filtered = applyFilter(records, filter); + + if (format === 'json') { + // Stream JSON array in chunks + onChunk('['); + for (let i = 0; i < filtered.length; i += chunkSize) { + const batch = filtered.slice(i, i + chunkSize); + const separator = i === 0 ? '' : ','; + onChunk(separator + batch.map((r) => JSON.stringify(r)).join(',')); + } + onChunk(']'); + } else { + const headers = headersForFormat(format); + onChunk(headers.map(csvEscape).join(',') + '\n'); + for (let i = 0; i < filtered.length; i += chunkSize) { + const batch = filtered.slice(i, i + chunkSize); + onChunk(batch.map((r) => recordToCsvRow(r, format)).join('\n') + '\n'); + } + } + + // Simple checksum over record IDs for reconciliation + const cs = filtered.reduce((acc, r) => acc ^ r.id.split('').reduce((h, c) => h + c.charCodeAt(0), 0), 0); + return { totalRecords: filtered.length, checksum: Math.abs(cs).toString(16) }; +} + +// ── Reconciliation ──────────────────────────────────────────────────────────── + +/** + * Reconcile exported records against expected totals. + * Returns mismatches where amount or type doesn't match expectations. + */ +export function reconcile( + exported: TransactionRecord[], + expected: Array<{ id: string; amount: number; transactionType: TransactionType }> +): ReconciliationResult { + const exportedMap = new Map(exported.map((r) => [r.id, r])); + const mismatches: Array<{ id: string; reason: string }> = []; + let totalAmount = 0; + + for (const exp of expected) { + const actual = exportedMap.get(exp.id); + if (!actual) { + mismatches.push({ id: exp.id, reason: 'missing from export' }); + continue; + } + if (Math.abs(actual.amount - exp.amount) > 0.001) { + mismatches.push({ + id: exp.id, + reason: `amount mismatch: expected ${exp.amount}, got ${actual.amount}`, + }); + } + if (actual.transactionType !== exp.transactionType) { + mismatches.push({ + id: exp.id, + reason: `type mismatch: expected ${exp.transactionType}, got ${actual.transactionType}`, + }); + } + totalAmount += actual.amount; + } + + return { + totalRecords: exported.length, + totalAmount, + mismatches, + isBalanced: mismatches.length === 0, + }; +} diff --git a/src/screens/AccountingExportScreen.tsx b/src/screens/AccountingExportScreen.tsx index 198a2bc..8a2420a 100644 --- a/src/screens/AccountingExportScreen.tsx +++ b/src/screens/AccountingExportScreen.tsx @@ -48,6 +48,8 @@ const sourceFields: AccountingSourceField[] = [ ]; const formatLabels: Record = { + csv: 'CSV', + json: 'JSON', quickbooks: 'QuickBooks', xero: 'Xero', }; @@ -196,6 +198,15 @@ const AccountingExportScreen: React.FC = () => { Alert.alert('Scheduled exports checked', `${runs.length} due export(s) completed.`); }, [loadExportState, subscriptions]); + const handleRedownload = useCallback(async (entry: ExportHistoryEntry) => { + if (!entry.content) { + Alert.alert('Not available', 'Content was not stored for this export.'); + return; + } + await Clipboard.setStringAsync(entry.content); + Alert.alert('Re-downloaded', `${entry.fileName ?? 'Export'} copied to clipboard.`); + }, []); + return ( @@ -233,7 +244,7 @@ const AccountingExportScreen: React.FC = () => { Format - {(['quickbooks', 'xero'] as AccountingFormat[]).map((item) => ( + {(['csv', 'json', 'quickbooks', 'xero'] as AccountingFormat[]).map((item) => ( { No exports have been recorded yet. ) : ( history.map((entry) => ( - + handleRedownload(entry)}> {formatLabels[entry.format]} - {entry.itemCount} item(s) @@ -391,7 +405,7 @@ const AccountingExportScreen: React.FC = () => { style={[styles.statusPill, entry.status === 'failed' && styles.statusPillError]}> {entry.status} - + )) )} diff --git a/src/services/__tests__/accountingExport.test.ts b/src/services/__tests__/accountingExport.test.ts index fdf2e3d..870dd99 100644 --- a/src/services/__tests__/accountingExport.test.ts +++ b/src/services/__tests__/accountingExport.test.ts @@ -131,4 +131,74 @@ describe('accountingExport', () => { expect(schedules[0]?.lastRunAt).toBe(fixedNow); expect(schedules[0]?.nextRunAt).toBeGreaterThan(fixedNow); }); + + it('exports JSON format with all fields', async () => { + const result = await export_to_accounting('merchant-4', 'json', { + subscriptions: [makeSubscription()], + now: fixedNow, + }); + + expect(result.mimeType).toBe('application/json'); + expect(result.fileName).toMatch(/\.json$/); + const parsed = JSON.parse(result.content); + expect(Array.isArray(parsed)).toBe(true); + expect(parsed[0]).toMatchObject({ + merchantId: 'merchant-4', + subscriptionId: 'sub_1', + subscriptionName: 'Slack', + transactionType: 'revenue', + price: 12.5, + }); + }); + + it('filters by date range', () => { + const inRange = makeSubscription({ nextBillingDate: new Date(Date.UTC(2026, 1, 15)) }); + const outOfRange = makeSubscription({ + id: 'sub_out', + nextBillingDate: new Date(Date.UTC(2026, 5, 1)), + }); + + const csv = buildAccountingExportCsv([inRange, outOfRange], 'merchant-5', 'csv', { + includeInactive: true, + dateFrom: Date.UTC(2026, 0, 1), + dateTo: Date.UTC(2026, 2, 31), + }); + + expect(csv).toContain('sub_1'); + expect(csv).not.toContain('sub_out'); + }); + + it('filters by transaction type', () => { + const active = makeSubscription({ id: 'active_sub', isActive: true }); + const inactive = makeSubscription({ id: 'inactive_sub', isActive: false }); + + const csv = buildAccountingExportCsv([active, inactive], 'merchant-6', 'csv', { + includeInactive: true, + transactionTypes: ['revenue'], + }); + + expect(csv).toContain('active_sub'); + expect(csv).not.toContain('inactive_sub'); + }); + + it('includes deferred revenue column when requested', () => { + const csv = buildAccountingExportCsv([makeSubscription()], 'merchant-7', 'csv', { + includeDeferredRevenue: true, + deferredRevenueMap: { sub_1: 5.25 }, + }); + + expect(csv).toContain('"DeferredRevenue"'); + expect(csv).toContain('"5.25"'); + }); + + it('stores content in history for re-download', async () => { + await export_to_accounting('merchant-8', 'csv', { + subscriptions: [makeSubscription()], + now: fixedNow, + }); + + const history = await get_export_history('merchant-8'); + expect(history[0]?.content).toBeTruthy(); + expect(history[0]?.content).toContain('sub_1'); + }); }); diff --git a/src/services/accountingExport.ts b/src/services/accountingExport.ts index 88a1c51..58ce396 100644 --- a/src/services/accountingExport.ts +++ b/src/services/accountingExport.ts @@ -2,10 +2,11 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import { BillingCycle, Subscription } from '../types/subscription'; export type MerchantId = string; -export type AccountingFormat = 'quickbooks' | 'xero'; +export type AccountingFormat = 'csv' | 'json' | 'quickbooks' | 'xero'; export type ExportFrequency = 'daily' | 'weekly' | 'monthly'; export type ExportDestination = 'download' | 'email' | 'webhook'; export type ExportStatus = 'success' | 'failed'; +export type TransactionType = 'revenue' | 'refund' | 'credit' | 'fee'; export type AccountingSourceField = | 'merchantId' @@ -41,6 +42,7 @@ export interface ExportSchedule { includeInactive: boolean; fieldMappings: AccountingFieldMapping[]; customFields: Record; + transactionTypes?: TransactionType[]; nextRunAt: number; lastRunAt?: number; createdAt: number; @@ -56,6 +58,7 @@ export type ExportScheduleInput = { includeInactive?: boolean; fieldMappings?: AccountingFieldMapping[]; customFields?: Record; + transactionTypes?: TransactionType[]; nextRunAt?: number; }; @@ -69,6 +72,8 @@ export interface ExportHistoryEntry { checksum?: string; scheduleId?: string; error?: string; + /** Stored content for re-download. */ + content?: string; createdAt: number; } @@ -78,7 +83,7 @@ export interface ExportResult { format: AccountingFormat; status: ExportStatus; fileName: string; - mimeType: 'text/csv'; + mimeType: 'text/csv' | 'application/json'; content: string; itemCount: number; checksum: string; @@ -92,6 +97,16 @@ export interface ExportOptions { customFields?: Record; scheduleId?: string; now?: number; + /** Filter by transaction type(s). Defaults to all types. */ + transactionTypes?: TransactionType[]; + /** Only include subscriptions with nextBillingDate >= dateFrom (Unix ms). */ + dateFrom?: number; + /** Only include subscriptions with nextBillingDate <= dateTo (Unix ms). */ + dateTo?: number; + /** Include deferred revenue column (GAAP). */ + includeDeferredRevenue?: boolean; + /** Deferred revenue per subscriptionId for GAAP column. */ + deferredRevenueMap?: Record; } export interface ScheduledExportRun { @@ -247,7 +262,8 @@ function buildRows( function buildFileName(merchantId: MerchantId, format: AccountingFormat, now: number): string { const safeMerchant = merchantId.replace(/[^a-z0-9_-]/gi, '-').toLowerCase(); - return `${safeMerchant}-${format}-subscription-export-${formatDate(now)}.csv`; + const ext = format === 'json' ? 'json' : 'csv'; + return `${safeMerchant}-${format}-subscription-export-${formatDate(now)}.${ext}`; } function nextRunAtForFrequency(frequency: ExportFrequency, from: number): number { @@ -283,23 +299,99 @@ export function getAccountingDefaultMapping(format: AccountingFormat): Accountin return getDefaultMapping(format).map((mapping) => ({ ...mapping })); } +/** Filter subscriptions by active status, date range, and transaction types. */ +function filterSubscriptions( + subscriptions: Subscription[], + options: Pick +): Subscription[] { + let result = options.includeInactive + ? subscriptions + : subscriptions.filter((s) => s.isActive); + + if (options.dateFrom !== undefined) { + result = result.filter( + (s) => s.nextBillingDate && new Date(s.nextBillingDate).getTime() >= options.dateFrom! + ); + } + if (options.dateTo !== undefined) { + result = result.filter( + (s) => s.nextBillingDate && new Date(s.nextBillingDate).getTime() <= options.dateTo! + ); + } + // Transaction type filter: map subscription state to type + if (options.transactionTypes?.length) { + result = result.filter((s) => { + const type: TransactionType = s.isActive ? 'revenue' : 'refund'; + return options.transactionTypes!.includes(type); + }); + } + return result; +} + export function buildAccountingExportCsv( subscriptions: Subscription[], merchantId: MerchantId, format: AccountingFormat, - options: Pick = {} + options: Pick< + ExportOptions, + | 'fieldMappings' + | 'customFields' + | 'includeInactive' + | 'dateFrom' + | 'dateTo' + | 'transactionTypes' + | 'includeDeferredRevenue' + | 'deferredRevenueMap' + > = {} ): string { - const selectedSubscriptions = options.includeInactive - ? subscriptions - : subscriptions.filter((subscription) => subscription.isActive); + const selected = filterSubscriptions(subscriptions, options); const mappings = options.fieldMappings?.length ? options.fieldMappings : getDefaultMapping(format); - const headers = mappings.map((mapping) => mapping.targetField); - const rows = buildRows(selectedSubscriptions, merchantId, mappings, options.customFields ?? {}); + const headers = mappings.map((m) => m.targetField); + const rows = buildRows(selected, merchantId, mappings, options.customFields ?? {}); + + if (options.includeDeferredRevenue) { + headers.push('DeferredRevenue'); + selected.forEach((s, i) => { + rows[i].push( + Number(options.deferredRevenueMap?.[s.id] ?? 0).toFixed(2) + ); + }); + } + return buildCsv(headers, rows); } +/** Build a JSON export payload for accounting software. */ +function buildAccountingExportJson( + subscriptions: Subscription[], + merchantId: MerchantId, + options: ExportOptions +): string { + const selected = filterSubscriptions(subscriptions, options); + const records = selected.map((s) => ({ + merchantId, + subscriptionId: s.id, + subscriptionName: s.name, + description: s.description ?? '', + category: s.category, + transactionType: (s.isActive ? 'revenue' : 'refund') as TransactionType, + price: s.price, + currency: s.currency?.toUpperCase() ?? 'USD', + billingCycle: formatBillingCycle(s.billingCycle), + nextBillingDate: formatDate(s.nextBillingDate), + status: s.isActive ? 'active' : 'inactive', + createdAt: formatDate(s.createdAt), + updatedAt: formatDate(s.updatedAt), + ...(options.includeDeferredRevenue + ? { deferredRevenue: Number(options.deferredRevenueMap?.[s.id] ?? 0).toFixed(2) } + : {}), + ...(options.customFields ?? {}), + })); + return JSON.stringify(records, null, 2); +} + export async function export_to_accounting( merchant_id: MerchantId, format: AccountingFormat, @@ -309,10 +401,13 @@ export async function export_to_accounting( const subscriptions = options.subscriptions ?? []; try { - const content = buildAccountingExportCsv(subscriptions, merchant_id, format, options); - const selectedCount = options.includeInactive - ? subscriptions.length - : subscriptions.filter((subscription) => subscription.isActive).length; + const isJson = format === 'json'; + const content = isJson + ? buildAccountingExportJson(subscriptions, merchant_id, options) + : buildAccountingExportCsv(subscriptions, merchant_id, format, options); + + const selected = filterSubscriptions(subscriptions, options); + const selectedCount = selected.length; const exportId = generateId('accounting_export', now); const fileName = buildFileName(merchant_id, format, now); const contentChecksum = checksum(content); @@ -325,6 +420,7 @@ export async function export_to_accounting( fileName, checksum: contentChecksum, scheduleId: options.scheduleId, + content, // stored for re-download createdAt: now, }; @@ -336,7 +432,7 @@ export async function export_to_accounting( format, status: 'success', fileName, - mimeType: 'text/csv', + mimeType: isJson ? 'application/json' : 'text/csv', content, itemCount: selectedCount, checksum: contentChecksum,