Lưu thẻ & thanh toán định kỳ
Lưu thẻ của khách hàng một lần và tái sử dụng cho các thanh toán sau mà không cần hỏi lại số thẻ. Một SetupIntent tokenize và xác minh thẻ trước (không tính phí), trả về một payment_method_id có thể tái sử dụng. Sau đó bạn tính phí cho thẻ đã lưu đó theo một trong hai cách:
- CIT (Customer-Initiated Transaction) — khách hàng có mặt và chủ động thanh toán, ví dụ chọn một thẻ đã lưu tại checkout hoặc thanh toán một chạm. Đặt
off_session=false. - MIT (Merchant-Initiated Transaction) — merchant tính phí thẻ mà không có khách hàng nào có mặt, ví dụ gia hạn subscription hoặc tính phí theo lịch. Đặt
off_session=true.
usage | Người khởi tạo | Khi nào sử dụng | Tính phí với |
|---|---|---|---|
on_session | Khách hàng (CIT) | Khách hàng có mặt và chọn một thẻ đã lưu tại checkout. | off_session=false |
off_session | Merchant (MIT) | Tính phí định kỳ / subscription / theo lịch, khách hàng vắng mặt. | off_session=true |
Điều kiện tiên quyết
Luồng tổng quan
sequenceDiagram
autonumber
participant User as User
participant MerchantWeb as Merchant Web<br/>(Frontend)
participant SDK as SDK<br/>(Embedded)
participant MerchantAPI as Merchant API<br/>(Backend)
participant API as Bahn API
Note over User, API: Part 1 — Save the card (SetupIntent)
MerchantAPI ->> API: CreateCustomer (once per customer)
API -->> MerchantAPI: {customer_id}
User ->> MerchantWeb: Choose "Save my card"
MerchantWeb ->> MerchantAPI: Request setup session
MerchantAPI ->> API: CreateSetupIntent(customer_id, usage)
API -->> MerchantAPI: {setup_intent, client_secret}
MerchantAPI -->> MerchantWeb: {client_secret}
MerchantWeb ->> SDK: Initialize SDK with client_secret
SDK ->> API: ConfirmSetupIntent(card)
Note over API: 3DS always required for setup<br/>verification authorization (0 or 5,000 VND, per merchant rules) to confirm the card
API -->> SDK: requires_action (3DS challenge)
User ->> SDK: Complete 3DS challenge
Note over API: SetupIntent → succeeded<br/>card attached to customer
MerchantAPI ->> API: GetSetupIntent(id)
API -->> MerchantAPI: {status: succeeded, payment_method_id}
Note over User, API: Part 2a — Charge later, customer present (CIT)
User ->> MerchantWeb: Select saved card & pay
MerchantWeb ->> MerchantAPI: Pay with payment_method_id
MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=false)
Note over API: evaluate merchant 3DS rules
alt 3DS required
API -->> MerchantAPI: {status: requires_action, client_secret}
Note over MerchantWeb, SDK: SDK completes 3DS challenge live
else non-3DS (rules skip authentication)
API -->> MerchantAPI: {status: processing}
else 3DS exemption (wallet token / SCA exemption)
API -->> MerchantAPI: {status: processing}
end
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
Note over MerchantAPI, API: Part 2b — Charge later, no customer (MIT)
MerchantAPI ->> API: CreatePaymentIntent(amount, customer_id,<br/>payment_method_id, confirm=true, off_session=true)
Note over API: payment method must be off_session_enabled<br/>evaluate merchant 3DS rules
alt non-3DS or 3DS exemption (typical for MIT)
API -->> MerchantAPI: {status: processing}
else 3DS required
API -->> MerchantAPI: {status: requires_action}
Note over MerchantAPI, API: no customer to complete challenge — payment cannot proceed
end
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
Cách hoạt động
Giai đoạn 1 — Tạo một SetupIntent
- Tạo một customer — Tạo customer một lần (qua
CreateCustomer) và tái sử dụngcustomer_id. Thẻ đã lưu được gắn với một customer. - Tạo một SetupIntent — Backend của bạn gọi
CreateSetupIntentvớicustomer_idvàusagemong muốn (on_sessionhoặcoff_session). Bahn trả vềsetup_intentvàclient_secrettương ứng. - Chỉ chuyển tiếp
client_secret— Trả về{ client_secret }cho frontend của bạn;setup_intent.idvà API key vẫn ở phía backend.
Giai đoạn 2 — Confirm & xác minh (SDK)
- SDK thu thập thông tin thẻ — SDK được nhúng hiển thị các ô nhập thẻ an toàn và xác nhận SetupIntent bằng
client_secret. Frontend của bạn không bao giờ xử lý dữ liệu thẻ thô. - Thử thách 3DS — Khác với một thanh toán, 3DS luôn bắt buộc đối với một setup. SDK hiển thị thử thách và khách hàng hoàn tất nó.
- Ủy quyền xác minh — Bahn chạy một giao dịch ủy quyền xác minh tài khoản trên thẻ để xác nhận thẻ còn hiệu lực — 0 hoặc 5.000 VND, tùy theo quy tắc của merchant. Không có khoản tiền nào được capture.
3DS và giao dịch ủy quyền vi mô chạy bất đồng bộ sau khi khách hàng hoàn tất thử thách. SetupIntent chuyển trạng thái
requires_payment_method → requires_action → processing → succeeded. Nếu xác minh thất bại, nó quay vềrequires_payment_methodvớilast_setup_errorđược đặt, và khách hàng có thể thử lại với thẻ khác.
Giai đoạn 3 — Lấy thẻ đã lưu
Khi thành công, Bahn gắn thẻ với customer và điền payment_method_id trên SetupIntent. Truy vấn SetupIntent để đọc giá trị này khi trạng thái là succeeded. Lưu payment_method_id vào bản ghi customer của bạn — đây là handle bạn dùng cho mọi lần tính phí trong tương lai.
Giai đoạn 4 — Tính phí thẻ đã lưu (CIT / MIT)
Tạo và xác nhận một PaymentIntent tham chiếu đến payment_method_id đã lưu. Dùng off_session=false khi khách hàng có mặt (CIT) và off_session=true khi họ vắng mặt (MIT). Từ đó, việc ủy quyền, webhook, và capture hoạt động giống hệt luồng Thanh toán một lần.
Đối với subscription và kế hoạch trả góp, MIT cũng phải chỉ rõ thỏa thuận nào nó đang thực thi — xem Thanh toán định kỳ & MIT không định kỳ để biết cách thiết lập một mandate định kỳ và tính phí dựa trên đó.
Trách nhiệm của merchant API
1. Tạo một customer
Thẻ đã lưu được gắn với một customer. Tạo customer một lần và tái sử dụng customer_id cho mọi SetupIntent và thanh toán trong tương lai. merchant_id được suy ra từ API key của bạn.
POST /api/customersRequest
{
"name": "Nguyen Van A",
"email": "[email protected]",
"phone": "+84901234567"
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
name | Không | Họ tên đầy đủ của customer. |
email | Không | Địa chỉ email của customer. |
phone | Không | Số điện thoại của customer. |
metadata | Không | Các cặp key-value tùy ý để lưu thêm thông tin. |
Các trường địa chỉ — addr_line1, addr_line2, ward, district, province, postal_code, country — cũng được chấp nhận và là tùy chọn.
Response
{
"customer": {
"id": "cus_xxxxxxxx",
"name": "Nguyen Van A",
"email": "[email protected]",
"status": "active"
}
}Lưu
customer.idtrả về vào bản ghi người dùng của bạn. Bạn truyền nó dưới dạngcustomer_idkhi tạo SetupIntent và khi tính phí thẻ đã lưu.
2. Tạo một SetupIntent
Khi khách hàng chọn lưu thẻ, tạo một SetupIntent cho họ. Bahn trả về một client_secret mà SDK dùng để xác nhận thay mặt khách hàng.
POST /api/setup_intentsRequest
{
"customer_id": "cus_xxxxxxxx",
"usage": "off_session",
"description": "Save card for subscription",
"metadata": {
"order_id": "1234"
}
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
customer_id | Có | Customer mà thẻ được lưu gắn với. |
usage | Có | on_session (tái sử dụng khi khách hàng có mặt) hoặc off_session (tái sử dụng do merchant khởi tạo). |
description | Không | Mô tả dễ đọc. |
metadata | Không | Các cặp key-value tùy ý để lưu thêm thông tin. |
Response
{
"setup_intent": {
"id": "seti_xxxxxxxx",
"client_secret": "seti_xxxxxxxx_secret_xxxxxxxx",
"status": "requires_payment_method",
"usage": "off_session",
"customer_id": "cus_xxxxxxxx"
}
}Chỉ trả về
client_secretcho frontend của bạn — SDK dùng nó để khởi tạo và xác nhận setup. Giữsetup_intent.idở backend của bạn, và không bao giờ để lộ API key cho client.
3. Lấy kết quả
Sau khi khách hàng hoàn tất 3DS, việc xác minh hoàn tất bất đồng bộ. Truy vấn SetupIntent để xác nhận nó đã thành công và đọc payment_method_id kết quả.
GET /api/setup_intents/{id}Response
{
"setup_intent": {
"id": "seti_xxxxxxxx",
"status": "succeeded",
"payment_method_id": "pm_xxxxxxxx",
"usage": "off_session",
"customer_id": "cus_xxxxxxxx"
}
}| Trạng thái | Ý nghĩa |
|---|---|
requires_action | Đang chờ khách hàng hoàn tất thử thách 3DS. |
processing | 3DS đã hoàn tất; đang chờ kết quả ủy quyền vi mô để xác minh. |
succeeded | Thẻ đã được xác minh và lưu. payment_method_id giờ đã sẵn sàng để tái sử dụng. |
requires_payment_method | Xác minh thất bại (xem last_setup_error). Yêu cầu khách hàng thử lại với thẻ khác. |
4. Tính phí thẻ đã lưu — khách hàng có mặt (CIT)
Khi khách hàng đang ở bước checkout và chọn một thẻ đã lưu, tạo và xác nhận một PaymentIntent tham chiếu đến payment_method_id với off_session=false. Việc charge này có bỏ qua hay cần 3DS hay không phụ thuộc vào quy tắc merchant của bạn. Nếu một quy tắc yêu cầu, Bahn trả về requires_action và một client_secret để SDK hoàn tất thử thách trực tiếp; nếu không, charge tiếp tục mà không có thử thách.
POST /api/payment_intentsRequest
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"confirm": true,
"off_session": false
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
customer_id | Có | Phải sở hữu phương thức thanh toán. |
payment_method_id | Có | Thẻ đã lưu được trả về bởi SetupIntent. |
confirm | Có | Đặt thành true để tạo và xác nhận trong một lệnh gọi. |
off_session | Không | false (mặc định) cho thanh toán khi khách hàng có mặt. |
5. Tính phí thẻ đã lưu — không có khách hàng (MIT)
Đối với subscription, gia hạn, hoặc tính phí theo lịch, tạo và xác nhận một PaymentIntent với off_session=true. Phương thức thanh toán phải được lưu với usage=off_session (phải được bật off-session), nếu không request sẽ bị từ chối.
POST /api/payment_intentsRequest
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"confirm": true,
"off_session": true
}Việc một charge MIT có bỏ qua hay cần 3DS hay không phụ thuộc vào quy tắc merchant của bạn. Vì không có khách hàng nào có mặt để hoàn tất thử thách, MIT thường được cấu hình để bỏ qua 3DS — thẻ đã được xác thực khi được lưu, và xác thực đó được mang theo.
off_session=trueyêu cầupayment_method_id— dữ liệu thẻ inline và confirmation token bị từ chối.
6. Kết quả thanh toán — 3DS, non-3DS & exemption
Khi bạn xác nhận một PaymentIntent với một payment_method_id đã lưu, Bahn đánh giá các quy tắc 3DS của bạn và trả về một trong ba kết quả:
| Kết quả | Khi nào | Response | Việc bạn cần làm |
|---|---|---|---|
| 3DS bắt buộc | Quy tắc 3DS của bạn (hoặc số tiền) yêu cầu xác thực và không có ngoại lệ nào áp dụng. | status: requires_action + next_action + client_secret | CIT: truyền client_secret cho SDK để chạy thử thách trực tiếp. MIT: không thể hoàn tất (không có khách hàng nào có mặt) — thanh toán sẽ không thể tiếp tục. |
| Non-3DS | Quy tắc của bạn cho phép bỏ qua thử thách (ví dụ số tiền dưới ngưỡng). | status: processing | Chờ webhook payment_intent.status.succeeded / payment_intent.payment_failed. |
| Ngoại lệ 3DS (3DS exemption) | Xác thực được thỏa mãn mà không cần thử thách — ví dụ một device network token của ví điện tử (Google Pay DPAN), hoặc một ngoại lệ SCA như merchant-initiated (credential-on-file), giá trị thấp, hoặc TRA. Giao dịch được gắn cờ với ngoại lệ để trách nhiệm pháp lý được xử lý đúng. | status: processing | Giống như non-3DS — chờ webhook. |
Ngoại lệ áp dụng tự động hiện nay là device network token của ví điện tử (Google Pay DPAN), có cryptogram mang theo xác thực và sự chuyển giao trách nhiệm. Các ngoại lệ SCA khác (MIT credential-on-file, giá trị thấp, TRA) được quản lý bởi quy tắc merchant của bạn.
7. Lưu thẻ trong khi thanh toán
Để lưu thẻ như một phần của một thanh toán thông thường (checkbox "lưu thẻ này cho lần sau") thay vì một SetupIntent riêng biệt, hãy truyền setup_future_usage trên PaymentIntent. Khi thanh toán thành công, thẻ được gắn với customer để tái sử dụng CIT/MIT trong tương lai.
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"setup_future_usage": "off_session"
}8. Xử lý webhook
Việc tính phí một thẻ đã lưu phát ra cùng các event như bất kỳ thanh toán nào khác. Hãy coi webhook là nguồn sự thật chính thức cho kết quả cuối cùng.
| Event | Mô tả |
|---|---|
payment_intent.status.succeeded | Thanh toán đã được capture thành công. Đánh dấu đơn hàng / lần gia hạn đã thanh toán. |
payment_intent.payment_failed | Charge bị từ chối. Đối với MIT, thử lại sau hoặc thông báo cho khách hàng cập nhật thẻ. |
charge.captured | Charge đã được capture. |