Payment · Stripe · 退款機制
01

概述與設計理念

Stripe Plugin 沒有實作 IRefundQueryablePOST /v1/refunds 成功,Stripe 就直接回傳最終結果(succeeded),PMW 不需要像 Cybersource 一樣額外輪詢退款狀態,退款這一步在 Stripe 這邊是同步就能拿到結果的。

但 Stripe 的退款並不是單純「打一支 API 退錢」這麼簡單。因為 Stripe Connect 架構下有子帳號拆帳(DirectCharge / DestinationCharge)與平台手續費(Application Fee)兩個維度要同時考慮,退款時必須依照付款當初的資金流向,反向把每一筆錢退回正確的帳號,最多需要串接 4 個 API 呼叫。

🔷 特性

Stripe 退款無需輪詢,POST /v1/refunds 一次呼叫即為終態;但依 PaymentFlow 與是否退手續費,實際串接的 API 數量會在 2~4 支之間變動。

⚠️ 複雜度來源

DirectCharge/DestinationCharge 決定退款要在子帳號或主帳號執行;Application Fee 是否退還則是獨立於資金流向之外的第二個條件判斷。

🎯 核心原則

退款永遠先反查 PaymentIntent 拿到真正的 charge.id,再依當初的資金流向,把每一筆錢(本金 / 手續費 / 轉移)分別退回發出它的那個帳號。

💡

資料來源: 本文彙整 Paymentmiddleware/Refund 資料夾內 4 份設計文件,並補上一則真實線上異常案例(異常案例紀錄/04-退款請求失敗.md)作為 DestinationCharge + Application Fee 退款的實際數字對照。

02

完整退款流程架構

Stripe 退款最多需要 4 個 API 呼叫,實際會執行哪幾步,取決於 PaymentFlow(DirectCharge / DestinationCharge)與 IsRefundApplicationFee 這兩個條件:

1
取得 PaymentIntent 必執行

GET /v1/payment_intents/{id} — 從回應中取出 charge.idcharge.ApplicationFeecharge.Transfer,這是後面 3 步的資料來源。

2
退還 Application Fee 條件執行

POST /v1/application_fees/{applicationFeeId}/refunds — 僅在 is_refund_application_fee=trueapplication_fee_amount > 0 時才執行,退還平台向子帳號收取的手續費。

3
撤銷資金轉移 條件執行

POST /v1/transfers/{transferId}/reversals — 僅在 payment_flow = DestinationCharge 時執行,撤銷付款當初從主帳號轉給子帳號的資金。

4
正式退款 必執行

POST /v1/refunds — 對 Step 1 取得的 Charge 發起實際退款,退還金額回到持卡人。

API 呼叫彙總

步驟API說明條件
1GET /v1/payment_intents/{id}取得付款意圖,提取 Charge 資訊必執行
2POST /v1/application_fees/{fee_id}/refunds退還 Application FeeIsRefundApplicationFee=trueApplicationFeeAmount > 0
3POST /v1/transfers/{transfer_id}/reversals撤銷 TransferStripePaymentFlow = DestinationCharge
4POST /v1/refunds正式退款至持卡人必執行

組合結果: 最少情況(DirectCharge、不退手續費)只需 2 支 API;最多情況(DestinationCharge、同時退手續費)需要串完全部 4 支。詳細組合請見「05 DirectCharge 退款」「06 DestinationCharge 退款」兩節的情境範例。

03

金額轉換規則

Stripe API 一律使用最小貨幣單位(smallest currency unit),PMW 呼叫任何金額相關的 Stripe API 之前,都要先經過同一個轉換函式:

private string AmountConvert(decimal amount)
{
 
    return Math.Floor(amount * 100).ToString();
}
💡

例如 HKD 100.00 呼叫 Stripe API 時要傳入 10000。退款金額(request.Amount)與手續費金額(ApplicationFeeAmount)都需要經過這個轉換,兩者不可以直接傳原始金額。

RefundRequestExtendInfo 欄位

// 繼承自 BaseRequestExtendInfo
public StripePaymentFlowEnum StripePaymentFlow { get; set; }  // DirectCharge / DestinationCharge
public string SubAccount { get; set; }                        // Stripe sub-account ID
 
// 退款專用欄位
public bool IsRefundApplicationFee { get; set; }              // 是否退還手續費
public decimal ApplicationFeeAmount { get; set; }             // 手續費金額(原始金額,非最小單位)
04

Step 4:退款 Request Body 與 ReturnCode

不論 DirectCharge 或 DestinationCharge,最後一步都要呼叫同一支 POST /v1/refunds,差別只在於是否帶 Stripe-Account Header:

POST /v1/refunds
Stripe-Account: {subAcct}   ← 僅 DirectCharge 帶此 header
 
{
  "charge": "{charge.id}",
  "amount": "{AmountConvert(request.Amount)}",
  "refund_application_fee": "false",
  "metadata[request_id]": "{request.RequestId}"
}
⚠️

refund_application_fee 固定為 "false": Application Fee 的退款已經在 Step 2 用獨立 API、精確金額處理過了,這裡刻意不讓 Stripe 自動退——若設為 true,Stripe 會依比例自動退還 Application Fee,PMW 就無法精確控制退多少手續費。

ReturnCode 對照

情境ReturnCode說明
退款成功1000 SuccessStripe POST /v1/refunds 正常回應
ApiException4001 RefundFailedStripe API 回傳錯誤(如 insufficient funds)
ArgumentNullException4001 RefundFailedCharge 資料異常(如 .Single() 找不到 Charge)
其他未知例外re-throwLogError 後直接往上拋,不吞例外
05

DirectCharge 退款

適用條件:request.ExtendInfo.StripePaymentFlow == StripePaymentFlowEnum.DirectCharge

💡

DirectCharge 模式: 付款時 PaymentMethodPaymentIntent 都在子帳號(Connected Account)下建立。退款時,所有操作也必須在子帳號下執行,需帶 Stripe-Account Header。

Step 1:取得 PaymentIntent(帶子帳號)

GET /v1/payment_intents/{transactionId}
Stripe-Account: {subAcct}
Authorization: ******

回應中取出:

paymentIntent.charges.data[0].id            → charge.id(退款用)
paymentIntent.charges.data[0].ApplicationFee → fee.id(退手續費用)
⚠️

paymentIntent.charges.data.Single() 預期只有一筆 Charge,若無則拋出 ArgumentNullException(對應 ReturnCode:RefundFailed)。

Step 2(條件):退還 Application Fee

僅在 IsRefundApplicationFee=trueApplicationFeeAmount > 0 時執行:

POST /v1/application_fees/{charge.ApplicationFee}/refunds
Authorization: ******
(無 Stripe-Account header — Application Fee 在主帳號下)
 
{
  "amount": "{AmountConvert(ApplicationFeeAmount)}"
}
⚠️

Application Fee Refund 不帶子帳號 Header,因為 Application Fee 收取後存放在平台主帳號,退款也在主帳號執行。

Step 3(DirectCharge 跳過):Transfer Reversal

DirectCharge 不執行 Transfer Reversal,此步驟為 DestinationCharge 專用。

Step 4:正式退款

POST /v1/refunds
Stripe-Account: {subAcct}   ← DirectCharge 必帶
Authorization: ******
 
{
  "charge": "{charge.id}",
  "amount": "{AmountConvert(request.Amount)}",
  "refund_application_fee": "false",
  "metadata[request_id]": "{request.RequestId}"
}

資金流向示意

付款時:
  持卡人 → [Stripe] → 子帳號(Connected Account)
             └→ Platform 收取 Application Fee
 
退款時:
  子帳號(Connected Account)→ [Stripe] → 持卡人   (Step 4: POST /v1/refunds + Stripe-Account)
  Platform → [Stripe] → 持卡人(或減少 Application Fee)  (Step 2: POST /v1/application_fees/{id}/refunds)

情境範例

情境ExtendInfo執行步驟
一般退款(不退手續費)is_refund_application_fee=false, application_fee_amount=0Step 1 → Step 4(共 2 個 API 呼叫)
退款同時退手續費is_refund_application_fee=true, application_fee_amount=15.00Step 1 → Step 2 → Step 4(共 3 個 API 呼叫)
06

DestinationCharge 退款

適用條件:request.ExtendInfo.StripePaymentFlow == StripePaymentFlowEnum.DestinationCharge

💡

DestinationCharge 模式: 付款時在主帳號下建立 PaymentIntent,資金收取後透過 transfer_data[destination] 轉給子帳號。退款時,主帳號執行退款,子帳號的 Transfer 也需要同步撤銷(Transfer Reversal),否則子帳號的錢不會回到主帳號,資金會不平衡。

Step 1:取得 PaymentIntent(不帶子帳號)

GET /v1/payment_intents/{transactionId}
Authorization: ******
(無 Stripe-Account header)

回應中取出:

paymentIntent.charges.data[0].id            → charge.id(退款用)
paymentIntent.charges.data[0].ApplicationFee → fee.id(退手續費用)
paymentIntent.charges.data[0].Transfer       → transfer.id(撤銷轉移用)

Step 2(條件):退還 Application Fee

與 DirectCharge 相同,僅在 IsRefundApplicationFee=trueApplicationFeeAmount > 0 時執行:

POST /v1/application_fees/{charge.ApplicationFee}/refunds
Authorization: ******
 
{
  "amount": "{AmountConvert(ApplicationFeeAmount)}"
}

Step 3(DestinationCharge 必執行):Transfer Reversal

POST /v1/transfers/{charge.Transfer}/reversals
Authorization: ******
(無 Stripe-Account header — Transfer 在主帳號下管理)
 
{
  "amount": "{AmountConvert(request.Amount)}"
}
⚠️

目的: 付款時主帳號透過 Transfer 將資金移至子帳號,退款時需同步撤銷這筆轉移,否則子帳號的資金不會回到主帳號,造成資金不平衡。

Step 4:正式退款

POST /v1/refunds
Authorization: ******
(無 Stripe-Account header — 主帳號退款)
 
{
  "charge": "{charge.id}",
  "amount": "{AmountConvert(request.Amount)}",
  "refund_application_fee": "false",
  "metadata[request_id]": "{request.RequestId}"
}

與 DirectCharge 的關鍵差異

項目DirectChargeDestinationCharge
Step 1 subAcct header✅ 帶(子帳號)❌ 不帶(主帳號)
Step 3 Transfer Reversal❌ 不執行✅ 必執行
Step 4 subAcct header✅ 帶(子帳號)❌ 不帶(主帳號)
退款執行帳號子帳號主帳號

資金流向示意

付款時:
  持卡人 → [Stripe 主帳號] → Transfer → 子帳號
                └→ Platform 收取 Application Fee
 
退款時:
  子帳號 ← [Transfer Reversal] ← 主帳號       (Step 3: POST /v1/transfers/{id}/reversals)
  主帳號 → [Stripe] → 持卡人                  (Step 4: POST /v1/refunds,無 subAcct)
  Platform → [Stripe] → 持卡人(退手續費時)   (Step 2: POST /v1/application_fees/{id}/refunds)

情境範例

情境ExtendInfo執行步驟
一般退款(不退手續費)is_refund_application_fee=false, application_fee_amount=0Step 1 → Step 3 → Step 4(共 3 個 API 呼叫)
退款同時退手續費is_refund_application_fee=true, application_fee_amount=15.00Step 1 → Step 2 → Step 3 → Step 4(共 4 個 API 呼叫,最多情況)
07

Application Fee 退款規則

Stripe Connect 架構中,平台(主帳號)在每筆付款時可從 Connected Account(子帳號)收取手續費,稱為 Application Fee:

付款金額 100 HKD
  └→ 子帳號收到 97 HKD
  └→ 平台主帳號收到 Application Fee 3 HKD

當訂單退款時,會視情況決定是否連同 Application Fee 一起退還。

觸發條件

兩個條件都必須成立才會執行 Application Fee 退款:

if (request.ExtendInfo.IsRefundApplicationFee == true &&
    request.ExtendInfo.ApplicationFeeAmount > 0)
{
    await this._stripeHttpClient.CreateApplicationFeeRefundAsync(charge.ApplicationFee, body);
}
欄位說明
is_refund_application_fee旗標,由上游系統決定此筆退款是否退還手續費
application_fee_amount要退還的手續費金額(原始金額,會乘以 100 轉換)
⚠️

application_fee_amount = 0 時不退: 即使 is_refund_application_fee = true,若金額為 0 也不會執行,避免呼叫 Stripe API 時帶入 0 金額導致錯誤。

API 呼叫

POST /v1/application_fees/{applicationFeeId}/refunds
Authorization: ******
(無 Stripe-Account header)
 
{
  "amount": "{AmountConvert(ApplicationFeeAmount)}"
}

applicationFeeId 來自 Step 1 取得的 charge.ApplicationFee

注意事項

  • 不帶子帳號 header: Application Fee 是由平台主帳號收取,存放在主帳號餘額,退款時直接在主帳號執行,不論 DirectCharge 或 DestinationCharge 都不帶 Stripe-Account header
  • refund_application_feePOST /v1/refunds 固定為 false: Application Fee 的退款已在 Step 2 獨立精確控制金額,若設為 true,Stripe 會自動依比例退還,無法精確控制金額
  • 部分退款的 Application Fee 計算: ApplicationFeeAmount 由上游傳入,PMW 不自行計算,上游應依退款比例自行算出要退多少手續費

情境對照

情境is_refund_application_feeapplication_fee_amount是否執行 Step 2
全額退款含手續費true15.00✅ 是
全額退款不退手續費false15.00❌ 否
部分退款含手續費true7.50✅ 是
手續費為零true0❌ 否(金額 = 0 跳過)
08

真實案例:DestinationCharge + Application Fee 退款

一筆 CreditCardOnce_Stripe 訂單的退款請求記錄,剛好完整對應到 DestinationCharge + Application Fee 的全部 4 個 API 步驟,可以拿真實數字驗證前面的規則:

案例CreditCardOnceStripeRefundRequestFinish

來源:異常案例紀錄/04-退款請求失敗.md

{"RefundRequestIds":[178103,178105],"TradesOrderGroupId":2621242,"PayType":"CreditCardOnce_Stripe"}
 
Payment Flow: DestinationCharge, SubAcct: acct_1LshoB2eEjG5vam6
TradesOrderSlaveCode:TS260318M000059
 
Stripe 退款金額:4203.72 手續費:210.19 費率:0.050

驗證一下手續費比例:210.19 ÷ 4203.72 ≈ 0.05,剛好對上記錄的 費率 0.050,也代表 ApplicationFeeAmount 是上游依這筆訂單的實際費率算好才傳進來的,PMW 本身不重算。

PayType = CreditCardOnce_Stripe 加上 PayFlow = DestinationCharge,這筆退款理論上會依序執行:

Step 1 取得 PaymentIntent
Step 2 退 Application Fee
Step 3 Transfer Reversal
Step 4 正式退款
🔍

排查方式: 案例中附上的 SQL 是從 TradesOrderSlave 關聯 TradesOrderGroupTradesOrderThirdPartyPaymentOrderSlaveFlow,用 TradesOrderGroupId 反查訂單當下的付款狀態、金流回應訊息與出貨流程狀態,是排查「退款請求送出後結果為何」的標準做法:先確認 TradesOrderThirdPartyPayment_StatusDefResponseMsg,再對照本文 04 節的 ReturnCode 表判斷卡在哪一步。

10

異常案例:Refund amount 大於 unrefunded amount on fee

Grafana 告警擷取到一筆第三方 API 4XX 錯誤,實際追下去是 Application Fee 退款時金額超過該筆 Fee 剩餘可退額度,觸發 Stripe 的 invalid_request_error

告警第三方 API 4XX

Labels:env=prod market=hk service=Payment | Annotations:Grafana_state_reason=NoData,Ref_id=A

REQUEST PATH: /api/v1.0/Refund/CreditCardOnce_Stripe/TG260307Y00010
REQUEST METHOD: POST
 
request "POST" "https://api.stripe.com/v1/application_fees/fee_1T8LSkQgTqi9XNs5Rn6BZA2k/refunds"
 
HTTP Response - Status: BadRequest
{
  "error": {
    "message": "Refund amount ($8.96) is greater than unrefunded amount on fee ($7.36)",
    "param": "amount",
    "request_log_url": "https://dashboard.stripe.com/acct_1KE3thHTw5MqAUMJ/workbench/logs?object=req_21VZr6ZOycmLtU",
    "type": "invalid_request_error"
  }
}

這張單在 13:02 左右因為遇到 API Key 權限問題,導致退款流程執行到一半失敗。

13:02 當下的退款發生歷程:

1
取得 PaymentIntent

正常執行完成。

2
退還 Application Fee

正常執行完成,\$7.36 額度已先被扣掉一部分。

3
Transfer Reversal 失敗

此時 API Key 權限失效,Transfer Reversal 呼叫失敗,整筆退款流程中斷在這一步。

4
正式退款

未執行到,流程已在 Step 3 中斷。

⚠️

根因: AM 將 API Key 權限復原後,系統重新觸發了一次以上的退款流程,但先前中斷前 Step 2(退還 Application Fee)已經成功執行過一次。重試時 Step 2 又被重複執行,導致同一筆 Application Fee 被要求退還超過其剩餘可退金額($8.96 > $7.36),Stripe 因而回傳 invalid_request_error

💡

啟示: 退款流程中斷重試時,需要判斷哪些步驟已經成功執行過(尤其是 Step 2 Application Fee 退款這種「非冪等」的操作),避免重試時對同一筆 Fee 重複扣款;否則即使前面 3 步都各自符合規則,組合起來仍可能超退。

後續處理

由於 Step 2 重複執行導致 Step 3(Transfer Reversal)與 Step 4(正式退款)卡住未能完成,最終需要人工介入,手動補執行 Transfer ReversalRefund 這兩步,才能讓整筆退款流程回到一致狀態。

處理結果: 手動補執行 Step 3、Step 4 後,這筆訂單的退款狀態恢復正常,資金也正確回到持卡人與各帳號。