Luồng dựa trên token
Token hóa thẻ trên trình duyệt, gửi token đến backend, và để backend tạo và xác nhận thanh toán. Dùng luồng này khi server của bạn chốt số tiền cuối cùng sau khi biết thẻ — giảm giá, phụ phí, kiểm tra gian lận — hoặc khi bạn xác nhận thanh toán phía server.
Nếu số tiền đã biết trước khi thanh toán, Xác nhận thanh toán đơn giản hơn.
Apple Pay và Google Pay dùng chính luồng này khi bạn gọi bahn.mountApplePay()/bahn.mountGooglePay() mà không có clientSecret: nút thanh toán phát sự kiện "token" kèm confirmation_token thay vì xác nhận thanh toán, và bạn tiếp tục từ bước 2.
Ví dụ đầy đủ
Code trình duyệt và server Node.js cho luồng này: Ví dụ tích hợp → Luồng dựa trên token.
Bước 1: Tạo confirmation token
const bahn = new Bahn({ pk: "pk_live_...", environment: "production" });
bahn.mountCard("#card-container");
const { confirmation_token } = await bahn.createConfirmationToken({
payment_method: {
billing_details: {
name: "Nguyen Van A",
email: "[email protected]",
phone: "+84901234567",
address: {
country: "VN",
city: "Ho Chi Minh City",
line1: "123 Nguyen Hue",
line2: "District 1",
postal_code: "70000",
},
},
},
});Bước 2: Gửi token đến backend
const response = await fetch("/api/confirm-payment", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
confirmation_token,
amount: 100000,
currency: "vnd",
}),
});
const { client_secret } = await response.json();Backend của bạn tạo và xác nhận payment intent bằng token. Xem Hoàn tất thanh toán trên server.
Bước 3: Xử lý hành động tiếp theo ở phía client
try {
await bahn.handleNextAction(client_secret, {
onReady: () => console.log("Setting up..."),
onSuccess: (paymentIntent) => redirectToSuccess(paymentIntent),
onFailure: (paymentIntentOrError) => redirectToFailure(),
onTimeout: () => console.warn("Polling timed out"),
onError: (error) => console.error("Error:", error),
onProgress: (attempt, max, status) => console.log(`Polling: ${attempt}/${max} — ${status}`),
});
} catch (error) {
// Chưa mount iframe thanh toán, hoặc không lấy được payment intent
showGenericError();
}Cách hoạt động
sequenceDiagram
participant App as Your App
participant SDK as Bahn SDK
participant Iframe as Card Iframe
participant Backend as Your Backend
participant API as Cake API
App->>SDK: mountCard
App->>SDK: createConfirmationToken({ payment_method })
SDK->>Iframe: Delegate + merge card data
Iframe->>API: POST /api/sdk/confirmation_token
API-->>Iframe: { confirmation_token }
Iframe-->>SDK: Result (card data stays in iframe)
SDK-->>App: { confirmation_token }
App->>Backend: Send token + amount/currency
Backend->>API: Create and confirm PaymentIntent with token
API-->>Backend: PaymentIntent
Backend-->>App: { client_secret }
App->>SDK: handleNextAction(client_secret, callbacks)
SDK->>Iframe: get-payment-intent (relay)
Iframe->>API: GET payment intent status
API-->>Iframe: PaymentIntent
Iframe-->>SDK: PaymentIntent
alt requires_action (3DS)
SDK->>SDK: Decode 3DS payload, mount challenge
SDK->>SDK: On success → start polling (via Iframe)
else succeeded
SDK-->>App: callbacks.onSuccess(paymentIntent)
else failed
SDK-->>App: callbacks.onFailure(paymentIntent)
end
bahn.createConfirmationToken(payload)
Tạo một token dùng một lần cho thẻ trong các trường thẻ đã mount. Dữ liệu thẻ được ghép bên trong iframe — token an toàn để gửi đến backend của bạn.
createConfirmationToken(payload: Bahn.CreateConfirmationTokenOptions): Promise<Bahn.ConfirmationTokenResult>;Tham số — CreateConfirmationTokenOptions:
| Trường | Kiểu | Bắt buộc | Mô tả |
|---|---|---|---|
payment_method.billing_details | ConfirmationTokenBillingDetails | Không | Tên, email, số điện thoại và địa chỉ của khách hàng |
Giá trị trả về — ConfirmationTokenResult:
interface ConfirmationTokenResult {
confirmation_token: string;
}Reject với APIError nếu pk chưa được cấu hình, cùng mọi trường hợp đã liệt kê cho confirmPayment(): chưa gọi mountCard(), trường thẻ không hợp lệ, lỗi HTTP và lỗi mạng.
bahn.handleNextAction(input, callbacks)
Xử lý hành động tiếp theo cần thiết cho một payment intent: thử thách 3DS, polling, hoặc trạng thái cuối ngay lập tức.
handleNextAction(input: Bahn.PaymentIntentInput, callbacks: Bahn.HandleNextActionCallbacks): Promise<void>;input (PaymentIntentInput) chấp nhận một trong hai dạng:
client_secret(string) — SDK tự lấy payment intent quaget-payment-intent. Yêu cầumountCard(),mountApplePay(), hoặcmountGooglePay()đang hoạt động — lệnh gọi này phải đi qua một trong các iframe đó (backend không cho phép CORS trên endpoint này, nên một fetch trực tiếp từ trang của bạn sẽ bị chặn). Nó không chạm vào dữ liệu thẻ, nên không nhất thiết cần iframe thẻ — một iframe Apple Pay hoặc Google Pay còn được mount sau khi luồng dựa trên token của nó hoàn tất cũng hoạt động tương tự. Nếurequires_action, nó poll trạng thái payment intent sau khi thử thách 3DS thành công cho đến khi đạt trạng thái cuối.- một đối tượng
payment_intent— nếu backend của bạn đã xác nhận thanh toán và trả vềpayment_intenttrong response, hãy truyền trực tiếp đối tượng đó (phải bao gồmclient_secret). Cách này bỏ qua việc fetchget-payment-intent, nên không cần mount iframe thanh toán nào. Nó không bao giờ poll: nếurequires_action,onSuccesskích hoạt ngay khi thử thách 3DS thành công, với cùng đối tượngpayment_intentbạn đã truyền vào — trạng thái cuối không được xác minh lại với backend.
Callback — HandleNextActionCallbacks:
| Callback | Chữ ký | Khi nào |
|---|---|---|
onReady | () => void | Thử thách 3DS đã sẵn sàng |
onSuccess | (paymentIntent: Record<string, unknown>) => void | Thành công (trạng thái cuối) |
onFailure | (errorOrIntent?: ComponentError | Record<string, unknown>) => void | Thất bại (trạng thái cuối), hoặc thử thách 3DS thất bại (ComponentError) |
onTimeout | () => void | Polling đạt số lần tối đa mà chưa có trạng thái cuối |
onError | (error: Error) => void | Polling hết số lần thử và lần poll cuối thất bại (xem pollPaymentIntentStatus), hoặc đầu vào là đối tượng payment_intent có trạng thái không phải cuối |
onProgress | (attempt: number, maxAttempts: number, status: string) => void | Mỗi lần poll trả về payment intent |
Reject với APIError — chỉ với đầu vào client_secret — trước khi bất kỳ callback nào được gọi, khi chưa mount iframe thanh toán nào hoặc lần fetch get-payment-intent đầu tiên thất bại (lỗi HTTP hoặc lỗi mạng). Mọi kết quả sau đó, kể cả 3DS và polling, đều được báo qua callback. Đầu vào là đối tượng payment_intent không bao giờ reject.
Cách hoạt động (đầu vào client_secret):
- Lấy payment intent hiện tại qua
get-payment-intent - Nếu
succeeded/requires_capture/requires_confirmation→ gọionSuccessngay - Nếu
canceled/failed/requires_payment_method→ gọionFailurengay - Nếu
requires_actionvới 3DS → giải mã và mount thử thách 3DS, sau đó poll khi thành công cho đến khi đạt trạng thái cuối - Với các trạng thái khác (
processing) → poll trạng thái payment intent mỗi 3 giây cho đến khi đạt trạng thái cuối hoặc hết số lần thử tối đa
Cách hoạt động (đầu vào là đối tượng payment_intent): giống hệt bước 1–3 ở trên (không cần fetch, trạng thái được đọc trực tiếp từ đối tượng), nhưng bước 4 khác — sau khi thử thách 3DS thành công, onSuccess được gọi ngay với cùng đối tượng payment_intent, không poll và không cần iframe thanh toán. Một trạng thái không phải cuối, không phải requires_action (ví dụ processing) sẽ gọi onError — luồng này không bao giờ poll, nên không có cách nào giải quyết tiếp từ đây.
Truyền một payment_intent mà backend của bạn đã có
// Response từ backend đã bao gồm payment_intent, ví dụ:
// { payment_intent: { id, client_secret, status: "requires_action", next_action: {...}, ... } }
const { payment_intent } = await fetch("/api/confirm-payment", { method: "POST" }).then((r) => r.json());
await bahn.handleNextAction(payment_intent, {
onSuccess: (paymentIntent) => redirectToSuccess(),
onFailure: () => redirectToFailure(),
});Lưu ý: luồng này bỏ qua lần fetch
get-payment-intentban đầu và không bao giờ poll sau đó. Hãy dùng đầu vàoclient_secretnếu bạn cần xác minh lại trạng thái cuối (ví dụ backend áp dụng thêm kiểm tra rủi ro sau 3DS) — khi đó bắt buộc phải cómountCard()/mountApplePay()/mountGooglePay(), vì polling luôn đi qua một iframe thanh toán đã mount (CORS).
bahn.pollPaymentIntentStatus(clientSecret, callbacks)
Poll trạng thái payment intent mỗi 3 giây cho đến khi đạt trạng thái cuối hoặc hết số lần thử tối đa. handleNextAction() gọi phương thức này thay bạn; hãy gọi trực tiếp sau một thử thách mount3DS() trong luồng Xác nhận thanh toán.
pollPaymentIntentStatus(clientSecret: string, callbacks: Bahn.PaymentIntentPollCallbacks): void;bahn.pollPaymentIntentStatus("pi_secret_abc", {
onProgress: (attempt, max, status) => console.log(`Poll ${attempt}/${max}: ${status}`),
onSuccess: (paymentIntent) => redirectToSuccess(),
onFailure: (paymentIntent) => redirectToFailure(),
onTimeout: () => showTimeoutMessage(),
onError: (error) => console.error("Poll error:", error.message),
});Callback — PaymentIntentPollCallbacks:
| Callback | Khi nào |
|---|---|
onSuccess | Thành công (trạng thái cuối): succeeded, requires_capture, requires_confirmation |
onFailure | Thất bại (trạng thái cuối): canceled, failed, requires_payment_method |
onTimeout | Đạt số lần tối đa mà chưa có trạng thái cuối |
onError | Đạt số lần tối đa và lần poll cuối thất bại (lỗi mạng, lỗi HTTP hoặc response sai định dạng) — nhận lỗi đó. Các lần poll lỗi trước đó được thử lại mà không báo |
onProgress | Mỗi lần poll trả về intent — nhận (attempt, maxAttempts, status) |
Số lần tối đa được cấu hình qua paymentIntentPollAttempts trong BahnOptions (mặc định: 10).
bahn.stopPaymentIntentPolling()
Dừng mọi quá trình polling payment intent đang chạy.
window.addEventListener("beforeunload", () => {
bahn.stopPaymentIntentPolling();
});bahn.cleanup() cũng dừng polling và unmount mọi component. Xem Tham chiếu SDK.