🛑 處理退款與客訴 (Refunds & Disputes) 實戰

當你的產品越賣越好,難免會遇到需要「退款」的狀況。 可能是客戶買錯了、不滿意,或是更麻煩的:客戶忘記自己買過,直接打電話給發卡銀行宣告「這筆是盜刷」。

在電商與 SaaS 領域中,如果你不具備處理退款與爭議 (Disputes / Chargebacks) 的自動化能力,你會被大量的客服信件與銀行罰單給淹沒。本章將帶你實戰這兩大危機處理機制!


1. 為什麼不能只在後台手動退款?

你可能會問:「如果客戶要退款,我登入 Stripe 後台按一個按鈕不就好了?」

確實可以。但如果你的產品是「自動派發系統」(例如:買了立刻發送軟體序號、買了立刻開通課程權限),當你在 Stripe 手動退款後,你的資料庫並不知道這件事! 這會導致:錢退給客戶了,但他依然擁有課程觀看權限、依然能使用你的軟體。

這就是為什麼我們必須學會:

  1. 主動發起退款 API:讓我們的後台系統能一鍵退款,並同步拔除權限。
  2. 被動監聽退款 Webhook:如果老闆自己在 Stripe 後台按了退款,我們的系統也要能抓到通知並同步處理。

2. 主動發起退款 (Refund API)

假設你在自己的管理後台 (Admin Dashboard) 做了一個「一鍵退款」按鈕,當按下時,它會呼叫我們接下來要寫的這支 API。

Stripe 提供了一個非常簡單的 stripe.refunds.create 方法。

撰寫退款 API

建立檔案:src/app/api/stripe/refund/route.ts

import { NextResponse } from 'next/server';
import Stripe from 'stripe';
import { revokeUserAccess } from '@/lib/db'; // 你的資料庫邏輯

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

export async function POST(request: Request) {
  try {
    const { paymentIntentId, reason, amount } = await request.json();

    if (!paymentIntentId) {
      return NextResponse.json({ error: 'Missing paymentIntentId' }, { status: 400 });
    }

    // 1. 發起退款請求
    const refundConfig: Stripe.RefundCreateParams = {
      payment_intent: paymentIntentId,
      // 可選:退款原因 (duplicate, fraudulent, requested_by_customer)
      reason: reason || 'requested_by_customer', 
    };

    // 如果有傳入 amount,代表是「部分退款」(Partial Refund)
    // 注意:Stripe 的金額單位是最小幣值 (例如 100 代表 1 塊美金或 100 塊台幣)
    if (amount) {
      refundConfig.amount = amount;
    }

    const refund = await stripe.refunds.create(refundConfig);

    // 2. 退款成功!同步更新我們的資料庫,拔除使用者的權限
    // (如果是部分退款,可能不需要拔除權限,請依照商業邏輯判斷)
    if (!amount) {
      // 假設這是全額退款
      await revokeUserAccess(paymentIntentId);
      console.log(`Access revoked for payment: ${paymentIntentId}`);
    }

    return NextResponse.json({ 
      success: true, 
      refundId: refund.id,
      status: refund.status 
    });

  } catch (error: any) {
    console.error('Refund Error:', error);
    // 錯誤處理:例如餘額不足、已經退款過等
    return NextResponse.json({ error: error.message }, { status: 500 });
  }
}

💡 實戰地雷:退款需要「帳戶餘額」

在 Stripe 中,退款是從你的「Stripe 帳戶餘額」中扣除的。如果你的餘額已經全部提現到銀行帳戶了,退款 API 就會報錯 (Insufficient Funds)。這時候你可能需要從銀行帳戶轉錢回 Stripe,或是等下一筆訂單進來。


3. 被動監聽退款與爭議 (Webhooks)

如果你不想寫「主動發起退款」的 API,而是選擇用 Stripe App 或網頁後台手動處理,那你就「必須」寫 Webhook 來同步資料庫狀態。

更重要的是,信用卡爭議 (Disputes / Chargebacks) 只能透過 Webhook 來捕捉。

什麼是信用卡爭議 (Chargebacks)?

當客戶打電話給銀行說「這筆交易我沒刷過」或「我沒收到貨」,銀行會直接強制把錢從你的 Stripe 帳戶扣走(通常還會附帶高達 $15 美金的罰款!)。這在業界稱為 Chargeback。 此時,你的商品必須立刻停止服務,以免損失擴大。

實作進階 Webhook 監聽

在我們之前寫的 src/app/api/stripe/webhook/route.ts 中,加入以下兩個事件的監聽:

// ... 前方 Webhook 驗證程式碼省略 ...

switch (event.type) {
  
  // 🟢 監聽:退款成功
  case 'charge.refunded': {
    const charge = event.data.object as Stripe.Charge;
    const paymentIntentId = charge.payment_intent as string;
    
    console.log(`Charge refunded for PaymentIntent: ${paymentIntentId}`);
    
    // 判斷是全額還是部分退款
    if (charge.amount_refunded === charge.amount) {
      console.log('這是全額退款,立刻拔除權限!');
      // TODO: 去資料庫把這個訂單狀態改為 'refunded',並拔除客戶權限
    } else {
      console.log(`這是部分退款,退了 ${charge.amount_refunded}`);
    }
    break;
  }

  // 🔴 監聽:可怕的信用卡爭議 (Chargeback) 被發起
  case 'charge.dispute.created': {
    const dispute = event.data.object as Stripe.Dispute;
    const paymentIntentId = dispute.payment_intent as string;
    
    console.log(`⚠️ 警告!收到信用卡爭議!PaymentIntent: ${paymentIntentId}`);
    console.log(`爭議原因: ${dispute.reason}`); 
    // reason 可能是 fraudulent(盜刷)、product_not_received 等
    
    // 💣 最佳實踐:遇到爭議,立刻鎖定該帳號,停止提供服務!
    // TODO: 去資料庫把這個訂單狀態改為 'disputed',並立刻凍結客戶帳號
    
    // 你也可以在這裡串接 Line Notify 或寄信通知老闆立刻上線處理!
    break;
  }
    
  // 🔵 監聽:爭議處理結果出爐 (你贏了或是你輸了)
  case 'charge.dispute.closed': {
    const dispute = event.data.object as Stripe.Dispute;
    if (dispute.status === 'won') {
      console.log('🎉 恭喜!銀行判決我們勝訴,錢拿回來了!');
      // TODO: 恢復客戶帳號與權限
    } else if (dispute.status === 'lost') {
      console.log('😭 悲劇,銀行判決我們敗訴,錢被扣走了。');
      // 摸摸鼻子認賠,帳號繼續凍結
    }
    break;
  }

  default:
    console.log(`Unhandled event type ${event.type}`);
}

4. 如何防止爭議發生?(商業智慧)

身為工程師,除了用程式碼處理爭議,你更該懂得如何「預防」它:

  1. 帳單描述 (Statement Descriptor) 要清楚: 在 Stripe 設定中,把帳單描述設定為你的品牌名稱 (例如 VIBETUTOR*COURSE)。很多爭議只是因為客戶看到帳單上寫著奇怪的英文字,忘記自己買過什麼就當成盜刷。
  2. 良好的客服管道: 在網頁最顯眼的地方提供退款政策與聯絡信箱。如果客戶能輕易找到你並獲得退款,他們就不會走銀行爭議流程(走銀行流程你會多賠 $15 美金的處理費)。
  3. 開啟 Stripe Radar: 這是 Stripe 內建的 AI 防偽系統,能自動阻擋高風險的盜刷卡片,每筆交易抽微薄的手續費,但能幫你省下大筆的 Dispute 罰款。

掌握了退款與爭議的程式化處理,你的平台才算是真正擁有「抗風險能力」的成熟商業級產品!盾牌架好後,就準備安心迎接龐大的金流吧!

解鎖完整教學內容

本章為付費內容。加入專案即可解鎖超過 5000 字的深度解析,包含 10 個以上神級 Prompt 與真實 Source Code 範例!