@cityssm/eft-generator
Version:
Formats Electronic Funds Transfer (EFT) data into the CPA 005 standard.
432 lines (368 loc) • 13.7 kB
text/typescript
import { isCPATransactionCode } from '@cityssm/cpa-codes'
import { toShortModernJulianDate } from '@cityssm/modern-julian-date'
import Debug from 'debug'
import { DEBUG_NAMESPACE } from '../debug.config.js'
import type { EFTGenerator } from '../index.js'
import type {
EFTConfiguration,
EFTTransaction,
ValidationWarning
} from '../types.js'
const debug = Debug(`${DEBUG_NAMESPACE}:cpa005`)
export const NEWLINE = '\r\n'
function toPaddedJulianDate(date: Date): `0${string}` {
return ('0' + toShortModernJulianDate(date)) as `0${string}`
}
function validateConfig(eftConfig: EFTConfiguration): ValidationWarning[] {
const validationWarnings: ValidationWarning[] = []
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: EFTTransaction[]
): ValidationWarning[] {
const validationWarnings: ValidationWarning[] = []
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<string>()
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: EFTGenerator
): ValidationWarning[] {
const validationWarnings = validateConfig(eftGenerator.getConfiguration())
validationWarnings.push(
...validateTransactions(eftGenerator.getTransactions())
)
return validationWarnings
}
function formatHeader(eftConfig: EFTConfiguration): string {
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: EFTGenerator): string {
const validationWarnings = validateCPA005(eftGenerator)
if (validationWarnings.length > 0) {
debug(`Proceeding with ${validationWarnings.length} warnings.`)
debug(validationWarnings)
}
const eftConfig = eftGenerator.getConfiguration()
const outputLines: string[] = []
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)
}