如何为 Prestashop 的 COD payment method 添加手续费?
结论
不要直接修改 PrestaShop 核心文件,也不要只在 COD 模块调用 validateOrder() 时简单地把手续费加到订单金额上。
更合适的做法是把 COD 手续费放在独立支付模块中,并让它进入购物车和订单明细。对于经过多人修改、版本及 override 情况不清楚的项目,可以采用以下方案:
- 创建一个“COD 手续费”服务商品。
- 客户确认使用货到付款时,将该商品加入购物车。
- 根据更新后的购物车总额创建订单。
- 客户更换支付方式、返回购物车或支付失败时,移除该商品。
这样可以让商品合计、税费、发票、退款和后台订单金额保持一致。
为什么不应直接改订单总金额
有些实现会在 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 模块,同时保留原模块作为回退方案。不要直接在生产环境修改。先在测试环境完成普通下单、更换支付方式、刷新页面、重复提交、退款和多币种测试。
备注:内容仅供参考。
