Java 或 Groovy 的 Recurring Payments Library
结论
通常不存在这样一个通用的 Java/Groovy Library:既能直接接入 Exact Payment Technology,又能同时处理付款调度、多支付处理器适配和会计对账。
实际可考虑两种方案:
- 如果订阅、账单、退款、重试和对账规则较复杂,可以评估 Kill Bill 这类开源 billing platform。不过,仍需单独确认它是否有可用的 Exact Payment 插件。如果没有,就要自行开发 payment adapter。
- 如果只是按固定周期使用 token 扣款,可以基于 Spring、Quartz 或现有任务调度系统实现。调度、支付网关适配和不可变事件流水应分开设计,无需从头编写调度器。
需要注意的是,token 只代表支付凭据。订阅计划、到期时间计算、失败重试、幂等控制和会计记录仍应由自己的系统管理。
推荐的系统结构
整个系统可以拆成四个相互独立的部分:
-
Subscription
保存客户、金额、币种、支付周期、下次扣款时间和状态。
-
Scheduler
定期查询已经到期的付款任务。单体应用可以使用 Spring Scheduling。需要持久化任务、集群运行或更精细地控制触发条件时,可以使用 Quartz。业务数据仍建议保存在自己的数据库中。
-
PaymentProcessor
定义统一接口,再为 Exact Payment、Stripe 或其他处理器分别实现 adapter。这样,业务层不必直接依赖某个厂商的请求和响应格式。
-
PaymentAttempt 与 AccountingEvent
PaymentAttempt记录每一次扣款尝试,AccountingEvent记录成功、失败、退款等业务事件。会计对账不能只依赖应用日志。
简化后的数据关系如下:
Subscription
└── PaymentAttempt
└── AccountingEvent
Subscription 表示“应该何时付款”,PaymentAttempt 表示“系统实际尝试了什么”,AccountingEvent 表示“最终需要核对什么”。
数据模型示例
下面只是示意结构。具体字段需要根据 Exact Payment 的实际 API、结算文件和业务规则调整:
CREATE TABLE subscription (
id BIGINT PRIMARY KEY,
customer_id BIGINT NOT NULL,
processor VARCHAR(50) NOT NULL,
payment_token VARCHAR(512) NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
interval_unit VARCHAR(20) NOT NULL,
interval_count INTEGER NOT NULL,
next_payment_at TIMESTAMP NOT NULL,
status VARCHAR(20) NOT NULL,
version BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE payment_attempt (
id BIGINT PRIMARY KEY,
subscription_id BIGINT NOT NULL,
scheduled_at TIMESTAMP NOT NULL,
idempotency_key VARCHAR(128) NOT NULL UNIQUE,
processor VARCHAR(50) NOT NULL,
processor_payment_id VARCHAR(128),
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL,
failure_code VARCHAR(100),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE TABLE accounting_event (
id BIGINT PRIMARY KEY,
payment_attempt_id BIGINT NOT NULL,
event_type VARCHAR(50) NOT NULL,
amount_minor BIGINT NOT NULL,
currency CHAR(3) NOT NULL,
occurred_at TIMESTAMP NOT NULL,
processor_reference VARCHAR(128),
payload_json TEXT
);
金额建议使用最小货币单位保存。例如,10.25 CAD 应保存为 1025,避免浮点数带来的精度问题。
支付处理器适配
业务层可以先定义一个稳定的接口:
public interface PaymentProcessor {
ChargeResult charge(ChargeRequest request);
}
public record ChargeRequest(
String paymentToken,
long amountMinor,
String currency,
String idempotencyKey
) {}
public record ChargeResult(
Status status,
String processorPaymentId,
String failureCode
) {
public enum Status {
SUCCEEDED,
DECLINED,
UNKNOWN
}
}
Exact Payment adapter 只负责转换厂商协议:
public final class ExactPaymentProcessor implements PaymentProcessor {
private final ExactPaymentClient client;
public ExactPaymentProcessor(ExactPaymentClient client) {
this.client = client;
}
@Override
public ChargeResult charge(ChargeRequest request) {
ExactChargeResponse response = client.chargeToken(
request.paymentToken(),
request.amountMinor(),
request.currency(),
request.idempotencyKey()
);
return switch (response.status()) {
case APPROVED -> new ChargeResult(
ChargeResult.Status.SUCCEEDED,
response.transactionId(),
null
);
case DECLINED -> new ChargeResult(
ChargeResult.Status.DECLINED,
response.transactionId(),
response.reasonCode()
);
default -> new ChargeResult(
ChargeResult.Status.UNKNOWN,
response.transactionId(),
response.reasonCode()
);
};
}
}
ExactPaymentClient、ExactChargeResponse 和相关参数都只是结构示例,并非 Exact Payment 官方 API。实际实现必须以当前使用的 Exact Payment 接口文档为准。
UNKNOWN 状态需要单独处理。网络超时并不等于付款失败,处理器可能已经完成扣款,只是应用没有收到响应。遇到这种情况,应先通过 transaction lookup、webhook 或结算数据确认结果,不能立即再次扣款。
调度与扣款流程
使用 Spring Scheduling 时,可以定期领取到期任务:
@Component
public class RecurringPaymentJob {
private final RecurringPaymentService paymentService;
public RecurringPaymentJob(RecurringPaymentService paymentService) {
this.paymentService = paymentService;
}
@Scheduled(fixedDelayString = "${payments.scan-delay-ms:30000}")
public void processDuePayments() {
paymentService.processDuePayments(100);
}
}
服务层需要重点处理的是任务领取、幂等和事务边界,而不是定时器本身:
@Service
public class RecurringPaymentService {
private final SubscriptionRepository subscriptions;
private final PaymentAttemptRepository attempts;
private final PaymentProcessorRegistry processors;
public void processDuePayments(int batchSize) {
var dueSubscriptions =
subscriptions.claimDueSubscriptions(Instant.now(), batchSize);
for (var subscription : dueSubscriptions) {
processOne(subscription);
}
}
public void processOne(Subscription subscription) {
String idempotencyKey =
"subscription:%d:scheduled:%s".formatted(
subscription.id(),
subscription.nextPaymentAt()
);
PaymentAttempt attempt =
attempts.findOrCreate(subscription, idempotencyKey);
if (attempt.isTerminal()) {
return;
}
PaymentProcessor processor =
processors.get(subscription.processor());
ChargeResult result = processor.charge(
new ChargeRequest(
subscription.paymentToken(),
subscription.amountMinor(),
subscription.currency(),
idempotencyKey
)
);
recordResultAndAdvanceSchedule(subscription, attempt, result);
}
}
在多实例部署中,claimDueSubscriptions 必须避免多个节点同时领取同一条记录。具体做法取决于数据库类型,例如:
SELECT *
FROM subscription
WHERE status = 'ACTIVE'
AND next_payment_at <= CURRENT_TIMESTAMP
ORDER BY next_payment_at
FOR UPDATE SKIP LOCKED
LIMIT 100;
并非所有数据库都支持 FOR UPDATE SKIP LOCKED,具体语法也可能不同。还可以使用带版本号的乐观锁,或者为任务增加 claimed_by 和 claimed_until 字段。
调用外部支付 API 时,不要一直持有数据库事务锁。更稳妥的流程是:
- 在短事务中领取任务并创建
PaymentAttempt; - 提交事务;
- 调用支付处理器;
- 在另一个事务中记录结果、生成会计事件,并推进下次扣款时间。
失败重试与状态处理
付款结果至少要分为三类:
SUCCEEDED:已经明确成功,可以推进账期;DECLINED:处理器已经明确拒绝,可以根据业务规则通知客户,或安排有限次数的重试;UNKNOWN:发生超时、连接中断或响应解析失败,必须先查询原交易状态。
重试记录中应保存 next_retry_at、尝试次数和失败原因。可以采用指数退避,但银行卡余额不足、卡片失效和系统超时不应共用完全相同的重试策略。
推进下一账期时,也不能简单地在时间戳上增加固定天数。例如,“每月 31 日扣款”在二月没有对应日期,因此必须提前确定处理规则:
- 调整到当月最后一天;
- 跳过该月;
- 从实际扣款日重新计算周期。
时间计算建议使用 java.time:
LocalDate nextDate = currentDate.plusMonths(1);
if (currentDate.getDayOfMonth() == currentDate.lengthOfMonth()) {
nextDate = nextDate.withDayOfMonth(nextDate.lengthOfMonth());
}
业务时区也要明确。数据库可以保存 Instant,订阅规则则单独保存 ZoneId,以免服务器时区或夏令时变化影响扣款日期。
对账不能只靠日志
普通应用日志适合排查错误,但不能作为会计账本。每笔交易至少应记录:
- 内部 subscription ID;
- payment attempt ID;
- 处理器交易编号;
- idempotency key;
- 金额和币种;
- 计划扣款时间和实际发生时间;
- 成功、拒绝、未知、退款等状态;
- 处理器错误码;
- webhook 或状态查询结果;
- 结算批次或 settlement reference。
事件记录最好采用只追加方式。状态变化时新增事件,不要直接覆盖历史。例如:
PAYMENT_SCHEDULED
PAYMENT_SUBMITTED
PAYMENT_SUCCEEDED
PAYMENT_DECLINED
PAYMENT_STATUS_UNKNOWN
PAYMENT_REFUNDED
SETTLEMENT_MATCHED
系统还应有独立的 reconciliation job,定期将本地成功交易与处理器提供的 transaction report、settlement report 或 API 查询结果进行比对。通常需要检查以下差异:
- 本地显示成功,但处理器中没有对应记录;
- 处理器已经扣款,本地仍为
UNKNOWN; - 金额或币种不一致;
- 出现重复扣款;
- 已经退款,但本地尚未入账;
- 授权成功,但最终没有进入结算。
何时考虑现成 billing platform
当需求包含以下内容时,一套简单的定时任务很快就会变得难以维护:
- 套餐升级和降级;
- 按比例计费;
- 试用期;
- 优惠券;
- 发票和税务;
- 欠款催收;
- 多币种;
- 退款与冲正;
- 订阅暂停、恢复和取消;
- 多支付处理器路由。
这时可以评估 Kill Bill 等 billing platform。它们可以管理订阅生命周期、账单和支付插件,但不能默认认为它们已经原生支持 Exact Payment。评估时应先确认:
- 是否有仍在维护的 Exact Payment 插件;
- 插件是否支持 hosted payment 返回的 token;
- 是否支持当前账户使用的 Exact Payment API;
- webhook、退款、状态查询和结算数据是否完整;
- 引入整个平台的运维成本是否低于自行开发。
如果只是少量固定周期扣款,完整的 billing platform 通常显得过重。使用 Spring 或 Quartz,再配合清晰的数据模型和处理器接口,一般更容易控制。
安全与合规注意事项
- 不要保存卡号、CVV 或 hosted payment page 返回页面中的敏感表单数据;
- token 仍要按敏感凭据管理,建议加密存储并限制读取权限;
- 不要将 token 写入普通日志、异常信息或监控标签;
- 使用 HTTPS,并妥善管理处理器密钥;
- webhook 必须验证签名,并按照事件 ID 去重;
- 确认 token 的有效期、客户绑定关系,以及它是否允许 merchant-initiated transaction;
- 保留必要的审计记录,但不要在事件 payload 中复制敏感数据;
- 根据所在地区和业务模式,确认 PCI DSS、隐私保护、退款和客户授权要求。
因此,值得复用的并不是一个包办所有功能的 Recurring Payments Library,而是成熟的调度组件、自有的持久化状态机、支付处理器 adapter,以及可审计的交易流水。对于 Exact Payment,最可能需要定制的部分仍然是 token 扣款、交易状态查询、webhook 和结算对账接口。
备注:内容仅供参考。
