Payment · Stripe · 退款機制

💳 Stripe 退款機制完整分析

情境 A:部分退款 情境 B:補收單 情境 C:Application Fee
01

架構總覽

各層職責

層級檔案職責
退款單產生ThirdPartyRefundRequestService / RefundRequestService依取消單/退貨單計算退款金額,建立 RefundRequest
退款派送骨架AbstractPayChannelService定義各金流共通的 Header 組裝、ExtendInfo 抽象方法
Stripe 專屬邏輯StripePayChannelService組裝 application_fee_amountpayment_flowstripe_account、Stripe API Key Header
中介呼叫PaymentMiddleWareRefundRequestService呼叫 PaymentMiddleware、回寫退款單狀態、觸發後續任務
費用轉費用單PaymentProcessingFeeExpenseOrderSlaveCreateEntityFactory手續費轉為 ExpenseOrderSlave
對帳報表StripeExpenseOrderReportExportService 等呈現退款/手續費彙總、下載 Stripe Payout 對帳報告
通知RefundRequestFinishV2MailService退款完成寄信通知
💡

接下來會依序展開三種退款情境(部分退款、補收單、Application Fee),再談運費規則、狀態機、對帳報表與 Email 通知這幾塊周邊機制。

02

情境 A 部分退款(Partial Refund)

最基本的退款情境:消費者取消或退貨其中一部分商品,系統要算出「這一部分」該退多少錢。觸發來源分兩種——取消子單(CancelOrderSlave)退貨子單(ReturnGoodsOrderSlave),分別對應 RefundRequestService.InsertRefundRequestByCancelOrderSlaveThirdPartyRefundRequestService

退款金額怎麼算

單一取消子單(無多付款方式攤提)

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(同一方法內,先取得 salesOrderSlavecancelOrderSlave 再算 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,退款單直接標記為 FinishIsClosed = 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: 寄送退款完成通知信
02+

運費規則 運費(SalesOrderFee)退款規則

重要區分:「取消訂單」與「退貨」是兩套完全獨立的運費退款機制,一個是系統自動判斷,一個必須人工手動觸發,不可混為一談。

⚠️

機制 A:取消訂單(CancelOrderSlave)流程 —— 系統自動判斷 邏輯位於 ERP\Backend\BLV2\RefundRequests\ThirdPartyRefundRequestService.cs 的 CreateRefundRequestByCancelFlow(line 263-298),只被各付款方式的 *CancelOrderService.cs(如 AtmCancelOrderServiceLinePayCancelOrderService 等)呼叫,是「取消訂單」流程專屬邏輯,跟「退貨」完全無關。

💡

第一道關卡:必須整張母單(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 資料表裡。

03

情境 B 補收單(多退少補)機制

「多退少補」發生在換貨/退貨導致商品差額時:若換貨後應付金額大於原付款金額,系統會開立一張補收單(RechargeReceipt)向消費者收取差額;退款金額若涉及此差額,Stripe 手續費計算不能單純以退款金額為基礎,須排除已透過補收單收回的手續費成本。

取消單金額為何要加上補收單金額

回到「情境 A」的公式:amount = (CancelOrderSlave_Price × CancelOrderSlave_Qty) + CancelOrderSlave_Discount + CancelOrderSlave_RechargeReceiptAmount。這裡的 CancelOrderSlave_RechargeReceiptAmountCancelOrderSlave_RechargeReceiptId 就是把本節補收單的金額與序號複製/連結到取消子單上,讓取消單知道「這筆交易還牽涉一張補收單」。

💡

為什麼是「加」不是「減」? 補收單代表已經跟消費者收取的差額款項,不是要從退款中扣掉的金額。情境是退貨換貨:消費者退回原商品、換成較貴的商品,系統開立補收單向消費者收取差額。若這筆交易後續整筆被取消,代表消費者已付的補收款也要一併退還,所以退款金額 = 原商品退款 + 補收款,是「加總後一起退」,而不是拿補收款去抵原本要退的錢。若改成減法,反而會少退消費者已經多收的那筆補收款。

程式碼位置:ERP\Backend\BLV2\RefundRequests\ThirdPartyRefundRequestService.cs(CreateRefundRequestByCancelFlow,約行 376);欄位定義與註解見 DB\databases\ERPDB\Tables\CancelOrderSlave.sql(CancelOrderSlave_RechargeReceiptAmount = 補收單金額,CancelOrderSlave_RechargeReceiptId = 補收單序號)。

⚠️

這個補收金額不是 mweb 前台觸發的 查過 nineyi.webstore.mobilewebmall(mweb 前台,訂單列表/訂單明細頁面觸發取消、退貨的地方)的 CancelRequestServiceCancelRequestManagerReturnGoodsRequestService 全文都沒有出現 RechargeReceipt 相關程式碼。也就是說,消費者在 mweb 自助按「取消」或「退貨」,走的是純取消/純退貨退款流程,建立出來的 CancelOrderSlaveCancelOrderSlave_RechargeReceiptAmount 只會是預設值 0,不會跟補收單有任何關聯。

RechargeReceipt(補收單)另外還有 RechargeReceipt_PickupContactorRechargeReceipt_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說明
RechargeReceiptStatusEnumCreated(已建立)/ 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.902.10

意義:訂單原本收了 100 元的 3%=3 元手續費;換貨後透過補收單重新收了 30 元、對應手續費 0.9 元也已由補收單流程收取,因此僅需退還「差額部分」的手續費 2.10 元,避免手續費被重複退還或漏退。

多付款方式下的分攤

若訂單同時使用信用卡(Stripe)與購物金付款,系統會優先以 RechargeReceiptPayProfileType(分攤表)查出 Stripe 這筆付款方式對應的補收分攤金額,而非直接使用補收單全額,避免將購物金部分的補收金額誤算入 Stripe 手續費退還計算。

04

情境 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 端不直接持有 applicationFeeIdpayment_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);
}
05

手續費費用單的帳戶類型限制

金流手續費要轉為費用單(ExpenseOrderSlave)需同時滿足以下 4 個條件:

  • AppSetting:ExpenseOrder.PaymentProcFee.{PayProfileType}.IsEnableCreateExpenseOrder = true
  • 訂單付款狀態為 Success
  • SalesOrderThirdPartyPayment_Info 非空
  • AccountType 必須以 Custom 開頭(Standard 帳戶不會產生手續費費用單,因其手續費由 Stripe 直接向平台收取)
07

對帳報表與 ExpenseOrder

手續費 → 費用單

PaymentProcessingFeeExpenseOrderSlaveCreateEntityFactoryItemTypeDef = PaymentProcFeeChangeAmountType = NegativeChangeAmount = 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。

1
使用者手動送出匯出請求

在 ERP 後台選店家、指定 StripeReportRunId(已在 Stripe 建立的 Report Run)並送出匯出請求。

2
輪詢 Stripe Report Run 狀態 條件重試

若狀態為 pending,系統 Thread.Sleep(TimeSpan.FromMinutes(5)) 後由 NMQ 排程重新呼叫檢查,尚未完成就先不處理。

3
下載並提供檔案

Report Run 完成後才下載檔案、壓縮加密,供使用者下載。

也就是「使用者按下匯出 → 系統輪詢等 Stripe 那邊報表跑完 → 才真正下載」,執行時機取決於使用者操作 + Stripe Reporting API 產生報表的速度(非固定排程)。

08

Email 通知機制

RefundRequestFinishV2MailService:依 TradesOrderGroupCode 撈出已完成退款單,退款總額為 0 時不寄信;信件包含退款方式、商品明細、退款金額、訂單資訊,運費退款會作為獨立項目列示。

寄信條件(需同時滿足 3 項)

1
商店未關閉此類通知

IsSendMail()(line 431)檢查 ShopCustomDisableMailSettingEnum.DisableRefundFinish,商店可自訂關閉退款完成信:

if (salesOrderItem != null && base.IsShopNeedSendMail(salesOrderItem.SalesOrder_ShopId, ShopCustomDisableMailSettingEnum.DisableRefundFinish) == false)
    return false;
2
會員本身有訂閱該類通知

IVipMemberEmailNotificationService.IsSendMail(memberId, shopId, EmailNotificationEnum.TradesOrder)——會員個人的訂單通知偏好設定。

3
這批退款單加總金額不為 0

DoMailDataProcess(line 144)加總後若為 0 則不寄信:

var sum = refundRequestList.Sum(x => x.RefundRequest_Amount);
if (sum == 0)
{
    this.ProceedSendMail = false;   // 0 元退款不寄信
    return;
}
🚫

三者缺一都不會寄出退款完成通知信。