Stripe 退款完整解析:DirectCharge / DestinationCharge 與 Application Fee 拆帳
概述與設計理念
Stripe Plugin 沒有實作 IRefundQueryable,POST /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 退款的實際數字對照。
完整退款流程架構
Stripe 退款最多需要 4 個 API 呼叫,實際會執行哪幾步,取決於 PaymentFlow(DirectCharge / DestinationCharge)與 IsRefundApplicationFee 這兩個條件:
GET /v1/payment_intents/{id} — 從回應中取出 charge.id、charge.ApplicationFee、charge.Transfer,這是後面 3 步的資料來源。
POST /v1/application_fees/{applicationFeeId}/refunds — 僅在 is_refund_application_fee=true 且 application_fee_amount > 0 時才執行,退還平台向子帳號收取的手續費。
POST /v1/transfers/{transferId}/reversals — 僅在 payment_flow = DestinationCharge 時執行,撤銷付款當初從主帳號轉給子帳號的資金。
POST /v1/refunds — 對 Step 1 取得的 Charge 發起實際退款,退還金額回到持卡人。
API 呼叫彙總
| 步驟 | API | 說明 | 條件 |
|---|---|---|---|
| 1 | GET /v1/payment_intents/{id} | 取得付款意圖,提取 Charge 資訊 | 必執行 |
| 2 | POST /v1/application_fees/{fee_id}/refunds | 退還 Application Fee | IsRefundApplicationFee=true 且 ApplicationFeeAmount > 0 |
| 3 | POST /v1/transfers/{transfer_id}/reversals | 撤銷 Transfer | StripePaymentFlow = DestinationCharge |
| 4 | POST /v1/refunds | 正式退款至持卡人 | 必執行 |
組合結果: 最少情況(DirectCharge、不退手續費)只需 2 支 API;最多情況(DestinationCharge、同時退手續費)需要串完全部 4 支。詳細組合請見「05 DirectCharge 退款」「06 DestinationCharge 退款」兩節的情境範例。
金額轉換規則
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; } // 手續費金額(原始金額,非最小單位)
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 Success | Stripe POST /v1/refunds 正常回應 |
ApiException | 4001 RefundFailed | Stripe API 回傳錯誤(如 insufficient funds) |
ArgumentNullException | 4001 RefundFailed | Charge 資料異常(如 .Single() 找不到 Charge) |
| 其他未知例外 | re-throw | LogError 後直接往上拋,不吞例外 |
DirectCharge 退款
適用條件:request.ExtendInfo.StripePaymentFlow == StripePaymentFlowEnum.DirectCharge
DirectCharge 模式: 付款時 PaymentMethod 和 PaymentIntent 都在子帳號(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=true 且 ApplicationFeeAmount > 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=0 | Step 1 → Step 4(共 2 個 API 呼叫) |
| 退款同時退手續費 | is_refund_application_fee=true, application_fee_amount=15.00 | Step 1 → Step 2 → Step 4(共 3 個 API 呼叫) |
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=true 且 ApplicationFeeAmount > 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 的關鍵差異
| 項目 | DirectCharge | DestinationCharge |
|---|---|---|
| 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=0 | Step 1 → Step 3 → Step 4(共 3 個 API 呼叫) |
| 退款同時退手續費 | is_refund_application_fee=true, application_fee_amount=15.00 | Step 1 → Step 2 → Step 3 → Step 4(共 4 個 API 呼叫,最多情況) |
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-Accountheader refund_application_fee在POST /v1/refunds固定為false: Application Fee 的退款已在 Step 2 獨立精確控制金額,若設為true,Stripe 會自動依比例退還,無法精確控制金額- 部分退款的 Application Fee 計算:
ApplicationFeeAmount由上游傳入,PMW 不自行計算,上游應依退款比例自行算出要退多少手續費
情境對照
| 情境 | is_refund_application_fee | application_fee_amount | 是否執行 Step 2 |
|---|---|---|---|
| 全額退款含手續費 | true | 15.00 | ✅ 是 |
| 全額退款不退手續費 | false | 15.00 | ❌ 否 |
| 部分退款含手續費 | true | 7.50 | ✅ 是 |
| 手續費為零 | true | 0 | ❌ 否(金額 = 0 跳過) |
真實案例:DestinationCharge + Application Fee 退款
一筆 CreditCardOnce_Stripe 訂單的退款請求記錄,剛好完整對應到 DestinationCharge + Application Fee 的全部 4 個 API 步驟,可以拿真實數字驗證前面的規則:
來源:異常案例紀錄/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,這筆退款理論上會依序執行:
排查方式: 案例中附上的 SQL 是從 TradesOrderSlave 關聯 TradesOrderGroup/TradesOrderThirdPartyPayment/OrderSlaveFlow,用 TradesOrderGroupId 反查訂單當下的付款狀態、金流回應訊息與出貨流程狀態,是排查「退款請求送出後結果為何」的標準做法:先確認 TradesOrderThirdPartyPayment_StatusDef 與 ResponseMsg,再對照本文 04 節的 ReturnCode 表判斷卡在哪一步。
異常案例:Refund amount 大於 unrefunded amount on fee
Grafana 告警擷取到一筆第三方 API 4XX 錯誤,實際追下去是 Application Fee 退款時金額超過該筆 Fee 剩餘可退額度,觸發 Stripe 的 invalid_request_error:
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 當下的退款發生歷程:
正常執行完成。
正常執行完成,\$7.36 額度已先被扣掉一部分。
此時 API Key 權限失效,Transfer Reversal 呼叫失敗,整筆退款流程中斷在這一步。
未執行到,流程已在 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 Reversal 與 Refund 這兩步,才能讓整筆退款流程回到一致狀態。
處理結果: 手動補執行 Step 3、Step 4 後,這筆訂單的退款狀態恢復正常,資金也正確回到持卡人與各帳號。


