EC-CUBE / EC-CUBE/ec-cube

ACP/UCP 共通基盤: エージェントコマース向け CheckoutSession・マッピング層の整備

Open
#6,777 0 comments 0 reactions 3 assignees Claimed by @dotani1111 View on GitHub
Agentic Commerce document
Dominant language
PHP
Stars
788
Forks
719
Avg merge
4d 4h
Merged PRs (30d)
39

Description

### 概要 (Overview)

#6776 (ACP) と #6574 (UCP) の両対応で重複する基盤コンポーネントを切り出して実装する。両 issue を着手する前提として先行して整備し、二重実装を避ける。将来のエージェントコマース仕様 (Stripe MPP, x402 等) や他エージェント (Claude, Gemini 経由のエージェントコマース) の対応にも応用可能な層を目指す。

### 背景

ACP / UCP は仕様としては別物だが、加盟店 (EC-CUBE) 側で必要となる以下の処理は本質的に同じ:

- 「エージェント経由のチェックアウトセッション」を保持する状態管理
- 内部エンティティ (`Customer`, `Cart`, `Order`, `Shipping`) と外部スキーマのマッピング
- `PurchaseFlow` を介した税・送料・在庫の再計算
- 通貨ごとの minor unit 変換
- **OAuth 2.0 認証 (capability-driven scope による認可)**
- エージェント経由購入の Order 識別 (どのプロトコル / どのエージェントから来たか)

各 issue で個別実装すると差分管理が複雑化するため、本 issue で共通レイヤを先行整備する。

### 参照仕様

- ACP: https://github.com/agentic-commerce-protocol/agentic-commerce-protocol (最新安定版 `2026-04-17`, Apache 2.0)
- UCP: https://github.com/Universal-Commerce-Protocol/ucp (`v2026-04-08`, Apache 2.0)
- OAuth 2.0 認可基盤: [eccube-api4](https://github.com/EC-CUBE/eccube-api4) (`ec-cube/api42`)

### 対象ブランチ

**`4.4` (Symfony 7 対応版)**

### 前提依存

OAuth 2.0 認可基盤として **eccube-api4 (`ec-cube/api42`)** プラグインを前提依存とする。詳細は本文「8. OAuth 2.0 認証基盤」参照。

---

## スコープ

### 1. `CheckoutSession` 汎用エンティティ

ACP / UCP 双方の状態を表現できる汎用設計。プロトコル種別を `protocol` カラムで識別:

| フィールド | 型 | 説明 |
|---|---|---|
| `id` | int | 内部 ID |
| `session_id` | string (unique) | 外部公開セッション ID (UUID) |
| `protocol` | string | `acp` / `ucp` (enum 固定せず文字列。将来プロトコル追加に備える) |
| `agent_id` | string\|null | エージェント識別子 (ChatGPT, Gemini 等) |
| `status` | string | 下表の正規化ステータス |
| `currency` | string | 通貨コード |
| `expires_at` | datetime | 有効期限 |
| `Order` | relation | 完了後の Order (nullable) |
| `Cart` | relation | 関連 Cart (nullable) |
| `buyer_data` | json | 購入者情報 |
| `fulfillment_data` | json | 配送先・配送方法 |
| `payment_data` | json | 決済情報 (token は暗号化/マスキング) |
| `metadata` | json | プロトコル固有データ・固有ステータスの逃がし先 |
| `create_date` / `update_date` | datetime | |

#### 正規化ステータス (`status`)

本体で持つ最小の共通ステータスは以下。プロトコル固有の細分ステータスは `metadata` に保持し、共通層は正規化値で扱う:

| 正規化値 | 意味 | 終端 |
|---|---|---|
| `incomplete` | 作成済・確定前 (情報収集中) | ✗ |
| `ready` | 決済可能 (必要情報が揃った) | ✗ |
| `requires_action` | **追加認証 / escalation 待ち**。在庫を引き当てたまま保持し、エージェントの再 complete を待つ。UCP `requires_escalation` / ACP `authentication_required` を正規化 | ✗ |
| `in_progress` | **非同期決済の確定待ち** (`complete_in_progress`)。IPN / Webhook で `completed` へ遷移 | ✗ |
| `completed` | 注文確定済 | ✓ |
| `canceled` | 取消済 (ACP の cancel エンドポイント等) | ✓ |
| `expired` | 期限切れ | ✓ |

> **重要 (2026-06-17 追記): 追加認証 (3DS / escalation) は「エラー」ではなく `complete` が返す正常な中間状態である。**
> `complete` は冪等な単発呼び出しではなく、状態に応じて複数回呼ばれる**状態機械**として設計する (詳細は「7. PaymentHandler 抽象」)。
> このため当初の 5 値に加え、制御フローを分岐させる中間状態 `requires_action` / `in_progress` を**正規化値として持つ**
> (metadata へ逃がすだけでは在庫引当の保持・再開を表現できないため)。
>
> 参考: ACP の `CheckoutSession.status` は `incomplete / not_ready_for_payment / requires_escalation / authentication_required / ready_for_payment / pending_approval / complete_in_progress / completed / canceled / in_progress / expired`、UCP は `incomplete / requires_escalation / ready_for_complete / complete_in_progress / completed / canceled` と細かい。これら**プロトコル固有の細分ステータスは `metadata` に原文保持**し、共通層では上表の正規化値へ写す。`requires_escalation` (UCP・buyer ハンドオフ) と `authentication_required` (ACP・agent 3DS) は、我々の状態機械では「在庫を保持して再開を待つ」点で同一のため単一 `requires_action` に正規化し、差異 (continue_url / authentication_metadata) は `payment_data` / `metadata` で表現する。`canceled` を必ず含める点に注意 (ACP に cancel エンドポイントがあるため)。
>
> **`requires_action` / `in_progress` は非終端だが在庫を引当済み**のため、期限切れ回収 (`CheckoutSessionRepository::findExpired`) の対象に含め、回収時は `PurchaseFlow::rollback` で在庫を戻してから `expired` 化する。

### 2. 住所マッピングサービス

`AddressMappingService`:
- EC-CUBE `Customer` / `Shipping` ↔ 汎用住所 DTO
- 姓名分離・都道府県名 ↔ region
- **国コード変換**: EC-CUBE の `Master\Country` に ISO コード取得メソッドは存在しない。`mtb_country.csv` の `id` は **ISO 3166-1 numeric** (840=米国 / 392=日本 等)。**numeric → alpha-2 は新規マスタ `mtb_country_iso_code`** (id=ISO numeric / name=alpha-2 / discriminator=`countryisocode`、`mtb_*` 固定スキーマ準拠・既存 `mtb_country` は改変せず) **で管理**し、`CountryIsoCodeRepository` 経由で解決して ACP/UCP の `country` (alpha-2) を生成する (ハードコードしない)。新規インストールは `import_csv` (`definition.yml` 登録)、既存環境は INSERT データ migration で backfill (PR #6802)
- ACP / UCP のスキーマ差はアダプタ層で吸収

### 3. 金額単位変換ユーティリティ

`MinorUnitConverter`:
- JPY / KRW / TWD 等ゼロデシマル通貨対応
- bcmath 使用
- **負数対応** (UCP の割引 `totals` を負の minor unit で表現)
- ACP / UCP 双方から利用

### 4. `PurchaseFlow` 連携アダプタ

`AgentCheckoutPurchaseFlowAdapter`:
- 外部スキーマ → 内部 `Cart` / `OrderItemCollection` 構築
- 既存 `PurchaseFlow` プロセッサを再利用して税・送料・在庫を再計算
- 結果を外部スキーマへ写し戻す
- **送料・税の真実は内部計算**: エージェント送信値と乖離した場合はビジネスロジックエラーとして返す (各プロトコルのエラー形式へ変換)

### 5. Order メタデータ拡張

`Order` エンティティに以下を追加 (Customize 経由ではなく本体):
- `agent_protocol` (nullable): `acp` / `ucp`
- `agent_id` (nullable): エージェント識別子
- 通常購入は両方 NULL

→ 管理画面の注文一覧で「エージェント経由注文」を識別できるようにする土台。

### 6. 共通エラーモデル

`AgentCheckoutException` / エラーコード enum:
- ACP / UCP のエラーレスポンス仕様に変換するアダプタを各 issue で実装する前提
- **両プロトコルとも「2 系統エラー」を持つ** (各 issue 側で具体化):
- **プロトコルエラー** = HTTP 4xx/5xx + 標準エラーボディ
- **ビジネスロジックエラー** = HTTP 200 + セッション応答内の `messages[]` (在庫切れ・決済拒否・価格不一致等)
- 共通基盤は「業務エラーは例外ではなく messages として持ち回せる」構造を提供する
- **重要: 追加認証 (3DS / escalation) はこの「エラー」のいずれにも該当しない。** `complete` が返す**正常な中間状態** (`requires_action` / `in_progress`) であり、エラーモデルと混同しない。例外として ACP の `authentication_result` 欠落のまま確定要求した場合のみ HTTP 400 `requires_3ds` (= プロトコルエラー) となるが、これはプロトコル層 (#6776/#6574) が判定・生成する責務であり、共通エラーコード enum には追加しない。
- UCP の business error は `severity` (`recoverable` / `requires_buyer_input` / `requires_buyer_review` / `unrecoverable`) を持つ。共通層の `messages[]` はこの severity を保持し、決済失敗後の遷移先判定 (recoverable → `ready` 据置 / unrecoverable → `canceled`) に用いる。

### 7. PaymentHandler 抽象 + complete 状態機械 (2026-06-17 改訂)

> **設計の核心**: 追加認証 (EMV-3DS / escalation) はエラーでなく `complete` が返す正常な中間状態であり、`complete` は冪等な単発呼び出しではなく**「中断 → 再開」する状態機械**である。決済ハンドラの戻り値が `void` だと「追加認証が必要・continue_url / authentication_metadata・pending」を表現できず、Stripe 等が EMV-3DS を実装できない。このため**結果型を返す**設計に改める。EC-CUBE 標準の `PaymentMethodInterface`(`apply(): PaymentDispatcher|bool` / `checkout(): PaymentResult`)が `PaymentResult` / `PaymentDispatcher` で外部遷移と成否を表現しているのと同じ構造を、プロトコル非依存の中立 DTO として持つ(エージェントは Symfony `Response` を解釈しないため `Response` は使わない)。

#### 7-1. `PaymentOutcome` 結果型 (新規・core 中立)

```php
enum PaymentOutcomeStatus: string {
case COMPLETED = 'completed'; // 与信/売上 成功 → commit 可
case REQUIRES_ACTION = 'requires_action'; // 3DS challenge / escalation 待ち (在庫は引当のまま保持)
case PENDING = 'pending'; // 非同期処理中 (complete_in_progress)
case FAILED = 'failed'; // 拒否/エラー → rollback
}

final readonly class PaymentOutcome {
// status, ?transactionId, actionData[] (continue_url/authentication_metadata の中立 bag),
// metadata[] (payment_data へ保持・token はマスキング), ?errorCode, ?errorMessage
// factory: ::completed() / ::requiresAction(actionData) / ::pending() / ::failed(code, msg)
}
```

`actionData` は**プロトコル非依存の bag**とし、UCP の `continue_url` / ACP の `authentication_metadata` の差異は**プロトコル層のマッパーが解釈**する (core は中身を規定しない・Customize 拡張も同経路)。

#### 7-2. `AgentCheckoutPaymentHandlerInterface` (戻り値を `void` → `PaymentOutcome` に改訂)

```php
interface AgentCheckoutPaymentHandlerInterface {
public function supports(Order $order): bool;
// 与信。complete 状態機械の入口。初回・再開の双方から呼ばれ、$paymentData に
// authentication_result 等の再開情報があればそれで認証を完了させる (再入可能)。
public function authorize(Order $order, array $paymentData): PaymentOutcome;
// 売上確定。authorize が COMPLETED を返した後にのみ呼ぶ。
public function capture(Order $order, array $paymentData): PaymentOutcome;
}
```

- メソッド集合 (`supports` / `authorize` / `capture`) は不変、**戻り値のみ `void` → `PaymentOutcome`**。
- `authorize` を**再入可能な状態機械の入口**とし、初回 (トークン与信) と再開 (auth result で 3DS 完了 / out-of-band 完了照会) を同一シグネチャで吸収する。
- ACP / UCP それぞれが派生インターフェイス (UCP は `getHandlerId()` / `exchangePaymentToken()`) を追加。具象実装は決済プラグイン側 (core は型のみ)。

#### 7-3. `AgentCheckoutCompletionService` (新規・complete 状態機械オーケストレータ)

protocol controller (#6776/#6574) はこのサービスへ委譲し、**状態遷移・トランザクション境界・在庫引当の保持/回収**を core に集約する。protocol mapper は戻り値 (`AgentCheckoutCompletionResult`: 正規化 status / ?Order / messages[] / actionData) を ACP/UCP レスポンスへ写すだけ。

```
complete(session, paymentData)
├ [冪等] status=completed → 既存 Order を返す (副作用を再実行しない・ACP MUST)
├ [終端] status∈{canceled,expired} → INVALID_SESSION_STATE (プロトコルエラー)
├ ❰初回❱ status∈{incomplete,ready}:
│ BEGIN TX
│ prepare(order) // 在庫引当・採番 (PROCESSING)。error なら ROLLBACK して messages[]
│ outcome = handler.authorize(order, paymentData)
│ ├ COMPLETED → capture → commit → status=completed・Order 確定
│ ├ REQUIRES_ACTION → payment_data/metadata 保持・status=requires_action・在庫は保持(rollback しない)
│ │ expires_at = now + %eccube_agent_checkout_escalation_expire%分
│ ├ PENDING → status=in_progress (IPN/Webhook 待ち)・expires_at 再設定
│ └ FAILED → rollback(在庫解放)・status 据置・messages[]
│ COMMIT TX // REQUIRES_ACTION/PENDING でも commit し引当+payment_data をリクエスト跨ぎで永続化
└ ❰再開❱ status∈{requires_action,in_progress}: // (a)エージェント再complete (b)PSPのIPN/Webhook受信
BEGIN TX
outcome = handler.authorize(order, paymentData) // auth result で 3DS 完了 / PSP 状態照会
├ COMPLETED → capture → commit → status=completed
├ FAILED → rollback; status = (severity が recoverable/requires_buyer_* なら ready / unrecoverable なら canceled)
COMMIT TX
```

- **トランザクション境界 = 各ステップ独立**。初回 complete は 1 トランザクションで prepare(引当)+(authorize/capture/commit or escalation 永続化) を確定し、`REQUIRES_ACTION` / `PENDING` でも commit することで引当済み在庫と `payment_data` がリクエスト跨ぎで永続する。再開 complete は別トランザクション。adapter 自身は tx を開かず、本オーケストレータが張る (外部サイト遷移型 UCP escalation は遷移中 tx を保持しない)。
- **非同期 (PENDING) の確定経路 = IPN / Webhook**。`sample-payment-plugin` の `PaymentController::receiveComplete()` (受注番号で Order を引き `purchaseFlow->commit()` を呼ぶセッションレスな IPN 受信) と同型。受信口は protocol 層で署名検証 (ACP=`Merchant-Signature` / UCP=RFC 9421) を付け、受信後は本サービスの再開経路に集約する。Order 側の遷移は `OrderStateMachine::can()/apply()` のガードを用いる。GET ポーリングは採らない。
- **在庫確保期限**: `requires_action` / `in_progress` 中は在庫を物理的に引き当てた (= `StockReduceProcessor::prepare` で `ProductStock` を減算した) まま保持する。`expires_at` はその確保を維持する上限で、`eccube.yaml` の parameter `eccube_agent_checkout_escalation_expire` (分単位・**既定 15**) で設定する。EMV-3DS のタイムアウト 10 分より大きい値とする (参考: GMO-PG https://mp-faq.gmo-pg.com/s/article/F01204 )。超過時は `findExpired` → `PurchaseFlow::rollback` で在庫を戻し `expired` 化する。

> 維持 (変更なし): `AgentCheckoutPurchaseFlowAdapter` の `prepare`/`commit`/`rollback` 分割は本状態機械を支える正しい土台のため維持。`AgentCheckoutResult` / `AgentCheckoutMessage` / `UcpPaymentHandlerInterface` / `AgentCheckoutPaymentHandlerRegistry` も維持。

### 8. OAuth 2.0 認証基盤 (eccube-api4 連携)

ACP / UCP の認証要件 (UCP は OAuth 2.0 foundation + capability-driven scopes、ACP は Bearer token / 署名) は、**eccube-api4 (`ec-cube/api42`)** の OAuth 2.0 認可サーバー (`league/oauth2-server-bundle`) を再利用する。

#### 前提依存
- ACP / UCP の **inbound (エージェント → 加盟店) エンドポイント**を有効化するには eccube-api4 のインストールが必要 (実質必須プラグイン扱い)
- eccube-api4 が無効の場合は inbound エンドポイントを 503 / 機能フラグで無効化
- **注意**: ACP の Product Feed は加盟店 → OpenAI への **outbound push** (#6794) であり、本 OAuth2 リソースサーバー (inbound 保護) の対象外。outbound 用の OpenAI 向け認証情報は別管理。

#### 共通基盤側で整備するもの
- **`AgentCommerceScopeRegistry`** — capability-driven scope の登録機構。プロトコル別に必要 scope を列挙
- **`AgentCommerceOAuth2Authenticator`** — リソースサーバーミドルウェア。Symfony 標準 `AccessTokenHandlerInterface` 経由でトークン検証を呼び、scope と protocol を照合 (eccube-api4 の具象には型レベルで依存しない)
- **スコープ規約**: `:` 形式で統一する。
| scope | 用途 |
|---|---|
| `acp:checkout` | ACP Checkout capability |
| `acp:catalog` | ACP カタログ関連 (該当する場合) |
| `ucp:checkout` | UCP Checkout capability |
| `ucp:cart` | UCP Cart capability (将来) |
| `ucp:catalog` | UCP Catalog capability (将来、GraphQL 再利用候補) |
| `ucp:identity` | UCP Identity Linking capability (将来) |
| `agent:*` | 汎用 (将来仕様用予約。通常は使わない) |

#### eccube-api4 側に必要な拡張 (eccube-api4 リポジトリ側で別 issue)
- ACP / UCP scope のプリセット登録機構
- マーチャント (OAuth2 client) 管理 UI で発行可能 scope を制御

#### Request / Response Signing
OAuth 2.0 とは別レイヤ。共通基盤側に `AgentCommerceMessageSigner` インターフェイスを置き、プロトコル別アダプタ (`AcpMessageSigner` / `UcpMessageSigner`) を各 issue で実装。**鍵方式は ACP / UCP で大きく異なるため、共通インターフェイスはアルゴリズム非依存に設計する**:

| | UCP (`v2026-04-08`) | ACP (`2026-04-17`) |
|---|---|---|
| 鍵方式 | **EC 公開鍵 JWK** (`kty:"EC"`, `crv:P-256\|P-384`)、**RFC 9421 HTTP Message Signatures** (`Signature-Input` / `Signature: sig1=:...:`、`UCP-Agent` が profile URI を運ぶ)。`ed25519` (OKP) は profile schema 非適合 | **未固定 / 面ごとに異なる**。Webhook は `Merchant-Signature` ヘッダ単独 (`t=,v1=`、HMAC-SHA256(`timestamp + "." + raw_body`))。delegate_payment は `Signature` + `Timestamp` パラメータ (detached JSON signature) + `Authorization: Bearer`。公開鍵 (JWK) 広告・正式な request signing は **deferred** |
| 公開鍵の配布 | `/.well-known/ucp` profile の `signing_keys[]` (EC JWK) | 現状なし (discovery 文書は鍵を含めない) |

→ `AgentCommerceMessageSigner` は EC/ECDSA (UCP) と HMAC (ACP) の双方を差し替え可能にし、UCP のテスト鍵は **EC P-256** を用いる。

### 9. 機能フラグ (BaseInfo、デフォルト OFF)

ACP / UCP の **checkout は日本では現時点で利用不可** (UCP Sandbox は米国・カナダ・豪の限定パイロット、ChatGPT Instant Checkout も国内では GA 前)。本番サイトで意図せず checkout が有効化されることを防ぐため、**checkout 有効化フラグを `BaseInfo` に追加し、デフォルトを OFF (`false`) とする**。

> **方針整理 (2026-06-09)**: フラグは **checkout の有無のみ**を制御する。**discovery (`/.well-known/ucp`) と catalog は公開して害がないため常時公開**とし、フラグでゲートしない。ACP feed push は認証情報 (base URL + API key) の有無で実質ガードされるため専用フラグを持たない。これにより当初の `acp_enabled` / `ucp_enabled` は **`acp_checkout_enabled` / `ucp_checkout_enabled` に改名**、`acp_feed_enabled` / `ucp_catalog_api_enabled` は **廃止**した。

| フィールド | 型 | デフォルト | 説明 |
|---|---|---|---|
| `acp_checkout_enabled` | boolean | **`false`** | ACP checkout (#6776) の有効化フラグ |
| `ucp_checkout_enabled` | boolean | **`false`** | UCP checkout (#6574) の有効化フラグ |
| `ucp_catalog_requires_auth` | boolean | **`false`** | UCP Catalog の OAuth 必須モード (api4 着手時に実装) |

- checkout 有効化フラグ自体は **共通基盤 (本 issue) で `BaseInfo` に定義・マイグレーション** し、デフォルト OFF を一元管理する (#6776 / #6574 で個別に追加しない)。管理画面の有効化トグルは **店舗設定 (`/admin/setting/shop` / `ShopMasterType`)** に置く。
- **Google Pay 等の決済ハンドラ固有設定 (`merchant_id` 等) は `BaseInfo` にカラム化しない** — 決済はプラグイン化方針 (Stripe 同様) のため、決済ハンドラプラグインが UCP discovery profile の `payment_handlers` に寄与する。プロトコル固有で `BaseInfo` カラム化が要る**非秘密の設定値**があれば #6574 / #6776 側で追加する (現状は無し)。**プライバシーポリシー / 利用規約 URL は `BaseInfo` カラムにせず、EC-CUBE 標準ページ (`help_privacy` / `help_agreement`) から絶対 URL を自動生成**して discovery に反映する (`RequestContext` 由来・`app/Customize` 差し替え可)。**署名用の秘密鍵・共有シークレットは `BaseInfo` カラムにせず**、共通の鍵保管ディレクトリ `app/keystore//` に保管する (環境変数でパス上書き=Secrets Manager / Key Vault 連携可、既定ファイルは有効化時自動生成・perm 600)。鍵保管ディレクトリの新設・保護・`KeyProvider` 抽象は **#6797** で共通化 (MCP サーバ #6796 等も共用)。
- マイグレーションのカラム `default` を `false` とし、**新規インストール・既存環境のアップグレード双方で OFF** になること。

#### 多段ガード (すべて満たした場合のみ有効)

1. `BaseInfo.acp_checkout_enabled` / `ucp_checkout_enabled` が `true` (管理者が明示的に ON。discovery / catalog は常にこのガード対象外で公開)
2. eccube-api4 がインストール済み (未導入なら 503)
3. ルーティングを ConfigurableExtension で条件登録し、無効時はルート未登録 = 404

#### 管理画面での注意喚起

設定 UI に「ACP / UCP は日本では未提供のため、有効化は検証目的に限る」旨を明記し、誤操作による本番公開を抑止する。

### 10. 設置形態と discovery 配信 (RFC 8615 制約)

capability discovery 文書 (UCP `/.well-known/ucp`、ACP `/.well-known/acp.json`) は **RFC 8615 によりオリジン (scheme+host+port) のルートに固定**され、**1 オリジンにつき 1 枚**しか存在できない。

| 設置形態 | discovery の可否 | 対応方針 |
|---|---|---|
| **ルートドメイン設置** (`example.com/`) | ◎ そのまま配信可 | 本体ルーティングで `/.well-known/*` を配信 |
| **サブディレクトリ設置** (`example.com/任意パス/`) | △ 本体だけでは不可 | ルートは本体管轄外。Web サーバ alias/rewrite が必要。**手順をドキュメント化+管理画面で警告** |
| **1 オリジン複数ショップ** (`example.com/shopA`, `/shopB`) | ✗ 標準 discovery 不成立 | well-known は 1 枚のみ・パス成分なし。**ショップ区別はホスト名でのみ可能**。各ショップに別オリジン (サブドメイン) を割り当てるのが唯一の解 |

#### 設計指針

- **推奨デプロイ: 1 ショップ = 1 オリジン** (サブドメイン)。
- discovery 文書内で本体/API の位置を指す: UCP は `services[].endpoint` / `capabilities[].endpoint`、ACP は `api_base_url`。これらは **`RequestContext` / 設定サイト URL から動的生成**し、設置パスをハードコードしない。
- **確定方針: 自動でルートへファイル生成・自動無効化はしない**。本体は設置パス配下の well-known コントローラを提供するに留め、ルート配信が必要なケースは「Web サーバ設定 + ドキュメント + 管理画面警告」で対応する。
- 管理画面の警告は「サブディレクトリ設置」と「同一オリジン複数ショップ」の双方を判定して告知する。
- **discovery 配信基盤はプロトコル非依存に設計する** (`DiscoveryDocumentBuilder` 等)。well-known ルーティング・`api_base_url`/`endpoint` の `RequestContext` 動的生成・capability/transport 宣言モデル・設置形態判定を共通化し、UCP `/.well-known/ucp` を最初の具象、ACP `/.well-known/acp.json` をスロットイン可能にする (#6794 で UCP と並行して抽象を整備)。
- **ACP `acp.json` の実出力はゲートする**: `rfc.discovery.md` が **Status: Proposal / unreleased** であり、かつ宣言する capability は **checkout** (Non-Goal: 商品/カタログ discovery)。(1) RFC released 化 + (2) ACP checkout (#6776) 実装済 + `acp_checkout_enabled=true` を満たすまで builder のみ整備し**出力しない** (動かない capability を広告しない / unreleased スキーマへの作り込みを避ける)。UCP の `/.well-known/ucp` (確定) とは扱いを分ける。

---

## 配送モデルのマッピング仕様

EC-CUBE の配送モデル (`Shipping`, `Delivery`, `DeliveryFee`, `Payment`, `PaymentOption`, `DeliveryTime`) と ACP / UCP の `fulfillment_*` モデルは前提が異なるため、エージェント経由のチェックアウトは **意図的な機能制限** を設けて単純化する。通常導線 (ショッピング画面経由) は従来通り全機能利用可能。

### Phase 1 機能制限 (エージェント経由のみ)

| EC-CUBE 機能 | エージェント経由 | 通常画面 | 理由 |
|---|---|---|---|
| 複数お届け先 (1 注文で複数 `Shipping`) | **非対応** (1 CheckoutSession = 1 `Shipping`) | 従来通り | ACP/UCP は 1 セッション 1 配送先が基本 |
| 時間帯指定 (`DeliveryTime`) | **非対応** | 従来通り | ACP/UCP に対応フィールドなし |
| お届け希望日 | **非対応** | 従来通り | `delivery_estimate_min_days/max_days` のみ |
| 着日不可日チェック | **対象外** | — | EC-CUBE 標準未提供 |
| 商品温度帯による配送業者フィルタ | **対象外** | — | EC-CUBE 標準未提供 |

### 共通基盤に置く変換責務

`AgentCheckoutPurchaseFlowAdapter` および新規 `FulfillmentOptionMapperInterface` に以下を集約:

1. **配送先住所の解決**: ACP/UCP の `fulfillment_address` → `AddressMappingService` 経由で `Shipping` を 1 件作成
2. **`fulfillment_options[]` 生成**:
- `Shipping.Pref` から利用可能な `Delivery` を列挙
- 各 `Delivery` について `(Delivery, Pref)` で `DeliveryFee` を引き当て、行が無い配送業者は除外
- **配送日数**: 注文明細の `ProductClass` が参照する `DeliveryDuration.getDuration()` の**最大値**から `delivery_estimate_min_days/max_days` を算出 (`ProductClass.delivery_duration` は `DeliveryDuration` への関連であり日数そのものではない点に注意)
- 結果を ACP/UCP の `fulfillment_options[]` に展開 (時間帯展開は行わない)
3. **`selected_fulfillment_option` の処理**: エージェントが選択した option ID を `Delivery` に解決し `Shipping.Delivery` に設定
4. **代引手数料の分離**: EC-CUBE の `Delivery` に手数料カラムは**存在しない**。代引手数料は **`Payment.charge`** であり、`Delivery` ↔ `Payment` は **`PaymentOption` (`delivery_id` × `payment_id`)** で関連付く。選択された `Delivery` に紐づく代引 `Payment` を `PaymentOption` 経由で解決し、その `Payment.charge` を ACP/UCP それぞれの `totals` の手数料項目に分離する (UCP v2026-04-08 は `type: "fee"` の `lines[]` に表示)
5. **送料の真実は内部 `DeliveryFee` 表**: `PurchaseFlow` が `Cart` 再構築時に再計算し、エージェント送信値と乖離した場合はビジネスロジックエラー (`messages[].type: error`、HTTP 200) として返す

### `FulfillmentOptionMapperInterface` (新規)

`app/Customize/` で差し替え可能にすることで、サイト固有の配送業者選定ロジック (離島追加送料、独自配送オプション等) を注入できる。標準実装は上記 1〜5 のロジックを提供する。

### 別 issue 化候補 (Phase 1 スコープ外)

- **複数お届け先 (multi-shipping)** / **時間帯指定 / お届け希望日**: UCP Embedded Protocol や ACP 将来仕様の動向を観測し、必要になれば別 issue で対応

---

## スコープ外

- ACP 固有のエンドポイント・データモデル → **#6776**
- UCP 固有のエンドポイント・データモデル → **#6574**
- Product Feed / Catalog 配信 → **#6794**
- MCP transport → 別タスク (進行中)
- eccube-api4 本体の機能拡張 (scope プリセット、管理画面拡張、GraphQL 拡張) → eccube-api4 リポジトリの別 issue

## 進め方

1. 本 issue (#6777) を先行マージ
2. 並行して eccube-api4 側の scope 拡張 issue を起票
3. #6776 (ACP) と #6574 (UCP) は本 issue + eccube-api4 拡張を前提に個別仕様を実装

## 技術的考慮事項

1. **拡張性**: 将来 Anthropic / Google が独自プロトコルを出した場合に備え、`protocol` カラムは enum 固定ではなく文字列で柔軟に
2. **app/Customize での拡張**: マッピングは Service 経由で差し替え可能に (numeric→alpha-2 は `mtb_country_iso_code` マスタの編集 or リポジトリ差し替え、配送業者選定、独自属性注入)
3. **マイグレーション**: `CheckoutSession` テーブル + `Order` への 2 カラム追加 + `BaseInfo` への `acp_checkout_enabled` / `ucp_checkout_enabled` / `ucp_catalog_requires_auth` カラム追加 (いずれも `default false`)。加えて **`mtb_checkout_session_status` マスタへ `requires_action` / `in_progress` の 2 行を追加** (CSV import は新規インストール時のみのため、既存環境へは INSERT データ migration で backfill)。`mtb_*` は固定スキーマのため列追加はなし
6. **在庫確保期限の設定値**: `app/config/eccube/packages/eccube.yaml` に parameter `eccube_agent_checkout_escalation_expire` (分単位・既定 15) を追加。`requires_action` / `in_progress` 中の在庫引当を維持する上限。EMV-3DS タイムアウト (10 分) より大きい値とする
4. **後方互換**: 既存 `Order` に `agent_protocol` / `agent_id` を NULL 許容で追加するため、既存購入フローへの影響なし。`BaseInfo` フラグはデフォルト OFF
5. **eccube-api4 依存の扱い**: inbound エンドポイント (ACP/UCP checkout) のみ runtime/統合テストで前提依存。**ユニットテストでは要求しない** (Symfony `AccessTokenHandlerInterface` をスタブ)

## テスト計画

ロジック層が中心で、外部接続なしで大半が完結する。**GA 待ち不要**。

### Layer 1: 純粋ロジック (PHPUnit、外部依存なし)

| 対象 | 検証内容 |
|---|---|
| `AddressMappingService` | 姓名分離、**ISO 3166-1 numeric → alpha-2** 変換 (マスタ `mtb_country_iso_code` を `CountryIsoCodeRepository` 経由)、都道府県名 ↔ region、半角/全角混在、海外住所のフォールバック |
| `MinorUnitConverter` | JPY/USD/EUR/KRW/TWD/GBP の境界値、bcmath 精度、**負数 (UCP 割引)**、ゼロデシマル通貨判定 |
| `AgentCheckoutException` | エラーコード → ACP/UCP それぞれのエラーレスポンス形式 (HTTP / messages[]) への変換 |
| `AgentCommerceScopeRegistry` | scope 登録・引き出し・重複検出、protocol ↔ scope 照合 (`:`) |

### Layer 2: Doctrine 統合 (PHPUnit + テスト DB)

- `CheckoutSession` ライフサイクル: 作成 → 更新 → 完了 / **取消 (`canceled`)** / expired の状態遷移
- `Order.agent_protocol` / `agent_id` マイグレーションの up/down、**既存 `Order` への影響なし回帰**
- **`BaseInfo.acp_checkout_enabled` / `ucp_checkout_enabled` / `ucp_catalog_requires_auth` がデフォルト `false`** (新規/アップグレード双方)
- PostgreSQL / MySQL 双方

### Layer 3: PurchaseFlow 連携 + complete 状態機械 (PHPUnit)

- 外部 DTO → Cart 構築 → 税・送料・在庫再計算 → DTO へ写し戻し
- 在庫不足・販売停止・配送制限・決済不可の伝播 (messages[] への反映)
- `app/Customize` 経由のマッピング差し替え (Service decoration)
- **complete 状態機械** (`InMemory` 決済ハンドラスタブで `PaymentOutcome` を切り替え):
- ① frictionless: `authorize`→COMPLETED→`capture`→commit→`completed`
- ② challenge: `authorize`→REQUIRES_ACTION (status=`requires_action`・Order 未確定・在庫は保持) →(再開) `authorize`→COMPLETED→`capture`→commit→`completed`
- ③ declined: `authorize`→FAILED→`rollback` で在庫が戻る・severity による遷移先 (`ready` / `canceled`)
- ④ 冪等性: `completed` セッションの再 complete で採番・在庫が**二重実行されない** (ACP MUST)
- ⑤ 非同期: PENDING→`in_progress`→IPN/Webhook 受信で `complete()` 再開→`completed`
- ⑥ 期限切れ回収: `requires_action` の `expires_at` 超過で `findExpired` → `rollback` され在庫が戻り `expired` 化

### Layer 4: OAuth 2.0 認証

- 有効/無効/scope 不足/期限切れ → 200/401/403
- `acp:checkout` で UCP エンドポイントを叩いて 403 (capability boundary)
- **eccube-api4 未インストール時 503** (handler スタブで検証可能。eccube-api4 不要)
- 実トークン発行→検証の E2E は **eccube-api4 導入時のみの専用ジョブ**で実施 (未導入は skip)

### Layer 5: Signing 抽象 (PHPUnit)

- ラウンドトリップ、鍵ローテーション (grace period)、不正署名 reject
- **UCP は EC P-256 JWK (RFC 9421)**、**ACP は HMAC (`Merchant-Signature`) / detached JSON signature** の双方を網羅。テスト鍵は `tests/fixtures/keys/` (UCP=EC P-256, ACP=HMAC 共有シークレット)

### CI 統合

- PHP 8.1/8.2/8.3 × PostgreSQL/MySQL マトリクス
- PHPStan level 5 (4.4 基準) 通過必須
- eccube-api4 を要する Layer 4 統合は専用ジョブに分離

---

## 追記 (2026-06-11): 実装からの確定事項 (Phase 1b・PR #6825)

CheckoutSession 中核を **PR #6825**(draft)で実装。実コード調査と一次仕様・参照実装の突合で以下を確定。

### 区分値はマスタテーブル化(文字列保存しない)
- `CheckoutSession.status` / `protocol`、`Order.agent_protocol` は **文字列カラムではなく、マスタ + INTEGER ManyToOne** にする(`Order`↔`OrderStatus` と同じ EC-CUBE 慣習)。
- 新規マスタ: **`mtb_checkout_session_status`**(`incomplete`/`ready`/`completed`/`canceled`/`expired`)、**`mtb_agent_protocol`**(`acp`/`ucp`)。protocol は `CheckoutSession` と `Order` 双方で参照するため特にマスタ必須。
- マスタ追加は entity + repo + import_csv(ja/en) + definition.yml 登録 + 既存環境向け INSERT migration。

### エージェント所有 Cart の Web 隔離
- CheckoutSession は `Cart`/`CartItem` を再利用するが、`Cart.agent_owned`(bool, default false)で **Web ストアフロント(`CartService::getPersistedCarts`/`getSessionCarts`)の解決から除外**。会員帰属は Order 側に持たせ、エージェント Cart の `customer_id` は常に NULL に保つ(ログイン会員の Web カート混入を防止)。
- Web 側の Cart 再生成への「追従」は侵襲的・脆いため採らず、隔離方式とする。

### インバウンド認証はプロトコルで異なる(core は両対応の seam)
- **ACP** = merchant 発行 Bearer / OAuth2(eccube-api4 [#188](https://github.com/EC-CUBE/eccube-api4/issues/188))。`AgentCommerceOAuth2Authenticator`(Symfony `AccessTokenHandlerInterface` 経由・api4 具象非依存・未導入時 503)。
- **UCP** = **RFC 9421 HTTP Message Signatures + `UCP-Agent` ヘッダが標準で、OAuth2/api4 非依存**(一次仕様 `checkout-rest.md:1252,1359-1367` / `signatures.md` で裏取り)。→ UCP は署名検証ゲート(#6574)。
- 会員 ID 連携(`ucp:identity` 等)は eccube-api4 [#189](https://github.com/EC-CUBE/eccube-api4/issues/189) 待ち。Phase 1b はゲスト購入を基準線(`CustomerResolverInterface` 標準実装は null)。

### PurchaseFlow 再利用
- 新規フローを作らず既存 `eccube.purchase.flow.shopping` を再利用(税/送料/代引手数料/在庫/受注番号を共有)。在庫超過等のビジネス結果は例外でなく `messages[]`(HTTP 200 系統)に反映。

---

## 追記 (2026-06-17): complete を「中断→再開」状態機械として再設計

ACP/UCP 一次仕様 (ACP `2026-04-17` / UCP `v2026-04-08`) と参照実装の精読により、**追加認証 (EMV-3DS / escalation) はエラーでなく `complete` が返す正常な中間状態**であることを確認した。当初の「単一リクエストで prepare→決済→commit を同期完結」という前提は誤りで、`complete` は状態に応じて複数回呼ばれる状態機械として設計する。本 issue の §1 (正規化ステータス) / §6 (エラーモデル) / §7 (PaymentHandler) / テスト計画 Layer 3 を上記の通り改訂した。要点:

- **正規化ステータスに `requires_action` / `in_progress` を追加** (§1)。`requires_escalation`(UCP) / `authentication_required`(ACP) は「在庫を保持して再開を待つ」点で同一のため `requires_action` に正規化し、差異は `payment_data`/`metadata` で表現。
- **決済ハンドラの戻り値を `void` → `PaymentOutcome` (COMPLETED/REQUIRES_ACTION/PENDING/FAILED) に改訂** (§7-2)。`void` では「追加認証が必要・continue_url/metadata・pending」を表現できず Stripe 等が EMV-3DS を実装できないため。
- **`AgentCheckoutCompletionService` (complete 状態機械オーケストレータ) を新設** (§7-3)。protocol controller が委譲し、状態遷移・トランザクション境界・在庫引当の保持/回収を core に集約。トランザクション境界は「各ステップ独立」(初回 complete で引当を commit し REQUIRES_ACTION でもリクエスト跨ぎで永続、再開は別トランザクション)。
- **`AgentCheckoutPurchaseFlowAdapter` の prepare/commit/rollback 分割 (PR #6825) は本状態機械を支える正しい土台のため維持。**
- **確定した設計判断** (ユーザー確認済): ①再開 FAILED = 常に rollback で在庫解放 + severity 分岐 (recoverable/requires_buyer_* → `ready` / unrecoverable → `canceled`)。②非同期 (PENDING) = IPN/Webhook で `complete()` 再開経路に集約 (`sample-payment-plugin::receiveComplete` と同型・GET ポーリングは不採用)。③ACP `authentication_result` 欠落の 400 `requires_3ds` はプロトコル層 (#6776/#6574) 専管 (共通エラーコードに追加しない)。④在庫確保期限は `eccube.yaml` の `eccube_agent_checkout_escalation_expire` (分単位・既定 15、EMV-3DS タイムアウト 10 分より大)。
- **波及 (本 issue 範囲外・前提整理)**: protocol mapper が core `requires_action`+actionData → UCP `requires_escalation`+`continue_url` / ACP `authentication_required`+`authentication_metadata` へ、core `in_progress` → `complete_in_progress` へ変換 (#6776/#6574)。Webhook 受信口 (署名検証付き) も protocol 層。

---

## 追記 (2026-06-17): Idempotency を DB 一意制約ベースの共通基盤として実装

ACP/UCP 共通の Idempotency-Key 処理を、**プロトコル非依存の共通基盤 (本 issue / PR #6825)** として DB の一意制約方式で実装した。当初の Symfony Lock + cache 方式は、ロックストア (SemaphoreStore/FlockStore) も cache (filesystem) も**ノードローカル**で、マルチインスタンス (AWS 等) では越境・並行の二重実行を防げないため作り替えた。

- **`Entity\AgentCheckoutIdempotency`** (`dtb_agent_checkout_idempotency`・**`unique(idempotency_key, subject)`**) + Repository + DB ベース `AgentCheckoutIdempotencyStore`。
- 予約 INSERT を一意制約で直列化し、並行時は「処理中=409 / 完了=リプレイ / 異パラメータ=409」を判定。compute 失敗時は予約削除で再試行可能に。**Symfony Lock は廃止** (ロック TTL 問題も解消)。
- `subject` に認証済みエージェント (UCP-Agent profile 等) を保持し、別主体による越境リプレイを DB レベルで防ぐ。
- **単一の共有 DB だけで動作**し、Redis 等の共有キャッシュや分散ロックに非依存 (共有レンサバ〜マルチインスタンスの全形態で正しく直列化)。テーブルは空始動のため schema:update 方式。
- ACP (#6776) / UCP (#6574) の checkout はこの共通 store を consume する (Idempotency-Key 必須要件は各プロトコル層で適用)。

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.