Looking to hire Laravel developers? Try LaraJobs

laravel-vnpay maintained by nguyenkhoi

Author
Last update
2026/07/04 09:14 (dev-master)
License
Downloads
0

Comments
comments powered by Disqus

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

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 qua data_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 (vd amount/status chữ 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):

  1. verifySign() — chữ ký sai ⇒ trả 97, không chạm đơn hàng.
  2. findByOrderId() null ⇒ 01.
  3. Amount lệch vnp_Amount/10004.
  4. Status != 0 (đã xử lý) ⇒ 02 (idempotent, không ghi trùng).
  5. Hợp lệ ⇒ trong một DB::transaction: lưu vnpay_transactions + markStatus(); thành công trả 00.
  6. 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 (Amount vs vnp_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