はじめに
B2B コマース(拡張 LWR のストア)でカード払いを付けたい。公式の手順を読むと、まず出てくるのは Salesforce Payments(Stripe・Adyen を Salesforce 経由で使う仕組み)です。
ところがヘルプの「Salesforce Payments」には、次の注記があります。
ブラジル・インド・日本で Salesforce と契約しているお客様は、Salesforce Payments を使えない(要旨)
日本の会社が日本の Salesforce と契約しているなら、この道は使えません。残る道は 「支払いのゲートウェイのアダプタ」を Apex で作り、決済会社の API に自分でつなぐ ことです。
この記事では、Stripe のテスト環境を相手に、アダプタを作ってストアにつなぎ、与信が通れば注文になり、断られれば注文が止まる ところまでを通した手順と、途中のつまずき2つをまとめます。
対象読者
- 日本で B2B コマースのストアにカード払いを付けたい管理者・開発者
- Salesforce Payments が使えないと分かって、次の手を探している方
対象外
- 売上の確定(キャプチャ)・返金・3D セキュア。この記事のアダプタは 与信だけ です
- B2C コマース(SFCC)
- 日本の決済代行会社ごとの API の違い(Stripe でしか確かめていません)
検証の環境
Developer Edition から作ったスクラッチ org(通貨は円)に、B2B コマースのストア(拡張 LWR)と購入者のユーザーを用意しました。決済の相手は Stripe のテスト環境(サンドボックス。お金は動きません)です。設定は Salesforce CLI で反映し、Claude Code を道具に使って「通るはず」「止まるはず」を先に書いてから結果と突き合わせました。カード番号と注文のボタンは人が画面で入れています。
全体の形
部品は5つです。上から順に作ります。
| # | 部品 | 役目 | 作り方 |
|---|---|---|---|
| 1 | Apex のクラス(commercepayments.PaymentGatewayAdapter) |
チェックアウトから呼ばれ、決済会社の API を呼ぶ | メタデータ |
| 2 | 外部資格情報 + 名前付き資格情報 | 決済会社の URL とキーを、コードの外に置く | メタデータ + キーは API で |
| 3 | 権限セット | 資格情報を使える人を決める | メタデータ |
| 4 | PaymentGatewayProvider |
「このアダプタを使う決済の提供元」の登録 | メタデータ |
| 5 |
PaymentGateway と StoreIntegratedService
|
提供元 + 資格情報をまとめ、ストアにつなぐ | レコードの作成 |
チェックアウトで呼ばれるのは Tokenize(カードをトークンに)→ Authorize(与信) の2回です。注文は与信が通ったあとに作られます。
1. アダプタ(Apex)
要るところだけ抜き出します(全体で約110行。テストクラスは別)。
global class SampleStripeAdapter implements commercepayments.PaymentGatewayAdapter {
global commercepayments.GatewayResponse processRequest(commercepayments.PaymentGatewayContext ctx) {
commercepayments.RequestType t = ctx.getPaymentRequestType();
try {
if (t == commercepayments.RequestType.Tokenize) {
return tokenize((commercepayments.PaymentMethodTokenizationRequest) ctx.getPaymentRequest());
}
if (t == commercepayments.RequestType.Authorize) {
return authorize((commercepayments.AuthorizationRequest) ctx.getPaymentRequest());
}
} catch (Exception e) {
// 例外の中身は購入者に出さない。管理者が読む記録に、種類と行だけ残す
return new commercepayments.GatewayErrorResponse('500', e.getTypeName() + ' line ' + e.getLineNumber());
}
return new commercepayments.GatewayErrorResponse('400', 'not supported ' + t);
}
commercepayments.GatewayResponse authorize(commercepayments.AuthorizationRequest req) {
// Tokenize で返したトークンは、カードの支払い方法(CardPaymentMethod)の GatewayToken に入っている
String pm = null;
if (req.paymentMethod != null && req.paymentMethod.id != null) {
for (CardPaymentMethod m : [SELECT GatewayToken FROM CardPaymentMethod WHERE Id = :req.paymentMethod.id]) {
pm = m.GatewayToken;
}
}
String cur = req.currencyIsoCode == null ? 'jpy' : req.currencyIsoCode.toLowerCase();
String body = 'amount=' + toMinor(Decimal.valueOf(req.amount), cur)
+ '¤cy=' + cur + '&payment_method=' + pm
+ '&confirm=true&capture_method=manual'
+ '&automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=never';
Map<String, Object> r = send('/v1/payment_intents', body);
commercepayments.AuthorizationResponse res = new commercepayments.AuthorizationResponse();
res.setGatewayDate(System.now());
res.setAmount(req.amount);
Map<String, Object> err = (Map<String, Object>) r.get('error');
if (err == null && r.get('status') == 'requires_capture') {
res.setGatewayReferenceNumber((String) r.get('id'));
res.setGatewayResultCode('requires_capture');
res.setSalesforceResultCodeInfo(new commercepayments.SalesforceResultCodeInfo(
commercepayments.SalesforceResultCode.Success));
} else {
// 断られた・追加の認証(3D セキュア)が要る、はどちらも断りとして返す
res.setGatewayResultCode(err == null ? String.valueOf(r.get('status')) : String.valueOf(err.get('code')));
res.setSalesforceResultCodeInfo(new commercepayments.SalesforceResultCodeInfo(
commercepayments.SalesforceResultCode.Decline));
}
return res;
}
// Stripe の amount は最小の単位。円は小数なし(5690 = ¥5,690)、ドルは 100 倍
public static Long toMinor(Decimal a, String cur) {
Set<String> zeroDecimal = new Set<String>{ 'jpy', 'krw', 'vnd' };
return (zeroDecimal.contains(cur) ? a : a * 100).setScale(0, RoundingMode.HALF_UP).longValue();
}
// 宛先は '/v1/...' の後半だけを渡す(下の「つまずき1」)
static Map<String, Object> send(String path, String body) {
HttpRequest h = new HttpRequest();
h.setEndpoint(path);
h.setMethod('POST');
h.setHeader('Content-Type', 'application/x-www-form-urlencoded');
h.setBody(body);
HttpResponse resp = new commercepayments.PaymentsHttp().send(h);
return (Map<String, Object>) JSON.deserializeUntyped(resp.getBody());
}
}
ポイントは3つです。
-
与信だけ:
capture_method=manualで、Stripe の状態はrequires_capture(枠を押さえただけ)になります。Salesforce に Success を返すのはこのときだけにしました -
円は小数なし: Stripe の
amountは最小の単位です。円は 5690 が ¥5,690、ドルは 100 倍。ここを取り違えると 100 倍の与信になります。テストクラスで「3,360円 → 3360」「33.6ドル → 3360」を先に確かめました -
呼び出しは
commercepayments.PaymentsHttp: 普通のHttpではなく、支払い用のこのクラスで送ります。どの名前付き資格情報を使うかは、あとで作るPaymentGatewayの設定で決まります
Tokenize について
この検証では、Tokenize で Stripe の テスト用の払い方(pm_card_visa。断りを試すときは pm_card_visa_chargeDeclined)を返しました。画面で入れたカード番号は Stripe に送っていません。理由は「確かめていないこと」の節に書きます。
2. 外部資格情報・名前付き資格情報
キーはコードにもメタデータにも書きません。外部資格情報(Custom)で「Authorization の見出しに Bearer + キー」を作り、名前付き資格情報から参照します。
<!-- externalCredentials/SampleStripeExt.externalCredential-meta.xml -->
<ExternalCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<authenticationProtocol>Custom</authenticationProtocol>
<externalCredentialParameters>
<parameterName>SamplePrincipal</parameterName>
<parameterType>NamedPrincipal</parameterType>
<sequenceNumber>1</sequenceNumber>
</externalCredentialParameters>
<externalCredentialParameters>
<parameterName>Authorization</parameterName>
<parameterType>AuthHeader</parameterType>
<parameterValue>{!'Bearer ' & $Credential.SampleStripeExt.SecretKey}</parameterValue>
<sequenceNumber>1</sequenceNumber>
</externalCredentialParameters>
<label>Sample Stripe (test key)</label>
</ExternalCredential>
<!-- namedCredentials/SampleStripe.namedCredential-meta.xml -->
<NamedCredential xmlns="http://soap.sforce.com/2006/04/metadata">
<allowMergeFieldsInBody>true</allowMergeFieldsInBody>
<allowMergeFieldsInHeader>true</allowMergeFieldsInHeader>
<calloutStatus>Enabled</calloutStatus>
<generateAuthorizationHeader>false</generateAuthorizationHeader>
<label>Sample Stripe</label>
<namedCredentialParameters>
<parameterName>Url</parameterName>
<parameterType>Url</parameterType>
<parameterValue>https://api.stripe.com</parameterValue>
</namedCredentialParameters>
<namedCredentialParameters>
<externalCredential>SampleStripeExt</externalCredential>
<parameterName>ExternalCredential</parameterName>
<parameterType>Authentication</parameterType>
</namedCredentialParameters>
<namedCredentialType>SecuredEndpoint</namedCredentialType>
</NamedCredential>
キーの値(SecretKey)は、反映のあとに Connect API で入れました。値がコマンドの履歴に残らないよう、標準入力で渡しています。
printf '{"externalCredential":"SampleStripeExt","principalName":"SamplePrincipal","principalType":"NamedPrincipal","credentials":{"SecretKey":{"value":"%s","encrypted":true}}}' "$KEY" \
| sf api request rest /services/data/v65.0/named-credentials/credential --method POST --body - -o <org>
Stripe のキーは、テスト環境の 制限付きキー(rk_test_) を使いました。新しい Stripe のダッシュボードでは、シークレットキーは最初から用意されておらず、「シークレットキーを作成」から作ります。
3. 権限セット
外部資格情報は、プリンシパルを権限セットで付けた人しか使えません。
<!-- permissionsets/SampleStripeAccess.permissionset-meta.xml -->
<PermissionSet xmlns="http://soap.sforce.com/2006/04/metadata">
<externalCredentialPrincipalAccesses>
<enabled>true</enabled>
<externalCredentialPrincipal>SampleStripeExt-SamplePrincipal</externalCredentialPrincipal>
</externalCredentialPrincipalAccesses>
<hasActivationRequired>false</hasActivationRequired>
<label>Sample Stripe Access</label>
</PermissionSet>
デバッグログで見ると、アダプタは購入者のユーザーとして動いていました。この検証では、管理者と購入者の両方にこの権限セットを付けてから試しています。
4. 提供元・ゲートウェイ・ストアへのつなぎ
提供元(PaymentGatewayProvider)はメタデータで作ります。
<!-- paymentGatewayProviders/SampleStripeProvider.paymentGatewayProvider-meta.xml -->
<PaymentGatewayProvider xmlns="http://soap.sforce.com/2006/04/metadata">
<apexAdapter>SampleStripeAdapter</apexAdapter>
<comments>Stripe test mode (authorize only)</comments>
<idempotencySupported>Yes</idempotencySupported>
<masterLabel>Sample Stripe Provider</masterLabel>
</PaymentGatewayProvider>
Tooling API でレコードとして作ろうとすると「You must provide a valid Metadata field」で止まりました。メタデータで反映すれば通ります。
残りの2つはレコードです。
# 提供元 + 名前付き資格情報をまとめたゲートウェイ
sf data create record -s PaymentGateway -o <org> \
-v "PaymentGatewayName='Sample Stripe' MerchantCredentialId=<名前付き資格情報の Id> PaymentGatewayProviderId=<提供元の Id> Status=Active"
# ストアにつなぐ
sf data create record -s StoreIntegratedService -o <org> \
-v "StoreId=<ストアの Id> Integration=<ゲートウェイの Id> ServiceProviderType=Payment"
つなぐと、ストアの Settings > Checkout > Payments に提供元の名前が「Active」で出て、購入者のチェックアウトの画面に「Credit Card」の入力が出ます。
結果
| # | 試したこと | 先に書いた期待 | 結果 |
|---|---|---|---|
| 1 | テストクラス(作り物の応答 HttpCalloutMock で5本) |
5/5 | 5/5・カバー率 93% |
| 2 | Salesforce から Stripe の残高を読むだけの呼び出し | 200 | 200・livemode=false(テスト環境) |
| 3 | 購入者の画面でカートの ¥5,690 を注文 | Stripe に 5690 jpy・requires_capture、注文ができる |
注文が Activated、PaymentAuthorization が Processed・5,690。Stripe のダッシュボードにも同じ参照番号で 5690 jpy・requires_capture
|
| 4 | 断られる払い方で ¥120 を注文 | 断り・注文は増えない・カートはそのまま | 画面は「Unable to Process Payment」。Stripe は card_declined / generic_decline、注文は増えず、カートは払い直せる状態 |
3 はつまずき2つを直して、3回目に通りました。
つまずき1: callout: を付けると「Named credential namespace part must be alphabetic」
普通の Apex の外向きの呼び出しと同じように h.setEndpoint('callout:SampleStripe/v1/payment_intents') と書くと、次の例外で止まりました。
Named credential namespace part must be alphabetic
commercepayments.PaymentsHttp は、PaymentGateway に登録した名前付き資格情報を 自分で付けて 送ります。渡すのは /v1/payment_intents のような後半だけ でした。
なお、購入者の画面に出るのは「Unable to Process Payment」という決まりの文だけで、原因は分かりません。この例外は、アダプタの catch で種類と行をゲートウェイの記録に残すようにして見つけました。困ったら PaymentGatewayLog を見るのが公式の案内です。
つまずき2: Stripe が payment_method_types で 400
Stripe の古い見本にある payment_method_types[]=card を付けると、Stripe が 400 を返しました(このパラメーターは使えなくなった、という趣旨のエラー)。代わりに次の2つを付けて通りました。
automatic_payment_methods[enabled]=true
automatic_payment_methods[allow_redirects]=never
allow_redirects=never は、別の画面に飛ぶ払い方を外すためです。この記事のアダプタは、飛んだ先から戻ってくる処理を持っていません。
落とし穴: 画面のカード番号は、Apex には見えない
作り物のアダプタで「この番号なら断る」と分けようとしたところ、断るはずの番号でも通って 注文ができてしまいました。
デバッグログで見ると、画面で入れたカード番号は Apex に数字では届かず、{!$CREDENTIAL.…} という 差し込みの印 で届いていました。本物の番号は、名前付き資格情報を通した外向きの呼び出しの本文に入るときにだけ差し込まれます(名前付き資格情報の allowMergeFieldsInBody を true にしているのはこのためです)。
そのため、アダプタの中で番号を見て分けることはできません。テストでは、カードの名義(Decline Test なら断る)で分けました。
確かめていないこと
本番に持っていく前に、次は確かめが要ります。
- 【未確認】画面の番号を Stripe に送る形: 上の差し込みで
POST /v1/payment_methodsに送る作りは用意しましたが、試していません。Stripe はカード番号をそのまま受ける API を、サポートに申し込んで有効にしてもらう決まりです(テストは書類なし、本番は PCI DSS の書類が毎年要る。Stripe のサポートの記事) - 【推測】日本の決済代行会社は、カード番号(かトークン)を受ける API が前提のところが多く、上の差し込みの形がそのまま使える見込みです。会社ごとの API は確かめていません
- 【未確認】購入者に権限セットを付けないと止まるか(付けた状態でしか試していません)
- 【未確認】売上の確定・返金・3D セキュア。この記事のアダプタは、追加の認証が要る応答を断りとして返します
- 【推測】Stripe の最小額(円は 50円)に届かない注文は、Stripe に断られる見込みです。少額の注文があるストアでは、要件で先に聞いておく点です
まとめ
- 日本で Salesforce と契約しているなら、Salesforce Payments は使えない(公式のヘルプの注記)。カード払いは 自作のアダプタ でつなぐ
- 部品は5つ: Apex のアダプタ → 外部資格情報 + 名前付き資格情報 → 権限セット →
PaymentGatewayProvider(メタデータ)→PaymentGateway+StoreIntegratedService(レコード) - チェックアウトでするのは 与信だけ。Success を返せば注文になり、Decline を返せば注文は作られずカートが残る
-
PaymentsHttpの宛先は 後半だけ(callout:を付けない) - Stripe は
payment_method_typesではなくautomatic_payment_methods - 画面のカード番号は Apex に見えない(差し込みの印で届く)。テストで番号によって分けることはできない
- 円は小数なし。
amountの桁を通貨ごとにテストで押さえる
筆者は Salesforce の設定の代行・相談を受けています(連絡先は Qiita のプロフィールに)。
参考
- ヘルプ「Salesforce Payments」(日本・ブラジル・インドの注記) https://help.salesforce.com/s/articleView?id=commerce.payments_product_intro.htm&language=en_US&type=5
- Stripe「Testing」(テスト用の払い方) https://docs.stripe.com/testing
- Stripe「Currencies」(円は小数なし・最小額) https://docs.stripe.com/currencies
- Stripe サポート「Enabling access to raw card data APIs」 https://support.stripe.com/questions/enabling-access-to-raw-card-data-apis
- 公式の参考実装(Stripe は無く、Payeezy などの見本) https://github.com/forcedotcom/Core-Payments-Reference-Gateway-Integration-Adapters