公用事业服务 recurring payment backend system 推荐
结论
如果计划自行开发用户界面、账单系统和付款历史,可以优先评估以下方案:
- Stripe ACH Direct Debit:API、SDK、托管式银行账户验证和 Webhook 较为完善,适合快速上线。
- Dwolla:专注于美国 ACH 和银行账户转账,适合交易量较大、需要精细控制 ACH 流程的系统。
- GoCardless:核心业务是银行自动扣款和 recurring payments,适合每月收取不同金额的场景。不过,应先确认其对美国公用事业服务、公共服务机构及所在地区的准入政策。
- InvoiceCloud、Paymentus 等公用事业缴费平台:如果除了支付 API,还需要电子账单、催缴、对账、客服工具和合规支持,这类垂直平台通常更合适,但成本和集成复杂度往往高于纯 API 方案。
“每月根据实际用量收取不同金额”不适合设计成固定金额的 subscription。更合适的流程是:
- 首次采集并验证 checking account;
- 保存支付服务商返回的 payment method/token;
- 取得符合要求的 ACH 扣款授权;
- 每月账单确定后,创建一笔新的 ACH debit;
- 通过 Webhook 异步更新
pending、succeeded、returned等状态。
如果更看重开发体验和上线速度,可以先对 Stripe 和 Dwolla 进行并行选型测试。如果市政采购、客服和对账要求较多,则可以直接评估公用事业专用平台。
为什么不能只找一个“自动扣款接口”
ACH checking account payment 与信用卡支付存在几个明显区别:
- ACH 通常不会实时结算。请求提交成功,不代表资金最终到账。
- 扣款可能在数日后被退回。
- 银行账户需要验证,常见方式包括即时验证和 micro-deposits。
- recurring debit 需要保留用户授权记录。
- 用户可能撤销授权、关闭账户,也可能因余额不足导致扣款失败。
- 系统需要处理 ACH return code,不能只记录简单的成功或失败。
- 退款、冲正和退回属于不同的资金事件,账务上不能混为一谈。
因此,选型时需要评估完整的支付流程,包括账户验证、tokenization、授权证据、扣款、Webhook、退回处理、对账、退款和审计。
推荐方案对比
| 方案 | 更适合的情况 | 主要优点 | 需要确认 |
|---|---|---|---|
| Stripe ACH Direct Debit | 希望快速集成、统一管理支付状态 | API 和 SDK 成熟,支持托管验证与 Webhook | 当前费率、资金可用时间、政府或公用事业准入 |
| Dwolla | ACH 是核心业务,需要更细粒度控制 | 专注银行转账,适合 API 驱动的资金流 | 报价模式、账户结构、合规审核和资金流限制 |
| GoCardless | 主要业务就是 recurring bank debit | 自动扣款模型清晰,适合可变金额账单 | 美国地区功能、行业准入、定价和结算安排 |
| InvoiceCloud / Paymentus | 希望获得完整公用事业缴费门户 | 包含账单、通知、对账和运营能力 | 合同周期、实施费、交易费和数据迁移 |
| 银行提供的 ACH Origination | 已有稳定银行合作和较强后台能力 | 大交易量下可能更经济 | API 质量、文件批处理、NACHA 文件及运维成本 |
具体价格和行业准入会随合同、交易量及机构性质而变化。实施前应以服务商的最新合同和官方文档为准,不能只根据公开的单笔费率做决定。
建议的系统结构
支付服务商不应是公用事业账单的唯一数据源。内部数据库仍需保存完整的账单和收款状态。
Meter/Usage System
|
v
Billing Engine ---> Invoice Database
| |
| v
+----------> Payment Scheduler
|
v
ACH Payment Provider
|
Webhooks
|
v
Payment Ledger / Reconciliation
系统至少应包含以下数据对象:
Customer
BankPaymentMethod
DebitAuthorization
UtilityAccount
MeterReading
Invoice
PaymentAttempt
PaymentEvent
Refund
ReconciliationEntry
其中:
Invoice表示应收账款;PaymentAttempt表示一次实际扣款尝试;PaymentEvent保存支付服务商发送的原始事件;DebitAuthorization保存用户授权的版本、时间、IP、授权文本摘要和撤销状态。
不要只在 Invoice 上设置一个 paid 布尔字段。ACH 至少需要区分以下状态:
scheduled
submitted
processing
succeeded
returned
failed
canceled
refunded
推荐实施步骤
1. 先确认服务商准入条件
编码前,应向候选服务商明确说明:
- 收款主体是 municipality、village,还是其委托运营商;
- 收取的是 water、sewer、waste 或其他 utility bill;
- 只接受美国 checking account;
- 金额每月变化;
- 涉及消费者账户、企业账户,还是两者都有;
- 预计每月交易笔数、平均金额和最大金额;
- 是否需要 same-day ACH;
- 是否允许 convenience fee;
- 是否需要将资金拆分到多个银行账户。
公用事业运营方还可能涉及采购、信息安全、档案保留和无障碍要求。服务商在技术上支持 ACH,不代表它一定接受相应的业务类型。
2. 使用服务商托管的账户采集流程
不要让银行账号和 routing number 经过自己的服务器,也不要明文保存。应优先使用服务商提供的 hosted form、embedded component 或即时银行验证流程。
内部只保存类似以下的信息:
{
"provider": "stripe",
"provider_customer_id": "cus_...",
"provider_payment_method_id": "pm_...",
"bank_name": "Example Bank",
"account_last4": "6789",
"verification_status": "verified"
}
provider_payment_method_id 是支付服务商生成的 token,不是真实银行账号。
3. 单独记录自动扣款授权
授权页面应清楚展示:
- 收款机构名称;
- 扣款账户或公用事业账号;
- 扣款频率;
- 金额的确定方式;
- 预计扣款日期;
- 用户撤销授权的方式;
- 联系方式;
- 授权文本和生效时间。
可变金额 recurring debit 还可能涉及提前通知要求。具体规则应由支付服务商、收单银行或熟悉 NACHA 规则的法律顾问确认。
4. 每月生成账单后再创建付款
不要提前创建永久固定金额的订阅。调度任务应在当月账单确定后,根据账单金额创建扣款:
async function collectInvoice(invoice) {
if (invoice.status !== "open") {
return;
}
const paymentAttempt = await createPaymentAttempt({
invoiceId: invoice.id,
amountCents: invoice.balanceCents,
idempotencyKey: `invoice:${invoice.id}:attempt:1`
});
await paymentProvider.debitBankAccount({
customerToken: invoice.customer.providerCustomerId,
paymentMethodToken: invoice.customer.providerPaymentMethodId,
amountCents: invoice.balanceCents,
currency: "usd",
description: `Utility invoice ${invoice.number}`,
metadata: {
invoice_id: invoice.id,
utility_account_id: invoice.utilityAccountId,
payment_attempt_id: paymentAttempt.id
},
idempotencyKey: paymentAttempt.idempotencyKey
});
}
这里的 paymentProvider.debitBankAccount 是应用内部的抽象接口,不是任何服务商的真实 API 名称。
5. 通过 Webhook 确认最终状态
ACH API 的同步响应通常只表示请求已被接收。后续状态应由 Webhook 推进:
app.post(
"/webhooks/payments",
express.raw({ type: "application/json" }),
async (req, res) => {
let event;
try {
event = verifyProviderWebhook(req.body, req.headers);
} catch {
return res.sendStatus(400);
}
const alreadyProcessed = await hasProcessedEvent(event.id);
if (alreadyProcessed) {
return res.sendStatus(200);
}
await database.transaction(async (tx) => {
await tx.paymentEvents.insert({
providerEventId: event.id,
type: event.type,
payload: event.raw
});
switch (event.type) {
case "payment.processing":
await tx.paymentAttempts.markProcessing(event.paymentId);
break;
case "payment.succeeded":
await tx.paymentAttempts.markSucceeded(event.paymentId);
await tx.invoices.applyPayment(
event.metadata.invoice_id,
event.amount
);
break;
case "payment.returned":
await tx.paymentAttempts.markReturned(
event.paymentId,
event.returnCode
);
await tx.invoices.reopen(event.metadata.invoice_id);
break;
}
});
res.sendStatus(200);
}
);
真实的事件名称、签名验证方法和字段结构,必须按照所选服务商的当前文档实现。
Webhook 处理必须支持:
- 签名验证;
- 幂等处理;
- 乱序事件处理;
- 重复事件处理;
- 原始事件留存;
- 失败重试;
- 金额、币种和内部账单编号校验。
Stripe 风格的示意代码
下面的代码用于说明如何在保存银行支付方式后,按账单发起可变金额扣款。Stripe 的参数和适用条件可能调整,实施时应核对当前官方 API。
const paymentIntent = await stripe.paymentIntents.create(
{
amount: invoice.balanceCents,
currency: "usd",
customer: customer.stripeCustomerId,
payment_method: customer.stripePaymentMethodId,
payment_method_types: ["us_bank_account"],
confirm: true,
off_session: true,
description: `Utility invoice ${invoice.number}`,
metadata: {
invoice_id: invoice.id,
utility_account_id: invoice.utilityAccountId
}
},
{
idempotencyKey: `utility-invoice-${invoice.id}`
}
);
这段代码有两个前提:
- 银行支付方式已经完成服务商要求的验证;
- 已取得允许后续
off_session扣款的有效授权。
银行账户采集和授权最好通过服务商提供的客户端组件或托管页面完成。不要自行制作表单,再把原始账户信息发送到应用服务器。
失败和退回策略
应根据 return code 分类处理,不要对所有失败都自动重试。
可以采用以下处理方式:
- 余额不足:根据授权和当地政策,决定是否进行有限次数的重试;
- 账户关闭或账户无效:停止自动扣款,要求用户更新账户;
- 用户声明未授权:立即暂停自动扣款,转入人工审核;
- payment stopped:不得直接重复提交;
- administrative return:修复账户或授权问题后再处理。
自动重试必须限制次数,并记录每次尝试。否则可能造成重复扣款、额外银行费用或合规风险。
对账与会计处理
“支付成功”和“银行入账”不一定发生在同一天。系统应分别记录:
invoice amount
submitted amount
settled amount
provider fee
returned amount
refund amount
bank deposit amount
每天至少执行一次自动对账:
- 拉取服务商的 settlement、balance transaction 或 transfer 数据;
- 根据 provider payment ID 匹配内部
PaymentAttempt; - 将多笔付款与银行存款批次对应;
- 标记无法匹配的差异;
- 生成可导入财务系统的报表。
如果市政财务系统对 fund accounting、general ledger code 或按服务类型拆账有严格要求,应在选型阶段完成验证,不要等支付接口完成后再处理。
安全与合规注意事项
- 不保存原始银行账号和 routing number。
- 不在日志、异常追踪或分析系统中记录银行账户信息。
- API secret 只存放在服务端的 secret manager 中。
- 所有后台操作都启用细粒度权限和审计日志。
- 修改银行账户时重新验证身份,必要时启用 MFA。
- 更换账户后发送通知,防止账户接管。
- 对 Webhook 进行签名验证,不把来源 IP 作为唯一验证方式。
- 保留授权、撤销、账单通知和扣款事件的可审计记录。
- 明确 NSF fee、late fee、退款和重复付款的处理政策。
- 核实 NACHA、消费者保护、州法、市政收费规定及数据保留要求。
即使采用 tokenization,应用仍需承担授权、访问控制、审计和业务流程方面的责任。
最实际的选型方法
可以使用同一组场景分别测试 Stripe 和 Dwolla:
- 新用户即时验证 checking account;
- micro-deposit 验证的备用流程;
- 首次授权;
- 当月可变金额扣款;
- 重复请求的幂等处理;
- NSF return;
- 用户更换银行账户;
- 退款;
- 每日对账;
- 撤销自动扣款。
最后根据以下指标评分:
总成本 =
transaction fees
+ monthly/platform fees
+ verification fees
+ return fees
+ implementation cost
+ reconciliation cost
+ support and compliance cost
单笔 ACH 价格最低的服务商,未必能带来最低的总体成本。对于规模不大的项目,成熟的托管验证、可靠的 Webhook 和清晰的对账数据,通常比节省少量单笔费用更重要。
备注:内容仅供参考。
