Ví dụ tích hợp
Code hoàn chỉnh, sao chép là dùng được, cho từng luồng trong Luồng thanh toán. Mỗi ví dụ là một trang đầy đủ, nên xử lý mọi kết quả: thành công, bị từ chối, 3DS, trạng thái chưa phải cuối, và lỗi.
| Ví dụ | Luồng | Nội dung |
|---|---|---|
| Xác nhận thanh toán | Xác nhận thanh toán | Trang thanh toán HTML: tên chủ thẻ, trường thẻ, confirmPayment(), 3DS, polling |
| Xác nhận thanh toán với React | Xác nhận thanh toán | Component PaymentForm có kiểu, tái sử dụng được |
| Luồng dựa trên token | Luồng dựa trên token | Trình duyệt + server Node.js: token hóa, xác nhận phía server, handleNextAction() |
| Lưu thẻ | Lưu thẻ | Trình duyệt + server: tạo setup intent, confirmCardSetup() |
| 3DS trên trang riêng | Xác thực 3DS | Chuyển hướng toàn trang với getChallengeRedirectUrl() và trang quay về |
| Apple Pay | Apple Pay | Nút Apple Pay, xác nhận trực tiếp |
| Google Pay | Google Pay | Nút Google Pay, xác nhận trực tiếp |
Mọi ví dụ đều tải SDK bằng cùng một thẻ <script> đã pin phiên bản như ở Bắt đầu, thẻ này định nghĩa biến toàn cục Bahn. Với TypeScript, hãy thêm bahn.d.ts vào dự án — các ví dụ có kiểu bên dưới cần file này.
Các endpoint /api/... mà trình duyệt gọi là của backend của bạn. Mỗi endpoint gửi một request Bahn API đã ký — xem Xác thực — và ví dụ Luồng dựa trên token có một server hoàn chỉnh.
Xác nhận thanh toán
Một trang thanh toán cho luồng Xác nhận thanh toán. Backend tạo payment intent khi khách hàng bấm Thanh toán, và trình duyệt xác nhận nó bằng thẻ khách hàng đã nhập.
Các trường thẻ của SDK chỉ gồm số thẻ, ngày hết hạn và CVC. Hãy thu thập tên in trên thẻ bằng một ô nhập trên trang của bạn, đặt sau các trường thẻ và tạo kiểu giống chúng (CSS .field bên dưới), rồi gửi nó dưới dạng payment_method.billing_details.name. Tên không phải dữ liệu thẻ, nên không cần iframe. Email và số điện thoại cũng vậy.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Checkout</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #101828; }
#checkout { max-width: 480px; margin: 40px auto; padding: 0 16px; }
/* Ô nhập của riêng bạn, tạo kiểu khớp với các trường thẻ của SDK: ô 62px, khoảng cách hàng 8px,
30px bên dưới cho thông báo lỗi — cùng nhịp với các iframe thẻ */
.field { position: relative; box-sizing: border-box; height: 62px; margin: 8px 0 30px; border: 2px solid transparent;
border-radius: 12px; background: #f8f8f8; transition: border-color 200ms ease; }
.field:focus-within { border-color: #3b82f6; }
.field input { box-sizing: border-box; width: 100%; height: 100%; padding: 2rem 0.875rem 0.625rem;
border: none; border-radius: 10px; outline: none; background: transparent;
font: inherit; font-size: 16px; font-weight: 500; color: #101828; }
.field input::placeholder { color: transparent; font-weight: 400; }
.field input:focus::placeholder { color: #737373; }
.field label { position: absolute; top: 0.5rem; left: 0.875rem; font-size: 12px; color: #737373;
pointer-events: none; transition: all 200ms ease; }
.field input:placeholder-shown:not(:focus) + label { top: 50%; transform: translateY(-50%); }
.field .required { color: #ef4444; }
#pay-btn { width: 100%; height: 52px; border: none; border-radius: 12px; background: #2563eb;
color: #fff; font: inherit; font-size: 16px; font-weight: 600; cursor: pointer; }
#pay-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#status { margin-top: 12px; font-size: 14px; color: #4b5563; }
</style>
</head>
<body>
<div id="checkout">
<!-- 1. Số thẻ, ngày hết hạn và CVC: các iframe của SDK -->
<div id="card-container"></div>
<!-- 2. Tên chủ thẻ: ô nhập của riêng bạn — các trường thẻ của SDK không có trường này -->
<div class="field">
<input id="cardholder-name" autocomplete="cc-name" placeholder="NGUYEN VAN A">
<label for="cardholder-name">Name on card <span class="required">*</span></label>
</div>
<button id="pay-btn" disabled>Pay 100,000 ₫</button>
<div id="status" role="status"></div>
</div>
<script
src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
crossorigin="anonymous"
></script>
<script>
const payBtn = document.getElementById("pay-btn");
const nameInput = document.getElementById("cardholder-name");
const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
const fail = (msg) => { setStatus(msg); payBtn.disabled = false; };
const succeed = () => { window.location.href = "/success"; };
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
const card = bahn.mountCard("#card-container", {
fields: {
number: { label: "Card number", placeholder: "1234 5678 9012 3456" },
expiry: { label: "Expiry date", placeholder: "MM / YY" },
cvc: { label: "CVC/CVV", tooltip: true },
},
});
// Chỉ bật nút Thanh toán khi đã nhập đủ trường thẻ và tên
let cardComplete = false;
const updatePayButton = () => { payBtn.disabled = !(cardComplete && nameInput.value.trim()); };
card.on("change", (values) => { cardComplete = Boolean(values?.isComplete); updatePayButton(); });
nameInput.addEventListener("input", updatePayButton);
// Poll cho đến khi thanh toán đạt trạng thái cuối
const pollUntilFinal = (clientSecret) =>
bahn.pollPaymentIntentStatus(clientSecret, {
onProgress: () => setStatus("Checking payment status..."),
onSuccess: succeed,
onFailure: () => fail("Payment was declined"),
onTimeout: () => fail("Still processing — check your order status shortly"),
onError: () => fail("Could not check the payment status"),
});
payBtn.addEventListener("click", async () => {
payBtn.disabled = true;
setStatus("Processing...");
try {
// Tạo payment intent khi khách hàng bấm Thanh toán, không phải khi tải trang —
// nếu không, mỗi lượt xem trang sẽ tạo một intent. Gửi mã đơn hàng, không gửi số tiền.
const res = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: "order_123" }),
});
if (!res.ok) throw new Error("Could not start the payment");
const { client_secret } = await res.json();
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({
client_secret,
// Số thẻ, ngày hết hạn và CVC được thêm bên trong iframe; tên lấy từ ô nhập của bạn
payment_method: {
billing_details: { name: nameInput.value.trim() },
},
});
const { status } = payment_intent;
if (three_ds_payload) {
setStatus("Verifying with your bank...");
bahn.mount3DS(three_ds_payload.id, {
// Qua được 3DS không có nghĩa là thanh toán thành công — hãy poll trạng thái cuối
onSuccess: () => pollUntilFinal(client_secret),
onFailure: (err) => fail(`Verification failed: ${err?.message ?? "unknown error"}`),
});
} else if (["succeeded", "requires_capture", "requires_confirmation"].includes(status)) {
succeed();
} else if (["failed", "canceled", "requires_payment_method"].includes(status)) {
fail("Payment was declined");
} else {
pollUntilFinal(client_secret); // ví dụ "processing"
}
} catch (err) {
// isUserError: có thể hiển thị message (thẻ không hợp lệ, request bị từ chối)
fail(err?.name === "APIError" && err.isUserError ? err.message : "Payment failed — please try again");
}
});
</script>
</body>
</html>Nếu bạn tùy chỉnh kiểu các trường thẻ bằng inputStyle hoặc stateStyles, hãy đổi CSS .field tương ứng để ô nhập tên vẫn khớp — xem Thanh toán thẻ. Dùng thẻ test sandbox để thử các nhánh 3DS và bị từ chối.
Xác nhận thanh toán với React
Cùng luồng đó dưới dạng component PaymentForm có kiểu, tái sử dụng được. Tải SDK một lần bằng thẻ <script> trong index.html, và thêm bahn.d.ts vào dự án để Bahn và các kiểu Bahn.* được nhận diện.
Component này chạy được với React StrictMode (chế độ mount effect hai lần khi phát triển): new Bahn() luôn trả về cùng một singleton, mountCard() thay thế thẻ đã mount trước đó, và hàm cleanup gọi bahn.cleanup(). Đây là component chỉ chạy phía client — nếu dùng server-side rendering (Next.js, Remix), chỉ render nó trên trình duyệt, vì Bahn không tồn tại trên server.
import { useCallback, useEffect, useRef, useState } from "react";
interface PaymentFormProps {
orderId: string;
amountLabel: string; // ví dụ "100.000 ₫" — chỉ để hiển thị; backend của bạn quyết định số tiền thật
onSuccess: (paymentIntent: Record<string, unknown>) => void;
onError: (message: string) => void;
}
const SUCCESS = ["succeeded", "requires_capture", "requires_confirmation"];
const FAILURE = ["failed", "canceled", "requires_payment_method"];
// Một id duy nhất, an toàn cho CSS selector, để nhiều form có thể cùng nằm trên một trang
let nextId = 0;
export function PaymentForm({ orderId, amountLabel, onSuccess, onError }: PaymentFormProps) {
const [containerId] = useState(() => `bahn-card-${++nextId}`);
const bahnRef = useRef<Bahn | null>(null);
const [isComplete, setIsComplete] = useState(false);
const [cardholderName, setCardholderName] = useState(""); // các trường thẻ của SDK không có tên
const [isProcessing, setIsProcessing] = useState(false);
useEffect(() => {
const bahn = new Bahn({
pk: import.meta.env.VITE_BAHN_PUBLISHABLE_KEY,
environment: import.meta.env.VITE_BAHN_ENVIRONMENT as Bahn.SDKEnvironment,
});
bahnRef.current = bahn;
const card = bahn.mountCard(`#${containerId}`, {
fields: {
number: { label: "Card number" },
expiry: { label: "Expiry date" },
cvc: { label: "CVC/CVV" },
},
});
card.on("change", (values) => {
setIsComplete((values as Bahn.CardComponent["values"] | undefined)?.isComplete ?? false);
});
// Unmount thẻ và thử thách 3DS (nếu có), đồng thời dừng polling
return () => bahn.cleanup();
}, [containerId]);
const handlePay = useCallback(async () => {
const bahn = bahnRef.current;
if (!bahn) return;
const done = (paymentIntent: Record<string, unknown>) => {
setIsProcessing(false);
onSuccess(paymentIntent);
};
const fail = (message: string) => {
setIsProcessing(false);
onError(message);
};
const pollCallbacks: Bahn.PaymentIntentPollCallbacks = {
onSuccess: done,
onFailure: () => fail("Payment was declined"),
onTimeout: () => fail("Payment is still processing"),
onError: () => fail("Could not check the payment status"),
};
setIsProcessing(true);
try {
const res = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: orderId }),
});
if (!res.ok) throw new Error("Could not start the payment");
const { client_secret } = (await res.json()) as { client_secret: string };
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({
client_secret,
payment_method: { billing_details: { name: cardholderName.trim() } },
});
const status = payment_intent.status as string;
if (three_ds_payload) {
bahn.mount3DS(three_ds_payload.id, {
// Qua được 3DS không có nghĩa là thanh toán thành công — hãy poll trạng thái cuối
onSuccess: () => bahn.pollPaymentIntentStatus(client_secret, pollCallbacks),
onFailure: (err) => fail(`Verification failed: ${(err as Bahn.ComponentError)?.message ?? "unknown error"}`),
});
} else if (SUCCESS.includes(status)) {
done(payment_intent);
} else if (FAILURE.includes(status)) {
fail("Payment was declined");
} else {
bahn.pollPaymentIntentStatus(client_secret, pollCallbacks); // ví dụ "processing"
}
} catch (err) {
const apiError = err as Bahn.APIError;
fail(apiError?.name === "APIError" && apiError.isUserError ? apiError.message : "Payment failed");
}
}, [orderId, cardholderName, onSuccess, onError]);
return (
<div>
{/* Số thẻ, ngày hết hạn và CVC: các iframe của SDK */}
<div id={containerId} />
{/* Tên chủ thẻ: ô nhập của riêng bạn, đặt sau các trường thẻ — tạo kiểu .field như ví dụ Xác nhận thanh toán */}
<div className="field">
<input
id={`${containerId}-name`}
value={cardholderName}
onChange={(e) => setCardholderName(e.target.value)}
autoComplete="cc-name"
placeholder="NGUYEN VAN A"
/>
<label htmlFor={`${containerId}-name`}>
Name on card <span className="required">*</span>
</label>
</div>
<button onClick={handlePay} disabled={!isComplete || !cardholderName.trim() || isProcessing}>
{isProcessing ? "Processing..." : `Pay ${amountLabel}`}
</button>
</div>
);
}Luồng dựa trên token
Toàn bộ Luồng dựa trên token, dùng khi backend chốt số tiền cuối cùng sau khi khách hàng nhập thẻ:
- Trình duyệt tạo một confirmation token dùng một lần. Dữ liệu thẻ vẫn nằm trong iframe.
- Server dùng token để tạo và xác nhận payment intent trong một lệnh gọi Bahn API đã ký, rồi trả về
client_secret. - Trình duyệt gọi
handleNextAction(). SDK hiển thị thử thách 3DS nếu cần và poll trạng thái cuối.
Giữ thẻ được mount từ bước 1 đến bước 3
createConfirmationToken() cần iframe thẻ, và handleNextAction() gửi request qua bất kỳ iframe thẻ, Apple Pay hoặc Google Pay nào đang được mount. Nếu bạn unmount thẻ trước, handleNextAction() sẽ reject.
Trình duyệt (checkout.ts)
Trang có cùng markup với ví dụ Xác nhận thanh toán — #card-container cho các trường thẻ, rồi ô #cardholder-name của riêng bạn — cùng một ô #email có cùng kiểu, và nút #pay-btn.
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
const card = bahn.mountCard("#card-container");
const payBtn = document.getElementById("pay-btn") as HTMLButtonElement;
// Ô nhập của riêng bạn — các trường thẻ của SDK không có tên hay email
const nameInput = document.getElementById("cardholder-name") as HTMLInputElement;
const emailInput = document.getElementById("email") as HTMLInputElement;
let cardComplete = false;
const updatePayButton = () => { payBtn.disabled = !(cardComplete && nameInput.value.trim()); };
card.on("change", (values) => {
cardComplete = Boolean((values as Bahn.CardComponent["values"] | undefined)?.isComplete);
updatePayButton();
});
nameInput.addEventListener("input", updatePayButton);
payBtn.addEventListener("click", async () => {
payBtn.disabled = true;
try {
// Bước 1: Tạo token — dữ liệu thẻ vẫn nằm trong iframe
const { confirmation_token } = await bahn.createConfirmationToken({
payment_method: {
billing_details: {
name: nameInput.value.trim(),
email: emailInput.value.trim() || undefined,
},
},
});
// Bước 2: Backend xác nhận bằng token và trả về client_secret.
// Gửi mã đơn hàng, không gửi số tiền — backend quyết định số tiền cần thu.
const res = await fetch("/api/payments/confirm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ confirmation_token, order_id: "order_123" }),
});
if (!res.ok) throw new Error("Payment could not be confirmed");
const { client_secret } = (await res.json()) as { client_secret: string };
// Bước 3: SDK chạy 3DS nếu cần và poll cho đến khi đạt trạng thái cuối
await bahn.handleNextAction(client_secret, {
onReady: () => console.log("3DS challenge loaded"),
onSuccess: () => { window.location.href = "/payment/success"; },
onFailure: () => { window.location.href = "/payment/failed"; },
onTimeout: () => { window.location.href = "/payment/processing"; },
// Polling dừng vì lần thử cuối thất bại — bản thân thanh toán vẫn có thể thành công
onError: () => { window.location.href = "/payment/processing"; },
});
} catch (err) {
// Reject trước khi có callback nào: thẻ không hợp lệ, request lỗi, hoặc chưa mount iframe
console.error("Payment error:", err);
payBtn.disabled = false;
}
});Server (Node.js / Express)
Lệnh gọi từ backend là một request Bahn REST API thông thường: API key đặt trong Bn-Api-Key, và body được ký JWS bằng private signing key của bạn. Xem Xác thực để biết key được cấp thế nào và ý nghĩa của từng header. Ví dụ dùng package jose để ký, và ví dụ Lưu thẻ dùng lại hàm bahnPost của nó.
import express from "express";
import { randomUUID } from "node:crypto";
import { CompactSign, importJWK } from "jose";
const app = express();
app.use(express.json());
const BAHN_API = "https://bn.cakedigibank.tech"; // production: https://bn.cake.vn
const API_KEY = process.env.BAHN_API_KEY!; // được cấp khi onboarding — chỉ dùng phía server
const SIGNING_KID = process.env.BAHN_SIGNING_KID!; // kid của public key bạn đã đăng ký
const signingKey = await importJWK(JSON.parse(process.env.BAHN_SIGNING_PRIVATE_JWK!), "ES256");
// Ký một body JSON cho một endpoint và POST nó đến Bahn
async function bahnPost(path: string, body: unknown, idempotencyKey: string) {
const jws = await new CompactSign(new TextEncoder().encode(JSON.stringify(body)))
.setProtectedHeader({
alg: "ES256",
kid: SIGNING_KID,
typ: "jose+json",
url: path, // gắn chữ ký với endpoint này
iat: Math.floor(Date.now() / 1000),
})
.sign(signingKey);
return fetch(`${BAHN_API}${path}`, {
method: "POST",
headers: {
"Content-Type": "application/jose",
"Bn-Api-Key": API_KEY,
"Bn-Jose-Method": "jws",
"Bn-Request-Id": randomUUID(), // giá trị mới cho mỗi lần thử
"Bn-Idempotency-Key": idempotencyKey, // giữ nguyên giá trị khi thử lại
},
body: jws,
});
}
// POST /api/payments/confirm — tạo và xác nhận payment intent bằng token
app.post("/api/payments/confirm", async (req, res) => {
const { confirmation_token, order_id } = req.body;
// Tra cứu số tiền ở phía bạn — không bao giờ tin số tiền do trình duyệt gửi
const order = await findOrder(order_id);
const response = await bahnPost(
"/api/payment_intents",
{
amount: order.amount, // số nguyên, đơn vị tiền tệ nhỏ nhất (VND: đồng)
currency: "704", // mã số ISO 4217 của VND
confirmation_token,
confirm: true,
metadata: { order_id },
},
`order_${order_id}_pay`,
);
if (!response.ok) {
const err = await response.json();
return res.status(response.status).json({ error: err.message ?? "Payment failed" });
}
// Bahn bọc đối tượng được tạo: { payment_intent: { ... } }
const { payment_intent } = await response.json();
// Chỉ trả về client_secret; trình duyệt truyền nó vào handleNextAction()
res.json({ client_secret: payment_intent.client_secret });
});
app.listen(3000);findOrder đại diện cho hàm tra cứu đơn hàng của riêng bạn. Hãy ghi nhận trạng thái thanh toán cuối cùng từ webhook, không phải từ response này — xem Webhooks.
Biến thể: trả về payment_intent
Nếu server trả về toàn bộ payment_intent (res.json({ payment_intent })), hãy truyền trực tiếp nó vào handleNextAction(). SDK bỏ qua việc fetch và không cần iframe đã mount — nhưng cũng không bao giờ poll, nên một trạng thái chưa phải cuối sẽ gọi onError. Xem Luồng dựa trên token → handleNextAction.
const { payment_intent } = await res.json();
await bahn.handleNextAction(payment_intent, {
onSuccess: () => { window.location.href = "/payment/success"; },
onFailure: () => { window.location.href = "/payment/failed"; },
// ví dụ "processing" — hiển thị trang đang xử lý và dựa vào webhook
onError: () => { window.location.href = "/payment/processing"; },
});Lưu thẻ
Luồng Lưu thẻ: lưu thẻ cho một customer mà không trừ tiền, để có thể trừ tiền sau. Xem Lưu thẻ & thanh toán định kỳ để biết cách tạo customer và trừ tiền thẻ đã lưu.
Trình duyệt
Cùng bố cục với ví dụ Xác nhận thanh toán: các trường thẻ, rồi đến ô nhập tên.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Save a card</title>
<style>
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #101828; }
#checkout { max-width: 480px; margin: 40px auto; padding: 0 16px; }
/* Ô nhập của riêng bạn, tạo kiểu khớp với các trường thẻ của SDK: ô 62px, khoảng cách hàng 8px,
30px bên dưới cho thông báo lỗi — cùng nhịp với các iframe thẻ */
.field { position: relative; box-sizing: border-box; height: 62px; margin: 8px 0 30px; border: 2px solid transparent;
border-radius: 12px; background: #f8f8f8; transition: border-color 200ms ease; }
.field:focus-within { border-color: #3b82f6; }
.field input { box-sizing: border-box; width: 100%; height: 100%; padding: 2rem 0.875rem 0.625rem;
border: none; border-radius: 10px; outline: none; background: transparent;
font: inherit; font-size: 16px; font-weight: 500; color: #101828; }
.field input::placeholder { color: transparent; font-weight: 400; }
.field input:focus::placeholder { color: #737373; }
.field label { position: absolute; top: 0.5rem; left: 0.875rem; font-size: 12px; color: #737373;
pointer-events: none; transition: all 200ms ease; }
.field input:placeholder-shown:not(:focus) + label { top: 50%; transform: translateY(-50%); }
.field .required { color: #ef4444; }
#save-btn { width: 100%; height: 52px; border: none; border-radius: 12px; background: #2563eb;
color: #fff; font: inherit; font-size: 16px; font-weight: 600; cursor: pointer; }
#save-btn:disabled { opacity: 0.5; cursor: not-allowed; }
#status { margin-top: 12px; font-size: 14px; color: #4b5563; }
</style>
</head>
<body>
<div id="checkout">
<!-- 1. Số thẻ, ngày hết hạn và CVC: các iframe của SDK -->
<div id="card-container"></div>
<!-- 2. Tên chủ thẻ: ô nhập của riêng bạn — các trường thẻ của SDK không có trường này -->
<div class="field">
<input id="cardholder-name" autocomplete="cc-name" placeholder="NGUYEN VAN A">
<label for="cardholder-name">Name on card <span class="required">*</span></label>
</div>
<button id="save-btn" disabled>Save card</button>
<div id="status" role="status"></div>
</div>
<script
src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
crossorigin="anonymous"
></script>
<script>
const saveBtn = document.getElementById("save-btn");
const nameInput = document.getElementById("cardholder-name");
const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
const fail = (msg) => { setStatus(msg); saveBtn.disabled = false; };
const saved = () => { window.location.href = "/account/cards?saved=1"; };
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
const card = bahn.mountCard("#card-container", {
fields: {
number: { label: "Card number" },
expiry: { label: "Expiry date" },
cvc: { label: "CVC/CVV" },
},
});
// Chỉ bật nút Lưu khi đã nhập đủ trường thẻ và tên
let cardComplete = false;
const updateSaveButton = () => { saveBtn.disabled = !(cardComplete && nameInput.value.trim()); };
card.on("change", (values) => { cardComplete = Boolean(values?.isComplete); updateSaveButton(); });
nameInput.addEventListener("input", updateSaveButton);
const pollUntilFinal = (clientSecret) =>
bahn.pollSetupIntentStatus(clientSecret, {
onSuccess: saved,
onFailure: () => fail("The card could not be saved"),
onTimeout: () => fail("Still verifying — check your saved cards shortly"),
onError: () => fail("Could not check the card status"),
});
saveBtn.addEventListener("click", async () => {
saveBtn.disabled = true;
setStatus("Saving...");
try {
// Backend tạo setup intent cho customer đang đăng nhập
const res = await fetch("/api/setup-intents", { method: "POST" });
if (!res.ok) throw new Error("Could not start saving the card");
const { client_secret } = await res.json();
const { setup_intent, three_ds_payload } = await bahn.confirmCardSetup({
client_secret,
payment_method: { billing_details: { name: nameInput.value.trim() } },
});
if (three_ds_payload) {
setStatus("Verifying with your bank...");
bahn.mount3DS(three_ds_payload.id, {
onSuccess: () => pollUntilFinal(client_secret),
onFailure: (err) => fail(`Verification failed: ${err?.message ?? "unknown error"}`),
});
} else if (setup_intent.status === "succeeded") {
saved();
} else if (["failed", "canceled", "requires_payment_method"].includes(setup_intent.status)) {
fail("The card could not be saved");
} else {
pollUntilFinal(client_secret);
}
} catch (err) {
fail(err?.name === "APIError" && err.isUserError ? err.message : "Could not save the card");
}
});
</script>
</body>
</html>Server
Thêm route này vào server của ví dụ Luồng dựa trên token — route dùng lại bahnPost.
// POST /api/setup-intents — tạo setup intent cho customer đang đăng nhập
app.post("/api/setup-intents", async (req, res) => {
const customerId = await getBahnCustomerId(req); // ánh xạ user của bạn → customer_id của Bahn
const response = await bahnPost(
"/api/setup_intents",
{
customer_id: customerId,
usage: "off_session", // bạn sẽ trừ tiền thẻ khi customer không có mặt
description: "Save card for later payments",
},
`save_card_${customerId}_${randomUUID()}`,
);
if (!response.ok) {
const err = await response.json();
return res.status(response.status).json({ error: err.message ?? "Could not create setup intent" });
}
const { setup_intent } = await response.json();
res.json({ client_secret: setup_intent.client_secret });
});getBahnCustomerId đại diện cho hàm tra cứu customer Bahn mà bạn đã tạo cho user này. Payment method của thẻ đã lưu có trên setup intent và webhook của nó — xem Lưu thẻ & thanh toán định kỳ.
3DS trên trang riêng
Thay vì overlay của mount3DS(), chuyển khách hàng sang trang thử thách độc lập của Cake rồi quay lại. Dùng cách này khi không thể dùng overlay — trong WebView của ứng dụng di động, hoặc trên một trang thanh toán bên ngoài. Xem cấu trúc URL tại Xác thực 3DS → Trang thử thách độc lập.
Trang thanh toán
Trong ví dụ Xác nhận thanh toán, thay lệnh gọi mount3DS() bằng một lần chuyển hướng:
// Bên trong click handler của nút Thanh toán, thay cho nhánh mount3DS()
const { payment_intent, three_ds_payload } = await bahn.confirmPayment({ client_secret });
if (three_ds_payload) {
window.location.href = bahn.getChallengeRedirectUrl(
three_ds_payload.id,
`${window.location.origin}/checkout/return?order_id=order_123`,
{ brandName: "My Store" },
);
return;
}Trang quay về (/checkout/return)
Trang thử thách chuyển hướng về kèm status (success, failure hoặc error), session và code khi thất bại. status là success chỉ có nghĩa là đã qua 3DS, và trang này không có thẻ nào được mount để poll — nên hãy hỏi backend trạng thái cuối của thanh toán.
async function handleChallengeReturn() {
const params = new URLSearchParams(window.location.search);
const status = params.get("status");
const orderId = params.get("order_id");
if (status !== "success") {
// ví dụ "challenge-failed", "challenge-cancelled" — xem Mã lỗi 3DS
showError(`Verification failed (${params.get("code") ?? status})`);
return;
}
// Backend lấy payment intent (GET /api/payment_intents/{id}) và trả về trạng thái của nó
const { payment_status } = await fetch(`/api/orders/${orderId}/payment-status`).then((r) => r.json());
if (["succeeded", "requires_capture", "requires_confirmation"].includes(payment_status)) {
window.location.href = "/success";
} else if (["failed", "canceled", "requires_payment_method"].includes(payment_status)) {
showError("Payment was declined");
} else {
window.location.href = "/payment/processing"; // chưa phải trạng thái cuối — dựa vào webhook
}
}
handleChallengeReturn();Xem mọi giá trị code tại Mã lỗi 3DS.
Apple Pay
Một nút Apple Pay dùng xác nhận trực tiếp: truyền client_secret và nút sẽ tự xác nhận thanh toán và chạy 3DS. Để dùng luồng dựa trên token, truyền undefined và lắng nghe sự kiện "token" — xem Apple Pay.
Khác với ví dụ Xác nhận thanh toán, ví dụ này tạo payment intent ngay khi tải trang: xác nhận trực tiếp cần client_secret trước khi nút được mount. Để tránh tạo một intent cho mỗi lượt xem trang, hãy dùng luồng dựa trên token, luồng này không tạo gì cho đến khi khách hàng thanh toán.
isApplePayAvailable() là tùy chọn. mountApplePay() không hiển thị gì khi không thể dùng Apple Pay; chỉ gọi bước kiểm tra này trước nếu bạn muốn ẩn phần giao diện xung quanh của mình, như mục "Or pay with" ở đây.
<section id="wallet-section" hidden>
<p>Or pay with</p>
<div id="apple-pay-container"></div>
</section>
<div id="status" role="status"></div>
<script
src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
crossorigin="anonymous"
></script>
<script>
const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
async function setUpApplePay() {
if (!(await bahn.isApplePayAvailable())) return; // giữ mục này ở trạng thái ẩn
document.getElementById("wallet-section").hidden = false; // hiện mục này trước khi mount nút
// client_secret đến từ backend của bạn (tạo payment intent phía server)
const { client_secret } = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: "order_123" }),
}).then((r) => r.json());
const ap = bahn.mountApplePay("#apple-pay-container", client_secret, {
amount: 100000, // đơn vị tiền tệ nhỏ nhất — VND giữ nguyên
currency: "VND",
country: "VN",
// merchantName, supportedNetworks và merchantCapabilities mặc định lấy
// giá trị đã cấu hình cho merchant của bạn — chỉ đặt khi cần ghi đè
});
ap.on("ready", () => setStatus(""));
// Hiển thị trang kết quả; backend ghi nhận trạng thái cuối từ webhook
ap.on("success", () => { window.location.href = "/success"; });
ap.on("error", (err) => setStatus(err?.message || "Payment failed"));
ap.on("cancel", () => setStatus("Payment cancelled"));
}
setUpApplePay();
</script>Google Pay
Một nút Google Pay dùng xác nhận trực tiếp, giống Apple Pay ở trên — kể cả việc tạo payment intent khi tải trang. Truyền undefined thay cho client_secret để dùng luồng dựa trên token, luồng này không tạo gì cho đến khi khách hàng thanh toán — xem Google Pay.
<section id="wallet-section" hidden>
<p>Or pay with</p>
<div id="google-pay-container"></div>
</section>
<div id="status" role="status"></div>
<script
src="https://bn.cake.vn/bn-sdk/v2.4.0/index.js"
integrity="sha384-jBbonUZLgKU82F+X/ITqcmeInXYYwZYdmCDoPFX8wkWL9dCkeUHyi0Fs1EUnkeDS"
crossorigin="anonymous"
></script>
<script>
const setStatus = (msg) => { document.getElementById("status").textContent = msg; };
const bahn = new Bahn({ pk: "pk_test_your_key_here", environment: "sandbox" });
async function setUpGooglePay() {
if (!(await bahn.isGooglePayAvailable())) return; // giữ mục này ở trạng thái ẩn
document.getElementById("wallet-section").hidden = false; // hiện mục này trước khi mount nút
// client_secret đến từ backend của bạn (tạo payment intent phía server)
const { client_secret } = await fetch("/api/create-payment-intent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ order_id: "order_123" }),
}).then((r) => r.json());
const gp = bahn.mountGooglePay("#google-pay-container", client_secret, {
amount: 100000, // đơn vị tiền tệ nhỏ nhất — VND giữ nguyên
currency: "VND",
country: "VN",
});
gp.on("ready", () => setStatus(""));
// Hiển thị trang kết quả; backend ghi nhận trạng thái cuối từ webhook
gp.on("success", () => { window.location.href = "/success"; });
gp.on("error", (err) => setStatus(err?.message || "Payment failed"));
gp.on("cancel", () => setStatus("Payment cancelled"));
}
setUpGooglePay();
</script>