@accounter/shaam-uniform-format-generator
Version:
Fully typed application that generates, parses, and validates SHAAM uniform format tax reports (INI.TXT and BKMVDATA.TXT).
294 lines (283 loc) • 12.7 kB
text/typescript
import { z } from 'zod';
import { CRLF } from '../../format/index.js';
import { DocumentTypeEnum } from '../../types/index.js';
import { formatField, formatNumericField } from '../index.js';
/**
* D120 - Payment/receipt record
* Fields 1300-1324 based on SHAAM 1.31 specification
*/
export const D120Schema = z.object({
// Field 1300: Record code (4) - Required - Alphanumeric - Value: D120
code: z.literal('D120').describe('Record type code - always "D120"'),
// Field 1301: Record number in file (9) - Required - Numeric
recordNumber: z
.string()
.min(1)
.max(9)
.regex(/^\d+$/)
.describe('Sequential record number in file'),
// Field 1302: Tax ID (9) - Required - Numeric
vatId: z.string().min(1).max(9).regex(/^\d+$/).describe('VAT identification number'),
// Field 1303: Document type (3) - Required - Numeric
documentType: DocumentTypeEnum.describe('Document type - see Appendix 1, Note 1'),
// Field 1304: Document number (20) - Required - Alphanumeric
documentNumber: z.string().min(1).max(20).describe('Document number'),
// Field 1305: Line number in document (4) - Required - Numeric
lineNumber: z.string().min(1).max(4).regex(/^\d+$/).describe('Line number in document'),
// Field 1306: Payment method (1) - Required - Numeric
paymentMethod: z
.string()
.min(1)
.max(1)
.regex(/^[1-9]$/)
.describe(
'Payment method type: 1-Cash, 2-Check, 3-Credit card, 4-Bank transfer, 5-Voucher, 6-Exchange slip, 7-Promissory note, 8-Standing order, 9-Other',
),
// Field 1307: Bank number (10) - Conditional - Numeric - Check only
bankNumber: z.string().max(10).regex(/^\d*$/).default('').describe('Bank number - check only'),
// Field 1308: Branch number (10) - Conditional - Numeric - Check only
branchNumber: z
.string()
.max(10)
.regex(/^\d*$/)
.default('')
.describe('Branch number - check only'),
// Field 1309: Account number (15) - Conditional - Numeric - Check only
accountNumber: z
.string()
.max(15)
.regex(/^\d*$/)
.default('')
.describe('Account number - check only'),
// Field 1310: Check number (10) - Conditional - Numeric - Check only
checkNumber: z.string().max(10).regex(/^\d*$/).default('').describe('Check number - check only'),
// Field 1311: Payment due date (8) - Optional - Numeric - Format YYYYMMDD
paymentDueDate: z
.string()
.max(8)
.regex(/^(\d{8}|)$/)
.default('')
.describe('Payment due date YYYYMMDD - check or credit card only'),
// Field 1312: Line amount (15) - Required - Alphanumeric - Format X9(12)V99
lineAmount: z
.string()
.min(1)
.max(15)
.regex(/^(\d{1,13}(\.\d{2})?|)$/)
.describe('Line amount'),
// Field 1313: Acquirer code (1) - Optional - Numeric
acquirerCode: z
.string()
.max(1)
.regex(/^[1-6]?$/)
.default('')
.describe('Acquirer code: 1-Isracard, 2-Cal, 3-Diners, 4-Amex, 6-Leumi Card'),
// Field 1314: Card brand (20) - Optional - Alphanumeric
cardBrand: z.string().max(20).default('').describe('Card brand name'),
// Field 1315: Credit transaction type (1) - Optional - Numeric
creditTransactionType: z
.string()
.max(1)
.regex(/^[1-5]?$/)
.default('')
.describe('Credit transaction type: 1-Regular, 2-Installments, 3-Credit, 4-Deferred, 5-Other'),
// Field 1316: First payment amount (0) - Deprecated - Alphanumeric
firstPaymentAmount: z.string().max(0).default('').describe('First payment amount (deprecated)'),
// Field 1317: Installments count (0) - Deprecated - Alphanumeric
installmentsCount: z.string().max(0).default('').describe('Installments count (deprecated)'),
// Field 1318: Additional payment amount (0) - Deprecated - Alphanumeric
additionalPaymentAmount: z
.string()
.max(0)
.default('')
.describe('Additional payment amount (deprecated)'),
// Field 1319: Reserved field (0) - Deprecated - Alphanumeric
reserved1: z.string().max(0).default('').describe('Reserved field 1 (deprecated)'),
// Field 1320: Branch ID (7) - Required - Alphanumeric - Required if field 1034 = 1
branchId: z
.string()
.max(7)
.default('')
.describe('Branch/sector ID - required if field 1034 = 1, see Note 3'),
// Field 1321: Reserved field (0) - Deprecated - Alphanumeric
reserved2: z.string().max(0).default('').describe('Reserved field 2 (deprecated)'),
// Field 1322: Document date (8) - Required - Numeric - Format YYYYMMDD
documentDate: z
.string()
.min(1)
.max(8)
.regex(/^\d{8}$/)
.describe('Document date YYYYMMDD - see Note 12'),
// Field 1323: Header link field (7) - Optional - Numeric - For linking to C100
headerLinkField: z
.string()
.max(7)
.regex(/^\d*$/)
.default('')
.describe('Header connecting field - for linking to C100, see Note 11'),
// Field 1324: Reserved field (60) - Optional - Alphanumeric
reserved: z.string().max(60).default('').describe('Reserved field for future use'),
});
export type D120 = z.infer<typeof D120Schema>;
/**
* Encodes a D120 record to fixed-width string format
* Total line width: 222 characters + CRLF (4+9+9+3+20+4+1+10+10+15+10+8+15+1+20+1+0+0+0+0+7+0+8+7+60)
*/
export function encodeD120(input: D120): string {
const fields = [
formatField(input.code, 4, 'left'), // Field 1300: Record code (4) - Alphanumeric
formatNumericField(input.recordNumber, 9), // Field 1301: Record number (9) - Numeric, zero-padded
formatNumericField(input.vatId, 9), // Field 1302: VAT ID (9) - Numeric, zero-padded
formatNumericField(input.documentType, 3), // Field 1303: Document type (3) - Numeric, zero-padded
formatField(input.documentNumber, 20, 'left'), // Field 1304: Document number (20) - Alphanumeric
formatNumericField(input.lineNumber, 4), // Field 1305: Line number (4) - Numeric, zero-padded
formatNumericField(input.paymentMethod, 1), // Field 1306: Payment method (1) - Numeric, zero-padded
formatField(input.bankNumber, 10, 'left'), // Field 1307: Bank number (10) - Conditional, space-padded when empty
formatField(input.branchNumber, 10, 'left'), // Field 1308: Branch number (10) - Conditional, space-padded when empty
formatField(input.accountNumber, 15, 'left'), // Field 1309: Account number (15) - Conditional, space-padded when empty
formatField(input.checkNumber, 10, 'left'), // Field 1310: Check number (10) - Conditional, space-padded when empty
formatField(input.paymentDueDate, 8, 'left'), // Field 1311: Payment due date (8) - Optional date field, space-padded when empty
formatField(input.lineAmount, 15, 'left'), // Field 1312: Line amount (15) - Alphanumeric monetary format
formatField(input.acquirerCode, 1, 'left'), // Field 1313: Acquirer code (1) - Optional, space-padded when empty
formatField(input.cardBrand, 20, 'left'), // Field 1314: Card brand (20) - Alphanumeric
formatField(input.creditTransactionType, 1, 'left'), // Field 1315: Credit transaction type (1) - Optional, space-padded when empty
formatField(input.firstPaymentAmount, 0, 'left'), // Field 1316: First payment amount (0) - Deprecated
formatField(input.installmentsCount, 0, 'left'), // Field 1317: Installments count (0) - Deprecated
formatField(input.additionalPaymentAmount, 0, 'left'), // Field 1318: Additional payment amount (0) - Deprecated
formatField(input.reserved1, 0, 'left'), // Field 1319: Reserved 1 (0) - Deprecated
formatField(input.branchId, 7, 'left'), // Field 1320: Branch ID (7) - Alphanumeric
formatField(input.reserved2, 0, 'left'), // Field 1321: Reserved 2 (0) - Deprecated
formatNumericField(input.documentDate, 8), // Field 1322: Document date (8) - Numeric, zero-padded
formatField(input.headerLinkField, 7, 'left'), // Field 1323: Header link field (7) - Optional, space-padded when empty
formatField(input.reserved, 60, 'left'), // Field 1324: Reserved (60) - Alphanumeric
];
return fields.join('') + CRLF;
}
/**
* Parses a fixed-width D120 record line back to object
* Expected line length: 222 characters (excluding CRLF)
*/
export function parseD120(line: string): D120 {
// Remove CRLF if present
const cleanLine = line.replace(/\r?\n$/, '');
if (cleanLine.length !== 222) {
throw new Error(`Invalid D120 record length: expected 222 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 =
cleanLine
.slice(pos, pos + 9)
.trim()
.replace(/^0+/, '') || '0'; // Strip leading zeros
pos += 9;
const vatId =
cleanLine
.slice(pos, pos + 9)
.trim()
.replace(/^0+/, '') || '0'; // Strip leading zeros
pos += 9;
const documentType = (cleanLine
.slice(pos, pos + 3)
.trim()
.replace(/^0+/, '') || '0') as z.infer<typeof DocumentTypeEnum>; // Strip leading zeros
pos += 3;
const documentNumber = cleanLine.slice(pos, pos + 20).trim();
pos += 20;
const lineNumber =
cleanLine
.slice(pos, pos + 4)
.trim()
.replace(/^0+/, '') || '0'; // Strip leading zeros
pos += 4;
const paymentMethod =
cleanLine
.slice(pos, pos + 1)
.trim()
.replace(/^0+/, '') || '0'; // Strip leading zeros
pos += 1;
const bankNumber = cleanLine.slice(pos, pos + 10).trim();
const bankNumberProcessed = bankNumber === '' ? '' : bankNumber.replace(/^0+/, '') || '0'; // Strip leading zeros only if not empty
pos += 10;
const branchNumber = cleanLine.slice(pos, pos + 10).trim();
const branchNumberProcessed = branchNumber === '' ? '' : branchNumber.replace(/^0+/, '') || '0'; // Strip leading zeros only if not empty
pos += 10;
const accountNumber = cleanLine.slice(pos, pos + 15).trim();
const accountNumberProcessed =
accountNumber === '' ? '' : accountNumber.replace(/^0+/, '') || '0'; // Strip leading zeros only if not empty
pos += 15;
const checkNumber = cleanLine.slice(pos, pos + 10).trim();
const checkNumberProcessed = checkNumber === '' ? '' : checkNumber.replace(/^0+/, '') || '0'; // Strip leading zeros only if not empty
pos += 10;
const paymentDueDate = cleanLine.slice(pos, pos + 8).trim();
const paymentDueDateProcessed = paymentDueDate; // Keep as-is since it's a date field or empty
pos += 8;
const lineAmount = cleanLine.slice(pos, pos + 15).trim(); // Alphanumeric monetary format
pos += 15;
const acquirerCode = cleanLine.slice(pos, pos + 1).trim();
const acquirerCodeProcessed = acquirerCode; // Keep as-is since it's a single digit field or empty
pos += 1;
const cardBrand = cleanLine.slice(pos, pos + 20).trim();
pos += 20;
const creditTransactionType = cleanLine.slice(pos, pos + 1).trim();
const creditTransactionTypeProcessed = creditTransactionType; // Keep as-is since it's a single digit field or empty
pos += 1;
const firstPaymentAmount = cleanLine.slice(pos, pos + 0).trim();
pos += 0; // 0 width
const installmentsCount = cleanLine.slice(pos, pos + 0).trim();
pos += 0; // 0 width
const additionalPaymentAmount = cleanLine.slice(pos, pos + 0).trim();
pos += 0; // 0 width
const reserved1 = cleanLine.slice(pos, pos + 0).trim();
pos += 0; // 0 width
const branchId = cleanLine.slice(pos, pos + 7).trim();
pos += 7;
const reserved2 = cleanLine.slice(pos, pos + 0).trim();
pos += 0; // 0 width
const documentDate =
cleanLine
.slice(pos, pos + 8)
.trim()
.replace(/^0+/, '') || '0'; // Strip leading zeros
pos += 8;
const headerLinkField = cleanLine.slice(pos, pos + 7).trim();
const headerLinkFieldProcessed =
headerLinkField === '' ? '' : headerLinkField.replace(/^0+/, '') || '0'; // Strip leading zeros only if not empty
pos += 7;
const reserved = cleanLine.slice(pos, pos + 60).trim();
// Validate the code field
if (code !== 'D120') {
throw new Error(`Invalid D120 record code: expected "D120", got "${code}"`);
}
const parsed: D120 = {
code,
recordNumber,
vatId,
documentType,
documentNumber,
lineNumber,
paymentMethod,
bankNumber: bankNumberProcessed,
branchNumber: branchNumberProcessed,
accountNumber: accountNumberProcessed,
checkNumber: checkNumberProcessed,
paymentDueDate: paymentDueDateProcessed,
lineAmount,
acquirerCode: acquirerCodeProcessed,
cardBrand,
creditTransactionType: creditTransactionTypeProcessed,
firstPaymentAmount,
installmentsCount,
additionalPaymentAmount,
reserved1,
branchId,
reserved2,
documentDate,
headerLinkField: headerLinkFieldProcessed,
reserved,
};
// Validate against schema
return D120Schema.parse(parsed);
}