Stripe-Account
Secret Key 設定位置
MachineConfig/Frontend/AppSettings.QA300.config
檔案結構:
| 設定項目 | 說明 | 範例 |
|---|---|---|
CustomAcctLiveSecretKey | Custom 帳戶正式環境金鑰 | sk_live_... |
CustomAcctTestSecretKey | Custom 帳戶測試環境金鑰 | sk_test_... |
StandardAcctLiveSecretKey | Standard 帳戶正式環境金鑰 | sk_live_... |
根據帳戶類型取得 API 金鑰
1 | private string GetStripeApiKey(long shopId, string accountType) |
API 金鑰對應表
| 帳戶類型 | 對應 API 金鑰 |
|---|---|
| Custom | CustomAcctLiveSecretKey |
| CustomTest | CustomAcctTestSecretKey |
| CustomUAT | CustomUATAcctLiveSecretKey |
| CustomUATTest | CustomUATAcctTestSecretKey |
| Standard | StandardAcctLiveSecretKey |
| StandardUAT | StandardUATAcctLiveSecretKey |
付款時,91APP 用自己的 Platform Key + 商店的
acct_xxxxx,代替商店向 Stripe 發起交易。
帳號結構
flowchart TD
A["Stripe 世界"] --> B["91APP Platform Account(主帳號)"]
B --> C["持有 sk_live_xxxx(平台自己的 Secret Key)"]
C --> D["acct_AAA ← 商店 A 的子帳號(Standard)"]
C --> E["acct_BBB ← 商店 B 的子帳號(Standard)"]
C --> F["acct_CCC ← 商店 C 的子帳號(Custom)"]
C --> G["acct_DDD ← 商店 D 的子帳號(Custom)"]
每間 91APP 商店對應一個 Stripe 子帳號(acct_xxxxx),這個對應關係存在資料庫 ShopDefault 表中。
兩種帳號類型
🔵 Standard 帳號(大型商店,例如 SASA)
| 項目 | 說明 |
|---|---|
| 帳號所有人 | 商店自己申請、自己擁有 |
| 費率 | 商店自行與 Stripe 談判 |
| 付款流程 | DirectCharge — 錢直接進商店子帳號 |
| API Key | 91APP 平台的 StandardAcctLiveSecretKey(所有 Standard 共用) |
| 商店區分方式 | Stripe-Account: acct_xxx Header(每間不同) |
使用者付款
→ 91APP 平台 Key(Authorization)+ Stripe-Account: acct_商店 Header
→ 錢直接進 商店子帳號
→ 91APP 從中收 application_fee
🟠 Custom 帳號(小型商店,由 91APP 在 OSM 代開)
| 項目 | 說明 |
|---|---|
| 帳號所有人 | 91APP 代替商店建立 |
| 費率 | 由平台統一設定 |
| 付款流程 | DestinationCharge — 錢先進平台,再 Transfer 給商店 |
| API Key | 91APP 平台的 CustomAcctLiveSecretKey(所有 Custom 共用) |
| 商店區分方式 | Body 的 transfer_data[destination]: acct_xxx(每間不同) |
使用者付款
→ 91APP 平台 Key(Authorization)
→ 錢進 91APP 平台主帳號
→ Transfer → 商店子帳號(transfer_data[destination])
→ 91APP 從中收 application_fee
為什麼 Standard 商店(自己的帳號)也用 91APP 的 Key?
用商店自己的 access_token | 用平台 Key + Stripe-Account Header(現行做法) | |
|---|---|---|
| 需要存什麼 | 每間商店各存一把 sk_live_... | 只存 acct_xxxxx(簡單字串) |
| 管理複雜度 | 高(N 間商店 = N 把 Key,還需處理 refresh) | 低(一把 Key 管全部) |
| Key 洩漏風險 | 高(影響單一商店) | 相對集中管理 |
| 91APP 的選擇 | ❌ | ✅ |
OAuth 授權
步驟一 點擊固定連結(91APP 事先申請好的 Stripe Connect 應用程式)
https://connect.stripe.com/oauth/authorize?response_type=code
&client_id=ca_FATj1hhoWn5hUpNgj9zafWQG4NNWOmRK&scope=read_write
步驟二 填寫帳戶資訊(*尚未有 Stripe 帳號時才需要)
步驟三 選擇要綁定的帳戶,點擊「連結我的 Stripe 帳戶」
步驟四 頁面轉導並顯示綁定成功
步驟五 確認授權 91APP Limited
步驟六 複製 acct_ ID,貼到 OSM「功能設定 ≫ 第三方金物流資料設定」完成開通
1️⃣
client_id=ca_FATj1hhoWn5hUpNgj9zafWQG4NNWOmRK 是 91APP 在 Stripe 註冊的 Connect 應用程式識別碼,全平台共用同一組,並非依商店動態產生——這也解釋了為什麼程式碼裡找不到組網址的邏輯:因為它從來就不是程式碼組出來的,而是 PDF 裡直接寫死給商家貼上瀏覽器用的。2️⃣ 網址裡沒有帶
redirect_uri 參數——代表 Stripe 導回的目的地,是這個 Connect App 在 Stripe Dashboard 後台預先設定好的 Default OAuth redirect URI(對應 StripeOAuthController 所在的 mweb 前台網域),而不是 91APP 系統每次請求時動態指定的。
Stripe Connect 的 redirect_uri 必須是公開對外網域(依 Stripe 規範,OAuth 導回頁面必須可公開存取),OSM/SMS 是內部後台網域不符資格,所以只能設定成商店的前台網域 mweb 來接收。OSM(SMS) 後台自始至終沒有、也不需要任何「連接 Stripe」的按鈕或程式邏輯——它只提供步驟六要用的手動輸入欄位。
StripeOAuthController(91APP 程式碼首次介入,僅換 token 寫 Log)→ 商店或維運人員把 acct_xxxxx 抄到 OSM 後台欄位。整條 OAuth 授權路徑,91APP 自己的系統只負責「接收 callback」這一小段,「發起」完全是文件驅動的人工流程,不存在於任何一個 repo 的程式碼中。
付款時 Stripe 的驗證邏輯
91APP 打 API:
Authorization: ****** → 「我是 91APP 平台」
Stripe-Account: acct_商店xxxxx → 「我要代表這間商店操作」
↓
Stripe 驗證:
1. sk_live_平台Key 是否合法? → ✅
2. acct_商店xxxxx 是否已授權給這個平台? → ✅(OAuth 時已同意)
↓
放行,錢進 acct_商店xxxxx
策略模式(Strategy Pattern)
PaymentMiddleware 用 Strategy Pattern 把兩種收款方式封裝成獨立策略類別,執行期依 StripePaymentFlowEnum 動態選擇:
1 | public enum StripePaymentFlowEnum |
1 | private IStripePaymentFlowStrategy GetPaymentFlowStrategy(StripePaymentFlowEnum flow) => flow switch |
Pay 比對
兩者都會先呼叫 /v1/payment_methods 建立卡片,再呼叫 /v1/payment_intents 建立付款意圖,差異在 API 呼叫時帶的參數與 Header。
🔵 DirectChargePaymentFlowStrategy.Pay()
1 | PaymentMethodResponseEntity paymentMethod = |
🟠 DestinationChargePaymentFlowStrategy.Pay()
1 | PaymentMethodResponseEntity paymentMethod = |
AppendStatementDescriptorAsync() 會呼叫 GET /v1/accounts/{subAccount} 取得子帳號的 statement_descriptor,塞進 PaymentIntent,讓使用者信用卡帳單上顯示的是「商店名稱」而非「91APP」。因為 DestinationCharge 錢會先入平台帳號,若不特別設定,帳單摘要預設會顯示平台資訊。
DestinationCharge 退款時,因為錢已經 Transfer 給商店子帳號,平台必須額外執行 Transfer Reversal(撤銷轉帳),把錢從子帳號的餘額中收回:
1 | if (request.ExtendInfo.StripePaymentFlow == StripePaymentFlowEnum.DestinationCharge) |
flowchart TD
A["發起退款"] --> B{"付款流程是 DirectCharge 還是 DestinationCharge?"}
B -->|"DirectCharge / Standard"| C["直接 CreateRefundsAsync()
退款子帳號上的 charge"]
B -->|"DestinationCharge / Custom"| D["先 Refund Application Fee"]
D --> E["再 CreateTransferReversalsAsync()
把已轉給商店的錢收回"]
E --> F["最後 CreateRefundsAsync()
退款平台帳號上的 charge"]
關鍵差異總表
| 比較項目 | DirectCharge(Standard) | DestinationCharge(Custom) |
|---|---|---|
| 策略類別 | DirectChargePaymentFlowStrategy | DestinationChargePaymentFlowStrategy |
建立 payment_method | 帶 Stripe-Account: acct_xxx | 不帶 Header,建立在平台帳號 |
| PaymentIntent 參數 | 無 transfer_data / on_behalf_of | 需帶 transfer_data[destination]、on_behalf_of |
Stripe-Account Header | 建立 / 查詢 / 取消都需帶子帳號 | 全程不帶,皆用平台身分操作 |
statement_descriptor | 不需額外處理(本就在子帳號下) | 需另呼叫 GetAccountAsync 補上,帳單才會顯示商店名稱 |
| 退款流程 | 單純 CreateRefundsAsync | 多一步 CreateTransferReversalsAsync 收回轉帳 |
| 資金流向 | 使用者 → 商店子帳號(直接扣 application_fee) | 使用者 → 平台帳號 → Transfer → 商店子帳號 |
| 錯誤 / 例外處理 | 共用同一套 try/catch ApiException 邏輯 | 另加 GetAccountAsync 失敗時記 Warning 並回傳 null,不中斷付款 |
完整程式碼呼叫鏈
flowchart TD
A["StripePlugin.Pay(request)"] --> B["GetPaymentFlowStrategy(request.ExtendInfo.StripePaymentFlow)"]
B -->|"DirectCharge"| C["DirectChargePaymentFlowStrategy"]
B -->|"DestinationCharge"| D["DestinationChargePaymentFlowStrategy"]
C --> C1["CreatePaymentMethodAsync(body, subAccount)
Header:Stripe-Account"]
C1 --> C2["CreatePaymentIntentAsync(body, subAccount)
Header:Stripe-Account"]
D --> D1["CreatePaymentMethodAsync(body)
無 Header"]
D1 --> D2["AppendStatementDescriptorAsync(subAccount, body)"]
D2 --> D3["CreatePaymentIntentAsync(body)
Body 帶 transfer_data / on_behalf_of"]
A --> E["Refund / QueryPayment / Cancel"]
E --> F{"DirectCharge?"}
F -->|"是"| G["subAcct = request.ExtendInfo.SubAccount"]
F -->|"否"| H["subAcct = null"]
H --> I["退款時額外執行 CreateTransferReversalsAsync()"]GetStripeApplicationFee():手續費計算的關鍵分歧
這是 Standard 和 Custom 邏輯真正分岔的地方。兩者都會計算「TS 系統使用費」(商品 + 運費的抽成),但 只有 Custom 帳號會額外加上「金流手續費」:
1 | private decimal GetStripeApplicationFee(PayProcessContextEntity context, StripeSettingsEntity stripeSetting) |
application_fee 作為平台使用費即可;Custom 帳號的費率則由平台統一設定,商店沒有自己跟 Stripe 談過費率,因此 91APP 必須把「金流手續費」也計進 application_fee_amount,才能讓平台先代墊、再從 Transfer 給商店的金額中扣回。
訂單建立後:查詢 / 取消 / 對帳的資訊留存
付款成功後,mweb 會把當下使用的帳號類型與流程「凍結」寫入訂單的第三方支付資訊(TradesOrderThirdPartyPayment_Info),日後查詢、取消、對帳都以這筆存檔資料為準,不會重新讀取商店當下的設定:
1 | StripeTradesOrderThirdPartyPaymentInfoEntity info = new() |
EnableCustomDate可能讓商店在訂單成立「之後」從 Custom 切換成 Standard(或反過來)。若查詢 / 取消時重新讀取商店最新設定,可能拿到跟下單當下不同的 payment_flow,導致 Stripe-Account Header 帶錯、查不到交易。因此 GetRequestExtendInfoForQueryCancel() 優先讀取訂單存檔的 TradesOrderThirdPartyPayment_Info,只有查無存檔時才 fallback 用商店目前設定(並預設為 DirectCharge)。
1 | public ThirdPartyServiceSettingDetailsInfoEntity GetSettingDetails(long shopId, StripeSettingsEntity settings) |
為什麼 Custom 沒有這個手動輸入欄位?
因為 Custom 帳號的子帳號從一開始就是91APP 自己建立(不是商店自己開的),子帳號 ID 由 91APP 內部流程直接寫入,商店端不需要、也不能自行修改:
商店先在 Stripe 官網開通自己的帳號
OSM(SMS) 後台顯示 StripeSubAccount 可編輯欄位
維運人員(或商店窗口)手動貼上 acct_xxxxx 並儲存
91APP 內部流程在 OSM 幫商店直接建立 Stripe 子帳號
OSM(SMS) 後台不顯示可編輯欄位(IsCustomAccountSettingType() == true 時直接跳過)
StripeCustomSubAccount 由代開流程直接寫入,商店無法自行更改
驗證子帳號是否可結帳:IsStripeChargedEnabled()
OSM(SMS) 後台在顯示設定狀態時,會額外呼叫 SCM API 確認子帳號的收款能力是否已開通(僅針對 Custom 類型會實際檢查):
1 | private bool IsStripeChargedEnabled(StripeSettingsEntity settings) |
這個檢查同樣使用平台自己的 Key(透過 StripeApiKeyOptionsEnum 對應 Custom 系列金鑰)去問 Stripe「這個子帳號可以收錢了嗎?」,並非用商店自己的 Key。
三個系統的責任分工圖
flowchart TD
A["① 商店老闆 / 窗口
依 91APP 官方 PDF 指示,手動點擊固定連結
https://connect.stripe.com/oauth/authorize?...&client_id=ca_FATj1..."] --> B["② Stripe 官網 OAuth 同意頁"]
B --> C["③ Webstore 前台(mobilewebmall)
StripeOAuthController.Index(accountType, code)"]
C --> D["StripeService.OAuth() 用平台 client_secret 換 token"]
D --> E["⚠️ 換回來的 access_token / stripe_user_id
只寫 Log,不落地存檔"]
E --> F["④ 維運人員 / 商店窗口
從 Log 或 Stripe Dashboard 取得 acct_xxxxx"]
F --> G["⑤ OSM(SMS) 後台
ThirdPartyServicesController → StripeSettingService
→ CreditCardOnceStripePaymentMethodSetting.UpdateSettingDetails()"]
G --> H["手動填入 StripeSubAccount = acct_xxxxx
寫入 ShopDefault 表"]
H --> I["⑥ 結帳流程(第 15、16 章)
mweb 讀取 ShopDefault.StripeSubAccount"]
I --> J["一律用 91APP 平台的 StandardAcctLiveSecretKey 打 API"]
J --> K["搭配 Stripe-Account: acct_xxxxx Header 代表該商店操作"]
K --> L["Stripe 驗證:「這把平台 Key 是否已被 acct_xxxxx 授權?」
→ OAuth 當初已同意 → 放行"]

