Stripe 信用卡付款有時候不是「打完 API 馬上知道結果」, 3D 驗證、系統可能需要事後再確認一次「這筆錢到底有沒有真的收到」。QueryPayment 就是拿著 Pay 時取得的交易編號,回頭去問 Stripe「這筆付款現在狀態是什麼」,再把 Stripe 的答案翻譯成 PaymentMiddleware 自己的標準回應碼,讓上游系統不用去研究 Stripe 原始的狀態機

三種時機:

使用時機說明
3D 驗證完成後Pay 回傳 2003,用戶完成 3D 驗證後,需呼叫此 API 確認最終結果
非同步補查付款後主動確認 PaymentIntent 是否真正成功,確保資料一致性
輪詢查詢持續確認等待中的付款狀態,直到收斂為明確結果

🎯 核心目的

把 Stripe PaymentIntent.status 轉換成 PaymentMiddleware 標準 ReturnCode,讓上游系統不需要理解 Stripe 原始狀態機。

🔑 查詢主鍵

transaction_id 必須是 PaymentIntent IDpi_xxx 格式),不能是 Charge ID(ch_xxx)。

⚠️ 設計重點

「查詢失敗」與「付款失敗」被刻意區分:Stripe API 呼叫失敗回傳 2003,只有明確的付款拒絕才回傳 3000

入口 API

1
2
3
4
POST /api/v1/QueryPayment/{payMethod}_{payChannel}

// Stripe 信用卡範例:
POST /api/v1/QueryPayment/CreditCardOnce_Stripe

Request 結構

HTTP Body 對應 QueryPaymentRequestEntity

欄位型別必填說明
request_idstring本次查詢的唯一識別碼
transaction_idstringPay 時回傳的 PaymentIntent IDpi_xxx 格式),查詢的主鍵
countrystring國家代碼
extend_info.payment_flowstringDirectChargeDestinationCharge,影響 Header 帶法
extend_info.stripe_accountstringStripe 子帳號 ID(DirectCharge 查詢時帶入 Header)
extend_info.query_stringstring⚠️ 目前程式碼未使用,傳入無任何效果(dead field)
1
2
3
4
5
6
7
8
{
"request_id": "唯一請求識別碼",
"transaction_id": "pi_xxxxxxxxxxxxxxxxxxxxxxxx",
"extend_info": {
"payment_flow": "DirectCharge | DestinationCharge",
"stripe_account": "acct_xxxxxxxxxx"
}
}

Response 結構

1
2
3
4
5
6
7
8
9
10
{
"request_id": "唯一請求識別碼",
"return_code": "0000",
"return_message": "succeeded",
"transaction_id": "pi_xxxxxxxxxxxxxxxxxxxxxxxx",
"extend_info": {
"payment_intent_id": "pi_xxxxxxxxxxxxxxxxxxxxxxxx",
"charge_id": "ch_xxxxxxxxxxxxxxxxxxxxxxxx"
}
}

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}

項目DirectChargeDestinationCharge
HeaderStripe-Account: {sub_account}
查詢範圍子帳號下的 PaymentIntent主帳號下的 PaymentIntent
subAcct 判斷request.ExtendInfo.SubAccountnull

GetThirdPartyQueryPaymentDetail() 是整支 API 的核心,依 PaymentIntent.status 與是否帶有錯誤資訊,決定回傳的 ReturnCode

✅ status = succeeded(付款成功)— ReturnCode 0000

信用卡情境下,ExtendInfo 只帶回:

欄位來源
payment_intent_idPaymentIntentResponseEntity.id
charge_idcharges.data[0].id

若付款方式是 Mobile Wallet(GooglePay / ApplePay),ExtendInfo 會額外多帶回卡片資訊:

欄位來源
card_brandcharges.data[0].payment_method_details.card.brand
card_countrycharges.data[0].payment_method_details.card.country
card_exp_monthcharges.data[0].payment_method_details.card.exp_month
card_exp_yearcharges.data[0].payment_method_details.card.exp_year
card_last4charges.data[0].payment_method_details.card.last4

❌ status = requires_payment_method 且有 last_payment_error(付款被拒)— ReturnCode 3000

欄位來源
statusrequires_payment_method
last_payment_error_codelast_payment_error.code
last_payment_error_decline_codelast_payment_error.decline_code
last_payment_error_messagelast_payment_error.message
last_payment_error_typelast_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(canceledprocessing 等未處理狀態)— 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附加條件ReturnCodeReturnMessageExtendInfo
succeeded0000"succeeded"payment_intent_id + charge_id(Mobile Wallet 另加卡片資訊)
requires_payment_methodlast_payment_error3000Stripe 錯誤文字錯誤詳情(含 decline_code
requires_action2003"requires_action"null
requires_payment_methodlast_payment_error2003"requires_payment_method"null
requires_confirmation2003"requires_confirmation"null
canceled / 其他9001原始 status 字串null(並觸發 logger.LogWarning
Stripe 回傳 4xx/5xxApiException2003"status code: {n}, message: ..."nulltransaction_id 為空字串
未知例外ExceptionHTTP 500

Response 裡同時出現的 payment_intent_idcharge_id,代表的是 Stripe 付款流程中兩個不同層級的物件,先分開理解各自的角色,再看兩者的關係。

🧭

PaymentIntent(pi_...

代表「一次完整付款的意圖/流程」,是 Stripe 付款的最上層物件,貫穿整個生命週期(requires_payment_methodrequires_actionprocessingsucceeded/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_methodcanceled)只有 payment_intent_id

同一筆 transaction_id,使用不同 payment_flow 查詢,結果可能不同,因為 Header 帶法會改變 Stripe 實際查詢的帳號範圍:

比較項目DirectChargeDestinationCharge
Stripe-Account Header✅ 帶入子帳號❌ 不帶
查詢對象子帳號下的 PaymentIntent主帳號下的 PaymentIntent
subAcct 判斷request.ExtendInfo.SubAccountnull
情況行為
DirectCharge 帶正確 sub_account✅ 正常查詢
DirectCharge 帶錯誤 sub_account❌ Stripe 回 404(PaymentIntent 不屬於此帳號)→ 2003
DestinationCharge 不帶 sub_account✅ 從主帳號查詢,可正常取得
DestinationCharge 誤傳 DirectCharge 的 payment_flow❌ 因不帶 Header 而查不到子帳號的 PaymentIntent2003
🎯

結論: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
13D 驗證完成 → 付款成功Pay 回 2003,用戶完成 3D0000
23D 驗證中 → 用戶尚未操作Pay 回 2003,立即查詢2003
33D 驗證完成 → 付款被拒3D 通過但發卡行最終拒絕3000
4未觸發 3D → 直接付款成功Pay 直接 succeeded,主動確認0000
5卡片被拒(明確原因)Stripe 直接拒卡3000
6查詢不存在的 PaymentIntenttransaction_id 錯誤或過期2003
7PaymentIntent 已被取消先 Cancel 後查詢9001
8Stripe 服務暫時異常Stripe API 回 5xx2003
1 3D 驗證完成 → 付款成功 ReturnCode 0000

用戶在 Pay 時,Stripe 判斷此張信用卡需要 3D 驗證(SCA 要求),Pay API 回傳 2003 並附上 3D 驗證的導頁 URL。用戶完成銀行 3D 驗證後,系統回呼並查詢最終付款結果。

requires_action → (用戶完成 3D)→ succeeded

觸發條件:Pay 回應 return_code: 2003PaymentIntent.status = requires_action、用戶已完成銀行端的 3D 驗證。

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-001",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
8
9
10
{
"request_id": "query-001",
"return_code": "0000",
"return_message": "succeeded",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_intent_id": "pi_3PabcXXXXXXXXXXXX",
"charge_id": "ch_3PabcXXXXXXXXXXXX"
}
}
2 3D 驗證中 → 用戶尚未完成操作 ReturnCode 2003

Pay 後系統立即或過短時間內查詢,用戶仍在銀行的 3D 驗證頁面尚未完成操作,PaymentIntent 狀態仍停留在 requires_action

requires_action(持續中)

觸發條件:Pay 回應 return_code: 2003、用戶尚未完成 3D 驗證、查詢時 PaymentIntent 仍為 requires_action

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-002",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
{
"request_id": "query-002",
"return_code": "2003",
"return_message": "requires_action",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": null
}
💡

上游系統應繼續輪詢,直到 status 從 requires_action 轉為 succeededrequires_payment_method(含錯誤)。

3 3D 驗證完成 → 付款最終被拒 ReturnCode 3000

用戶完成 3D 驗證,但發卡行在最終授權階段仍拒絕此筆交易(例如額度不足、帳戶異常等),Stripe 將 PaymentIntent 狀態設為 requires_payment_method 並附上 last_payment_error

requires_action → (3D 完成)→ requires_payment_method(附 last_payment_error)

觸發條件:用戶完成了 3D 驗證、發卡行在授權時拒絕、PaymentIntent.status = requires_payment_methodlast_payment_error != null

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-003",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DestinationCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"request_id": "query-003",
"return_code": "3000",
"return_message": "Your card has insufficient funds.",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"status": "requires_payment_method",
"last_payment_error_code": "card_declined",
"last_payment_error_decline_code": "insufficient_funds",
"last_payment_error_message": "Your card has insufficient funds.",
"last_payment_error_type": "card_error"
}
}
4 未觸發 3D → 主動確認付款成功 ReturnCode 0000

Pay 時 Stripe 判斷為低風險交易,直接回傳 succeededreturn_code: 0000)。上游系統為確保資料一致性,仍主動呼叫 QueryPayment 二次確認。

succeeded

觸發條件:Pay 已回傳 return_code: 0000、主動發起查詢以二次確認。

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-004",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
8
9
10
{
"request_id": "query-004",
"return_code": "0000",
"return_message": "succeeded",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_intent_id": "pi_3PabcXXXXXXXXXXXX",
"charge_id": "ch_3PabcXXXXXXXXXXXX"
}
}
5 卡片被 Stripe 直接拒絕(有明確 decline_code) ReturnCode 3000

Pay 階段,Stripe 建立 PaymentIntent 並立即確認,但信用卡被拒絕(例如卡號無效、已過期、被停用)。此時 Pay 本身就回傳 3000,QueryPayment 若再查詢,同樣取得拒絕狀態。

requires_payment_method(含 last_payment_error)

常見 decline_code 一覽:

decline_code原因
insufficient_funds餘額不足
card_declined發卡行拒絕(未提供細節)
expired_card信用卡已過期
incorrect_cvcCVV 錯誤
stolen_card疑似被盜卡
do_not_honor發卡行要求拒絕,原因不明
fraudulentStripe 風險判斷為詐欺

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-005",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"request_id": "query-005",
"return_code": "3000",
"return_message": "Your card is expired.",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"status": "requires_payment_method",
"last_payment_error_code": "expired_card",
"last_payment_error_decline_code": "expired_card",
"last_payment_error_message": "Your card is expired.",
"last_payment_error_type": "card_error"
}
}
6 查詢不存在的 PaymentIntent ReturnCode 2003

傳入錯誤的 transaction_id(打錯 ID、ID 不屬於此帳號、測試環境 ID 用於正式環境等),Stripe 回傳 HTTP 404,程式碼進入 ApiException 處理。

觸發條件:transaction_id 格式錯誤、不存在或不屬於查詢帳號,Stripe 回傳 HTTP 404。

程式碼處理路徑:

1
2
3
4
5
6
7
8
catch (ApiException ex)
{
return new QueryPaymentResponseEntity
{
ReturnCode = ReturnCodes.WaitingToPay, // "2003"
ReturnMessage = $"status code: 404, message: No such payment_intent: '{pi_xxx}'"
};
}

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-006",
"transaction_id": "pi_INVALID_OR_WRONG",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
{
"request_id": "query-006",
"return_code": "2003",
"return_message": "status code: 404, message: No such payment_intent: 'pi_INVALID_OR_WRONG'",
"transaction_id": "",
"extend_info": null
}
⚠️

ReturnCode 為 2003 3000,設計上避免因查詢失敗而誤判付款失敗;transaction_id 回傳空字串(非傳入值);上游系統應記錄此情況並人工確認,不應直接視為付款失敗。

7 PaymentIntent 已被取消(Cancel 後再查詢) ReturnCode 9001

付款請求先被呼叫 Cancel API 取消,之後再查詢其狀態。Stripe PaymentIntent 狀態為 canceled,程式碼進入 else(UnhandledException)分支。

canceled

觸發條件:先呼叫 POST /api/v1/Cancel/... 取消成功(ReturnCode 5000),再呼叫 QueryPayment 查詢同一筆。

程式碼處理路徑:

1
2
3
4
5
else
{
_logger.LogWarning($"Payment Exception. PaymentIntentResponseEntity: ...");
return (ReturnCodes.UnhandledException, status, null); // "9001"
}

Request

1
2
3
4
5
6
7
8
{
"request_id": "query-007",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": {
"payment_flow": "DirectCharge",
"stripe_account": "acct_1A2B3C4D5E"
}
}

Response

1
2
3
4
5
6
7
{
"request_id": "query-007",
"return_code": "9001",
"return_message": "canceled",
"transaction_id": "pi_3PabcXXXXXXXXXXXX",
"extend_info": null
}
💡

9001UnhandledException,系統沒有針對 canceled 定義特定行為,同時會觸發 logger.LogWarning 並記錄完整 PaymentIntent JSON;上游系統若收到 9001,應自行判斷是否為已取消場景。

8 Stripe 服務暫時異常(5xx) ReturnCode 2003

Stripe 服務端發生暫時性錯誤(例如 503 Service Unavailable),查詢失敗並進入 ApiException 處理。

觸發條件:Stripe API 回傳 HTTP 5xx;若為網路超時或連線失敗,則會進入 Exception(非 ApiException)分支。

  • 5xx(ApiException) → ReturnCode 2003
  • 網路超時(Exception)logger.LogErrorthrow → HTTP 500

Response(5xx 情況)

1
2
3
4
5
6
7
{
"request_id": "query-008",
"return_code": "2003",
"return_message": "status code: 503, message: Service temporarily unavailable.",
"transaction_id": "",
"extend_info": null
}