UNPKG

@accounter/shaam-uniform-format-generator

Version:

Fully typed application that generates, parses, and validates SHAAM uniform format tax reports (INI.TXT and BKMVDATA.TXT).

851 lines (724 loc) 33.8 kB
/** * Integration test for round-trip validation with actual fixture files * This test validates the BKMVDATA.txt and ini.txt fixture files by: * 1. Parsing them into structured data * 2. Generating new files from the parsed data * 3. Comparing the results to ensure data integrity */ import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { generateUniformFormatReport } from '../../src/api/generate-report.js'; import { parseUniformFormatFiles } from '../../src/api/parse-files.js'; import { parseA000, parseA000Sum, parseA100, parseB100, parseB110, parseC100, parseD110, parseD120, parseM100, parseZ900, } from '../../src/generator/records/index.js'; import type { Account, Document, InventoryItem, JournalEntry, ReportInput, } from '../../src/types/index'; const FIXTURES_DIR = join(__dirname, '..', 'fixtures'); interface ParsedFixtureData { business: ReportInput['business'] | null; documents: Document[]; journalEntries: JournalEntry[]; accounts: Account[]; inventory: InventoryItem[]; rawRecords: { a100: ReturnType<typeof parseA100> | null; b100: ReturnType<typeof parseB100>[]; b110: ReturnType<typeof parseB110>[]; c100: ReturnType<typeof parseC100>[]; d110: ReturnType<typeof parseD110>[]; d120: ReturnType<typeof parseD120>[]; m100: ReturnType<typeof parseM100>[]; z900: ReturnType<typeof parseZ900> | null; }; } interface RecordParsingResult { success: boolean; recordType?: string; parsedRecord?: | ReturnType<typeof parseA100> | ReturnType<typeof parseB100> | ReturnType<typeof parseB110> | ReturnType<typeof parseC100> | ReturnType<typeof parseD110> | ReturnType<typeof parseD120> | ReturnType<typeof parseM100> | ReturnType<typeof parseZ900>; error?: Error; } /** * Shared helper function to parse a single record line based on its record type * @param line The line to parse (should already be cleaned of line endings) * @returns Object containing parsing result and parsed record if successful */ function parseRecordLine(line: string): RecordParsingResult { if (line.length < 4) { return { success: false }; } const recordType = line.substring(0, 4); try { switch (recordType) { case 'A100': return { success: true, recordType, parsedRecord: parseA100(line) }; case 'B100': return { success: true, recordType, parsedRecord: parseB100(line) }; case 'B110': return { success: true, recordType, parsedRecord: parseB110(line) }; case 'C100': return { success: true, recordType, parsedRecord: parseC100(line) }; case 'D110': return { success: true, recordType, parsedRecord: parseD110(line) }; case 'D120': return { success: true, recordType, parsedRecord: parseD120(line) }; case 'M100': return { success: true, recordType, parsedRecord: parseM100(line) }; case 'Z900': return { success: true, recordType, parsedRecord: parseZ900(line) }; default: // Unknown record type return { success: false, recordType }; } } catch (error) { return { success: false, recordType, error: error as Error }; } } /** * Parses the BKMVDATA.txt fixture file into structured data */ async function parseFixtureData(bkmvDataPath: string): Promise<ParsedFixtureData> { const content = await readFile(bkmvDataPath, 'utf-8'); const lines = content.split('\n').filter(line => line.trim().length > 0); const result: ParsedFixtureData = { business: null, documents: [], journalEntries: [], accounts: [], inventory: [], rawRecords: { a100: null, b100: [], b110: [], c100: [], d110: [], d120: [], m100: [], z900: null, }, }; // Parse each line based on record type for (const line of lines) { if (line.length < 4) continue; // Only remove CRLF, not trailing spaces which might be significant const cleanLine = line.replace(/\r?\n?$/, ''); const parseResult = parseRecordLine(cleanLine); if (!parseResult.success) { if (parseResult.error) { // For debugging, log the line that caused the parsing error. // eslint-disable-next-line no-console console.error(`Failed to parse line: ${cleanLine}`, parseResult.error); } // Skip parsing errors for invalid lines continue; } const { recordType, parsedRecord } = parseResult; switch (recordType) { case 'A100': { result.rawRecords.a100 = parsedRecord as ReturnType<typeof parseA100>; if (result.rawRecords.a100) { result.business = { businessId: result.rawRecords.a100.primaryIdentifier.toString(), // Convert number to string name: 'Unknown Business', // Dummy value to satisfy schema taxId: result.rawRecords.a100.vatId, reportingPeriod: { startDate: '1900-01-01', // Dummy value to satisfy schema endDate: '1900-12-31', // Dummy value to satisfy schema }, }; } break; } case 'B100': { const b100 = parsedRecord as ReturnType<typeof parseB100>; result.rawRecords.b100.push(b100); result.journalEntries.push({ id: `JE_${b100.transactionNumber}`, date: b100.date.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3'), amount: b100.transactionAmount, // Use transactionAmount as-is since it already contains the correct sign accountId: b100.accountKey, description: b100.details, // Preserve exact original details (including empty strings) transactionNumber: b100.transactionNumber, transactionLineNumber: b100.transactionLineNumber, batchNumber: b100.batchNumber, transactionType: b100.transactionType, referenceDocument: b100.referenceDocument, referenceDocumentType: b100.referenceDocumentType, referenceDocument2: b100.referenceDocument2, referenceDocumentType2: b100.referenceDocumentType2, valueDate: b100.valueDate, counterAccountKey: b100.counterAccountKey, debitCreditIndicator: b100.debitCreditIndicator, currencyCode: b100.currencyCode, transactionAmount: b100.transactionAmount, // Preserve original transaction amount with sign foreignCurrencyAmount: b100.foreignCurrencyAmount, quantityField: b100.quantityField, matchingField1: b100.matchingField1, matchingField2: b100.matchingField2, branchId: b100.branchId, entryDate: b100.entryDate, operatorUsername: b100.operatorUsername, reserved: b100.reserved, }); break; } case 'B110': { const b110 = parsedRecord as ReturnType<typeof parseB110>; result.rawRecords.b110.push(b110); result.accounts.push({ id: b110.accountKey, name: b110.accountName || '', // Preserve exact original account name (including empty strings) sortCode: { key: b110.trialBalanceCode || 'Other', name: b110.trialBalanceCodeDescription || '', // Preserve exact original description (including empty strings) }, address: { street: b110.customerSupplierAddressStreet, houseNumber: b110.customerSupplierAddressHouseNumber, city: b110.customerSupplierAddressCity, zip: b110.customerSupplierAddressZip, country: b110.customerSupplierAddressCountry, }, countryCode: b110.countryCode, parentAccountKey: b110.parentAccountKey, vatId: b110.supplierCustomerTaxId, accountOpeningBalance: b110.accountOpeningBalance ?? 0, totalDebits: b110.totalDebits, totalCredits: b110.totalCredits, accountingClassificationCode: b110.accountingClassificationCode?.toString(), branchId: b110.branchId, openingBalanceForeignCurrency: b110.openingBalanceForeignCurrency, foreignCurrencyCode: b110.foreignCurrencyCode, }); break; } case 'C100': { const c100 = parsedRecord as ReturnType<typeof parseC100>; result.rawRecords.c100.push(c100); result.documents.push({ id: c100.documentId, type: c100.documentType, date: c100.documentIssueDate.replace(/(\d{4})(\d{2})(\d{2})/, '$1-$2-$3'), amount: parseFloat(c100.amountIncludingVat || '0'), description: c100.customerName || 'Document', }); break; } case 'D110': result.rawRecords.d110.push(parsedRecord as ReturnType<typeof parseD110>); break; case 'D120': result.rawRecords.d120.push(parsedRecord as ReturnType<typeof parseD120>); break; case 'M100': { const m100 = parsedRecord as ReturnType<typeof parseM100>; result.rawRecords.m100.push(m100); result.inventory.push({ id: m100.internalItemCode, name: m100.itemName, quantity: parseInt(m100.totalStockOut || '0'), }); break; } case 'Z900': result.rawRecords.z900 = parsedRecord as ReturnType<typeof parseZ900>; break; default: // Should not reach here due to parseRecordLine filtering break; } } return result; } describe.skip('Fixture Files Round-trip Integration Test', () => { it('should parse and validate the BKMVDATA.txt fixture file', async () => { const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt'); const content = await readFile(bkmvDataPath, 'utf-8'); const firstLine = content.split('\n').find(line => line.trim().length > 0); // Debug: Check the first line (should be A100) expect(firstLine).toBeDefined(); if (!firstLine) { throw new Error('The first non-empty line in BKMVDATA.txt is undefined.'); } expect(firstLine.substring(0, 4)).toBe('A100'); // Try to parse the A100 line directly (only removing CRLF, not internal spaces) const cleanLine = firstLine.replace(/\r?\n?$/, '').replace(/\r$/, ''); const a100 = parseA100(cleanLine); expect(a100).toBeDefined(); expect(a100.vatId).toBeTruthy(); const parsedData = await parseFixtureData(bkmvDataPath); // Debug: Check what was parsed expect(parsedData.rawRecords.a100).toBeDefined(); if (parsedData.rawRecords.a100) { // Check that A100 has the expected fields expect(parsedData.rawRecords.a100.vatId).toBeTruthy(); expect(parsedData.rawRecords.a100.primaryIdentifier).toBeTruthy(); } // Validate that we parsed some data expect(parsedData.business).toBeDefined(); expect(parsedData.business?.taxId).toBeTruthy(); // Check that we have some tax ID expect(parsedData.business?.name).toBeTruthy(); // Check that we have various record types expect(parsedData.rawRecords.a100).toBeDefined(); expect(parsedData.rawRecords.b100.length).toBeGreaterThan(0); expect(parsedData.rawRecords.z900).toBeDefined(); // Validate some business logic if (parsedData.rawRecords.z900) { const totalDataRecords = parsedData.rawRecords.z900.totalRecords; // totalRecords is now a number const actualDataRecords = (parsedData.rawRecords.a100 ? 1 : 0) + parsedData.rawRecords.b100.length + parsedData.rawRecords.b110.length + parsedData.rawRecords.c100.length + parsedData.rawRecords.d110.length + parsedData.rawRecords.d120.length + parsedData.rawRecords.m100.length; // The fixture may have more records than we parse, so just ensure we parsed some expect(actualDataRecords).toBeGreaterThan(0); expect(totalDataRecords).toBeGreaterThan(0); } // Validate parsed data structure expect(parsedData.business).toBeDefined(); expect(parsedData.business?.taxId).toBe('516232675'); // Actual tax ID from fixture expect(parsedData.business?.name).toBeTruthy(); }); it('should perform a complete round-trip with fixture data', async () => { const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt'); const bkmvContent = await readFile(bkmvDataPath, 'utf-8'); const iniDataPath = join(FIXTURES_DIR, 'ini.txt'); const iniContent = await readFile(iniDataPath, 'utf-8'); const parsedData = await parseUniformFormatFiles(iniContent, bkmvContent); // Skip test if we couldn't parse meaningful data if (!parsedData.data.business) { expect( parsedData.data.business, 'Business data could not be parsed from fixture', ).toBeDefined(); return; // This line is for type-safety and will not be reached if the assertion fails. } // Generate new files from the parsed data const generatedReport = generateUniformFormatReport(parsedData.data); expect(generatedReport).toBeDefined(); expect(generatedReport.dataText).toBeDefined(); expect(generatedReport.iniText).toBeDefined(); // Parse the generated data back using the new parsing function const reparsedData = parseUniformFormatFiles(generatedReport.iniText, generatedReport.dataText); // Validate basic structure expect(reparsedData.data.business).toBeDefined(); expect(reparsedData.data.business.taxId).toBe(parsedData.data.business.taxId); // Validate that we preserved the data structure through round-trip if (parsedData.data.journalEntries.length > 0) { expect(reparsedData.data.journalEntries).toHaveLength(parsedData.data.journalEntries.length); } if (parsedData.data.accounts.length > 0) { expect(reparsedData.data.accounts).toHaveLength(parsedData.data.accounts.length); } if (parsedData.data.documents.length > 0) { expect(reparsedData.data.documents).toHaveLength(parsedData.data.documents.length); } if (parsedData.data.inventory.length > 0) { expect(reparsedData.data.inventory).toHaveLength(parsedData.data.inventory.length); } // Validate round-trip completed successfully expect(generatedReport.summary.totalRecords).toBeGreaterThan(0); // DEEP DATA FILE CONTENT COMPARISON: Compare actual file content line by line // The round-trip should produce nearly identical SHAAM records const originalContent = await readFile(bkmvDataPath, 'utf-8'); const originalLines = originalContent.split('\n').filter(line => line.trim().length > 0); const generatedLines = generatedReport.dataText.split('\r\n').filter(line => line.length > 0); // Both files should have the same number of data record lines expect(generatedLines.length).toBe(originalLines.length); // Compare each line - any differences should be investigated for (let i = 0; i < originalLines.length; i++) { const originalLine = originalLines[i].replace(/\r$/, ''); // Remove CR if present const generatedLine = generatedLines[i]; if (originalLine.length < 4) continue; const recordType = originalLine.substring(0, 4); // Normalize both lines by masking variable fields that are expected to differ const normalizedOriginal = normalizeLineForComparison(originalLine, recordType); const normalizedGenerated = normalizeLineForComparison(generatedLine, recordType); if (normalizedOriginal === normalizedGenerated) { // Perfect match after normalization - continue continue; } // Found a mismatch - provide detailed information const maxLen = Math.max(normalizedOriginal.length, normalizedGenerated.length); let differenceReport = `\nDATA FILE ${recordType} record ${i + 1} mismatch after normalization:\n`; differenceReport += `Original: "${originalLine}"\n`; differenceReport += `Generated: "${generatedLine}"\n`; differenceReport += `Normalized Original: "${normalizedOriginal}"\n`; differenceReport += `Normalized Generated: "${normalizedGenerated}"\n`; differenceReport += `Original length: ${normalizedOriginal.length}, Generated length: ${normalizedGenerated.length}\n`; // Show character-by-character differences in normalized versions for (let j = 0; j < maxLen; j++) { const origChar = normalizedOriginal[j] || '∅'; const genChar = normalizedGenerated[j] || '∅'; if (origChar !== genChar) { differenceReport += ` Position ${j}: "${origChar}" vs "${genChar}"\n`; } } // Fail with detailed report throw new Error(differenceReport); } // DEEP INI FILE CONTENT COMPARISON: Compare INI file structure and validate generated content const iniPath = join(FIXTURES_DIR, 'ini.txt'); const originalIniContent = await readFile(iniPath, 'utf-8'); const originalIniLines = originalIniContent.split('\n').filter(line => line.trim().length > 0); const generatedIniLines = generatedReport.iniText .split('\r\n') .filter(line => line.trim().length > 0); // Validate that both INI files have at least the A000 header expect(generatedIniLines.length).toBeGreaterThanOrEqual(1); expect(originalIniLines.length).toBeGreaterThanOrEqual(1); // Both INI files should start with A000 header expect(generatedIniLines[0].startsWith('A000')).toBe(true); expect(originalIniLines[0].startsWith('A000')).toBe(true); // Parse the A000 headers from both files for comparison const originalA000Line = originalIniLines[0].replace(/\r$/, ''); const generatedA000Line = generatedIniLines[0]; // Normalize and compare A000 headers const normalizedOriginalA000 = normalizeIniLineForComparison(originalA000Line, 'A000'); const normalizedGeneratedA000 = normalizeIniLineForComparison(generatedA000Line, 'A000'); if (normalizedOriginalA000 !== normalizedGeneratedA000) { const maxA000Len = Math.max(normalizedOriginalA000.length, normalizedGeneratedA000.length); let a000DifferenceReport = `\nINI FILE A000 header mismatch after normalization:\n`; a000DifferenceReport += `Original: "${originalA000Line}"\n`; a000DifferenceReport += `Generated: "${generatedA000Line}"\n`; a000DifferenceReport += `Normalized Original: "${normalizedOriginalA000}"\n`; a000DifferenceReport += `Normalized Generated: "${normalizedGeneratedA000}"\n`; a000DifferenceReport += `Original length: ${normalizedOriginalA000.length}, Generated length: ${normalizedGeneratedA000.length}\n`; // Show character-by-character differences in normalized versions for (let j = 0; j < maxA000Len; j++) { const origA000Char = normalizedOriginalA000[j] || '∅'; const genA000Char = normalizedGeneratedA000[j] || '∅'; if (origA000Char !== genA000Char) { a000DifferenceReport += ` Position ${j}: "${origA000Char}" vs "${genA000Char}"\n`; } } throw new Error(a000DifferenceReport); } // Parse and validate A000Sum records (summary lines) - these should be consistent with the actual data const generatedSummaryLines = generatedIniLines.slice(1); // Skip A000 header // Create map of record type to count for generated INI const generatedSummaryCounts = new Map<string, number>(); // Parse generated summary lines for (const line of generatedSummaryLines) { if (line.length === 19) { const recordType = line.substring(0, 4); const count = parseInt(line.substring(4, 19), 10); generatedSummaryCounts.set(recordType, count); } } // Validate that generated summary counts make sense with actual parsed data // Note: We don't require exact match with original because parsed data might differ // but we validate that the generated counts are consistent with what we actually have // Check that if we have data for a record type, we have a summary line for it if (parsedData.summary.perType.b100 > 0) { expect(generatedSummaryCounts.has('B100')).toBe(true); expect(generatedSummaryCounts.get('B100')).toBeGreaterThan(0); } if (parsedData.summary.perType.b110 > 0) { expect(generatedSummaryCounts.has('B110')).toBe(true); expect(generatedSummaryCounts.get('B110')).toBeGreaterThan(0); } if (parsedData.summary.perType.c100 > 0) { expect(generatedSummaryCounts.has('C100')).toBe(true); expect(generatedSummaryCounts.get('C100')).toBeGreaterThan(0); } // Validate that summary line format is correct for generated lines for (const line of generatedSummaryLines) { expect(line.length).toBe(19); // A000Sum records should be exactly 19 characters expect(line.match(/^[A-Z]\d{3}\d{15}$/)).toBeTruthy(); // Should match pattern: RecordType + 15-digit count } }); /** * Normalizes a SHAAM record line for comparison by replacing variable fields * with consistent placeholders */ function normalizeLineForComparison(line: string, recordType: string): string { if (line.length < 4) return line; switch (recordType) { case 'A100': // A100: Replace primary identifier (pos 22-36) - generated ad-hoc per file return ( line.substring(0, 22) + 'XXXXXXXXXXXXXXX' + // primary identifier placeholder (15 chars) line.substring(37) ); case 'Z900': // Z900: Replace primary identifier and total records count return ( line.substring(0, 22) + 'XXXXXXXXXXXXXXX' + // primary identifier placeholder (15 chars) line.substring(37) ); case 'B110': // B110: Normalize supplier customer tax ID field (pos 326-334) to handle formatting differences // Original might have "14216790 " while generated has "014216790" if (line.length >= 335) { const taxIdField = line.substring(326, 335); // 9 chars const normalizedTaxId = taxIdField.trim().replace(/^0+/, '').padEnd(9, ' '); return line.substring(0, 326) + normalizedTaxId + line.substring(335); } return line; default: return line; } } /** * Normalizes an INI file line for comparison by replacing variable fields * with consistent placeholders */ function normalizeIniLineForComparison(line: string, recordType: string): string { if (line.length < 4) return line; switch (recordType) { case 'A000': // A000: This is a complex header record with many variable fields // We need to normalize most fields since they vary between original and generated if (line.length >= 466) { // Based on A000 record specification, normalize key variable fields: // Field positions calculated based on the encoding order in a000.ts return ( line.substring(0, 33) + 'XXXXXXXXXXXXXXX' + // pos 33-48: Primary Identifier (15 chars) - varies line.substring(48, 56) + 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + // pos 56-133: Software information (77 chars) - varies line.substring(133, 134) + // Keep pos 133: softwareType 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' + // pos 134-184: Saved files path (50 chars) - varies line.substring(184, 384) + // Keep pos 184-384 'XXXXXXXXXX' + // pos 384-394: Execution date and time - varies line.substring(394, 396) + // Keep pos 394-396: Language and encoding - should be similar 'XXXXXXXXXXXXXXXXXXXX' + // pos 396-416: Compression software (20 chars) - varies line.substring(416) // Keep the rest - should be similar ); } return line; case 'A000Sum': // A000Sum: These are count records, but the counts should match exactly // No normalization needed - counts should be identical return line; default: return line; } } it('should validate the ini.txt fixture file structure', async () => { const iniPath = join(FIXTURES_DIR, 'ini.txt'); const iniContent = await readFile(iniPath, 'utf-8'); expect(iniContent).toBeDefined(); expect(iniContent.length).toBeGreaterThan(0); // Check basic structure - should start with A000 record const lines = iniContent.split('\n').filter(line => line.trim().length > 0); expect(lines.length).toBeGreaterThan(0); expect(lines[0].startsWith('A000')).toBe(true); // Should contain summary records (A000Sum format: B1, B11, C10, D11, D12, M10) const summaryLines = lines.filter(line => line.match(/^[BCDM]1[0-9]/)); expect(summaryLines.length).toBeGreaterThan(0); // Validate INI file structure expect(summaryLines.length).toBeGreaterThan(0); expect(lines[0].startsWith('A000')).toBe(true); }); it('should perform a complete round-trip with ini.txt fixture file', async () => { const iniPath = join(FIXTURES_DIR, 'ini.txt'); const iniContent = await readFile(iniPath, 'utf-8'); const lines = iniContent.split('\n').filter(line => line.trim().length > 0); // Parse the INI file structure let a000Record: ReturnType<typeof parseA000> | null = null; const a000SumRecords: ReturnType<typeof parseA000Sum>[] = []; for (const line of lines) { if (line.length < 4) continue; const cleanLine = line.replace(/\r?\n?$/, '').replace(/\r$/, ''); // Check line length to determine record type if (cleanLine.length === 466) { // A000 header record const recordType = cleanLine.slice(0, 4); try { if (recordType === 'A000') { a000Record = parseA000(cleanLine); } } catch (error) { // Re-throwing the error will make the test fail with a descriptive message. throw new Error(`Failed to parse A000 line: ${cleanLine}`, { cause: error }); } } else if (cleanLine.length === 19) { // A000Sum record (record type summary) try { a000SumRecords.push(parseA000Sum(cleanLine)); } catch (error) { // Re-throwing the error will make the test fail with a descriptive message. throw new Error(`Failed to parse A000Sum line: ${cleanLine}`, { cause: error }); } } else { throw new Error( `Unknown line length ${cleanLine.length}, line: ${cleanLine.slice(0, 20)}...`, ); } } // Validate that we parsed the A000 header record expect(a000Record).toBeDefined(); expect(a000Record?.vatId).toBeTruthy(); expect(a000Record?.totalRecords).toBeTruthy(); expect(a000Record?.primaryIdentifier).toBeTruthy(); // Validate that we parsed summary records expect(a000SumRecords.length).toBeGreaterThan(0); // Verify that we have summary records for different record types const summarizedRecordTypes = a000SumRecords.map(record => record.code); // Use actual record types found in the INI file instead of hardcoded expectations expect(summarizedRecordTypes.length).toBeGreaterThan(0); // Now test round-trip by getting corresponding BKMVDATA and generating new INI const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt'); const parsedData = await parseFixtureData(bkmvDataPath); if (parsedData.business) { // Create ReportInput from parsed BKMV data // Note: This is a simplified round-trip test using minimal data const reportInput = { business: parsedData.business, documents: parsedData.documents, journalEntries: parsedData.journalEntries, accounts: parsedData.accounts, inventory: parsedData.inventory, } as ReportInput; // Generate new files from the parsed data const generatedReport = generateUniformFormatReport(reportInput); expect(generatedReport.iniText).toBeDefined(); // Parse the generated INI content const generatedIniLines = generatedReport.iniText .split('\r\n') .filter(line => line.trim().length > 0); // Validate structure of generated INI expect(generatedIniLines.length).toBeGreaterThan(0); expect(generatedIniLines[0].startsWith('A000')).toBe(true); // Parse the generated A000 record const generatedA000Line = generatedIniLines.find(line => line.startsWith('A000')); expect(generatedA000Line).toBeDefined(); const generatedA000 = parseA000(generatedA000Line!); expect(generatedA000.vatId).toBe(parsedData.business.taxId); // The generated file may have fewer records than the original fixture // since we're only using the parsed business data structure // Just validate that the structure is correct const generatedTotalRecords = parseInt(generatedA000.totalRecords); expect(generatedTotalRecords).toBeGreaterThanOrEqual(0); // Validate that generated INI has summary records (they are 19 chars, not matched by record code pattern) const generatedSummaryLines = generatedIniLines.filter( line => line.replace(/\r?\n$/, '').length === 19, ); expect(generatedSummaryLines.length).toBeGreaterThanOrEqual(0); // Parse generated summary records and validate they have counts > 0 for (const summaryLine of generatedSummaryLines) { const summaryRecord = parseA000Sum(summaryLine); expect(parseInt(summaryRecord.recordCount)).toBeGreaterThan(0); } } }); it('should handle edge cases in fixture data gracefully', async () => { const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt'); const content = await readFile(bkmvDataPath, 'utf-8'); // Test with various line endings and whitespace const normalizedContent = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = normalizedContent.split('\n'); let validRecords = 0; let invalidRecords = 0; for (const line of lines) { if (line.trim().length === 0) continue; if (line.length < 4) { invalidRecords++; continue; } const parseResult = parseRecordLine(line); if (parseResult.success) { validRecords++; } else { invalidRecords++; } } // Validate edge case handling results expect(validRecords).toBeGreaterThan(0); expect(validRecords).toBeGreaterThan(invalidRecords); // We should be able to parse most records successfully expect(validRecords).toBeGreaterThan(0); expect(validRecords).toBeGreaterThan(invalidRecords); }); it('should preserve monetary precision in round-trip', async () => { const bkmvDataPath = join(FIXTURES_DIR, 'BKMVDATA.txt'); const parsedData = await parseFixtureData(bkmvDataPath); if (parsedData.rawRecords.b100.length === 0) { // Skip test for fixtures without B100 records expect(parsedData.rawRecords.b100).toHaveLength(0); return; } // Skip test if we don't have business data required for report generation if (!parsedData.business) { expect(parsedData.business, 'Business data could not be parsed from fixture').toBeDefined(); return; } // Get original monetary values from journal entries (already have correct sign applied) const originalAmounts = parsedData.journalEntries.map((entry, index) => ({ amount: Math.abs(entry.amount), debit: entry.amount >= 0 ? '1' : '2', signedAmount: entry.amount, index, })); // Validate that we can parse monetary values correctly from the fixture expect(originalAmounts.length).toBeGreaterThan(0); expect(originalAmounts[0].amount).toBeTruthy(); expect(['1', '2']).toContain(originalAmounts[0].debit); // COMPLETE THE ROUND-TRIP: Generate new report from parsed data const reportInput = { business: parsedData.business, documents: parsedData.documents, journalEntries: parsedData.journalEntries, accounts: parsedData.accounts, inventory: parsedData.inventory, } as ReportInput; const generatedReport = generateUniformFormatReport(reportInput); expect(generatedReport).toBeDefined(); expect(generatedReport.dataText).toBeDefined(); // Parse the generated data back to extract monetary values const reparsedData = parseUniformFormatFiles(generatedReport.iniText, generatedReport.dataText); expect(reparsedData.data.journalEntries).toBeDefined(); // Get monetary values from the re-parsed data const reparsedAmounts = reparsedData.data.journalEntries.map((entry, index) => ({ amount: Math.abs(entry.amount), debit: entry.amount >= 0 ? '1' : '2', signedAmount: entry.amount, index, })); // PRECISION VALIDATION: Compare original vs reparsed monetary values expect(reparsedAmounts.length).toBe(originalAmounts.length); // Test monetary precision preservation - the core test for (let i = 0; i < originalAmounts.length; i++) { const original = originalAmounts[i]; const reparsed = reparsedAmounts[i]; // Most important: verify the signed amounts match exactly (monetary precision) expect(reparsed.signedAmount).toBe(original.signedAmount); // Additional verification: absolute amounts match expect(Math.abs(reparsed.signedAmount)).toBe(Math.abs(original.signedAmount)); } // Additional validation: ensure total amounts are preserved const originalTotal = originalAmounts.reduce((sum, amt) => sum + amt.signedAmount, 0); const reparsedTotal = reparsedAmounts.reduce((sum, amt) => sum + amt.signedAmount, 0); expect(reparsedTotal).toBe(originalTotal); }); });