Thanh toán định kỳ & MIT không định kỳ
Một thẻ đã lưu có thể được tính phí mà không cần khách hàng có mặt (một MIT — giao dịch do merchant khởi tạo), nhưng các mạng thẻ phân biệt chủ thẻ đã đồng ý điều gì khi thẻ được lưu lại. Sự đồng ý đó là một mandate, và Bahn ghi nhận nó trên giao dịch đã thiết lập nó.
Có hai hình thức:
| Thỏa thuận | Chủ thẻ đã đồng ý điều gì | Trường hợp sử dụng phổ biến | Cách thiết lập |
|---|---|---|---|
| Không định kỳ (Unscheduled) (mặc định) | Merchant có thể lưu thẻ và tính phí lại, không theo lịch cố định. | Nạp tiền, trả tiền theo mức sử dụng, tính lại phí cho đơn hàng thất bại, mua lại chỉ với một cú nhấp. | Một SetupIntent, hoặc một PaymentIntent với setup_future_usage=off_session — tùy chọn kèm setup_future_usage_cadence=unscheduled, cũng là giá trị mặc định |
| Định kỳ (Recurring) | Merchant có thể tính phí theo một lịch cố định. | Subscription, trả góp, phí thành viên. | Một PaymentIntent với setup_future_usage=off_session và payment_method_options.card.setup_future_usage_cadence=recurring |
Việc khai báo đúng loại thỏa thuận rất quan trọng đối với tranh chấp (dispute): các marker mà Bahn gửi cho Visa và Mastercard trên giao dịch lưu thẻ ban đầu sẽ được phát lại trên mọi MIT sau đó dựa theo mandate đó, và đây chính là bằng chứng cho sự đồng ý của chủ thẻ nếu giao dịch bị tranh chấp. Một khoản trả góp định kỳ được tính phí dưới marker không định kỳ sẽ là một vị thế yếu hơn so với việc tính phí dưới đúng thỏa thuận mà chủ thẻ thực sự đã chấp nhận.
WARNING
Thỏa thuận được cố định bởi giao dịch lưu thông tin thanh toán — nó không thể thay đổi sau đó. Nếu một khách hàng bắt đầu một subscription, hãy thiết lập một mandate định kỳ ngay tại thời điểm đó; bạn không thể nâng cấp một mandate không định kỳ thành định kỳ sau này. Hãy thiết lập một mandate mới thay vào đó.
Điều kiện tiên quyết
- Webhook
- Tích hợp API
- Lưu thẻ & thanh toán định kỳ — cơ chế SetupIntent /
payment_method_idmà hướng dẫn này xây dựng dựa trên
Luồng tổng quan
sequenceDiagram
autonumber
participant User as User
participant SDK as SDK<br/>(Embedded)
participant MerchantAPI as Merchant API<br/>(Backend)
participant API as Bahn API
Note over User, API: Step 1 — a CIT establishes the agreement
User ->> MerchantAPI: Subscribe / accept terms
MerchantAPI ->> API: CreatePaymentIntent(customer_id,<br/>setup_future_usage=off_session,<br/>payment_method_options.card.setup_future_usage_cadence=recurring)
Note over API: both fields are creation-only
alt Path A — SDK confirms with the client secret (recommended)
API -->> MerchantAPI: {payment_intent, client_secret}
MerchantAPI -->> SDK: {client_secret}
SDK ->> API: ConfirmPaymentIntentWithClientSecret(card)
Note over API: mandate_data captured from the<br/>customer's own request
else Path B — server-side confirm at creation
Note over MerchantAPI, API: same call carries confirm=true,<br/>off_session=false and mandate_data
API -->> MerchantAPI: {payment_intent}
end
Note over API: storing authorization states the<br/>recurring agreement to the network
API -->> SDK: 3DS challenge (customer present)
User ->> SDK: Complete 3DS
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
Note over API: mandate created, recorded on the charge
Note over MerchantAPI, API: Step 2 — read back the mandate_id
MerchantAPI ->> API: GetPaymentIntent(id,<br/>expand=["payment_intent.latest_charge"])
API -->> MerchantAPI: {latest_charge.payment_method_details.card.mandate_id}
Note over MerchantAPI: store mandate_id with the subscription
Note over MerchantAPI, API: Step 3 — each renewal is an MIT
MerchantAPI ->> API: CreatePaymentIntent(confirm=true, off_session=true,<br/>payment_method_id, mandate_id)
Note over API: replays the recurring agreement<br/>recorded on that mandate
API ->> MerchantAPI: Webhook: payment_intent.status.succeeded
Bước 1 — Thiết lập thỏa thuận bằng một CIT
Mandate được tạo bởi một giao dịch do khách hàng khởi tạo (customer-initiated), nơi khách hàng có mặt và xác thực. Cả hai trường mô tả thỏa thuận đều nằm trên lệnh gọi create:
POST /api/payment_intents| Trường | Bắt buộc | Mô tả |
|---|---|---|
setup_future_usage | Có | Phải là off_session — đây là thứ lưu lại một thông tin thanh toán mà bạn có thể tính phí sau này mà không cần khách hàng. |
payment_method_options.card.setup_future_usage_cadence | Không | recurring biến thanh toán này thành giao dịch khởi đầu của một thỏa thuận định kỳ; unscheduled giữ thông tin thanh toán trên hồ sơ mà không theo lịch nào. Bỏ qua trường này nghĩa là unscheduled. |
customer_id | Có | Khách hàng mà thông tin thanh toán được lưu gắn với. Không thể lưu thông tin thanh toán mà không có trường này. |
Cả hai trường chỉ áp dụng lúc tạo
ConfirmPaymentIntent bỏ qua payment_method_options, và ConfirmPaymentIntentWithClientSecret hoàn toàn không chấp nhận trường này. Đặt setup_future_usage_cadence tại thời điểm confirm sẽ không có hiệu lực — thỏa thuận sẽ âm thầm quay về unscheduled. Hãy đặt nó khi bạn tạo intent.
Từ đó, xác nhận intent theo một trong hai cách.
Phương án A — SDK xác nhận bằng client secret
Tạo intent mà không có confirm, chuyển client_secret cho frontend của bạn, và để SDK xác nhận nó. Đây là luồng checkout thông thường (Thanh toán một lần) và là phương án được khuyến nghị.
Request
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"setup_future_usage": "off_session",
"payment_method_options": {
"card": {
"setup_future_usage_cadence": "recurring"
}
},
"description": "Pro plan — first month"
}Response
{
"payment_intent": {
"id": "pi_xxxxxxxx",
"client_secret": "pi_xxxxxxxx_secret_xxxxxxxx",
"status": "requires_payment_method",
"setup_future_usage": "off_session",
"customer_id": "cus_xxxxxxxx"
}
}Bạn không gửi mandate_data trên phương án này. Khi SDK xác nhận bằng client secret, Bahn ghi nhận sự chấp thuận của khách hàng từ chính request đó — IP thực của client, User-Agent, và thời điểm chấp thuận — những thứ mà SDK trên trình duyệt không thể tự báo cáo một cách đáng tin cậy và backend của bạn hoàn toàn không thể quan sát được. Đây là nguồn gốc tốt hơn cho một bản ghi đồng ý, đó là lý do phương án này được ưu tiên.
Phương án B — Xác nhận phía server tại thời điểm tạo
Tạo và xác nhận trong cùng một lệnh gọi với confirm=true. Vì không có request nào từ phía khách hàng liên quan, bạn phải tự cung cấp mandate_data.customer_acceptance, trong cùng lệnh gọi tạo đó.
Request
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"confirm": true,
"off_session": false,
"setup_future_usage": "off_session",
"payment_method_options": {
"card": {
"setup_future_usage_cadence": "recurring"
}
},
"mandate_data": {
"customer_acceptance": {
"type": "online",
"accepted_at": "2026-09-07T10:15:30Z",
"online": {
"ip_address": "203.0.113.42",
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36"
}
}
}
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
confirm | Có | true để tạo và xác nhận trong một lệnh gọi. |
off_session | Có | Phải là false. Giao dịch thiết lập là do khách hàng khởi tạo; off_session=true kết hợp với setup_future_usage sẽ bị từ chối. |
mandate_data.customer_acceptance.type | Có | online hoặc offline. |
mandate_data.customer_acceptance.accepted_at | Có | Thời điểm khách hàng chấp thuận thỏa thuận. |
mandate_data.customer_acceptance.online.ip_address | Bắt buộc với online | Địa chỉ IP của khách hàng tại thời điểm chấp thuận. |
mandate_data.customer_acceptance.online.user_agent | Bắt buộc với online | User-Agent của khách hàng tại thời điểm chấp thuận. |
TIP
mandate_data được cung cấp lúc tạo sẽ được lưu trên intent, vì vậy nó cũng thỏa mãn cho một lần confirm sau đó không mang theo trường này. Việc cung cấp nó vô hại trên Phương án A — nhưng sự chấp thuận mà Bahn ghi nhận từ chính request của SDK có giá trị pháp lý cao hơn so với thứ backend của bạn tự dựng lại, vì vậy hãy ưu tiên việc ghi nhận tự động của Phương án A khi có thể.
3DS trên giao dịch thiết lập
3DS trên khoản thanh toán này là thứ mang tính xác thực đi tiếp đến mọi MIT sau này. Yêu cầu bỏ qua nó sẽ bị từ chối thẳng thừng thay vì bị bỏ qua: payment_method_options.card.request_three_d_secure=never kết hợp với setup_future_usage sẽ bị từ chối, vì vậy bạn sẽ không bao giờ nhầm tưởng rằng một thông tin thanh toán đã lưu được xác thực trong khi thực tế không phải vậy. Xem Kiểm soát 3DS.
Thiết lập thông qua một SetupIntent
Một SetupIntent cũng lưu một thông tin thanh toán off-session, và là lựa chọn phù hợp khi bạn lưu thẻ mà không tính phí ngay. Nó không có payment_method_options, vì vậy nó chỉ có thể tạo ra một mandate không định kỳ.
Để thiết lập một mandate định kỳ, hãy dùng một PaymentIntent thực sự tính phí cho kỳ đầu tiên — đây cũng chính là những gì các mạng thẻ mong đợi ở giao dịch khởi đầu của một thỏa thuận định kỳ.
INFO
setup_future_usage_cadence chỉ có thể là recurring hoặc unscheduled, và bỏ qua trường này nghĩa là unscheduled. Một giá trị mà phiên bản API này không nhận diện được sẽ hạ cấp về unscheduled thay vì bị từ chối — một thỏa thuận định kỳ sẽ không bao giờ vô tình được khai báo với mạng thẻ.
Bước 2 — Lấy mandate_id
Mandate được tạo bất đồng bộ, khi giao dịch ủy quyền lưu thẻ thành công. ID của nó được ghi nhận trên charge đã tạo ra nó, tại payment_method_details.card.mandate_id.
Charge không được bao gồm trong một PaymentIntent theo mặc định. Yêu cầu nó bằng expand:
GET /api/payment_intents/{id}?expand=payment_intent.latest_chargeexpand là một danh sách chuỗi, không phải một giá trị đơn. Mỗi phần tử là một đường dẫn phân tách bằng dấu chấm, và bạn truyền một danh sách bằng cách lặp lại tham số một lần cho mỗi phần tử:
GET /api/payment_intents/{id}?expand=payment_intent.latest_charge&expand=some.other.path| Tham số | Kiểu | Mô tả |
|---|---|---|
expand | string[] | Các đường dẫn sub-resource cần đưa vào response. Lặp lại tham số một lần cho mỗi đường dẫn. Hiện tại chỉ hỗ trợ payment_intent.latest_charge. |
Mỗi đường dẫn được phân tách bằng dấu chấm tính từ resource mà nó thuộc về — payment_intent.latest_charge, không phải latest_charge — để các đường dẫn luôn rõ ràng khi có thêm sub-resource của sub-resource.
Một giá trị phân tách bằng dấu phẩy không phải là một danh sách
?expand=a,b được đọc là một đường dẫn duy nhất tên là a,b, không phải hai đường dẫn. Vì các đường dẫn không nhận diện được sẽ bị bỏ qua thay vì bị từ chối (xem bên dưới), request đó vẫn thành công nhưng âm thầm không expand được gì. Hãy lặp lại tham số thay vào đó.
Response
{
"payment_intent": {
"id": "pi_xxxxxxxx",
"status": "succeeded",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"latest_charge_id": "ch_xxxxxxxx",
"latest_charge": {
"id": "ch_xxxxxxxx",
"status": "succeeded",
"payment_method_details": {
"type": "card",
"card": {
"brand": "visa",
"last4": "4242",
"network_transaction_id": "123456789012345",
"mandate_id": "mand_xxxxxxxx"
}
}
}
}
}Lưu mandate_id cùng với payment_method_id trên bản ghi subscription của bạn. Cả hai đều cần thiết để tính phí sau này.
Chờ thành công trước khi đọc
mandate_id chỉ được điền khi thanh toán đạt trạng thái succeeded. Hãy đọc PaymentIntent sau webhook payment_intent.status.succeeded thay vì ngay sau khi confirm — một intent ở trạng thái processing có thể chưa mang trường này.
Lưu ý về expand:
- Một đường dẫn
expandmà phiên bản API này không biết sẽ bị bỏ qua, không bị từ chối — một client được viết cho một bề mặt API mới hơn vẫn tiếp tục hoạt động. latest_chargesẽ không xuất hiện nếu intent chưa có charge nào.- Cùng một
card.mandate_idxuất hiện trên các charge MIT sau này, ở đó nó báo cáo mandate mà charge đó thực thi (exercised) thay vì mandate mà nó tạo ra — hữu ích để đối chiếu một lần gia hạn với thỏa thuận gốc của nó.
Bước 3 — Tính phí theo mandate (MIT)
Mỗi lần gia hạn hoặc tính phí lại không định kỳ là một PaymentIntent được xác nhận với off_session=true, chỉ rõ mandate mà nó thực thi.
POST /api/payment_intentsRequest
{
"amount": "10000",
"currency": "usd",
"customer_id": "cus_xxxxxxxx",
"payment_method_id": "pm_xxxxxxxx",
"mandate_id": "mand_xxxxxxxx",
"confirm": true,
"off_session": true,
"description": "Pro plan — renewal"
}| Trường | Bắt buộc | Mô tả |
|---|---|---|
payment_method_id | Có | Thông tin thanh toán đã lưu. Dữ liệu thẻ inline và confirmation token bị từ chối đối với thanh toán off-session. |
off_session | Có | true — không có khách hàng nào có mặt. |
mandate_id | Không | Mandate mà thanh toán này thực thi. Nếu bỏ qua, server sẽ coi charge này là không định kỳ và tự chọn một mandate — xem bên dưới. |
confirm | Có | true. mandate_id chỉ được đọc tại thời điểm confirm. |
Một MIT không mang setup_future_usage — nó thực thi một thỏa thuận đã có sẵn thay vì tạo mới, và việc kết hợp cả hai sẽ bị từ chối. Nó cũng không cần mandate_data: sự đồng ý đã được ghi nhận một lần, bởi CIT ở Bước 1.
Bahn đọc mandate, phát lại thỏa thuận đã ghi nhận trên đó cho mạng thẻ, và thanh toán tiếp tục như bình thường — xem Thanh toán một lần để biết về ủy quyền, capture, và webhook.
Khi bạn bỏ qua mandate_id
Server sẽ coi charge này là không định kỳ và tự chọn một mandate theo chiến lược riêng của nó, dựa trên các thỏa thuận không định kỳ đang hoạt động của phương thức thanh toán đó. Các thỏa thuận định kỳ cố tình bị loại khỏi việc tìm kiếm này.
Mandate nào được chọn không phải là một phần của hợp đồng giao tiếp (API contract). Đừng phụ thuộc vào lựa chọn này: nếu có nhiều hơn một thỏa thuận không định kỳ tồn tại trên cùng một thẻ, mandate bạn nhận được có thể không phải là mandate bạn muốn.
| Tình huống | Có cần truyền mandate_id? |
|---|---|
| Tính phí định kỳ / subscription / trả góp | Có — bắt buộc trong thực tế. Cơ chế fallback không bao giờ chọn một thỏa thuận định kỳ, vì vậy một subscription bị bỏ mặc cho giá trị mặc định sẽ bị tính phí như không định kỳ, dưới sai thỏa thuận. |
| Nhiều thỏa thuận trên cùng một thẻ (ví dụ một subscription cộng với các lần nạp tiền tùy ý) | Có. Chỉ rõ mandate bạn đang tính phí — cơ chế fallback không thể phân biệt chúng. |
| Tính phí thẻ lưu sẵn không định kỳ, chỉ đúng một thỏa thuận cho mỗi thẻ | Tùy chọn. Chỉ có một ứng viên duy nhất, nên cơ chế fallback sẽ chọn đúng nó. |
Việc chỉ rõ mandate luôn an toàn, và là lựa chọn mặc định được khuyến nghị.
Các mandate được tạo trước khi có tính năng này
Một mandate không ghi nhận marker nào cho mạng thẻ — mọi mandate được tạo trước khi thỏa thuận định kỳ được hỗ trợ — sẽ tiếp tục được coi là không định kỳ. Đây là hướng an toàn (một marker không định kỳ trên một khoản trả góp định kỳ là một sự hạ cấp mà các mạng thẻ chấp nhận; chiều ngược lại là một tranh chấp bạn sẽ thua), nhưng điều này có nghĩa là các subscription hiện có sẽ tiếp tục được tính phí như không định kỳ cho đến khi bạn thiết lập một mandate định kỳ mới.
Quy tắc & giới hạn
Thiết lập thỏa thuận (Bước 1):
setup_future_usagevàpayment_method_options.card.setup_future_usage_cadencechỉ áp dụng lúc tạo.ConfirmPaymentIntentbỏ quapayment_method_options;ConfirmPaymentIntentWithClientSecretkhông chấp nhận trường này.setup_future_usage=off_sessionyêu cầumandate_data.customer_acceptance— do bạn cung cấp lúc tạo, hoặc được ghi nhận tự động khi SDK xác nhận bằng client secret.setup_future_usageyêu cầu mộtcustomer_id.setup_future_usagekhông thể kết hợp vớioff_session=true, cũng không thể kết hợp vớirequest_three_d_secure=never.- Một SetupIntent chỉ có thể tạo ra một mandate không định kỳ.
setup_future_usage=on_sessionkèmsetup_future_usage_cadence=recurringkhai báo thỏa thuận trên giao dịch ủy quyền nhưng không tạo mandate nào — việc tạo mandate chỉ diễn ra ở off-session. Dùngoff_sessionkhi bạn có ý định tính phí mà không có khách hàng.
Tính phí theo mandate (Bước 3):
mandate_idchỉ có thể được cung cấp trên lệnh gọi create-with-confirm (POST /api/payment_intentsvớiconfirm=true).ConfirmPaymentIntentkhông chấp nhận trường này.mandate_idđược gửi kèmconfirm=falsesẽ bị bỏ qua.mandate_idtrên một thanh toán on-session (off_session=false) sẽ bị từ chối — một mandate chỉ được thực thi khi khách hàng vắng mặt.- Bỏ qua
mandate_idtrên một thanh toán off-session sẽ không gây lỗi: server sẽ tự chọn một mandate không định kỳ theo chiến lược riêng, và mandate nào được chọn không được xác định trước. - Một mandate được chỉ rõ phải thuộc về
payment_method_idđang được tính phí và phải ở trạng tháiactive, nếu không request sẽ bị từ chối. - Không có endpoint công khai nào để liệt kê hoặc đọc mandate.
mandate_idchỉ đến với bạn qua charge, như ở Bước 2 — hãy lưu lại nó.
Lỗi
| Điều kiện | Response |
|---|---|
setup_future_usage=off_session và không có mandate_data từ cả lệnh gọi create lẫn confirm | mandate_data is required for setup_future_usage=off-session |
setup_future_usage kèm off_session=true | setup_future_usage is not allowed for off-session payment |
setup_future_usage kèm request_three_d_secure=never | request_three_d_secure=never is not allowed with setup_future_usage |
setup_future_usage mà không có customer_id | Customer is required to save a payment method |
Mandate được chỉ rõ không tồn tại, thuộc về một phương thức thanh toán khác, hoặc không active | no active mandate found for this payment method |
Không có mandate_id nào được truyền và phương thức thanh toán không có mandate không định kỳ nào đang hoạt động | no active mandate found for this payment method |
mandate_id được truyền kèm off_session=false | cannot provide mandate in on-session payment |
off_session=true mà không có payment_method_id | payment_method_id is required for off-session |
Xem Xử lý lỗi để biết về cấu trúc lỗi và hướng dẫn retry.
Webhook
Các charge MIT phát ra cùng các event như bất kỳ thanh toán nào khác — hãy coi webhook là kết quả chính thức.
| Event | Mô tả |
|---|---|
payment_intent.status.succeeded | Charge đã được capture. Đánh dấu lần gia hạn đã thanh toán. |
payment_intent.payment_failed | Bị từ chối. Thử lại theo chính sách dunning của bạn, hoặc yêu cầu khách hàng cập nhật thẻ — điều này đồng nghĩa với việc thiết lập một mandate mới. |
charge.captured | Charge đã được capture. |