PAYATHON 2026

哪种开源电商解决方案适合订阅业务?

支付阿杰

结论

如果网站已经基于 Drupal,建议优先考虑 Drupal Commerce + Commerce Recurring。这套方案可以直接沿用现有的用户、权限和内容体系,也方便通过 Drupal 事件系统、REST 接口或 JSON

对接 provisioning system。

具体可以采用以下组合:

  • Drupal Commerce:管理商品、订单、支付、税费和多币种
  • Commerce Recurring:管理订阅周期、续费和周期性订单
  • Commerce Invoice:管理发票实体和发票生成
  • 支付网关模块:完成支付方式 tokenization 和后续自动扣款
  • Drupal Tax / VAT 相关扩展:根据客户所在地、VAT 号码和税区计算税费
  • 自定义事件订阅器或队列任务:将开通、续订、暂停和取消同步到 provisioning system

如果计费规则很复杂,包括按用量计费、分段价格、余额账户、账单调整或大量补扣款,可以将 Kill Bill 作为独立的订阅计费核心,让 Drupal Commerce 负责商店和客户入口。这样通常比把所有计费规则都放进 Drupal 更稳妥。

为什么优先选择 Drupal Commerce

网站已经在使用 Drupal,继续采用 Drupal Commerce,就不必额外维护一套用户、权限和内容系统。客户登录、产品页面、购买流程以及售后入口都可以留在同一个平台。

它也更适合通过应用层完成集成:

  • 通过事件订阅器处理订单、付款和状态变更。
  • 使用 Drupal REST、JSON
    或自定义 API 提供外部接口。
  • 将耗时的开通操作放入 Queue API,避免支付回调超时。
  • 使用自定义字段保存 provisioning ID、订阅计划编号和同步状态。

不过,Drupal Commerce 本身不是完整的 SaaS billing platform。自动续费、发票和 VAT 通常需要多个模块配合,并加入少量定制开发。只安装核心模块并不能完成全部功能。

推荐的业务模型

不要把“订阅”“付款”和“服务实例”合并成同一个对象。更合适的关系是:

Customer
  └── Subscription
        ├── Recurring Order
        │     └── Payment
        ├── Invoice
        └── Provisioned Service

每个对象承担不同职责:

  • Subscription:记录套餐、周期、开始时间、下次续费时间和状态
  • Order:记录某个账期的商品、税费、折扣和应付总额
  • Payment:记录支付授权、扣款结果、退款和网关交易编号
  • Invoice:保存具有固定编号的账单凭证和税务信息
  • Provisioned Service:表示 SaaS tenant、license 或支持服务权益

按照这种方式设计,即使付款失败,系统仍能保留发票和订阅记录。以后更换支付网关时,provisioning 数据也不会受到影响。

实施步骤

1. 先验证支付网关的续费能力

自动续费要求支付网关能够保存支付方式,并允许商户在后续账期主动发起扣款。选型时至少要确认:

  • 是否支持 tokenized payment method
  • 是否允许 off-session 或 merchant-initiated payment
  • 是否支持所需的国家、币种和支付方式
  • 欧洲交易是否支持相应的强客户认证流程
  • webhook 能否报告支付成功、失败、退款和争议
  • Drupal 对应的网关模块是否真正支持 recurring payment

能够完成一次付款,不代表能够自动续费。不同网关模块对 Commerce Recurring 的支持也有差异,需要针对计划使用的 Drupal 版本和支付网关实际验证。

2. 建立订阅产品

为支持服务和 SaaS 套餐分别建立商品类型,并明确以下规则:

  • 计费周期,例如月付或年付
  • 首次收费时间
  • 是否提供试用期
  • 升级和降级的生效时间
  • 周期中变更是否按比例计费
  • 取消后立即停用,还是到期后停用
  • 续费失败后的宽限期

建议把 provisioning 使用的 plan code 保存为商品变体字段,例如:

support_standard_yearly
saas_team_monthly
saas_enterprise_yearly

不要使用商品标题做系统映射。标题可能被翻译,也可能由运营人员修改。

3. 配置税务判断

“欧洲含 VAT、其他地区不含 VAT”只能作为业务概括。实际配置时,至少要考虑:

  • 客户是个人还是企业
  • 客户所在的国家或地区
  • 商品属于 SaaS、电子服务还是人工支持服务
  • 企业是否提供有效的 VAT ID
  • 是否适用 reverse charge
  • 应根据账单地址、服务地还是其他证据判断所在地
  • 发票上需要显示哪些税务字段

因此,不适合简单设置一条“欧盟国家统一加 VAT”的固定规则。税率、申报方式和证据要求可能变化,应先由税务人员确认规则,再映射到 Commerce 的 tax type 和 tax zone。

系统还应保存 VAT ID 的验证结果、验证时间和验证时使用的客户地址,避免以后只能看到客户修改后的资料。

4. 生成不可变的发票快照

不能在用户打开发票页面时,根据当前订单重新计算发票。生成发票时,应固定保存以下信息:

  • 发票号码
  • 开票日期和币种
  • 买卖双方的名称和地址
  • VAT ID
  • 商品描述、数量和单价
  • 税率、税额和总额
  • reverse charge 等必要说明
  • 对应的订单和支付记录
  • 作废、退款或贷项凭证关系

正式开票后,不应直接覆盖原发票。如果金额发生变化,应按照适用的会计规则生成更正记录或 credit note。

5. 通过事件和 API 对接 provisioning system

可以在订单付款成功、订阅取消或续费失败时触发同步。不建议让 provisioning system 直接查询 Commerce 数据库。

下面是一个简化的 Drupal 事件订阅器示例。订单付款完成后,它会向内部 provisioning API 发送消息:

<?php

namespace Drupal\saas_provisioning\EventSubscriber;

use Drupal\commerce_order\Event\OrderEvent;
use Drupal\commerce_order\OrderEvents;
use GuzzleHttp\ClientInterface;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class PaidOrderSubscriber implements EventSubscriberInterface {

  public function __construct(
    private readonly ClientInterface $httpClient,
  ) {}

  public static function getSubscribedEvents(): array {
    return [
      OrderEvents::ORDER_PAID => 'onOrderPaid',
    ];
  }

  public function onOrderPaid(OrderEvent $event): void {
    $order = $event->getOrder();

    $items = [];
    foreach ($order->getItems() as $orderItem) {
      $purchasedEntity = $orderItem->getPurchasedEntity();

      $items[] = [
        'sku' => $purchasedEntity?->getSku(),
        'quantity' => (string) $orderItem->getQuantity(),
      ];
    }

    $this->httpClient->request('POST', 'https://provisioning.internal/v1/orders', [
      'headers' => [
        'Idempotency-Key' => 'commerce-order-' . $order->id(),
        'Accept' => 'application/json',
      ],
      'json' => [
        'order_id' => (string) $order->id(),
        'order_uuid' => $order->uuid(),
        'customer_id' => (string) $order->getCustomerId(),
        'items' => $items,
      ],
      'timeout' => 10,
    ]);
  }

}

对应的服务定义可以写成:

services:
  saas_provisioning.paid_order_subscriber:
    class: Drupal\saas_provisioning\EventSubscriber\PaidOrderSubscriber
    arguments:
      - '@http_client'
    tags:
      - { name: event_subscriber }

在生产环境中,不建议在支付事件里同步等待 provisioning 完成。更可靠的处理流程是:

付款成功
  → 写入 Drupal 队列或 outbox
  → 后台 worker 调用 provisioning API
  → provisioning 返回实例 ID
  → 保存同步结果
  → 失败时按策略重试

接口需要支持幂等性。支付网关、消息队列和定时任务都可能重复投递消息,同一个订单即使被处理两次,也不应创建两个 SaaS tenant。

例如:

POST /v1/subscriptions/provision
Idempotency-Key: commerce-order-18472
Content-Type: application/json
{
  "customer_id": "9281",
  "subscription_id": "4135",
  "plan_code": "saas_team_monthly",
  "effective_at": "2026-09-01T00:00:00Z"
}

其他值得评估的方案

Kill Bill

Kill Bill 更侧重订阅计费和支付编排,适合复杂账期、插件化支付、账户余额以及用量计费等场景。Drupal 可以继续负责商品展示和客户门户,订阅账单则交给 Kill Bill 管理。

这种方案的系统结构、部署和运维会复杂很多,而且 Drupal 与 Kill Bill 之间通常需要自行开发集成层。

Medusa

Medusa 适合采用 headless commerce 和 JavaScript/TypeScript 技术栈的团队。它有比较清晰的服务端扩展方式和 API,但订阅、税务发票以及完整的 SaaS billing 流程通常仍需要额外模块或定制,不能视为默认具备的现成功能。

如果继续保留 Drupal 作为内容平台,就需要维护 Drupal 与独立 commerce backend 之间的身份、购物车和订单集成。

Solidus

Solidus 有成熟的订单、支付和扩展机制,数据模型也比较清晰,适合拥有 Ruby 团队并愿意维护独立商店的情况。对于订阅计费、欧洲 VAT 和发票功能,仍需逐项验证相关扩展的维护状态。

Solidus 与 Drupal 不共用运行时,因此集成成本高于 Drupal Commerce。

不建议直接集成数据库

即使某个平台的数据表很容易理解,也不应让 provisioning system 直接写入订单、付款或订阅表。电商系统通常还依赖以下机制:

  • 状态机转换
  • 实体保存钩子
  • 税额重新计算
  • 付款状态聚合
  • 缓存失效
  • 审计日志
  • 后台任务

绕过应用层直接写数据库,容易出现“表中已有记录,但业务事件没有发生”的不一致状态。数据库可以用于只读报表或故障排查,正式集成则应优先使用 API、事件、消息队列或 outbox。

最终建议

如果订阅主要采用固定月费或年费,建议选择 Drupal Commerce + Commerce Recurring + Commerce Invoice + 合适的支付网关模块,再通过事件队列连接 provisioning system。这通常是成本较低,也最符合现有架构的方案。

正式开发前,可以先完成一个最小验证项目,完整测试以下流程:

  1. 欧洲个人客户购买并计算 VAT。
  2. 有效 VAT ID 的企业客户购买。
  3. 非欧洲客户购买。
  4. 首次付款并自动开通服务。
  5. 自动续费并生成新账期发票。
  6. 续费失败、重试和宽限期处理。
  7. 套餐升级、降级和取消。
  8. 退款、发票更正和服务回收。
  9. 重复 webhook 或重复队列消息的幂等处理。

如果这些场景需要大幅修改 Commerce Recurring 的核心行为,或者还要加入复杂的用量计费,应尽早评估 Kill Bill 等专用 billing engine,避免继续扩大 Drupal 内部的定制范围。

备注:内容仅供参考。