Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions backend/services/__tests__/accountingExportService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import {
streamExport,
reconcile,
TransactionRecord,
TransactionType,
} from '../accountingExportService';

function makeRecord(overrides: Partial<TransactionRecord> = {}): 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');
});
});
});
250 changes: 250 additions & 0 deletions backend/services/accountingExportService.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading