PAYATHON 2026

如何用 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 文件,一般需要完成以下流程:

  1. 取得客户的 ACH Debit 授权。
  2. 验证银行账户,或使用银行提供的账户验证服务。
  3. 加密保存银行信息,并严格限制访问权限。
  4. 生成付款批次和唯一交易编号。
  5. 按银行要求生成 NACHA 文件。
  6. 校验记录长度、哈希值、控制金额和批次总数。
  7. 通过 SFTP 或银行 API 提交。
  8. 保存提交回执,防止重复发送。
  9. 处理 ACH Return、NOC 和结算状态。
  10. 按授权要求处理撤销、停止扣款及客户争议。

数据模型

不要把银行账号作为普通字符串分散存放在业务表中。数据模型至少要区分客户、授权、银行账户和付款指令:

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 格式本身。

备注:内容仅供参考。