PAYATHON 2026

Java 或 Groovy 的 Recurring Payments Library

支付老李

结论

通常不存在这样一个通用的 Java/Groovy Library:既能直接接入 Exact Payment Technology,又能同时处理付款调度、多支付处理器适配和会计对账。

实际可考虑两种方案:

  • 如果订阅、账单、退款、重试和对账规则较复杂,可以评估 Kill Bill 这类开源 billing platform。不过,仍需单独确认它是否有可用的 Exact Payment 插件。如果没有,就要自行开发 payment adapter。
  • 如果只是按固定周期使用 token 扣款,可以基于 Spring、Quartz 或现有任务调度系统实现。调度、支付网关适配和不可变事件流水应分开设计,无需从头编写调度器。

需要注意的是,token 只代表支付凭据。订阅计划、到期时间计算、失败重试、幂等控制和会计记录仍应由自己的系统管理。

推荐的系统结构

整个系统可以拆成四个相互独立的部分:

  1. Subscription

    保存客户、金额、币种、支付周期、下次扣款时间和状态。

  2. Scheduler

    定期查询已经到期的付款任务。单体应用可以使用 Spring Scheduling。需要持久化任务、集群运行或更精细地控制触发条件时,可以使用 Quartz。业务数据仍建议保存在自己的数据库中。

  3. PaymentProcessor

    定义统一接口,再为 Exact Payment、Stripe 或其他处理器分别实现 adapter。这样,业务层不必直接依赖某个厂商的请求和响应格式。

  4. 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 时,不要一直持有数据库事务锁。更稳妥的流程是:

  1. 在短事务中领取任务并创建 PaymentAttempt;
  2. 提交事务;
  3. 调用支付处理器;
  4. 在另一个事务中记录结果、生成会计事件,并推进下次扣款时间。

失败重试与状态处理

付款结果至少要分为三类:

  • 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。评估时应先确认:

  1. 是否有仍在维护的 Exact Payment 插件;
  2. 插件是否支持 hosted payment 返回的 token;
  3. 是否支持当前账户使用的 Exact Payment API;
  4. webhook、退款、状态查询和结算数据是否完整;
  5. 引入整个平台的运维成本是否低于自行开发。

如果只是少量固定周期扣款,完整的 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 和结算对账接口。

备注:内容仅供参考。