Stripe-Query
Stripe 信用卡付款有時候不是「打完 API 馬上知道結果」, 3D 驗證、系統可能需要事後再確認一次「這筆錢到底有沒有真的收到」。QueryPayment 就是拿著 Pay 時取得的交易編號,回頭去問 Stripe「這筆付款現在狀態是什麼」,再把 Stripe 的答案翻譯成 PaymentMiddleware 自己的標準回應碼,讓上游系統不用去研究 Stripe 原始的狀態機
三種時機:
| 使用時機 | 說明 |
|---|---|
| 3D 驗證完成後 | Pay 回傳 2003,用戶完成 3D 驗證後,需呼叫此 API 確認最終結果 |
| 非同步補查 | 付款後主動確認 PaymentIntent 是否真正成功,確保資料一致性 |
| 輪詢查詢 | 持續確認等待中的付款狀態,直到收斂為明確結果 |
🎯 核心目的
把 Stripe PaymentIntent.status 轉換成 PaymentMiddleware 標準 ReturnCode,讓上游系統不需要理解 Stripe 原始狀態機。
🔑 查詢主鍵
transaction_id 必須是 PaymentIntent ID(pi_xxx 格式),不能是 Charge ID(ch_xxx)。
⚠️ 設計重點
「查詢失敗」與「付款失敗」被刻意區分:Stripe API 呼叫失敗回傳 2003,只有明確的付款拒絕才回傳 3000。
入口 API
1 | POST /api/v1/QueryPayment/{payMethod}_{payChannel} |
Request 結構
HTTP Body 對應 QueryPaymentRequestEntity:
| 欄位 | 型別 | 必填 | 說明 |
|---|---|---|---|
request_id | string | ✅ | 本次查詢的唯一識別碼 |
transaction_id | string | ✅ | Pay 時回傳的 PaymentIntent ID(pi_xxx 格式),查詢的主鍵 |
country | string | — | 國家代碼 |
extend_info.payment_flow | string | ✅ | DirectCharge 或 DestinationCharge,影響 Header 帶法 |
extend_info.stripe_account | string | ✅ | Stripe 子帳號 ID(DirectCharge 查詢時帶入 Header) |
extend_info.query_string | string | ❌ | ⚠️ 目前程式碼未使用,傳入無任何效果(dead field) |
1 | { |
Response 結構
1 | { |
StripePlugin.QueryPayment() 收到請求後,依 payment_flow 決定是否帶入子帳號 Header,再呼叫 Stripe API 查詢,最後把結果交給 GetThirdPartyQueryPaymentDetail() 判斷狀態:
QueryPayment(request)
│
├─ payment_flow == DirectCharge?
│ ├─ YES → subAcct = ExtendInfo.SubAccount
│ └─ NO → subAcct = null
│
▼
[Stripe API] GET /v1/payment_intents/{transaction_id}
DirectCharge → Header: Stripe-Account: {sub_account}
DestinationCharge → Header: 無(使用 Platform 主帳號查詢)
│
├─ 成功 → GetThirdPartyQueryPaymentDetail(response)
│ 依 status 判斷,回傳 ReturnCode + ExtendInfo
│
├─ ApiException(Stripe 回 4xx/5xx)
│ ReturnCode = 2003(WaitingToPay)
│
└─ Exception(其他未知錯誤)
logger.LogError → throw → HTTP 500
Stripe API 呼叫:GET /v1/payment_intents/{id}
| 項目 | DirectCharge | DestinationCharge |
|---|---|---|
| Header | Stripe-Account: {sub_account} | 無 |
| 查詢範圍 | 子帳號下的 PaymentIntent | 主帳號下的 PaymentIntent |
| subAcct 判斷 | request.ExtendInfo.SubAccount | null |
GetThirdPartyQueryPaymentDetail() 是整支 API 的核心,依 PaymentIntent.status 與是否帶有錯誤資訊,決定回傳的 ReturnCode:
✅ status = succeeded(付款成功)— ReturnCode 0000
信用卡情境下,ExtendInfo 只帶回:
| 欄位 | 來源 |
|---|---|
payment_intent_id | PaymentIntentResponseEntity.id |
charge_id | charges.data[0].id |
若付款方式是 Mobile Wallet(GooglePay / ApplePay),ExtendInfo 會額外多帶回卡片資訊:
| 欄位 | 來源 |
|---|---|
card_brand | charges.data[0].payment_method_details.card.brand |
card_country | charges.data[0].payment_method_details.card.country |
card_exp_month | charges.data[0].payment_method_details.card.exp_month |
card_exp_year | charges.data[0].payment_method_details.card.exp_year |
card_last4 | charges.data[0].payment_method_details.card.last4 |
❌ status = requires_payment_method 且有 last_payment_error(付款被拒)— ReturnCode 3000
| 欄位 | 來源 |
|---|---|
status | requires_payment_method |
last_payment_error_code | last_payment_error.code |
last_payment_error_decline_code | last_payment_error.decline_code |
last_payment_error_message | last_payment_error.message |
last_payment_error_type | last_payment_error.type |
ReturnMessage 直接使用 last_payment_error.message(Stripe 原始錯誤文字)。
⏳ status = requires_action / requires_payment_method(無錯誤)/ requires_confirmation(等待中)— ReturnCode 2003
| status | 意義 |
|---|---|
requires_action | 仍在等待 3D 驗證 |
requires_payment_method(無錯誤) | 等待用戶提供付款方式 |
requires_confirmation | 等待確認 |
這三種情況 ExtendInfo 皆為 null,ReturnMessage 直接回傳原始 Stripe status 字串。
⚠️ 其他 status(canceled、processing 等未處理狀態)— ReturnCode 9001(UnhandledException)
會觸發 logger.LogWarning 記錄完整 PaymentIntentResponseEntity,ReturnMessage 為原始 Stripe status 字串,ExtendInfo 為 null。
🔴 Stripe API 呼叫失敗(ApiException,4xx/5xx)— ReturnCode 2003(WaitingToPay)
設計注意:QueryPayment 遇到 Stripe API 錯誤時,回傳的是 2003 而不是 3000。設計意圖是「查詢失敗 ≠ 付款失敗」,上游系統應判斷是否需要重試,不可直接認定為付款失敗。
ReturnMessage 格式為 "status code: {http_status}, message: {error.message}",TransactionId 回傳空字串。
🔴 其他未預期例外(Exception)— HTTP 500
logger.LogError 記錄後直接 throw,回傳 HTTP 500,代表非 Stripe 業務邏輯內的錯誤(例如網路逾時、連線失敗)。
完整狀態對照表
| PaymentIntent status | 附加條件 | ReturnCode | ReturnMessage | ExtendInfo |
|---|---|---|---|---|
succeeded | — | 0000 | "succeeded" | payment_intent_id + charge_id(Mobile Wallet 另加卡片資訊) |
requires_payment_method | 有 last_payment_error | 3000 | Stripe 錯誤文字 | 錯誤詳情(含 decline_code) |
requires_action | — | 2003 | "requires_action" | null |
requires_payment_method | 無 last_payment_error | 2003 | "requires_payment_method" | null |
requires_confirmation | — | 2003 | "requires_confirmation" | null |
canceled / 其他 | — | 9001 | 原始 status 字串 | null(並觸發 logger.LogWarning) |
| Stripe 回傳 4xx/5xx | ApiException | 2003 | "status code: {n}, message: ..." | null,transaction_id 為空字串 |
| 未知例外 | Exception | HTTP 500 | — | — |
Response 裡同時出現的 payment_intent_id 與 charge_id,代表的是 Stripe 付款流程中兩個不同層級的物件,先分開理解各自的角色,再看兩者的關係。
PaymentIntent(pi_...)
代表「一次完整付款的意圖/流程」,是 Stripe 付款的最上層物件,貫穿整個生命週期(requires_payment_method → requires_action → processing → succeeded/canceled 等)。
即使卡片被拒後換卡重試,id 也不會變——PMW/mweb 用它來查詢狀態、取消付款(GET /v1/payment_intents/{id}、POST /v1/payment_intents/{id}/cancel)。
Charge(ch_...)
代表 PaymentIntent 底下「實際成功扣款的那一筆交易紀錄」,只有 status = succeeded 之後才會產生,是退款(Refund)的操作對象。
DirectCharge/DestinationCharge 退款都要先用 GET /v1/payment_intents/{id} 撈出 charges.data[0].id 才能對該筆 Charge 執行退款。一個 PaymentIntent 理論上最多對應一筆成功的 Charge;失敗重試的嘗試不會產生 Charge。
簡單比喻:PaymentIntent 是這筆訂單的付款流程/狀態機,Charge 是流程走到成功那一刻留下的扣款收據。
這也是為什麼只有 succeeded 狀態才會同時帶出 payment_intent_id + charge_id,其餘狀態(如 requires_payment_method、canceled)只有 payment_intent_id。
同一筆 transaction_id,使用不同 payment_flow 查詢,結果可能不同,因為 Header 帶法會改變 Stripe 實際查詢的帳號範圍:
| 比較項目 | DirectCharge | DestinationCharge |
|---|---|---|
Stripe-Account Header | ✅ 帶入子帳號 | ❌ 不帶 |
| 查詢對象 | 子帳號下的 PaymentIntent | 主帳號下的 PaymentIntent |
| subAcct 判斷 | request.ExtendInfo.SubAccount | null |
| 情況 | 行為 |
|---|---|
| DirectCharge 帶正確 sub_account | ✅ 正常查詢 |
| DirectCharge 帶錯誤 sub_account | ❌ Stripe 回 404(PaymentIntent 不屬於此帳號)→ 2003 |
| DestinationCharge 不帶 sub_account | ✅ 從主帳號查詢,可正常取得 |
| DestinationCharge 誤傳 DirectCharge 的 payment_flow | ❌ 因不帶 Header 而查不到子帳號的 PaymentIntent → 2003 |
結論:payment_flow 必須與 Pay 時使用的 flow 完全一致,否則會因為查詢帳號範圍錯位而拿到 2003,容易被誤判為「付款狀態不明」而非「查詢參數帶錯」。
把整支 API 的判斷邏輯畫成一張樹狀圖,方便快速定位「收到某個回應時,究竟命中哪一條分支」:
收到 QueryPayment 請求
│
▼
GET /v1/payment_intents/{id}
│
┌────┴──────────────────────────────────┐
▼ ▼
API 成功 ApiException(4xx/5xx)
│ │
│ ReturnCode = 2003
▼ ReturnMessage = "status code: xxx, ..."
status?
│
├─ "succeeded"
│ └─ ReturnCode = 0000
│ ExtendInfo: payment_intent_id + charge_id
│
├─ "requires_payment_method" + last_payment_error
│ └─ ReturnCode = 3000
│ ExtendInfo: 錯誤詳情 + decline_code
│
├─ "requires_action"
├─ "requires_payment_method"(無錯誤)
├─ "requires_confirmation"
│ └─ ReturnCode = 2003(持續等待)
│ ExtendInfo: null
│
└─ "canceled" / 其他
└─ ReturnCode = 9001(UnhandledException)
logger.LogWarning 記錄
ExtendInfo: null
以下 8 種情境涵蓋了 QueryPayment 在真實環境中會遇到的所有代表性狀況,每個情境都附上觸發條件、Stripe 狀態變化,以及對應的 Request / Response 範例。
| # | 情境名稱 | 觸發來源 | ReturnCode |
|---|---|---|---|
| 1 | 3D 驗證完成 → 付款成功 | Pay 回 2003,用戶完成 3D | 0000 |
| 2 | 3D 驗證中 → 用戶尚未操作 | Pay 回 2003,立即查詢 | 2003 |
| 3 | 3D 驗證完成 → 付款被拒 | 3D 通過但發卡行最終拒絕 | 3000 |
| 4 | 未觸發 3D → 直接付款成功 | Pay 直接 succeeded,主動確認 | 0000 |
| 5 | 卡片被拒(明確原因) | Stripe 直接拒卡 | 3000 |
| 6 | 查詢不存在的 PaymentIntent | transaction_id 錯誤或過期 | 2003 |
| 7 | PaymentIntent 已被取消 | 先 Cancel 後查詢 | 9001 |
| 8 | Stripe 服務暫時異常 | Stripe API 回 5xx | 2003 |
用戶在 Pay 時,Stripe 判斷此張信用卡需要 3D 驗證(SCA 要求),Pay API 回傳 2003 並附上 3D 驗證的導頁 URL。用戶完成銀行 3D 驗證後,系統回呼並查詢最終付款結果。
requires_action → (用戶完成 3D)→ succeeded
觸發條件:Pay 回應 return_code: 2003、PaymentIntent.status = requires_action、用戶已完成銀行端的 3D 驗證。
Request
1 | { |
Response
1 | { |
Pay 後系統立即或過短時間內查詢,用戶仍在銀行的 3D 驗證頁面尚未完成操作,PaymentIntent 狀態仍停留在 requires_action。
requires_action(持續中)
觸發條件:Pay 回應 return_code: 2003、用戶尚未完成 3D 驗證、查詢時 PaymentIntent 仍為 requires_action。
Request
1 | { |
Response
1 | { |
上游系統應繼續輪詢,直到 status 從 requires_action 轉為 succeeded 或 requires_payment_method(含錯誤)。
用戶完成 3D 驗證,但發卡行在最終授權階段仍拒絕此筆交易(例如額度不足、帳戶異常等),Stripe 將 PaymentIntent 狀態設為 requires_payment_method 並附上 last_payment_error。
requires_action → (3D 完成)→ requires_payment_method(附 last_payment_error)
觸發條件:用戶完成了 3D 驗證、發卡行在授權時拒絕、PaymentIntent.status = requires_payment_method 且 last_payment_error != null。
Request
1 | { |
Response
1 | { |
Pay 時 Stripe 判斷為低風險交易,直接回傳 succeeded(return_code: 0000)。上游系統為確保資料一致性,仍主動呼叫 QueryPayment 二次確認。
succeeded
觸發條件:Pay 已回傳 return_code: 0000、主動發起查詢以二次確認。
Request
1 | { |
Response
1 | { |
Pay 階段,Stripe 建立 PaymentIntent 並立即確認,但信用卡被拒絕(例如卡號無效、已過期、被停用)。此時 Pay 本身就回傳 3000,QueryPayment 若再查詢,同樣取得拒絕狀態。
requires_payment_method(含 last_payment_error)
常見 decline_code 一覽:
| decline_code | 原因 |
|---|---|
insufficient_funds | 餘額不足 |
card_declined | 發卡行拒絕(未提供細節) |
expired_card | 信用卡已過期 |
incorrect_cvc | CVV 錯誤 |
stolen_card | 疑似被盜卡 |
do_not_honor | 發卡行要求拒絕,原因不明 |
fraudulent | Stripe 風險判斷為詐欺 |
Request
1 | { |
Response
1 | { |
傳入錯誤的 transaction_id(打錯 ID、ID 不屬於此帳號、測試環境 ID 用於正式環境等),Stripe 回傳 HTTP 404,程式碼進入 ApiException 處理。
觸發條件:transaction_id 格式錯誤、不存在或不屬於查詢帳號,Stripe 回傳 HTTP 404。
程式碼處理路徑:
1 | catch (ApiException ex) |
Request
1 | { |
Response
1 | { |
ReturnCode 為 2003 而非 3000,設計上避免因查詢失敗而誤判付款失敗;transaction_id 回傳空字串(非傳入值);上游系統應記錄此情況並人工確認,不應直接視為付款失敗。
付款請求先被呼叫 Cancel API 取消,之後再查詢其狀態。Stripe PaymentIntent 狀態為 canceled,程式碼進入 else(UnhandledException)分支。
canceled
觸發條件:先呼叫 POST /api/v1/Cancel/... 取消成功(ReturnCode 5000),再呼叫 QueryPayment 查詢同一筆。
程式碼處理路徑:
1 | else |
Request
1 | { |
Response
1 | { |
9001 是 UnhandledException,系統沒有針對 canceled 定義特定行為,同時會觸發 logger.LogWarning 並記錄完整 PaymentIntent JSON;上游系統若收到 9001,應自行判斷是否為已取消場景。
Stripe 服務端發生暫時性錯誤(例如 503 Service Unavailable),查詢失敗並進入 ApiException 處理。
觸發條件:Stripe API 回傳 HTTP 5xx;若為網路超時或連線失敗,則會進入 Exception(非 ApiException)分支。
- 5xx(ApiException) → ReturnCode
2003 - 網路超時(Exception) →
logger.LogError→throw→ HTTP 500
Response(5xx 情況)
1 | { |


