---
title: 錯誤處理
description: 處理付款失敗與結帳錯誤
---

# 錯誤處理

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` 錯誤：

```json
{
  "error": {
    "code": "bad_request",
    "message": "Promotion code 'EXPIRED123' is not valid: Code has expired"
  }
}
```

常見的優惠碼錯誤原因：

| 原因 | 說明 | 建議處理 |
|------|------|----------|
| 優惠碼不存在 | 輸入的代碼找不到 | 提示用戶確認代碼是否正確 |
| 優惠碼已過期 | 超過有效期限 | 提示用戶此優惠已結束 |
| 已達使用上限 | 總使用次數或每人次數已滿 | 提示用戶此優惠已被領取完畢 |
| 不適用此商品 | 優惠碼限定特定商品 | 提示用戶此優惠不適用於目前選擇的方案 |
| 不符合顧客資格 | 限定新/舊客戶 | 提示用戶此優惠僅限新客戶或現有客戶 |

```typescript
// 處理優惠碼錯誤
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`，此錯誤會在**結帳建立階段**就回傳，避免不必要的扣款。

```typescript
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';
      }
    }
  },
});
```

**重複訂閱的判定**

系統會檢查客戶是否已有該商品的以下狀態訂閱：
- `ACTIVE`：正常訂閱中
- `TRIAL`：試用期中
- `PAST_DUE`：逾期但尚未取消

如果客戶需要切換方案，請使用[訂閱方案切換 API](/api-reference/subscriptions#訂閱方案切換)。

### 主動預防重複訂閱

除了處理 409 錯誤，您也可以在觸發結帳前主動檢查，提供更好的用戶體驗：

```typescript
// 在後端觸發 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();
}
```

```typescript
// 建立 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` 讓您可以自訂付款失敗的處理方式：

```typescript
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 預設行為
  },
});
```

### 自訂處理邏輯

您可以根據不同的失敗原因回傳不同的處理動作：

```typescript
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'` | 顯示自訂的標題和訊息 |

```typescript
// 允許重試（預設）
return { action: 'retry' };

// 關閉視窗
return { action: 'close' };

// 自訂訊息
return {
  action: 'custom',
  customTitle: '付款失敗',
  customMessage: '請聯繫客服取得協助',
};
```

## 完整範例

### React 範例

```tsx
'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>
  );
}
```

```tsx
'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 範例

```javascript
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);
    },
  });
});
```

## 錯誤物件結構

```typescript
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. 提供清楚的錯誤訊息

用戶需要知道發生了什麼以及該怎麼做：

```typescript
onPaymentFailed: (error) => {
  if (error.details?.failure_code === 'CARD_DECLINED') {
    return {
      action: 'custom',
      customTitle: '卡片被拒絕',
      customMessage: '您的銀行拒絕了此筆交易。請聯繫發卡銀行或使用其他卡片。',
    };
  }
}
```

### 2. 根據 can_retry 決定處理方式

```typescript
onPaymentFailed: (error) => {
  if (error.details?.can_retry) {
    // 可重試的錯誤 - 讓用戶再試一次
    return { action: 'retry' };
  } else {
    // 不可重試 - 提供替代方案
    return {
      action: 'custom',
      customTitle: '付款失敗',
      customMessage: '請使用其他付款方式或聯繫客服。',
    };
  }
}
```

### 3. 記錄錯誤以便分析

```typescript
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](/guides/webhooks) 來驗證付款狀態。前端回調僅用於改善用戶體驗，不應作為業務邏輯的依據。

## 下一步

- [Webhook 整合](/guides/webhooks) - 在後端接收付款通知
- [基本結帳範例](/guides/examples/basic-checkout) - 查看完整的結帳範例
- [自訂樣式](/guides/examples/custom-styling) - 自訂結帳視窗外觀
