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).

295 lines (284 loc) • 12.5 kB
import { z } from 'zod'; import { CRLF } from '../../format/index.js'; import { CountryCodeEnum, CurrencyCodeEnum, type CountryCode, type CurrencyCode, } from '../../types/index.js'; import { formatField, formatNumericField } from '../format/encoder.js'; import { formatMonetaryAmount, formatOptionalMonetaryAmount, parseMonetaryAmount, } from '../format/monetary.js'; /** * B110 Record Schema - Account record * Fields 1400-1424 based on SHAAM 1.31 specification table */ export const B110Schema = z.object({ // Field 1400: Record code (4) - Required - Alphanumeric code: z.literal('B110').describe('Record type code - always "B110"'), // Field 1401: Record number in file (9) - Required - Numeric recordNumber: z .number() .int() .min(1) .max(999_999_999) .describe('Sequential record number in file'), // Field 1402: Tax ID (9) - Required - Numeric vatId: z.string().min(1).max(9).regex(/^\d+$/).describe('Tax identification number'), // Field 1403: Account key (15) - Required - Alphanumeric accountKey: z.string().min(1).max(15).describe('Account key - must be unique'), // Field 1404: Account name (50) - Required - Alphanumeric accountName: z.string().max(50).optional().describe('Account name'), // Field 1405: Trial balance code (15) - Required - Alphanumeric trialBalanceCode: z.string().min(1).max(15).describe('Trial balance code'), // Field 1406: Trial balance code description (30) - Optional - Alphanumeric trialBalanceCodeDescription: z .string() .max(30) .optional() .describe('Trial balance code description'), // Field 1407: Customer/Supplier address - street (50) - Optional - Alphanumeric customerSupplierAddressStreet: z .string() .max(50) .optional() .describe('Customer/Supplier address - street (only for customer/supplier accounts)'), // Field 1408: Customer/Supplier address - house number (10) - Optional - Alphanumeric customerSupplierAddressHouseNumber: z .string() .max(10) .optional() .describe('Customer/Supplier address - house number (only for customer/supplier accounts)'), // Field 1409: Customer/Supplier address - city (30) - Optional - Alphanumeric customerSupplierAddressCity: z .string() .max(30) .optional() .describe('Customer/Supplier address - city (only for customer/supplier accounts)'), // Field 1410: Customer/Supplier address - ZIP (8) - Optional - Alphanumeric customerSupplierAddressZip: z .string() .max(8) .optional() .describe('Customer/Supplier address - ZIP (only for customer/supplier accounts)'), // Field 1411: Customer/Supplier address - country (30) - Optional - Alphanumeric customerSupplierAddressCountry: z .string() .max(30) .optional() .describe('Customer/Supplier address - country (only for customer/supplier accounts)'), // Field 1412: Country code (2) - Optional - Alphanumeric countryCode: CountryCodeEnum.optional().describe( 'Country code (only for customer/supplier accounts)', ), // Field 1413: Parent account key (15) - Optional - Alphanumeric parentAccountKey: z.string().max(15).optional().describe('Parent account key'), // Field 1414: Account opening balance (15) - Optional - Numeric - Format: X9(12)V99 accountOpeningBalance: z .number() .describe('Account opening balance (positive = debit, negative = credit)'), // Field 1415: Total debits (15) - Optional - Numeric - Format: X9(12)V99 totalDebits: z.number().optional().describe('Total debits (excludes opening balance)'), // Field 1416: Total credits (15) - Optional - Numeric - Format: X9(12)V99 totalCredits: z.number().optional().describe('Total credits (excludes opening balance)'), // Field 1417: Accounting classification code (4) - Conditional - Numeric accountingClassificationCode: z .number() .int() .min(0) .max(9999) .optional() .describe('Accounting classification code (mandatory if 6111 report required)'), // Field 1418: Reserved field (0) - Deprecated - Alphanumeric - Skipped as it has 0 length // Field 1419: Supplier/Customer tax ID (9) - Conditional - Numeric supplierCustomerTaxId: z .string() .max(9) .regex(/^$|^\d+$/) .optional() .describe( 'Supplier/Customer tax ID (required if double-entry bookkeeping, code 2 in field 1013)', ), // Field 1420: Reserved field (0) - Deprecated - Alphanumeric - Skipped as it has 0 length // Field 1421: Branch ID (7) - Conditional - Alphanumeric branchId: z .string() .max(7) .optional() .describe('Branch ID (required if field 1034 = 1, see Note 3)'), // Field 1422: Opening balance in foreign currency (15) - Optional - Numeric - Format: X9(12)V99 openingBalanceForeignCurrency: z .number() .optional() .describe('Opening balance in foreign currency'), // Field 1423: Foreign currency code (3) - Optional - Alphanumeric foreignCurrencyCode: CurrencyCodeEnum.optional().describe( 'Foreign currency code (refers to field 1422)', ), // Field 1424: Reserved field (16) - Optional - Alphanumeric reserved: z.string().max(16).optional().describe('Reserved field for future use'), }); export type B110 = z.infer<typeof B110Schema>; /** * Encodes a B110 record to fixed-width string format * Total line width: 376 characters + CRLF */ export function encodeB110(input: B110): string { const fields = [ formatField(input.code, 4, 'left'), // Field 1400: Record code (4) formatField(input.recordNumber.toString().padStart(9, '0'), 9, 'left'), // Field 1401: Record number (9) formatNumericField(input.vatId, 9), // Field 1402: VAT ID (9) formatField(input.accountKey, 15, 'left'), // Field 1403: Account key (15) formatField(input.accountName ?? '', 50, 'left'), // Field 1404: Account name (50) formatField(input.trialBalanceCode, 15, 'left'), // Field 1405: Trial balance code (15) formatField(input.trialBalanceCodeDescription ?? '', 30, 'left'), // Field 1406: Trial balance code description (30) formatField(input.customerSupplierAddressStreet ?? '', 50, 'left'), // Field 1407: Customer/Supplier address - street (50) formatField(input.customerSupplierAddressHouseNumber ?? '', 10, 'left'), // Field 1408: Customer/Supplier address - house number (10) formatField(input.customerSupplierAddressCity ?? '', 30, 'left'), // Field 1409: Customer/Supplier address - city (30) formatField(input.customerSupplierAddressZip ?? '', 8, 'left'), // Field 1410: Customer/Supplier address - ZIP (8) formatField(input.customerSupplierAddressCountry ?? '', 30, 'left'), // Field 1411: Customer/Supplier address - country (30) formatField(input.countryCode ?? '', 2, 'left'), // Field 1412: Country code (2) formatField(input.parentAccountKey ?? '', 15, 'left'), // Field 1413: Parent account key (15) formatOptionalMonetaryAmount(input.accountOpeningBalance) || formatMonetaryAmount(0), // Field 1414: Account opening balance (15) formatOptionalMonetaryAmount(input.totalDebits) || formatMonetaryAmount(0), // Field 1415: Total debits (15) formatOptionalMonetaryAmount(input.totalCredits) || formatMonetaryAmount(0), // Field 1416: Total credits (15) formatField( input.accountingClassificationCode?.toString().padStart(4, '0') ?? '0000', 4, 'left', ), // Field 1417: Accounting classification code (4) // Field 1418: Reserved field (0) - skipped as it has 0 length // Use original value if available and looks like raw field data, otherwise format input.supplierCustomerTaxId ? input.supplierCustomerTaxId.length === 9 ? input.supplierCustomerTaxId // Preserve original raw field data if exactly 9 chars : formatField(input.supplierCustomerTaxId, 9, 'left') // Right-pad with spaces for processed values : formatField('', 9, 'left'), // Field 1419: Supplier/Customer tax ID (9) - use spaces if empty // Field 1420: Reserved field (0) - skipped as it has 0 length formatField(input.branchId ?? '0', 7, 'left'), // Field 1421: Branch ID (7) formatOptionalMonetaryAmount(input.openingBalanceForeignCurrency) || formatMonetaryAmount(0), // Field 1422: Opening balance in foreign currency (15) formatField(input.foreignCurrencyCode ?? '', 3, 'left'), // Field 1423: Foreign currency code (3) formatField(input.reserved ?? '', 16, 'left'), // Field 1424: Reserved field (16) ]; return fields.join('') + CRLF; } /** * Parses a fixed-width B110 record line back to object * Expected line length: 376 characters (excluding CRLF) */ export function parseB110(line: string): B110 { // Remove CRLF if present const cleanLine = line.replace(/\r?\n$/, ''); if (cleanLine.length !== 376) { throw new Error(`Invalid B110 record length: expected 376 characters, got ${cleanLine.length}`); } // Extract fields at their fixed positions let pos = 0; const code = cleanLine.slice(pos, pos + 4).trim(); pos += 4; const recordNumber = parseInt( cleanLine .slice(pos, pos + 9) .trim() .replace(/^0+/, '') || '0', 10, ); pos += 9; const vatId = cleanLine .slice(pos, pos + 9) .trim() .replace(/^0+/, '') || '0'; pos += 9; const accountKey = cleanLine.slice(pos, pos + 15).trim(); pos += 15; const accountName = cleanLine.slice(pos, pos + 50).trim(); pos += 50; const trialBalanceCode = cleanLine.slice(pos, pos + 15).trim(); pos += 15; const trialBalanceCodeDescription = cleanLine.slice(pos, pos + 30).trim(); pos += 30; const customerSupplierAddressStreet = cleanLine.slice(pos, pos + 50).trim(); pos += 50; const customerSupplierAddressHouseNumber = cleanLine.slice(pos, pos + 10).trim(); pos += 10; const customerSupplierAddressCity = cleanLine.slice(pos, pos + 30).trim(); pos += 30; const customerSupplierAddressZip = cleanLine.slice(pos, pos + 8).trim(); pos += 8; const customerSupplierAddressCountry = cleanLine.slice(pos, pos + 30).trim(); pos += 30; const countryCodeRaw = cleanLine.slice(pos, pos + 2).trim(); const countryCode = (countryCodeRaw || undefined) as CountryCode | undefined; pos += 2; const parentAccountKey = cleanLine.slice(pos, pos + 15).trim(); pos += 15; const accountOpeningBalance = cleanLine.slice(pos, pos + 15); pos += 15; const totalDebits = cleanLine.slice(pos, pos + 15); pos += 15; const totalCredits = cleanLine.slice(pos, pos + 15); pos += 15; const accountingClassificationCodeStr = cleanLine .slice(pos, pos + 4) .trim() .replace(/^0+/, '') || ''; const accountingClassificationCode = accountingClassificationCodeStr === '' ? undefined : parseInt(accountingClassificationCodeStr, 10); pos += 4; // Field 1418: Reserved field (0) - skipped as it has 0 length const supplierCustomerTaxId = cleanLine .slice(pos, pos + 9) .trim() .replace(/^0+/, '') || ''; pos += 9; // Field 1420: Reserved field (0) - skipped as it has 0 length const branchId = cleanLine.slice(pos, pos + 7).trim(); pos += 7; const openingBalanceForeignCurrency = cleanLine.slice(pos, pos + 15); pos += 15; const foreignCurrencyCodeRaw = cleanLine.slice(pos, pos + 3).trim(); const foreignCurrencyCode = (foreignCurrencyCodeRaw || undefined) as CurrencyCode | undefined; pos += 3; const reserved = cleanLine.slice(pos, pos + 16).trim(); // Validate the code field if (code !== 'B110') { throw new Error(`Invalid B110 record code: expected "B110", got "${code}"`); } const parsed: B110 = { code, recordNumber, vatId, accountKey, accountName, trialBalanceCode, trialBalanceCodeDescription, customerSupplierAddressStreet, customerSupplierAddressHouseNumber, customerSupplierAddressCity, customerSupplierAddressZip, customerSupplierAddressCountry, countryCode, parentAccountKey, accountOpeningBalance: parseMonetaryAmount(accountOpeningBalance), totalDebits: totalDebits.trim() ? parseMonetaryAmount(totalDebits) : undefined, totalCredits: totalCredits.trim() ? parseMonetaryAmount(totalCredits) : undefined, accountingClassificationCode, supplierCustomerTaxId, branchId, openingBalanceForeignCurrency: openingBalanceForeignCurrency.trim() ? parseMonetaryAmount(openingBalanceForeignCurrency) : undefined, foreignCurrencyCode, reserved, }; // Validate against schema return B110Schema.parse(parsed); }