LikeShop 订单分期支付二开:首付 + 尾款分期账单与逾期自动处理

一、原生支付模型装不下“时间维度”

LikeShop 的原生支付是一次性结清模型。ls_order_pay_status 只有“未支付/已支付”两态,支付成功后订单直接进入待发货,库存扣减、积分发放、分销绑定全部在 OrderLogic::paySuccess()中一次性完成。这套模型假设“钱一次付完、货一次发出”,没有为“钱分几次付、订单跨时间履约”留出任何中间态。

分期支付和预售、混合支付都不同。预售是“定金锁资格、尾款定成交”,混合支付是“同一时刻多渠道凑钱”,而分期支付是首付即成交、尾款按账期后续支付。用户支付首付后,订单已经成立、库存已经锁定、商家开始备货,但尾款是一笔独立的、有到期日的债务;这笔债务需要独立的账单来承载,需要到期提醒、逾期判定、逾期处置,而原生订单表没有任何字段能表达“这笔订单还有一期尾款没付、到期日是几号”。

更关键的是逾期处置。原生订单的超时关闭只处理“未支付订单”,而分期订单的尾款逾期时,订单本身已经支付过首付、可能已经发货。逾期取消不是简单关单,而是“关单 + 回收库存(未发货时)+ 处理已付首付”,这个链路的复杂度远超原生超时关单。

二开的核心策略是:首付走原生支付通道,尾款走独立的分期账单体系,订单状态在原生基础上增加“分期中”的中间态,逾期处置通过定时任务驱动,不修改 OrderLogic::paySuccess()的核心逻辑。

二、分期账单数据模型

2.1 分期方案配置表

分期方案是商品或订单维度的配置,定义首付比例和尾款账期:

sql
CREATE TABLE `ls_installment_plan` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `plan_name` varchar(50) NOT NULL COMMENT '方案名称',
  `scope_type` tinyint NOT NULL DEFAULT 1 COMMENT '适用范围:1-全场通用,2-指定商品,3-指定分类',
  `scope_ids` varchar(500) NOT NULL DEFAULT '' COMMENT '适用对象ID,逗号分隔',
  `down_payment_rate` decimal(5,2) NOT NULL DEFAULT 30.00 COMMENT '首付比例(百分比)',
  `period_count` tinyint NOT NULL DEFAULT 1 COMMENT '尾款期数,当前版本支持1期(首付+尾款)',
  `balance_days` int UNSIGNED NOT NULL DEFAULT 7 COMMENT '尾款账期天数(首付支付后N天内付清)',
  `grace_days` int UNSIGNED NOT NULL DEFAULT 3 COMMENT '逾期宽限天数',
  `late_fee_rate` decimal(5,2) NOT NULL DEFAULT 0.00 COMMENT '逾期违约金比例(按尾款金额百分比)',
  `cancel_refund_rate` decimal(5,2) NOT NULL DEFAULT 100.00 COMMENT '逾期取消时首付退还比例(百分比)',
  `min_order_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '最低订单金额门槛',
  `is_enable` tinyint NOT NULL DEFAULT 1 COMMENT '是否启用',
  `create_time` int UNSIGNED NOT NULL DEFAULT 0,
  `update_time` int UNSIGNED NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `idx_scope` (`scope_type`, `is_enable`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期方案配置表';

down_payment_rate 控制首付比例(如 30% 表示首付三成),balance_days 控制尾款账期(首付后 7天内付清),grace_days 是逾期宽限期,cancel_refund_rate 决定逾期取消时首付退多少(如 100% 全退,或 80% 扣 20% 违约金)。

2.2 分期账单表

每笔分期订单生成一条分期账单主记录,尾款作为账单明细:

sql
CREATE TABLE `ls_installment_bill` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `bill_sn` varchar(32) NOT NULL COMMENT '分期账单号',
  `order_id` int UNSIGNED NOT NULL COMMENT '订单ID',
  `order_sn` varchar(32) NOT NULL COMMENT '订单号',
  `user_id` int UNSIGNED NOT NULL COMMENT '用户ID',
  `plan_id` int UNSIGNED NOT NULL COMMENT '分期方案ID',
  `total_amount` decimal(10,2) NOT NULL COMMENT '订单总额',
  `down_payment_amount` decimal(10,2) NOT NULL COMMENT '首付金额',
  `balance_amount` decimal(10,2) NOT NULL COMMENT '尾款金额',
  `down_payment_status` tinyint NOT NULL DEFAULT 0 COMMENT '首付状态:0-待支付,1-已支付',
  `down_payment_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '首付支付时间',
  `balance_due_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款到期时间',
  `balance_status` tinyint NOT NULL DEFAULT 0 COMMENT '尾款状态:0-待支付,1-已支付,2-已逾期,3-已取消',
  `balance_pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '尾款支付时间',
  `late_fee` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '已产生的逾期违约金',
  `status` tinyint NOT NULL DEFAULT 0 COMMENT '账单总状态:0-待首付,1-分期中,2-已结清,3-已逾期,4-已取消',
 `remind_count` tinyint NOT NULL DEFAULT 0 COMMENT '已提醒次数',
 `last_remind_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '最后提醒时间',
 `stock_locked` tinyint NOT NULL DEFAULT 0 COMMENT '库存是否已锁 定:0-否,1-是',
 `create_time` int UNSIGNED NOT NULL DEFAULT 0,
 `update_time` int UNSIGNED NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_bill_sn` (`bill_sn`),
 UNIQUE KEY `uk_order_id` (`order_id`),
 KEY `idx_user_status` (`user_id`, `status`),
 KEY `idx_balance_due` (`balance_status`, `balance_due_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期账单表';

uk_order_id 唯一约束保证一笔订单只有一条分期账单。balance_due_time 是逾期判定的核心字段,stock_locked 记录库存锁定状态,用于逾期取消时的库存回收。

2.3 分期支付流水表

首付和尾款分别走支付通道,每次支付产生一条流水:

sql
CREATE TABLE `ls_installment_pay_flow` (
 `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
 `flow_sn` varchar(32) NOT NULL COMMENT '支付流水号',
 `bill_id` int UNSIGNED NOT NULL COMMENT '分期账单ID',
 `order_sn` varchar(32) NOT NULL COMMENT '订单号',
 `pay_stage` tinyint NOT NULL DEFAULT 1 COMMENT '支付阶段:1-首付,2-尾款',
 `pay_type` varchar(20) NOT NULL COMMENT '支付方式:balance/wechat/alipay',
 `pay_amount` decimal(10,2) NOT NULL COMMENT '支付金额',
 `pay_status` tinyint NOT NULL DEFAULT 0 COMMENT '状态:0-待支付,1-支付中,2-成功,3-失败,4-已关闭',
 `channel_trade_no` varchar(64) NOT NULL DEFAULT '' COMMENT '渠道交易号',
 `pay_time` int UNSIGNED NOT NULL DEFAULT 0 COMMENT '支付成功时间',
 `create_time` int UNSIGNED NOT NULL DEFAULT 0,
 PRIMARY KEY (`id`),
 UNIQUE KEY `uk_flow_sn` (`flow_sn`),
 UNIQUE KEY `uk_channel_trade` (`pay_type`, `channel_trade_no`),
 KEY `idx_bill_stage` (`bill_id`, `pay_stage`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='分期支付流水表';
三、首付支付与订单状态编排

3.1 下单时选择分期

用户在下单页选择“分期支付”时,系统根据分期方案计算首付和尾款金额,创建分期账单而首付支付流水;订单本身照常创建,但 pay_status 保持 0,等待首付支付:

php
public function createInstallmentOrder($orderId, $planId)
{
$order = OrderModel::find($orderId);
if ($order['order_amount'] < $plan['min_order_amount']) {
    throw new \Exception('订单金额未达到分期门槛');
}

$plan = InstallmentPlanModel::find($planId);
$downPayment = round($order['order_amount'] * $plan['down_payment_rate'] / 100, 2);
$balance = round($order['order_amount'] - $downPayment, 2);

Db::startTrans();
try {
    $billId = InstallmentBillModel::create([
        'bill_sn'            => generateSn('IB'),
        'order_id'           => $orderId,
        'order_sn'           => $order['order_sn'],
        'user_id'            => $order['user_id'],
        'plan_id'            => $planId,
        'total_amount'       => $order['order_amount'],
        'down_payment_amount'=> $downPayment,
        'balance_amount'     => $balance,
        'status'             => 0, // 待首付
        'create_time'        => time(),
    ])->id;

    // 创建首付支付流水
    InstallmentPayFlowModel::create([
        'flow_sn'    => generateSn('IF'),
        'bill_id'    => $billId,
        'order_sn'   => $order['order_sn'],
        'pay_stage'  => 1,
        'pay_type'   => 'wechat',
        'pay_amount' => $downPayment,
        'pay_status' => 0,
        'create_time'=> time(),
    ]);

    Db::commit();
    return ['bill_id' => $billId, 'down_payment' => $downPayment];
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
  }
}

3.2 首付支付成功:不推订单,只锁库存

首付支付回调到底时,不能调用 OrderLogic::paySuccess();原生 paySuccess 会把订单置为已支付并进入待发货,但分期订单的尾款还没付,不能发货。

首付成功后的正确编排是:更新分期账单状态 → 锁定库存 → 计算尾款到期时间 → 通知用户;订单的 pay_status 仍然保持 0,但增加一个 installment_status 字段标记“分期中”:

 sql
 ALTER TABLE `ls_order`
   ADD COLUMN `installment_status` tinyint NOT NULL DEFAULT 0 
     COMMENT '分期状态:0-非分期,1-待首付,2-分期中,3-已结清,4-已逾期取消' AFTER `pay_status`;
  
 php
 public function onDownPaymentSuccess($billId)
{
Db::startTrans();
try {
    $bill = InstallmentBillModel::lock(true)->find($billId);
    if ($bill['down_payment_status'] == 1) {
        Db::commit();
        return; // 幂等
    }

    $plan = InstallmentPlanModel::find($bill['plan_id']);
    $now = time();
    $dueTime = $now + $plan['balance_days'] * 86400;

    // 更新账单
    InstallmentBillModel::where('id', $billId)->update([
        'down_payment_status' => 1,
        'down_payment_time'   => $now,
        'balance_due_time'    => $dueTime,
        'status'              => 1, // 分期中
        'stock_locked'        => 1,
        'update_time'         => $now,
    ]);

    // 锁定库存(首付成功即锁定,防止尾款期间超卖)
    $this->lockOrderStock($bill['order_id']);

    // 更新订单分期状态
    OrderModel::where('id', $bill['order_id'])->update([
        'installment_status' => 2, // 分期中
    ]);

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
  }
}

库存为什么在首付时锁定而不是尾款时锁定:首付后订单已经成立,商家开始备货,如果等尾款付完才锁库存,期间商品可能被其他订单买走,尾款付了却发不了货;首付即锁库存是分期支付的基本前提。锁定方式复用原生的库存扣减逻辑(stock 字段扣减或独立的锁定库存字段),并在 stock_locked 中标记,用于逾期取消时回退。

四、尾款账单支付

4.1 尾款支付入口

用户可以在“我的分期账单”中查看待付尾款,点击支付时生成尾款支付流水;尾款支付金额 = balance_amount + late_fee (如有逾期违约金):

php
public function payBalance($billId)
{
$bill = InstallmentBillModel::find($billId);
if ($bill['balance_status'] != 0 && $bill['balance_status'] != 2) {
    throw new \Exception('尾款状态不可支付');
}

$payAmount = $bill['balance_amount'] + $bill['late_fee'];

// 创建尾款支付流水
$flowId = InstallmentPayFlowModel::create([
    'flow_sn'    => generateSn('IF'),
    'bill_id'    => $billId,
    'order_sn'   => $bill['order_sn'],
    'pay_stage'  => 2,
    'pay_type'   => 'wechat',
    'pay_amount' => $payAmount,
    'pay_status' => 0,
    'create_time'=> time(),
])->id;

// 发起支付(out_trade_no 带流水标识)
$payParams = PayServer::createOrder([
    'order_sn' => $bill['order_sn'] . '_B_' . $flowId,
    'amount'   => $payAmount,
    'pay_type' => 'wechat',
]);

 return $payParams;
}

4.2 尾款支付成功:推进订单

尾款支付回调成功后,分期账单结清,此时才调用原生的 Orderlogic::paySuccess()推进订单进入正常流程:

php
public function onBalanceSuccess($flowId)
{
Db::startTrans();
try {
    $flow = InstallmentPayFlowModel::lock(true)->find($flowId);
    if ($flow['pay_status'] == 2) {
        Db::commit();
        return;
    }

    InstallmentPayFlowModel::where('id', $flowId)->update([
        'pay_status'      => 2,
        'channel_trade_no'=> $channelTradeNo,
        'pay_time'        => time(),
    ]);

    $bill = InstallmentBillModel::lock(true)->find($flow['bill_id']);
    $now = time();

    InstallmentBillModel::where('id', $bill['id'])->update([
        'balance_status'   => 1,
        'balance_pay_time' => $now,
        'status'           => 2, // 已结清
        'update_time'      => $now,
    ]);

    OrderModel::where('id', $bill['order_id'])->update([
        'installment_status' => 3, // 已结清
    ]);

    // 尾款结清,推进订单进入待发货
    OrderLogic::paySuccess($bill['order_id']);

    Db::commit();
} catch (\Exception $e) {
    Db::rollback();
    throw $e;
  }
}

关键顺序:先更新账单和流水,再调用 paySuccess,最后提交事务;如果 paySuccess 内部抛异常,整个事务回滚,流水状态回到“待支付”,用户可重新支付。反过来先提交账单再调 paySuccess,一旦 paySuccess 失败,账单已结清但订单未推进,状态不一致。

五、到期提醒与逾期自动处理

5.1 多节点提醒

尾款到期提醒设置三个提醒节点:到期前 3天、到期前 1 天,到期当天;提醒通过站内消息 + 模版消息双通道发送,remind_count 记录已提醒次数,防止重复轰炸:

php
public function remindBalance()
{
$now = time();

// 到期前3天
$this->sendRemind($now + 3 * 86400, $now + 4 * 86400, '尾款将在3天后到期');
// 到期前1天
$this->sendRemind($now + 86400, $now + 2 * 86400, '尾款将在明天到期');
// 到期当天
$this->sendRemind($now, $now + 86400, '尾款今日到期,请尽快支付');
}

private function sendRemind($start, $end, $tip)
{
$bills = InstallmentBillModel::where('status', 1)
    ->where('balance_status', 0)
    ->where('balance_due_time', '>=', $start)
    ->where('balance_due_time', '<', $end)
    ->select();

foreach ($bills as $bill) {
    MessageService::send($bill['user_id'], '分期尾款提醒',
        "{$tip},应付尾款 {$bill['balance_amount']} 元。");
    InstallmentBillModel::where('id', $bill['id'])->update([
        'remind_count'    => $bill['remind_count'] + 1,
        'last_remind_time'=> time(),
       ]);
    }
}

5.2 逾期判定与违约金

尾款超过 balance_due_time 后进入逾期状态,balance_status 置 2(已逾期)。逾期后每天按 late_fee_rate 计算违约金,但违约金总额设上限(通常不超过尾款金额的 30%),防止无限累积:

php
public function markOverdue()
{
$now = time();
$overdueBills = InstallmentBillModel::where('status', 1)
    ->where('balance_status', 0)
    ->where('balance_due_time', '<', $now)
    ->select();

foreach ($overdueBills as $bill) {
    $plan = InstallmentPlanModel::find($bill['plan_id']);
    $overdueDays = floor(($now - $bill['balance_due_time']) / 86400);

    // 违约金 = 尾款 × 费率 × 逾期天数,上限为尾款30%
    $lateFee = min(
        round($bill['balance_amount'] * $plan['late_fee_rate'] / 100 * $overdueDays, 2),
        round($bill['balance_amount'] * 0.3, 2)
    );

    InstallmentBillModel::where('id', $bill['id'])->update([
        'balance_status' => 2,
        'late_fee'       => $lateFee,
        'status'         => 3,
        'update_time'    => $now,
      ]);
    }
 }

5.3 宽限期后自动取消

逾期超过 grace_days 宽限期后,自动取消订单并回收库存;这是分期支付最重的处置动作,必须严格幂等:

 php
 public function cancelOverdueOrders()
{
$now = time();
$bills = InstallmentBillModel::where('status', 3) // 已逾期
    ->where('balance_status', 2)
    ->where('balance_due_time', '<', $now - 86400)
    ->limit(50)->select();

foreach ($bills as $bill) {
    Db::startTrans();
    try {
        // 幂等:条件更新抢占
        $affected = InstallmentBillModel::where('id', $bill['id'])
            ->where('status', 3)
            ->where('balance_status', 2)
            ->update(['status' => 4, 'balance_status' => 3, 'update_time' => $now]);

        if (!$affected) {
            Db::rollback();
            continue;
        }

        $plan = InstallmentPlanModel::find($bill['plan_id']);

        // 1. 回收库存(仅当库存已锁定且订单未发货)
        if ($bill['stock_locked'] == 1) {
            $this->releaseOrderStock($bill['order_id']);
        }

        // 2. 处理首付:按方案配置退还比例
        $refundAmount = round($bill['down_payment_amount'] * $plan['cancel_refund_rate'] / 100, 2);
        if ($refundAmount > 0) {
            RefundService::refundDownPayment(
                $bill['order_id'], $refundAmount, '分期尾款逾期取消,退还首付'
            );
        }

        // 3. 关闭订单
        OrderModel::where('id', $bill['order_id'])->update([
            'order_status'       => 4, // 已关闭
            'installment_status' => 4, // 已逾期取消
        ]);

        // 4. 通知用户
        MessageService::send($bill['user_id'], '分期订单已取消',
            "尾款逾期未付,订单已取消。首付退还 {$refundAmount} 元,已原路退回。");

        Db::commit();
    } catch (\Exception $e) {
        Db::rollback();
        Log::error("分期逾期取消失败 bill_id={$bill['id']}: " . $e->getMessage());
     }
  } 
}

库存回收的边界:如果订单在尾款逾期前已经发货(部分商家可能在首付后就发货),库存不能回收,只能走售后或追偿流程;因此库存回收前必须校验订单的发货状态,已发货的订单跳过库存回收,只关闭账单。

首付退还的边界:cancel_refund_rate 设为 100% 表示首付全退(用户友好),设为 80% 表示扣 20% 违约金;违约金和逾期违约金(late_fee)是两回事——late_fee 是尾款逾期产生的、随尾款一起支付的;cancel_refund_rate 是取消时从首付中扣除的;两者不能重复计算。

六、退款与账务处理

6.1 分期订单的退款拆分

分期订单退款时,按已支付阶段拆分退还:首付已付、尾款未付的,只退首付;首付和尾款都已付的,按订单退款规则全额退;退款必须按原支付流水原路退回,不能混通道:

php
public function refundInstallment($orderId, $refundAmount)
{
$bill = InstallmentBillModel::where('order_id', $orderId)->find();
$paidFlows = InstallmentPayFlowModel::where('bill_id', $bill['id'])
    ->where('pay_status', 2)->select();

$totalPaid = array_sum(array_column($paidFlows, 'pay_amount'));
if (bccomp($refundAmount, $totalPaid, 2) > 0) {
    throw new \Exception('退款金额超过已支付金额');
}

$remaining = $refundAmount;
foreach ($paidFlows as $index => $flow) {
    $split = ($index == count($paidFlows) - 1)
        ? $remaining
        : bcmul($refundAmount, bcdiv($flow['pay_amount'], $totalPaid, 6), 2);

    if ($flow['pay_type'] == 'balance') {
        $this->refundToBalance($flow, $split);
    } else {
        $this->refundToChannel($flow, $split);
    }
    $remaining = bcsub($remaining, $split, 2);
  }
}

6.2 账务对账的三本账

分期支付的账务对账需要三本账闭合:分期账单账(应收首付 + 应收尾款)、支付流水账(实收首付 + 实收尾款)、渠道账单(微信/支付宝实际到账);对账关系为:应收总额 = 实收总额 + 未收尾款 + 违约金,实收总额必须与渠道账单逐笔匹配。

逾期取消的订单,首付退还部分要从渠道账单中扣回,late_fee 和违约金如果实际收取了,也要在账务中单独列示,不能混入商品收入。

七、后台配置与开发注意事项

7.1 后台配置清单

配置项类型说明
分期支付开关开关是否启用分期支付
首付比例百分比默认 30%,范围 10%–90%
尾款账期天数数字首付后 N 天内付清,默认 7 天
逾期宽限天数数字逾期后 N 天内仍可支付,默认 3 天
逾期违约金比例百分比/天按尾款金额每日计算,设上限
违约金上限百分比违约金总额不超过尾款的百分比
取消时首付退还比例百分比默认 100%,可设 80% 扣违约金
最低订单金额金额达到此金额才允许分期
提醒节点多选到期前 3 天 / 1 天 / 当天

7.2 开发注意事项

首付成功不能调用 OrderLogic::paySuccess()。分期订单的首付只是“锁定交易”,订单真正进入履约要等尾款结清。通过 instllment_status 字段做逻辑隔离,原生的 pay_status 和 order_status 在首付阶段保持不变。

库存锁定必须在首付时完成。首付后商家开始备货,库存若不在首付时锁定,尾款期间会被其他订单买走;锁定时复用原生库存扣减逻辑,stock_locked 标记用于逾期取消时的精准回退,避免重复扣减或重复回退。

逾期取消的幂等靠条件更新抢占。定时任务可能多实例并发执行,同一账单被两个实例同时处理会导致库存重复回收、首付重复退款。用 WHERE status = 3 ADN balance_status = 2 的条件更新抢占,影响行数为 0 即跳过。

违约金和首付退还比例不能重复扣。late_fee 是尾款逾期产生的、随尾款支付;cancel_refund_rate 是取消时从首付中扣的;如果两者都设了,用户实际损失是“已付 late_fee + 首付被扣部分”,后台配置时需要明确提示这两个参数的叠加关系。

兼容性。新增表独立 ls_order,installment_status 字段默认值为 0,存量订单不受影响。分期支付作为下单页的可选支付方式,不改变原生一次性支付和混合支付的链路。逾期定时任务作为定时任务的新增子任务接入,与订单超时关闭、退款扫描等任务错开执行时间。

0
0
0
0
评论
未登录
暂无评论