如何在 Laravel 8 中实现预约定金或首付款?
支付小周
结论
Laravel 8 没有内置的“定金支付”功能。可以在预约业务中加入付款类型和定金比例,再用服务价格乘以所选比例,算出本次应付金额。
有一条规则不能省:服务器必须根据数据库中的服务价格重新计算金额,不能信任前端提交的金额。 如果最低支付比例是 30%,服务器就只能接受 30% 及以上的选项,不能允许用户提交 20%。
假设服务价格为 50€:
- 支付 30%:
50 × 30% = 15€ - 支付 50%:
50 × 50% = 25€ - 全额支付:
50 × 100% = 50€
数据库设计
金额最好用最小货币单位保存。欧元可以按“分”存储,避免浮点数带来的计算误差。
可以在预约表中增加这些字段:
// database/migrations/xxxx_xx_xx_add_payment_fields_to_appointments_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddPaymentFieldsToAppointmentsTable extends Migration
{
public function up()
{
Schema::table('appointments', function (Blueprint $table) {
$table->unsignedBigInteger('service_price')->comment('预约时的服务价格,单位:分');
$table->unsignedTinyInteger('payment_percentage')->default(30);
$table->unsignedBigInteger('amount_due')->comment('本次应付金额,单位:分');
$table->unsignedBigInteger('amount_paid')->default(0);
$table->string('payment_status')->default('pending');
$table->string('payment_reference')->nullable();
});
}
public function down()
{
Schema::table('appointments', function (Blueprint $table) {
$table->dropColumn([
'service_price',
'payment_percentage',
'amount_due',
'amount_paid',
'payment_status',
'payment_reference',
]);
});
}
}
然后执行:
php artisan migrate
预约中需要保存 service_price。这样即使服务价格后来发生变化,已创建的预约仍会保留当时的价格。
提供付款选项
假设最低定金比例是 30%,页面可以提供 30%、50% 和全额付款三个选项:
<form method="POST" action="{{ route('appointments.store') }}">
@csrf
<input type="hidden" name="service_id" value="{{ $service->id }}">
<label for="payment_percentage">付款方式</label>
<select name="payment_percentage" id="payment_percentage" required>
<option value="30">支付 30% 定金</option>
<option value="50">支付 50%</option>
<option value="100">全额支付</option>
</select>
<button type="submit">创建预约并付款</button>
</form>
前端可以展示预计金额,但它只能作为提示。实际支付金额仍要由控制器重新计算。
在服务器端验证和计算金额
// app/Http/Controllers/AppointmentController.php
namespace App\Http\Controllers;
use App\Models\Appointment;
use App\Models\Service;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\Rule;
class AppointmentController extends Controller
{
public function store(Request $request)
{
$validated = $request->validate([
'service_id' => ['required', 'integer', 'exists:services,id'],
'payment_percentage' => [
'required',
'integer',
Rule::in([30, 50, 100]),
],
]);
$service = Service::findOrFail($validated['service_id']);
// 假设 price 保存的是欧分,例如 50€ 保存为 5000。
$servicePrice = (int) $service->price;
$percentage = (int) $validated['payment_percentage'];
// 整数运算,并四舍五入到最小货币单位。
$amountDue = (int) round($servicePrice * $percentage / 100);
$appointment = DB::transaction(function () use (
$request,
$service,
$servicePrice,
$percentage,
$amountDue
) {
return Appointment::create([
'user_id' => $request->user()->id,
'service_id' => $service->id,
'service_price' => $servicePrice,
'payment_percentage' => $percentage,
'amount_due' => $amountDue,
'amount_paid' => 0,
'payment_status' => 'pending',
]);
});
// 下一步应创建支付平台的支付请求。
return redirect()->route('appointments.payment', $appointment);
}
}
模型还要允许这些字段进行批量赋值:
// app/Models/Appointment.php
protected $fillable = [
'user_id',
'service_id',
'service_price',
'payment_percentage',
'amount_due',
'amount_paid',
'payment_status',
'payment_reference',
];
对应路由可以这样写:
use App\Http\Controllers\AppointmentController;
use App\Http\Controllers\AppointmentPaymentController;
use Illuminate\Support\Facades\Route;
Route::middleware('auth')->group(function () {
Route::post('/appointments', [
AppointmentController::class,
'store',
])->name('appointments.store');
Route::get('/appointments/{appointment}/payment', [
AppointmentPaymentController::class,
'show',
])->name('appointments.payment');
});
金额计算示例
如果数据库使用欧分,50€ 应保存为 5000:
$servicePrice = 5000;
$percentage = 30;
$amountDue = (int) round($servicePrice * $percentage / 100);
// 结果为 1500,即 15.00€
在页面上展示时,可以转换格式:
{{ number_format($appointment->amount_due / 100, 2, ',', '.') }} €
接入实际支付平台
算出 amount_due 后,把这个金额传给 Stripe、PayPal 或其他支付平台。具体 API 取决于使用的支付服务,基本流程如下:
- 创建一条状态为
pending的预约。 - 用
amount_due创建支付订单。 - 把支付平台返回的订单编号保存到
payment_reference。 - 用户在支付平台完成付款。
- 支付平台通过 webhook 通知 Laravel。
- Laravel 验证 webhook 签名和实际支付金额。
- 验证通过后,更新
amount_paid和payment_status。
用户付款后跳转到“成功页面”,并不能证明支付已经完成。浏览器跳转可能被伪造,也可能中断。付款结果应以支付平台经过签名验证的 webhook 为准。
更新逻辑可以参考下面的写法:
DB::transaction(function () use ($appointment, $paidAmount) {
$appointment->amount_paid = $paidAmount;
if ($paidAmount >= $appointment->service_price) {
$appointment->payment_status = 'paid';
} elseif ($paidAmount >= $appointment->amount_due) {
$appointment->payment_status = 'deposit_paid';
} else {
$appointment->payment_status = 'partially_paid';
}
$appointment->save();
});
实际处理 webhook 时,还要核对支付金额:
if ($paidAmount !== $appointment->amount_due) {
// 记录异常,不要自动确认预约。
}
后续尾款处理
定金到账后,剩余金额可以这样计算:
$remainingAmount = max(
0,
$appointment->service_price - $appointment->amount_paid
);
例如服务价格为 5000 分,已经支付 1500 分:
$remainingAmount = 5000 - 1500; // 3500,即 35.00€
支付尾款时,应创建一条新的支付记录。更完整的方案通常会增加 payments 表,不把所有付款信息都放在预约表中:
appointments
├── service_price
├── payment_status
└── payment_percentage
payments
├── appointment_id
├── amount
├── status
├── provider
└── provider_reference
采用这种结构后,定金、尾款、补款和退款等多笔交易都能分别记录。
注意事项
- 最低定金为 30% 时,服务器必须拒绝 20% 的请求,不能只在页面上隐藏这个选项。
- 不要接收前端直接提交的
amount_due或服务价格。 - 金额应使用整数分保存,不要使用
float或double。 - 创建预约时要保存价格快照,避免服务改价影响历史预约。
- 支付状态应由经过验证的支付平台 webhook 更新。
- Webhook 可能重复发送,处理逻辑需要具备幂等性,避免同一笔付款被重复记账。
- 如果允许管理员配置付款比例,服务器仍要检查该比例不低于最低值,并限制在
1到100之间。 - 税费、退款、取消预约以及定金是否可退,需要根据业务所在地的法律和支付平台规则另行设计。
备注:内容仅供参考。
