PAYATHON 2026

Laravel 9 API 如何根据付款和 due_on 更新交易状态

支付阿杰

结论

每次新增、修改或删除付款记录后,都应重新汇总这笔交易的付款金额,并按以下顺序更新状态:

  1. paid_amount >= transaction.amount:paid
  2. 尚未付清,且今天晚于 due_on:overdue
  3. 尚未付清,且未超过 due_on:outstanding

判断是否超过到期日时,要比较日期。due_on 是 date 类型,因此到期日当天通常仍为 outstanding,第二天才变为 overdue。

在模型中集中处理状态

先为日期和金额字段添加类型转换:

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;

class Transaction extends Model
{
    protected $fillable = [
        'payer_id',
        'category_id',
        'subcategory_id',
        'amount',
        'status',
        'due_on',
    ];

    protected $casts = [
        'amount' => 'decimal:2',
        'due_on' => 'date',
    ];

    public function payments(): HasMany
    {
        return $this->hasMany(Payment::class);
    }

    public function refreshStatus(?int $buyerId = null): string
    {
        $payments = $this->payments();

        if ($buyerId !== null) {
            $payments->where('buyer_id', $buyerId);
        }

        $paidAmount = (string) $payments->sum('amount');

        if (bccomp($paidAmount, (string) $this->amount, 2) >= 0) {
            $status = 'paid';
        } elseif ($this->due_on->isBefore(today())) {
            $status = 'overdue';
        } else {
            $status = 'outstanding';
        }

        if ($this->status !== $status) {
            $this->update(['status' => $status]);
        }

        return $status;
    }
}

这里使用 bccomp(),可以避免直接比较浮点金额。如果运行环境没有安装 BCMath 扩展,可以把金额统一换算成“分”,再按整数比较:

$paidInCents = (int) round(((float) $paidAmount) * 100);
$totalInCents = (int) round(((float) $this->amount) * 100);

if ($paidInCents >= $totalInCents) {
    $status = 'paid';
}

财务系统最好直接用最小货币单位存储整数,或者始终使用精确的 decimal 运算。

在付款 API 中更新状态

创建付款和更新交易状态应放在同一个数据库事务中,防止付款已经保存,状态却未能同步更新:

use App\Models\Payment;
use App\Models\Transaction;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;

public function store(Request $request, Transaction $transaction): JsonResponse
{
    $validated = $request->validate([
        'buyer_id' => ['required', 'integer', 'exists:users,id'],
        'amount' => ['required', 'numeric', 'gt:0'],
        'payment_method' => ['required', 'string', 'max:255'],
        'paid_at' => ['required', 'date'],
        'details' => ['nullable', 'string'],
    ]);

    $result = DB::transaction(function () use ($transaction, $validated) {
        $lockedTransaction = Transaction::query()
            ->lockForUpdate()
            ->findOrFail($transaction->id);

        $payment = $lockedTransaction->payments()->create($validated);

        // 如果一笔交易只统计某个 buyer 的付款,可以传入 buyer_id。
        // 如果所有付款都应计入交易总额,则不要传参数。
        $lockedTransaction->refreshStatus();

        return [
            'payment' => $payment,
            'transaction' => $lockedTransaction->fresh(),
        ];
    });

    return response()->json($result, 201);
}

lockForUpdate() 可以防止同一笔交易同时收到多笔付款时出现状态覆盖,而且必须放在 DB::transaction() 内使用。

修改或删除付款后,同样要重新计算状态:

public function destroy(Payment $payment): JsonResponse
{
    DB::transaction(function () use ($payment) {
        $transactionId = $payment->transaction_id;

        $transaction = Transaction::query()
            ->lockForUpdate()
            ->findOrFail($transactionId);

        $payment->delete();

        $transaction->refreshStatus();
    });

    return response()->json(null, 204);
}

使用 Eloquent 的 withSum()

如果只想查询交易及其已付总额,可以使用 withSum():

$transaction = Transaction::query()
    ->withSum('payments as paid_amount', 'amount')
    ->findOrFail($transactionId);

$paidAmount = (string) ($transaction->paid_amount ?? '0.00');

if (bccomp($paidAmount, (string) $transaction->amount, 2) >= 0) {
    $status = 'paid';
} elseif ($transaction->due_on->isBefore(today())) {
    $status = 'overdue';
} else {
    $status = 'outstanding';
}

$transaction->update(['status' => $status]);

如果只统计某个买家的付款,可以这样写:

$transaction = Transaction::query()
    ->withSum([
        'payments as paid_amount' => function ($query) use ($buyerId) {
            $query->where('buyer_id', $buyerId);
        },
    ], 'amount')
    ->findOrFail($transactionId);

这里要先确定具体的业务含义。transaction.status 表示整笔交易的状态,通常应该汇总这笔交易的所有付款。若只汇总某个 buyer_id,其他买家的付款不会计入总额。

使用 Query Builder 实现 CASE

如果想在 SQL 中完成判断,可以结合 leftJoin()、SUM() 和 CASE:

use Illuminate\Support\Facades\DB;

$result = DB::table('transactions as t')
    ->leftJoin('payments as p', 'p.transaction_id', '=', 't.id')
    ->where('t.id', $transactionId)
    ->select([
        't.id',
        't.amount',
        't.due_on',
        DB::raw('COALESCE(SUM(p.amount), 0) AS paid_amount'),
        DB::raw("
            CASE
                WHEN COALESCE(SUM(p.amount), 0) >= t.amount
                    THEN 'paid'
                WHEN t.due_on < CURRENT_DATE
                    THEN 'overdue'
                ELSE 'outstanding'
            END AS calculated_status
        "),
    ])
    ->groupBy('t.id', 't.amount', 't.due_on')
    ->first();

if (!$result) {
    abort(404, 'Transaction not found.');
}

DB::table('transactions')
    ->where('id', $result->id)
    ->update([
        'status' => $result->calculated_status,
        'updated_at' => now(),
    ]);

这里应使用 leftJoin(),不能使用普通的 join()。即使没有任何付款记录,交易也要被判断为 outstanding 或 overdue。如果使用内连接,这类交易不会出现在查询结果中。

需要按买家过滤时,应把条件写进连接:

->leftJoin('payments as p', function ($join) use ($buyerId) {
    $join->on('p.transaction_id', '=', 't.id')
        ->where('p.buyer_id', '=', $buyerId);
})

不要在 leftJoin() 后直接添加:

->where('p.buyer_id', $buyerId)

这种写法会排除没有付款记录的交易,使左连接的效果接近内连接。

关于“逾期后才付清”的规则

现有三种状态无法明确表示“逾期后已付清”。通常应优先判断是否付清。因此,即使付款发生在到期日之后,只要累计金额已经付清,当前状态仍应为 paid。

如果业务需要区分逾期付款,可以增加 paid_late 状态。此时不能只看当前日期,还要确定累计付款首次达到交易金额的日期。例如:

if ($fullyPaid && $fullyPaidOn->lte($transaction->due_on)) {
    $status = 'paid';
} elseif ($fullyPaid) {
    $status = 'paid_late';
} elseif ($transaction->due_on->isBefore(today())) {
    $status = 'overdue';
} else {
    $status = 'outstanding';
}

只有在所有付款都参与结算,且不存在退款或冲正时,MAX(paid_at) 才能近似表示付清日期。如果有退款、撤销或负数调整,就要按付款时间顺序计算累计金额,找出余额首次达到应付金额的时间。

其他注意事项

可以为外键和常用查询字段添加约束及索引:

$table->foreignId('transaction_id')
    ->constrained()
    ->cascadeOnDelete();

$table->foreignId('buyer_id')
    ->constrained('users');

$table->index(['transaction_id', 'buyer_id']);

还要注意以下几点:

  • 状态值统一使用小写,例如 paid、overdue、outstanding,避免混用 Outstanding 和 outstanding。
  • 不要让客户端直接决定 status,应由服务端根据付款数据计算状态。
  • 如果没有 BCMath 扩展,应使用整数金额或可靠的 decimal 金额库。
  • CURRENT_DATE 和 today() 都会受时区影响,应保持应用时区与数据库时区一致。
  • 只在发生付款操作时更新状态并不够。没有新付款的交易过期后,也需要从 outstanding 变为 overdue。可以每天通过定时任务批量更新,也可以在查询时动态计算状态。

备注:内容仅供参考。