Secret Key 設定位置

📁 設定檔路徑:MachineConfig/Frontend/AppSettings.QA300.config

檔案結構:

設定項目說明範例
CustomAcctLiveSecretKeyCustom 帳戶正式環境金鑰sk_live_...
CustomAcctTestSecretKeyCustom 帳戶測試環境金鑰sk_test_...
StandardAcctLiveSecretKeyStandard 帳戶正式環境金鑰sk_live_...

根據帳戶類型取得 API 金鑰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private string GetStripeApiKey(long shopId, string accountType)
{
return accountType switch
{
// Custom 系列
StripeAccountTypeConstants.Custom =>
this._stripeConfigurations.CustomAcctLiveSecretKey,
StripeAccountTypeConstants.CustomTest =>
this._stripeConfigurations.CustomAcctTestSecretKey,
StripeAccountTypeConstants.CustomUAT =>
this._stripeConfigurations.CustomUATAcctLiveSecretKey,
StripeAccountTypeConstants.CustomUATTest =>
this._stripeConfigurations.CustomUATAcctTestSecretKey,

// Standard 系列
StripeAccountTypeConstants.Standard =>
this._stripeConfigurations.StandardAcctLiveSecretKey,
StripeAccountTypeConstants.StandardUAT =>
this._stripeConfigurations.StandardUATAcctLiveSecretKey,

// 預設使用 Standard Live Key
_ => this._stripeConfigurations.StandardAcctLiveSecretKey
};
}

API 金鑰對應表

帳戶類型對應 API 金鑰
CustomCustomAcctLiveSecretKey
CustomTestCustomAcctTestSecretKey
CustomUATCustomUATAcctLiveSecretKey
CustomUATTestCustomUATAcctTestSecretKey
StandardStandardAcctLiveSecretKey
StandardUATStandardUATAcctLiveSecretKey
91APP 是 Stripe 的「平台帳號」,每間商店在 Stripe 都有自己的「子帳號(Connected Account)」。
付款時,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 Key91APP 平台的 StandardAcctLiveSecretKey(所有 Standard 共用)
商店區分方式Stripe-Account: acct_xxx Header(每間不同)
Standard / DirectCharge
使用者付款
    → 91APP 平台 Key(Authorization)+ Stripe-Account: acct_商店 Header
    → 錢直接進 商店子帳號
    → 91APP 從中收 application_fee

🟠 Custom 帳號(小型商店,由 91APP 在 OSM 代開)

項目說明
帳號所有人91APP 代替商店建立
費率由平台統一設定
付款流程DestinationCharge — 錢先進平台,再 Transfer 給商店
API Key91APP 平台的 CustomAcctLiveSecretKey(所有 Custom 共用)
商店區分方式Body 的 transfer_data[destination]: acct_xxx(每間不同)
Custom / DestinationCharge
使用者付款
    → 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 授權

Stripe 子帳號申請與綁定流程.pdf
步驟一 點擊固定連結(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_FATj1hhoWn5hUpNgj9zafWQG4NNWOmRK91APP 在 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」的按鈕或程式邏輯——它只提供步驟六要用的手動輸入欄位。

🧭 端到端總結:觸發者是商店老闆/窗口本人,依照 91APP 提供的 PDF 手動點擊一組固定連結 → 在 Stripe 官網完成授權 → Stripe 導回 mweb 的 StripeOAuthController(91APP 程式碼首次介入,僅換 token 寫 Log)→ 商店或維運人員把 acct_xxxxx 抄到 OSM 後台欄位。整條 OAuth 授權路徑,91APP 自己的系統只負責「接收 callback」這一小段,「發起」完全是文件驅動的人工流程,不存在於任何一個 repo 的程式碼中。

付款時 Stripe 的驗證邏輯

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
2
3
4
5
6
7
8
public enum StripePaymentFlowEnum
{
/// Direct Charge on Sub-Account
DirectCharge,

/// Charge on Platform and Transfer to Sub-Account
DestinationCharge
}
1
2
3
4
5
6
private IStripePaymentFlowStrategy GetPaymentFlowStrategy(StripePaymentFlowEnum flow) => flow switch
{
StripePaymentFlowEnum.DirectCharge => this._serviceProvider.GetRequiredService<DirectChargePaymentFlowStrategy>(),
StripePaymentFlowEnum.DestinationCharge => this._serviceProvider.GetRequiredService<DestinationChargePaymentFlowStrategy>(),
_ => throw new NotSupportedException()
};

Pay 比對

兩者都會先呼叫 /v1/payment_methods 建立卡片,再呼叫 /v1/payment_intents 建立付款意圖,差異在 API 呼叫時帶的參數與 Header

🔵 DirectChargePaymentFlowStrategy.Pay()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PaymentMethodResponseEntity paymentMethod =
await _stripeHttpClient.CreatePaymentMethodAsync(
body, // request.ExtendInfo.SubAccount
request.ExtendInfo.SubAccount);

IDictionary<string, object> paymentIntentRequest = new()
{
{ "payment_method", paymentMethod.id },
{ "amount", amount },
{ "currency", request.Currency },
{ "application_fee_amount", applicationFeeAmount },
{ "return_url", request.ExtendInfo.ReturnUrl }
// 沒有 transfer_data / on_behalf_of
};

return await _stripeHttpClient.CreatePaymentIntentAsync(
paymentIntentRequest,
request.ExtendInfo.SubAccount); // ← Stripe-Account Header

🟠 DestinationChargePaymentFlowStrategy.Pay()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PaymentMethodResponseEntity paymentMethod =
await _stripeHttpClient.CreatePaymentMethodAsync(body);
// ← 不帶 subAccount,卡片建立在平台帳號下

IDictionary<string, object> paymentIntentRequest = new()
{
{ "transfer_data[destination]", subAccount },
{ "on_behalf_of", subAccount },
{ "payment_method", paymentMethod.id },
{ "amount", amount },
{ "currency", request.Currency },
{ "application_fee_amount", applicationFeeAmount },
{ "return_url", request.ExtendInfo.ReturnUrl }
};

await AppendStatementDescriptorAsync(subAccount, paymentIntentRequest);

return await _stripeHttpClient.CreatePaymentIntentAsync(paymentIntentRequest);
// ← 不帶 Stripe-Account Header,用平台帳號身分呼叫
🔍 DestinationCharge 多做一步:AppendStatementDescriptorAsync() 會呼叫 GET /v1/accounts/{subAccount} 取得子帳號的 statement_descriptor,塞進 PaymentIntent,讓使用者信用卡帳單上顯示的是「商店名稱」而非「91APP」。因為 DestinationCharge 錢會先入平台帳號,若不特別設定,帳單摘要預設會顯示平台資訊。

DestinationCharge 退款時,因為錢已經 Transfer 給商店子帳號,平台必須額外執行 Transfer Reversal(撤銷轉帳),把錢從子帳號的餘額中收回:

1
2
3
4
5
6
7
8
9
10
11
12
13
if (request.ExtendInfo.StripePaymentFlow == StripePaymentFlowEnum.DestinationCharge)
{
IDictionary<string, object> transferReversalsRequestBody = new()
{
["amount"] = refundAmount
};

// 從子帳號收回已 Transfer 的資金
await _stripeHttpClient.CreateTransferReversalsAsync(charge.Transfer, transferReversalsRequestBody);
}

// 兩種流程都會走的一般退款請求
RefundResponseEntity response = await _stripeHttpClient.CreateRefundsAsync(refundRequestBody, subAcct);
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)
策略類別DirectChargePaymentFlowStrategyDestinationChargePaymentFlowStrategy
建立 payment_methodStripe-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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private decimal GetStripeApplicationFee(PayProcessContextEntity context, StripeSettingsEntity stripeSetting)
{
// 兩種帳號類型都會計算:商品 TS 使用費 + 運費 TS 使用費
var applicationFee = salePageAndFeePriceList.Sum(x => CalculateFee(x, salesProcessingFee.Rate));
applicationFee += feePriceList.Sum(x => CalculateFee(x, salesProcessingFee.Rate));

if (stripeSetting.IsCustomAccountType() == true)
{
// ★ 只有 Custom 帳號才需要另外收「金流手續費」,
// 並疊加進 application_fee_amount 一併從平台代收
ProcessingFeeEntity payProfileProcessingFee = context.ProcessingFeeInfo.PayProfile;
applicationFee += (CalculateFee(context.GetTotalPaymentWithoutMultiPayment(), payProfileProcessingFee.Rate)
+ payProfileProcessingFee.FixedFee);
}

return applicationFee;
}
💡 Standard 帳號的商店與 Stripe 自行談判費率,Stripe 在撥款給商店子帳號前就已扣掉信用卡手續費,91APP 只需抽 application_fee 作為平台使用費即可;Custom 帳號的費率則由平台統一設定,商店沒有自己跟 Stripe 談過費率,因此 91APP 必須把「金流手續費」也計進 application_fee_amount,才能讓平台先代墊、再從 Transfer 給商店的金額中扣回。

訂單建立後:查詢 / 取消 / 對帳的資訊留存

付款成功後,mweb 會把當下使用的帳號類型與流程「凍結」寫入訂單的第三方支付資訊(TradesOrderThirdPartyPayment_Info),日後查詢、取消、對帳都以這筆存檔資料為準,不會重新讀取商店當下的設定

1
2
3
4
5
6
7
8
9
StripeTradesOrderThirdPartyPaymentInfoEntity info = new()
{
PaymentFlow = settings.GetStripePaymentFlow(), // DirectCharge / DestinationCharge
AccountType = settings.StripeAccountType, // Standard / Custom / ...
SubAccount = subAccount, // acct_xxxxx
FeeRate = payProfileProcessingFee.Rate,
FixedFee = payProfileProcessingFee.FixedFee,
SCMSalesFeeRate = salesProcessingFee.Rate,
};
🛡️ EnableCustomDate可能讓商店在訂單成立「之後」從 Custom 切換成 Standard(或反過來)。若查詢 / 取消時重新讀取商店最新設定,可能拿到跟下單當下不同的 payment_flow,導致 Stripe-Account Header 帶錯、查不到交易。因此 GetRequestExtendInfoForQueryCancel() 優先讀取訂單存檔的 TradesOrderThirdPartyPayment_Info,只有查無存檔時才 fallback 用商店目前設定(並預設為 DirectCharge)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public ThirdPartyServiceSettingDetailsInfoEntity GetSettingDetails(long shopId, StripeSettingsEntity settings)
{
// Custom 帳號:不顯示可編輯欄位(因為子帳號由 91APP 在 OSM 內部代開)
if (settings.IsCustomAccountSettingType() == true)
{
result.Properties = new[] { isNewPopupFormProperty };
return result;
}

// Standard 帳號:開放後台人員手動輸入/編輯 StripeSubAccount(acct_xxxxx)
result.Properties = new[]
{
isNewPopupFormProperty,
new ThirdPartyServiceSettingPropertyEntity("Stripe", nameof(StripeSettingsEntity.StripeSubAccount), settings.StripeSubAccount)
};
return result;
}

public void UpdateSettingDetails(ThirdPartyServiceSettingDetailsInfoEntity details, StripeSettingsEntity settings)
{
if (settings.IsCustomAccountSettingType() == true)
{
return; // Custom 不需更新(沒有可編輯欄位)
}

var subAcctSetting = details.Properties.Single(x => x.Group == "Stripe" && x.Property == nameof(StripeSettingsEntity.StripeSubAccount));

// 直接把後台表單填的值寫進 ShopDefault 表
this._shopDefaultService.Update(details.ShopId, subAcctSetting.Group, subAcctSetting.Property, subAcctSetting.Value, this._userService.GetCurrentOperatorName());

this._mwebApiService.CleanShopDefaultCache(details.ShopId);
this._shoppingApiService.CleanShopDefaultCacheAsync(details.ShopId).GetAwaiter().GetResult();
}

為什麼 Custom 沒有這個手動輸入欄位?

因為 Custom 帳號的子帳號從一開始就是91APP 自己建立(不是商店自己開的),子帳號 ID 由 91APP 內部流程直接寫入,商店端不需要、也不能自行修改:

Standard — 手動填寫

商店先在 Stripe 官網開通自己的帳號

OSM(SMS) 後台顯示 StripeSubAccount 可編輯欄位

維運人員(或商店窗口)手動貼上 acct_xxxxx 並儲存

Custom — 系統代開

91APP 內部流程在 OSM 幫商店直接建立 Stripe 子帳號

OSM(SMS) 後台不顯示可編輯欄位(IsCustomAccountSettingType() == true 時直接跳過)

StripeCustomSubAccount 由代開流程直接寫入,商店無法自行更改

驗證子帳號是否可結帳:IsStripeChargedEnabled()

OSM(SMS) 後台在顯示設定狀態時,會額外呼叫 SCM API 確認子帳號的收款能力是否已開通(僅針對 Custom 類型會實際檢查):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private bool IsStripeChargedEnabled(StripeSettingsEntity settings)
{
using (IStripeAccountClient client = _scmApiClientFactory.Create<IStripeAccountClient, StripeClient>())
{
StripeApiKeyOptionsEnum option = settings.StripeAccountSettingType switch
{
"Custom" => StripeApiKeyOptionsEnum.CustomLive,
"CustomUAT" => StripeApiKeyOptionsEnum.CustomUATLive,
"CustomTest" => StripeApiKeyOptionsEnum.CustomTest,
"CustomUATTest" => StripeApiKeyOptionsEnum.CustomUATTest,
_ => return false // Standard 不檢查,直接視為已可收款
};

string subAcct = settings.IsTestMode() ? settings.StripeCustomTestSubAccount : settings.StripeCustomSubAccount;
ApiResultEntity<bool> result = client.IsChargeEnabled(new IsChargeEnabledRequestEntity(option, subAcct));
return result.Status == ApiResultStatusEnum.Success && result.Data == true;
}
}

這個檢查同樣使用平台自己的 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 當初已同意 → 放行"]