@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
text/typescript
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);
}