UNPKG

@midlandsbank/node-nacha

Version:

NACHA ACH EFT File Parser/Formatter for CCD+ / PPD+ / CTX+

1,310 lines (1,288 loc) 36 kB
"use strict"; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __hasOwnProp = Object.prototype.hasOwnProperty; var __export = (target, all) => { for (var name2 in all) __defProp(target, name2, { get: all[name2], enumerable: true }); }; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") { for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); // src/index.ts var index_exports = {}; __export(index_exports, { SEC_CODES: () => SEC_CODES, TRANSACTION_CODES: () => TRANSACTION_CODES, default: () => index_default, format: () => formatNacha, parse: () => parseNacha }); module.exports = __toCommonJS(index_exports); // src/utils/dates.ts function toYYMMDD(date) { if (date === void 0) return void 0; const year = String(date.getFullYear() % 100).padStart(2, "0"); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}${month}${day}`; } function parseYYMMDD(dateStr, timeStr) { if (!dateStr) return void 0; if (!/^\d{6}$/.test(dateStr)) { throw new Error(`Invalid date format. Expected YYMMDD. Received ${dateStr}`); } if (timeStr && !/^\d{4}$/.test(timeStr)) { throw new Error("Invalid time format. Expected HHMM."); } const currentDate = /* @__PURE__ */ new Date(); const currentCentury = currentDate.getFullYear() - currentDate.getFullYear() % 100; const year = Number(dateStr.slice(0, 2)); const month = Number(dateStr.slice(2, 4)) - 1; const day = Number(dateStr.slice(4, 6)); let parsedDate = new Date(currentCentury + year, month, day); if (timeStr) { if (!/^\d{4}$/.test(timeStr)) { throw new Error("Invalid time format. Expected HHMM."); } const hour = Number(timeStr.slice(0, 2)); const minute = Number(timeStr.slice(2, 4)); if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { throw new Error("Invalid time values in timeStr"); } parsedDate.setHours(hour, minute, 0, 0); } else { parsedDate.setHours(0, 0, 0, 0); } return parsedDate; } function toHHMM(date) { if (!date) return void 0; const hours = date.getHours().toString().padStart(2, "0"); const minutes = date.getMinutes().toString().padStart(2, "0"); return `${hours}${minutes}`; } // src/utils/sumArray.ts function sumArray(numArr) { return numArr.reduce((acc, curVal) => acc + curVal, 0); } // src/types/Nacha/RecordTypes.ts var RecordTypes = { FILE_HEADER: 1, BATCH_HEADER: 5, ENTRY: 6, ADDENDA: 7, BATCH_FOOTER: 8, FILE_FOOTER: 9 }; var RecordTypeCodes = Object.values(RecordTypes); // src/fieldDefinitions/addenda.ts var addenda = { addendaTypeCode: { position: [2, 3], allow: ["05", "98", "99"] }, paymentRelatedInfo: { position: [4, 83], optional: true }, addendaSequenceNum: { position: [84, 87], numeric: true, validator: { regex: /\d{4}/, message: "addendaSequenceNum must be 4 digits" } }, entryDetailSequenceNum: { position: [88, 94], validator: { regex: /\d{7}/, message: "entryDetailSequenceNum must be 7 digits" } } }; // src/types/Nacha/SEC.ts var SEC_CODES = ["WEB", "TEL", "PPD", "CCD", "CTX"]; // src/validators.ts function aba(val) { const routNum = val.toString().trim(); const issues = []; if (!/^[0-9]{9}$/.test(routNum)) { issues.push({ message: "should be 9 digits long and only digits", level: "NACHA", value: val }); return issues; } if (!isRoutingInRange(routNum)) { issues.push({ message: "First 2 digits ABA routing not within valid ranges", level: "NACHA", value: val }); } if (!isCheckDigitValid(routNum)) { issues.push({ message: "check digit is invalid", level: "NACHA", value: val }); } return issues; } function dfi(val) { const dfiNum = val.toString().trim(); const issues = []; if (!/^[0-9]{8}$/.test(dfiNum)) { issues.push({ message: "should be 8 digits long and only digits", level: "NACHA", value: val }); return issues; } if (!isRoutingInRange(dfiNum)) { issues.push({ message: "First 2 digits ABA routing not within valid ranges", level: "NACHA", value: val }); } return issues; } function isCheckDigitValid(rtn) { const d = rtn.split("").map(Number); return (3 * (d[0] + d[3] + d[6]) + 7 * (d[1] + d[4] + d[7]) + 1 * (d[2] + d[5] + d[8])) % 10 === 0; } function isRoutingInRange(val) { const firstTwoDigits = Number(val.slice(0, 2)); return firstTwoDigits === 80 || isNumberInRanges(firstTwoDigits, [0, 12], [21, 32], [61, 72]); } function isNumberInRanges(num, ...ranges) { return ranges.some(([lo, hi]) => num >= lo && num <= hi); } // src/fieldDefinitions/batch.ts var batchHeader = { serviceClassCode: { position: [2, 4], numeric: true, allow: [200, 220, 225] }, companyName: { position: [5, 20] }, companyDiscretionaryData: { position: [21, 40], optional: true }, companyId: { position: [41, 50], trim: false }, standardEntryClass: { position: [51, 53], allow: SEC_CODES }, companyEntryDescription: { position: [54, 63] }, companyEntryDescriptiveDate: { position: [64, 69], optional: true }, effectiveDate: { position: [70, 75] }, settlementDate: { position: [76, 78], isBlank: true, optional: true }, originatorStatusCode: { numeric: true, position: [79, 79] }, originatingDFIIdentification: { position: [80, 87] }, batchNumber: { position: [88, 94], numeric: true } }; var batchFooter = { serviceClassCode: { position: [2, 4], numeric: true, allow: [200, 220, 225] }, entryAndAddendaCount: { position: [5, 10], numeric: true }, entryHash: { position: [11, 20], numeric: true }, totalDebits: { position: [21, 32], numeric: true }, totalCredits: { position: [33, 44], numeric: true }, companyId: { position: [45, 54], trim: false }, messageAuthCode: { position: [55, 73], trim: false, optional: true }, reserved: { position: [74, 79], isBlank: true, trim: false }, originatingDFIIdentification: { position: [80, 87], validator: dfi }, batchNumber: { position: [88, 94], numeric: true } }; // src/types/EntryHelpers.ts var AccountTypeMap = { Checking: 2, Savings: 3, GL: 4, Loan: 5 }; var AccountTypeCodes = Object.values(AccountTypeMap); var AccountCodeMap = Object.fromEntries( Object.entries(AccountTypeMap).map(([k, v]) => [v, k]) ); var EntryTypeCodes = [2, 3, 4, 6, 7, 8, 9]; var TRANSACTION_CODES = AccountTypeCodes.flatMap( (a) => EntryTypeCodes.map((e) => a * 10 + e) ); // src/fieldDefinitions/entry.ts var transactionCode = { // length: 2, numeric: true, position: [2, 3], allow: TRANSACTION_CODES }; var routingNumber = { // length: 9, position: [4, 12], validator: aba }; var accountNumber = { // length: 17, position: [13, 29] }; var amount = { // length: 10, numeric: true, position: [30, 39] }; var addendaRecordIndicator = { // length: 1, allow: [0, 1], numeric: true }; var idNumber = { // length: 15, position: [40, 54], optional: true }; var discretionaryData = { // length: 2, optional: true // position: 8, }; var name = { // length: 22, optional: true // position: 7, }; var PPD = { transactionCode, routingNumber, accountNumber, amount, idNumber, name: { position: [55, 76], ...name }, discretionaryData: { position: [77, 78], ...discretionaryData }, addendaRecordIndicator: { position: [79, 79], ...addendaRecordIndicator }, traceNumber: { position: [80, 94] } }; var CCD = PPD; var TEL = PPD; var WEB = { // recordTypeCode, transactionCode, routingNumber, accountNumber, amount, idNumber, name: { position: [55, 76], ...name }, paymentTypeCode: { // length: 2, optional: true, position: [77, 78], allow: ["R", "S"] }, addendaRecordIndicator: { position: [79, 79], ...addendaRecordIndicator }, traceNumber: { position: [80, 94] } }; var CTX = { transactionCode, routingNumber, accountNumber, amount, idNumber, numberOfAddendas: { // length: 4, numeric: true, position: [55, 58] }, name: { ...name, position: [59, 74] }, reserved: { position: [75, 76], // length: 2, isBlank: true, trim: false }, discretionaryData: { position: [77, 78], ...discretionaryData }, addendaRecordIndicator: { position: [79, 79], ...addendaRecordIndicator }, traceNumber: { position: [80, 94] } }; // src/fieldDefinitions/file.ts var fileHeader = { priorityCode: { numeric: true, position: [2, 3] }, destination: { validator: aba, // trim: false, position: [4, 13], justify: "right" }, origin: { validator: aba, // trim: false, position: [14, 23], justify: "right" }, fileCreationDate: { position: [24, 29] // type: "date", }, fileCreationTime: { position: [30, 33] }, fileIdModifier: { position: [34, 34], validator: { regex: /[A-Z0-9]/, message: "fileIdModifier must be alphanumeric" } }, recordSize: { position: [35, 37], mustEqual: 94, numeric: true }, blockingFactor: { position: [38, 39], mustEqual: 10, numeric: true }, formatCode: { position: [40, 40], mustEqual: 1, numeric: true }, destinationName: { position: [41, 63], optional: true }, originName: { position: [64, 86], optional: true }, referenceCode: { position: [87, 94], optional: true } }; var fileFooter = { batchCount: { numeric: true, position: [2, 7] }, blockCount: { numeric: true, position: [8, 13] }, entryAndAddendaCount: { numeric: true, position: [14, 21] }, entryHash: { numeric: true, position: [22, 31] }, totalDebits: { numeric: true, position: [32, 43] }, totalCredits: { numeric: true, position: [44, 55] }, reserved: { isBlank: true, trim: false, position: [56, 94] } }; // src/fieldDefinitions/index.ts var fieldDefinitions = { [RecordTypes.FILE_HEADER]: fileHeader, [RecordTypes.BATCH_HEADER]: batchHeader, [RecordTypes.ENTRY]: { CCD, PPD, WEB, TEL, CTX }, [RecordTypes.ADDENDA]: addenda, [RecordTypes.BATCH_FOOTER]: batchFooter, [RecordTypes.FILE_FOOTER]: fileFooter }; var fieldDefinitions_default = fieldDefinitions; function getNonEntryFieldDef(recordTypeCode) { return fieldDefinitions[recordTypeCode]; } function getEntryFieldDef(sec) { return fieldDefinitions[6][sec]; } // src/utils/formatNacha.ts function formatNacha(data) { let records = [data.header]; data.batches.sort((a, b) => a.header.batchNumber - b.header.batchNumber).forEach((batch) => { records.push(batch.header); batch.entries.forEach(({ entry, addenda: addenda2 }) => { records.push(entry); (addenda2 ?? []).sort((a, b) => a.addendaSequenceNum - b.addendaSequenceNum).forEach((addenda3) => records.push(addenda3)); }); records.push(batch.footer); }); records.push(data.footer); let padding = Array(data.padding).fill("9".repeat(94)); let fileContents = records.map(formatField); fileContents.push(...padding); return fileContents.join("\n"); } function formatField(data) { let fieldDefs = data.recordTypeCode === 6 ? fieldDefinitions_default[6][data.type] : fieldDefinitions_default[data.recordTypeCode]; let charArray = [data.recordTypeCode.toString()]; charArray.push(..." ".repeat(93).split("")); Object.keys(fieldDefs).forEach((key) => { let { position, numeric, justify } = fieldDefs[key]; let [start, end] = position; let val = data[key]?.toString() ?? ""; let length = end - start + 1; let justifySide = justify ?? (numeric ? "right" : "left"); let padChar = numeric ? "0" : " "; val = justifySide === "right" ? val.padStart(length, padChar) : val.padEnd(length, padChar); charArray.splice(start - 1, end, ...val.split("")); }); return charArray.join(""); } // src/utils/isBlank.ts function isBlank(str) { for (const char of str) { if (char !== " " && char !== " " && char !== "\r" && char !== "\n") return false; } return true; } // src/utils/parse/parseLine.ts function parseLine(recordTypeCode, raw, sec) { if (raw.length !== 94) throw new Error("line must be 94 chars long"); const fieldDefs = recordTypeCode === 6 ? getEntryFieldDef(sec) : getNonEntryFieldDef(recordTypeCode); const record = { recordTypeCode }; for (const key in fieldDefs) { const def = fieldDefs[key]; const [start, end] = def.position; let valStr = raw.slice(start - 1, end); if (def.optional && isBlank(valStr)) continue; if (def.trim !== false) { valStr = valStr.trim(); } let value = valStr; if (def.numeric) { const num = Number(valStr); if (!Number.isFinite(num)) { throw new Error(`${String(key)} is not a valid number`); } value = num; } if (def.mustEqual !== void 0 && def.mustEqual !== value) { throw new Error( `${String(key)} must equal ${def.mustEqual}. Found "${value}"` ); } if (def.allow && def.allow.length > 0) { if (!def.allow.includes(value)) { const allowed = def.allow.join(", "); throw new Error( `${String(key)} must be one of: ${allowed}. Found "${value}"` ); } } record[key] = value; } if (recordTypeCode === 6) record.type = sec; return record; } // src/utils/parse/parseNacha.ts var RECORD_LEN = 94; var PADDING = "9".repeat(RECORD_LEN); function parseNacha(raw) { const lines = raw.indexOf("\r") >= 0 ? raw.split("\r\n") : raw.split("\n"); while (lines.length && isBlank(lines[lines.length - 1])) lines.pop(); let fileHeader2; let fileFooter2; let filePadding = 0; let currentBatchHeader; let currentEntries = []; const batches = []; for (let idx = 0; idx < lines.length; idx++) { const line = lines[idx]; const lineNum = idx + 1; let isNines = /^9+$/; if (line.length === RECORD_LEN && isNines.test(line)) { if (line === PADDING) { if (!fileHeader2 || !fileFooter2) { throw new Error("File Padding found before end of file"); } filePadding++; continue; } } if (line.length === 0) continue; const recordTypeCode = Number(line[0]); switch (recordTypeCode) { // File Header case 1: { if (fileHeader2) throw new Error("More than one File Header found in file"); fileHeader2 = parseLine(1, line); break; } // 5 Batch Header case 5: { if (currentBatchHeader) { const batchNum = currentBatchHeader.batchNumber; throw new Error(`Batch #${batchNum} missing footer`); } currentBatchHeader = parseLine(5, line); break; } // Entry Detail case 6: { if (!currentBatchHeader) { throw new Error(`Entry found outside of batch at ${lineNum}`); } const sec = currentBatchHeader.standardEntryClass; const entry = parseLine(6, line, sec); currentEntries.push({ entry, addenda: [] }); break; } // Addenda case 7: { if (!currentBatchHeader) { throw new Error(`Addenda found outside of batch at ${lineNum}`); } const current = currentEntries[currentEntries.length - 1]; if (!current) throw new Error("Addenda found before entry"); current.addenda.push(parseLine(7, line)); break; } // Batch Control/Footer case 8: { if (!currentBatchHeader) { throw new Error(`Batch footer without corresponding header found at ${lineNum}`); } const footer = parseLine(8, line); batches.push({ header: currentBatchHeader, entries: currentEntries, footer }); currentBatchHeader = void 0; currentEntries = []; break; } // File Footer (non-padding) case 9: { if (!fileHeader2) { throw new Error(`File footer found before file header at ${lineNum}`); } fileFooter2 = parseLine(9, line); break; } default: { const recordTypeCode2 = line[0]; throw new Error( `Line #:${lineNum} has an invalid record type code of ${recordTypeCode2}` ); } } } if (!fileHeader2) throw new Error("No file header found"); if (!fileFooter2) throw new Error("No file footer found"); return { header: fileHeader2, batches, footer: fileFooter2, padding: filePadding }; } // src/utils/tranCodeHelpers.ts function getTranCodeDetails(tranCode) { const { entryTypeCode, accountTypeCode } = splitTranCode(tranCode); return { accountType: AccountCodeMap[accountTypeCode], // Credit codes are 2/3/4; debit are 7/8/9 direction: entryTypeCode < 5 ? "credit" : "debit", purpose: getPurpose(entryTypeCode) }; } function splitTranCode(tranCode) { const entryTypeCode = tranCode % 10; const accountTypeCode = Math.floor(tranCode / 10); return { entryTypeCode, accountTypeCode }; } function getPurpose(entryTypeCode) { switch (entryTypeCode) { case 3: case 8: return "prenote"; case 4: case 9: return "remittance"; default: return "live"; } } // src/api/Entry/BaseEntry.ts var BaseEntryWrapper = class { _batch; /** SEC for this entry */ type; /** Two digit code identifying the account type & purpose at the * receiving financial institution */ transactionCode; accountNumber; routingNumber; /** amount of the transaction in dollars */ amount; /** determines the purpose of the entry. Non-live transactions should * have an amount of 0. */ // public purpose: Purpose; /** Receiver's identification number. This number may * be printed on the receiver's bank statement by the * Receiving Financial Institution */ idNumber; /** Name of receiver. */ name; /** This number will be unique to the transaction and will help * identify the transaction in case of an inquiry */ traceNumber; constructor(batch, opts) { this._batch = batch; if ("entry" in opts) { this.type = opts.entry.type; this.transactionCode = opts.entry.transactionCode; this.accountNumber = opts.entry.accountNumber; this.routingNumber = opts.entry.routingNumber; this.amount = opts.entry.amount; this.idNumber = opts.entry.idNumber; this.name = opts.entry.name; this.traceNumber = opts.entry.traceNumber; return; } let tranCode; if (opts.accountType && opts.direction) { tranCode = this.getTranCode( opts.accountType, opts.direction, opts.purpose ?? "live" ); } else { tranCode = opts.transactionCode; } this.transactionCode = tranCode; this.type = batch.sec; this.accountNumber = opts.accountNumber; this.routingNumber = opts.routingNumber; this.amount = opts.amount; this.idNumber = opts.idNumber; this.name = opts.name; this.traceNumber = opts.traceNumber ?? this.generateTraceNum(); } // -------------- // Helper methods // -------------- getBaseEntry() { return { recordTypeCode: 6, transactionCode: this.transactionCode, routingNumber: this.routingNumber, accountNumber: this.accountNumber, amount: Math.round(this.amount * 100), name: this.name, idNumber: this.idNumber, addendaRecordIndicator: this.getAddendaCount() > 0 ? 1 : 0, traceNumber: this.traceNumber }; } toAddenda(addenda2, addendaNumber = 1) { const { paymentRelatedInfo, typeCode } = typeof addenda2 === "string" ? { paymentRelatedInfo: addenda2, typeCode: "05" } : addenda2; return { recordTypeCode: 7, addendaTypeCode: typeCode, paymentRelatedInfo, addendaSequenceNum: addendaNumber, entryDetailSequenceNum: this.traceNumber.slice(-7) }; } toAddendaArray(addenda2) { return addenda2?.map((a, i) => this.toAddenda(a, i + 1)) ?? []; } getTranCode(accountType, direction, purpose) { const accountCode = typeof accountType === "number" ? accountType : AccountTypeMap[accountType]; let entryTypeCode; const isCredit = direction === "credit"; switch (purpose) { case "live": entryTypeCode = isCredit ? 2 : 7; break; case "prenote": entryTypeCode = isCredit ? 3 : 8; break; case "remittance": entryTypeCode = isCredit ? 4 : 9; break; default: entryTypeCode = 2; } return accountCode * 10 + entryTypeCode; } generateTraceNum() { const originDFI = this._batch.origin.slice(0, 7); const seq = this._batch.entries.length + 1; return originDFI + String(seq).padStart(7, "0"); } // ----------------- // Common accessors // ----------------- /** Credit/Debit */ get direction() { let { direction } = getTranCodeDetails(this.transactionCode); return direction; } get purpose() { let { purpose } = getTranCodeDetails(this.transactionCode); return purpose; } get accountType() { let { accountType } = getTranCodeDetails(this.transactionCode); return accountType; } /** Signed amount in dollars. Credit are positives & Debits are negative. */ get amountSigned() { return this.direction === "credit" ? this.amount : -this.amount; } get cents() { return Math.round(this.amount * 100); } set cents(val) { this.amount = val / 100; } get centsSigned() { return this.direction === "credit" ? this.cents : -this.cents; } }; // src/api/Entry/index.ts var hasEntry = (opts) => typeof opts === "object" && !!opts && "entry" in opts; function ensureArray(val) { if (val == null) return []; return Array.isArray(val) ? val : [val]; } function rawSingle(wrapper, type, extra, addenda2) { const base = wrapper.getBaseEntry(); const addendaArr = ensureArray(addenda2); return { entry: { ...base, type, ...extra }, addenda: addendaArr }; } var CCDPPDEntryWrapper = class extends BaseEntryWrapper { discretionaryData; addenda; constructor(batch, opts) { super(batch, opts); if (hasEntry(opts)) { this.discretionaryData = opts.entry.discretionaryData; this.addenda = opts.addenda?.[0]; } else { this.discretionaryData = opts.discretionaryData; if (opts.addenda) this.addenda = this.toAddenda(opts.addenda); } } getAddendaCount() { return this.addenda ? 1 : 0; } toJSON() { return rawSingle( this, this._batch.sec, { discretionaryData: this.discretionaryData }, this.addenda ); } }; var CCDEntryWrapper = class extends CCDPPDEntryWrapper { }; var PPDEntryWrapper = class extends CCDPPDEntryWrapper { }; var WEBEntryWrapper = class extends BaseEntryWrapper { paymentTypeCode; addenda; constructor(batch, opts) { super(batch, opts); if (hasEntry(opts)) { this.paymentTypeCode = opts.entry.paymentTypeCode; this.addenda = opts.addenda?.[0]; } else { this.paymentTypeCode = opts.paymentTypeCode; if (opts.addenda) this.addenda = this.toAddenda(opts.addenda); } } getAddendaCount() { return this.addenda ? 1 : 0; } toJSON() { return rawSingle( this, "WEB", { paymentTypeCode: this.paymentTypeCode }, this.addenda ); } }; var TELEntryWrapper = class extends BaseEntryWrapper { discretionaryData; constructor(batch, opts) { super(batch, opts); this.discretionaryData = hasEntry(opts) ? opts.entry.discretionaryData : opts.discretionaryData; } getAddendaCount() { return 0; } toJSON() { return rawSingle(this, "TEL", { discretionaryData: this.discretionaryData }); } }; var CTXEntryWrapper = class extends BaseEntryWrapper { discretionaryData; addenda; constructor(batch, opts) { super(batch, opts); if (hasEntry(opts)) { this.discretionaryData = opts.entry.discretionaryData; this.addenda = opts.addenda; } else { this.discretionaryData = opts.discretionaryData; this.addenda = this.toAddendaArray(opts.addenda); } } getAddendaCount() { return this.addenda?.length ?? 0; } get numberOfAddendas() { return this.getAddendaCount(); } toJSON() { const base = this.getBaseEntry(); return { entry: { ...base, type: "CTX", numberOfAddendas: this.numberOfAddendas, reserved: " ", discretionaryData: this.discretionaryData }, addenda: this.addenda ?? [] }; } }; var EntryClassMap = { PPD: PPDEntryWrapper, CCD: CCDEntryWrapper, WEB: WEBEntryWrapper, TEL: TELEntryWrapper, CTX: CTXEntryWrapper }; function makeEntry(sec, batch, entryDetails) { const EntryClass = EntryClassMap[sec]; return new EntryClass(batch, entryDetails); } // src/api/BatchWrapper.ts var BatchWrapper = class { constructor(_parentFile, sec, opts) { this._parentFile = _parentFile; this.sec = sec; if ("header" in opts && "footer" in opts && "entries" in opts) { this.company = { id: opts.header.companyId, name: opts.header.companyName, discretionaryData: opts.header.companyDiscretionaryData, descriptiveDate: opts.header.companyEntryDescriptiveDate }; this.effectiveDate = parseYYMMDD(opts.header.effectiveDate); this.entryDescription = opts.header.companyEntryDescription; this.origin = opts.header.originatingDFIIdentification; this.messageAuthCode = opts.footer.messageAuthCode; this.entries = opts.entries.map((e) => makeEntry(sec, this, e)); return; } this.effectiveDate = opts.effectiveDate ?? this._parentFile.creationDate; this.company = opts.company; this.entryDescription = opts.entryDescription; this.origin = opts.origin ?? this._parentFile.origin.routing; this.messageAuthCode = opts.messageAuthCode; this.entries = []; } sec; company; effectiveDate; entryDescription; originatorStatusCode = 1; origin; messageAuthCode; entries; // ---------- Builder-ish API when constructing new ---------- addEntry(opts) { let entryOpts = Array.isArray(opts) ? opts : [opts]; let entries = entryOpts.map((e) => makeEntry(this.sec, this, e)); this.entries.push(...entries); return this; } credit(opts) { return this.addEntry({ direction: "credit", ...opts }); } debit(opts) { return this.addEntry({ direction: "debit", ...opts }); } /** Returns parent file */ done() { return this._parentFile; } /** Alias of {@link done} */ end = this.done; // Service Class Code (non-mutable) get serviceClassCode() { if (this.entries.length === 0) return 200; const hasDebits = this.entries.some((e) => e.direction === "debit"); const hasCredits = this.entries.some((e) => e.direction === "credit"); if (hasCredits && hasDebits) return 200; if (hasCredits) return 220; if (hasDebits) return 225; return 200; } get standardEntryClass() { return this.sec; } get batchNumber() { return this._parentFile.batches.indexOf(this) + 1; } get entryAndAddendaCount() { return this.entries.reduce( (acc, entry) => acc + entry.getAddendaCount() + 1, 0 // Default ); } get entryHash() { let entryDFIs = this.entries.map((e) => e.routingNumber).map((routing) => Number(routing.slice(0, 8))); return sumArray(entryDFIs) % (1e9 * 10); } get totalDebits() { let debits = this.entries.filter((e) => e.direction === "debit").map((e) => e.cents); return sumArray(debits); } get totalCredits() { let credits = this.entries.filter((e) => e.direction === "credit").map((e) => e.cents); return sumArray(credits); } toJSON() { let { id, name: name2, discretionaryData: discretionaryData2, descriptiveDate } = this.company; return { header: { recordTypeCode: 5, serviceClassCode: this.serviceClassCode, companyName: name2, companyDiscretionaryData: discretionaryData2, companyId: id, standardEntryClass: this.sec, companyEntryDescription: this.entryDescription, companyEntryDescriptiveDate: typeof descriptiveDate === "string" ? descriptiveDate : toYYMMDD(descriptiveDate), effectiveDate: toYYMMDD(this.effectiveDate), settlementDate: " ".repeat(3), originatorStatusCode: 1, originatingDFIIdentification: this.origin, batchNumber: this.batchNumber }, entries: this.entries.map((entry) => entry.toJSON()), footer: { recordTypeCode: 8, serviceClassCode: this.serviceClassCode, entryAndAddendaCount: this.entryAndAddendaCount, entryHash: this.entryHash, totalDebits: this.totalDebits, totalCredits: this.totalCredits, companyId: id, messageAuthCode: this.messageAuthCode, reserved: " ".repeat(6), originatingDFIIdentification: this.origin, batchNumber: this.batchNumber } }; } }; // src/api/Nacha.ts var Nacha = class _Nacha { /** The lower the number, the higher processing priority. */ priorityCode = 1; /** RDFI Details */ destination; /** ODFI Details */ origin; /** Date file was created */ creationDate = /* @__PURE__ */ new Date(); /** Code to distinguish among multiple input files. Label the first * (or only) file "A", and continue in sequence (A-Z). If more than * one file is delivered, they must have different modifiers. */ fileIdModifier = "A"; /** Number of bytes per record. Should only be "094". */ recordSize = 94; /** How many blocks the file is made up of. Should always be 10 */ blockingFactor = 10; /** Currently there is only one code. Enter 1. */ formatCode = 1; /** Optional field you may use to describe input * file for internal accounting purposes. */ referenceCode; batches = []; constructor(opts) { if ("header" in opts && "footer" in opts && "batches" in opts) { this.batches = opts.batches.map( (batch) => new BatchWrapper( this, batch.header.standardEntryClass, batch ) ); this.priorityCode = opts.header.priorityCode; this.destination = { name: opts.header.destinationName, routing: opts.header.destination }; this.origin = { name: opts.header.originName, routing: opts.header.origin }; this.creationDate = parseYYMMDD( opts.header.fileCreationDate, opts.header.fileCreationTime ); this.fileIdModifier = opts.header.fileIdModifier; this.referenceCode = opts.header.referenceCode; return; } let { priorityCode, destination, origin, creationDate, fileIdModifier, referenceCode } = opts; if (priorityCode) this.priorityCode = priorityCode; this.destination = destination; this.origin = origin; if (creationDate) this.creationDate = creationDate; if (fileIdModifier) this.fileIdModifier = fileIdModifier; this.referenceCode = referenceCode; } addBatch(sec, opts, entries) { const batch = new BatchWrapper(this, sec, opts); this.batches.push(batch); if (entries) { batch.addEntry(entries); return this; } return batch; } static { const define = (ctor, sec) => { const key = sec.toLowerCase(); Object.defineProperty(ctor.prototype, key, { value(opts, entries) { return entries !== void 0 ? this.addBatch(sec, opts, entries) : this.addBatch(sec, opts); }, configurable: true, writable: true }); }; SEC_CODES.forEach((sec) => define(this, sec)); } /** The total number of batch header records in the file. */ get batchCount() { return this.batches.length; } /** * The total number of physical blocks on the file, * including the File Header and File Control records. */ get blockCount() { return Math.ceil(this.totalRecordCount / this.blockingFactor); } get totalRecordCount() { return 2 + // File Header & Footer this.entryAndAddendaCount + this.batchCount * 2; } /** Total number of entry detail and addenda records on the file. */ get entryAndAddendaCount() { let entryAndAddendaCounts = this.batches.map((b) => b.entryAndAddendaCount); return sumArray(entryAndAddendaCounts); } /** * Total of all entries' DFI(Routing # excluding final check digit). * Only use the final 10 positions in the entry. */ get entryHash() { let entryDFIs = this.batches.flatMap((b) => b.entries.map((e) => e.routingNumber)).map((routing) => Number(routing.slice(0, 8))); return sumArray(entryDFIs) % (1e8 * 10); } /** Dollar totals of debit entries within the file. */ get totalDebits() { let allDebits = this.batches.map((b) => b.totalDebits); return sumArray(allDebits); } /** Dollar totals of credit entries within the file. */ get totalCredits() { let allCredits = this.batches.map((b) => b.totalCredits); return sumArray(allCredits); } /** * Amount of file padding the file will have. * File padding is a line of 94 '9's. */ get padding() { return 10 - this.totalRecordCount % 10; } /** @returns A NACHA formatted file */ toString() { return formatNacha(this.toJSON()); } /** @returns a JSON representation of the file */ toJSON() { return { header: { recordTypeCode: 1, priorityCode: this.priorityCode, destination: this.destination.routing, origin: this.origin.routing, fileCreationDate: toYYMMDD(this.creationDate), fileCreationTime: toHHMM(this.creationDate), fileIdModifier: this.fileIdModifier, recordSize: this.recordSize, blockingFactor: this.blockingFactor, formatCode: this.formatCode, destinationName: this.destination.name, originName: this.origin.name, referenceCode: this.referenceCode }, batches: this.batches.map((b) => b.toJSON()), footer: { recordTypeCode: 9, batchCount: this.batchCount, blockCount: this.blockCount, entryAndAddendaCount: this.entryAndAddendaCount, entryHash: this.entryHash, totalDebits: this.totalDebits, totalCredits: this.totalCredits, reserved: " ".repeat(39) }, padding: this.padding }; } /** Factory: parses NACHA text and return an initialized Nacha file */ static fromNacha(raw) { let data = parseNacha(raw); return new _Nacha(data); } /** * Accepts a NACHA file in the form of a string & returns an object * representation of the file. Any issues preventing the file from being * parsed will cause the function to throw. */ static parse = parseNacha; /** Takes a NACHA object and returns NACHA formatted file as a string */ static format = formatNacha; }; // src/index.ts var index_default = Nacha; // Annotate the CommonJS export names for ESM import in node: 0 && (module.exports = { SEC_CODES, TRANSACTION_CODES, format, parse }); //# sourceMappingURL=index.cjs.map