PAYATHON 2026

Drupal 和 Ubercart 如何创建可在线支付的客户账单?

支付阿杰

结论

Ubercart 支持管理员创建自定义金额的订单,但“创建订单”和“让客户在线支付已有订单”是两个独立环节。

管理员可以在后台为客户创建订单,添加一个通用收费商品,再修改订单行的价格。客户通常也能在订单历史中看到这张订单。不过,Ubercart 核心支付流程是围绕购物车结账设计的。创建一张待处理订单,不会自动将其放入客户的购物车,也未必会生成“立即支付”入口。

常见的解决办法有两种:

  • 安装兼容当前 Drupal 和 Ubercart 版本的“支付未结订单”或“账单支付”扩展。
  • 编写一个小型自定义模块,为已有订单提供安全的付款页面,并通过 Ubercart 的支付方式完成交易。

如果需要完全沿用原生购物车结账流程,也可以让客户领取一项专属收费商品后再结账,但必须额外处理价格校验、访问权限和重复付款问题。

为什么后台订单不会出现在客户购物车中

Ubercart 的购物车通常保存在当前用户的会话或购物车记录中,而管理员在后台创建的订单属于订单系统,不属于客户当前的购物车。因此可能出现以下情况:

  1. 管理员已经创建订单。
  2. 客户可以在订单历史中看到该订单。
  3. 客户的购物车仍然是空的。
  4. 支付网关只会在标准结账流程中被调用。

这并不是缺少某个“同步到购物车”选项。购物车和订单原本就是不同阶段的数据。

也不建议将已创建的订单重新放回购物车,因为这样很容易生成第二张订单,最终出现原账单仍未付款、新订单却已经付款的情况。

推荐的处理流程

1. 建立通用收费商品

可以在 Ubercart 中创建一个普通商品,例如:

  • 商品名称:Additional charge
  • SKU:EXTRA-FEE
  • 默认价格:0.00
  • 不需要配送
  • 不公开展示,或限制普通用户自行购买

管理员创建账单时,将该商品加入订单,再在订单行中填写实际金额和说明,例如:

  • 墙面维修费:¥800
  • 钥匙补办费:¥100
  • 逾期管理费:¥50

能否直接修改订单行价格,取决于 Ubercart 版本、后台权限和已启用的模块。修改订单价格的权限应只授予受信任的管理员。

2. 以客户身份创建订单

在 Ubercart 后台新建订单时,应选择真实的客户账户,不要使用匿名用户。之后按以下步骤操作:

  1. 添加通用收费商品。
  2. 修改订单行的标题、SKU、数量和价格。
  3. 将订单保存为自定义待付款状态,例如 Invoice pending。
  4. 向客户发送包含订单号和付款入口的通知。

创建账单时,不要直接将订单状态设为 Completed。完成状态通常表示业务流程已经结束,还可能触发库存、通知或其他规则。

3. 提供已有订单的付款入口

付款页面至少需要检查以下内容:

  • 当前登录用户是否为订单所有者。
  • 订单是否处于允许付款的状态。
  • 订单是否仍有未付余额。
  • 订单是否已被取消或删除。
  • 支付金额是否根据服务器端订单数据计算,不能直接采用 URL 参数或表单隐藏字段中的金额。
  • 支付成功后是否记录交易并更新订单状态。

在 Drupal 7 + Ubercart 3 这类旧项目中,访问检查的核心逻辑可以写成下面这样。具体的菜单路由和支付表单 API 需要按项目使用的版本调整:

/**
 * 检查当前用户是否可以支付指定订单。
 */
function rental_invoice_pay_access($order_id) {
  global $user;

  if (!$user->uid) {
    return FALSE;
  }

  $order = uc_order_load($order_id);

  if (!$order) {
    return FALSE;
  }

  // 管理员可以代查,普通用户只能访问自己的订单。
  if ($order->uid != $user->uid && !user_access('administer orders')) {
    return FALSE;
  }

  // 只允许指定的未付款状态进入付款流程。
  $payable_statuses = array(
    'pending',
    'invoice_pending',
  );

  if (!in_array($order->order_status, $payable_statuses, TRUE)) {
    return FALSE;
  }

  // uc_payment_balance() 的可用性及参数形式应以当前版本为准。
  $balance = uc_payment_balance($order);

  return $balance > 0;
}

这段代码只处理访问控制,不负责收集银行卡信息,也不会直接调用第三方网关。支付表单应使用当前 Ubercart 版本提供的支付方式接口,或者调用相应支付网关模块的正式 API。

不要自行保存银行卡号、CVV 等敏感数据。

余额应如何计算

如果系统支持部分付款,未付余额应根据服务器端记录计算:

未付余额 = 订单应付总额 - 已成功入账的付款 - 已确认退款抵扣

示意代码如下:

$order_total = (float) uc_order_get_total($order);
$paid_total = rental_invoice_get_successful_payments($order->order_id);
$refund_credit = rental_invoice_get_confirmed_refund_credit($order->order_id);

$balance = max(
  0,
  $order_total - $paid_total - $refund_credit
);

其中,rental_invoice_get_successful_payments() 和 rental_invoice_get_confirmed_refund_credit() 是项目需要自行实现的业务函数,不属于 Ubercart 核心 API。实际计算时,必须明确哪些交易状态代表付款成功,不能将未确认或失败的付款计入已付金额。

如果业务不需要部分付款,流程会简单得多。客户只需一次性支付订单的全部未付金额。

使用原生结账流程的替代方案

如果不打算开发“支付已有订单”功能,可以将账单设计成客户专属的待购项目:

  1. 管理员创建一条内部账单记录。
  2. 系统生成一个仅限指定客户访问的付款链接。
  3. 客户打开链接后,服务器根据账单记录将收费商品加入购物车。
  4. 服务器使用账单金额覆盖商品价格。
  5. 客户通过正常的 Ubercart 流程结账。
  6. 新订单付款成功后,系统将内部账单标记为已支付。

这种方式可以复用现有支付网关,但必须防止客户修改价格或支付他人的账单。金额不能直接放在查询字符串中,例如:

/cart/add/EXTRA-FEE?price=800

即使页面隐藏了价格字段,客户仍然可以修改请求。安全的链接只能携带无法猜测的账单令牌:

/invoice/pay/4f2d8c7e...

服务器收到令牌后,再从数据库读取客户、金额、币种和账单状态。

扩展模块的选择

Ubercart 相关扩展的名称和维护状态会随 Drupal 和 Ubercart 版本变化。选择模块时,应确认它是否明确支持:

  • 支付已有订单的余额;
  • 当前使用的 Drupal 和 Ubercart 主版本;
  • 项目正在使用的支付网关;
  • 部分付款或多次付款;
  • 支付回调的重复通知处理;
  • 订单所有权和访问控制。

只提供 PDF 或 HTML 发票的模块,未必支持已有订单的在线支付。“生成发票”和“支付未结订单”不是同一项功能。

重要注意事项

防止重复付款

支付网关可能多次发送同一笔交易的成功回调。处理回调时,必须检查交易号或幂等键,确保同一笔交易只入账一次。

不要直接信任订单状态

判断订单是否已经付款时,应同时核对成功的支付记录和剩余余额。管理员误改订单状态、网关回调失败或部分付款,都可能造成订单状态与实际金额不一致。

保留收费明细

订单行不要只写“额外费用”,至少应记录收费原因、发生日期和对应物业,必要时附上维修记录或图片。客户可以据此了解账单内容,后续审计也有记录可查。

处理订单变更

客户开始付款后,不应再随意修改订单金额。如果确实需要修改,应让旧的付款会话失效,重新计算余额,并记录修改人、修改时间和修改原因。

先在测试环境验证

不同版本的 Ubercart、支付网关模块和订单状态配置可能存在较大差异。上线前至少需要验证:

  • 管理员创建账单;
  • 客户只能看到自己的账单;
  • 全额付款和部分付款;
  • 支付失败后重试;
  • 网关重复回调;
  • 管理员取消账单;
  • 已付款订单无法再次支付;
  • 退款后余额和状态是否正确。

Ubercart 可以处理按实际情况开具费用并在线收款的业务,但后台创建的订单不会自动恢复成客户的购物车。更稳妥的做法是保留该订单作为账单,再增加一个经过严格权限检查和余额校验的“支付此订单”流程。

备注:内容仅供参考。