@accounter/server
Version:
Accounter GraphQL server
347 lines • 15.4 kB
JavaScript
/**
* Main API for generating SHAAM uniform format reports
*/
import { assembleFile } from '../generator/format/encoder.js';
import { encodeA000, encodeA000Sum, encodeA100, encodeB100, encodeB110, encodeC100, encodeD110, encodeD120, encodeM100, encodeZ900, } from '../generator/records/index.js';
import { ShaamFormatError } from '../validation/errors.js';
import { validateInput } from '../validation/validate-input.js';
/**
* Generates SHAAM uniform format report files (INI.TXT and BKMVDATA.TXT)
* from a high-level JSON input object.
*
* @param input - The report input data
* @param options - Generation options
* @returns Generated SHAAM format files and metadata
*/
export function generateUniformFormatReport(input, options = {}) {
// Validate input data first
const validationMode = options.validationMode || 'fail-fast';
const validationErrors = validateInput(input, validationMode);
if (validationErrors.length > 0) {
if (validationMode === 'fail-fast') {
// This should have already thrown, but just in case
throw new ShaamFormatError('Validation failed', validationErrors);
}
// For collect-all mode, we still throw if there are errors
// The user can catch and inspect the errors if needed
throw new ShaamFormatError('Input validation failed', validationErrors);
}
const records = [];
const iniRecords = [];
const recordCounts = {};
let recordNumber = 1;
// Helper function to count and add data record
const addRecord = (recordType, recordContent) => {
records.push(recordContent);
recordCounts[recordType] = (recordCounts[recordType] || 0) + 1;
recordNumber++;
};
// Helper function to add INI record
const addIniRecord = (recordType, recordContent) => {
iniRecords.push(recordContent);
recordCounts[recordType] = (recordCounts[recordType] || 0) + 1;
};
// Calculate expected BKMVDATA record counts based on input data
const expectedDataRecordCount = 1 + // A100 record
input.documents.length * 3 + // C100, D110, D120 for each document
input.journalEntries.length + // B100 for each journal entry
input.accounts.length + // B110 for each account
input.inventory.length + // M100 for each inventory item
1; // Z900 record
// Generate A000 record for INI.TXT
const fileHeaderRecord = {
totalRecords: expectedDataRecordCount.toString(), // Actual count of BKMVDATA records
vatId: input.business.taxId,
softwareRegNumber: '12345678', // TODO: Use actual software registration number
softwareName: 'SHAAM Generator',
softwareVersion: '1.0.0',
vendorVatId: '123456782', // TODO: Use actual vendor VAT ID
vendorName: 'Accounter',
softwareType: '2', // Multi-year software
fileOutputPath: options.fileNameBase || `C:\\OPENFRMT\\${input.business.taxId}.`,
accountingType: '2', // Double-entry accounting
balanceRequired: '2',
companyRegId: '',
withholdingFileNum: '',
businessName: input.business.name,
businessStreet: input.business.address?.street ?? '',
businessHouseNum: input.business.address?.houseNumber ?? '',
businessCity: input.business.address?.city ?? '',
businessZip: input.business.address?.zip ?? '',
taxYear: ' ', // YYYY format. not required for now
startDate: input.business.reportingPeriod.startDate.replace(/-/g, ''),
endDate: input.business.reportingPeriod.endDate.replace(/-/g, ''),
processStartDate: new Date().toISOString().slice(0, 10).replace(/-/g, ''),
processStartTime: new Date().toTimeString().slice(0, 5).replace(':', ''),
languageCode: '0', // Hebrew
characterEncoding: '1', // ISO-8859-8-i
compressionSoftware: 'zip', // TODO:replace with a real compression software
baseCurrency: 'ILS',
branchInfoFlag: '0', // No branches for now
};
addIniRecord('A000', encodeA000(fileHeaderRecord));
// Add A000Sum records for each record type to INI.TXT
// Calculate expected counts based on input data
const expectedCounts = {
A100: 1,
C100: input.documents.length,
D110: input.documents.length,
D120: input.documents.length,
B100: input.journalEntries.length,
B110: input.accounts.length,
M100: input.inventory.length,
Z900: 1,
};
// Add summary records for each record type
for (const [recordType, count] of Object.entries(expectedCounts)) {
if (count > 0) {
const summaryRecord = {
code: recordType,
recordCount: count.toString(),
};
addIniRecord('A000Sum', encodeA000Sum(summaryRecord));
}
}
// 1. Business metadata - A100 record
const businessMetadata = {
recordNumber,
vatId: input.business.taxId,
reserved: '',
};
addRecord('A100', encodeA100(businessMetadata));
// TODO: 2. Summaries... (not implemented yet)
// 3. For each document in input.documents
for (const document of input.documents) {
// Document header - C100 record
const documentRecord = {
code: 'C100',
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
documentType: document.type,
documentId: document.id,
documentIssueDate: document.date.replace(/-/g, ''), // Convert YYYY-MM-DD to YYYYMMDD
documentIssueTime: '',
customerName: '',
customerStreet: '',
customerHouseNumber: '',
customerCity: '',
customerPostCode: '',
customerCountry: '',
customerCountryCode: '',
customerPhone: '',
customerVatId: '',
documentValueDate: '',
foreignCurrencyAmount: '',
currencyCode: '',
amountBeforeDiscount: '',
documentDiscount: '',
amountAfterDiscountExcludingVat: '',
vatAmount: '',
amountIncludingVat: document.amount.toFixed(2), // Format as decimal with 2 places
withholdingTaxAmount: '',
customerKey: '',
matchingField: '',
cancelledAttribute1: '',
cancelledDocument: '',
cancelledAttribute2: '',
documentDate: '',
branchKey: '',
cancelledAttribute3: '',
actionExecutor: '',
lineConnectingField: '',
reserved: '',
};
addRecord('C100', encodeC100(documentRecord));
// Document line - D110 record
const documentLineRecord = {
code: 'D110',
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
documentType: document.type,
documentNumber: document.id,
lineNumber: '1',
baseDocumentType: '',
baseDocumentNumber: '',
transactionType: '',
internalCatalogCode: '',
goodsServiceDescription: document.description || 'Item',
manufacturerName: '',
serialNumber: '',
unitOfMeasureDescription: '',
quantity: '1',
unitPriceExcludingVat: '',
lineDiscount: '',
lineTotal: '',
vatRatePercent: '',
reserved1: '',
branchId: '1',
reserved2: '',
documentDate: document.date.replace(/-/g, ''),
headerLinkField: '',
baseDocumentBranchId: '',
reserved3: '',
};
addRecord('D110', encodeD110(documentLineRecord));
// Payment record - D120 record (if applicable)
const paymentRecord = {
code: 'D120',
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
documentType: document.type,
documentNumber: document.id,
lineNumber: '1',
paymentMethod: '1', // Default payment method
bankNumber: '',
branchNumber: '',
accountNumber: '',
checkNumber: '',
paymentDueDate: '',
lineAmount: document.amount.toFixed(2), // Format as decimal with 2 places
acquirerCode: '',
cardBrand: '',
creditTransactionType: '',
firstPaymentAmount: '',
installmentsCount: '',
additionalPaymentAmount: '',
reserved1: '',
branchId: '1',
reserved2: '',
documentDate: document.date.replace(/-/g, ''),
headerLinkField: '',
reserved: '',
};
addRecord('D120', encodeD120(paymentRecord));
}
// 4. Journal lines: for each entry and its lines
for (const entry of input.journalEntries) {
const journalRecord = {
code: 'B100',
recordNumber,
vatId: input.business.taxId,
transactionNumber: entry.transactionNumber ?? parseInt(entry.id.replace(/\D/g, '') || '1', 10), // Use preserved value or extract from ID
transactionLineNumber: entry.transactionLineNumber ?? 1,
batchNumber: entry.batchNumber, // Use preserved batch number
transactionType: entry.transactionType ?? '',
referenceDocument: entry.referenceDocument ?? '',
referenceDocumentType: entry.referenceDocumentType,
referenceDocument2: entry.referenceDocument2 ?? '',
referenceDocumentType2: entry.referenceDocumentType2,
details: entry.description || '',
date: entry.date.replace(/-/g, ''),
valueDate: entry.valueDate ? entry.valueDate.replace(/-/g, '') : entry.date.replace(/-/g, ''), // Use preserved or fallback to date
accountKey: entry.accountId,
counterAccountKey: entry.counterAccountKey ?? '',
debitCreditIndicator: entry.debitCreditIndicator ?? (entry.amount >= 0 ? '1' : '2'),
currencyCode: entry.currencyCode,
transactionAmount: entry.transactionAmount ?? entry.amount, // Use signed value (reverted to preserve fixture behavior)
foreignCurrencyAmount: entry.foreignCurrencyAmount, // Use preserved value as-is
quantityField: entry.quantityField, // Use preserved value
matchingField1: entry.matchingField1 ?? '',
matchingField2: entry.matchingField2 ?? '',
branchId: entry.branchId ?? '',
entryDate: entry.entryDate ? entry.entryDate.replace(/-/g, '') : entry.date.replace(/-/g, ''), // Use preserved or fallback to date
operatorUsername: entry.operatorUsername ?? '',
reserved: entry.reserved ?? '',
};
addRecord('B100', encodeB100(journalRecord));
}
// 5. Accounts: B110 records
for (const account of input.accounts) {
const accountRecord = {
code: 'B110',
recordNumber,
vatId: input.business.taxId,
accountKey: account.id,
accountName: account.name,
trialBalanceCode: account.sortCode.key,
trialBalanceCodeDescription: account.sortCode.name,
customerSupplierAddressStreet: account.address?.street,
customerSupplierAddressHouseNumber: account.address?.houseNumber,
customerSupplierAddressCity: account.address?.city,
customerSupplierAddressZip: account.address?.zip,
customerSupplierAddressCountry: account.address?.country,
countryCode: account.countryCode, // Cast to CountryCodeEnum type
parentAccountKey: account.parentAccountKey,
accountOpeningBalance: account.accountOpeningBalance,
totalDebits: account.totalDebits,
totalCredits: account.totalCredits,
accountingClassificationCode: account.accountingClassificationCode
? (() => {
// Extract numeric part from alphanumeric code (e.g., 'A1100' -> 1100)
const numericPart = account.accountingClassificationCode.replace(/\D/g, '');
const parsed = parseInt(numericPart, 10);
return Number.isNaN(parsed) ? undefined : Math.min(parsed, 9999); // Cap at max value
})()
: undefined,
supplierCustomerTaxId: account.vatId
? (() => {
// Remove all spaces and validate that only digits remain
const digitsOnly = account.vatId.replace(/\s/g, '');
if (digitsOnly && !/^\d+$/.test(digitsOnly)) {
throw new Error(`Invalid vatId for account ${account.id}: "${account.vatId}" contains non-digit characters. Only digits and spaces are allowed.`);
}
if (digitsOnly && digitsOnly.length > 0) {
return digitsOnly.length > 9 ? digitsOnly.substring(0, 9) : digitsOnly;
}
return undefined;
})()
: undefined,
branchId: account.branchId,
openingBalanceForeignCurrency: account.openingBalanceForeignCurrency,
foreignCurrencyCode: account.foreignCurrencyCode,
reserved: undefined,
};
addRecord('B110', encodeB110(accountRecord));
}
// 6. Inventory: M100 records
for (const item of input.inventory) {
const inventoryRecord = {
code: 'M100',
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
universalItemCode: '',
supplierItemCode: '',
internalItemCode: item.id,
itemName: item.name,
classificationCode: '',
classificationDescription: '',
unitOfMeasure: '',
openingStock: '0',
totalStockIn: '0',
totalStockOut: item.quantity.toString(),
endPeriodCostNonBonded: '',
endPeriodCostBonded: '',
reserved: '',
};
addRecord('M100', encodeM100(inventoryRecord));
}
// 7. Closing record: Z900
const closingRecord = {
recordNumber,
vatId: input.business.taxId,
totalRecords: records.length + 1, // Count of records before Z900 is added
reserved: '',
};
addRecord('Z900', encodeZ900(closingRecord));
// Build iniText and dataText using the assembler
const iniText = assembleFile(iniRecords);
const dataText = assembleFile(records);
// Create virtual File objects
const iniFile = new File([new Blob([iniText])], `${options.fileNameBase || 'report'}.INI.TXT`, {
type: 'text/plain;charset=windows-1255',
});
const dataFile = new File([new Blob([dataText])], `${options.fileNameBase || 'report'}.BKMVDATA.TXT`, {
type: 'text/plain;charset=windows-1255',
});
return {
iniText,
dataText,
iniFile,
dataFile,
summary: {
totalRecords: records.length + iniRecords.length,
perType: recordCounts,
},
};
}
//# sourceMappingURL=generate-report.js.map