Refund
💳 Stripe 退款機制完整分析
架構總覽
各層職責
| 層級 | 檔案 | 職責 |
|---|---|---|
| 退款單產生 | ThirdPartyRefundRequestService / RefundRequestService | 依取消單/退貨單計算退款金額,建立 RefundRequest |
| 退款派送骨架 | AbstractPayChannelService | 定義各金流共通的 Header 組裝、ExtendInfo 抽象方法 |
| Stripe 專屬邏輯 | StripePayChannelService | 組裝 application_fee_amount、payment_flow、stripe_account、Stripe API Key Header |
| 中介呼叫 | PaymentMiddleWareRefundRequestService | 呼叫 PaymentMiddleware、回寫退款單狀態、觸發後續任務 |
| 費用轉費用單 | PaymentProcessingFeeExpenseOrderSlaveCreateEntityFactory | 手續費轉為 ExpenseOrderSlave |
| 對帳報表 | StripeExpenseOrderReportExportService 等 | 呈現退款/手續費彙總、下載 Stripe Payout 對帳報告 |
| 通知 | RefundRequestFinishV2MailService | 退款完成寄信通知 |
接下來會依序展開三種退款情境(部分退款、補收單、Application Fee),再談運費規則、狀態機、對帳報表與 Email 通知這幾塊周邊機制。
情境 A 部分退款(Partial Refund)
最基本的退款情境:消費者取消或退貨其中一部分商品,系統要算出「這一部分」該退多少錢。觸發來源分兩種——取消子單(CancelOrderSlave)與退貨子單(ReturnGoodsOrderSlave),分別對應 RefundRequestService.InsertRefundRequestByCancelOrderSlave 與 ThirdPartyRefundRequestService。
退款金額怎麼算
單一取消子單(無多付款方式攤提)
canceledQty = isCancelAllQty ? SalesOrderSlave_Qty : CancelOrderSlave_Qty amount = (SalesOrderSlave_Price × canceledQty) + totalDiscount
「單一取消子單」的計算單位是什麼? 對應 RefundRequestService.InsertRefundRequestByCancelOrderSlave(tradesOrderSlaveId, ...),這個方法每次只處理一個 OrderSlave(銷售子單,即訂單中的一個商品項目行),而不是以數量或整張訂單為單位。
var isCancelAllQty = cancelOrderSlave.CancelOrderSlave_Qty >= salesOrderSlave.SalesOrderSlave_Qty; var canceledQty = isCancelAllQty ? salesOrderSlave.SalesOrderSlave_Qty : cancelOrderSlave.CancelOrderSlave_Qty; var totalDiscount = isCancelAllQty ? salesOrderSlave.SalesOrderSlave_TotalDiscount : cancelOrderSlave.CancelOrderSlave_TotalDiscount;
isCancelAllQty = true:該 slave 購買數量全部取消(含取消數 > 購買數的異常保護),折扣採SalesOrderSlave_TotalDiscount(整單攤提折扣)。isCancelAllQty = false:僅取消該 slave 部分數量,折扣採CancelOrderSlave_TotalDiscount(已依取消比例攤提的折扣)。
程式碼位置:ERP\Backend\BLV2\RefundRequests\RefundRequestService.cs(InsertRefundRequestByCancelOrderSlave,約行 630–797)。此分支僅處理單一付款方式、無補收單攤提的情境;若涉及退貨補收單或多付款方式攤提,則走下方另兩個公式。
isCancelAllQty 是怎麼算出來的? 它不是儲存在 DB 的欄位,而是每次計算退款金額當下,即時比對兩筆已存在資料現算出來的:
var salesOrderSlave = this._salesOrderRepository.GetSlave(tradesOrderSlaveId); // 原始銷售子單(購買數量,不會變動) var cancelOrderSlave = this._cancelOrderSlaveRepository.GetSlaveByTsId(tradesOrderSlaveId); // 取消子單(本次申請取消的數量)
salesOrderSlave.SalesOrderSlave_Qty:該商品行的原始購買數量,來源是銷售子單,建立後不會再變。cancelOrderSlave.CancelOrderSlave_Qty:這次取消申請要取消的數量。這筆CancelOrderSlave記錄是在更早的取消單建立流程中就已寫入 ERPDB,RefundRequestService只是依tradesOrderSlaveId讀出來用,並不負責產生它。
兩者比大小 cancelOrderSlave.CancelOrderSlave_Qty >= salesOrderSlave.SalesOrderSlave_Qty:取消數量涵蓋(或超過)原購買數量就判定為全部取消;否則就是部分取消。用 >= 而非 ==,同時也對「取消數異常大於購買數」的資料做了防呆。
程式碼位置:ERP\Backend\BLV2\RefundRequests\RefundRequestService.cs(同一方法內,先取得 salesOrderSlave/cancelOrderSlave 再算 isCancelAllQty,約行 636–647、725)。
含補收單金額之取消(退貨情境)
amount = (CancelOrderSlave_Price × CancelOrderSlave_Qty)
+ CancelOrderSlave_Discount
+ CancelOrderSlave_RechargeReceiptAmount
CancelOrderSlave_RechargeReceiptAmount 是什麼、為什麼是「加」不是「減」?這個欄位串接的補收單(RechargeReceipt)與其正負值規則、觸發來源,詳見下方「情境 B:補收單(多退少補)機制」章節。
程式碼位置:ERP\Backend\BLV2\RefundRequests\ThirdPartyRefundRequestService.cs(CreateRefundRequestByCancelFlow,約行 376);欄位定義與註解見 DB\databases\ERPDB\Tables\CancelOrderSlave.sql(CancelOrderSlave_RechargeReceiptAmount = 補收單金額,CancelOrderSlave_RechargeReceiptId = 補收單序號)。
多付款方式攤提:先為其他付款方式建立各自的退款單並扣除攤提金額,剩餘才歸屬 Stripe:
amount -= Σ(otherPayTypeAmount.Amount)
涉及運費(SalesOrderFee)時會獨立建立一張運費退款單,同樣依付款方式攤提。
0 元退款特殊處理 若計算後 amount == 0,退款單直接標記為 Finish 且 IsClosed = true,不會實際呼叫 Stripe API(常見於多付款情境下該付款方式攤提為 0)。
端到端流程
sequenceDiagram
participant ERP as ERP後台/取消流程
participant RRS as RefundRequestService
participant PM as PaymentMiddleWareRefundRequestService
participant SPC as StripePayChannelService
participant MW as PaymentMiddleware
participant STRIPE as Stripe API
participant OSF as OrderSlaveFlow
ERP->>RRS: 取消/退貨子單建立
RRS->>RRS: 計算退款金額(攤提/運費/補收)
RRS->>RRS: 建立 RefundRequest(RefundRequestProcessing)
Note over RRS: amount=0 直接 Finish,不呼叫 API
PM->>PM: CreateRefundRequestFinish() 撈待處理退款單
PM->>SPC: GetRefundExtendInfo(...)
SPC-->>PM: application_fee_amount / is_refund_application_fee
PM->>MW: POST /Refund/Stripe/TgCode
MW->>STRIPE: Refund + Application Fee Refund
STRIPE-->>MW: 退款結果
MW-->>PM: ReturnCode / TransactionId
PM->>PM: UpdateRefundRequest 狀態轉換
PM->>OSF: RefundFinish / RefundFail
PM->>ERP: 寄送退款完成通知信
運費規則 運費(SalesOrderFee)退款規則
重要區分:「取消訂單」與「退貨」是兩套完全獨立的運費退款機制,一個是系統自動判斷,一個必須人工手動觸發,不可混為一談。
機制 A:取消訂單(CancelOrderSlave)流程 —— 系統自動判斷 邏輯位於 ERP\Backend\BLV2\RefundRequests\ThirdPartyRefundRequestService.cs 的 CreateRefundRequestByCancelFlow(line 263-298),只被各付款方式的 *CancelOrderService.cs(如 AtmCancelOrderService、LinePayCancelOrderService 等)呼叫,是「取消訂單」流程專屬邏輯,跟「退貨」完全無關。
第一道關卡:必須整張母單(SalesOrder)所有子單都取消
if (isAllCanceled == false) { // 直接不處理運費退款 return Tuple.Create(0, salesOrderFee); }
訂單裡只要還有其他商品沒取消(部分取消),運費完全不退——因為運費是綁在整張母單上,只要還有東西要出貨,這筆運費就算「有在用」。
第二道關卡:只有以下兩種情況,才會真的建立一張獨立的運費 RefundRequest(RefundRequest_SourceDef = SalesOrderFee)
- 運費單完全沒被使用(
SalesOrderFee_TradesOrderSlaveId == null):消費者根本還沒出貨就把整張訂單取消了,運費本來就沒發生物流成本,全額退還。 - 出貨後「超商驗收失敗」(
VerifyFailAbnormalPackage/VerifyFailErrorCode/VerifyFailInvalidCode/VerifyFailLost/VerifyFailRenovation共 5 種狀態):貨物寄到超商但沒有真正送達消費者手上(超商拒收、遺失、包裝異常等),等同出貨沒有成功完成,同樣退運費。
以上兩道關卡皆通過,系統才會在取消訂單處理當下自動建立這筆運費退款單,商家不需要另外操作。
機制 B:退貨(ReturnGoodsOrderSlave)流程 —— 必須人工手動觸發,系統不會自動退運費 追蹤 SCMAPIV2\BusinessLogic\Services\ReturnGoodsOrders\ReturnGoodsOrderFinishService.cs(退貨結案邏輯)後確認:整段退貨結案流程完全沒有任何自動建立 SalesOrderFee 退款的程式碼。運費退款要靠獨立的 API:
// RefundRequestController.cs line 37-42 POST /RefundRequest/ReturnSalesOrderFee // → RefundRequestService.ReturnSalesOrderFee(entity) line 178,用 this._userService.GetUserName() 記錄操作人
這代表商家/客服必須在 SCM 賣家後台手動點擊「退運費」按鈕才會呼叫這支 API 建立運費退款單,退貨單結案本身不會自動連動觸發,兩者是脫鉤的。若商家忘記手動點退運費,消費者就不會收到運費退款。
| 情境 | 觸發流程 | 運費是否退還 | 是否需人工操作 |
|---|---|---|---|
| 消費者下單後隔天反悔,商品尚未出貨就整單取消 | 取消訂單(機制 A) | ✅ 全額退還 | 否,系統自動判斷並建立 |
| 商品已出貨到超商,但超商驗收時發現包裝異常拒收,貨退回倉庫,消費者順勢取消訂單 | 取消訂單(機制 A) | ✅ 退還(等同貨未送達) | 否,系統自動判斷並建立 |
| 消費者已收到貨,使用一週後申請退貨(正常完成配送) | 退貨(機制 B) | 視商家是否手動處理 | 是,須客服/商家在 SCM 後台手動點「退運費」 |
結論:「取消訂單」的運費退款是系統自動判斷(依出貨/驗收狀態);「退貨」的運費退款則必須商家手動操作,系統本身不會因為退貨結案就自動退運費——若商家未手動處理,消費者已收到貨後申請退貨時,運費預設不會退還,等於商家吸收出貨運費成本,除非客服主動決定手動退運費補償消費者。至於「消費者把商品寄回商家」的退貨逆物流運費,並不在這套 RefundRequest 機制內處理——它是物流/客服另外決定是否提供免運寄件標籤的營運成本,不會反映在 Stripe 退款金額或 RefundRequest 資料表裡。
情境 B 補收單(多退少補)機制
「多退少補」發生在換貨/退貨導致商品差額時:若換貨後應付金額大於原付款金額,系統會開立一張補收單(RechargeReceipt)向消費者收取差額;退款金額若涉及此差額,Stripe 手續費計算不能單純以退款金額為基礎,須排除已透過補收單收回的手續費成本。
取消單金額為何要加上補收單金額
回到「情境 A」的公式:amount = (CancelOrderSlave_Price × CancelOrderSlave_Qty) + CancelOrderSlave_Discount + CancelOrderSlave_RechargeReceiptAmount。這裡的 CancelOrderSlave_RechargeReceiptAmount/CancelOrderSlave_RechargeReceiptId 就是把本節補收單的金額與序號複製/連結到取消子單上,讓取消單知道「這筆交易還牽涉一張補收單」。
為什麼是「加」不是「減」? 補收單代表已經跟消費者收取的差額款項,不是要從退款中扣掉的金額。情境是退貨換貨:消費者退回原商品、換成較貴的商品,系統開立補收單向消費者收取差額。若這筆交易後續整筆被取消,代表消費者已付的補收款也要一併退還,所以退款金額 = 原商品退款 + 補收款,是「加總後一起退」,而不是拿補收款去抵原本要退的錢。若改成減法,反而會少退消費者已經多收的那筆補收款。
程式碼位置:ERP\Backend\BLV2\RefundRequests\ThirdPartyRefundRequestService.cs(CreateRefundRequestByCancelFlow,約行 376);欄位定義與註解見 DB\databases\ERPDB\Tables\CancelOrderSlave.sql(CancelOrderSlave_RechargeReceiptAmount = 補收單金額,CancelOrderSlave_RechargeReceiptId = 補收單序號)。
這個補收金額不是 mweb 前台觸發的 查過 nineyi.webstore.mobilewebmall(mweb 前台,訂單列表/訂單明細頁面觸發取消、退貨的地方)的 CancelRequestService、CancelRequestManager、ReturnGoodsRequestService 全文都沒有出現 RechargeReceipt 相關程式碼。也就是說,消費者在 mweb 自助按「取消」或「退貨」,走的是純取消/純退貨退款流程,建立出來的 CancelOrderSlave 之 CancelOrderSlave_RechargeReceiptAmount 只會是預設值 0,不會跟補收單有任何關聯。
RechargeReceipt(補收單)另外還有 RechargeReceipt_PickupContactor/RechargeReceipt_PickupPhone(取件人/電話)等欄位,屬於門市退貨換貨、消費者到店取件時當面收差額的情境,是 ERP 後台由客服/門市人員操作的退貨換貨補收流程,與 mweb 前台的取消/退貨屬於兩條完全不同的路徑。因此「含補收單金額之取消(退貨情境)」公式,只會在 ERP 後台退貨換貨補收流程中被觸發,不會出現在一般消費者自助取消/退貨的案例中。
補收單五種類型與情境範例
補收單(RechargeReceipt)是一個通用機制,涵蓋「退貨/換貨過程中需要向消費者額外收費」或「出貨前客服手動退費」兩大用途,由 RechargeTypeDefEnum 分成 5 種(SCMAPIV2\...\RechargeReceipts\Enums\RechargeTypeDefEnum.cs):
| 類型 | 建立時機 | 金額方向 | 情境範例 |
|---|---|---|---|
ActionOrDiscountSpreads活動/折扣差價 | 退貨進行中 | 正值(向消費者追收) | 消費者買 3 件湊到「滿 3000 折 300」,退貨 1 件後剩下商品只值 2000 元、不再滿足門檻,需追回當初多折的 300 元優惠。 |
PackageOrRenewFee包裝/整新費 | 退貨進行中 | 正值 | 消費者退回的商品已拆封使用,需要清潔/整新才能重新上架,收取 200 元整新處理費。 |
SaleProductGift贈品 | 退貨進行中 | 正值 | 訂單買一送一,消費者只退回主商品、贈品未退回(弄丟/已使用),以贈品市價折抵扣款。 |
SalesOrderFee運費 | 退貨進行中 | 正值 | 原訂單滿 1000 免運,退貨後剩餘商品金額掉到 800、不再達免運門檻,向消費者收取運費差額。 |
OrderSpreads(負向) 訂單差價 | 出貨前,訂單不取消由客服手動退費(RechargeReceiptService.CreatePartialAmount) | 負值(代表已退給消費者) | 商品有小瑕疵,客服決定退 200 元給消費者但商品照樣出貨(不取消訂單),系統存 RechargeReceipt_RechargeAmount = -200。 |
為什麼 OrderSpreads 一定要存負值? 因為這筆錢是「已經退給消費者、但訂單還沒被取消」的紀錄。若日後這筆訂單子單又整張被取消,退款金額公式 amount = Price×Qty + Discount + RechargeReceiptAmount 必須把先前已退的 200 元扣掉(1000 + 0 + (-200) = 800),否則消費者會拿到 1000 + 200 = 1200,造成重複退款。前 4 種類型則相反:一律為正值,且必須綁在「進行中的退貨子單(ReturnGoodsOrderSlave)」上才能建立(RechargeReceiptService.Create() 會檢查 GetProcessingReturnGoodsInfoForRechargeReceipt),用途是退貨當下就先向消費者扣抵這些額外費用,避免退款金額退多了。
相關資料結構
| Entity / Enum | 說明 |
|---|---|
RechargeReceiptStatusEnum | Created(已建立)/ Finished(已結案)/ Void(已作廢) |
RechargeReceipt_RechargeAmount | 補收單整筆金額(單一付款方式訂單使用) |
RechargeReceiptPayProfileType(分攤表) | 多付款方式訂單時,記錄每個付款方式各自分攤的補收金額 |
GetRechargePayTypeAmountByReceiptIdAndPayProfileType | 依補收單 Id + 付款方式查詢分攤金額,查無則 fallback 用整筆金額 |
計算邏輯
僅當 RefundRequest_SourceDef == ReturnGoodsOrderSlave 且補收單存在、狀態非 Void 時才進入此分支:
// 1. 取得此付款方式的補收分攤金額(無分攤紀錄則用整筆金額) rechargeAmount = GetRechargePayTypeAmountByReceiptIdAndPayProfileType(receiptId, payProfileTypeDef) ?? RechargeReceipt_RechargeAmount; // 2. 一致性檢查:退款金額 + 補收金額 必須等於「該付款方式的分攤支付總額」 totalPayment = refundAmount + rechargeAmount; expectedTotalPayment = GetOtherPayTypeAmount(tsCode, payProfileTypeDef)?.Amount ?? SalesOrderSlave_TotalPayment; if (expectedTotalPayment != totalPayment) throw new ApplicationException(...); // 3. 手續費計算 rechargeAmountFee = CalculateFee(rechargeAmount, feeRate); // 補收單本身應收的手續費 totalPaymentFee = CalculateFee(refundAmount + rechargeAmount, feeRate); // 原始交易總額對應手續費 // 4. 最終應退還手續費 = 原交易手續費 - 補收單手續費 refundApplicationFee = totalPaymentFee - rechargeAmountFee;
CalculateFee 保底規則 Math.Round(amount × rate, 2, AwayFromZero);若結果 ≤ 0 則保底回傳 0.01,避免傳 0 或負值手續費給 Stripe API。
範例試算:訂單 100 元、費率 3%、退貨 70 元、補收單 30 元
| 步驟 | 公式 | 結果 |
|---|---|---|
| ① 一致性檢查 | 70 + 30 = 100(等於原總支付金額) | ✅ 通過 |
| ② 原交易手續費 | CalculateFee(70+30, 3%) = 100 × 3% | 3.00 |
| ③ 補收單手續費 | CalculateFee(30, 3%) = 30 × 3% | 0.90 |
| ④ 應退還手續費 | ② 原交易手續費 − ③ 補收單手續費 = 3.00 − 0.90 | 2.10 |
意義:訂單原本收了 100 元的 3%=3 元手續費;換貨後透過補收單重新收了 30 元、對應手續費 0.9 元也已由補收單流程收取,因此僅需退還「差額部分」的手續費 2.10 元,避免手續費被重複退還或漏退。
多付款方式下的分攤
若訂單同時使用信用卡(Stripe)與購物金付款,系統會優先以 RechargeReceiptPayProfileType(分攤表)查出 Stripe 這筆付款方式對應的補收分攤金額,而非直接使用補收單全額,避免將購物金部分的補收金額誤算入 Stripe 手續費退還計算。
情境 C Application Fee(平台手續費)退還
Application Fee 機制專屬於 DestinationCharge(目的地收費)流程 + Custom 帳戶類型:平台以 Custom Connect 帳戶代收款項後,透過 application_fee_amount 從賣家所得中抽取平台服務費,退款時同步涉及是否/如何退還這筆已扣的服務費。
| 概念 | 資金流向 | 對應本專案欄位 |
|---|---|---|
| Refund(退款) | 平台 → 顧客 | RefundRequest_Amount |
| Application Fee Refund(手續費退還) | 平台 → 賣家(子帳號) | application_fee_amount |
是否退還手續費的判斷
bool IsRefundApplicationFee(refundRequest, salesOrderSlaveDateTime, shopId) { if (refundRequest.SourceDef != SalesOrderFee) // 非運費退款 return true; // → 一律退還手續費 // 運費退款:需符合日期門檻或商店白名單設定才退手續費 return salesOrderSlaveDateTime >= 2020-07-01 || shopArray(config: "Charge.SalesOrderFee.Shop").Contains(shopId); }
- 非運費類退款(商品退貨、取消單):永遠退還 Application Fee。
- 運費類退款:因 2020/07/01 前的歷史政策運費不退手續費,故加上日期與商店白名單雙重判斷。
手續費金額計算
// 一般情境(無補收單) refundApplicationFee = CalculateFee(refundAmount, feeRate) = Math.Round(refundAmount × feeRate, 2, AwayFromZero) = 至少 0.01(保底值) // 補收單情境:見「情境 B」的計算邏輯(原交易手續費 − 補收單手續費)
傳遞給 PaymentMiddleware / Stripe 的資訊
{
"payment_flow": "DirectCharge | DestinationCharge",
"stripe_account": "子帳號 ID(Custom 帳戶時)",
"application_fee_amount": 2.10,
"is_refund_application_fee": true
}
由 PaymentMiddleware 接手後才實際呼叫 Stripe:
POST /v1/refunds(退款本體)POST /v1/application_fees/{applicationFeeId}/refunds(若is_refund_application_fee = true)
注意 ERP 端不直接持有 applicationFeeId;payment_flow + stripe_account + TransactionId 由 PaymentMiddleware 用以反查對應的 Application Fee 紀錄。
payment_flow 與 stripe_account 的取得邏輯
GetRefundFlowAndSubAccount(shopId, thirdPartyPaymentInfo)
{
if (thirdPartyPaymentInfo 為空)
return ("DirectCharge", ShopDefault.StripeSubAccount);
info = Deserialize(thirdPartyPaymentInfo);
paymentFlow = info.PaymentFlow ?? "DirectCharge";
return (paymentFlow, info.SubAccount);
}
手續費費用單的帳戶類型限制
金流手續費要轉為費用單(ExpenseOrderSlave)需同時滿足以下 4 個條件:
- AppSetting:
ExpenseOrder.PaymentProcFee.{PayProfileType}.IsEnableCreateExpenseOrder = true - 訂單付款狀態為
Success SalesOrderThirdPartyPayment_Info非空- AccountType 必須以
Custom開頭(Standard帳戶不會產生手續費費用單,因其手續費由 Stripe 直接向平台收取)
對帳報表與 ExpenseOrder
手續費 → 費用單
PaymentProcessingFeeExpenseOrderSlaveCreateEntityFactory:ItemTypeDef = PaymentProcFee、ChangeAmountType = Negative、ChangeAmount = TotalPayment,並帶入 FixedFee / FeeRate;帳期依日期切為每月 1 日 / 16 日兩期。
FixedFee / FeeRate 來源 來自 SalesOrderGroupRepository.GetSalesOrderGroupPaymentProcessingFeeInfo():
FixedFee = salesOrderGroup.SalesOrderGroup_PaymentFixedFee,
FeeRate = salesOrderSlave.SalesOrderSlave_SCMCreditCardFeeRate ?? 0.00m
FixedFee來自訂單主檔欄位SalesOrderGroup_PaymentFixedFee。FeeRate來自銷售子單欄位SalesOrderSlave_SCMCreditCardFeeRate。
兩者都是訂單成立當下寫入的快照值(代表當時商店與金流商議定的手續費率/固定手續費),退款/對帳時直接讀這筆歷史快照,不會在退款當下重新查詢最新費率設定,確保費率不會因為日後費率調整而追溯影響已完成訂單的計算。
Stripe 帳務對帳報表
StripeExpenseOrderReportExportService 彙總退款來源(ReturnOrderRefund / CancelOrderRefund / AbnormalOrderRefund / OrderFeeRefund)與手續費來源(CreditCardOnce_Stripe / ApplePay / GooglePay):
| 欄位 | 意義 |
|---|---|
| TotalStripeFee 等 | 各支付方式系統手續費彙總 |
| TotalStripePaymentProcessingFee 等 | 各支付方式的金流處理手續費彙總 |
| FirstSystemFee | 正流程為負、逆流程為正,依 SourceDef 分正逆流程計算手續費符號 |
| totalExclScFee | 排除購物金退款後的手續費總額(購物金退款 FirstSystemFee 固定為 0) |
CalculateFee(sourceDef, amount, feeRateNote) 對 Stripe 類來源使用 StripeFeeEntity(FeeRate + FixedFee)計算:Math.Round(amount × FeeRate + FixedFee, 小數位, AwayFromZero)。
是否為獨立報表? 是兩個獨立的報表類型,但共用同一套 ERP 匯出框架:
// Stripe 入帳明細匯出 .RegisterBatchUploadTaskService<StripePayoutReconciliationReportExportService>(builder, BatchUploadTypeEnum.ExportStripePayoutReport) // Stripe 對帳明細匯出 .RegisterBatchUploadTaskService<StripeExpenseOrderReportExportService>(builder, BatchUploadTypeEnum.ExportStripeExpenseOrderReport)
兩者都繼承 BatchUploadBaseService<T>,跟 ERP 後台其他「批次匯出」報表(商品匯出、訂單匯出等)走同一套非同步匯出機制(建立 BatchUpload 任務 → NMQ 背景處理 → 產檔供下載),但資料來源與計算邏輯完全獨立:本節報表讀 ERP 內部 ExpenseOrderSlave 退款/手續費資料;下方 Payout 對帳報表則讀 Stripe Reporting API 的資料。
Payout 對帳下載
StripePayoutReconciliationReportExportService 呼叫 Stripe Reporting API 產生/下載 Payout 對帳報告,用於核對 Stripe 實際撥款金額與系統紀錄是否一致,是退款/手續費計算正確性的最終防線。
什麼時候會執行? 是使用者在 ERP 後台手動觸發的匯出任務,不是排程 Cron Job。
在 ERP 後台選店家、指定 StripeReportRunId(已在 Stripe 建立的 Report Run)並送出匯出請求。
若狀態為 pending,系統 Thread.Sleep(TimeSpan.FromMinutes(5)) 後由 NMQ 排程重新呼叫檢查,尚未完成就先不處理。
Report Run 完成後才下載檔案、壓縮加密,供使用者下載。
也就是「使用者按下匯出 → 系統輪詢等 Stripe 那邊報表跑完 → 才真正下載」,執行時機取決於使用者操作 + Stripe Reporting API 產生報表的速度(非固定排程)。
Email 通知機制
RefundRequestFinishV2MailService:依 TradesOrderGroupCode 撈出已完成退款單,退款總額為 0 時不寄信;信件包含退款方式、商品明細、退款金額、訂單資訊,運費退款會作為獨立項目列示。
寄信條件(需同時滿足 3 項)
IsSendMail()(line 431)檢查 ShopCustomDisableMailSettingEnum.DisableRefundFinish,商店可自訂關閉退款完成信:
if (salesOrderItem != null && base.IsShopNeedSendMail(salesOrderItem.SalesOrder_ShopId, ShopCustomDisableMailSettingEnum.DisableRefundFinish) == false) return false;
IVipMemberEmailNotificationService.IsSendMail(memberId, shopId, EmailNotificationEnum.TradesOrder)——會員個人的訂單通知偏好設定。
DoMailDataProcess(line 144)加總後若為 0 則不寄信:
var sum = refundRequestList.Sum(x => x.RefundRequest_Amount); if (sum == 0) { this.ProceedSendMail = false; // 0 元退款不寄信 return; }
三者缺一都不會寄出退款完成通知信。


