@accounter/shaam-uniform-format-generator
Version:
Fully typed application that generates, parses, and validates SHAAM uniform format tax reports (INI.TXT and BKMVDATA.TXT).
367 lines (344 loc) • 11.6 kB
text/typescript
/**
* 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,
type A000Input,
type A000Sum as A000SumInput,
type A100Input,
type Z900Input,
} from '../generator/records/index.js';
import type { GenerationOptions, ReportInput, ReportOutput } from '../types/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 Report output with generated file content
*/
export function generateUniformFormatReport(
input: ReportInput,
options: GenerationOptions = {},
): ReportOutput {
// 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: string[] = [];
const iniRecords: string[] = [];
const recordCounts: Record<string, number> = {};
let recordNumber = 1;
// Helper function to count and add data record
const addRecord = (recordType: string, recordContent: string) => {
records.push(recordContent);
recordCounts[recordType] = (recordCounts[recordType] || 0) + 1;
recordNumber++;
};
// Helper function to add INI record
const addIniRecord = (recordType: string, recordContent: string) => {
iniRecords.push(recordContent);
recordCounts[recordType] = (recordCounts[recordType] || 0) + 1;
};
// Generate A000 record for INI.TXT
const fileHeaderRecord: A000Input = {
reservedFuture: '',
totalRecords: '0', // Will be updated later with actual count
vatId: input.business.taxId,
softwareRegNumber: '12345678', // TODO: Use actual software registration number
softwareName: 'SHAAM Generator',
softwareVersion: '1.0.0',
vendorVatId: '123456789', // TODO: Use actual vendor VAT ID
vendorName: 'Accounter',
softwareType: '2', // Multi-year software
fileOutputPath: options.fileNameBase || 'report',
accountingType: '2', // Double-entry accounting
balanceRequired: '1',
companyRegId: '',
withholdingFileNum: '',
reserved1017: '',
businessName: input.business.name,
businessStreet: '',
businessHouseNum: '',
businessCity: '',
businessZip: '',
taxYear: '',
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: '',
reserved1031: '',
baseCurrency: 'ILS',
reserved1033: '',
branchInfoFlag: '0', // No branches for now
reserved1035: '',
};
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: A000SumInput = {
code: recordType,
recordCount: count.toString(),
};
addIniRecord('A000Sum', encodeA000Sum(summaryRecord));
}
}
// 1. Business metadata - A100 record
const businessMetadata: A100Input = {
recordNumber: recordNumber.toString(),
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' as const,
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' as const,
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
documentType: document.type,
documentNumber: document.id,
lineNumber: '1',
baseDocumentType: '' as const,
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' as const,
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' as const,
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
transactionNumber: entry.id.replace(/\D/g, '') || '1', // Extract only digits, default to '1'
transactionLineNumber: '1',
batchNumber: '',
transactionType: '',
referenceDocument: '',
referenceDocumentType: '' as const,
referenceDocument2: '',
referenceDocumentType2: '' as const,
details: entry.description || '',
date: entry.date.replace(/-/g, ''),
valueDate: entry.date.replace(/-/g, ''), // Same as date for simplicity
accountKey: entry.accountId,
counterAccountKey: '',
debitCreditIndicator: (entry.amount >= 0 ? '1' : '2') as '1' | '2',
currencyCode: '',
transactionAmount: Math.abs(entry.amount).toFixed(2), // Format as decimal with 2 places
foreignCurrencyAmount: '',
quantityField: '',
matchingField1: '',
matchingField2: '',
branchId: '',
entryDate: entry.date.replace(/-/g, ''),
operatorUsername: '',
reserved: '',
};
addRecord('B100', encodeB100(journalRecord));
}
// 5. Accounts: B110 records
for (const account of input.accounts) {
const accountRecord = {
code: 'B110' as const,
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
accountKey: account.id,
accountName: account.name,
trialBalanceCode: account.type,
trialBalanceCodeDescription: account.type,
customerSupplierAddressStreet: '',
customerSupplierAddressHouseNumber: '',
customerSupplierAddressCity: '',
customerSupplierAddressZip: '',
customerSupplierAddressCountry: '',
countryCode: '',
parentAccountKey: '',
accountOpeningBalance: '0',
totalDebits: '0',
totalCredits: '0',
accountingClassificationCode: '',
supplierCustomerTaxId: '',
branchId: '',
openingBalanceForeignCurrency: '',
foreignCurrencyCode: '',
reserved: '',
};
addRecord('B110', encodeB110(accountRecord));
}
// 6. Inventory: M100 records
for (const item of input.inventory) {
const inventoryRecord = {
code: 'M100' as const,
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: Z900Input = {
recordNumber: recordNumber.toString(),
vatId: input.business.taxId,
totalRecords: records.length.toString(), // 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([iniText], `${options.fileNameBase || 'report'}.INI.TXT`, {
type: 'text/plain',
});
const dataFile = new File([dataText], `${options.fileNameBase || 'report'}.BKMVDATA.TXT`, {
type: 'text/plain',
});
return {
iniText,
dataText,
iniFile,
dataFile,
summary: {
totalRecords: records.length + iniRecords.length,
perType: recordCounts,
},
};
}