UNPKG

@accounter/server

Version:
347 lines • 15.4 kB
/** * 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