PAYATHON 2026

如何在 cartridge 结账时禁用 payment details 步骤?

支付小周

结论

可以,但不能只在模板里隐藏或禁用 payment details。

应将积分作为一种支付方式处理。服务端需要重新计算积分可抵扣的金额。只有当积分足以覆盖订单应付金额,并且用户明确选择全额积分支付时,才能将外部支付金额设为 0,跳过支付信息校验和支付网关调用。之后仍需执行 Cartridge 原有的订单创建、库存处理和订单完成逻辑。

Cartridge 的结账步骤通常采用全局配置,不适合根据用户动态修改。要为不同用户跳过支付步骤,一般需要自定义 checkout 视图、表单或 payment handler。

推荐流程

完整流程可以这样设计:

  1. 在购物车页或 billing/shipping 步骤提供“使用积分支付”选项。
  2. 用户提交后,由服务端读取最新的购物车金额和积分余额。
  3. 按照积分兑换规则计算可抵扣金额。
  4. 如果积分足以覆盖全部应付金额:
    • 将订单的外部支付金额设为 0;
    • 不要求用户填写银行卡、PayPal 等支付信息;
    • 不调用外部支付网关;
    • 扣减积分并完成订单。
  5. 如果积分不足:
    • 返回 payment details 步骤;
    • 或进入“积分 + 在线支付”的组合支付流程。
  6. 订单完成后记录积分流水,以便后续审计、退款或撤销。

判断条件必须由服务端计算,例如:

use_reward_points = request.POST.get("use_reward_points") == "1"

points_balance = get_available_points(request.user)
points_required = points_required_for_amount(cart_total)

points_cover_order = (
    use_reward_points
    and points_balance >= points_required
)

不要直接采用浏览器提交的 points_balance、points_required 或订单金额。

不要通过全局设置动态删除步骤

部分 Cartridge 版本允许使用 SHOP_CHECKOUT_STEPS 配置结账步骤,但这通常是整个站点共享的设置。不要在请求处理期间执行类似操作:

settings.SHOP_CHECKOUT_STEPS = ...

这会修改进程级状态。在多用户并发环境中,一个用户的结账选择可能会影响其他用户。

更合适的做法是在自定义 checkout 逻辑中,根据当前请求决定下一步:

def next_checkout_step(request, cart):
    if can_pay_fully_with_points(request.user, cart, request.POST):
        return "confirmation"  # 或直接执行下单逻辑

    return "payment"

具体的步骤名称、视图名称和表单类取决于 Cartridge 版本及项目的定制方式,应以项目实际安装版本中的 checkout URL、表单和视图实现为准。

在 payment handler 中绕过外部支付

如果项目通过 Cartridge 的 payment handler 接入支付网关,可以在自定义 handler 中添加“全额积分支付”分支。以下代码展示的是实现思路,handler 的具体函数签名应与当前 Cartridge 版本一致。

from django.core.exceptions import ValidationError


def payment_handler(request, order_form, order):
    payment = calculate_reward_payment(
        user=request.user,
        order=order,
        use_reward_points=request.session.get(
            "use_reward_points", False
        ),
    )

    if payment.external_amount == 0:
        request.reward_points_to_redeem = payment.points
        return

    # 仍有现金金额时,继续调用原来的支付处理逻辑。
    return process_external_payment(
        request=request,
        order_form=order_form,
        order=order,
        amount=payment.external_amount,
    )

随后在项目配置中,将 Cartridge 的支付处理器指向自定义实现。不同版本使用的配置项可能不同,常见形式如下:

SHOP_HANDLER_PAYMENT = "myproject.shop.payment.payment_handler"

这里需要同时满足以下条件:

  • 全额积分支付时,不校验外部支付字段;
  • 不向支付网关提交金额为 0 的交易;
  • 订单仍进入 Cartridge 正常的完成流程;
  • 积分扣减与订单创建保持一致。

动态移除支付字段

如果 billing/shipping 和 payment details 共用一个结账表单,可以在表单初始化或验证时,根据服务端计算结果移除支付字段:

class RewardCheckoutForm(BaseCheckoutForm):
    payment_fields = (
        "card_name",
        "card_number",
        "card_expiry",
        "card_ccv",
    )

    def __init__(self, *args, **kwargs):
        self.user = kwargs.pop("user")
        self.cart = kwargs.pop("cart")
        super().__init__(*args, **kwargs)

        if self._fully_covered_by_points():
            for field_name in self.payment_fields:
                self.fields.pop(field_name, None)

    def _fully_covered_by_points(self):
        use_points = self.data.get("use_reward_points") == "1"

        if not use_points:
            return False

        return user_points_cover_cart(self.user, self.cart)

    def clean(self):
        cleaned_data = super().clean()

        if cleaned_data.get("use_reward_points"):
            validate_reward_payment(self.user, self.cart)

        return cleaned_data

其中的字段名称仅作示例,应按照项目所用支付后端的实际表单字段调整。

如果支付信息位于独立页面,可以在 checkout 视图中直接重定向到 confirmation。如果无需用户再次确认,也可以直接执行下单逻辑:

if reward_service.can_cover_order(
    user=request.user,
    cart=cart,
    requested=request.POST.get("use_reward_points") == "1",
):
    request.session["use_reward_points"] = True
    return redirect("shop_checkout_confirmation")

return redirect("shop_checkout_payment")

即使跳过支付页面,也必须在最终提交订单前再次验证积分余额和订单金额。

积分扣减必须与订单创建放在同一事务中

结账期间,积分余额可能发生变化。例如,用户同时打开两个页面提交订单,两个请求在首次检查时都可能看到“积分充足”。因此,扣减积分时需要锁定余额记录:

from django.core.exceptions import ValidationError
from django.db import transaction


@transaction.atomic
def complete_reward_order(user, order, points_required):
    account = (
        RewardAccount.objects
        .select_for_update()
        .get(user=user)
    )

    if account.balance < points_required:
        raise ValidationError("积分余额不足,请重新选择支付方式。")

    account.balance -= points_required
    account.save(update_fields=["balance"])

    RewardTransaction.objects.create(
        user=user,
        order=order,
        points=-points_required,
        transaction_type="order_payment",
    )

    order.payment_method = "reward_points"
    order.save(update_fields=["payment_method"])

    return order

积分流水还应设置订单级唯一约束或幂等标识,避免用户刷新确认页面、网络重试或重复提交时被扣减两次。

订单金额应该如何保存

不要为了跳过支付,直接把商品价格或订单总额改成 0。这样会导致订单明细、销售统计和退款记录失真。

更合理的方式是分别记录:

商品及运费合计:100.00
积分抵扣金额:100.00
外部支付金额:0.00
订单应收合计:100.00

如果现有订单模型没有对应字段,可以添加:

reward_points_used
reward_discount_amount
external_payment_amount
payment_method

金额计算应使用 Decimal,不要使用浮点数:

from decimal import Decimal

external_amount = max(
    order_total - reward_discount_amount,
    Decimal("0.00"),
)

必须处理的边界情况

实现时还要覆盖以下情况:

  • 用户选择积分支付后,购物车内容、价格、税费或运费发生变化;
  • 用户进入确认页后,积分被其他订单使用;
  • 匿名用户尝试使用积分;
  • 用户重复提交订单;
  • 下单失败后返还积分;
  • 订单取消或退款时退回积分;
  • 积分已经过期、被冻结或不可用;
  • 积分不足时返回支付页面,并显示明确提示;
  • 使用部分积分支付时,外部支付网关只收取剩余金额。

因此,隐藏 payment details 只是界面层面的处理。能否跳过支付必须由服务端决定,并在订单最终提交时重新验证。

较稳妥的实现方式是保留 Cartridge 原有的 checkout 和订单完成流程,自定义积分服务及 payment handler。只有服务端确认外部应付金额为 0 后,才跳过 payment details 和支付网关调用。

备注:内容仅供参考。