如何用 node.js 自动处理 NACHA 批量付款?
结论
ACH 批量扣款可以自动化,但 NACHA 文件不能提交到一个通用的“银行 API”。
在提交文件前,你需要与银行或支付服务商签订 ACH Origination 协议。对方会分配 Company ID、ODFI Routing Number,并提供文件提交渠道和具体的格式要求。常见的提交方式有:
- 银行提供的 SFTP/Host-to-Host 通道
- 银行专用 API
- 支付服务商的 ACH API
- 企业网银人工上传,部分银行不允许自动提交
从客户账户扣款到你的账户属于 ACH Debit,并不是普通的“付款”。拿到客户的银行账号不代表可以直接发起扣款。你还必须取得符合 NACHA 规则的授权,并根据交易场景选择正确的 SEC Code,例如 WEB、PPD 或 CCD。
推荐的实现方式
如果银行没有明确要求上传 NACHA 文件,建议优先使用支付服务商的 ACH API。服务商通常会处理账户验证、授权记录、退票、NOC、重复提交控制和敏感数据存储。Node.js 系统只需保存服务商返回的 token 或 payment method ID。
如果银行要求直接提交 NACHA 文件,一般需要完成以下流程:
- 取得客户的 ACH Debit 授权。
- 验证银行账户,或使用银行提供的账户验证服务。
- 加密保存银行信息,并严格限制访问权限。
- 生成付款批次和唯一交易编号。
- 按银行要求生成 NACHA 文件。
- 校验记录长度、哈希值、控制金额和批次总数。
- 通过 SFTP 或银行 API 提交。
- 保存提交回执,防止重复发送。
- 处理 ACH Return、NOC 和结算状态。
- 按授权要求处理撤销、停止扣款及客户争议。
数据模型
不要把银行账号作为普通字符串分散存放在业务表中。数据模型至少要区分客户、授权、银行账户和付款指令:
const payment = {
paymentId: "pay_01J...",
customerId: "cus_01J...",
amountCents: 12500,
effectiveEntryDate: "260902",
secCode: "WEB",
status: "pending",
idempotencyKey: "invoice-2026-10042",
bankAccountToken: "vault_account_...",
authorizationId: "auth_01J..."
};
数据库应为 idempotencyKey 建立唯一约束,避免任务重试时再次扣款。
金额应始终使用整数美分,不要使用浮点数:
const amountCents = 12500; // $125.00
生成 NACHA 文件
NACHA 文件由固定宽度记录组成,每行必须是 94 个字符。文件通常包括:
- File Header Record
- Batch Header Record
- Entry Detail Record
- Addenda Record(可选或按 SEC Code 要求)
- Batch Control Record
- File Control Record
9填充记录,使总记录数满足区块要求
不能只是拼出一份看起来相似的文本就直接发送。Routing Number 校验位、Entry Hash、Service Class Code、Trace Number、借贷方向,以及 balanced/unbalanced file 的要求都必须正确。
下面是一个流程示例。buildNachaFile 应当是经过验证的内部模块或 NACHA 库,其中的字段必须按照开户银行提供的实施规范进行映射:
import { randomUUID } from "node:crypto";
async function createAchBatch({ payments, bankProfile, accountVault }) {
const entries = [];
for (const payment of payments) {
const account = await accountVault.get(payment.bankAccountToken);
entries.push({
transactionId: payment.paymentId,
amountCents: payment.amountCents,
routingNumber: account.routingNumber,
accountNumber: account.accountNumber,
accountType: account.accountType,
receiverName: account.accountHolderName,
secCode: payment.secCode,
traceSuffix: payment.traceSuffix
});
}
const fileId = randomUUID();
const contents = buildNachaFile({
fileId,
immediateDestination: bankProfile.immediateDestination,
immediateOrigin: bankProfile.immediateOrigin,
companyName: bankProfile.companyName,
companyId: bankProfile.companyId,
odfiRoutingNumber: bankProfile.odfiRoutingNumber,
destinationName: bankProfile.destinationName,
originName: bankProfile.originName,
effectiveEntryDate: payments[0].effectiveEntryDate,
entries
});
validateNachaFile(contents);
return {
fileId,
fileName: `ACH_${fileId}.ach`,
contents
};
}
buildNachaFile 和 validateNachaFile 不能依靠猜测来实现。不同银行可能对 File ID Modifier、文件名、换行符、Service Class Code、offset entry 或加密方式有不同要求。正式接入前,需要使用银行的测试环境和测试文件完成认证。
通过 SFTP 自动提交
如果银行提供 SFTP 通道,可以使用 ssh2-sftp-client 上传文件。下面只是一个通用的传输示例,实际目录、认证方式和命名规则必须以银行说明为准:
npm install ssh2-sftp-client
import SftpClient from "ssh2-sftp-client";
export async function uploadNachaFile({ fileName, contents }) {
const sftp = new SftpClient();
const privateKey = process.env.BANK_SFTP_PRIVATE_KEY?.replace(
/\\n/g,
"\n"
);
if (!privateKey) {
throw new Error("BANK_SFTP_PRIVATE_KEY is not configured");
}
const incomingDir = process.env.BANK_SFTP_INCOMING_DIR;
if (!incomingDir) {
throw new Error("BANK_SFTP_INCOMING_DIR is not configured");
}
const temporaryPath = `${incomingDir}/${fileName}.uploading`;
const finalPath = `${incomingDir}/${fileName}`;
try {
await sftp.connect({
host: process.env.BANK_SFTP_HOST,
port: Number(process.env.BANK_SFTP_PORT ?? 22),
username: process.env.BANK_SFTP_USERNAME,
privateKey,
passphrase: process.env.BANK_SFTP_KEY_PASSPHRASE
});
const payload = Buffer.from(contents, "ascii");
await sftp.put(payload, temporaryPath);
await sftp.rename(temporaryPath, finalPath);
return { remotePath: finalPath };
} finally {
await sftp.end().catch(() => {});
}
}
先上传临时文件,完成后再重命名,可以减少银行在文件尚未传完时开始处理的风险。但这种方式只能在银行明确支持时使用。部分 SFTP 接口不允许重命名,也有些接口会要求同时上传 manifest 或 PGP 签名文件。
调度和防重复提交
文件生成和提交应拆分成有状态的任务,不能只用定时器生成文件后直接上传:
async function submitBatch(batchId, repository, bankGateway) {
const batch = await repository.lockBatch(batchId);
if (batch.status === "submitted" || batch.status === "accepted") {
return batch;
}
if (batch.status !== "ready") {
throw new Error(`Batch ${batchId} is not ready`);
}
const file = await createAchBatch(batch);
await repository.markUploading(batchId, {
fileId: file.fileId,
fileName: file.fileName
});
try {
const result = await bankGateway.upload(file);
await repository.markSubmitted(batchId, {
remotePath: result.remotePath,
submittedAt: new Date().toISOString()
});
} catch (error) {
await repository.markUploadFailed(batchId, {
message: error instanceof Error ? error.message : "Unknown error"
});
throw error;
}
}
提交超时时需要格外谨慎。超时不代表银行没有收到文件。自动重试之前,应通过远程目录、银行回执或文件状态 API 确认提交结果,否则可能导致重复扣款。
必须注意的合规和安全问题
客户授权
系统需要根据交易场景保存授权文本、授权时间、授权渠道、账户主体和撤销记录。消费者网上授权通常涉及 WEB,消费者预授权通常可能使用 PPD,企业账户交易通常使用 CCD。最终使用哪一种 SEC Code,应由银行或合规人员确认。
账户验证
首次对消费者网上账户发起扣款时,可能需要采取符合适用 NACHA 规则的账户验证措施。具体采用哪种方式、由哪一方负责,需要向 ODFI 或支付服务商确认。
敏感信息保护
银行账号和 Routing Number 都是高敏感金融数据,应存放在专门的数据保险库中,或采用字段级加密。还需要做到:
- 密钥与数据库分开保存
- 在日志中屏蔽账号
- 只允许必要的服务解密
- 定期轮换密钥和 SFTP 凭据
- 记录读取、解密和提交操作的审计日志
- 不在错误信息或监控事件中输出完整文件
处理银行账户不一定自动属于 PCI DSS 场景,但仍可能受到 NACHA 规则、银行合同、隐私法规和当地数据安全要求的约束。
退票和更正通知
文件上传成功不等于付款已经完成。系统还需要处理:
- ACH Return
- NOC(Notification of Change)
- 拒绝或格式错误回执
- 账户余额不足
- 未授权扣款
- 结算完成与资金可用状态
收到最终结果之前,付款状态应为 submitted 或 processing,不能立即标记为 paid。
实际选型建议
如果交易量不大,团队也没有 ACH 合规经验,使用支付服务商的 API 通常更合适。系统保存 token,通过 API 创建扣款,再使用 webhook 更新状态,无需自行生成或传输 NACHA 文件。
只有当银行已经提供 ACH Origination 账户、文件规范、测试环境和自动传输通道时,才适合直接生成 NACHA 文件。Node.js 可以负责文件生成、SFTP/API 提交和状态处理,但具体如何实现,最终取决于银行的接入规范,而不只是 NACHA 格式本身。
备注:内容仅供参考。
