Cybersource-Refund
退款這件事,不是使用者在後台按下「退款」按鈕,錢就馬上原路退回。以 Cybersource 這個金流為例,系統其實會先把退款請求排隊,等每小時固定一次的排程撿起來,經過「分群鎖定 → 建立任務 → 呼叫金流 API → 解析回應」好幾層之後,才會真正把退款單狀態改成完成
flowchart TD
A["⏰ SQL Server 排程
NMQV2_GeneratePaymentMiddleWareRefundRequestGrouping
每小時 :02 分觸發"] -->|"cfn_GetJobWhetherToPerform('readwrite',1) IN ('ALL')"| B{"NMQV2DB.dbo.Job
是否存在
PaymentMiddleWareRefundRequestGrouping ?"}
B -- 不存在 --> Z1["結束,不做事"]
B -- 存在 --> C["INSERT INTO NMQV2DB.dbo.Task
Task_Status = Ready
Task_JobId = @jobId"]
C --> D["NMQ Worker 撈取 Ready Task 執行
PaymentMiddleWareRefundRequestGroupingProcess.DoJob()"]
D --> E["PaymentMiddleWareRefundRequestService.CreateRefundRequestFinish()"]
E --> F["GetRefundRequestData()
撈 28 種付款方式中狀態=RefundRequestProcessing 的退款單
+ 各 PayChannel Redo 重試清單"]
F --> G["GroupBy(TradesOrderGroupId)
依 TGCode 分群"]
G --> H["UpdateRefundRequestStatus → RefundRequestGrouping
(鎖定,防止重複撈取)"]
H --> I["依付款方式建立對應 NMQ Task
Cybersource → CreditCardOnceCybersourceRefundRequestFinish"]
I --> J["RefundTaskBookingTime()
Cybersource:回傳 null → 不限速"]
J --> K["⏱ NMQ Task 到點觸發
DoRefundRequestFinish(taskData)"]
K --> L{"CanGroupingRefund() ?
Cybersource = false"}
L -- "true(僅 Razer)" --> M["RefundByGroupingAmount
合併同 TransactionId 金額後退款"]
L -- "false(Cybersource 走此路)" --> N["RefundByRequestId
逐筆退款 / 查詢"]
N --> O{"IsQueryRefund(refundRequest)
= RefundRequest_TransactionId 是否有值?"}
O -- "無值 → 新退款" --> P["呼叫 PaymentMiddleware
POST /Refund/Cybersource"]
O -- "有值 → 查詢既有退款" --> Q["呼叫 PaymentMiddleware
GET 對應 /RefundQuery/Cybersource"]
P --> R["PaymentMiddleware 組裝 HTTP Signature
POST pts/v2/payments/{transactionId}/refunds/"]
Q --> S["PaymentMiddleware 組裝 HTTP Signature
GET tss/v2/transactions/{RefundRequestTransactionId}"]
R --> T["Cybersource 回應 status"]
S --> U["Cybersource 回應 applicationInformation.status/reasonCode"]
T --> V["解析並轉換 ReturnCode
PENDING→RefundPending / 500,DECLINED→RefundFailed"]
U --> W["解析並轉換 ReturnCode
PENDING→RefundPending / TRANSMITTED→Success / reasonCode=100→RefundPending / 其他→RefundRejected"]
V --> X["ChangeRefundRequestStatus + RefundDelay()(空實作)"]
W --> X
X --> Y["UpdateRefundRequest 更新退款單狀態
+ UpdateOrderSlaveFlow 更新訂單大表
(運費退款不更新大表)"]
Y --> AA{"ReturnCode = Success(00) ?"}
AA -- 是 --> AB["Finish 結案
IsClosed=true,寫入 ConfirmDate"]
AA -- "RefundPending / RefundRejected 未結案" --> AC["維持 RefundRequestProcessing
等待下次 :02 分排程再次撈取(RefundQuery)"]STEP 1撈取待處理退款單
對每個 payType(含
CreditCardOnce_Cybersource)呼叫 GetRefundRequestData(),撈出 RefundRequestProcessing 狀態的退款單,並額外呼叫 payChannelService.GetRedoRefundRequestList(payType) 撈重試清單——但 Cybersource 目前這裡固定回傳 null,代表尚未實作 Redo 重試機制。STEP 2依購物車分群
GroupBy(TradesOrderGroupId) 依 TGCode 分群,方便後續 Razer 合併退款;Cybersource 雖然不合併金額,仍共用同一套分群與 Task 建立流程。STEP 3鎖定退款單狀態
UpdateRefundRequestStatus → RefundRequestGrouping,避免下次 :02 分排程重複撈取到同一批退款單。STEP 4建立對應的 NMQ Task
Cybersource 對應的 JobName 是
CreditCardOnceCybersourceRefundRequestFinish。STEP 5決定任務執行時機
RefundTaskBookingTime() 在 Cybersource 覆寫為回傳 null,代表不做限速排程,Task 建立後立即可以被執行。Cybersource 的 CanGroupingRefund()(繼承 AbstractPayChannelService 的預設值)固定回傳 false,因此一定走逐筆退款路線,不會像 Razer 一樣把同一張購物車(TGCode)底下的金額合併起來一次退。
flowchart LR
A["取出待處理 RefundRequest"] --> B{"IsQueryRefund(refundRequest)"}
B -->|"RefundRequest_TransactionId 為空"| C["IsRefund = true(預設)
→ 呼叫 Refund API"]
B -->|"RefundRequest_TransactionId 有值"| D["→ 呼叫 RefundQuery API"]
C --> E["ChangeRefundRequestStatus"]
D --> E
E --> F["RefundDelay()(Cybersource 空實作,不延遲)"]
F --> G["UpdateRefundRequest + UpdateOrderSlaveFlow"]
💡 這裡的分岔只看一件事:這筆退款單有沒有已經記錄過
TransactionId。CybersourcePayChannelService.IsQueryRefund() 判斷 RefundRequest_TransactionId 是否已有值——第一次退款時是空的,呼叫 Refund;Refund 回應受理中(PENDING)之後,TransactionId 已經寫回退款單,下一次排程就改呼叫 RefundQuery 查結果。IsRefund 這個開關 Cybersource 沒有覆寫,預設為 true。額外附帶的參數,GetRefundExtendInfo 帶入 TradesOrderGroupCode,GetRefundQueryExtendInfo 帶入 RefundRequestTransactionId,兩者都由 CybersourcePayChannelService 提供。
Header 來源是 ShopSecret 底下的 Cybersource group,共 6 組憑證(後 3 組供其他用途/預留,實際簽章只用前 3 組):
| Header | 對應 ShopSecret 欄位 |
|---|---|
x-merchant-id |
Cybersource_MerchantId |
x-secret-key |
Cybersource_SecretKey |
x-merchant-key-id |
Cybersource_MerchantKeyId |
x-profile-id |
Cybersource_ProfileId(預留) |
x-profile-access-key |
Cybersource_ProfileAccessKey(預留) |
x-profile-secret |
Cybersource_ProfileSecret(預留) |
實際的簽章 Header 由 HttpSignatureHelper.GenerateHttpSignatureHeaders() 產生。
Refund(新退款)
1 | POST pts/v2/payments/{transactionId}/refunds/ |
| 欄位 | 來源 |
|---|---|
transactionId(URL) |
付款時 Cybersource 回傳的 transaction ID |
clientReferenceInformation.code |
RefundRequestExtendInfo.TradesOrderGroupCode |
orderInformation.amountDetails.totalAmount |
request.Amount |
orderInformation.amountDetails.currency |
request.Currency |
💡 特殊設計 — HTTP 錯誤不拋出:即使 HTTP 回應非 2xx(例如 DECLINED 案例常見的 4xx/5xx),程式也不會直接拋例外中斷,而是改抓例外裡的 response body 繼續解析
errorInformation,確保不會因為 HTTP 狀態碼而遺失金流回傳的錯誤細節。
1 | try |
Status → ReturnCode 對照:
| Status | ReturnCode | 說明 |
|---|---|---|
PENDING |
RefundPending |
受理中,需後續呼叫 RefundQuery |
500 |
RefundFailed |
系統錯誤,含 errorInformation |
DECLINED |
RefundFailed |
發卡行拒絕,含 errorInformation |
| 其他未知值 | throw NotImplementedException |
Cybersource 若新增狀態需修改程式碼才能支援 |
RefundQuery(查詢既有退款)
1 | GET tss/v2/transactions/{RefundRequestTransactionId} |
⚠ 這裡 URL 帶入的是**退款動作**回傳的 transaction ID,不是原始付款的 transaction ID,兩者不要搞混。
Status/ReasonCode → ReturnCode 對照:
applicationInformation.status |
reasonCode | ReturnCode | 說明 |
|---|---|---|---|
PENDING |
任意 | RefundPending |
Cybersource 僅受理,尚未真正送出 |
TRANSMITTED |
任意 | Success |
已傳送至發卡行,視為退款完成 |
| 其他 | 100 |
RefundPending |
Fallback:reasonCode 成功但狀態非預期,仍視為處理中 |
| 其他 | 非 100 |
RefundRejected |
退款最終被拒絕 |
✅ 為什麼
TRANSMITTED 才算成功,而不是 PENDING?Cybersource 的退款生命週期是:PENDING(受理)→ TRANSMITTED(送出至發卡行)→ 最終到帳。PENDING 只代表系統收到請求,還沒有真正動作,所以不能當作退款已完成的依據。
狀態機的節點彼此會互相繞回去(例如查詢中又退回處理中),用流程圖畫反而滿版都是交叉線、很難跟著看。這裡改用「狀態卡片」由上往下呈現,每張卡片只講兩件事:現在是什麼狀態、接下來依什麼條件會走到哪裡。
① RefundRequestProcessing — 退款單的預設狀態
退款單建立時的起始狀態,同時也是任何「結果還沒確定」時會被打回來的狀態。SQL 排程每小時 :02 分會鎖定這批退款單,依
TransactionId 是否已存在,分別導向下面 ② 或 ③ 其中一條路徑。② 呼叫 Refund API(TransactionId 為空,第一次退款)
回應
回應
PENDING → 寫入 TransactionId,狀態退回「① RefundRequestProcessing」,下次改走③查詢路線。回應
500 或 DECLINED → 直接進入「④ RefundRequestFail」。③ 呼叫 RefundQuery API(TransactionId 已存在,查詢中)
PENDING,或 reasonCode 為 100(fallback)→ 退回「① RefundRequestProcessing」,下次繼續查詢。TRANSMITTED → 進入「⑤ Finish」結案。其他狀態且 reasonCode 非
100 → 進入「④ RefundRequestFail」。④ RefundRequestFail — 失敗狀態
符合重試條件(3 天內 + 可重試錯誤碼)→ 改回「① RefundRequestProcessing」重新嘗試。
逾 3 天或不可重試錯誤碼 → 流程結束,需要人工介入。
逾 3 天或不可重試錯誤碼 → 流程結束,需要人工介入。
⑤ Finish — 結案
IsClosed=true,寫入 ConfirmDate,並呼叫 UpdateOrderSlaveFlow 更新訂單大表。
⚠ Cybersource 目前
GetRedoRefundRequestList() 固定回傳 null(TODO 待補),代表 Cybersource 沒有自動化的失敗重試機制——這和 Razer、KPay 等有 PR020/PR015/29523 可重試錯誤碼的付款方式不同,一旦進入 RefundRequestFail,就需要人工介入處理。
sequenceDiagram
participant SQL as SQL Agent 排程
participant NMQDB as NMQV2DB.Task
participant Grouping as GroupingProcess
(PaymentMiddleWareRefundRequestGroupingProcess)
participant Service as PaymentMiddleWareRefundRequestService
participant PayChSvc as CybersourcePayChannelService
participant Finish as FinishJob
(DoRefundRequestFinish)
participant PMW as PaymentMiddleware
participant CS as Cybersource API
SQL->>NMQDB: INSERT Task(Ready, JobId=PaymentMiddleWareRefundRequestGrouping)
NMQDB-->>Grouping: NMQ Worker 撈取 Ready Task
Grouping->>Service: CreateRefundRequestFinish()
Service->>Service: GetRefundRequestData()(含 Cybersource)
Service->>PayChSvc: GetRedoRefundRequestList("CreditCardOnce_Cybersource")
PayChSvc-->>Service: null(無 redo 機制)
Service->>Service: GroupBy(TradesOrderGroupId)
Service->>Service: UpdateRefundRequestStatus → RefundRequestGrouping
Service->>NMQDB: 建立 Task(CreditCardOnceCybersourceRefundRequestFinish)
NMQDB-->>Finish: Task 到點觸發
Finish->>PayChSvc: CanGroupingRefund()
PayChSvc-->>Finish: false → 走 RefundByRequestId
Finish->>PayChSvc: IsQueryRefund(refundRequest)
alt TransactionId 為空(首次退款)
PayChSvc-->>Finish: false
Finish->>PMW: POST /Refund/Cybersource(TradesOrderGroupCode, Amount, Currency)
PMW->>CS: POST pts/v2/payments/{transactionId}/refunds/
CS-->>PMW: status = PENDING / DECLINED / 500
PMW-->>Finish: ReturnCode = RefundPending / RefundFailed
else TransactionId 已存在(查詢中)
PayChSvc-->>Finish: true
Finish->>PMW: GET /RefundQuery/Cybersource(RefundRequestTransactionId)
PMW->>CS: GET tss/v2/transactions/{RefundRequestTransactionId}
CS-->>PMW: applicationInformation.status / reasonCode
PMW-->>Finish: ReturnCode = RefundPending / Success / RefundRejected
end
Finish->>Finish: ChangeRefundRequestStatus + RefundDelay()(空實作)
Finish->>Service: UpdateRefundRequest + UpdateOrderSlaveFlow
Note over Service: Success → Finish結案(IsClosed=true)
Pending → 維持Processing,等下次:02分再次撈取
Rejected/Failed → RefundRequestFail以下是這條退款路徑目前已知需要留意的地方,依嚴重程度排序:
1
未知 Status 會拋例外
風險:高
Refund 回應若出現非
PENDING/500/DECLINED 的新狀態,程式會直接拋出 NotImplementedException 中斷處理,需要持續留意 Cybersource 是否新增了狀態值。
3
RefundQuery 長時間停留在 PENDING
風險:中
Cybersource 查詢回應可能長達數小時停留在
PENDING。如果系統邏輯有 timeout 中斷,最後一次查詢結果可能被誤判為 RefundPending(對應碼 4003),需要靠下一次排程再次 Redo,才能拿到最終的 TRANSMITTED。
4
運費退款不會更新大表
設計備註
當
SourceDef == SalesOrderFee 時,Finish 階段不會呼叫 UpdateOrderSlaveFlow,只更新退款單本身——這是刻意的設計,不算異常。
5
多付款方式退款(IsMultiPayment)
設計備註
Cybersource 若涉及多付款方式退款,Finish 完成後不會直接更新大表,而是改建立
SyncRefundRequestFinishJob,等所有退款單都完成後再統一更新。
All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.


