PAYATHON 2026

Laravel invoice 和 Payment 页面如何计算 Balance?

支付老李

结论

累计余额要按时间正序计算,计算完成后再按 created_at DESC 倒序展示。

计算规则如下:

Invoice:balance += amount
Payment:balance -= amount

不能在当前的倒序循环中直接执行 $balance += $total。这种写法没有区分收款和账单的正负方向,还会从最新记录开始向历史记录反向累计。

推荐做法:在控制器中计算余额

先加载每笔 billing 的明细,按时间从旧到新计算累计余额:

$billings = $student->billings()
    ->with('billingRecords')
    ->get();

$balance = 0;

$billings = $billings
    ->sortBy('created_at')
    ->map(function ($billing) use (&$balance) {
        $total = $billing->billingRecords->sum('amount');

        if ($billing->type === 'Payment') {
            $balance -= $total;
        } elseif ($billing->type === 'Invoice') {
            $balance += $total;
        }

        $billing->total = $total;
        $billing->running_balance = $balance;

        return $billing;
    })
    ->sortByDesc('created_at')
    ->values();

return view('students.billings', compact('student', 'billings'));

整个过程分为两步:

  1. 使用 sortBy('created_at') 按时间从旧到新计算余额。
  2. 计算完成后使用 sortByDesc('created_at'),按页面需要倒序展示。

Blade 只负责输出已经算好的结果:

<table>
    <thead>
        <tr>
            <th>Date</th>
            <th>Type</th>
            <th>Amount</th>
            <th>Balance</th>
        </tr>
    </thead>

    <tbody>
        @foreach ($billings as $billing)
            <tr id="{{ $billing->id }}">
                <td>
                    {{ optional($billing->created_at)->format('M d, Y') }}
                    {{ optional($billing->created_at)->format('h:i A') }}
                </td>

                <td>{{ $billing->type }}</td>

                <td>
                    @if ($billing->type === 'Payment')
                        -${{ number_format($billing->total, 2) }}
                    @elseif ($billing->type === 'Invoice')
                        ${{ number_format($billing->total, 2) }}
                    @endif
                </td>

                <td>
                    ${{ number_format($billing->running_balance, 2) }}
                </td>
            </tr>
        @endforeach
    </tbody>
</table>

假设最早的一笔记录是金额为 $30 的 Payment,下一笔是金额为 $100 的 Invoice,计算过程如下:

0 - 30 = -30
-30 + 100 = 70

所以,即使页面按倒序显示,这两笔记录对应的余额仍然分别是 $-30 和 $70。

如果必须在 Blade 中计算

也可以先按时间正序遍历集合,将每笔余额按 billing ID 保存,再按原来的倒序输出:

@php
    $balance = 0;
    $balances = [];
    $totals = [];

    foreach ($student->billings->sortBy('created_at') as $billing) {
        $total = $billing->billingRecords->sum('amount');
        $totals[$billing->id] = $total;

        if ($billing->type === 'Payment') {
            $balance -= $total;
        } elseif ($billing->type === 'Invoice') {
            $balance += $total;
        }

        $balances[$billing->id] = $balance;
    }
@endphp

@foreach ($student->billings->sortByDesc('created_at') as $billing)
    @php
        $total = $totals[$billing->id];
    @endphp

    <tr id="{{ $billing->id }}">
        <td>
            {{ optional($billing->created_at)->format('M d, Y') }}
            {{ optional($billing->created_at)->format('h:i A') }}
        </td>

        <td>{{ $billing->type }}</td>

        <td>
            @if ($billing->type === 'Payment')
                -${{ number_format($total, 2) }}
            @else
                ${{ number_format($total, 2) }}
            @endif
        </td>

        <td>
            ${{ number_format($balances[$billing->id], 2) }}
        </td>
    </tr>
@endforeach

这种写法可以正常工作,不过把业务计算放在控制器或服务类中,通常更便于测试和维护。

注意事项

预加载 billingRecords 可以避免在循环中产生 N+1 查询:

$student->load('billings.billingRecords');

如果多条记录的 created_at 完全相同,还需要增加一个稳定的第二排序条件,否则累计顺序可能不固定:

public function billings()
{
    return $this->hasMany(Billing::class)
        ->orderByDesc('created_at')
        ->orderByDesc('id');
}

计算余额时也要使用对应的正序规则:

$student->billings()
    ->with('billingRecords')
    ->orderBy('created_at')
    ->orderBy('id')
    ->get();

金额字段不宜使用二进制浮点数进行财务计算。数据库字段可以使用 DECIMAL,也可以统一按最小货币单位(例如“分”)保存和计算,以免累计时出现精度误差。

备注:内容仅供参考。