PAYATHON 2026

市政公用事业 recurring payment backend system 选型

选型许工

结论

这类系统应采用“ACH Direct Debit + 可变金额账单”的设计。普通的固定金额订阅不适用于每月金额变化的公用事业账单。

可以优先评估以下方案:

  1. ACH 专用平台,如 Dwolla。其 API 通常涵盖银行账户验证、授权、扣款和 webhook,适合自行开发完整的账单界面。
  2. 综合支付平台提供的 ACH Direct Debit,如 Stripe。适合需要快速接入托管式银行账户采集、tokenization、付款状态和 webhook 的团队。
  3. 银行提供的 ACH Origination / Treasury Management。如果村镇已有合作银行,这种方案可能更符合政府采购和资金管理要求,不过 API 的易用性往往不如支付平台。
  4. 市政公用事业收费平台。如果系统还要处理纸质账单、柜台支付、催缴、总账对账和居民门户,可以考虑专门的 municipal utility billing vendor。此类产品通常成本较高,但可以缩小自建业务系统的范围。
  5. GoCardless、Adyen 等其他 Direct Debit 服务。它们是否支持美国 ACH、市政机构及目标地区,需要直接向厂商确认,不能只看公开资料中的通用支付能力。

如果目标是控制成本、自建 frontend,并且只接受 checking account,可以先请 Stripe 和 Dwolla 分别针对市政机构进行准入评估和报价,同时向合作银行咨询 ACH Origination。最终选择要综合考虑公共机构准入、ACH 退票处理、账户验证、资金结算和对账能力,单笔手续费只是其中一项。

为什么普通 subscription 不适用

公用事业账单的金额每月都会变化。系统需要保存居民对银行账户扣款的授权,再按每期账单金额发起扣款。

业务流程可以这样设计:

居民绑定 checking account
        ↓
支付服务商完成账户验证并返回 token
        ↓
系统保存授权记录和 payment method token
        ↓
每月抄表并生成正式账单
        ↓
提前发送扣款通知
        ↓
按账单金额发起一次 ACH debit
        ↓
通过 webhook 接收 processing / succeeded / returned 等状态
        ↓
更新账单、付款历史和财务对账记录

ACH 付款通常无法实时确认最终结果。扣款请求提交后,银行仍可能因余额不足、账户关闭或授权争议等原因退回款项。因此,API 请求成功时,用户界面应先显示“处理中”,收到最终状态后再更新为相应结果。

选型时需要确认的能力

1. 市政机构能否开户

部分平台虽然支持普通企业,却会对政府机构、公用事业收费或特定业务模式进行额外审核。正式开发前,应让厂商书面确认以下事项:

  • 是否接受 municipality 或 village utility 作为商户;
  • 资金由谁代收,结算到哪个政府账户;
  • 是否需要额外的 KYC、KYB 或政府采购材料;
  • 能否使用市政机构自己的 merchant account 或银行账户;
  • 是否支持测试环境与生产环境分离。

2. 银行账户的采集和验证方式

routing number 和 account number 不应经过自己的服务器,也不应保存到业务数据库。优先使用服务商提供的 hosted form、embedded component 或银行登录验证流程,由服务商返回 payment method token。

还需确认平台是否支持:

  • instant account verification;
  • micro-deposit verification;
  • checking account 类型限制;
  • 账户持有人姓名采集;
  • account validation;
  • 无法即时验证时的备用流程。

ACH 的安全及合规要求与信用卡 PCI DSS 不同,但银行账户数据同样属于敏感金融数据,需要加密、限制访问并保留审计记录。

3. 可变金额 recurring debit

需要向厂商说明,系统会在居民授权后发起 recurring ACH debit,每期金额根据实际用量确定。服务商应支持:

  • 保存长期有效的 mandate 或 authorization;
  • 每期提交不同金额;
  • 幂等请求;
  • 关联账单号与支付请求;
  • 扣款前通知;
  • 用户撤销自动扣款;
  • 银行账户变更后重新验证并授权。

ACH authorization 的展示内容、保存期限、通知时间及 SEC code 选择,可能受到 Nacha Rules、银行要求和所在地法规的约束。相关规则会更新,应以支付服务商、合作银行和法律顾问提供的当前要求为准。

4. 退票、争议和重试

ACH debit 返回“提交成功”,只表示处理请求已被受理。系统还需要处理以下情况:

  • insufficient funds;
  • account closed;
  • invalid account;
  • authorization revoked;
  • unauthorized debit;
  • administrative return;
  • reversal;
  • settlement 后发生的 return。

系统不应无限自动重试。部分 return code 不适合再次扣款,另一些情况只允许在限定条件下重试。具体规则应根据处理商提供的 return code 分类和当前 Nacha Rules 制定。

5. 对账和财务导出

市政收费系统通常需要与总账、银行流水和应收账款核对。服务商至少应提供:

  • payment ID;
  • settlement ID;
  • gross amount、fee 和 net amount;
  • return fee;
  • settlement date;
  • CSV 或 API 对账接口;
  • webhook 重放或事件查询;
  • 日终结算报告。

如果平台只能发起扣款,却无法说明某笔银行入账对应哪些居民账单,后续会产生较高的人工对账成本。

系统边界

支付平台负责银行账户验证、授权、ACH debit、结算和支付事件。自己的应用负责以下业务:

  • 居民账户;
  • 水、电或其他公用事业用量;
  • 费率计算;
  • 月度账单;
  • 扣款计划;
  • 付款历史展示;
  • 通知;
  • 对账状态;
  • 审计日志。

数据库可以保存支付平台 token,但不应保存完整银行账号。需要向用户展示账户时,只保存服务商返回的掩码信息,例如 ****6789。

简化后的数据模型如下:

CREATE TABLE payment_mandates (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    provider VARCHAR(32) NOT NULL,
    provider_customer_id VARCHAR(128) NOT NULL,
    provider_payment_method_id VARCHAR(128) NOT NULL,
    bank_account_last4 CHAR(4),
    authorization_status VARCHAR(32) NOT NULL,
    authorized_at TIMESTAMP,
    revoked_at TIMESTAMP,
    created_at TIMESTAMP NOT NULL
);

CREATE TABLE utility_invoices (
    id UUID PRIMARY KEY,
    customer_id UUID NOT NULL,
    billing_period VARCHAR(7) NOT NULL,
    amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
    status VARCHAR(32) NOT NULL,
    due_at TIMESTAMP NOT NULL,
    UNIQUE (customer_id, billing_period)
);

CREATE TABLE payment_attempts (
    id UUID PRIMARY KEY,
    invoice_id UUID NOT NULL,
    mandate_id UUID NOT NULL,
    provider_payment_id VARCHAR(128),
    idempotency_key VARCHAR(128) NOT NULL UNIQUE,
    amount_cents BIGINT NOT NULL,
    status VARCHAR(32) NOT NULL,
    return_code VARCHAR(32),
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

金额应以最小货币单位的整数保存,例如 amount_cents。不要使用浮点数计算账单金额。

支持更换支付平台的代码结构

可以先定义内部接口,把各服务商的具体实现放在 adapter 中。以后从 Stripe、Dwolla 或银行 API 迁移时,账单逻辑无需重写。

type DebitRequest = {
  customerToken: string;
  paymentMethodToken: string;
  amountCents: number;
  currency: "USD";
  invoiceId: string;
  idempotencyKey: string;
};

type DebitResult = {
  providerPaymentId: string;
  status: "processing" | "succeeded" | "failed";
};

interface AchProvider {
  createDebit(request: DebitRequest): Promise<DebitResult>;
  getDebit(providerPaymentId: string): Promise<DebitResult>;
}

每张账单只生成一个稳定的幂等键:

import { createHash } from "node:crypto";

function paymentIdempotencyKey(invoiceId: string): string {
  return createHash("sha256")
    .update(`utility-ach:${invoiceId}`)
    .digest("hex");
}

async function collectInvoice(
  invoice: {
    id: string;
    amountCents: number;
  },
  mandate: {
    customerToken: string;
    paymentMethodToken: string;
    status: string;
  },
  provider: AchProvider,
) {
  if (mandate.status !== "active") {
    throw new Error("ACH authorization is not active");
  }

  if (!Number.isSafeInteger(invoice.amountCents) || invoice.amountCents <= 0) {
    throw new Error("Invalid invoice amount");
  }

  return provider.createDebit({
    customerToken: mandate.customerToken,
    paymentMethodToken: mandate.paymentMethodToken,
    amountCents: invoice.amountCents,
    currency: "USD",
    invoiceId: invoice.id,
    idempotencyKey: paymentIdempotencyKey(invoice.id),
  });
}

每次重试都随机生成幂等键,会导致网络超时后的再次提交被视为新请求,从而产生重复扣款。因此,同一张账单必须始终使用同一个幂等键。

webhook 处理示例

签名算法、header 名称和事件结构由服务商决定。下面的代码只展示内部处理方式,不代表任何厂商的真实 API 参数。

type PaymentEvent = {
  eventId: string;
  eventType:
    | "payment.processing"
    | "payment.succeeded"
    | "payment.failed"
    | "payment.returned";
  providerPaymentId: string;
  returnCode?: string;
};

async function handlePaymentEvent(event: PaymentEvent) {
  await database.transaction(async (tx) => {
    const inserted = await tx.processedEvents.insertIfAbsent({
      eventId: event.eventId,
    });

    if (!inserted) {
      return;
    }

    const payment = await tx.paymentAttempts.findByProviderPaymentId(
      event.providerPaymentId,
    );

    if (!payment) {
      throw new Error("Unknown provider payment");
    }

    switch (event.eventType) {
      case "payment.processing":
        await tx.paymentAttempts.updateStatus(payment.id, "processing");
        break;

      case "payment.succeeded":
        await tx.paymentAttempts.updateStatus(payment.id, "succeeded");
        await tx.utilityInvoices.markPaid(payment.invoiceId);
        break;

      case "payment.failed":
        await tx.paymentAttempts.updateStatus(payment.id, "failed");
        break;

      case "payment.returned":
        await tx.paymentAttempts.markReturned(
          payment.id,
          event.returnCode ?? "unknown",
        );
        await tx.utilityInvoices.reopen(payment.invoiceId);
        break;
    }
  });
}

接收 webhook 时,必须先验证原始请求体、签名和事件时间戳,不能仅凭 JSON 中的 eventType 更新账单。事件可能重复或乱序到达,因此需要保存 eventId,并为状态转换设置约束。

落地顺序

第一阶段:验证厂商准入资格

向候选平台提交完整的业务说明,包括:

  • 市政公用事业收费;
  • 仅接受美国 checking account;
  • 每月根据实际用量生成不同金额;
  • 预计居民数量;
  • 每月交易数量和金额;
  • 退款、退票及催缴方式;
  • 是否需要市政机构作为 merchant of record。

正式准入通过前,不宜围绕某一家 API 投入大量开发。

第二阶段:实现账户绑定和授权

使用支付平台托管的银行账户采集组件。系统保存 mandate 状态、授权时间、授权文本版本、授权凭证和 payment method token。居民更换账户时,应创建新的授权记录,并保留旧记录用于审计。

第三阶段:生成账单并发送通知

先冻结当期用量并生成不可随意修改的账单,再安排扣款。扣款通知应包含金额、预计扣款日期、账单周期以及撤销或咨询方式。通知期限按照支付服务商和适用规则设置。

第四阶段:异步扣款

后台 job 只处理已经出账、授权有效且尚未创建 payment attempt 的账单。数据库唯一约束与 API idempotency key 应同时使用,防止重复扣款。

第五阶段:处理退票和对账

每天拉取 settlement report,并与 webhook 结果核对。对账异常应进入人工处理队列,不能依靠重复扣款来“修复”。

常见问题

  • 把 ACH 当作即时支付,过早将账单标记为 paid。
  • 自行保存完整的 routing number 和 account number。
  • 只实现成功路径,遗漏 return、reversal 和争议处理。
  • 使用 cron job 直接遍历所有居民并发起扣款,却没有账单快照、幂等键和批次审计。
  • 居民更换银行账户后,仍使用旧 token 扣款。
  • 使用浮点数计算用量费用,产生分币误差。
  • 只比较 API 单价,忽略账户验证费、退票费、月费、最低消费和人工对账成本。
  • 默认支付平台会接受政府机构,直到开发完成后才申请生产账户。
  • 在日志、错误追踪系统或客服后台暴露银行账户信息。
  • 把 webhook 的 succeeded 当作不可逆的最终状态,没有处理之后发生的 return。

自建 UI 时,可以先比较 Stripe、Dwolla 和现有合作银行提供的 ACH 服务。如果公用事业账单、催缴和会计系统也计划自建,API 型支付平台通常更合适。如果这些业务能力尚未具备,采购市政收费套件往往比单独接入 ACH API 更容易维护。

备注:内容仅供参考。