@cityssm/eft-generator
Version:
Formats Electronic Funds Transfer (EFT) data into the CPA 005 standard.
327 lines (326 loc) • 14.6 kB
JavaScript
import { isCPATransactionCode } from '@cityssm/cpa-codes';
import { toShortModernJulianDate } from '@cityssm/modern-julian-date';
import Debug from 'debug';
import { DEBUG_NAMESPACE } from '../debug.config.js';
const debug = Debug(`${DEBUG_NAMESPACE}:cpa005`);
export const NEWLINE = '\r\n';
function toPaddedJulianDate(date) {
return ('0' + toShortModernJulianDate(date));
}
function validateConfig(eftConfig) {
const validationWarnings = [];
if (eftConfig.originatorId.length > 10) {
throw new Error(`originatorId length exceeds 10: ${eftConfig.originatorId}`);
}
if (!/^\d{1,4}$/.test(eftConfig.fileCreationNumber)) {
throw new Error(`fileCreationNumber should be 1 to 4 digits: ${eftConfig.fileCreationNumber}`);
}
if (!/^\d{0,5}$/.test(eftConfig.destinationDataCentre ?? '')) {
throw new Error(`destinationDataCentre should be 1 to 5 digits: ${eftConfig.destinationDataCentre}`);
}
if (eftConfig.originatorShortName === undefined) {
validationWarnings.push({
warningField: 'originatorShortName',
warning: 'originatorShortName not defined, using originatorLongName.'
});
eftConfig.originatorShortName = eftConfig.originatorLongName;
}
if (eftConfig.originatorShortName.length > 15) {
validationWarnings.push({
warningField: 'originatorShortName',
warning: `originatorShortName will be truncated to 15 characters: ${eftConfig.originatorShortName}`
});
}
if (eftConfig.originatorLongName.length > 30) {
validationWarnings.push({
warningField: 'originatorLongName',
warning: `originatorLongName will be truncated to 30 characters: ${eftConfig.originatorLongName}`
});
}
if (!['', 'CAD', 'USD'].includes(eftConfig.destinationCurrency ?? '')) {
throw new Error(`Unsupported destinationCurrency: ${eftConfig.destinationCurrency}`);
}
let returnAccountUndefinedCount = 0;
if (eftConfig.returnInstitutionNumber === undefined) {
returnAccountUndefinedCount += 1;
}
else if (!/^\d{1,3}$/.test(eftConfig.returnInstitutionNumber)) {
throw new Error(`returnInstitutionNumber should be 1 to 3 digits: ${eftConfig.returnInstitutionNumber}`);
}
if (eftConfig.returnTransitNumber === undefined) {
returnAccountUndefinedCount += 1;
}
else if (!/^\d{1,5}$/.test(eftConfig.returnTransitNumber)) {
throw new Error(`returnTransitNumber should be 1 to 3 digits: ${eftConfig.returnTransitNumber}`);
}
if (eftConfig.returnAccountNumber === undefined) {
returnAccountUndefinedCount += 1;
}
else if (!/^\d{1,12}$/.test(eftConfig.returnAccountNumber)) {
throw new Error(`returnAccountNumber should be 1 to 3 digits: ${eftConfig.returnAccountNumber}`);
}
if (returnAccountUndefinedCount > 0 && returnAccountUndefinedCount < 3) {
throw new Error('returnInstitutionNumber, returnTransitNumber, and returnAccountNumber must by defined together, or not defined at all.');
}
return validationWarnings;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function validateTransactions(eftTransactions) {
const validationWarnings = [];
if (eftTransactions.length === 0) {
validationWarnings.push({
warningField: 'transactions',
warning: 'There are no transactions to include in the file.'
});
}
else if (eftTransactions.length > 999_999_999) {
throw new Error('Transaction count exceeds 999,999,999.');
}
const crossReferenceNumbers = new Set();
for (const [transactionIndex, transaction] of eftTransactions.entries()) {
if (transaction.segments.length === 0) {
validationWarnings.push({
transactionIndex,
warningField: 'segments',
warning: 'Transaction record has no segments, will be ignored.'
});
}
else if (transaction.segments.length > 6) {
validationWarnings.push({
transactionIndex,
warningField: 'segments',
warning: 'Transaction record has more than 6 segments, will be split into multiple transactions.'
});
}
if (!['C', 'D'].includes(transaction.recordType)) {
throw new Error(`Unsupported recordType: ${transaction.recordType}`);
}
for (const [transactionSegmentIndex, segment] of transaction.segments.entries()) {
if (!isCPATransactionCode(segment.cpaCode)) {
validationWarnings.push({
transactionIndex,
transactionSegmentIndex,
warningField: 'cpaCode',
warning: `Unknown CPA code: ${segment.cpaCode}`
});
}
if (segment.amount <= 0) {
throw new Error(`Segment amount cannot be less than or equal to zero: ${segment.amount}`);
}
else if (segment.amount >= 100_000_000) {
throw new Error(`Segment amount cannot exceed $100,000,000: ${segment.amount}`);
}
if (!/^\d{1,3}$/.test(segment.bankInstitutionNumber)) {
throw new Error(`bankInstitutionNumber should be 1 to 3 digits: ${segment.bankInstitutionNumber}`);
}
if (!/^\d{1,5}$/.test(segment.bankTransitNumber)) {
throw new Error(`bankTransitNumber should be 1 to 5 digits: ${segment.bankTransitNumber}`);
}
if (!/^\d{1,12}$/.test(segment.bankAccountNumber)) {
throw new Error(`bankAccountNumber should be 1 to 12 digits: ${segment.bankAccountNumber}`);
}
if (segment.payeeName.length > 30) {
validationWarnings.push({
transactionIndex,
transactionSegmentIndex,
warningField: 'payeeName',
warning: `payeeName will be truncated to 30 characters: ${segment.payeeName}`
});
}
if (segment.crossReferenceNumber !== undefined) {
if (crossReferenceNumbers.has(segment.crossReferenceNumber)) {
validationWarnings.push({
transactionIndex,
transactionSegmentIndex,
warningField: 'crossReferenceNumber',
warning: `crossReferenceNumber should be unique: ${segment.crossReferenceNumber}`
});
}
crossReferenceNumbers.add(segment.crossReferenceNumber);
}
}
}
return validationWarnings;
}
export function validateCPA005(eftGenerator) {
const validationWarnings = validateConfig(eftGenerator.getConfiguration());
validationWarnings.push(...validateTransactions(eftGenerator.getTransactions()));
return validationWarnings;
}
function formatHeader(eftConfig) {
const fileCreationJulianDate = toPaddedJulianDate(eftConfig.fileCreationDate ?? new Date());
let dataCentre = ''.padEnd(5, ' ');
if (eftConfig.destinationDataCentre !== undefined) {
dataCentre = eftConfig.destinationDataCentre.padStart(5, '0');
}
let destinationCurrency = ''.padEnd(3, ' ');
if (eftConfig.destinationCurrency !== undefined) {
destinationCurrency = eftConfig.destinationCurrency;
}
return (
// Logical Record Type Id
'A' +
// Logical Record Count
'1'.padStart(9, '0') +
// Originator's Id / Client Number
eftConfig.originatorId.padEnd(10, ' ') +
// File Creation Number
eftConfig.fileCreationNumber.padStart(4, '0').slice(-4) +
// Creation Date (0YYDDD)
fileCreationJulianDate +
// Destination Data Centre
dataCentre +
// Reserved Customer Direct Clearer Communication Area
''.padEnd(20, ' ') +
// Currency Code Identifier
destinationCurrency +
// Filler
''.padEnd(1406, ' '));
}
export function formatToCPA005(eftGenerator) {
const validationWarnings = validateCPA005(eftGenerator);
if (validationWarnings.length > 0) {
debug(`Proceeding with ${validationWarnings.length} warnings.`);
debug(validationWarnings);
}
const eftConfig = eftGenerator.getConfiguration();
const outputLines = [];
outputLines.push(formatHeader(eftConfig));
let recordCount = 1;
let record = '';
let totalValueDebits = 0;
let totalNumberDebits = 0;
let totalValueCredits = 0;
let totalNumberCredits = 0;
for (const transaction of eftGenerator.getTransactions()) {
record = '';
for (let segmentIndex = 0; segmentIndex < transaction.segments.length; segmentIndex += 1) {
if (segmentIndex % 6 === 0) {
if (segmentIndex > 0) {
outputLines.push(record);
}
recordCount += 1;
if (transaction.recordType === 'C') {
totalNumberCredits += 1;
}
else {
totalNumberDebits += 1;
}
record =
// Logical Record Type Id
transaction.recordType +
// Logical Record Count
recordCount.toString().padStart(9, '0') +
// Origination Control Data
eftConfig.originatorId.padEnd(10, ' ') +
eftConfig.fileCreationNumber.padStart(4, '0');
}
const segment = transaction.segments[segmentIndex];
const paymentJulianDate = toPaddedJulianDate(segment.paymentDate ?? new Date());
let crossReferenceNumber = segment.crossReferenceNumber;
if (crossReferenceNumber === undefined) {
crossReferenceNumber =
'f' +
eftConfig.fileCreationNumber +
'r' +
recordCount.toString() +
's' +
(segmentIndex + 1).toString();
}
const originatorShortName = eftConfig.originatorShortName ?? eftConfig.originatorLongName;
record +=
// Transaction Type
segment.cpaCode.toString() +
// Amount
Math.round(segment.amount * 100)
.toString()
.padStart(10, '0') +
// Credit: Date Funds to be Available
// Debit: Due Date
paymentJulianDate +
// Institutional Identification Number (9 digits)
''.padStart(1, '0') +
segment.bankInstitutionNumber.padStart(3, '0') +
segment.bankTransitNumber.padStart(5, '0') +
// Credit: Payee Account Number
// Debit: Payor Account Number
segment.bankAccountNumber.padEnd(12, ' ') +
// Item Trace Number
''.padStart(22, '0') +
// Stored Transaction Type
''.padStart(3, '0') +
// Originator's Short Name
originatorShortName.padEnd(15, ' ').slice(0, 15) +
// Credit: Payee Name
// Debit: Payor Name
segment.payeeName.padEnd(30, ' ').slice(0, 30) +
// Originator's Long Name
eftConfig.originatorLongName.padEnd(30, ' ').slice(0, 30) +
// Originating Direct Clearer's User's Id
eftConfig.originatorId.padEnd(10, ' ') +
// Originator's Cross Reference Number
crossReferenceNumber.padEnd(19, ' ').slice(0, 19) +
// Institutional ID Number for Returns
''.padStart(1, '0') +
// Institution Number for Returns
(eftConfig.returnInstitutionNumber === undefined
? ''.padEnd(3, ' ')
: eftConfig.returnInstitutionNumber.padStart(3, '0')) +
// Transit Number for Returns
(eftConfig.returnTransitNumber === undefined
? ''.padEnd(5, ' ')
: eftConfig.returnTransitNumber.padStart(5, '0')) +
// Account Number for Returns
(eftConfig.returnAccountNumber ?? '').padEnd(12, ' ') +
// Originator's Sundry Information
''.padEnd(15, ' ') +
// Filler
''.padEnd(22, ' ') +
// Originator-Direct Clearer Settlement Code
''.padEnd(2, ' ') +
// Invalid Data Element Id
''.padStart(11, '0');
if (transaction.recordType === 'C') {
totalValueCredits += segment.amount;
}
else {
totalValueDebits += segment.amount;
}
}
if (record !== '') {
outputLines.push(record.padEnd(1464, ' '));
}
}
const trailer =
// Logical Record Type Id
'Z' +
// Logical Record Count
(recordCount + 1).toString().padStart(9, '0') +
// Origination Control Data
eftConfig.originatorId.padEnd(10, ' ') +
eftConfig.fileCreationNumber.padStart(4, '0').slice(-4) +
// Total Value of Debit Transactions
Math.round(totalValueDebits * 100)
.toString()
.padStart(14, '0') +
// Total Number of Debit Transactions
totalNumberDebits.toString().padStart(8, '0') +
// Total Value of Credit Transactions
Math.round(totalValueCredits * 100)
.toString()
.padStart(14, '0') +
// Total Number of Credit Transactions
totalNumberCredits.toString().padStart(8, '0') +
// Total Value of Error Corrections "E"
'0'.padStart(14, '0') +
// Total Number of Error Corrections "E"
'0'.padStart(8, '0') +
// Total Value of Error Corrections "F"
'0'.padStart(14, '0') +
// Total Number of Error Corrections "F"
'0'.padStart(8, '0') +
// Filler
''.padEnd(1352, ' ');
outputLines.push(trailer);
return outputLines.join(NEWLINE);
}