市政公用事业 recurring payment backend system 选型
结论
这类系统应采用“ACH Direct Debit + 可变金额账单”的设计。普通的固定金额订阅不适用于每月金额变化的公用事业账单。
可以优先评估以下方案:
- ACH 专用平台,如 Dwolla。其 API 通常涵盖银行账户验证、授权、扣款和 webhook,适合自行开发完整的账单界面。
- 综合支付平台提供的 ACH Direct Debit,如 Stripe。适合需要快速接入托管式银行账户采集、tokenization、付款状态和 webhook 的团队。
- 银行提供的 ACH Origination / Treasury Management。如果村镇已有合作银行,这种方案可能更符合政府采购和资金管理要求,不过 API 的易用性往往不如支付平台。
- 市政公用事业收费平台。如果系统还要处理纸质账单、柜台支付、催缴、总账对账和居民门户,可以考虑专门的 municipal utility billing vendor。此类产品通常成本较高,但可以缩小自建业务系统的范围。
- 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 更容易维护。
备注:内容仅供参考。
