PAYATHON 2026

如何为 Prestashop 的 COD payment method 添加手续费?

支付老李

结论

不要直接修改 PrestaShop 核心文件,也不要只在 COD 模块调用 validateOrder() 时简单地把手续费加到订单金额上。

更合适的做法是把 COD 手续费放在独立支付模块中,并让它进入购物车和订单明细。对于经过多人修改、版本及 override 情况不清楚的项目,可以采用以下方案:

  1. 创建一个“COD 手续费”服务商品。
  2. 客户确认使用货到付款时,将该商品加入购物车。
  3. 根据更新后的购物车总额创建订单。
  4. 客户更换支付方式、返回购物车或支付失败时,移除该商品。

这样可以让商品合计、税费、发票、退款和后台订单金额保持一致。

为什么不应直接改订单总金额

有些实现会在 COD 验证控制器中这样处理:

$amount = $cart->getOrderTotal(true, Cart::BOTH) + $fee;
$this->module->validateOrder($cart->id, $orderState, $amount, ...);

这种写法可能会让 total_paid 包含手续费,但订单中没有对应的商品、运费或其他费用明细,容易引发以下问题:

  • 订单总额与明细之和不一致;
  • 发票金额或税额计算异常;
  • 部分退款时无法单独处理手续费;
  • 后台统计和第三方 ERP 对账出现偏差;
  • 重新计算订单时丢失手续费。

也不要直接修改以下核心类或控制器:

classes/Cart.php
classes/PaymentModule.php
controllers/front/OrderController.php

修改核心文件会增加升级和排查问题的成本。如果项目中已有 override,继续叠加修改还可能产生难以预料的冲突。

推荐实现步骤

1. 先确认项目实际结构

开发前先检查以下内容:

  • PrestaShop 的准确版本;
  • 当前 COD 模块名称,例如旧版 cashondelivery 或新版 ps_cashondelivery;
  • override/ 目录是否覆盖了 Cart、Order 或结账控制器;
  • COD 模块是否经过二次开发;
  • 结账页是否使用第三方 one-page checkout;
  • 是否启用了多店铺、多币种和含税显示。

不同版本使用的支付 Hook 和控制器结构并不相同。下面的代码只能作为实现骨架,核对项目版本后才能部署。

2. 创建手续费服务商品

在后台创建一个专门表示手续费的商品,例如:

商品名称:Cash on delivery fee
商品类型:虚拟商品
库存管理:关闭
配送:不产生额外重量
可见性:不在搜索和分类中展示

手续费是否需要纳税,应按业务所在地的税务规则设置,不能默认手续费免税。

建议将商品 ID 保存在模块配置中,不要硬编码:

Configuration::updateValue('MY_COD_FEE_PRODUCT_ID', (int) $productId);

3. 在 COD 确认阶段加入手续费商品

下面的示例展示了主要逻辑。控制器基类和命名空间需要按照项目使用的 PrestaShop 版本调整。

<?php

class MyCodValidationModuleFrontController extends ModuleFrontController
{
    public function postProcess()
    {
        $cart = $this->context->cart;
        $customer = $this->context->customer;

        if (!$cart || !$cart->id || !$customer || !$customer->isLogged()) {
            Tools::redirect('index.php?controller=order');
        }

        $feeProductId = (int) Configuration::get('MY_COD_FEE_PRODUCT_ID');

        if ($feeProductId <= 0) {
            $this->errors[] = $this->module->l('COD fee product is not configured.');
            return;
        }

        if (!$this->cartContainsProduct($cart, $feeProductId)) {
            $result = $cart->updateQty(
                1,
                $feeProductId,
                null,
                false,
                'up'
            );

            if (!$result) {
                $this->errors[] = $this->module->l('Unable to add the COD fee.');
                return;
            }
        }

        // 加入手续费后重新加载购物车,避免使用旧的价格缓存或对象状态。
        $cart = new Cart((int) $cart->id);
        $amount = (float) $cart->getOrderTotal(true, Cart::BOTH);

        $orderState = (int) Configuration::get('PS_OS_PREPARATION');

        $this->module->validateOrder(
            (int) $cart->id,
            $orderState,
            $amount,
            $this->context->currency->iso_code,
            $this->module->l('Cash on delivery'),
            [],
            null,
            false,
            (string) $customer->secure_key
        );

        Tools::redirect(
            'index.php?controller=order-confirmation'
            . '&id_cart=' . (int) $cart->id
            . '&id_module=' . (int) $this->module->id
            . '&id_order=' . (int) $this->module->currentOrder
            . '&key=' . urlencode($customer->secure_key)
        );
    }

    private function cartContainsProduct(Cart $cart, int $productId): bool
    {
        foreach ($cart->getProducts() as $product) {
            if ((int) $product['id_product'] === $productId) {
                return true;
            }
        }

        return false;
    }
}

这段逻辑需要注意两点:

  • 防止用户重复提交,导致手续费商品被多次加入;
  • 加入手续费后重新计算总金额,再调用 validateOrder()。

PrestaShop 版本或项目定制可能会改变 validateOrder() 的参数签名。实现时应以当前项目中 PaymentModule::validateOrder() 的实际定义为准。

根据订单金额计算手续费

如果手续费是固定金额,可以直接为服务商品设置固定价格。

如果按比例收取,例如订单金额的 2%,计算时要先排除手续费商品本身,避免循环累加:

private function calculateCodFee(Cart $cart): float
{
    $rate = 0.02;
    $minimumFee = 5.00;

    $baseAmount = 0.0;
    $feeProductId = (int) Configuration::get('MY_COD_FEE_PRODUCT_ID');

    foreach ($cart->getProducts() as $product) {
        if ((int) $product['id_product'] === $feeProductId) {
            continue;
        }

        $baseAmount += (float) $product['total_wt'];
    }

    return max($minimumFee, round($baseAmount * $rate, 2));
}

通常不应动态修改服务商品的公共价格,否则可能影响其他购物车和多店铺环境。可以考虑以下方式:

  • 为当前购物车创建 specific price;
  • 使用购物车规则处理固定优惠或附加业务逻辑;
  • 在自定义模块中实现完整的订单费用明细;
  • 规则较复杂时,采用与当前 PrestaShop 版本兼容的成熟 COD 手续费模块。

使用 specific price 时,要将其限制在当前购物车、币种、店铺和客户范围内,并在购物车废弃或订单完成后清理相关记录。

在支付方式上显示实际金额

客户选择 COD 时,应当看到手续费和最终应付金额,不能等到订单确认后才显示价格变化。

在支持 PaymentOption 的版本中,可以在 COD 支付选项中添加说明:

use PrestaShop\PrestaShop\Core\Payment\PaymentOption;

public function hookPaymentOptions($params)
{
    if (!$this->active) {
        return [];
    }

    $fee = $this->calculateCodFee($this->context->cart);

    $option = new PaymentOption();
    $option
        ->setCallToActionText($this->l('Pay by cash on delivery'))
        ->setAction($this->context->link->getModuleLink(
            $this->name,
            'validation',
            [],
            true
        ))
        ->setAdditionalInformation(
            sprintf(
                $this->l('A cash on delivery fee of %s will be added.'),
                Tools::displayPrice($fee, $this->context->currency)
            )
        );

    return [$option];
}

这段代码只用于展示金额。创建订单之前,服务端仍要重新计算手续费,不能使用浏览器提交的金额。

如果希望客户选中支付方式后,结账页立即更新订单汇总,就需要配合当前主题或 one-page checkout 的 JavaScript/Ajax 流程。相关事件和刷新机制在各版本中并不统一,需要针对实际使用的结账组件进行适配。

切换支付方式时必须移除手续费

如果提前将手续费商品加入购物车,客户改用银行卡、PayPal 等其他支付方式时,必须将其删除:

private function removeCodFee(Cart $cart): bool
{
    $feeProductId = (int) Configuration::get('MY_COD_FEE_PRODUCT_ID');

    foreach ($cart->getProducts() as $product) {
        if ((int) $product['id_product'] !== $feeProductId) {
            continue;
        }

        return (bool) $cart->deleteProduct(
            $feeProductId,
            (int) $product['id_product_attribute'],
            (int) $product['id_customization'],
            (int) $product['id_address_delivery']
        );
    }

    return true;
}

不同版本的 deleteProduct() 参数可能有差异,需根据当前项目中的方法签名调整。

为了减少购物车中的费用残留,可以等到客户最终确认 COD 时再加入手续费商品。这样做的代价是需要在结账页通过前端预览金额,确保客户提交订单前已经看到这笔费用。

还需要处理的边界情况

上线前至少要验证以下场景:

  • 重复点击提交按钮不会重复收取手续费;
  • 切换支付方式后可以删除手续费;
  • 返回购物车修改商品后会重新计算手续费;
  • 固定金额和比例金额使用正确的舍入规则;
  • 含税价、未税价与发票税额一致;
  • 多币种订单按正确币种计算金额;
  • 多店铺环境读取正确的配置和手续费商品;
  • 分包配送或使用多个配送地址时不会重复添加手续费;
  • COD 在不可用的国家、承运商或订单金额范围内受到正确限制;
  • 后台退款、取消订单和重新下单时能合理处理手续费;
  • 第三方结账、ERP、发票和统计模块可以识别手续费商品。

如果项目已经有较多改动,可以复制并重构现有 COD 模块,同时保留原模块作为回退方案。不要直接在生产环境修改。先在测试环境完成普通下单、更换支付方式、刷新页面、重复提交、退款和多币种测试。

备注:内容仅供参考。