# 錯誤處理 (/guides/checkout/error-handling)





錯誤處理 [#錯誤處理]

Recur SDK 提供完整的錯誤處理機制，讓您可以優雅地處理付款失敗和其他結帳錯誤。

錯誤類型 [#錯誤類型]

SDK 區分兩種錯誤類型：

| 回調                | 觸發時機      | 說明                |
| ----------------- | --------- | ----------------- |
| `onPaymentFailed` | 用戶送出付款後失敗 | 卡片被拒絕、餘額不足等       |
| `onError`         | 結帳流程中發生錯誤 | 網路問題、API 錯誤、重複訂閱等 |

<Callout type="info">
  `onPaymentFailed` 是專門為付款失敗設計的回調，讓您可以根據失敗原因提供更好的用戶體驗。
</Callout>

API 錯誤碼 [#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） [#處理優惠碼錯誤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） [#處理重複訂閱錯誤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';
      }
    }
  },
});
```

<Callout type="info">
  **重複訂閱的判定**

  系統會檢查客戶是否已有該商品的以下狀態訂閱：

  * `ACTIVE`：正常訂閱中
  * `TRIAL`：試用期中
  * `PAST_DUE`：逾期但尚未取消

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

主動預防重複訂閱 [#主動預防重複訂閱]

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

<Tabs items="['Server-side 預檢查', 'Hosted Checkout 處理 409']">
  <Tab value="Server-side 預檢查">
    ```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();
    }
    ```
  </Tab>

  <Tab value="Hosted Checkout 處理 409">
    ```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}`);
    }
    ```
  </Tab>
</Tabs>

付款失敗錯誤碼 [#付款失敗錯誤碼]

當付款失敗時，`error.details.failure_code` 會包含以下其中一個錯誤碼：

| 錯誤碼                  | 說明     | 可重試 |
| -------------------- | ------ | --- |
| `PAYUNI_DECLINED`    | 銀行拒絕交易 | ❌   |
| `UNAPPROVED`         | 交易未獲授權 | ❌   |
| `INSUFFICIENT_FUNDS` | 餘額不足   | ❌   |
| `CARD_DECLINED`      | 卡片被拒絕  | ❌   |
| `EXPIRED_CARD`       | 卡片已過期  | ❌   |
| `INVALID_CARD`       | 無效的卡號  | ❌   |
| `NETWORK_ERROR`      | 網路連線問題 | ✅   |
| `TIMEOUT`            | 交易逾時   | ✅   |
| `UNKNOWN`            | 未知錯誤   | ❌   |

<Callout type="warn">
  「可重試」表示問題可能是暫時性的，用戶可以使用相同的卡片再次嘗試。不可重試的錯誤通常需要用戶更換卡片或聯繫銀行。
</Callout>

使用 onPaymentFailed [#使用-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 範例 [#react-範例]

<Tabs items="['useRecur', 'useSubscribe']">
  <Tab value="useRecur">
    ```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>
      );
    }
    ```
  </Tab>

  <Tab value="useSubscribe">
    ```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>
      );
    }
    ```
  </Tab>
</Tabs>

Vanilla JS 範例 [#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） [#payuni-sdk-錯誤碼client-side]

除了 Recur API 錯誤碼外，結帳過程中 PAYUNi SDK（信用卡 iframe 表單）也可能在客戶端拋出錯誤。這些錯誤由 PAYUNi 的 UNi Embed SDK 產生，**不經過 Recur API**，直接在使用者的瀏覽器中發生。

SDK Token 錯誤（OBJ*） [#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 商店狀態                 |

<Callout type="warn">
  **OBJ01003 是最常見的 SDK 錯誤。** 常見原因包括：

  * 使用者在結帳頁面停留過久（Token 預設 30 分鐘過期）
  * SDK 版本過舊導致 Token 格式不相容
  * 開發環境的 Domain 與 Token 綁定的 IFrameDomain 不匹配

  建議確保使用最新版本的 `recur-tw` SDK，它內建 Token 自動刷新機制。
</Callout>

iframe 交易錯誤（IFTRADE*） [#iframe-交易錯誤iftrade]

| 錯誤碼            | 說明       | 處理建議                                                                                  |
| -------------- | -------- | ------------------------------------------------------------------------------------- |
| `IFTRADE04001` | 未有 Token | 確認呼叫 merchant\_trade 時有帶 Token 參數                                                     |
| `IFTRADE04002` | 未有交易設定資料 | 前端 SDK 須先呼叫 `start()` 設定交易。**注意：不可使用 merchant\_trade 做週期扣款，應使用 Server SDK 的幕後扣款 API** |

SDK 解密錯誤（DEF*） [#sdk-解密錯誤def]

| 錯誤碼        | 說明     | 處理建議                            |
| ---------- | ------ | ------------------------------- |
| `DEF01001` | 資料解密失敗 | 確認 Hash Key/IV 設定正確             |
| `DEF01002` | 資料解密失敗 | 確認使用正確的 API Version（幕後交易需 v1.2） |

最佳實踐 [#最佳實踐]

1\. 提供清楚的錯誤訊息 [#1-提供清楚的錯誤訊息]

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

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

2\. 根據 can_retry 決定處理方式 [#2-根據-can_retry-決定處理方式]

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

3\. 記錄錯誤以便分析 [#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\. 後端驗證 [#4-後端驗證]

<Callout type="warn">
  永遠在後端使用 [Webhook](/guides/webhooks) 來驗證付款狀態。前端回調僅用於改善用戶體驗，不應作為業務邏輯的依據。
</Callout>

下一步 [#下一步]

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