laravel-vnpay maintained by nguyenkhoi
Laravel VNPay — Thư viện thanh toán VNPAY cho Laravel
Package Laravel giúp tích hợp VNPAY (chuẩn vnp_Version 2.1.0): tạo URL thanh toán có chữ ký HMAC-SHA512, xác thực IPN webhook, lưu lịch sử giao dịch và đồng bộ trạng thái đơn hàng qua một contract do app tự implement.
📋 Mục lục
- Yêu cầu
- Cài đặt
- Cấu hình
- Bước bắt buộc: bind
OrderRepositoryInterface - Migration & bảng dữ liệu
- Routes
- Sử dụng
- Mã phản hồi IPN
- Trạng thái đơn hàng (VNPayEnum)
- Bảo mật
- Troubleshooting
✅ Yêu cầu
| Thành phần | Phiên bản |
|---|---|
| PHP | ^8.1 |
| Laravel | 10.x trở lên |
| Tài khoản VNPAY | TmnCode + HashSecret (sandbox hoặc production) |
📦 Cài đặt
composer require nguyenkhoi/laravel-vnpay
Nếu dùng dưới dạng local path package (chưa lên Packagist), khai báo repository trong composer.json của app:
{
"repositories": [
{ "type": "path", "url": "packages/nguyenkhoi/laravel-vnpay" }
]
}
composer require nguyenkhoi/laravel-vnpay:@dev
Service provider và facade VNPay được auto-discover (khai báo trong extra.laravel của package) — không cần đăng ký thủ công.
⚙️ Cấu hình
Config được mergeConfigFrom sẵn, chỉ cần đặt biến môi trường trong .env:
VNPAY_TMN_CODE=your_tmn_code
VNPAY_HASH_SECRET=your_hash_secret
VNPAY_URL=https://sandbox.vnpayment.vn/paymentv2/vpcpay.html
VNPAY_RETURN_URL=https://your-app.test/api/vnpay/callback
| Key config | ENV | Mô tả |
|---|---|---|
vnpay.vnp_TmnCode |
VNPAY_TMN_CODE |
Mã website tại VNPAY |
vnpay.vnp_HashSecret |
VNPAY_HASH_SECRET |
Secret ký/verify chữ ký |
vnpay.vnp_Url |
VNPAY_URL |
Endpoint cổng thanh toán |
vnpay.vnp_Returnurl |
VNPAY_RETURN_URL |
URL VNPAY redirect khách về sau thanh toán |
Publish config (tuỳ chọn, khi cần chỉnh trực tiếp file):
php artisan vendor:publish --tag=vnpay-config # -> config/vnpay.php
php artisan vendor:publish --tag=vnpay-lang # -> lang/vendor/vnpay
Sandbox:
https://sandbox.vnpayment.vn/paymentv2/vpcpay.html— Production:https://vnpayment.vn/paymentv2/vpcpay.html.
🔌 Bước bắt buộc: bind OrderRepositoryInterface
Package không biết cấu trúc đơn hàng của bạn. Nó phụ thuộc vào contract sau và bạn phải bind một implementation, nếu không việc resolve VNPay sẽ báo lỗi.
namespace NguyenKhoi\LaravelVNPay\Contracts;
interface OrderRepositoryInterface
{
// Trả về đơn hàng theo mã (vnp_TxnRef), hoặc null nếu không có.
public function findByOrderId(string $orderId);
// Cập nhật trạng thái đơn theo giá trị VNPayEnum ('success' | 'failed' | ...).
public function markStatus(string $orderId, string $status);
}
Hợp đồng dữ liệu findByOrderId() — quan trọng: giá trị trả về phải đọc được 2 khoá:
| Khoá | Ý nghĩa | Dùng ở |
|---|---|---|
Amount |
Số tiền đơn hàng (đơn vị VND, không nhân 100) | so khớp với vnp_Amount / 100 |
Status |
0 = đang chờ thanh toán; khác 0 = đã xử lý |
chặn xử lý trùng (idempotent) |
handleWebhookđọc quadata_get($order, 'Amount')/data_get($order, 'Status')nên nhận cả array lẫn Eloquent model — miễn là truy cập được đúng khoá. Nếu schema của bạn dùng tên cột khác (vdamount/statuschữ thường), hãy map lại trong implementation để trả về đúng khoáAmount/Status.
Ví dụ implementation + bind trong AppServiceProvider:
use App\Models\Order;
use NguyenKhoi\LaravelVNPay\Contracts\OrderRepositoryInterface;
class OrderRepository implements OrderRepositoryInterface
{
public function findByOrderId(string $orderId)
{
$order = Order::find($orderId);
if (! $order) {
return null;
}
return [
'Amount' => $order->total_amount, // VND, không nhân 100
'Status' => $order->is_paid ? 1 : 0, // 0 = chờ thanh toán
];
}
public function markStatus(string $orderId, string $status): void
{
Order::whereKey($orderId)->update(['payment_status' => $status]);
}
}
// AppServiceProvider::register()
$this->app->bind(OrderRepositoryInterface::class, OrderRepository::class);
🗄️ Migration & bảng dữ liệu
Migration được tự nạp (loadMigrationsFrom), chỉ cần chạy:
php artisan migrate
Bảng vnpay_transactions:
| Cột | Kiểu | Ghi chú |
|---|---|---|
id |
bigint PK | |
order_id |
integer (index) | = vnp_TxnRef |
amount |
decimal(15,2) | VND |
vnpay_bankcode |
string, nullable | vnp_BankCode |
vnpay_transactionno |
string, nullable (index) | vnp_TransactionNo |
vnpay_card_type |
string, nullable | vnp_CardType |
vnpay_paytime |
string, nullable | vnp_PayDate |
vnpay_pay_message |
string, nullable | vnp_ResponseMessage |
created_at / updated_at |
timestamps |
🛣️ Routes
Package tự đăng ký (prefix api/vnpay, middleware web):
| Method | URI | Action | Mục đích |
|---|---|---|---|
POST |
/api/vnpay/create-payment |
createPayment |
Tạo URL thanh toán |
GET |
/api/vnpay/callback |
callback |
Return URL (khách quay về) |
GET |
/api/vnpay/vnpay-ipn |
webhook |
IPN từ VNPAY (server-to-server) |
Cấu hình URL IPN này trong trang quản trị VNPAY để nhận kết quả thanh toán.
🚀 Sử dụng
1. Tạo URL thanh toán
Qua route có sẵn — body được validate bởi VNPayCreatePaymentRequest:
POST /api/vnpay/create-payment
Content-Type: application/json
{
"amount": 100000,
"orderId": "123",
"message": "Thanh toán đơn hàng #123",
"vnp_Locale": "vn"
}
| Field | Rule | Mô tả |
|---|---|---|
amount |
required, numeric, min:1 | Số tiền VND (package tự nhân 100) |
orderId |
required, string, max:255 | Mã đơn → vnp_TxnRef |
message |
nullable, string, max:255 | Nội dung; mặc định Thanh toán đơn hàng ID #{orderId} |
vnp_Locale |
nullable, in:vn,en |
Ngôn ngữ giao diện VNPAY |
Response là URL thanh toán; redirect khách sang URL đó.
Hoặc gọi trực tiếp qua Facade ở nơi khác:
use NguyenKhoi\LaravelVNPay\Facades\VNPay;
$paymentUrl = VNPay::createPayment([
'amount' => 100000,
'orderId' => '123',
]);
return redirect()->away($paymentUrl);
2. Xử lý IPN (webhook)
Route /api/vnpay/vnpay-ipn gọi VNPay::handleWebhook($request->all()). Luồng xử lý (đã tối ưu, phẳng, atomic):
verifySign()— chữ ký sai ⇒ trả97, không chạm đơn hàng.findByOrderId()null ⇒01.Amountlệchvnp_Amount/100⇒04.Status != 0(đã xử lý) ⇒02(idempotent, không ghi trùng).- Hợp lệ ⇒ trong một
DB::transaction: lưuvnpay_transactions+markStatus(); thành công trả00. - Lỗi bất kỳ ⇒ rollback toàn bộ, ghi
Log::error('VNPay IPN failed', ...), trả99.
Trạng thái ghi vào đơn: success nếu vnp_ResponseCode == '00' && vnp_TransactionStatus == '00', ngược lại failed.
Response trả về VNPAY dạng JSON:
{ "RspCode": "00", "Message": "Confirm Success" }
3. Return URL (callback)
GET /api/vnpay/callback là nơi khách được redirect về. Controller mặc định chỉ dd($request->all()) — bạn cần tự thay bằng logic hiển thị kết quả (đọc vnp_ResponseCode, hiển thị trang cảm ơn / thất bại). Return URL không thay thế IPN; hãy để việc cập nhật trạng thái/ghi giao dịch cho IPN (đáng tin hơn, server-to-server).
📟 Mã phản hồi IPN
| RspCode | Message | Điều kiện |
|---|---|---|
00 |
Confirm Success | Chữ ký hợp lệ, đơn đang chờ, đã lưu giao dịch |
01 |
Order not found | findByOrderId() trả null |
02 |
Order already confirmed | Status != 0 |
04 |
Invalid amount | Amount không khớp vnp_Amount/100 |
97 |
Invalid signature | Sai chữ ký hoặc thiếu HashSecret |
99 |
Unknown error | Exception (đã ghi log, rollback) |
🏷️ Trạng thái đơn hàng (VNPayEnum)
NguyenKhoi\LaravelVNPay\Enums\VNPayEnum (backed string):
initialized · success · failed · pending · canceled · expired · refunded · invalid
markStatus() ở luồng IPN nhận success hoặc failed. Các giá trị còn lại dùng cho vòng đời đơn hàng phía app.
🔒 Bảo mật
- Luôn verify chữ ký trước khi tin dữ liệu IPN — package không chạm đơn hàng khi chữ ký sai (chống giả mạo request lật trạng thái).
- Không commit
HashSecret/.env. - IPN idempotent: đơn đã xử lý trả
02, không ghi giao dịch trùng. - Đối chiếu số tiền phía server (
Amountvsvnp_Amount/100), không tin số tiền từ client. - Ưu tiên cập nhật trạng thái ở IPN thay vì Return URL (Return URL có thể bị bỏ dở/giả mạo).
🛠️ Troubleshooting
| Triệu chứng | Nguyên nhân thường gặp |
|---|---|
Không lưu vnpay_transactions |
Chưa php artisan migrate; hoặc đơn không ở Status == 0; hoặc Amount lệch (RspCode 04). Xem log VNPay IPN failed. |
Luôn nhận 97 |
VNPAY_HASH_SECRET sai/thiếu; hoặc dữ liệu IPN bị proxy chỉnh sửa. |
Target [OrderRepositoryInterface] is not instantiable |
Chưa bind OrderRepositoryInterface trong app. |
04 invalid amount |
findByOrderId() trả Amount sai đơn vị (đang nhân 100) hoặc sai khoá. |
02 ở lần test lặp lại |
Đơn đã bị markStatus ở lần trước; reset Status về 0 để test lại. |
📄 License
MIT © Nguyen Khoi