決済エラーの分類・ハンドリング・リトライ戦略パターン。StripeError型の階層的分類、 3D Secureフロー、decline codes対応、ネットワークエラーのリトライ、 ユーザー向けエラーメッセージ設計を網羅する。決済エラーは必ず発生するものであり、 適切な分類と対応がチャージバック率低減・コンバージョン維持の鍵となる。
import Stripe from "stripe";
// Stripe エラー型の階層
// StripeError (基底)
// ├── StripeCardError -- カード拒否 (type: "card_error")
// ├── StripeInvalidRequestError -- パラメータ不正 (type: "invalid_request_error")
// ├── StripeAPIError -- Stripe内部エラー (type: "api_error")
// ├── StripeAuthenticationError -- APIキー不正 (type: "authentication_error")
// ├── StripeRateLimitError -- レート制限 (type: "rate_limit_error")
// ├── StripeConnectionError -- ネットワーク障害
// └── StripeIdempotencyError -- 冪等キー競合
type PaymentErrorCategory =
| "card_declined" // カード拒否(ユーザー起因)
| "authentication_required" // 3DS認証が必要
| "processing_error" // Stripe処理エラー(リトライ可能)
| "invalid_request" // リクエスト不正(バグ)
| "network_error" // ネットワーク障害(リトライ可能)
| "rate_limit" // レート制限(リトライ可能)
| "internal_error"; // 想定外のエラー
interface PaymentError {
category: PaymentErrorCategory;
code: string;
message: string; // 内部ログ用
userMessage: string; // ユーザー表示用
retryable: boolean;
declineCode?: string;
}
function classifyStripeError(error: unknown): PaymentError {
if (error instanceof Stripe.errors.StripeCardError) {
return classifyCardError(error);
}
if (error instanceof Stripe.errors.StripeInvalidRequestError) {
return {
category: "invalid_request",
code: error.code ?? "invalid_request",
message: error.message,
userMessage: "リクエストの処理中にエラーが発生しました。再度お試しください。",
retryable: false,
};
}
if (error instanceof Stripe.errors.StripeAPIError) {
return {
category: "processing_error",
code: "stripe_api_error",
message: error.message,
userMessage: "決済システムに一時的な問題が発生しています。しばらく後に再度お試しください。",
retryable: true,
};
}
if (error instanceof Stripe.errors.StripeConnectionError) {
return {
category: "network_error",
code: "connection_error",
message: error.message,
userMessage: "決済システムとの通信に失敗しました。しばらく後に再度お試しください。",
retryable: true,
};
}
if (error instanceof Stripe.errors.StripeRateLimitError) {
return {
category: "rate_limit",
code: "rate_limit",
message: error.message,
userMessage: "リクエストが集中しています。しばらく後に再度お試しください。",
retryable: true,
};
}
if (error instanceof Stripe.errors.StripeAuthenticationError) {
return {
category: "internal_error",
code: "authentication_error",
message: error.message,
userMessage: "システムエラーが発生しました。管理者にお問い合わせください。",
retryable: false,
};
}
return {
category: "internal_error",
code: "unknown_error",
message: error instanceof Error ? error.message : String(error),
userMessage: "予期しないエラーが発生しました。再度お試しください。",
retryable: false,
};
}
function classifyCardError(error: Stripe.errors.StripeCardError): PaymentError {
const declineCode = error.decline_code;
// 3DS認証が必要
if (error.code === "authentication_required") {
return {
category: "authentication_required",
code: error.code,
message: error.message,
userMessage: "カードの追加認証が必要です。認証を完了してください。",
retryable: false, // 3DSフロー経由で再試行
declineCode,
};
}
// カード拒否の分類
const declineInfo = getDeclineInfo(declineCode);
return {
category: "card_declined",
code: error.code ?? "card_declined",
message: error.message,
userMessage: declineInfo.userMessage,
retryable: declineInfo.retryable,
declineCode,
};
}
interface DeclineInfo {
userMessage: string;
retryable: boolean;
}
function getDeclineInfo(declineCode: string | undefined): DeclineInfo {
switch (declineCode) {
// リトライ可能な一時的拒否
case "insufficient_funds":
return {
userMessage: "カードの残高が不足しています。別のカードをお試しください。",
retryable: true,
};
case "processing_error":
return {
userMessage: "決済処理中にエラーが発生しました。再度お試しください。",
retryable: true,
};
case "try_again_later":
return {
userMessage: "一時的にカードが使用できません。しばらく後に再度お試しください。",
retryable: true,
};
// リトライ不可能な永続的拒否
case "card_not_supported":
return {
userMessage: "このカードはサポートされていません。別のカードをお使いください。",
retryable: false,
};
case "expired_card":
return {
userMessage: "カードの有効期限が切れています。別のカードをお使いください。",
retryable: false,
};
case "incorrect_cvc":
return {
userMessage: "セキュリティコード(CVC)が正しくありません。確認の上、再度お試しください。",
retryable: true,
};
case "incorrect_number":
return {
userMessage: "カード番号が正しくありません。確認の上、再度お試しください。",
retryable: true,
};
case "stolen_card":
case "lost_card":
case "merchant_blacklist":
return {
userMessage: "このカードは使用できません。カード会社にお問い合わせください。",
retryable: false,
};
case "do_not_honor":
return {
userMessage: "カード会社により決済が拒否されました。カード会社にお問い合わせいただくか、別のカードをお試しください。",
retryable: false,
};
// 不正検知
case "fraudulent":
return {
userMessage: "決済が拒否されました。別のカードをお試しください。",
retryable: false,
};
default:
return {
userMessage: "カード決済が拒否されました。別のカードをお試しいただくか、カード会社にお問い合わせください。",
retryable: false,
};
}
}
// サーバー側: PaymentIntent確認時の3DS対応
async function confirmPayment(
paymentIntentId: string,
): Promise<{ status: string; clientSecret?: string; redirectUrl?: string }> {
try {
const paymentIntent = await stripe.paymentIntents.confirm(paymentIntentId);
if (paymentIntent.status === "requires_action") {
// 3DS認証が必要
return {
status: "requires_action",
clientSecret: paymentIntent.client_secret!,
};
}
if (paymentIntent.status === "succeeded") {
return { status: "succeeded" };
}
return { status: paymentIntent.status };
} catch (error) {
const paymentError = classifyStripeError(error);
if (paymentError.category === "authentication_required") {
// PaymentIntentのclient_secretを返してフロントで3DS認証を促す
const pi = await stripe.paymentIntents.retrieve(paymentIntentId);
return {
status: "requires_action",
clientSecret: pi.client_secret!,
};
}
throw error;
}
}
// クライアント側: 3DS認証フロー
import { loadStripe } from "@stripe/stripe-js";
const stripeClient = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
async function handle3DSAuthentication(clientSecret: string): Promise<{
success: boolean;
error?: string;
}> {
if (!stripeClient) {
return { success: false, error: "Stripe not loaded" };
}
const { error, paymentIntent } = await stripeClient.confirmCardPayment(
clientSecret,
);
if (error) {
return {
success: false,
error: error.message ?? "認証に失敗しました",
};
}
if (paymentIntent?.status === "succeeded") {
return { success: true };
}
return { success: false, error: "決済が完了しませんでした" };
}
interface RetryConfig {
maxAttempts: number;
baseDelayMs: number;
maxDelayMs: number;
}
const DEFAULT_RETRY_CONFIG: RetryConfig = {
maxAttempts: 3,
baseDelayMs: 1000,
maxDelayMs: 10_000,
};
async function withPaymentRetry<T>(
operation: () => Promise<T>,
config: RetryConfig = DEFAULT_RETRY_CONFIG,
): Promise<T> {
let lastError: PaymentError | undefined;
for (let attempt = 0; attempt < config.maxAttempts; attempt++) {
try {
return await operation();
} catch (error) {
lastError = classifyStripeError(error);
// リトライ不可能なエラーは即座にスロー
if (!lastError.retryable) {
throw error;
}
// 最後の試行なら再スローする
if (attempt === config.maxAttempts - 1) {
throw error;
}
// 指数バックオフ + ジッター
const delay = Math.min(
config.baseDelayMs * Math.pow(2, attempt) + Math.random() * 1000,
config.maxDelayMs,
);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
// TypeScript 用: 到達しないが型安全のため
throw lastError;
}
// 使用例
async function chargeCustomer(customerId: string, amount: number): Promise<Stripe.PaymentIntent> {
return withPaymentRetry(async () => {
return stripe.paymentIntents.create({
customer: customerId,
amount,
currency: "jpy",
confirm: true,
off_session: true,
});
});
}
async function logPaymentError(
error: PaymentError,
context: {
orderId?: string;
customerId?: string;
paymentIntentId?: string;
amount?: number;
},
): Promise<void> {
// 構造化ログ
console.error(JSON.stringify({
level: "error",
type: "payment_error",
category: error.category,
code: error.code,
declineCode: error.declineCode,
retryable: error.retryable,
message: error.message,
...context,
timestamp: new Date().toISOString(),
}));
// DBにエラー記録
await db.insert(paymentErrors).values({
category: error.category,
code: error.code,
declineCode: error.declineCode,
orderId: context.orderId,
customerId: context.customerId,
paymentIntentId: context.paymentIntentId,
amount: context.amount,
});
// 内部エラーの場合はアラート
if (error.category === "internal_error" || error.category === "invalid_request") {
await notifyAlertChannel({
title: "Payment System Error",
category: error.category,
code: error.code,
message: error.message,
context,
});
}
}