Laravel 9 API 如何根据付款和 due_on 更新交易状态
结论
每次新增、修改或删除付款记录后,都应重新汇总这笔交易的付款金额,并按以下顺序更新状态:
paid_amount >= transaction.amount:paid- 尚未付清,且今天晚于
due_on:overdue - 尚未付清,且未超过
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。可以每天通过定时任务批量更新,也可以在查询时动态计算状态。
备注:内容仅供参考。
