錯誤處理
處理付款失敗與結帳錯誤
錯誤處理
Recur SDK 提供完整的錯誤處理機制,讓您可以優雅地處理付款失敗和其他結帳錯誤。
錯誤類型
SDK 區分兩種錯誤類型:
| 回調 | 觸發時機 | 說明 |
|---|---|---|
onPaymentFailed | 用戶送出付款後失敗 | 卡片被拒絕、餘額不足等 |
onError | 結帳流程中發生錯誤 | 網路問題、API 錯誤、重複訂閱等 |
onPaymentFailed 是專門為付款失敗設計的回調,讓您可以根據失敗原因提供更好的用戶體驗。
API 錯誤碼
Recur API 使用標準的 HTTP 狀態碼和錯誤碼:
| HTTP 狀態 | 錯誤碼 | 說明 |
|---|---|---|
| 400 | bad_request | 請求參數無效 |
| 401 | unauthorized | API Key 無效或缺失 |
| 402 | payment_required | 付款失敗 |
| 403 | forbidden | 無權存取此資源 |
| 404 | not_found | 找不到資源 |
| 409 | conflict | 資源衝突(如重複訂閱) |
| 422 | validation_error | 驗證失敗 |
| 429 | rate_limit_exceeded | 請求過於頻繁 |
| 500 | internal_server_error | 伺服器錯誤 |
處理優惠碼錯誤(400)
當建立 Checkout Session 時帶入無效的 promotionCode,API 會回傳 400 錯誤:
{
"error": {
"code": "bad_request",
"message": "Promotion code 'EXPIRED123' is not valid: Code has expired"
}
}常見的優惠碼錯誤原因:
| 原因 | 說明 | 建議處理 |
|---|---|---|
| 優惠碼不存在 | 輸入的代碼找不到 | 提示用戶確認代碼是否正確 |
| 優惠碼已過期 | 超過有效期限 | 提示用戶此優惠已結束 |
| 已達使用上限 | 總使用次數或每人次數已滿 | 提示用戶此優惠已被領取完畢 |
| 不適用此商品 | 優惠碼限定特定商品 | 提示用戶此優惠不適用於目前選擇的方案 |
| 不符合顧客資格 | 限定新/舊客戶 | 提示用戶此優惠僅限新客戶或現有客戶 |
// 處理優惠碼錯誤
const res = await fetch('https://api.recur.tw/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
productId: 'prod_xxx',
promotionCode: userInputCode,
successUrl: 'https://your-site.com/success',
cancelUrl: 'https://your-site.com/cancel',
}),
});
if (res.status === 400) {
const { error } = await res.json();
// 向用戶顯示錯誤訊息
showError(error.message); // 例如 "Promotion code 'XXX' is not valid: Code has expired"
// 可以讓用戶重新輸入優惠碼,或不帶優惠碼重新建立 Session
}處理重複訂閱錯誤(409)
當客戶嘗試訂閱已有有效訂閱的商品時,API 會返回 409 conflict 錯誤。如果開發者在建立 Checkout 時帶入 customerEmail,此錯誤會在結帳建立階段就回傳,避免不必要的扣款。
await checkout({
productId: 'prod_xxx',
customerEmail: 'user@example.com',
onError: (error) => {
if (error.code === 'conflict') {
// 客戶已有該商品的有效訂閱
const details = error.details?.[0];
if (details?.existing_subscription_id) {
console.log('現有訂閱 ID:', details.existing_subscription_id);
console.log('訂閱狀態:', details.status);
// 引導用戶到 Customer Portal 管理訂閱
window.location.href = '/account/subscriptions';
}
}
},
});主動預防重複訂閱
除了處理 409 錯誤,您也可以在觸發結帳前主動檢查,提供更好的用戶體驗:
// 在後端觸發 Checkout 前先查詢
async function createCheckoutIfEligible(email: string, productId: string) {
const subRes = await fetch(
`https://api.recur.tw/v1/subscriptions?email=${email}&product_id=${productId}&active=true`,
{ headers: { 'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}` } }
);
const { has_active_subscription } = await subRes.json();
if (has_active_subscription) {
return { error: '您已訂閱此方案,請前往帳戶管理頁面' };
}
// 安全地建立 Checkout
const checkoutRes = await fetch('https://api.recur.tw/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
productId,
customerEmail: email,
successUrl: 'https://your-site.com/success',
cancelUrl: 'https://your-site.com/cancel',
}),
});
return checkoutRes.json();
}// 建立 Hosted Checkout Session 時處理 409
const res = await fetch('https://api.recur.tw/v1/checkout/sessions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RECUR_SECRET_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
productId: 'prod_xxx',
customerEmail: 'user@example.com',
successUrl: 'https://your-site.com/success',
cancelUrl: 'https://your-site.com/cancel',
}),
});
if (res.status === 409) {
const { error } = await res.json();
const existingSubId = error.details?.[0]?.existing_subscription_id;
// 引導到訂閱管理頁面或 Customer Portal
return redirect(`/account/subscriptions?existing=${existingSubId}`);
}付款失敗錯誤碼
當付款失敗時,error.details.failure_code 會包含以下其中一個錯誤碼:
| 錯誤碼 | 說明 | 可重試 |
|---|---|---|
PAYUNI_DECLINED | 銀行拒絕交易 | ❌ |
UNAPPROVED | 交易未獲授權 | ❌ |
INSUFFICIENT_FUNDS | 餘額不足 | ❌ |
CARD_DECLINED | 卡片被拒絕 | ❌ |
EXPIRED_CARD | 卡片已過期 | ❌ |
INVALID_CARD | 無效的卡號 | ❌ |
NETWORK_ERROR | 網路連線問題 | ✅ |
TIMEOUT | 交易逾時 | ✅ |
UNKNOWN | 未知錯誤 | ❌ |
「可重試」表示問題可能是暫時性的,用戶可以使用相同的卡片再次嘗試。不可重試的錯誤通常需要用戶更換卡片或聯繫銀行。
使用 onPaymentFailed
基本用法
onPaymentFailed 讓您可以自訂付款失敗的處理方式:
await checkout({
productId: 'prod_xxx',
onPaymentFailed: (error) => {
console.log('付款失敗:', {
code: error.details?.failure_code,
message: error.details?.failure_message,
canRetry: error.details?.can_retry,
});
// 回傳 undefined 使用 SDK 預設行為
},
});自訂處理邏輯
您可以根據不同的失敗原因回傳不同的處理動作:
await checkout({
productId: 'prod_xxx',
onPaymentFailed: (error) => {
const failureCode = error.details?.failure_code;
// 餘額不足 - 顯示自訂訊息
if (failureCode === 'INSUFFICIENT_FUNDS') {
return {
action: 'custom',
customTitle: '餘額不足',
customMessage: '您的卡片餘額不足,請使用其他付款方式或聯繫銀行。',
};
}
// 卡片過期 - 關閉結帳視窗
if (failureCode === 'EXPIRED_CARD') {
return { action: 'close' };
}
// 其他錯誤 - 使用預設行為(允許重試)
return undefined;
},
});處理動作
onPaymentFailed 可以回傳以下動作:
| action | 說明 |
|---|---|
'retry' | 顯示錯誤訊息,保持結帳視窗開啟讓用戶重試(預設行為) |
'close' | 關閉結帳視窗,觸發 onError 回調 |
'custom' | 顯示自訂的標題和訊息 |
// 允許重試(預設)
return { action: 'retry' };
// 關閉視窗
return { action: 'close' };
// 自訂訊息
return {
action: 'custom',
customTitle: '付款失敗',
customMessage: '請聯繫客服取得協助',
};完整範例
React 範例
'use client';
import { useRecur } from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const { checkout, isCheckingOut } = useRecur();
const handleSubscribe = async () => {
await checkout({
productId,
onPaymentComplete: (result) => {
console.log('訂閱成功!', result);
window.location.href = '/success';
},
onPaymentFailed: (error) => {
// 根據錯誤類型自訂處理
const code = error.details?.failure_code;
if (code === 'INSUFFICIENT_FUNDS') {
return {
action: 'custom',
customTitle: '餘額不足',
customMessage: '請確認卡片餘額或使用其他卡片',
};
}
if (code === 'EXPIRED_CARD') {
return {
action: 'custom',
customTitle: '卡片已過期',
customMessage: '請使用有效的信用卡',
};
}
// 可重試的錯誤 - 使用預設行為
if (error.details?.can_retry) {
return { action: 'retry' };
}
// 其他錯誤 - 使用預設行為
return undefined;
},
onError: (error) => {
// 處理其他錯誤(網路問題等)
alert('發生錯誤:' + error.message);
},
});
};
return (
<button onClick={handleSubscribe} disabled={isCheckingOut}>
{isCheckingOut ? '處理中...' : '立即訂閱'}
</button>
);
}'use client';
import { useSubscribe } from 'recur-tw';
export function SubscribeButton({ productId }: { productId: string }) {
const { subscribe, isLoading, error } = useSubscribe({
onPaymentComplete: (result) => {
console.log('訂閱成功!', result);
window.location.href = '/success';
},
onPaymentFailed: (error) => {
const code = error.details?.failure_code;
// 根據錯誤碼自訂處理
switch (code) {
case 'INSUFFICIENT_FUNDS':
return {
action: 'custom',
customTitle: '餘額不足',
customMessage: '請確認卡片餘額或使用其他卡片',
};
case 'EXPIRED_CARD':
return {
action: 'custom',
customTitle: '卡片已過期',
customMessage: '請使用有效的信用卡',
};
default:
return undefined; // 使用預設行為
}
},
onError: (error) => {
console.error('結帳錯誤:', error);
},
});
return (
<div>
<button
onClick={() => subscribe({ productId })}
disabled={isLoading}
>
{isLoading ? '處理中...' : '立即訂閱'}
</button>
{error && <p className="text-red-500">{error.message}</p>}
</div>
);
}Vanilla JS 範例
const recur = RecurCheckout.init({
publishableKey: 'pk_test_xxx',
});
document.getElementById('subscribe-btn').addEventListener('click', async () => {
await recur.checkout({
productId: 'prod_xxx',
mode: 'modal',
onPaymentFailed: (error) => {
console.log('付款失敗:', error);
// 根據錯誤碼處理
switch (error.details?.failure_code) {
case 'INSUFFICIENT_FUNDS':
return {
action: 'custom',
customTitle: '餘額不足',
customMessage: '請確認卡片餘額或使用其他卡片。',
};
case 'NETWORK_ERROR':
case 'TIMEOUT':
// 網路問題 - 允許重試
return { action: 'retry' };
default:
// 使用 SDK 預設行為
return undefined;
}
},
onPaymentComplete: (result) => {
window.location.href = '/success';
},
onError: (error) => {
alert('發生錯誤:' + error.message);
},
});
});錯誤物件結構
interface CheckoutError {
/** 錯誤代碼(如 'conflict', 'not_found', 'payment_required') */
code: string;
/** 人類可讀的錯誤訊息 */
message: string;
/** 相關文件連結(選填) */
docUrl?: string;
/** 錯誤詳情(選填) */
details?: Array<{
// 付款失敗詳情
failure_code?: string; // 付款失敗代碼
failure_message?: string; // 付款失敗訊息
can_retry?: boolean; // 是否可重試
// 重複訂閱詳情(當 code 為 'conflict')
existing_subscription_id?: string; // 現有訂閱 ID
status?: string; // 現有訂閱狀態
}>;
}PAYUNi SDK 錯誤碼(Client-side)
除了 Recur API 錯誤碼外,結帳過程中 PAYUNi SDK(信用卡 iframe 表單)也可能在客戶端拋出錯誤。這些錯誤由 PAYUNi 的 UNi Embed SDK 產生,不經過 Recur API,直接在使用者的瀏覽器中發生。
SDK Token 錯誤(OBJ*)
當 PAYUNi SDK 初始化或操作時,Token 發生問題:
| 錯誤碼 | 說明 | 可重試 | 處理建議 |
|---|---|---|---|
OBJ01001 | 查無 Token | ❌ | 重新建立 Checkout Session |
OBJ01002 | Token 格式錯誤 | ❌ | 確認 SDK 版本是否為最新 |
OBJ01003 | Token 已過期 | ✅ | 呼叫 refresh-token API 或重新建立結帳 |
OBJ01004 | Token 已使用 | ✅ | 呼叫 refresh-token API 取得新 Token |
OBJ01005 | 商店未啟用 | ❌ | 確認 PAYUNi 商店狀態 |
OBJ01003 是最常見的 SDK 錯誤。 常見原因包括:
- 使用者在結帳頁面停留過久(Token 預設 30 分鐘過期)
- SDK 版本過舊導致 Token 格式不相容
- 開發環境的 Domain 與 Token 綁定的 IFrameDomain 不匹配
建議確保使用最新版本的 recur-tw SDK,它內建 Token 自動刷新機制。
iframe 交易錯誤(IFTRADE*)
| 錯誤碼 | 說明 | 處理建議 |
|---|---|---|
IFTRADE04001 | 未有 Token | 確認呼叫 merchant_trade 時有帶 Token 參數 |
IFTRADE04002 | 未有交易設定資料 | 前端 SDK 須先呼叫 start() 設定交易。注意:不可使用 merchant_trade 做週期扣款,應使用 Server SDK 的幕後扣款 API |
SDK 解密錯誤(DEF*)
| 錯誤碼 | 說明 | 處理建議 |
|---|---|---|
DEF01001 | 資料解密失敗 | 確認 Hash Key/IV 設定正確 |
DEF01002 | 資料解密失敗 | 確認使用正確的 API Version(幕後交易需 v1.2) |
最佳實踐
1. 提供清楚的錯誤訊息
用戶需要知道發生了什麼以及該怎麼做:
onPaymentFailed: (error) => {
if (error.details?.failure_code === 'CARD_DECLINED') {
return {
action: 'custom',
customTitle: '卡片被拒絕',
customMessage: '您的銀行拒絕了此筆交易。請聯繫發卡銀行或使用其他卡片。',
};
}
}2. 根據 can_retry 決定處理方式
onPaymentFailed: (error) => {
if (error.details?.can_retry) {
// 可重試的錯誤 - 讓用戶再試一次
return { action: 'retry' };
} else {
// 不可重試 - 提供替代方案
return {
action: 'custom',
customTitle: '付款失敗',
customMessage: '請使用其他付款方式或聯繫客服。',
};
}
}3. 記錄錯誤以便分析
onPaymentFailed: (error) => {
// 記錄到分析服務
analytics.track('payment_failed', {
failure_code: error.details?.failure_code,
failure_message: error.details?.failure_message,
can_retry: error.details?.can_retry,
});
return undefined; // 使用預設行為
}4. 後端驗證
永遠在後端使用 Webhook 來驗證付款狀態。前端回調僅用於改善用戶體驗,不應作為業務邏輯的依據。
下一步
- Webhook 整合 - 在後端接收付款通知
- 基本結帳範例 - 查看完整的結帳範例
- 自訂樣式 - 自訂結帳視窗外觀
Last updated on