@accounter/server
Version:
Accounter GraphQL server
562 lines • 22.4 kB
JavaScript
/**
* API for parsing SHAAM uniform format files
*/
import { z } from 'zod';
import { parseA000, parseA000Sum } from '../generator/records/index.js';
import { parseDataFile } from '../parser/data-parser.js';
import { AccountSchema, BusinessMetadataSchema, DocumentSchema, InventoryItemSchema, JournalEntrySchema, } from '../types/index.js';
/**
* Parses SHAAM uniform format files (INI.TXT and BKMVDATA.TXT)
* back into structured JSON objects with comprehensive validation.
*
* @param iniContent - Content of the INI.TXT file
* @param dataContent - Content of the BKMVDATA.TXT file
* @param options - Parse options for validation and error handling
* @returns Parsed report input data with validation summary
*/
export function parseUniformFormatFiles(iniContent, dataContent, options = {}) {
const { validationMode = 'lenient', skipUnknownRecords = true, allowPartialData = true, } = options;
const errors = [];
const recordCounts = {};
// Parse files independently
const parsedData = parseFilesSeparately(iniContent, dataContent, errors, recordCounts, {
validationMode,
skipUnknownRecords,
});
// Convert parsed records to structured data
const structuredData = convertToStructuredData(parsedData, errors, validationMode);
// Perform individual validation
performRecordsValidation(structuredData, errors, validationMode);
// Perform cross-validation
const crossValidationPassed = performCrossFileValidation(parsedData, errors, validationMode);
// Handle validation errors based on mode
if (validationMode === 'strict' &&
errors.some(e => e.severity === 'error') &&
!allowPartialData) {
throw new Error(`Validation failed: ${errors.map(e => e.message).join(', ')}`);
}
const totalRecords = Object.values(recordCounts).reduce((sum, count) => sum + count, 0);
return {
data: structuredData,
summary: {
totalRecords,
perType: recordCounts,
errors,
crossValidationPassed,
},
};
}
/**
* Parse INI and data files separately and extract all records
*/
function parseFilesSeparately(iniContent, dataContent, errors, recordCounts, options) {
const parsedData = {
iniData: {
a000: null,
a000Sum: [],
},
dataRecords: {
a100: null,
b100: [],
b110: [],
c100: [],
d110: [],
d120: [],
m100: [],
z900: null,
},
};
// Parse INI file
const iniLines = iniContent.split('\n').filter(line => line.trim().length > 0);
for (let i = 0; i < iniLines.length; i++) {
const line = iniLines[i];
if (line.length < 4)
continue;
const recordType = line.substring(0, 4);
const cleanLine = line.replace(/\r?\n?$/, '').replace(/\r$/, '');
try {
if (recordType === 'A000') {
parsedData.iniData.a000 = parseA000(cleanLine);
recordCounts['A000'] = (recordCounts['A000'] || 0) + 1;
}
else if (cleanLine.length === 19) {
// A000Sum records
const summaryRecord = parseA000Sum(cleanLine);
parsedData.iniData.a000Sum.push(summaryRecord);
recordCounts['A000Sum'] = (recordCounts['A000Sum'] || 0) + 1;
}
else if (!options.skipUnknownRecords) {
errors.push({
recordType: 'UNKNOWN_INI',
recordIndex: i,
field: 'recordType',
message: `Unknown INI record type: ${recordType}`,
severity: options.validationMode === 'strict' ? 'error' : 'warning',
});
}
}
catch (error) {
errors.push({
recordType,
recordIndex: i,
field: 'parsing',
message: `Failed to parse ${recordType} record: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: 'error',
});
}
}
// Parse data file
const parsedDataFile = parseDataFile(dataContent);
parsedData.dataRecords = parsedDataFile.records;
for (const [recordType, count] of Object.entries(parsedDataFile.summary.perType)) {
recordCounts[recordType] = (recordCounts[recordType] || 0) + count;
}
for (const parseError of parsedDataFile.errors) {
errors.push({
recordType: parseError.recordType,
recordIndex: parseError.lineNumber - 1,
field: 'parsing',
message: `Failed to parse ${parseError.recordType} record: ${parseError.message}`,
severity: 'error',
});
}
if (!options.skipUnknownRecords) {
for (const unknownRecord of parsedDataFile.unknownRecords) {
errors.push({
recordType: 'UNKNOWN_DATA',
recordIndex: unknownRecord.lineNumber - 1,
field: 'recordType',
message: `Unknown data record type: ${unknownRecord.recordType}`,
severity: options.validationMode === 'strict' ? 'error' : 'warning',
});
}
}
return parsedData;
}
/**
* Convert parsed records to structured ReportInput format
*/
function convertToStructuredData(parsedData, errors, validationMode) {
const result = {
business: {
businessId: 'unknown',
name: 'Unknown Business',
taxId: '0',
reportingPeriod: {
startDate: '1900-01-01',
endDate: '1900-01-01',
},
},
documents: [],
journalEntries: [],
accounts: [],
inventory: [],
};
// Extract business metadata from A000 or A100
if (parsedData.iniData.a000) {
const a000 = parsedData.iniData.a000;
let startDate = '1900-01-01';
let endDate = '1900-01-01';
try {
startDate = formatDateFromShaam(a000.startDate);
}
catch (error) {
errors.push({
recordType: 'A000',
recordIndex: 0,
field: 'startDate',
message: `Failed to convert A000 startDate: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
try {
endDate = formatDateFromShaam(a000.endDate);
}
catch (error) {
errors.push({
recordType: 'A000',
recordIndex: 0,
field: 'endDate',
message: `Failed to convert A000 endDate: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
result.business = {
businessId: a000.primaryIdentifier,
name: a000.businessName || 'Unknown Business',
taxId: a000.vatId,
reportingPeriod: {
startDate,
endDate,
},
};
// Add business address if available
if (a000.businessStreet || a000.businessHouseNum || a000.businessCity || a000.businessZip) {
result.business.address = {
street: a000.businessStreet || undefined,
houseNumber: a000.businessHouseNum || undefined,
city: a000.businessCity || undefined,
zip: a000.businessZip || undefined,
};
}
}
else if (parsedData.dataRecords.a100) {
const a100 = parsedData.dataRecords.a100;
result.business = {
...result.business,
businessId: a100.primaryIdentifier.toString(),
taxId: a100.vatId,
};
}
// Convert journal entries
for (const b100 of parsedData.dataRecords.b100) {
try {
const journalEntry = {
id: `JE_${b100.transactionNumber.toString()}`,
date: formatDateFromShaam(b100.date),
amount: b100.transactionAmount, // Use transactionAmount as-is (it already contains the correct sign)
accountId: b100.accountKey,
description: b100.details,
// Include extended B100 fields
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 ? formatDateFromShaam(b100.valueDate) : undefined,
counterAccountKey: b100.counterAccountKey,
debitCreditIndicator: b100.debitCreditIndicator,
currencyCode: b100.currencyCode,
transactionAmount: b100.transactionAmount, // Preserve original signed amount
foreignCurrencyAmount: b100.foreignCurrencyAmount,
quantityField: b100.quantityField,
matchingField1: b100.matchingField1,
matchingField2: b100.matchingField2,
branchId: b100.branchId,
entryDate: b100.entryDate ? formatDateFromShaam(b100.entryDate) : undefined,
operatorUsername: b100.operatorUsername,
reserved: b100.reserved,
};
result.journalEntries.push(journalEntry);
}
catch (error) {
errors.push({
recordType: 'B100',
recordIndex: result.journalEntries.length,
field: 'conversion',
message: `Failed to convert B100 to JournalEntry: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
// Convert accounts
for (const b110 of parsedData.dataRecords.b110) {
try {
const account = {
id: b110.accountKey,
name: b110.accountName?.trim(),
sortCode: {
key: b110.trialBalanceCode,
name: b110.trialBalanceCodeDescription,
},
accountOpeningBalance: b110.accountOpeningBalance,
// Include extended fields if available
countryCode: b110.countryCode,
parentAccountKey: b110.parentAccountKey,
vatId: b110.supplierCustomerTaxId,
totalDebits: b110.totalDebits,
totalCredits: b110.totalCredits,
accountingClassificationCode: b110.accountingClassificationCode?.toString(),
branchId: b110.branchId,
openingBalanceForeignCurrency: b110.openingBalanceForeignCurrency,
foreignCurrencyCode: b110.foreignCurrencyCode,
};
// Add address if available
if (b110.customerSupplierAddressStreet ||
b110.customerSupplierAddressCity ||
b110.customerSupplierAddressZip ||
b110.customerSupplierAddressCountry) {
account.address = {
street: b110.customerSupplierAddressStreet,
houseNumber: b110.customerSupplierAddressHouseNumber,
city: b110.customerSupplierAddressCity,
zip: b110.customerSupplierAddressZip,
country: b110.customerSupplierAddressCountry,
};
}
result.accounts.push(account);
}
catch (error) {
errors.push({
recordType: 'B110',
recordIndex: result.accounts.length,
field: 'conversion',
message: `Failed to convert B110 to Account: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
// Convert documents
for (const c100 of parsedData.dataRecords.c100) {
try {
// Find the corresponding D110 record for this document
const d110 = parsedData.dataRecords.d110.find(d => d.documentType === c100.documentType && d.documentNumber === c100.documentId);
const document = {
id: c100.documentId,
type: c100.documentType,
date: formatDateFromShaam(c100.documentIssueDate),
amount: parseFloat(c100.amountIncludingVat || '0'),
description: d110?.goodsServiceDescription || c100.customerName || 'Document',
};
result.documents.push(document);
}
catch (error) {
errors.push({
recordType: 'C100',
recordIndex: result.documents.length,
field: 'conversion',
message: `Failed to convert C100 to Document: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
// Convert inventory
for (const m100 of parsedData.dataRecords.m100) {
try {
const inventoryItem = {
id: m100.internalItemCode,
name: m100.itemName,
quantity: parseInt(m100.totalStockOut || '0', 10),
};
result.inventory.push(inventoryItem);
}
catch (error) {
errors.push({
recordType: 'M100',
recordIndex: result.inventory.length,
field: 'conversion',
message: `Failed to convert M100 to InventoryItem: ${error instanceof Error ? error.message : 'Unknown error'}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
return result;
}
/**
* Perform record-level validation using Zod schemas
*/
function performRecordsValidation(data, errors, validationMode) {
if (validationMode === 'none')
return;
// Validate business metadata
try {
BusinessMetadataSchema.parse(data.business);
}
catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
recordType: 'BusinessMetadata',
recordIndex: 0,
field: issue.path.join('.'),
message: issue.message,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
}
// Validate documents
for (let i = 0; i < data.documents.length; i++) {
try {
DocumentSchema.parse(data.documents[i]);
}
catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
recordType: 'Document',
recordIndex: i,
field: issue.path.join('.'),
message: issue.message,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
}
}
// Validate journal entries
for (let i = 0; i < data.journalEntries.length; i++) {
try {
JournalEntrySchema.parse(data.journalEntries[i]);
}
catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
recordType: 'JournalEntry',
recordIndex: i,
field: issue.path.join('.'),
message: issue.message,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
}
}
// Validate accounts
for (let i = 0; i < data.accounts.length; i++) {
try {
AccountSchema.parse(data.accounts[i]);
}
catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
recordType: 'Account',
recordIndex: i,
field: issue.path.join('.'),
message: issue.message,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
}
}
// Validate inventory
for (let i = 0; i < data.inventory.length; i++) {
try {
InventoryItemSchema.parse(data.inventory[i]);
}
catch (error) {
if (error instanceof z.ZodError) {
for (const issue of error.issues) {
errors.push({
recordType: 'InventoryItem',
recordIndex: i,
field: issue.path.join('.'),
message: issue.message,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
}
}
}
}
}
/**
* Perform cross-validation between INI and data files
*/
function performCrossFileValidation(parsedData, errors, validationMode) {
if (validationMode === 'none')
return true;
let crossValidationPassed = true;
// Validate record counts from A000Sum against actual data
for (const summaryRecord of parsedData.iniData.a000Sum) {
const expectedCount = parseInt(summaryRecord.recordCount);
let actualCount;
switch (summaryRecord.code) {
case 'A100':
actualCount = parsedData.dataRecords.a100 ? 1 : 0;
break;
case 'B100':
actualCount = parsedData.dataRecords.b100.length;
break;
case 'B110':
actualCount = parsedData.dataRecords.b110.length;
break;
case 'C100':
actualCount = parsedData.dataRecords.c100.length;
break;
case 'D110':
actualCount = parsedData.dataRecords.d110.length;
break;
case 'D120':
actualCount = parsedData.dataRecords.d120.length;
break;
case 'M100':
actualCount = parsedData.dataRecords.m100.length;
break;
case 'Z900':
actualCount = parsedData.dataRecords.z900 ? 1 : 0;
break;
default:
// Unknown record type in summary
errors.push({
recordType: 'A000Sum',
recordIndex: 0,
field: 'code',
message: `Unknown record type in summary: ${summaryRecord.code}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
crossValidationPassed = false;
continue;
}
if (expectedCount !== actualCount) {
errors.push({
recordType: 'CrossValidation',
recordIndex: 0,
field: `${summaryRecord.code}_count`,
message: `Record count mismatch for ${summaryRecord.code}: expected ${expectedCount}, found ${actualCount}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
crossValidationPassed = false;
}
}
// Validate Z900 total records count
if (parsedData.dataRecords.z900) {
const z900TotalRecords = parsedData.dataRecords.z900.totalRecords;
const actualDataRecords = (parsedData.dataRecords.a100 ? 1 : 0) +
parsedData.dataRecords.b100.length +
parsedData.dataRecords.b110.length +
parsedData.dataRecords.c100.length +
parsedData.dataRecords.d110.length +
parsedData.dataRecords.d120.length +
parsedData.dataRecords.m100.length;
// Z900 doesn't count itself
if (z900TotalRecords !== actualDataRecords) {
errors.push({
recordType: 'CrossValidation',
recordIndex: 0,
field: 'z900_total_records',
message: `Z900 total records mismatch: expected ${z900TotalRecords}, found ${actualDataRecords}`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
crossValidationPassed = false;
}
}
// Validate business data consistency between A000 and A100
if (parsedData.iniData.a000 && parsedData.dataRecords.a100) {
const a000 = parsedData.iniData.a000;
const a100 = parsedData.dataRecords.a100;
if (a000.vatId !== a100.vatId) {
errors.push({
recordType: 'CrossValidation',
recordIndex: 0,
field: 'vat_id_consistency',
message: `VAT ID mismatch between A000 (${a000.vatId}) and A100 (${a100.vatId})`,
severity: validationMode === 'strict' ? 'error' : 'warning',
});
crossValidationPassed = false;
}
}
return crossValidationPassed;
}
/**
* Formats a SHAAM date string (YYYYMMDD) to ISO format (YYYY-MM-DD)
*/
function formatDateFromShaam(shaamDate) {
// if shaamDate is not 8 digits string, throw an error
if (!shaamDate ||
typeof shaamDate !== 'string' ||
shaamDate.length !== 8 ||
!/^\d{8}$/.test(shaamDate)) {
throw new Error(`Invalid SHAAM date format: "${shaamDate}" (expected YYYYMMDD)`);
}
const year = shaamDate.substring(0, 4);
const month = shaamDate.substring(4, 6);
const day = shaamDate.substring(6, 8);
return `${year}-${month}-${day}`;
}
//# sourceMappingURL=parse-files.js.map