Recur
開發者指南結帳整合

錯誤處理

處理付款失敗與結帳錯誤

錯誤處理

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

錯誤類型

SDK 區分兩種錯誤類型:

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

onPaymentFailed 是專門為付款失敗設計的回調,讓您可以根據失敗原因提供更好的用戶體驗。

API 錯誤碼

Recur API 使用標準的 HTTP 狀態碼和錯誤碼:

HTTP 狀態錯誤碼說明
400bad_request請求參數無效
401unauthorizedAPI Key 無效或缺失
402payment_required付款失敗
403forbidden無權存取此資源
404not_found找不到資源
409conflict資源衝突(如重複訂閱)
422validation_error驗證失敗
429rate_limit_exceeded請求過於頻繁
500internal_server_error伺服器錯誤

處理重複訂閱錯誤(409)

當客戶嘗試訂閱已有有效訂閱的商品時,API 會返回 409 conflict 錯誤:

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

付款失敗錯誤碼

當付款失敗時,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;                     // 現有訂閱狀態
  }>;
}

最佳實踐

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 來驗證付款狀態。前端回調僅用於改善用戶體驗,不應作為業務邏輯的依據。

下一步