diff --git a/.gitignore b/.gitignore index 52dcccb2915..8338e814354 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ node_modules !/app/Plugin/.gitkeep /app/PluginData/* !/app/PluginData/.gitkeep +/app/keystore/* +!/app/keystore/.gitkeep +!/app/keystore/.htaccess /app/template/* !/app/template/admin !/app/template/default diff --git a/.htaccess b/.htaccess index 4439047aca8..015024cf20f 100644 --- a/.htaccess +++ b/.htaccess @@ -12,7 +12,7 @@ DirectoryIndex index.php index.html .ht Allow from all - + order allow,deny deny from all diff --git a/app/DoctrineMigrations/Version20260604120000.php b/app/DoctrineMigrations/Version20260604120000.php new file mode 100644 index 00000000000..74274ee06bb --- /dev/null +++ b/app/DoctrineMigrations/Version20260604120000.php @@ -0,0 +1,55 @@ + alpha-2) の初期データを投入する. + * + * テーブル自体は doctrine:schema:update でエンティティから生成される。 + * 新規インストールは import_csv (definition.yml) で投入されるため、 + * 本マイグレーションは既存インストールのアップグレード時の backfill を担う + * (空テーブルのときのみ INSERT する冪等実装)。 + */ +final class Version20260604120000 extends AbstractMigration +{ + public const NAME = 'mtb_country_iso_code'; + + public function up(Schema $schema): void + { + if (!$schema->hasTable(self::NAME)) { + return; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.self::NAME); + if ($count > 0) { + return; + } + + $this->addSql("INSERT INTO mtb_country_iso_code (id, name, sort_no, discriminator_type) VALUES (352, 'IS', 1, 'countryisocode'), (372, 'IE', 2, 'countryisocode'), (31, 'AZ', 3, 'countryisocode'), (4, 'AF', 4, 'countryisocode'), (840, 'US', 5, 'countryisocode'), (850, 'VI', 6, 'countryisocode'), (16, 'AS', 7, 'countryisocode'), (784, 'AE', 8, 'countryisocode'), (12, 'DZ', 9, 'countryisocode'), (32, 'AR', 10, 'countryisocode'), (533, 'AW', 11, 'countryisocode'), (8, 'AL', 12, 'countryisocode'), (51, 'AM', 13, 'countryisocode'), (660, 'AI', 14, 'countryisocode'), (24, 'AO', 15, 'countryisocode'), (28, 'AG', 16, 'countryisocode'), (20, 'AD', 17, 'countryisocode'), (887, 'YE', 18, 'countryisocode'), (826, 'GB', 19, 'countryisocode'), (86, 'IO', 20, 'countryisocode'), (92, 'VG', 21, 'countryisocode'), (376, 'IL', 22, 'countryisocode'), (380, 'IT', 23, 'countryisocode'), (368, 'IQ', 24, 'countryisocode'), (364, 'IR', 25, 'countryisocode'), (356, 'IN', 26, 'countryisocode'), (360, 'ID', 27, 'countryisocode'), (876, 'WF', 28, 'countryisocode'), (800, 'UG', 29, 'countryisocode'), (804, 'UA', 30, 'countryisocode'), (860, 'UZ', 31, 'countryisocode'), (858, 'UY', 32, 'countryisocode'), (218, 'EC', 33, 'countryisocode'), (818, 'EG', 34, 'countryisocode'), (233, 'EE', 35, 'countryisocode'), (231, 'ET', 36, 'countryisocode'), (232, 'ER', 37, 'countryisocode'), (222, 'SV', 38, 'countryisocode'), (36, 'AU', 39, 'countryisocode'), (40, 'AT', 40, 'countryisocode'), (248, 'AX', 41, 'countryisocode'), (512, 'OM', 42, 'countryisocode'), (528, 'NL', 43, 'countryisocode'), (288, 'GH', 44, 'countryisocode'), (132, 'CV', 45, 'countryisocode'), (831, 'GG', 46, 'countryisocode'), (328, 'GY', 47, 'countryisocode'), (398, 'KZ', 48, 'countryisocode'), (634, 'QA', 49, 'countryisocode'), (581, 'UM', 50, 'countryisocode'), (124, 'CA', 51, 'countryisocode'), (266, 'GA', 52, 'countryisocode'), (120, 'CM', 53, 'countryisocode'), (270, 'GM', 54, 'countryisocode'), (116, 'KH', 55, 'countryisocode'), (580, 'MP', 56, 'countryisocode'), (324, 'GN', 57, 'countryisocode'), (624, 'GW', 58, 'countryisocode'), (196, 'CY', 59, 'countryisocode'), (192, 'CU', 60, 'countryisocode'), (531, 'CW', 61, 'countryisocode'), (300, 'GR', 62, 'countryisocode'), (296, 'KI', 63, 'countryisocode'), (417, 'KG', 64, 'countryisocode'), (320, 'GT', 65, 'countryisocode'), (312, 'GP', 66, 'countryisocode'), (316, 'GU', 67, 'countryisocode'), (414, 'KW', 68, 'countryisocode'), (184, 'CK', 69, 'countryisocode'), (304, 'GL', 70, 'countryisocode'), (162, 'CX', 71, 'countryisocode'), (268, 'GE', 72, 'countryisocode'), (308, 'GD', 73, 'countryisocode'), (191, 'HR', 74, 'countryisocode'), (136, 'KY', 75, 'countryisocode'), (404, 'KE', 76, 'countryisocode'), (384, 'CI', 77, 'countryisocode'), (166, 'CC', 78, 'countryisocode'), (188, 'CR', 79, 'countryisocode'), (174, 'KM', 80, 'countryisocode'), (170, 'CO', 81, 'countryisocode'), (178, 'CG', 82, 'countryisocode'), (180, 'CD', 83, 'countryisocode'), (682, 'SA', 84, 'countryisocode'), (239, 'GS', 85, 'countryisocode'), (882, 'WS', 86, 'countryisocode'), (678, 'ST', 87, 'countryisocode'), (652, 'BL', 88, 'countryisocode'), (894, 'ZM', 89, 'countryisocode'), (666, 'PM', 90, 'countryisocode'), (674, 'SM', 91, 'countryisocode'), (663, 'MF', 92, 'countryisocode'), (694, 'SL', 93, 'countryisocode'), (262, 'DJ', 94, 'countryisocode'), (292, 'GI', 95, 'countryisocode'), (832, 'JE', 96, 'countryisocode'), (388, 'JM', 97, 'countryisocode'), (760, 'SY', 98, 'countryisocode'), (702, 'SG', 99, 'countryisocode'), (534, 'SX', 100, 'countryisocode'), (716, 'ZW', 101, 'countryisocode'), (756, 'CH', 102, 'countryisocode'), (752, 'SE', 103, 'countryisocode'), (729, 'SD', 104, 'countryisocode'), (744, 'SJ', 105, 'countryisocode'), (724, 'ES', 106, 'countryisocode'), (740, 'SR', 107, 'countryisocode'), (144, 'LK', 108, 'countryisocode'), (703, 'SK', 109, 'countryisocode'), (705, 'SI', 110, 'countryisocode'), (748, 'SZ', 111, 'countryisocode'), (690, 'SC', 112, 'countryisocode'), (226, 'GQ', 113, 'countryisocode'), (686, 'SN', 114, 'countryisocode'), (688, 'RS', 115, 'countryisocode'), (659, 'KN', 116, 'countryisocode'), (670, 'VC', 117, 'countryisocode'), (426, 'LS', 118, 'countryisocode'), (654, 'SH', 119, 'countryisocode'), (662, 'LC', 120, 'countryisocode'), (706, 'SO', 121, 'countryisocode'), (90, 'SB', 122, 'countryisocode'), (796, 'TC', 123, 'countryisocode'), (764, 'TH', 124, 'countryisocode'), (410, 'KR', 125, 'countryisocode'), (158, 'TW', 126, 'countryisocode'), (762, 'TJ', 127, 'countryisocode'), (834, 'TZ', 128, 'countryisocode'), (203, 'CZ', 129, 'countryisocode'), (148, 'TD', 130, 'countryisocode'), (140, 'CF', 131, 'countryisocode'), (156, 'CN', 132, 'countryisocode'), (788, 'TN', 133, 'countryisocode'), (408, 'KP', 134, 'countryisocode'), (152, 'CL', 135, 'countryisocode'), (798, 'TV', 136, 'countryisocode'), (208, 'DK', 137, 'countryisocode'), (276, 'DE', 138, 'countryisocode'), (768, 'TG', 139, 'countryisocode'), (772, 'TK', 140, 'countryisocode'), (214, 'DO', 141, 'countryisocode'), (212, 'DM', 142, 'countryisocode'), (780, 'TT', 143, 'countryisocode'), (795, 'TM', 144, 'countryisocode'), (792, 'TR', 145, 'countryisocode'), (776, 'TO', 146, 'countryisocode'), (566, 'NG', 147, 'countryisocode'), (520, 'NR', 148, 'countryisocode'), (516, 'NA', 149, 'countryisocode'), (10, 'AQ', 150, 'countryisocode'), (570, 'NU', 151, 'countryisocode'), (558, 'NI', 152, 'countryisocode'), (562, 'NE', 153, 'countryisocode'), (392, 'JP', 154, 'countryisocode'), (732, 'EH', 155, 'countryisocode'), (540, 'NC', 156, 'countryisocode'), (554, 'NZ', 157, 'countryisocode'), (524, 'NP', 158, 'countryisocode'), (574, 'NF', 159, 'countryisocode'), (578, 'NO', 160, 'countryisocode'), (334, 'HM', 161, 'countryisocode'), (48, 'BH', 162, 'countryisocode'), (332, 'HT', 163, 'countryisocode'), (586, 'PK', 164, 'countryisocode'), (336, 'VA', 165, 'countryisocode'), (591, 'PA', 166, 'countryisocode'), (548, 'VU', 167, 'countryisocode'), (44, 'BS', 168, 'countryisocode'), (598, 'PG', 169, 'countryisocode'), (60, 'BM', 170, 'countryisocode'), (585, 'PW', 171, 'countryisocode'), (600, 'PY', 172, 'countryisocode'), (52, 'BB', 173, 'countryisocode'), (275, 'PS', 174, 'countryisocode'), (348, 'HU', 175, 'countryisocode'), (50, 'BD', 176, 'countryisocode'), (626, 'TL', 177, 'countryisocode'), (612, 'PN', 178, 'countryisocode'), (242, 'FJ', 179, 'countryisocode'), (608, 'PH', 180, 'countryisocode'), (246, 'FI', 181, 'countryisocode'), (64, 'BT', 182, 'countryisocode'), (74, 'BV', 183, 'countryisocode'), (630, 'PR', 184, 'countryisocode'), (234, 'FO', 185, 'countryisocode'), (238, 'FK', 186, 'countryisocode'), (76, 'BR', 187, 'countryisocode'), (250, 'FR', 188, 'countryisocode'), (254, 'GF', 189, 'countryisocode'), (258, 'PF', 190, 'countryisocode'), (260, 'TF', 191, 'countryisocode'), (100, 'BG', 192, 'countryisocode'), (854, 'BF', 193, 'countryisocode'), (96, 'BN', 194, 'countryisocode'), (108, 'BI', 195, 'countryisocode'), (704, 'VN', 196, 'countryisocode'), (204, 'BJ', 197, 'countryisocode'), (862, 'VE', 198, 'countryisocode'), (112, 'BY', 199, 'countryisocode'), (84, 'BZ', 200, 'countryisocode'), (604, 'PE', 201, 'countryisocode'), (56, 'BE', 202, 'countryisocode'), (616, 'PL', 203, 'countryisocode'), (70, 'BA', 204, 'countryisocode'), (72, 'BW', 205, 'countryisocode'), (535, 'BQ', 206, 'countryisocode'), (68, 'BO', 207, 'countryisocode'), (620, 'PT', 208, 'countryisocode'), (344, 'HK', 209, 'countryisocode'), (340, 'HN', 210, 'countryisocode'), (584, 'MH', 211, 'countryisocode'), (446, 'MO', 212, 'countryisocode'), (807, 'MK', 213, 'countryisocode'), (450, 'MG', 214, 'countryisocode'), (175, 'YT', 215, 'countryisocode'), (454, 'MW', 216, 'countryisocode'), (466, 'ML', 217, 'countryisocode'), (470, 'MT', 218, 'countryisocode'), (474, 'MQ', 219, 'countryisocode'), (458, 'MY', 220, 'countryisocode'), (833, 'IM', 221, 'countryisocode'), (583, 'FM', 222, 'countryisocode'), (710, 'ZA', 223, 'countryisocode'), (728, 'SS', 224, 'countryisocode'), (104, 'MM', 225, 'countryisocode'), (484, 'MX', 226, 'countryisocode'), (480, 'MU', 227, 'countryisocode'), (478, 'MR', 228, 'countryisocode'), (508, 'MZ', 229, 'countryisocode'), (492, 'MC', 230, 'countryisocode'), (462, 'MV', 231, 'countryisocode'), (498, 'MD', 232, 'countryisocode'), (504, 'MA', 233, 'countryisocode'), (496, 'MN', 234, 'countryisocode'), (499, 'ME', 235, 'countryisocode'), (500, 'MS', 236, 'countryisocode'), (400, 'JO', 237, 'countryisocode'), (418, 'LA', 238, 'countryisocode'), (428, 'LV', 239, 'countryisocode'), (440, 'LT', 240, 'countryisocode'), (434, 'LY', 241, 'countryisocode'), (438, 'LI', 242, 'countryisocode'), (430, 'LR', 243, 'countryisocode'), (642, 'RO', 244, 'countryisocode'), (442, 'LU', 245, 'countryisocode'), (646, 'RW', 246, 'countryisocode'), (422, 'LB', 247, 'countryisocode'), (638, 'RE', 248, 'countryisocode'), (643, 'RU', 249, 'countryisocode')"); + } + + public function down(Schema $schema): void + { + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException(self::NAME.' はマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/DoctrineMigrations/Version20260611120000.php b/app/DoctrineMigrations/Version20260611120000.php new file mode 100644 index 00000000000..5f1d4269b0a --- /dev/null +++ b/app/DoctrineMigrations/Version20260611120000.php @@ -0,0 +1,63 @@ + + */ + private const INSERTS = [ + 'mtb_checkout_session_status' => "INSERT INTO mtb_checkout_session_status (id, name, sort_no, discriminator_type) VALUES (1, 'incomplete', 1, 'checkoutsessionstatus'), (2, 'ready', 2, 'checkoutsessionstatus'), (3, 'completed', 3, 'checkoutsessionstatus'), (4, 'canceled', 4, 'checkoutsessionstatus'), (5, 'expired', 5, 'checkoutsessionstatus')", + 'mtb_agent_protocol' => "INSERT INTO mtb_agent_protocol (id, name, sort_no, discriminator_type) VALUES (1, 'acp', 1, 'agentprotocol'), (2, 'ucp', 2, 'agentprotocol')", + ]; + + public function up(Schema $schema): void + { + foreach (self::INSERTS as $table => $sql) { + if (!$schema->hasTable($table)) { + continue; + } + + // 既にデータがある場合 (新規インストールの fixtures 投入済み等) は二重投入しない. + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM '.$table); + if ($count > 0) { + continue; + } + + $this->addSql($sql); + } + } + + public function down(Schema $schema): void + { + // 新規インストールでは up() が no-op (import_csv 投入済み) のため、 + // 一律 DELETE すると import_csv 由来の初期データまで巻き添えで消える。 + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない。 + $this->throwIrreversibleMigrationException('エージェントコマースの区分値マスタはマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/DoctrineMigrations/Version20260617120000.php b/app/DoctrineMigrations/Version20260617120000.php new file mode 100644 index 00000000000..892ae66f294 --- /dev/null +++ b/app/DoctrineMigrations/Version20260617120000.php @@ -0,0 +1,69 @@ + + */ + private const ROWS = [ + [6, 'requires_action'], + [7, 'in_progress'], + ]; + + public function up(Schema $schema): void + { + if (!$schema->hasTable('mtb_checkout_session_status')) { + return; + } + + foreach (self::ROWS as [$id, $name]) { + $exists = $this->connection->fetchOne( + 'SELECT COUNT(*) FROM mtb_checkout_session_status WHERE id = :id', + ['id' => $id] + ); + if ($exists > 0) { + continue; + } + + $this->addSql( + 'INSERT INTO mtb_checkout_session_status (id, name, sort_no, discriminator_type) VALUES (:id, :name, :sort_no, :discriminator_type)', + ['id' => $id, 'name' => $name, 'sort_no' => $id, 'discriminator_type' => 'checkoutsessionstatus'] + ); + } + } + + public function down(Schema $schema): void + { + // マスタ初期データは不可逆として扱い、ロールバックでは何もしない + // (一律 DELETE すると import_csv 由来の初期データまで巻き添えになるため)。 + $this->throwIrreversibleMigrationException('エージェントコマースの区分値マスタはマスタ初期データのため down は非対応です.'); + } +} diff --git a/app/config/eccube/packages/eccube.yaml b/app/config/eccube/packages/eccube.yaml index 0b4a38f45d5..956bd3d4595 100644 --- a/app/config/eccube/packages/eccube.yaml +++ b/app/config/eccube/packages/eccube.yaml @@ -83,6 +83,11 @@ parameters: eccube_default_page_count: 50 eccube_admin_product_stock_status: 3 eccube_customer_reset_expire: 10 + # エージェントチェックアウトの追加対応 (3DS/escalation) / 非同期決済の待機中に在庫を確保する期限 (分). + # この間 prepare で引き当てた在庫は他購入者に対し確保されたまま保持される。超過すると + # 期限切れ回収 (CheckoutSessionRepository::findExpired) で rollback され在庫が戻る。 + # EMV-3DS のタイムアウト (10 分) より大きい値とすること。 + eccube_agent_checkout_escalation_expire: 15 # CSVの区切り文字(タブ区切りにしたい場合は'\t'ではなく' 'で設定する eccube_csv_export_separator: , # 出力エンコーディング diff --git a/app/config/eccube/services.yaml b/app/config/eccube/services.yaml index ebdebaa9853..71f7c5e49cc 100644 --- a/app/config/eccube/services.yaml +++ b/app/config/eccube/services.yaml @@ -237,3 +237,41 @@ services: eccube.asset.user_data_version_strategy: alias: Eccube\Asset\FilemtimeVersionStrategy public: true + + # Agent Commerce (ACP / UCP) 共通基盤 + Eccube\Service\AgentCommerce\Security\FilesystemKeyStore: + arguments: + $projectDir: '%kernel.project_dir%' + $envPathOverrides: + ucp_signing: '%env(default::string:ECCUBE_AGENT_COMMERCE_UCP_SIGNING_KEY)%' + # ACP Webhook (Merchant-Signature) の共有シークレット保管先 (#6776、outbound 送出は後続 PR)。 + acp_webhook: '%env(default::string:ECCUBE_AGENT_COMMERCE_ACP_WEBHOOK_SECRET)%' + + Eccube\Service\AgentCommerce\Security\KeyStoreInterface: + alias: Eccube\Service\AgentCommerce\Security\FilesystemKeyStore + + Eccube\Service\AgentCommerce\Security\AgentCommerceMessageSignerInterface: + alias: Eccube\Service\AgentCommerce\Security\UcpMessageSigner + + # Agent Commerce CheckoutSession 中核 (#6777 Phase 1b) + Eccube\Service\AgentCommerce\Fulfillment\FulfillmentOptionMapperInterface: + alias: Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper + + # 標準はゲスト購入 (会員解決なし)。会員 ID 連携は eccube-api4#189 landing 後に差し替える。 + Eccube\Service\AgentCommerce\CheckoutSession\CustomerResolverInterface: + alias: Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver + + # OAuth2 トークン検証。リソースサーバー (eccube-api4#188) 未導入時は handler が null となり 503 を返す。 + Eccube\Service\AgentCommerce\Security\AgentCommerceOAuth2Authenticator: + arguments: + $accessTokenHandler: '@?Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface' + + # 決済ハンドラレジストリ。具象ハンドラ (決済プラグイン) は agent_commerce.payment_handler タグで寄与する。 + Eccube\Service\AgentCommerce\Payment\AgentCheckoutPaymentHandlerRegistry: + arguments: + $handlers: !tagged_iterator agent_commerce.payment_handler + + # complete 状態機械オーケストレータ。在庫確保期限は eccube.yaml の設定値を注入。 + Eccube\Service\AgentCommerce\CheckoutSession\AgentCheckoutCompletionService: + arguments: + $escalationExpireMinutes: '%eccube_agent_checkout_escalation_expire%' diff --git a/app/config/eccube/services_test.yaml b/app/config/eccube/services_test.yaml index ea2133e30e6..c04ab5b199f 100644 --- a/app/config/eccube/services_test.yaml +++ b/app/config/eccube/services_test.yaml @@ -77,3 +77,47 @@ services: Eccube\Service\Composer\ComposerApiService: autowire: true public: true + # AddressMappingService はまだ consumer が無く private では除去されるためテスト時のみ public 化 + Eccube\Service\AgentCommerce\AddressMappingService: + autowire: true + public: true + # CheckoutSession 中核 (#6777 Phase 1b) も consumer (checkout controller) が #6776/#6574 まで + # 無いため、テスト時のみ public 化してコンテナから取得する。 + Eccube\Service\AgentCommerce\AgentCheckoutPurchaseFlowAdapter: + autowire: true + public: true + # 明示定義により _defaults の $shoppingPurchaseFlow bind が外れるため、ここで再束縛する。 + arguments: + $shoppingPurchaseFlow: '@eccube.purchase.flow.shopping' + Eccube\Service\AgentCommerce\Fulfillment\StandardFulfillmentOptionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\CheckoutSession\GuestCustomerResolver: + autowire: true + public: true + # Idempotency ストア (conformance テストが直接 execute を検証するため public 化)。 + Eccube\Service\AgentCommerce\Idempotency\AgentCheckoutIdempotencyStore: + autowire: true + public: true + # ACP プロトコル層 (#6776)。consumer (controller) は存在するが、単体テストが直接取得するため public 化。 + Eccube\Service\AgentCommerce\Acp\AcpCheckoutSessionMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Acp\AcpMessageMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Acp\AcpStatusMapper: + autowire: true + public: true + Eccube\Service\AgentCommerce\Acp\AcpDiscoveryDocumentBuilder: + autowire: true + public: true + Eccube\Service\AgentCommerce\Acp\AcpMessageSigner: + autowire: true + public: true + # ACP の OAuth2 認証検証用。eccube-api4 非依存のスタブハンドラを AccessTokenHandlerInterface に束ねる。 + Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler: + public: true + Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface: + alias: Eccube\Tests\Service\AgentCommerce\Stub\InMemoryAccessTokenHandler + public: true diff --git a/app/keystore/.gitkeep b/app/keystore/.gitkeep new file mode 100644 index 00000000000..e69de29bb2d diff --git a/app/keystore/.htaccess b/app/keystore/.htaccess new file mode 100644 index 00000000000..baa56e5a369 --- /dev/null +++ b/app/keystore/.htaccess @@ -0,0 +1,2 @@ +order allow,deny +deny from all \ No newline at end of file diff --git a/src/Eccube/Controller/AgentCommerce/AcpCheckoutController.php b/src/Eccube/Controller/AgentCommerce/AcpCheckoutController.php new file mode 100644 index 00000000000..9ba1137154a --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/AcpCheckoutController.php @@ -0,0 +1,442 @@ + 201 + * - POST /acp/checkout_sessions/{id} (update) -> 200 + * - GET /acp/checkout_sessions/{id} (get) -> 200 + * - POST /acp/checkout_sessions/{id}/complete -> 200 (order) + * - POST /acp/checkout_sessions/{id}/cancel -> 200 (canceled) / 405 (確定済) + * + * 設計: + * - セッションレスなエージェント向けに {@link CheckoutSession} で Cart/Order を束ねる。 + * - 見積/確定は通常購入と同一の shopping flow を {@link AgentCheckoutPurchaseFlowAdapter} 経由で再利用。 + * - complete は「中断 → 再開」状態機械 ({@link AgentCheckoutCompletionService})。3DS は + * `authentication_required` という正常な中間状態として返る (エラーではない)。 + * - **2 系統エラー**: プロトコル系は HTTP 4xx/5xx + flat Error ({type, code, message, param?})、 + * ビジネス系は HTTP 200 + messages[]。 + * - インバウンド認証は OAuth2 Bearer (`acp:checkout` scope、{@link AcpAuthenticationSubscriber} が適用)。 + * - **Idempotency-Key は全 POST で必須** (欠落=400 / 異内容=422 / 処理中=409)。 + * - `acp_checkout_enabled` が false のときは NotFoundHttpException (ルートは削除しない既存パターン)。 + * + * @see https://github.com/EC-CUBE/ec-cube/issues/6776 + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP openapi.agentic_checkout.yaml (2026-04-17) + */ +class AcpCheckoutController extends AbstractController +{ + public function __construct( + private readonly BaseInfoRepository $baseInfoRepository, + private readonly CheckoutSessionRepository $checkoutSessionRepository, + private readonly AgentProtocolRepository $agentProtocolRepository, + private readonly CheckoutSessionStatusRepository $statusRepository, + private readonly AgentCheckoutPurchaseFlowAdapter $purchaseFlowAdapter, + private readonly AcpCheckoutSessionMapper $mapper, + private readonly AcpMessageMapper $messageMapper, + private readonly AgentCheckoutIdempotencyStore $idempotencyStore, + private readonly CustomerResolverInterface $customerResolver, + private readonly AgentCheckoutCompletionService $completionService, + ) { + } + + #[Route(path: '/acp/checkout_sessions', name: 'acp_checkout_create', methods: ['POST'])] + public function create(Request $request): JsonResponse + { + $this->assertEnabled(); + + return $this->withIdempotency($request, function () use ($request): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + $session = (new CheckoutSession()) + ->setSessionId($this->generateSessionId()) + ->setProtocol($this->agentProtocolRepository->find(AgentProtocol::ACP)) + ->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)) + ->setCurrencyCode($checkoutRequest->currencyCode) + ->setAgentId($checkoutRequest->agentId) + ->setMetadata(['line_items' => $this->lineItemsMetadata($checkoutRequest)]); + + return ['status' => 201, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(400, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/acp/checkout_sessions/{sessionId}', name: 'acp_checkout_update', methods: ['POST'])] + public function update(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($request, $sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + try { + $payload = $this->decodeBody($request); + $checkoutRequest = $this->mapper->toCheckoutRequest($payload, $this->resolveAgentId($request)); + + $session->setMetadata(array_merge($session->getMetadata() ?? [], ['line_items' => $this->lineItemsMetadata($checkoutRequest)])); + + return ['status' => 200, 'body' => $this->buildAndPersist($session, $checkoutRequest)]; + } catch (AgentCheckoutException $e) { + return $this->protocolError(400, $e->getErrorCode()->value, $e->getMessage()); + } + }); + } + + #[Route(path: '/acp/checkout_sessions/{sessionId}', name: 'acp_checkout_get', methods: ['GET'])] + public function get(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($request, $sessionId); + + $order = $session->getOrder(); + if ($order !== null) { + return new JsonResponse($this->mapper->buildResponseFromOrder($session, $order, []), Response::HTTP_OK); + } + + return new JsonResponse($this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []), Response::HTTP_OK); + } + + #[Route(path: '/acp/checkout_sessions/{sessionId}/complete', name: 'acp_checkout_complete', methods: ['POST'])] + public function complete(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($request, $sessionId); + + return $this->withIdempotency($request, function () use ($request, $session): array { + $order = $session->getOrder(); + if ($order === null) { + return $this->protocolError(400, 'invalid_session_state', 'The checkout session is not ready for completion.'); + } + + try { + // decodeBody は不正 JSON で AgentCheckoutException を投げるため try 内に置き、 + // create/update と同じく 400 プロトコルエラーへ変換する (500 にしない)。 + $payload = $this->decodeBody($request); + $paymentData = is_array($payload['payment_data'] ?? null) ? $payload['payment_data'] : []; + + // 3DS: authentication_required 状態で authentication_result 無しの再開 complete は 400 requires_3ds。 + if ($this->isAuthenticationPending($session) && !isset($paymentData['authentication_result'])) { + return $this->protocolError(400, 'requires_3ds', 'This checkout session requires issuer authentication. The request must include "authentication_result".', '$.authentication_result'); + } + + // complete は状態機械 (#6777)。3DS/escalation はエラーでなく requires_action 等の中間状態として返る。 + $result = $this->completionService->complete($session, $paymentData); + } catch (AgentCheckoutException $e) { + return $this->protocolError(400, $e->getErrorCode()->value, $e->getMessage()); + } + + $acpMessages = $this->messageMapper->toAcpMessages($result->messages); + + return ['status' => 200, 'body' => $this->mapper->buildResponseFromOrder($session, $order, $acpMessages, $result->actionData)]; + }); + } + + #[Route(path: '/acp/checkout_sessions/{sessionId}/cancel', name: 'acp_checkout_cancel', methods: ['POST'])] + public function cancel(Request $request, string $sessionId): JsonResponse + { + $this->assertEnabled(); + $session = $this->findSession($request, $sessionId); + + return $this->withIdempotency($request, function () use ($session): array { + $statusId = $session->getStatus()?->getId(); + if (in_array($statusId, [CheckoutSessionStatus::COMPLETED, CheckoutSessionStatus::CANCELED], true)) { + // 既に完了/取消済のセッションは取消不可 (ACP: 405 Not Allowed)。 + return $this->protocolError(405, 'not_allowed', 'A completed or canceled checkout session cannot be canceled.'); + } + + $session->setStatus($this->findStatus(CheckoutSessionStatus::CANCELED)); + $this->entityManager->flush(); + + $order = $session->getOrder(); + $body = $order !== null + ? $this->mapper->buildResponseFromOrder($session, $order, []) + : $this->mapper->buildProvisionalResponse($session, $this->provisionalRequestFromSession($session), []); + + return ['status' => 200, 'body' => $body]; + }); + } + + /** + * 中立リクエストからセッションを確定 (見積) し、レスポンスボディを返す. + * + * 住所未確定なら暫定見積 + not_ready_for_payment、住所ありなら shopping flow で再計算する。 + * + * @return array + */ + private function buildAndPersist(CheckoutSession $session, AgentCheckoutRequest $checkoutRequest): array + { + if ($checkoutRequest->buyer === null) { + $session->setStatus($this->findStatus(CheckoutSessionStatus::INCOMPLETE)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + $messages = $this->messageMapper->toAcpMessages([ + new AgentCheckoutMessage( + AgentCheckoutMessageLevel::ERROR, + 'Shipping address is required to calculate shipping and complete checkout.', + ), + ]); + + return $this->mapper->buildProvisionalResponse($session, $checkoutRequest, $messages); + } + + $result = $this->purchaseFlowAdapter->buildOrder($checkoutRequest, $this->customerResolver->resolve($session)); + $order = $result->order; + + $session + ->setOrder($order) + ->setBuyerData($this->buyerData($checkoutRequest->buyer)) + ->setStatus($this->findStatus($result->hasError() ? CheckoutSessionStatus::INCOMPLETE : CheckoutSessionStatus::READY)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + return $this->mapper->buildResponseFromOrder($session, $order, $this->messageMapper->toAcpMessages($result->messages)); + } + + /** + * Idempotency-Key を考慮してハンドラを実行し JsonResponse を返す. + * + * ACP は全 POST で Idempotency-Key が必須 (欠落=400)。同一キーの異内容=422、処理中=409。 + * + * @param callable(): array{status: int, body: array} $handler + */ + private function withIdempotency(Request $request, callable $handler): JsonResponse + { + $key = $request->headers->get('Idempotency-Key'); + if ($key === null || $key === '') { + return new JsonResponse( + ['type' => 'invalid_request', 'code' => 'idempotency_key_required', 'message' => 'Idempotency-Key header is required on all POST requests.'], + Response::HTTP_BAD_REQUEST, + ); + } + + // 認証済みクライアント (AcpAuthenticationSubscriber が設定) を主体として名前空間化し、越境リプレイを防ぐ。 + $subject = $request->attributes->get('acp_client_id'); + $subject = is_string($subject) ? $subject : null; + $requestHash = $this->idempotencyStore->hashRequest([ + 'path' => $request->getPathInfo(), + 'method' => $request->getMethod(), + 'body' => $request->getContent(), + 'agent' => $subject, + ]); + + try { + $result = $this->idempotencyStore->execute($key, $requestHash, $handler, $subject); + } catch (IdempotencyConflictException $e) { + if ($e->isInFlight()) { + return new JsonResponse( + ['type' => 'invalid_request', 'code' => 'idempotency_in_flight', 'message' => 'A request with this Idempotency-Key is currently being processed.'], + Response::HTTP_CONFLICT, + ); + } + + return new JsonResponse( + ['type' => 'invalid_request', 'code' => 'idempotency_conflict', 'message' => 'Idempotency-Key has already been used with a different request body.'], + Response::HTTP_UNPROCESSABLE_ENTITY, + ); + } + + $response = new JsonResponse($result['body'], $result['status']); + if ($result['replayed']) { + // リプレイされたレスポンスである旨を広告する (SHOULD)。 + $response->headers->set('Idempotent-Replayed', 'true'); + } + + return $response; + } + + /** + * プロトコル系エラー (flat Error) を組み立てる. + * + * @return array{status: int, body: array} + */ + private function protocolError(int $status, string $code, string $message, ?string $param = null): array + { + $body = ['type' => 'invalid_request', 'code' => $code, 'message' => $message]; + if ($param !== null) { + $body['param'] = $param; + } + + return ['status' => $status, 'body' => $body]; + } + + private function assertEnabled(): void + { + if (!$this->baseInfoRepository->get()->isAcpCheckoutEnabled()) { + throw new NotFoundHttpException('ACP checkout is not enabled.'); + } + } + + private function findSession(Request $request, string $sessionId): CheckoutSession + { + $session = $this->checkoutSessionRepository->findOneBySessionId($sessionId); + if ($session === null + || $session->getProtocol()?->getId() !== AgentProtocol::ACP + || $session->getAgentId() !== $this->resolveAgentId($request) + ) { + // 他プロトコル/他マーチャント/他エージェントのセッションへの越境アクセスを遮断する + // (主体不一致は存在を秘匿して 404)。session_id は乱数で列挙不可だが、漏洩時の越境も防ぐ。 + throw new NotFoundHttpException(sprintf('Checkout session "%s" was not found.', $sessionId)); + } + + return $session; + } + + private function findStatus(int $id): CheckoutSessionStatus + { + /** @var CheckoutSessionStatus $status */ + $status = $this->statusRepository->find($id); + + return $status; + } + + /** + * セッションが追加認証 (3DS / authentication_required) の再開待ちか. + * + * REQUIRES_ACTION かつ metadata の payment_action が 3DS を示す場合に true。 + */ + private function isAuthenticationPending(CheckoutSession $session): bool + { + if ($session->getStatus()?->getId() !== CheckoutSessionStatus::REQUIRES_ACTION) { + return false; + } + + $action = $session->getMetadata()['payment_action'] ?? []; + if (!is_array($action)) { + return false; + } + + return ($action['intervention'] ?? null) === '3ds' + || ($action['type'] ?? null) === '3ds' + || ($action['authentication_required'] ?? false) === true + || isset($action['authentication_metadata']); + } + + /** + * @return array + */ + private function decodeBody(Request $request): array + { + $content = $request->getContent(); + if ($content === '') { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'Malformed JSON request body.', $e); + } + + if (!is_array($decoded)) { + return []; + } + + $normalized = []; + foreach ($decoded as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; + } + + private function generateSessionId(): string + { + return 'acp_cs_'.bin2hex(random_bytes(16)); + } + + private function resolveAgentId(Request $request): ?string + { + $clientId = $request->attributes->get('acp_client_id'); + + return is_string($clientId) ? $clientId : null; + } + + /** + * @return list + */ + private function lineItemsMetadata(AgentCheckoutRequest $checkoutRequest): array + { + return array_map( + static fn (AgentCheckoutLineItem $item): array => ['pc' => $item->productClassId, 'qty' => $item->quantity], + $checkoutRequest->lineItems + ); + } + + /** + * GET/cancel で Order 未生成のセッションを暫定表示するため、metadata から明細を復元する. + */ + private function provisionalRequestFromSession(CheckoutSession $session): AgentCheckoutRequest + { + $lineItems = []; + $stored = $session->getMetadata()['line_items'] ?? []; + if (is_array($stored)) { + foreach ($stored as $entry) { + if (is_array($entry) && isset($entry['pc'], $entry['qty'])) { + $lineItems[] = new AgentCheckoutLineItem((int) $entry['pc'], (int) $entry['qty']); + } + } + } + + return new AgentCheckoutRequest($lineItems, null, $session->getCurrencyCode(), AgentProtocol::ACP); + } + + /** + * 中立住所 DTO を buyer_data (echo 用) へ写す. + * + * @return array + */ + private function buyerData(AgentCheckoutAddress $address): array + { + return array_filter([ + 'family_name' => $address->name01, + 'given_name' => $address->name02, + 'email' => $address->email, + 'phone' => $address->phoneNumber, + ], static fn ($value): bool => $value !== null); + } +} diff --git a/src/Eccube/Controller/AgentCommerce/AcpDiscoveryController.php b/src/Eccube/Controller/AgentCommerce/AcpDiscoveryController.php new file mode 100644 index 00000000000..c082c3fdcd4 --- /dev/null +++ b/src/Eccube/Controller/AgentCommerce/AcpDiscoveryController.php @@ -0,0 +1,59 @@ +baseInfoRepository->get()->isAcpCheckoutEnabled()) { + throw new NotFoundHttpException('ACP checkout is not enabled.'); + } + + $response = new JsonResponse($this->documentBuilder->build(), Response::HTTP_OK); + // 認証不要・キャッシュ可 (SHOULD: public, max-age>=3600)。 + $response->setPublic(); + $response->setMaxAge(3600); + // EC-CUBE はセッション利用時に Cache-Control を private へ強制する。discovery は公開文書のため、 + // セッションリスナーの自動 no-cache を抑止して public キャッシュを維持する。 + $response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true'); + + return $response; + } +} diff --git a/src/Eccube/Entity/AgentCheckoutIdempotency.php b/src/Eccube/Entity/AgentCheckoutIdempotency.php new file mode 100644 index 00000000000..93941552972 --- /dev/null +++ b/src/Eccube/Entity/AgentCheckoutIdempotency.php @@ -0,0 +1,190 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * エージェントが指定した Idempotency-Key. + */ + #[ORM\Column(name: 'idempotency_key', type: Types::STRING, length: 255)] + private string $idempotency_key = ''; + + /** + * 主体 (認証済みエージェント識別子)。未認証は空文字. + */ + #[ORM\Column(name: 'subject', type: Types::STRING, length: 255)] + private string $subject = ''; + + /** + * リクエスト内容のハッシュ (同一キーの異パラメータ再利用検知用). + */ + #[ORM\Column(name: 'request_hash', type: Types::STRING, length: 255)] + private string $request_hash = ''; + + /** + * 保存済みレスポンスの HTTP ステータス (処理中は NULL). + */ + #[ORM\Column(name: 'response_status', type: Types::INTEGER, nullable: true)] + private ?int $response_status = null; + + /** + * 保存済みレスポンスボディ (処理中は NULL). + * + * @var array|null + */ + #[ORM\Column(name: 'response_body', type: Types::JSON, nullable: true)] + private ?array $response_body = null; + + #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $create_date; + + #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $update_date; + + public function getId(): ?int + { + return $this->id; + } + + public function getIdempotencyKey(): string + { + return $this->idempotency_key; + } + + public function setIdempotencyKey(string $idempotency_key): self + { + $this->idempotency_key = $idempotency_key; + + return $this; + } + + public function getSubject(): string + { + return $this->subject; + } + + public function setSubject(string $subject): self + { + $this->subject = $subject; + + return $this; + } + + public function getRequestHash(): string + { + return $this->request_hash; + } + + public function setRequestHash(string $request_hash): self + { + $this->request_hash = $request_hash; + + return $this; + } + + public function getResponseStatus(): ?int + { + return $this->response_status; + } + + public function setResponseStatus(?int $response_status = null): self + { + $this->response_status = $response_status; + + return $this; + } + + /** + * @return array|null + */ + public function getResponseBody(): ?array + { + return $this->response_body; + } + + /** + * @param array|null $response_body + */ + public function setResponseBody(?array $response_body = null): self + { + $this->response_body = $response_body; + + return $this; + } + + public function getCreateDate(): ?\DateTime + { + return $this->create_date ?? null; + } + + public function setCreateDate(\DateTime $create_date): self + { + $this->create_date = $create_date; + + return $this; + } + + public function getUpdateDate(): ?\DateTime + { + return $this->update_date ?? null; + } + + public function setUpdateDate(\DateTime $update_date): self + { + $this->update_date = $update_date; + + return $this; + } + + /** + * レスポンスが確定済か (処理中=予約のみは false). + */ + public function hasResponse(): bool + { + return $this->response_status !== null; + } + } +} diff --git a/src/Eccube/Entity/BaseInfo.php b/src/Eccube/Entity/BaseInfo.php index 042253f5157..b5e3fd92f66 100644 --- a/src/Eccube/Entity/BaseInfo.php +++ b/src/Eccube/Entity/BaseInfo.php @@ -154,6 +154,15 @@ class BaseInfo extends AbstractEntity #[ORM\Column(name: 'ga_id', type: Types::STRING, length: 255, nullable: true)] private ?string $gaId = null; + #[ORM\Column(name: 'acp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $acp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_checkout_enabled', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_checkout_enabled = false; + + #[ORM\Column(name: 'ucp_catalog_requires_auth', type: Types::BOOLEAN, options: ['default' => false])] + private bool $ucp_catalog_requires_auth = false; + /** * Get id. * @@ -831,5 +840,59 @@ public function getGaId(): ?string { return $this->gaId; } + + /** + * Set acpCheckoutEnabled. + */ + public function setAcpCheckoutEnabled(bool $acpCheckoutEnabled): BaseInfo + { + $this->acp_checkout_enabled = $acpCheckoutEnabled; + + return $this; + } + + /** + * Get acpCheckoutEnabled. + */ + public function isAcpCheckoutEnabled(): bool + { + return $this->acp_checkout_enabled; + } + + /** + * Set ucpCheckoutEnabled. + */ + public function setUcpCheckoutEnabled(bool $ucpCheckoutEnabled): BaseInfo + { + $this->ucp_checkout_enabled = $ucpCheckoutEnabled; + + return $this; + } + + /** + * Get ucpCheckoutEnabled. + */ + public function isUcpCheckoutEnabled(): bool + { + return $this->ucp_checkout_enabled; + } + + /** + * Set ucpCatalogRequiresAuth. + */ + public function setUcpCatalogRequiresAuth(bool $ucpCatalogRequiresAuth): BaseInfo + { + $this->ucp_catalog_requires_auth = $ucpCatalogRequiresAuth; + + return $this; + } + + /** + * Get ucpCatalogRequiresAuth. + */ + public function isUcpCatalogRequiresAuth(): bool + { + return $this->ucp_catalog_requires_auth; + } } } diff --git a/src/Eccube/Entity/Cart.php b/src/Eccube/Entity/Cart.php index ce85c4b14d3..a19ba10e541 100644 --- a/src/Eccube/Entity/Cart.php +++ b/src/Eccube/Entity/Cart.php @@ -45,6 +45,16 @@ class Cart extends AbstractEntity implements PurchaseInterface, ItemHolderInterf #[ORM\Column(name: 'cart_key', type: Types::STRING, nullable: true)] private ?string $cart_key = null; + /** + * エージェントコマース (CheckoutSession) が所有するカートか. + * + * true のカートは Web ストアフロント (CartService) のカート解決から除外され、 + * ログイン会員のカートマージ・再計算・購入完了等で操作されない。 + * 通常購入のカートは常に false。 + */ + #[ORM\Column(name: 'agent_owned', type: Types::BOOLEAN, options: ['default' => false])] + private bool $agent_owned = false; + #[ORM\ManyToOne(targetEntity: Customer::class)] #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id')] private ?Customer $Customer = null; @@ -125,6 +135,18 @@ public function setCartKey(string $cartKey): Cart return $this; } + public function isAgentOwned(): bool + { + return $this->agent_owned; + } + + public function setAgentOwned(bool $agentOwned): Cart + { + $this->agent_owned = $agentOwned; + + return $this; + } + /** * @deprecated 使用しないので削除予定 */ diff --git a/src/Eccube/Entity/CheckoutSession.php b/src/Eccube/Entity/CheckoutSession.php new file mode 100644 index 00000000000..626836db8cf --- /dev/null +++ b/src/Eccube/Entity/CheckoutSession.php @@ -0,0 +1,366 @@ + true])] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'IDENTITY')] + private ?int $id = null; + + /** + * エージェントへ公開するセッション識別子 (推測困難・API パスで使用). + * + * 整数 PK は列挙可能なため、外部公開用にはこの不透明な識別子を用いる。 + */ + #[ORM\Column(name: 'session_id', type: Types::STRING, length: 255)] + private string $session_id = ''; + + /** + * プロトコル種別 (ACP / UCP) マスタへの参照. + */ + #[ORM\ManyToOne(targetEntity: AgentProtocol::class)] + #[ORM\JoinColumn(name: 'agent_protocol_id', referencedColumnName: 'id')] + private ?AgentProtocol $Protocol = null; + + /** + * セッションを開始したエージェントの識別子. + */ + #[ORM\Column(name: 'agent_id', type: Types::STRING, length: 255, nullable: true)] + private ?string $agent_id = null; + + /** + * 正規化ステータス (incomplete/ready/completed/canceled/expired) マスタへの参照. + */ + #[ORM\ManyToOne(targetEntity: CheckoutSessionStatus::class)] + #[ORM\JoinColumn(name: 'checkout_session_status_id', referencedColumnName: 'id')] + private ?CheckoutSessionStatus $Status = null; + + /** + * 通貨コード (ISO 4217 alpha-3). + */ + #[ORM\Column(name: 'currency_code', type: Types::STRING, length: 3)] + private string $currency_code = 'JPY'; + + /** + * セッションの有効期限. + */ + #[ORM\Column(name: 'expires_at', type: Types::DATETIMETZ_MUTABLE, nullable: true)] + private ?\DateTime $expires_at = null; + + /** + * 購入者情報 (氏名・住所・連絡先等) の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'buyer_data', type: Types::JSON, nullable: true)] + private ?array $buyer_data = null; + + /** + * 配送 (fulfillment) 情報の中立表現. + * + * @var array|null + */ + #[ORM\Column(name: 'fulfillment_data', type: Types::JSON, nullable: true)] + private ?array $fulfillment_data = null; + + /** + * 支払情報の中立表現 (token はマスキング/暗号化して保持). + * + * @var array|null + */ + #[ORM\Column(name: 'payment_data', type: Types::JSON, nullable: true)] + private ?array $payment_data = null; + + /** + * プロトコル固有のメタデータ (正規化ステータスに収まらない情報を保持). + * + * @var array|null + */ + #[ORM\Column(name: 'metadata', type: Types::JSON, nullable: true)] + private ?array $metadata = null; + + /** + * 明細を保持する Cart (セッションレスに束ねる対象). + */ + #[ORM\ManyToOne(targetEntity: Cart::class)] + #[ORM\JoinColumn(name: 'cart_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Cart $Cart = null; + + /** + * 確定時に生成される Order (完了前は null). + */ + #[ORM\ManyToOne(targetEntity: Order::class)] + #[ORM\JoinColumn(name: 'order_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Order $Order = null; + + /** + * 紐づく会員 (ゲスト購入では null). + */ + #[ORM\ManyToOne(targetEntity: Customer::class)] + #[ORM\JoinColumn(name: 'customer_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] + private ?Customer $Customer = null; + + #[ORM\Column(name: 'create_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $create_date; + + #[ORM\Column(name: 'update_date', type: Types::DATETIMETZ_MUTABLE)] + private \DateTime $update_date; + + public function getId(): ?int + { + return $this->id; + } + + public function getSessionId(): string + { + return $this->session_id; + } + + public function setSessionId(string $session_id): CheckoutSession + { + $this->session_id = $session_id; + + return $this; + } + + public function getProtocol(): ?AgentProtocol + { + return $this->Protocol; + } + + public function setProtocol(?AgentProtocol $Protocol = null): CheckoutSession + { + $this->Protocol = $Protocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): CheckoutSession + { + $this->agent_id = $agent_id; + + return $this; + } + + public function getStatus(): ?CheckoutSessionStatus + { + return $this->Status; + } + + public function setStatus(?CheckoutSessionStatus $Status = null): CheckoutSession + { + $this->Status = $Status; + + return $this; + } + + public function getCurrencyCode(): string + { + return $this->currency_code; + } + + public function setCurrencyCode(string $currency_code): CheckoutSession + { + $this->currency_code = $currency_code; + + return $this; + } + + public function getExpiresAt(): ?\DateTime + { + return $this->expires_at; + } + + public function setExpiresAt(?\DateTime $expires_at = null): CheckoutSession + { + $this->expires_at = $expires_at; + + return $this; + } + + /** + * @return array|null + */ + public function getBuyerData(): ?array + { + return $this->buyer_data; + } + + /** + * @param array|null $buyer_data + */ + public function setBuyerData(?array $buyer_data = null): CheckoutSession + { + $this->buyer_data = $buyer_data; + + return $this; + } + + /** + * @return array|null + */ + public function getFulfillmentData(): ?array + { + return $this->fulfillment_data; + } + + /** + * @param array|null $fulfillment_data + */ + public function setFulfillmentData(?array $fulfillment_data = null): CheckoutSession + { + $this->fulfillment_data = $fulfillment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getPaymentData(): ?array + { + return $this->payment_data; + } + + /** + * @param array|null $payment_data + */ + public function setPaymentData(?array $payment_data = null): CheckoutSession + { + $this->payment_data = $payment_data; + + return $this; + } + + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + */ + public function setMetadata(?array $metadata = null): CheckoutSession + { + $this->metadata = $metadata; + + return $this; + } + + public function getCart(): ?Cart + { + return $this->Cart; + } + + public function setCart(?Cart $Cart = null): CheckoutSession + { + $this->Cart = $Cart; + + return $this; + } + + public function getOrder(): ?Order + { + return $this->Order; + } + + public function setOrder(?Order $Order = null): CheckoutSession + { + $this->Order = $Order; + + return $this; + } + + public function getCustomer(): ?Customer + { + return $this->Customer; + } + + public function setCustomer(?Customer $Customer = null): CheckoutSession + { + $this->Customer = $Customer; + + return $this; + } + + public function getCreateDate(): ?\DateTime + { + return $this->create_date ?? null; + } + + public function setCreateDate(\DateTime $create_date): CheckoutSession + { + $this->create_date = $create_date; + + return $this; + } + + public function getUpdateDate(): ?\DateTime + { + return $this->update_date ?? null; + } + + public function setUpdateDate(\DateTime $update_date): CheckoutSession + { + $this->update_date = $update_date; + + return $this; + } + + /** + * 有効期限を過ぎているか. + */ + public function isExpired(\DateTime $now): bool + { + return $this->expires_at !== null && $this->expires_at <= $now; + } + } +} diff --git a/src/Eccube/Entity/Master/AgentProtocol.php b/src/Eccube/Entity/Master/AgentProtocol.php new file mode 100644 index 00000000000..16292b49ce5 --- /dev/null +++ b/src/Eccube/Entity/Master/AgentProtocol.php @@ -0,0 +1,42 @@ + */ @@ -1177,6 +1195,30 @@ public function appendCompleteMailMessage(?string $complete_mail_message = null) return $this; } + public function getAgentProtocol(): ?AgentProtocol + { + return $this->AgentProtocol; + } + + public function setAgentProtocol(?AgentProtocol $AgentProtocol = null): Order + { + $this->AgentProtocol = $AgentProtocol; + + return $this; + } + + public function getAgentId(): ?string + { + return $this->agent_id; + } + + public function setAgentId(?string $agent_id = null): Order + { + $this->agent_id = $agent_id; + + return $this; + } + /** * 商品の受注明細を取得 * diff --git a/src/Eccube/Form/Type/Admin/ShopMasterType.php b/src/Eccube/Form/Type/Admin/ShopMasterType.php index ec5f4de8a9c..2bf342d587e 100644 --- a/src/Eccube/Form/Type/Admin/ShopMasterType.php +++ b/src/Eccube/Form/Type/Admin/ShopMasterType.php @@ -218,6 +218,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ]), ], ]) + // エージェントコマース checkout の有効化フラグ (discovery / catalog は常時公開、checkout のみ制御) + ->add('acp_checkout_enabled', ToggleSwitchType::class) + ->add('ucp_checkout_enabled', ToggleSwitchType::class) ; $builder->add( diff --git a/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php b/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php new file mode 100644 index 00000000000..d8da0528910 --- /dev/null +++ b/src/Eccube/Repository/AgentCheckoutIdempotencyRepository.php @@ -0,0 +1,55 @@ + + */ +class AgentCheckoutIdempotencyRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, AgentCheckoutIdempotency::class); + } + + /** + * Idempotency-Key と主体で 1 件取得する. + */ + public function findOneByKeyAndSubject(string $key, string $subject): ?AgentCheckoutIdempotency + { + return $this->findOneBy(['idempotency_key' => $key, 'subject' => $subject]); + } + + /** + * 指定時刻より前に作成された記録を削除する (保管期間超過分のクリーンアップ用). + * + * クリーンアップコマンド (派生 issue) から利用する想定。 + * + * @return int 削除件数 + */ + public function deleteOlderThan(\DateTime $threshold): int + { + return (int) $this->createQueryBuilder('i') + ->delete() + ->where('i.create_date < :threshold') + ->setParameter('threshold', $threshold) + ->getQuery() + ->execute(); + } +} diff --git a/src/Eccube/Repository/CheckoutSessionRepository.php b/src/Eccube/Repository/CheckoutSessionRepository.php new file mode 100644 index 00000000000..75400f0deb9 --- /dev/null +++ b/src/Eccube/Repository/CheckoutSessionRepository.php @@ -0,0 +1,70 @@ + + */ +class CheckoutSessionRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSession::class); + } + + /** + * エージェント公開用のセッション識別子で 1 件取得する. + */ + public function findOneBySessionId(string $sessionId): ?CheckoutSession + { + return $this->findOneBy(['session_id' => $sessionId]); + } + + /** + * 指定時刻より前に有効期限が切れた未完了セッションを取得する. + * + * クリーンアップコマンド (派生 issue) から利用する想定。 + * + * @return array + */ + public function findExpired(\DateTime $now): array + { + $qb = $this->createQueryBuilder('cs'); + + /** @var array $result */ + $result = $qb + ->where('cs.expires_at IS NOT NULL') + ->andWhere('cs.expires_at < :now') + ->andWhere($qb->expr()->orX( + 'cs.Status IS NULL', + $qb->expr()->notIn('IDENTITY(cs.Status)', ':terminalStatuses') + )) + ->setParameter('now', $now) + ->setParameter('terminalStatuses', [ + CheckoutSessionStatus::COMPLETED, + CheckoutSessionStatus::CANCELED, + CheckoutSessionStatus::EXPIRED, + ]) + ->getQuery() + ->getResult(); + + return $result; + } +} diff --git a/src/Eccube/Repository/Master/AgentProtocolRepository.php b/src/Eccube/Repository/Master/AgentProtocolRepository.php new file mode 100644 index 00000000000..d61f64cd6cc --- /dev/null +++ b/src/Eccube/Repository/Master/AgentProtocolRepository.php @@ -0,0 +1,29 @@ + + */ +class AgentProtocolRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, AgentProtocol::class); + } +} diff --git a/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php new file mode 100644 index 00000000000..5cd6f8e6bbc --- /dev/null +++ b/src/Eccube/Repository/Master/CheckoutSessionStatusRepository.php @@ -0,0 +1,29 @@ + + */ +class CheckoutSessionStatusRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CheckoutSessionStatus::class); + } +} diff --git a/src/Eccube/Repository/Master/CountryIsoCodeRepository.php b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php new file mode 100644 index 00000000000..149bc547b97 --- /dev/null +++ b/src/Eccube/Repository/Master/CountryIsoCodeRepository.php @@ -0,0 +1,31 @@ + + */ +class CountryIsoCodeRepository extends AbstractRepository +{ + public function __construct(RegistryInterface $registry) + { + parent::__construct($registry, CountryIsoCode::class); + } +} diff --git a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml index bd8383c3485..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/en/definition.yml @@ -1,5 +1,8 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/en/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..5a90d4ad32d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_checkout_session_status.csv @@ -0,0 +1,8 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" +"6","requires_action","6","checkoutsessionstatus" +"7","in_progress","7","checkoutsessionstatus" diff --git a/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/en/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml index bd8383c3485..588b3553fd8 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml +++ b/src/Eccube/Resource/doctrine/import_csv/ja/definition.yml @@ -1,5 +1,8 @@ +- mtb_agent_protocol.csv - mtb_authority.csv +- mtb_checkout_session_status.csv - mtb_country.csv +- mtb_country_iso_code.csv - mtb_csv_type.csv - mtb_customer_order_status.csv - mtb_customer_status.csv diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv index 95a7ffd2662..2e993540906 100644 --- a/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv +++ b/src/Eccube/Resource/doctrine/import_csv/ja/dtb_base_info.csv @@ -1 +1 @@ -id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type +id,country_id,pref_id,company_name,company_kana,postal_code,addr01,addr02,phone_number,business_hour,email01,email02,email03,email04,shop_name,shop_kana,shop_name_eng,update_date,good_traded,message,delivery_free_amount,delivery_free_quantity,option_mypage_order_status_display,option_nostock_hidden,option_favorite_product,option_product_delivery_fee,option_product_tax_rule,option_customer_activate,option_guest_purchase,option_remember_me,option_mail_notifier,authentication_key,option_point,basic_point_rate,point_conversion_rate,discriminator_type,acp_checkout_enabled,ucp_checkout_enabled,ucp_catalog_requires_auth diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv new file mode 100644 index 00000000000..575027df7fe --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_agent_protocol.csv @@ -0,0 +1,3 @@ +id,name,sort_no,discriminator_type +"1","acp","1","agentprotocol" +"2","ucp","2","agentprotocol" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv new file mode 100644 index 00000000000..5a90d4ad32d --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_checkout_session_status.csv @@ -0,0 +1,8 @@ +id,name,sort_no,discriminator_type +"1","incomplete","1","checkoutsessionstatus" +"2","ready","2","checkoutsessionstatus" +"3","completed","3","checkoutsessionstatus" +"4","canceled","4","checkoutsessionstatus" +"5","expired","5","checkoutsessionstatus" +"6","requires_action","6","checkoutsessionstatus" +"7","in_progress","7","checkoutsessionstatus" diff --git a/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv new file mode 100644 index 00000000000..941bd5c983f --- /dev/null +++ b/src/Eccube/Resource/doctrine/import_csv/ja/mtb_country_iso_code.csv @@ -0,0 +1,250 @@ +id,name,sort_no,discriminator_type +"352","IS","1","countryisocode" +"372","IE","2","countryisocode" +"31","AZ","3","countryisocode" +"4","AF","4","countryisocode" +"840","US","5","countryisocode" +"850","VI","6","countryisocode" +"16","AS","7","countryisocode" +"784","AE","8","countryisocode" +"12","DZ","9","countryisocode" +"32","AR","10","countryisocode" +"533","AW","11","countryisocode" +"8","AL","12","countryisocode" +"51","AM","13","countryisocode" +"660","AI","14","countryisocode" +"24","AO","15","countryisocode" +"28","AG","16","countryisocode" +"20","AD","17","countryisocode" +"887","YE","18","countryisocode" +"826","GB","19","countryisocode" +"86","IO","20","countryisocode" +"92","VG","21","countryisocode" +"376","IL","22","countryisocode" +"380","IT","23","countryisocode" +"368","IQ","24","countryisocode" +"364","IR","25","countryisocode" +"356","IN","26","countryisocode" +"360","ID","27","countryisocode" +"876","WF","28","countryisocode" +"800","UG","29","countryisocode" +"804","UA","30","countryisocode" +"860","UZ","31","countryisocode" +"858","UY","32","countryisocode" +"218","EC","33","countryisocode" +"818","EG","34","countryisocode" +"233","EE","35","countryisocode" +"231","ET","36","countryisocode" +"232","ER","37","countryisocode" +"222","SV","38","countryisocode" +"36","AU","39","countryisocode" +"40","AT","40","countryisocode" +"248","AX","41","countryisocode" +"512","OM","42","countryisocode" +"528","NL","43","countryisocode" +"288","GH","44","countryisocode" +"132","CV","45","countryisocode" +"831","GG","46","countryisocode" +"328","GY","47","countryisocode" +"398","KZ","48","countryisocode" +"634","QA","49","countryisocode" +"581","UM","50","countryisocode" +"124","CA","51","countryisocode" +"266","GA","52","countryisocode" +"120","CM","53","countryisocode" +"270","GM","54","countryisocode" +"116","KH","55","countryisocode" +"580","MP","56","countryisocode" +"324","GN","57","countryisocode" +"624","GW","58","countryisocode" +"196","CY","59","countryisocode" +"192","CU","60","countryisocode" +"531","CW","61","countryisocode" +"300","GR","62","countryisocode" +"296","KI","63","countryisocode" +"417","KG","64","countryisocode" +"320","GT","65","countryisocode" +"312","GP","66","countryisocode" +"316","GU","67","countryisocode" +"414","KW","68","countryisocode" +"184","CK","69","countryisocode" +"304","GL","70","countryisocode" +"162","CX","71","countryisocode" +"268","GE","72","countryisocode" +"308","GD","73","countryisocode" +"191","HR","74","countryisocode" +"136","KY","75","countryisocode" +"404","KE","76","countryisocode" +"384","CI","77","countryisocode" +"166","CC","78","countryisocode" +"188","CR","79","countryisocode" +"174","KM","80","countryisocode" +"170","CO","81","countryisocode" +"178","CG","82","countryisocode" +"180","CD","83","countryisocode" +"682","SA","84","countryisocode" +"239","GS","85","countryisocode" +"882","WS","86","countryisocode" +"678","ST","87","countryisocode" +"652","BL","88","countryisocode" +"894","ZM","89","countryisocode" +"666","PM","90","countryisocode" +"674","SM","91","countryisocode" +"663","MF","92","countryisocode" +"694","SL","93","countryisocode" +"262","DJ","94","countryisocode" +"292","GI","95","countryisocode" +"832","JE","96","countryisocode" +"388","JM","97","countryisocode" +"760","SY","98","countryisocode" +"702","SG","99","countryisocode" +"534","SX","100","countryisocode" +"716","ZW","101","countryisocode" +"756","CH","102","countryisocode" +"752","SE","103","countryisocode" +"729","SD","104","countryisocode" +"744","SJ","105","countryisocode" +"724","ES","106","countryisocode" +"740","SR","107","countryisocode" +"144","LK","108","countryisocode" +"703","SK","109","countryisocode" +"705","SI","110","countryisocode" +"748","SZ","111","countryisocode" +"690","SC","112","countryisocode" +"226","GQ","113","countryisocode" +"686","SN","114","countryisocode" +"688","RS","115","countryisocode" +"659","KN","116","countryisocode" +"670","VC","117","countryisocode" +"426","LS","118","countryisocode" +"654","SH","119","countryisocode" +"662","LC","120","countryisocode" +"706","SO","121","countryisocode" +"90","SB","122","countryisocode" +"796","TC","123","countryisocode" +"764","TH","124","countryisocode" +"410","KR","125","countryisocode" +"158","TW","126","countryisocode" +"762","TJ","127","countryisocode" +"834","TZ","128","countryisocode" +"203","CZ","129","countryisocode" +"148","TD","130","countryisocode" +"140","CF","131","countryisocode" +"156","CN","132","countryisocode" +"788","TN","133","countryisocode" +"408","KP","134","countryisocode" +"152","CL","135","countryisocode" +"798","TV","136","countryisocode" +"208","DK","137","countryisocode" +"276","DE","138","countryisocode" +"768","TG","139","countryisocode" +"772","TK","140","countryisocode" +"214","DO","141","countryisocode" +"212","DM","142","countryisocode" +"780","TT","143","countryisocode" +"795","TM","144","countryisocode" +"792","TR","145","countryisocode" +"776","TO","146","countryisocode" +"566","NG","147","countryisocode" +"520","NR","148","countryisocode" +"516","NA","149","countryisocode" +"10","AQ","150","countryisocode" +"570","NU","151","countryisocode" +"558","NI","152","countryisocode" +"562","NE","153","countryisocode" +"392","JP","154","countryisocode" +"732","EH","155","countryisocode" +"540","NC","156","countryisocode" +"554","NZ","157","countryisocode" +"524","NP","158","countryisocode" +"574","NF","159","countryisocode" +"578","NO","160","countryisocode" +"334","HM","161","countryisocode" +"48","BH","162","countryisocode" +"332","HT","163","countryisocode" +"586","PK","164","countryisocode" +"336","VA","165","countryisocode" +"591","PA","166","countryisocode" +"548","VU","167","countryisocode" +"44","BS","168","countryisocode" +"598","PG","169","countryisocode" +"60","BM","170","countryisocode" +"585","PW","171","countryisocode" +"600","PY","172","countryisocode" +"52","BB","173","countryisocode" +"275","PS","174","countryisocode" +"348","HU","175","countryisocode" +"50","BD","176","countryisocode" +"626","TL","177","countryisocode" +"612","PN","178","countryisocode" +"242","FJ","179","countryisocode" +"608","PH","180","countryisocode" +"246","FI","181","countryisocode" +"64","BT","182","countryisocode" +"74","BV","183","countryisocode" +"630","PR","184","countryisocode" +"234","FO","185","countryisocode" +"238","FK","186","countryisocode" +"76","BR","187","countryisocode" +"250","FR","188","countryisocode" +"254","GF","189","countryisocode" +"258","PF","190","countryisocode" +"260","TF","191","countryisocode" +"100","BG","192","countryisocode" +"854","BF","193","countryisocode" +"96","BN","194","countryisocode" +"108","BI","195","countryisocode" +"704","VN","196","countryisocode" +"204","BJ","197","countryisocode" +"862","VE","198","countryisocode" +"112","BY","199","countryisocode" +"84","BZ","200","countryisocode" +"604","PE","201","countryisocode" +"56","BE","202","countryisocode" +"616","PL","203","countryisocode" +"70","BA","204","countryisocode" +"72","BW","205","countryisocode" +"535","BQ","206","countryisocode" +"68","BO","207","countryisocode" +"620","PT","208","countryisocode" +"344","HK","209","countryisocode" +"340","HN","210","countryisocode" +"584","MH","211","countryisocode" +"446","MO","212","countryisocode" +"807","MK","213","countryisocode" +"450","MG","214","countryisocode" +"175","YT","215","countryisocode" +"454","MW","216","countryisocode" +"466","ML","217","countryisocode" +"470","MT","218","countryisocode" +"474","MQ","219","countryisocode" +"458","MY","220","countryisocode" +"833","IM","221","countryisocode" +"583","FM","222","countryisocode" +"710","ZA","223","countryisocode" +"728","SS","224","countryisocode" +"104","MM","225","countryisocode" +"484","MX","226","countryisocode" +"480","MU","227","countryisocode" +"478","MR","228","countryisocode" +"508","MZ","229","countryisocode" +"492","MC","230","countryisocode" +"462","MV","231","countryisocode" +"498","MD","232","countryisocode" +"504","MA","233","countryisocode" +"496","MN","234","countryisocode" +"499","ME","235","countryisocode" +"500","MS","236","countryisocode" +"400","JO","237","countryisocode" +"418","LA","238","countryisocode" +"428","LV","239","countryisocode" +"440","LT","240","countryisocode" +"434","LY","241","countryisocode" +"438","LI","242","countryisocode" +"430","LR","243","countryisocode" +"642","RO","244","countryisocode" +"442","LU","245","countryisocode" +"646","RW","246","countryisocode" +"422","LB","247","countryisocode" +"638","RE","248","countryisocode" +"643","RU","249","countryisocode" diff --git a/src/Eccube/Resource/locale/messages.en.yaml b/src/Eccube/Resource/locale/messages.en.yaml index 3807b8b6380..22771af0744 100644 --- a/src/Eccube/Resource/locale/messages.en.yaml +++ b/src/Eccube/Resource/locale/messages.en.yaml @@ -1186,6 +1186,10 @@ admin.setting.shop.shop.option_point_rate: Point Return Rate admin.setting.shop.shop.option_point_conversion_rate: Point Conversion Rate admin.setting.shop.shop.ga: "Google Analytics" admin.setting.shop.shop.ga.tracking_id: "Tracking ID" +admin.setting.shop.shop.agent_commerce: "Agentic Commerce" +admin.setting.shop.shop.agent_commerce.description: "Enable checkout via AI agents (ChatGPT / Gemini, etc.). Discovery and catalog are always public; only checkout is controlled here. Checkout is not yet available in Japan, so normally leave these disabled." +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "Enable ACP checkout" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "Enable UCP checkout" #------------------------------------------------------------------------------------ # Settings:Store Settings:Trade Law Settings diff --git a/src/Eccube/Resource/locale/messages.ja.yaml b/src/Eccube/Resource/locale/messages.ja.yaml index 96445e787c0..fb4ec95a305 100644 --- a/src/Eccube/Resource/locale/messages.ja.yaml +++ b/src/Eccube/Resource/locale/messages.ja.yaml @@ -1185,6 +1185,10 @@ admin.setting.shop.shop.option_point_rate: ポイント付与率 admin.setting.shop.shop.option_point_conversion_rate: ポイント換算レート admin.setting.shop.shop.ga: "Googleアナリティクス設定" admin.setting.shop.shop.ga.tracking_id: "トラッキングID" +admin.setting.shop.shop.agent_commerce: "エージェントコマース" +admin.setting.shop.shop.agent_commerce.description: "AIエージェント (ChatGPT / Gemini 等) 経由のチェックアウトを有効化します。Discovery / カタログは常時公開され、ここでは checkout のみを制御します。checkout は日本未提供のため、通常は無効のままにしてください。" +admin.setting.shop.shop.agent_commerce.acp_checkout_enabled: "ACP チェックアウトを有効にする" +admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled: "UCP チェックアウトを有効にする" #------------------------------------------------------------------------------------ # 設定:店舗設定:特定商取引法設定 diff --git a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig index b30e83f8049..67ef3f96e25 100644 --- a/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig +++ b/src/Eccube/Resource/template/admin/Setting/Shop/shop_master.twig @@ -373,6 +373,30 @@ file that was distributed with this source code. +
+
{{ 'admin.setting.shop.shop.agent_commerce'|trans }}
+
+

{{ 'admin.setting.shop.shop.agent_commerce.description'|trans }}

+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.acp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.acp_checkout_enabled) }} + {{ form_errors(form.acp_checkout_enabled) }} +
+
+
+
+ {{ 'admin.setting.shop.shop.agent_commerce.ucp_checkout_enabled'|trans }} +
+
+ {{ form_widget(form.ucp_checkout_enabled) }} + {{ form_errors(form.ucp_checkout_enabled) }} +
+
+
+
diff --git a/src/Eccube/Service/AgentCommerce/Acp/AcpAuthenticationSubscriber.php b/src/Eccube/Service/AgentCommerce/Acp/AcpAuthenticationSubscriber.php new file mode 100644 index 00000000000..2f2e1a1387a --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Acp/AcpAuthenticationSubscriber.php @@ -0,0 +1,114 @@ + 'onController', + ]; + } + + public function onController(ControllerEvent $event): void + { + $request = $event->getRequest(); + $route = (string) $request->attributes->get('_route'); + if (!str_starts_with($route, 'acp_checkout_')) { + return; + } + + $token = $this->extractBearerToken($request->headers->get('Authorization')); + if ($token === null) { + $this->reject($event, Response::HTTP_UNAUTHORIZED, 'unauthorized', 'Authorization Bearer token is required.'); + + return; + } + + try { + $userBadge = $this->authenticator->authenticate($token, 'acp', 'checkout'); + } catch (ServiceUnavailableHttpException) { + $this->reject($event, Response::HTTP_SERVICE_UNAVAILABLE, 'service_unavailable', 'The OAuth2 resource server is not available.'); + + return; + } catch (AccessDeniedException) { + $this->reject($event, Response::HTTP_FORBIDDEN, 'insufficient_scope', 'The token is not granted the required scope "acp:checkout".'); + + return; + } catch (AuthenticationException) { + $this->reject($event, Response::HTTP_UNAUTHORIZED, 'invalid_token', 'The access token is invalid.'); + + return; + } + + $request->attributes->set('acp_client_id', $userBadge->getUserIdentifier()); + } + + /** + * `Authorization: Bearer ` からトークンを取り出す (scheme は大文字小文字を区別しない). + */ + private function extractBearerToken(?string $header): ?string + { + if ($header === null) { + return null; + } + if (!preg_match('/\ABearer\s+(.+)\z/i', trim($header), $matches)) { + return null; + } + + $token = trim($matches[1]); + + return $token === '' ? null : $token; + } + + private function reject(ControllerEvent $event, int $status, string $code, string $message): void + { + // ACP プロトコル系エラーは flat Error ({type, code, message})。詳細な内部理由は露出させない。 + $event->setController(static fn (): JsonResponse => new JsonResponse( + ['type' => 'invalid_request', 'code' => $code, 'message' => $message], + $status, + )); + } +} diff --git a/src/Eccube/Service/AgentCommerce/Acp/AcpCheckoutSessionMapper.php b/src/Eccube/Service/AgentCommerce/Acp/AcpCheckoutSessionMapper.php new file mode 100644 index 00000000000..576a5ce65df --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Acp/AcpCheckoutSessionMapper.php @@ -0,0 +1,416 @@ + $payload + */ + public function toCheckoutRequest(array $payload, ?string $agentId): AgentCheckoutRequest + { + $lineItems = []; + $rawLineItems = $payload['line_items'] ?? []; + if (is_array($rawLineItems)) { + foreach ($rawLineItems as $rawLineItem) { + if (!is_array($rawLineItem)) { + continue; + } + // ACP の line_items[].id はカタログの item id (= ProductClass id)。 + // id・quantity はいずれも正の整数のみ許可する (非数値→0 や負数の素通りを防ぎ、 + // 不正値は AgentCheckoutException → 400 プロトコルエラーに寄せる)。 + $itemId = filter_var($rawLineItem['id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + $quantity = filter_var($rawLineItem['quantity'] ?? 1, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + if ($itemId === false || $quantity === false) { + throw new AgentCheckoutException(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, 'Each line item must include a positive integer "id" and "quantity".'); + } + + $lineItems[] = new AgentCheckoutLineItem($itemId, $quantity); + } + } + + // ACP の currency は小文字 ISO 4217 (例: "usd")。EC-CUBE 内部は大文字で扱う。 + $currency = is_string($payload['currency'] ?? null) ? strtoupper($payload['currency']) : 'JPY'; + + return new AgentCheckoutRequest( + $lineItems, + $this->extractAddress($payload), + $currency, + AgentProtocol::ACP, + $agentId, + ); + } + + /** + * 再計算済みの `Order` から ACP CheckoutSession レスポンスを組み立てる. + * + * @param list> $acpMessages + * @param array $actionData REQUIRES_ACTION 時の中立データ (status 振り分けに使用) + * + * @return array + */ + public function buildResponseFromOrder(CheckoutSession $session, Order $order, array $acpMessages, array $actionData = []): array + { + $currency = $session->getCurrencyCode(); + + $response = $this->baseResponse($session, $acpMessages, $actionData); + $response['line_items'] = $this->lineItemsFromOrder($order, $currency); + $response['totals'] = $this->totalsFromOrder($order, $currency); + + if ($session->getStatus()?->getId() === CheckoutSessionStatus::COMPLETED) { + $orderNo = (string) ($order->getOrderNo() ?? $order->getId()); + $response['order'] = [ + 'id' => $orderNo, + 'checkout_session_id' => $session->getSessionId(), + 'order_number' => $orderNo, + 'permalink_url' => $this->urlResolver->orderPermalinkUrl($orderNo), + ]; + } + + return $response; + } + + /** + * 住所未確定 (Order 未生成) の暫定見積レスポンスを組み立てる. + * + * 商品単価から subtotal を算出し、total は subtotal と同値とする (送料・税は住所確定後に再計算)。 + * + * @param list> $acpMessages + * + * @return array + */ + public function buildProvisionalResponse(CheckoutSession $session, AgentCheckoutRequest $request, array $acpMessages): array + { + $currency = $session->getCurrencyCode(); + + $lineItems = []; + $subtotalMinor = 0; + foreach ($request->lineItems as $lineItem) { + $productClass = $this->productClassRepository->find($lineItem->productClassId); + if ($productClass === null) { + continue; + } + $unitMinor = $this->minorUnitConverter->toMinorUnits((string) $productClass->getPrice02IncTax(), $currency); + $lineTotalMinor = $unitMinor * $lineItem->quantity; + $subtotalMinor += $lineTotalMinor; + + $lineItems[] = [ + 'id' => (string) $productClass->getId(), + 'item' => ['id' => (string) $productClass->getId()], + 'name' => (string) $productClass->getProduct()?->getName(), + 'quantity' => $lineItem->quantity, + 'unit_amount' => $unitMinor, + 'totals' => [ + $this->total('items_base_amount', 'Base Amount', $lineTotalMinor), + $this->total('total', 'Total', $lineTotalMinor), + ], + ]; + } + + $response = $this->baseResponse($session, $acpMessages, []); + $response['line_items'] = $lineItems; + $response['totals'] = [ + $this->total('subtotal', 'Subtotal', $subtotalMinor), + $this->total('total', 'Total', $subtotalMinor), + ]; + + return $response; + } + + /** + * レスポンスの共通骨格 (id/protocol/capabilities/status/currency/messages/buyer/expires_at). + * + * @param list> $acpMessages + * @param array $actionData + * + * @return array + */ + private function baseResponse(CheckoutSession $session, array $acpMessages, array $actionData): array + { + $hasBlockingError = false; + foreach ($acpMessages as $message) { + if (($message['type'] ?? null) === 'error') { + $hasBlockingError = true; + break; + } + } + + $response = [ + 'id' => $session->getSessionId(), + 'protocol' => ['version' => self::ACP_VERSION], + 'capabilities' => new \stdClass(), + 'status' => $this->statusMapper->toAcpStatus($session->getStatus(), $actionData, $hasBlockingError), + 'currency' => strtolower($session->getCurrencyCode()), + ]; + + if ($acpMessages !== []) { + $response['messages'] = $acpMessages; + } + + $buyer = $this->buyerEcho($session); + if ($buyer !== []) { + $response['buyer'] = $buyer; + } + + $expiresAt = $session->getExpiresAt(); + if ($expiresAt !== null) { + $response['expires_at'] = $expiresAt->format(\DateTimeInterface::RFC3339); + } + + return $response; + } + + /** + * `Order` の明細 (商品行) を ACP line_items[] へ写す. + * + * @return list> + */ + private function lineItemsFromOrder(Order $order, string $currency): array + { + $lineItems = []; + foreach ($order->getOrderItems() as $orderItem) { + if (!$orderItem->isProduct()) { + continue; + } + $productClass = $orderItem->getProductClass(); + $id = $productClass !== null ? (string) $productClass->getId() : (string) $orderItem->getId(); + $unitMinor = $this->minorUnitConverter->toMinorUnits((string) $orderItem->getPriceIncTax(), $currency); + $totalMinor = $this->minorUnitConverter->toMinorUnits((string) $orderItem->getTotalPrice(), $currency); + + $lineItems[] = [ + 'id' => $id, + 'item' => ['id' => $id], + 'name' => $orderItem->getProductName(), + 'quantity' => (int) $orderItem->getQuantity(), + 'unit_amount' => $unitMinor, + 'totals' => [ + $this->total('items_base_amount', 'Base Amount', $totalMinor), + $this->total('total', 'Total', $totalMinor), + ], + ]; + } + + return $lineItems; + } + + /** + * `Order` の金額から ACP totals[] を組み立てる. + * + * subtotal と total は常に出力し、discount(負)/fulfillment/fee(代引手数料)/tax は非零のときのみ出力する。 + * + * @return list> + */ + private function totalsFromOrder(Order $order, string $currency): array + { + $convert = fn (string $amount): int => $this->minorUnitConverter->toMinorUnits($amount, $currency); + + $totals = [ + $this->total('subtotal', 'Subtotal', $convert($order->getSubtotal())), + ]; + + $discount = $convert($order->getDiscount()); + if ($discount !== 0) { + // ACP の discount は負の符号。 + $totals[] = $this->total('discount', 'Discount', -abs($discount)); + } + + $fulfillment = $convert($order->getDeliveryFeeTotal()); + if ($fulfillment !== 0) { + $totals[] = $this->total('fulfillment', 'Fulfillment', $fulfillment); + } + + // 代引手数料 (Payment.charge を Order に集約済み) は fee 行に出す。 + $fee = $convert($order->getCharge()); + if ($fee !== 0) { + $totals[] = $this->total('fee', 'Fee', $fee); + } + + $tax = $convert($order->getTax()); + if ($tax !== 0) { + $totals[] = $this->total('tax', 'Tax', $tax); + } + + $totals[] = $this->total('total', 'Total', $convert($order->getPaymentTotal())); + + return $totals; + } + + /** + * ACP Total オブジェクト ({type, display_text, amount}) を組み立てる (display_text は必須). + * + * @return array + */ + private function total(string $type, string $displayText, int $amount): array + { + return ['type' => $type, 'display_text' => $displayText, 'amount' => $amount]; + } + + /** + * セッションの buyer_data を ACP buyer ブロックへ写す (無ければ空配列). + * + * @return array + */ + private function buyerEcho(CheckoutSession $session): array + { + $buyerData = $session->getBuyerData(); + if (!is_array($buyerData) || $buyerData === []) { + return []; + } + + $buyer = []; + if (isset($buyerData['given_name'])) { + $buyer['first_name'] = (string) $buyerData['given_name']; + } + if (isset($buyerData['family_name'])) { + $buyer['last_name'] = (string) $buyerData['family_name']; + } + if (isset($buyerData['email'])) { + $buyer['email'] = (string) $buyerData['email']; + } + if (isset($buyerData['phone'])) { + $buyer['phone_number'] = (string) $buyerData['phone']; + } + + return $buyer; + } + + /** + * ペイロードから配送先住所 (fulfillment_details / buyer) を抽出する. + * + * `fulfillment_details.address` (line_one/line_two/city/state/postal_code/country) を主とし、 + * 氏名・連絡先は fulfillment_details または buyer から補う。address が無ければ住所未確定とみなす + * (送料計算ができないため update で後追い提供できる)。 + * + * @param array $payload + */ + private function extractAddress(array $payload): ?AgentCheckoutAddress + { + $fulfillment = is_array($payload['fulfillment_details'] ?? null) ? $payload['fulfillment_details'] : []; + $buyer = is_array($payload['buyer'] ?? null) ? $payload['buyer'] : []; + $address = is_array($fulfillment['address'] ?? null) ? $fulfillment['address'] : []; + + if ($address === []) { + return null; + } + + [$familyName, $givenName] = $this->resolveNames($fulfillment, $buyer, $address); + + $pref = $this->addressMappingService->getPrefFromRegion($this->stringOrNull($address['state'] ?? null)); + $line1 = $this->stringOrNull($address['line_one'] ?? null); + $line2 = $this->stringOrNull($address['line_two'] ?? null); + + return new AgentCheckoutAddress( + name01: $familyName, + name02: $givenName, + kana01: null, + kana02: null, + companyName: null, + postalCode: $this->stringOrNull($address['postal_code'] ?? null), + prefId: $pref?->getId(), + addr01: $this->stringOrNull($address['city'] ?? null), + addr02: $this->joinLines($line1, $line2), + email: $this->stringOrNull($buyer['email'] ?? $fulfillment['email'] ?? null), + phoneNumber: $this->stringOrNull($buyer['phone_number'] ?? $fulfillment['phone_number'] ?? null), + ); + } + + /** + * 氏名 (family, given) を解決する. + * + * buyer.first_name/last_name を優先し、無ければ fulfillment_details.name / address.name を + * 空白で分割する (Western 表記 "First Last" → given=先頭・family=残り)。 + * + * @param array $fulfillment + * @param array $buyer + * @param array $address + * + * @return array{0: ?string, 1: ?string} [family, given] + */ + private function resolveNames(array $fulfillment, array $buyer, array $address): array + { + $family = $this->stringOrNull($buyer['last_name'] ?? null); + $given = $this->stringOrNull($buyer['first_name'] ?? null); + if ($family !== null || $given !== null) { + return [$family, $given]; + } + + $fullName = $this->stringOrNull($fulfillment['name'] ?? $address['name'] ?? null); + if ($fullName === null) { + return [null, null]; + } + + $parts = preg_split('/\s+/', trim($fullName)) ?: []; + if (count($parts) < 2) { + return [$fullName, null]; + } + + $given = array_shift($parts); + + return [implode(' ', $parts), $given]; + } + + private function joinLines(?string $line1, ?string $line2): ?string + { + $joined = trim(implode(' ', array_filter([$line1, $line2], static fn (?string $v): bool => $v !== null && $v !== ''))); + + return $joined === '' ? null : $joined; + } + + private function stringOrNull(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Acp/AcpDiscoveryDocumentBuilder.php b/src/Eccube/Service/AgentCommerce/Acp/AcpDiscoveryDocumentBuilder.php new file mode 100644 index 00000000000..996ea1d4b55 --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Acp/AcpDiscoveryDocumentBuilder.php @@ -0,0 +1,75 @@ + + */ + public function build(): array + { + return [ + 'protocol' => [ + 'name' => 'acp', + 'version' => AcpCheckoutSessionMapper::ACP_VERSION, + 'supported_versions' => AcpCheckoutSessionMapper::SUPPORTED_VERSIONS, + 'documentation_url' => 'https://agenticcommerce.dev', + ], + 'api_base_url' => $this->apiBaseUrl(), + 'transports' => ['rest'], + 'capabilities' => [ + 'services' => ['checkout'], + ], + ]; + } + + /** + * checkout エンドポイントのベース URL (絶対 HTTPS) を RequestContext から導出する. + * + * create ルート (`{base}/checkout_sessions`) の絶対 URL から末尾を除いてベースを得る。 + * 設置パス (サブディレクトリ等) は RequestContext に従い、ハードコードしない。 + */ + private function apiBaseUrl(): string + { + $create = $this->urlGenerator->generate('acp_checkout_create', [], UrlGeneratorInterface::ABSOLUTE_URL); + $base = preg_replace('#/checkout_sessions/?$#', '', $create) ?? $create; + + // discovery / API は HTTPS 必須 (RequestContext が http のときも https に強制)。 + return str_starts_with($base, 'http://') ? substr_replace($base, 'https://', 0, 7) : $base; + } +} diff --git a/src/Eccube/Service/AgentCommerce/Acp/AcpMessageMapper.php b/src/Eccube/Service/AgentCommerce/Acp/AcpMessageMapper.php new file mode 100644 index 00000000000..c9f81fa3d5e --- /dev/null +++ b/src/Eccube/Service/AgentCommerce/Acp/AcpMessageMapper.php @@ -0,0 +1,114 @@ +`/`

`/``/`
` 等) と HTML コメント (``) を検知する。 + * CommonMark の autolink (`` 等。タグ名の直後が `:` になる) は raw HTML ではないため + * 誤検知しない (タグ名直後を `空白`/`>`/`/>` に限定)。 + */ + private const RAW_HTML_PATTERN = '/ after']; + yield 'closing tag' => ['text more']; + } + + public function testAssertNoRawHtmlAllowsAutolink(): void + { + // CommonMark の autolink () は raw HTML ではないため誤検知しない。 + $this->mapper->assertNoRawHtml('See for details'); + $this->expectNotToPerformAssertions(); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpMessageSignerTest.php new file mode 100644 index 00000000000..2d48088775d --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpMessageSignerTest.php @@ -0,0 +1,87 @@ +value; + } + + public function write(string $purpose, string $pem): void + { + $this->value = $pem; + } + }; + + $this->signer = new AcpMessageSigner($keyStore); + } + + public function testSignProducesTimestampAndHexSignature(): void + { + $signature = $this->signer->sign('{"type":"order_create"}', 1750000000); + + // MUST: ヘッダ形式は t=,v1=。 + $this->assertMatchesRegularExpression('/\At=1750000000,v1=[0-9a-f]{64}\z/', $signature, 'Merchant-Signature は t=,v1=<64桁hex>'); + } + + public function testVerifyRoundTrip(): void + { + $body = '{"type":"order_update","data":{}}'; + $timestamp = 1750000000; + $signature = $this->signer->sign($body, $timestamp); + + $this->assertTrue($this->signer->verify($body, $signature, 300, $timestamp), '同一 body/secret の署名は検証成功'); + } + + public function testVerifyRejectsTamperedBody(): void + { + $signature = $this->signer->sign('{"amount":100}', 1750000000); + + $this->assertFalse($this->signer->verify('{"amount":999}', $signature, 300, 1750000000), 'body 改竄は検証失敗'); + } + + public function testVerifyRejectsExpiredTimestamp(): void + { + $signature = $this->signer->sign('{}', 1750000000); + + // now が timestamp から 301 秒乖離 → 許容窓 (300秒) 超過で reject (MUST: timestamp 検証)。 + $this->assertFalse($this->signer->verify('{}', $signature, 300, 1750000301), 'タイムスタンプ超過は検証失敗'); + } + + public function testVerifyRejectsMalformedHeader(): void + { + $this->assertFalse($this->signer->verify('{}', 'garbage', 300, 1750000000), '不正フォーマットは検証失敗'); + $this->assertFalse($this->signer->verify('{}', 'v1=abcd', 300, 1750000000), 't 欠落は検証失敗'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpStatusMapperTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpStatusMapperTest.php new file mode 100644 index 00000000000..70ee9bf2119 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Acp/AcpStatusMapperTest.php @@ -0,0 +1,88 @@ +mapper = new AcpStatusMapper(); + } + + private function statusMaster(int $id): CheckoutSessionStatus + { + return (new CheckoutSessionStatus())->setId($id); + } + + public function testReadyMapsToReadyForPayment(): void + { + $this->assertSame('ready_for_payment', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::READY))); + } + + public function testCompletedCanceledExpired(): void + { + $this->assertSame('completed', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::COMPLETED))); + $this->assertSame('canceled', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::CANCELED))); + $this->assertSame('expired', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::EXPIRED))); + } + + public function testInProgressMapsToCompleteInProgress(): void + { + $this->assertSame('complete_in_progress', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::IN_PROGRESS))); + } + + public function testRequiresActionWith3dsMapsToAuthenticationRequired(): void + { + $this->assertSame( + 'authentication_required', + $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::REQUIRES_ACTION), ['intervention' => '3ds']), + '3DS の追加対応待ちは authentication_required' + ); + } + + public function testRequiresActionWithoutAuthMapsToEscalation(): void + { + $this->assertSame( + 'requires_escalation', + $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::REQUIRES_ACTION), ['continue_url' => 'https://example.com/handoff']), + '3DS でない外部ハンドオフは requires_escalation' + ); + } + + public function testIncompleteWithBlockingErrorMapsToNotReadyForPayment(): void + { + $this->assertSame( + 'not_ready_for_payment', + $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::INCOMPLETE), [], true), + 'ブロッキングエラーがある INCOMPLETE は not_ready_for_payment' + ); + } + + public function testIncompleteWithoutErrorMapsToIncomplete(): void + { + $this->assertSame('incomplete', $this->mapper->toAcpStatus($this->statusMaster(CheckoutSessionStatus::INCOMPLETE))); + $this->assertSame('incomplete', $this->mapper->toAcpStatus(null)); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php new file mode 100644 index 00000000000..ba98e0cbf15 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/AddressMappingServiceTest.php @@ -0,0 +1,185 @@ + alpha-2 の + * 解決、mtb_country.csv の全 id がマスタで解決できること、姓名分離・DTO 整形を確認する。 + */ +final class AddressMappingServiceTest extends EccubeTestCase +{ + private ?AddressMappingService $service = null; + + protected function setUp(): void + { + parent::setUp(); + // AddressMappingService はまだ consumer が無く private では除去されるため、 + // services_test.yaml で public 化してコンテナから取得する。 + $this->service = self::getContainer()->get(AddressMappingService::class); + } + + public function testGetAlpha2FromCountryIdKnownIds(): void + { + $this->assertSame('JP', $this->service->getAlpha2FromCountryId(392), 'ISO 3166-1 numeric 392 is Japan -> JP'); + $this->assertSame('US', $this->service->getAlpha2FromCountryId(840), 'ISO 3166-1 numeric 840 is United States -> US'); + $this->assertSame('GB', $this->service->getAlpha2FromCountryId(826), 'ISO 3166-1 numeric 826 is United Kingdom -> GB'); + $this->assertSame('CN', $this->service->getAlpha2FromCountryId(156), 'ISO 3166-1 numeric 156 is China -> CN'); + $this->assertSame('KR', $this->service->getAlpha2FromCountryId(410), 'ISO 3166-1 numeric 410 is Republic of Korea -> KR'); + $this->assertSame('RU', $this->service->getAlpha2FromCountryId(643), 'ISO 3166-1 numeric 643 is Russia -> RU'); + } + + public function testGetAlpha2FromCountryIdNullAndUnknown(): void + { + $this->assertNull($this->service->getAlpha2FromCountryId(null), 'Null country id must yield null alpha-2'); + $this->assertNull($this->service->getAlpha2FromCountryId(999), 'Unknown numeric id must yield null alpha-2 (no exception)'); + } + + /** + * mtb_country.csv に存在する全 numeric id が mtb_country_iso_code マスタで + * 非 null の alpha-2 に解決できること (どの国でも住所変換が破綻しない保証)。 + */ + public function testAllCountryIdsResolveViaMaster(): void + { + $ids = $this->loadCountryIdsFromCsv(); + $this->assertNotEmpty($ids, 'mtb_country.csv must contain country rows for this assertion to be meaningful'); + + foreach ($ids as $id) { + $alpha2 = $this->service->getAlpha2FromCountryId($id); + $this->assertNotNull($alpha2, sprintf('mtb_country id %d must resolve to a non-null alpha-2 via mtb_country_iso_code', $id)); + $this->assertMatchesRegularExpression('/^[A-Z]{2}$/', $alpha2, sprintf('mtb_country id %d must map to a two-letter uppercase alpha-2 code', $id)); + } + } + + public function testGetRegionFromPref(): void + { + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + $this->assertSame('東京都', $this->service->getRegionFromPref($pref), 'Region must be the prefecture name'); + $this->assertNull($this->service->getRegionFromPref(null), 'Null prefecture must yield null region'); + } + + public function testToAddressArrayFromCustomerSplitsNameAndMapsCountry(): void + { + $country = new Country(); + $country->setId(392); + + $pref = new Pref(); + $pref->setId(13); + $pref->setName('東京都'); + + $customer = new Customer(); + $customer->setName01('山田'); + $customer->setName02('太郎'); + $customer->setKana01('ヤマダ'); + $customer->setKana02('タロウ'); + $customer->setCompanyName('株式会社テスト'); + $customer->setPostalCode('1000001'); + $customer->setPref($pref); + $customer->setAddr01('千代田区千代田'); + $customer->setAddr02('1-1'); + $customer->setCountry($country); + $customer->setPhoneNumber('0312345678'); + + $address = $this->service->toAddressArray($customer); + + $this->assertSame('山田', $address['family_name'], 'family_name must come from name01'); + $this->assertSame('太郎', $address['given_name'], 'given_name must come from name02'); + $this->assertSame('ヤマダ', $address['family_name_kana'], 'family_name_kana must come from kana01'); + $this->assertSame('タロウ', $address['given_name_kana'], 'given_name_kana must come from kana02'); + $this->assertSame('株式会社テスト', $address['company'], 'company must come from company_name'); + $this->assertSame('1000001', $address['postal_code'], 'postal_code must be mapped'); + $this->assertSame('東京都', $address['region'], 'region must be the prefecture name'); + $this->assertSame('千代田区千代田', $address['address1'], 'address1 must come from addr01'); + $this->assertSame('1-1', $address['address2'], 'address2 must come from addr02'); + $this->assertSame('JP', $address['country'], 'country must be alpha-2 resolved from Country.id (392 -> JP)'); + $this->assertSame('0312345678', $address['phone'], 'phone must come from phone_number'); + } + + public function testToAddressArrayFromShipping(): void + { + $country = new Country(); + $country->setId(840); + + $pref = new Pref(); + $pref->setId(1); + $pref->setName('北海道'); + + $shipping = new Shipping(); + $shipping->setName01('佐藤'); + $shipping->setName02('花子'); + $shipping->setPostalCode('0600000'); + $shipping->setPref($pref); + $shipping->setAddr01('札幌市中央区'); + $shipping->setCountry($country); + $shipping->setPhoneNumber('0111234567'); + + $address = $this->service->toAddressArray($shipping); + + $this->assertSame('佐藤', $address['family_name'], 'Shipping family_name must come from name01'); + $this->assertSame('花子', $address['given_name'], 'Shipping given_name must come from name02'); + $this->assertSame('北海道', $address['region'], 'Shipping region must be the prefecture name'); + $this->assertSame('US', $address['country'], 'Shipping country must be alpha-2 resolved from Country.id (840 -> US)'); + $this->assertSame('0111234567', $address['phone'], 'Shipping phone must come from phone_number'); + } + + public function testToAddressArrayWithNullCountryAndPref(): void + { + $customer = new Customer(); + $customer->setName01('田中'); + $customer->setName02('一郎'); + + $address = $this->service->toAddressArray($customer); + + $this->assertSame('田中', $address['family_name'], 'family_name must be mapped even when country/pref are null'); + $this->assertNull($address['country'], 'Null Country must yield null country alpha-2'); + $this->assertNull($address['region'], 'Null Pref must yield null region'); + } + + /** + * @return int[] + */ + private function loadCountryIdsFromCsv(): array + { + $csvPath = __DIR__.'/../../../../../src/Eccube/Resource/doctrine/import_csv/ja/mtb_country.csv'; + $this->assertFileExists($csvPath, 'mtb_country.csv must exist at the expected resource path'); + + $ids = []; + $handle = fopen($csvPath, 'r'); + $this->assertIsResource($handle, 'mtb_country.csv must be readable'); + fgetcsv($handle, null, ',', '"', ''); // skip header row + while (($row = fgetcsv($handle, null, ',', '"', '')) !== false) { + if (!isset($row[0]) || $row[0] === '') { + continue; + } + $ids[] = (int) $row[0]; + } + fclose($handle); + + return $ids; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php new file mode 100644 index 00000000000..ffa948fda80 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/AgentCheckoutPurchaseFlowAdapterTest.php @@ -0,0 +1,257 @@ + Cart(永続) -> Order -> shopping flow 再計算 -> 結果 の経路を、 + * fixtures を読み込んだ DB 上で検証する。ゲスト購入を基準線とし、税・送料・手数料が + * 既存 shopping flow で計算されること、ビジネス系結果 (在庫超過) が例外でなく + * messages[] に反映されること、プロトコル系エラーが例外になることを確認する。 + */ +final class AgentCheckoutPurchaseFlowAdapterTest extends EccubeTestCase +{ + private ?AgentCheckoutPurchaseFlowAdapter $adapter = null; + + protected function setUp(): void + { + parent::setUp(); + $this->adapter = self::getContainer()->get(AgentCheckoutPurchaseFlowAdapter::class); + } + + private function guestAddress(): AgentCheckoutAddress + { + return new AgentCheckoutAddress( + name01: '山田', + name02: '太郎', + kana01: 'ヤマダ', + kana02: 'タロウ', + postalCode: '5300001', + prefId: 27, // 大阪府 + addr01: '大阪市北区', + addr02: '梅田1-1-1', + email: 'agent-guest@example.com', + phoneNumber: '0612345678', + ); + } + + private function createPurchasableProductClass(string $stock = '100'): ProductClass + { + $Product = $this->createProduct('エージェント注文テスト商品', 1); + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + $ProductClass->setStock($stock); + $ProductClass->setStockUnlimited(false); + $this->entityManager->flush(); + + return $ProductClass; + } + + public function testBuildOrderComputesTotalsForGuest(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 2)], + buyer: $this->guestAddress(), + currencyCode: 'JPY', + protocolId: AgentProtocol::ACP, + agentId: 'agent-xyz', + ); + + $result = $this->adapter->buildOrder($request); + $Order = $result->order; + + $this->assertFalse($result->hasError(), '在庫十分なゲスト注文ではエラーメッセージは出ない'); + $this->assertSame('acp', $Order->getAgentProtocol()?->getName(), 'agent_protocol マスタが Order に刻まれる'); + $this->assertSame('agent-xyz', $Order->getAgentId(), 'agent_id が Order に刻まれる'); + $this->assertNotInstanceOf(Customer::class, $Order->getCustomer(), 'ゲスト購入では Order.Customer は null'); + $this->assertSame('山田', $Order->getName01(), 'buyer 住所が Order にコピーされる'); + + // 明細が DTO 通り (単価 = price02 / 数量 = 2) に構築されていること。 + $productItems = $Order->getProductOrderItems(); + $this->assertCount(1, $productItems, '商品明細は 1 行'); + $this->assertSame(2, (int) $productItems[0]->getQuantity(), '数量が DTO の通り反映される'); + $this->assertSame(0, bccomp((string) $productItems[0]->getPrice(), (string) $ProductClass->getPrice02(), 2), '明細単価は price02'); + // shopping flow が税・送料・手数料込みの支払総額を計算していること。 + $this->assertGreaterThan(0, (int) $Order->getPaymentTotal(), 'shopping flow で支払総額 (税送料込) が計算される'); + } + + public function testPrepareThenCommitFinalizesOrder(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + + $Order = $this->adapter->buildOrder($request)->order; + + // PurchaseFlow の悲観ロック (StockReduceProcessor) のためトランザクションを張る。 + // 通常購入の ShoppingController と同様、トランザクション境界は呼び出し側 (決済 + // オーケストレーション層) が管理し、Adapter 自身は開始しない。 + $this->entityManager->beginTransaction(); + try { + // prepare = 在庫引当・受注番号採番 (決済オーソリの「前」, PaymentMethod::apply 相当)。 + $prepareResult = $this->adapter->prepare($Order); + $this->assertFalse($prepareResult->hasError(), 'prepare でエラーが出ない'); + $this->assertNotNull($Order->getOrderNo(), 'prepare で受注番号が採番される'); + + // commit = 確定 (決済オーソリ成功後, PaymentMethod::checkout 成功時相当)。 + $this->adapter->commit($Order); + $this->entityManager->commit(); + } catch (\Throwable $e) { + $this->entityManager->rollback(); + + throw $e; + } + + $this->assertNotNull($Order->getOrderNo(), 'commit 後も受注番号が保持される'); + } + + public function testRollbackRestoresStockAfterPrepare(): void + { + $ProductClass = $this->createPurchasableProductClass('10'); + $ProductStock = $ProductClass->getProductStock(); + $initialStock = (int) $ProductStock->getStock(); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 3)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + + $Order = $this->adapter->buildOrder($request)->order; + + $this->entityManager->beginTransaction(); + try { + // prepare で在庫を 3 引き当て、決済失敗を想定して rollback で戻す。 + $this->adapter->prepare($Order); + $this->adapter->rollback($Order); + $this->entityManager->commit(); + } catch (\Throwable $e) { + $this->entityManager->rollback(); + + throw $e; + } + + $this->entityManager->refresh($ProductStock); + $this->assertSame($initialStock, (int) $ProductStock->getStock(), 'rollback で在庫が prepare 前に戻る (決済失敗時)'); + } + + public function testAgentCartIsIsolatedFromWebStorefront(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + + $this->adapter->buildOrder($request); + // buildOrder が生成した agent_owned カートを cartRepository から特定する。 + $cartRepository = self::getContainer()->get(CartRepository::class); + + $agentCarts = $cartRepository->findBy(['agent_owned' => true]); + $this->assertNotEmpty($agentCarts, 'エージェント生成カートは agent_owned=true で保存される'); + foreach ($agentCarts as $agentCart) { + $this->assertTrue($agentCart->isAgentOwned(), 'agent_owned フラグが立つ'); + $this->assertNull($agentCart->getCustomer(), 'エージェントカートの customer_id は常に NULL (会員帰属は Order 側)'); + } + + // CartService が Web カート解決に用いる検索条件 (agent_owned=false) では拾われないこと。 + $webVisible = $cartRepository->findBy(['agent_owned' => false]); + $webVisibleIds = array_map(static fn (Cart $c): ?int => $c->getId(), $webVisible); + foreach ($agentCarts as $agentCart) { + $this->assertNotContains($agentCart->getId(), $webVisibleIds, 'エージェントカートは Web 解決条件 (agent_owned=false) に含まれない'); + } + } + + public function testOverStockSurfacesAsMessageNotException(): void + { + $ProductClass = $this->createPurchasableProductClass('1'); + + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 5)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + + // 在庫超過は例外でなく messages[] (ビジネス系) として返る。 + $result = $this->adapter->buildOrder($request); + + $this->assertNotEmpty($result->messages, '在庫超過は messages[] に反映される (HTTP 200 + messages[] 系統)'); + } + + public function testEmptyLineItemsThrows(): void + { + $request = new AgentCheckoutRequest(lineItems: [], buyer: $this->guestAddress()); + + try { + $this->adapter->buildOrder($request); + self::fail('空明細は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + $this->assertSame(AgentCheckoutErrorCode::EMPTY_LINE_ITEMS, $e->getErrorCode()); + } + } + + public function testUnknownProductThrows(): void + { + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem(999999999, 1)], + buyer: $this->guestAddress(), + ); + + try { + $this->adapter->buildOrder($request); + self::fail('未知の商品参照は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + $this->assertSame(AgentCheckoutErrorCode::PRODUCT_NOT_FOUND, $e->getErrorCode()); + } + } + + public function testGuestWithoutAddressThrows(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), 1)], + ); + + try { + $this->adapter->buildOrder($request); + self::fail('ゲストで住所が無い場合は AgentCheckoutException を投げる'); + } catch (AgentCheckoutException $e) { + $this->assertSame(AgentCheckoutErrorCode::MISSING_ADDRESS, $e->getErrorCode()); + } + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php new file mode 100644 index 00000000000..fb0391bf169 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/BaseInfoAgentCommerceFlagsTest.php @@ -0,0 +1,73 @@ +get(BaseInfoRepository::class)->get(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + $this->assertFalse($value, sprintf('BaseInfo flag "%s" must default to false (off by default)', $property)); + } + } + + public function testNewBaseInfoFlagsDefaultToFalse(): void + { + $BaseInfo = new BaseInfo(); + + foreach (self::FLAG_PROPERTIES as $property) { + $value = $this->readBooleanFlag($BaseInfo, $property); + $this->assertFalse($value, sprintf('A freshly constructed BaseInfo must report "%s" as false', $property)); + } + } + + private function readBooleanFlag(BaseInfo $BaseInfo, string $property): bool + { + $studly = str_replace('_', '', ucwords($property, '_')); + foreach (['is'.$studly, 'get'.$studly] as $getter) { + if (method_exists($BaseInfo, $getter)) { + return (bool) $BaseInfo->{$getter}(); + } + } + + self::fail(sprintf('BaseInfo must expose a getter (is%1$s or get%1$s) for the "%2$s" flag', $studly, $property)); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionServiceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionServiceTest.php new file mode 100644 index 00000000000..66658f31b47 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/CheckoutSession/AgentCheckoutCompletionServiceTest.php @@ -0,0 +1,325 @@ +adapter = self::getContainer()->get(AgentCheckoutPurchaseFlowAdapter::class); + } + + public function testFrictionlessAuthorizeThenCapturePlacesOrder(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $stock = $ProductClass->getProductStock(); + $session = $this->createReadySession($ProductClass, 2); + + $handler = $this->stubHandler([PaymentOutcome::completed('auth_1')], PaymentOutcome::completed('cap_1')); + $result = $this->service($handler)->complete($session, []); + + $this->assertSame(CheckoutSessionStatus::COMPLETED, $result->status->getId(), 'frictionless で completed へ遷移する'); + $this->assertInstanceOf(Order::class, $result->order, 'completed 時は Order が返る'); + $this->assertSame(CheckoutSessionStatus::COMPLETED, $session->getStatus()?->getId()); + + $this->entityManager->refresh($stock); + $this->assertInstanceOf(ProductStock::class, $stock); + $this->assertSame(98, (int) $stock->getStock(), '在庫が 2 引き当てられて確定する'); + } + + public function testChallengeHoldsStockThenResumeCompletes(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $stock = $ProductClass->getProductStock(); + $session = $this->createReadySession($ProductClass, 2); + + // authorize: 1 回目 REQUIRES_ACTION (3DS challenge) → 2 回目 COMPLETED (再開)。 + $handler = $this->stubHandler( + [PaymentOutcome::requiresAction(['continue_url' => 'https://example.com/3ds/abc']), PaymentOutcome::completed('auth_2')], + PaymentOutcome::completed('cap_1'), + ); + $service = $this->service($handler); + + // 初回 complete: 追加認証待ち。在庫は引き当てたまま保持し、Order は未確定。 + $first = $service->complete($session, []); + $this->assertSame(CheckoutSessionStatus::REQUIRES_ACTION, $first->status->getId(), '3DS challenge は requires_action (エラーでない)'); + $this->assertNotInstanceOf(Order::class, $first->order, 'requires_action では Order は未確定'); + $this->assertSame(['continue_url' => 'https://example.com/3ds/abc'], $first->actionData, 'actionData に continue_url 原資が載る'); + $this->assertInstanceOf(\DateTime::class, $session->getExpiresAt(), 'requires_action で在庫確保期限 (expires_at) が設定される'); + $this->assertGreaterThan(new \DateTime(), $session->getExpiresAt(), 'expires_at は将来 (在庫確保期限)'); + + $this->entityManager->refresh($stock); + $this->assertInstanceOf(ProductStock::class, $stock); + $this->assertSame(98, (int) $stock->getStock(), 'requires_action 中も在庫は引当のまま保持される (rollback しない)'); + + // 再開 complete: authentication_result を受けて確定。再 prepare せず authorize→capture→commit。 + $second = $service->complete($session, ['authentication_result' => ['outcome' => 'authenticated']]); + $this->assertSame(CheckoutSessionStatus::COMPLETED, $second->status->getId(), '再開で completed へ遷移'); + $this->assertInstanceOf(Order::class, $second->order); + + $this->entityManager->refresh($stock); + $this->assertSame(98, (int) $stock->getStock(), '再開確定後も在庫は二重に減らない'); + } + + public function testDeclinedRetryableRollsBackStockAndReturnsToReady(): void + { + $ProductClass = $this->createPurchasableProductClass('10'); + $stock = $ProductClass->getProductStock(); + $session = $this->createReadySession($ProductClass, 3); + + $handler = $this->stubHandler([PaymentOutcome::failed('payment_declined', 'Card declined.', true)]); + $result = $this->service($handler)->complete($session, []); + + $this->assertSame(CheckoutSessionStatus::READY, $result->status->getId(), 'retryable な決済失敗は ready に戻す (再 complete 可)'); + $this->assertNotInstanceOf(Order::class, $result->order, '失敗時は Order を確定しない'); + $this->assertNotEmpty($result->messages, '決済失敗はビジネス系メッセージで返る'); + + $this->entityManager->refresh($stock); + $this->assertInstanceOf(ProductStock::class, $stock); + $this->assertSame(10, (int) $stock->getStock(), '決済失敗で引当をロールバックし在庫が戻る'); + } + + public function testDeclinedNonRetryableCancelsSession(): void + { + $ProductClass = $this->createPurchasableProductClass('10'); + $session = $this->createReadySession($ProductClass, 1); + + $handler = $this->stubHandler([PaymentOutcome::failed('fraud_blocked', 'Blocked.', false)]); + $result = $this->service($handler)->complete($session, []); + + $this->assertSame(CheckoutSessionStatus::CANCELED, $result->status->getId(), 'unrecoverable な決済失敗は canceled'); + } + + public function testCompletedSessionIsIdempotentOnReplay(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $stock = $ProductClass->getProductStock(); + $session = $this->createReadySession($ProductClass, 2); + + $handler = $this->stubHandler([PaymentOutcome::completed('auth_1')], PaymentOutcome::completed('cap_1')); + $service = $this->service($handler); + + $first = $service->complete($session, []); + $this->assertSame(CheckoutSessionStatus::COMPLETED, $first->status->getId()); + $this->entityManager->refresh($stock); + $this->assertInstanceOf(ProductStock::class, $stock); + $this->assertSame(98, (int) $stock->getStock()); + $orderId = $first->order?->getId(); + + // 完了済セッションの再 complete は副作用 (採番・在庫引当・与信) を再実行しない (ACP MUST)。 + $replay = $service->complete($session, []); + $this->assertSame(CheckoutSessionStatus::COMPLETED, $replay->status->getId(), '再 complete も completed のまま'); + $this->assertSame($orderId, $replay->order?->getId(), '同一 Order を返す'); + $this->assertSame(1, $handler->authorizeCount, 'リプレイで authorize は再実行されない'); + $this->assertSame(1, $handler->captureCount, 'リプレイで capture は再実行されない'); + + $this->entityManager->refresh($stock); + $this->assertSame(98, (int) $stock->getStock(), 'リプレイで在庫が二重に減らない'); + } + + public function testPendingHoldsAsInProgressThenResumeCompletes(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $stock = $ProductClass->getProductStock(); + $session = $this->createReadySession($ProductClass, 1); + + // authorize: 1 回目 PENDING (非同期) → 2 回目 COMPLETED (IPN/Webhook 受信後の再開)。 + $handler = $this->stubHandler( + [PaymentOutcome::pending(['psp_ref' => 'pi_123']), PaymentOutcome::completed('auth_2')], + PaymentOutcome::completed('cap_1'), + ); + $service = $this->service($handler); + + $first = $service->complete($session, []); + $this->assertSame(CheckoutSessionStatus::IN_PROGRESS, $first->status->getId(), 'PENDING は in_progress で保持'); + $this->entityManager->refresh($stock); + $this->assertInstanceOf(ProductStock::class, $stock); + $this->assertSame(99, (int) $stock->getStock(), 'in_progress 中も在庫は保持'); + + $second = $service->complete($session, []); + $this->assertSame(CheckoutSessionStatus::COMPLETED, $second->status->getId(), 'IPN/Webhook 相当の再開で completed'); + } + + public function testExpiredRequiresActionSessionIsReclaimable(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $session = $this->createReadySession($ProductClass, 1); + + $handler = $this->stubHandler([PaymentOutcome::requiresAction(['continue_url' => 'https://example.com/3ds'])]); + $this->service($handler)->complete($session, []); + $this->assertSame(CheckoutSessionStatus::REQUIRES_ACTION, $session->getStatus()?->getId()); + + // 在庫確保期限を過去に倒して、期限切れ回収 (findExpired) の対象になることを確認する。 + $session->setExpiresAt((new \DateTime())->modify('-1 minutes')); + $this->entityManager->flush(); + + /** @var CheckoutSessionRepository $repository */ + $repository = $this->entityManager->getRepository(CheckoutSession::class); + $expired = $repository->findExpired(new \DateTime()); + $ids = array_map(static fn (CheckoutSession $s): ?int => $s->getId(), $expired); + $this->assertContains($session->getId(), $ids, 'requires_action は非終端のため期限切れ回収の対象に含まれる'); + } + + /** + * Order を構築済みの ready なセッションを作る. + */ + private function createReadySession(ProductClass $ProductClass, int $quantity): CheckoutSession + { + $request = new AgentCheckoutRequest( + lineItems: [new AgentCheckoutLineItem((int) $ProductClass->getId(), $quantity)], + buyer: $this->guestAddress(), + protocolId: AgentProtocol::ACP, + ); + $order = $this->adapter->buildOrder($request)->order; + + $session = new CheckoutSession(); + $session + ->setSessionId('cs_'.bin2hex(random_bytes(8))) + ->setOrder($order) + ->setCurrencyCode('JPY') + ->setStatus($this->statusMaster(CheckoutSessionStatus::READY)); + $this->entityManager->persist($session); + $this->entityManager->flush(); + + return $session; + } + + private function statusMaster(int $id): CheckoutSessionStatus + { + $status = $this->entityManager->getRepository(CheckoutSessionStatus::class)->find($id); + $this->assertInstanceOf(CheckoutSessionStatus::class, $status); + + return $status; + } + + private function service(AgentCheckoutPaymentHandlerInterface ...$handlers): AgentCheckoutCompletionService + { + /** @var CheckoutSessionStatusRepository $statusRepository */ + $statusRepository = $this->entityManager->getRepository(CheckoutSessionStatus::class); + + return new AgentCheckoutCompletionService( + $this->entityManager, + self::getContainer()->get(AgentCheckoutPurchaseFlowAdapter::class), + new AgentCheckoutPaymentHandlerRegistry($handlers), + $statusRepository, + self::getContainer()->get(GuestCustomerResolver::class), + 15, + ); + } + + /** + * 設定可能な決済ハンドラスタブ. + * + * @param array $authorizeOutcomes authorize の戻り値 (呼び出し順に消費・末尾を反復) + */ + private function stubHandler(array $authorizeOutcomes, ?PaymentOutcome $captureOutcome = null): AgentCheckoutPaymentHandlerInterface + { + return new class($authorizeOutcomes, $captureOutcome) implements AgentCheckoutPaymentHandlerInterface { + public int $authorizeCount = 0; + + public int $captureCount = 0; + + /** + * @param array $authorizeOutcomes + */ + public function __construct( + private array $authorizeOutcomes, + private readonly ?PaymentOutcome $captureOutcome, + ) { + } + + public function authorize(Order $order, array $paymentData): PaymentOutcome + { + $outcome = $this->authorizeOutcomes[$this->authorizeCount] ?? $this->authorizeOutcomes[array_key_last($this->authorizeOutcomes)]; + ++$this->authorizeCount; + + return $outcome; + } + + public function capture(Order $order, array $paymentData): PaymentOutcome + { + ++$this->captureCount; + + return $this->captureOutcome ?? PaymentOutcome::completed('cap_'.$this->captureCount); + } + + public function supports(Order $order): bool + { + return true; + } + }; + } + + private function guestAddress(): AgentCheckoutAddress + { + return new AgentCheckoutAddress( + name01: '山田', + name02: '太郎', + kana01: 'ヤマダ', + kana02: 'タロウ', + postalCode: '5300001', + prefId: 27, + addr01: '大阪市北区', + addr02: '梅田1-1-1', + email: 'agent-completion@example.com', + phoneNumber: '0612345678', + ); + } + + private function createPurchasableProductClass(string $stock = '100'): ProductClass + { + $Product = $this->createProduct('エージェント complete テスト商品', 1); + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + $ProductClass->setStock($stock); + $ProductClass->setStockUnlimited(false); + // createProduct は ProductStock を faker の乱数で生成するため、引当検証を決定的にするよう + // ProductClass.stock と ProductStock.stock の双方を明示的に揃える (引当は ProductStock を減らす)。 + $ProductClass->getProductStock()->setStock($stock); + $this->entityManager->flush(); + + return $ProductClass; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpCheckoutConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpCheckoutConformanceTest.php new file mode 100644 index 00000000000..48c02179a39 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AcpCheckoutConformanceTest.php @@ -0,0 +1,237 @@ +get(BaseInfoRepository::class)->get(); + $baseInfo->setAcpCheckoutEnabled(true); + $this->entityManager->flush(); + } + + private function productClass(string $stock = '100'): ProductClass + { + $Product = $this->createProduct('ACP 適合性テスト商品', 1); + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + $ProductClass->setStock($stock); + $ProductClass->setStockUnlimited(false); + $ProductClass->getProductStock()->setStock($stock); + $this->entityManager->flush(); + + return $ProductClass; + } + + /** + * @return array + */ + private function payload(int $productClassId, int $quantity = 1): array + { + return [ + 'currency' => 'jpy', + 'line_items' => [['id' => (string) $productClassId, 'quantity' => $quantity]], + 'buyer' => ['first_name' => '太郎', 'last_name' => '山田', 'email' => 'acp@example.com', 'phone_number' => '0612345678'], + 'fulfillment_details' => [ + 'name' => '山田 太郎', + 'address' => ['line_one' => '梅田1-1-1', 'city' => '大阪市北区', 'state' => '大阪府', 'country' => 'JP', 'postal_code' => '5300001'], + ], + ]; + } + + /** + * @param array $body + * @param array $extraServer + * + * @return array + */ + private function post(string $uri, array $body, array $extraServer = []): array + { + $server = array_merge([ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, + 'HTTP_Idempotency-Key' => 'conf-'.bin2hex(random_bytes(8)), + ], $extraServer); + $this->client->request(Request::METHOD_POST, $uri, [], [], $server, (string) json_encode($body)); + + return json_decode((string) $this->client->getResponse()->getContent(), true) ?? []; + } + + /** + * MUST: Idempotency-Key header is required on all POST requests (missing -> 400 idempotency_key_required). + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §6.1 + */ + public function testIdempotencyKeyRequiredOnPost(): void + { + $pc = $this->productClass(); + $this->client->request(Request::METHOD_POST, '/acp/checkout_sessions', [], [], [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, + ], (string) json_encode($this->payload((int) $pc->getId()))); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), 'MUST: Idempotency-Key header is required on all POST requests'); + $body = json_decode((string) $this->client->getResponse()->getContent(), true); + $this->assertSame('idempotency_key_required', $body['code'], 'code MUST be "idempotency_key_required"'); + } + + /** + * MUST: Same key + different body -> 422 idempotency_conflict. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §6.4 + */ + public function testIdempotencyConflictReturns422(): void + { + $pc = $this->productClass(); + $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId(), 1), ['HTTP_Idempotency-Key' => 'conf-conflict']); + $conflict = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId(), 2), ['HTTP_Idempotency-Key' => 'conf-conflict']); + + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode(), 'MUST: same key + different body returns 422 idempotency_conflict'); + $this->assertSame('idempotency_conflict', $conflict['code']); + } + + /** + * MUST NOT re-execute side effects on replay; replayed response SHOULD include Idempotent-Replayed: true. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §6.3 + */ + public function testReplayDoesNotReexecuteSideEffects(): void + { + $pc = $this->productClass(); + $first = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId()), ['HTTP_Idempotency-Key' => 'conf-replay']); + $second = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId()), ['HTTP_Idempotency-Key' => 'conf-replay']); + + $this->assertSame($first['id'], $second['id'], 'MUST NOT re-execute side effects on replay'); + $this->assertSame('true', $this->client->getResponse()->headers->get('Idempotent-Replayed'), 'replayed response SHOULD include Idempotent-Replayed: true'); + } + + /** + * MUST: monetary amounts are integers in the currency's minor unit. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §3.1 + */ + public function testMonetaryAmountsAreMinorUnitIntegers(): void + { + $pc = $this->productClass(); + $created = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId())); + + foreach ($created['totals'] as $total) { + $this->assertIsInt($total['amount'], 'MUST: all monetary amounts are integers in minor units'); + } + } + + /** + * MUST: business outcomes (out of stock) are HTTP 200/201 + messages[], NOT a 4xx error. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §5 + */ + public function testBusinessOutcomeUsesMessagesNotHttpError(): void + { + $pc = $this->productClass('0'); + $created = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId())); + + $this->assertSame(Response::HTTP_CREATED, $this->client->getResponse()->getStatusCode(), 'MUST: business failures are not protocol-level 4xx errors'); + $this->assertNotEmpty($created['messages'], 'MUST: business outcomes are surfaced in messages[]'); + $this->assertSame('not_ready_for_payment', $created['status']); + } + + /** + * MUST: when status is authentication_required and complete is called without authentication_result, + * server returns 4XX with type=invalid_request, code=requires_3ds, param=$.authentication_result. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §4.4 + */ + public function testRequires3dsWhenAuthenticationResultMissing(): void + { + $pc = $this->productClass(); + $created = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId())); + + // セッションを 3DS の認証待ち状態に遷移させる (決済ハンドラが REQUIRES_ACTION を返した状況を再現)。 + $repository = self::getContainer()->get(CheckoutSessionRepository::class); + $statusRepository = self::getContainer()->get(CheckoutSessionStatusRepository::class); + $session = $repository->findOneBySessionId($created['id']); + $this->assertNotNull($session); + $session->setStatus($statusRepository->find(CheckoutSessionStatus::REQUIRES_ACTION)); + $session->setMetadata(array_merge($session->getMetadata() ?? [], ['payment_action' => ['intervention' => '3ds']])); + $this->entityManager->flush(); + + // authentication_result を含めずに complete -> 400 requires_3ds。 + $error = $this->post('/acp/checkout_sessions/'.$created['id'].'/complete', ['payment_data' => ['handler_id' => 'card_tokenized']]); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), 'MUST: missing authentication_result returns 4XX'); + $this->assertSame('requires_3ds', $error['code'], 'code MUST be "requires_3ds"'); + $this->assertSame('$.authentication_result', $error['param'], 'param MUST be "$.authentication_result"'); + } + + /** + * MUST: a completed or canceled session cannot be canceled (405). + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP openapi.agentic_checkout.yaml (cancel 405) + */ + public function testCancelCompletedReturns405(): void + { + $pc = $this->productClass(); + $created = $this->post('/acp/checkout_sessions', $this->payload((int) $pc->getId())); + $this->post('/acp/checkout_sessions/'.$created['id'].'/complete', []); + + $this->post('/acp/checkout_sessions/'.$created['id'].'/cancel', []); + $this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode(), 'MUST: a completed session cannot be canceled'); + } + + /** + * Inbound markdown content MUST NOT contain raw HTML (servers MUST reject). + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP rfc.agentic_checkout.md §5.1 + */ + public function testInboundMarkdownRejectsRawHtml(): void + { + $mapper = self::getContainer()->get(AcpMessageMapper::class); + $this->expectException(\InvalidArgumentException::class); + $mapper->assertNoRawHtml(''); + } + + /** + * Get Order webhook の Merchant-Signature 検証は outbound 送出と合わせて後続 PR で実装する. + * + * @see https://github.com/agentic-commerce-protocol/agentic-commerce-protocol ACP openapi.agentic_checkout_webhook.yaml + */ + public function testWebhookDispatchDeferred(): void + { + $this->markTestIncomplete('Webhook の outbound 送出 (order_create/order_update) は後続 PR で実装する (署名基盤 AcpMessageSigner は本 PR に存在)。'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php new file mode 100644 index 00000000000..fa37df11da8 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCheckoutCoreConformanceTest.php @@ -0,0 +1,133 @@ +assertContains(CheckoutSessionStatus::CANCELED, $ids, 'MUST: 正規化ステータスマスタは canceled を含む'); + $this->assertSame([1, 2, 3, 4, 5, 6, 7], $ids, '正規化ステータスマスタの定数が 7 段そろう'); + } + + /** + * 追加認証 (3DS) / escalation はエラーでなく complete が返す中間状態であり、正規化ステータスとして表現する. + * + * UCP `requires_escalation` / ACP `authentication_required` → `requires_action`、 + * `complete_in_progress` → `in_progress` に正規化する (#6777)。これらは在庫を引当のまま保持し + * 再開 complete を待つ非終端ステータスである。 + */ + public function testNormalizedStatusIncludesIntermediateActionStates(): void + { + $this->assertSame(6, CheckoutSessionStatus::REQUIRES_ACTION, 'MUST: 追加認証/escalation 待ちの正規化ステータス requires_action を持つ'); + $this->assertSame(7, CheckoutSessionStatus::IN_PROGRESS, 'MUST: 非同期決済確定待ちの正規化ステータス in_progress を持つ'); + } + + /** + * 決済ハンドラの結果型は「中断→再開」状態機械を表現する 4 値を持つ. + * + * COMPLETED / REQUIRES_ACTION (3DS/escalation) / PENDING (非同期) / FAILED。 + * 追加認証はエラー (FAILED) ではなく REQUIRES_ACTION で表現される点が要。 + */ + public function testPaymentOutcomeCoversStateMachineSignals(): void + { + $values = array_map(static fn (PaymentOutcomeStatus $s): string => $s->value, PaymentOutcomeStatus::cases()); + + $this->assertEqualsCanonicalizing( + ['completed', 'requires_action', 'pending', 'failed'], + $values, + 'MUST: 決済結果型は completed/requires_action/pending/failed の 4 状態を表現できる', + ); + } + + /** + * ビジネス系の結果 (HTTP 200 + messages[]) は error/warning/info の 3 段で表現する. + * + * @see ACP MessageError / MessageWarning / MessageInfo + */ + public function testBusinessMessageLevelsCoverErrorWarningInfo(): void + { + $levels = array_map(static fn (AgentCheckoutMessageLevel $l): string => $l->value, AgentCheckoutMessageLevel::cases()); + + $this->assertEqualsCanonicalizing(['error', 'warning', 'info'], $levels, 'MUST: ビジネス系メッセージは error/warning/info の 3 段を持つ'); + } + + /** + * エージェント経由の注文は Order に protocol/agent 識別子を保持でき、通常購入では NULL である. + */ + public function testOrderCarriesAgentAttributionAndDefaultsNull(): void + { + $normal = new Order(); + $this->assertNotInstanceOf(AgentProtocol::class, $normal->getAgentProtocol(), 'MUST: 通常購入の Order は agent_protocol が NULL'); + $this->assertNull($normal->getAgentId(), 'MUST: 通常購入の Order は agent_id が NULL'); + + $protocol = new AgentProtocol(); + $agentOrder = new Order(); + $agentOrder->setAgentProtocol($protocol)->setAgentId('agent-1'); + $this->assertSame($protocol, $agentOrder->getAgentProtocol(), 'エージェント注文は protocol マスタを保持できる'); + } + + /** + * プロトコル系 (HTTP 4xx/5xx) とビジネス系 (HTTP 200 + messages[]) の 2 系統への + * 実際の HTTP 変換は checkout controller (#6776/#6574) の責務であり中核の対象外. + */ + public function testTwoTierHttpMappingIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('HTTP ステータスと messages[] への 2 系統変換は ACP/UCP checkout controller (#6776/#6574) で検証する。'); + } + + /** + * リプレイ (Idempotency-Key) で副作用を再実行しない不変条件は、controller/middleware の + * 冪等性処理に依存するため中核では未実装. + */ + public function testIdempotentReplayIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('Idempotency-Key によるリプレイ抑止は checkout controller (#6776/#6574) で検証する。'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php new file mode 100644 index 00000000000..e3fe24f461f --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Conformance/AgentCommerceBaseConformanceTest.php @@ -0,0 +1,96 @@ +toMinorUnits('1000', 'JPY'); + $this->assertIsInt($jpy, 'MUST: monetary amounts are integer minor units (JPY, zero-decimal)'); + $this->assertSame(1000, $jpy, 'MUST: a JPY amount of 1000 is 1000 minor units'); + + $usd = $converter->toMinorUnits('10.99', 'USD'); + $this->assertIsInt($usd, 'MUST: monetary amounts are integer minor units (USD, two-decimal)'); + $this->assertSame(1099, $usd, 'MUST: a USD amount of 10.99 is 1099 minor units (cents)'); + } + + /** + * MUST: published signing keys advertise public key material only. The + * private key parameter "d" MUST NOT appear in any discovery JWK. + * + * @see UCP /.well-known/ucp signing_keys[] — EC public-key JWKs only + */ + public function testPublishedSigningKeysContainPublicMaterialOnly(): void + { + $store = new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + + $signer = new UcpMessageSigner($store, 'ucp_signing'); + $jwks = $signer->getPublicJwks(); + + $this->assertNotEmpty($jwks, 'MUST: at least one signing key is advertised for discovery'); + foreach ($jwks as $jwk) { + $this->assertSame('EC', $jwk['kty'] ?? null, 'MUST: UCP signing keys are EC keys'); + $this->assertArrayNotHasKey('d', $jwk, 'MUST NOT: discovery JWKs must not contain the private parameter d'); + } + } + + /** + * Cross-protocol two-tier error model (protocol errors as HTTP 4xx/5xx vs + * business errors as HTTP 200 + messages[]) is enforced at the controller + * layer, which is out of scope for the common base. + */ + public function testTwoTierErrorModelIsDeferredToControllerLayer(): void + { + self::markTestIncomplete('Two-tier error model (HTTP errors vs messages[]) is verified in the ACP/UCP checkout controller tracks, not in the common base.'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php new file mode 100644 index 00000000000..e58e0fd88dd --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Fulfillment/StandardFulfillmentOptionMapperTest.php @@ -0,0 +1,145 @@ + Payment::getCharge())・配送日数 (DeliveryDuration の + * 明細横断 max) が正しく解決されることを検証する。金額は minor unit (JPY=0桁) で確認する。 + */ +final class StandardFulfillmentOptionMapperTest extends EccubeTestCase +{ + private ?StandardFulfillmentOptionMapper $mapper = null; + + private ?PrefRepository $prefRepository = null; + + private ?PaymentRepository $paymentRepository = null; + + protected function setUp(): void + { + parent::setUp(); + $this->mapper = self::getContainer()->get(StandardFulfillmentOptionMapper::class); + $this->prefRepository = self::getContainer()->get(PrefRepository::class); + $this->paymentRepository = self::getContainer()->get(PaymentRepository::class); + } + + private function pref(int $id): Pref + { + /** @var Pref $pref */ + $pref = $this->prefRepository->find($id); + + return $pref; + } + + private function firstProductClass(string $name): ProductClass + { + $Product = $this->createProduct($name, 1); + + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + + return $ProductClass; + } + + public function testReturnsOptionsWithShippingFeeAndPaymentOptions(): void + { + $ProductClass = $this->firstProductClass('配送テスト商品'); + + $options = $this->mapper->mapForDestination([$ProductClass], $this->pref(27), 'JPY'); + + $this->assertNotEmpty($options, '利用可能な配送方法が 1 件以上返る'); + $option = $options[0]; + $this->assertGreaterThan(0, $option->deliveryId, 'deliveryId が解決される'); + $this->assertNotSame('', $option->name, '配送方法名が解決される'); + $this->assertGreaterThanOrEqual(0, $option->shippingFeeMinor, '送料が minor unit (整数) で解決される'); + $this->assertSame('JPY', $option->currencyCode); + $this->assertIsArray($option->paymentOptions, '支払方法の選択肢が配列で返る'); + } + + public function testCodChargeResolvedViaPaymentOption(): void + { + // 代金引換 (id=4) に手数料を設定し、PaymentOption 経由で minor unit に解決されることを確認。 + /** @var Payment $cod */ + $cod = $this->paymentRepository->find(4); + $cod->setCharge('330'); + $this->entityManager->flush(); + + $ProductClass = $this->firstProductClass('代引テスト商品'); + $options = $this->mapper->mapForDestination([$ProductClass], $this->pref(13), 'JPY'); + + $codChargeMinor = null; + foreach ($options as $option) { + foreach ($option->paymentOptions as $paymentOption) { + if ($paymentOption->paymentId === 4) { + $codChargeMinor = $paymentOption->chargeMinor; + break 2; + } + } + } + + $this->assertNotNull($codChargeMinor, '代金引換が支払選択肢に含まれる'); + $this->assertSame(330, $codChargeMinor, '代引手数料が Payment::getCharge() から minor unit (JPY=330) で解決される'); + } + + public function testDeliveryDaysIsMaxAcrossItems(): void + { + $pc2days = $this->firstProductClass('配送2日商品'); + $pc2days->setDeliveryDuration($this->createDuration('お届け2日', 2)); + $pc5days = $this->firstProductClass('配送5日商品'); + $pc5days->setDeliveryDuration($this->createDuration('お届け5日', 5)); + $this->entityManager->flush(); + + $options = $this->mapper->mapForDestination([$pc2days, $pc5days], $this->pref(27), 'JPY'); + + $this->assertNotEmpty($options); + $this->assertSame(5, $options[0]->estimatedDeliveryDays, '配送日数は明細横断の最大値 (2 と 5 -> 5)'); + } + + public function testBackorderItemYieldsNullDeliveryDays(): void + { + $pcNormal = $this->firstProductClass('通常配送商品'); + $pcNormal->setDeliveryDuration($this->createDuration('お届け3日', 3)); + $pcBackorder = $this->firstProductClass('お取り寄せ商品'); + $pcBackorder->setDeliveryDuration($this->createDuration('お取り寄せ', -1)); + $this->entityManager->flush(); + + $options = $this->mapper->mapForDestination([$pcNormal, $pcBackorder], $this->pref(27), 'JPY'); + + $this->assertNotEmpty($options); + $this->assertNull($options[0]->estimatedDeliveryDays, 'お取り寄せ (duration<0) が含まれると配送日数は未確定 (null)'); + } + + private function createDuration(string $name, int $duration): DeliveryDuration + { + $DeliveryDuration = new DeliveryDuration(); + $DeliveryDuration->setName($name)->setDuration($duration)->setSortNo($duration + 100); + $this->entityManager->persist($DeliveryDuration); + $this->entityManager->flush(); + + return $DeliveryDuration; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStoreTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStoreTest.php new file mode 100644 index 00000000000..e38effb9543 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Idempotency/AgentCheckoutIdempotencyStoreTest.php @@ -0,0 +1,153 @@ +entityManager->getRepository(AgentCheckoutIdempotency::class); + $this->repository = $repository; + $this->store = new AgentCheckoutIdempotencyStore($this->entityManager, $this->repository); + } + + /** + * @return callable(): array{status: int, body: array} + */ + private function countingCompute(int &$calls, int $status = 201): callable + { + return function () use (&$calls, $status): array { + ++$calls; + + return ['status' => $status, 'body' => ['order' => 'ord-'.$calls]]; + }; + } + + public function testReplaysSameKeyWithoutReexecutingSideEffects(): void + { + $key = 'idem-'.bin2hex(random_bytes(6)); + $hash = $this->store->hashRequest(['a' => 1]); + $calls = 0; + $compute = $this->countingCompute($calls); + + $first = $this->store->execute($key, $hash, $compute); + $second = $this->store->execute($key, $hash, $compute); + + $this->assertSame(1, $calls, 'MUST NOT re-execute side effects on replay'); + $this->assertFalse($first['replayed']); + $this->assertTrue($second['replayed'], '2 回目はリプレイ'); + $this->assertSame($first['body'], $second['body'], '同一レスポンスを返す'); + $this->assertSame(201, $second['status']); + } + + public function testConflictOnSameKeyDifferentRequest(): void + { + $key = 'idem-'.bin2hex(random_bytes(6)); + $calls = 0; + $this->store->execute($key, $this->store->hashRequest(['a' => 1]), $this->countingCompute($calls)); + + $this->expectException(IdempotencyConflictException::class); + $this->store->execute($key, $this->store->hashRequest(['a' => 2]), $this->countingCompute($calls)); + } + + public function testNullKeyExecutesEveryTime(): void + { + $calls = 0; + $compute = $this->countingCompute($calls); + $this->store->execute(null, 'h', $compute); + $this->store->execute(null, 'h', $compute); + $this->assertSame(2, $calls, 'キー無しは冪等化せず都度実行する'); + } + + public function testDifferentSubjectsDoNotCollide(): void + { + $key = 'idem-'.bin2hex(random_bytes(6)); + $hash = $this->store->hashRequest(['a' => 1]); + $calls = 0; + $compute = $this->countingCompute($calls); + + // 同一キーでも主体 (subject) が異なれば別記録として両方実行される (越境リプレイ防止)。 + $a = $this->store->execute($key, $hash, $compute, 'https://agent-a.example/.well-known/ucp'); + $b = $this->store->execute($key, $hash, $compute, 'https://agent-b.example/.well-known/ucp'); + + $this->assertSame(2, $calls, '別主体は衝突せず各々実行される'); + $this->assertFalse($a['replayed']); + $this->assertFalse($b['replayed']); + } + + public function testComputeFailureRemovesReservationForRetry(): void + { + $key = 'idem-'.bin2hex(random_bytes(6)); + $hash = $this->store->hashRequest(['a' => 1]); + + try { + $this->store->execute($key, $hash, function (): array { + throw new \RuntimeException('payment failed'); + }); + self::fail('compute の例外は伝播する'); + } catch (\RuntimeException $e) { + $this->assertSame('payment failed', $e->getMessage()); + } + + // 予約行が削除され、再試行可能な状態に戻っていること。 + $this->assertNotInstanceOf(AgentCheckoutIdempotency::class, $this->repository->findOneByKeyAndSubject($key, ''), 'compute 失敗時は予約が消えて再試行できる'); + } + + public function testInProgressReservationReturnsConflict(): void + { + $key = 'idem-'.bin2hex(random_bytes(6)); + $hash = $this->store->hashRequest(['a' => 1]); + + // レスポンス未確定 (処理中) の予約を先に作る。 + $reservation = (new AgentCheckoutIdempotency()) + ->setIdempotencyKey($key) + ->setSubject('') + ->setRequestHash($hash); + $this->entityManager->persist($reservation); + $this->entityManager->flush(); + + // 同一キーの並行リクエストは「処理中」競合として弾かれる (副作用は実行しない)。 + $calls = 0; + $this->expectException(IdempotencyConflictException::class); + try { + $this->store->execute($key, $hash, $this->countingCompute($calls)); + } finally { + $this->assertSame(0, $calls, '処理中の並行リクエストは compute を実行しない'); + } + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php new file mode 100644 index 00000000000..ddbd7e69c7b --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/MinorUnitConverterTest.php @@ -0,0 +1,132 @@ +toAmountString round trips. + */ +final class MinorUnitConverterTest extends TestCase +{ + private MinorUnitConverter $converter; + + protected function setUp(): void + { + parent::setUp(); + $this->converter = new MinorUnitConverter(); + } + + public function testToMinorUnitsZeroDecimalJpy(): void + { + $this->assertSame(1000, $this->converter->toMinorUnits('1000', 'JPY'), 'JPY has 0 fraction digits so the minor unit equals the major amount'); + $this->assertSame(1000, $this->converter->toMinorUnits('1000.00', 'JPY'), 'JPY trailing zeros must not introduce extra minor units'); + } + + public function testToMinorUnitsTwoDecimalUsd(): void + { + $this->assertSame(1000, $this->converter->toMinorUnits('10.00', 'USD'), 'USD 10.00 dollars equals 1000 cents'); + $this->assertSame(1099, $this->converter->toMinorUnits('10.99', 'USD'), 'USD 10.99 dollars equals 1099 cents'); + $this->assertSame(5, $this->converter->toMinorUnits('0.05', 'USD'), 'USD 0.05 dollars equals 5 cents'); + } + + public function testToMinorUnitsThreeDecimalBhd(): void + { + $this->assertSame(1500, $this->converter->toMinorUnits('1.500', 'BHD'), 'BHD has 3 fraction digits so 1.5 dinar equals 1500 fils'); + $this->assertSame(1234, $this->converter->toMinorUnits('1.234', 'BHD'), 'BHD 1.234 dinar equals 1234 fils'); + } + + public function testToMinorUnitsNegativeAmount(): void + { + $this->assertSame(-500, $this->converter->toMinorUnits('-5.00', 'USD'), 'Negative amounts (UCP discounts) must convert to negative minor units'); + $this->assertSame(-1000, $this->converter->toMinorUnits('-1000', 'JPY'), 'Negative zero-decimal amount must remain negative'); + } + + public function testToMinorUnitsRoundHalfUp(): void + { + $this->assertSame(1010, $this->converter->toMinorUnits('10.095', 'USD'), 'Round-half-up: 10.095 USD rounds up to 1010 cents'); + $this->assertSame(1009, $this->converter->toMinorUnits('10.094', 'USD'), 'Round down: 10.094 USD rounds to 1009 cents'); + $this->assertSame(-1010, $this->converter->toMinorUnits('-10.095', 'USD'), 'Round-half-up on negative magnitude: -10.095 USD rounds to -1010 cents'); + } + + #[DataProvider(methodName: 'malformedAmountProvider')] + public function testToMinorUnitsMalformedReturnsZero(string $amount): void + { + // 仕様: 空や不正な表記は 0。BCMath に渡す前に弾き ValueError を起こさないこと。 + $this->assertSame(0, $this->converter->toMinorUnits($amount, 'USD'), sprintf('Malformed amount "%s" must convert to 0 without throwing', $amount)); + } + + public static function malformedAmountProvider(): \Iterator + { + yield 'empty' => ['']; + yield 'dot only' => ['.']; + yield 'sign only' => ['-']; + yield 'alpha' => ['abc']; + yield 'thousands separator' => ['1,000']; + yield 'double dot' => ['1..2']; + yield 'trailing garbage' => ['12.34x']; + yield 'whitespace inside' => ['1 000']; + yield 'exponential notation' => ['1e3']; + yield 'exponential with decimal' => ['1.5e3']; + yield 'hex' => ['0x1A']; + } + + public function testToAmountStringZeroDecimalJpy(): void + { + $this->assertSame('1000', $this->converter->toAmountString(1000, 'JPY'), 'JPY minor units map back to the same integer string with no decimal point'); + } + + public function testToAmountStringTwoDecimalUsd(): void + { + $this->assertSame('10.00', $this->converter->toAmountString(1000, 'USD'), 'USD 1000 cents map back to 10.00 dollars with 2 decimals'); + $this->assertSame('10.99', $this->converter->toAmountString(1099, 'USD'), 'USD 1099 cents map back to 10.99 dollars'); + $this->assertSame('0.05', $this->converter->toAmountString(5, 'USD'), 'USD 5 cents map back to 0.05 dollars'); + } + + public function testToAmountStringThreeDecimalBhd(): void + { + $this->assertSame('1.500', $this->converter->toAmountString(1500, 'BHD'), 'BHD 1500 fils map back to 1.500 dinar with 3 decimals'); + } + + public function testToAmountStringNegative(): void + { + $this->assertSame('-5.00', $this->converter->toAmountString(-500, 'USD'), 'Negative minor units must map back to a negative decimal string'); + } + + #[DataProvider(methodName: 'roundTripProvider')] + public function testRoundTrip(string $amount, string $currency): void + { + $minor = $this->converter->toMinorUnits($amount, $currency); + $back = $this->converter->toAmountString($minor, $currency); + $reMinor = $this->converter->toMinorUnits($back, $currency); + $this->assertSame($minor, $reMinor, 'toAmountString output must convert back to the identical minor-unit integer'); + } + + public static function roundTripProvider(): \Iterator + { + yield 'JPY positive' => ['1000', 'JPY']; + yield 'JPY negative' => ['-2500', 'JPY']; + yield 'USD positive' => ['10.99', 'USD']; + yield 'USD negative' => ['-3.50', 'USD']; + yield 'BHD positive' => ['1.234', 'BHD']; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php new file mode 100644 index 00000000000..ef9e643e0fe --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceOAuth2AuthenticatorTest.php @@ -0,0 +1,146 @@ +scopeRegistry = new AgentCommerceScopeRegistry(); + } + + /** + * 付与 scope を attributes に載せて UserBadge を返すスタブ handler. + * + * @param array $scopes + */ + private function handlerWithScopes(array $scopes): AccessTokenHandlerInterface + { + // 匿名 readonly クラスは PHP 8.3+ 専用のため (サポート下限 8.2)、名前付きクラスへ切り出す。 + return new ScopeStubAccessTokenHandler($scopes); + } + + /** + * 不正トークンで AuthenticationException を投げるスタブ handler. + */ + private function handlerRejectingToken(): AccessTokenHandlerInterface + { + return new class implements AccessTokenHandlerInterface { + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + throw new BadCredentialsException('Invalid token.'); + } + }; + } + + public function testValidTokenWithMatchingScopeReturnsUserBadge(): void + { + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['acp:checkout'])); + + $badge = $authenticator->authenticate('valid-token', 'acp', 'checkout'); + + $this->assertSame('agent-platform', $badge->getUserIdentifier(), 'valid token + matching scope は UserBadge を返す'); + } + + public function testSpaceDelimitedScopeAttributeIsAccepted(): void + { + $handler = new class implements AccessTokenHandlerInterface { + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + return new UserBadge('agent-platform', null, ['scope' => 'ucp:catalog ucp:checkout']); + } + }; + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $handler); + + $badge = $authenticator->authenticate('valid-token', 'ucp', 'checkout'); + + $this->assertSame('agent-platform', $badge->getUserIdentifier(), 'OAuth2 標準の空白区切り scope 文字列も解釈できる'); + } + + public function testInvalidTokenThrowsAuthenticationException(): void + { + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerRejectingToken()); + + $this->expectException(BadCredentialsException::class); + $authenticator->authenticate('invalid-token', 'acp', 'checkout'); + } + + public function testMissingScopeThrowsAccessDenied(): void + { + // catalog scope しか持たないトークンで checkout を要求 -> 403。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['acp:catalog'])); + + $this->expectException(AccessDeniedException::class); + $authenticator->authenticate('valid-token', 'acp', 'checkout'); + } + + public function testProtocolCrossoverIsDenied(): void + { + // ucp:checkout を持つトークンで acp:checkout を要求 -> protocol 越境で 403。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry, $this->handlerWithScopes(['ucp:checkout'])); + + $this->expectException(AccessDeniedException::class); + $authenticator->authenticate('valid-token', 'acp', 'checkout'); + } + + public function testHandlerUnavailableThrowsServiceUnavailable(): void + { + // eccube-api4 未導入 = handler が null -> 503。 + $authenticator = new AgentCommerceOAuth2Authenticator($this->scopeRegistry); + + $this->assertFalse($authenticator->isAvailable(), 'handler 未注入時は isAvailable() が false'); + + $this->expectException(ServiceUnavailableHttpException::class); + $authenticator->authenticate('any-token', 'acp', 'checkout'); + } +} + +/** + * 付与 scope を attributes に載せて UserBadge を返すスタブ handler. + * + * 名前付き readonly クラス (PHP 8.2 で有効) として定義する。匿名 readonly クラスは + * PHP 8.3+ 専用で、サポート下限 (8.2) の CI で parse error になるため使用しない。 + */ +final readonly class ScopeStubAccessTokenHandler implements AccessTokenHandlerInterface +{ + /** @param array $scopes */ + public function __construct(private array $scopes) + { + } + + public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge + { + return new UserBadge('agent-platform', null, ['scopes' => $this->scopes]); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php new file mode 100644 index 00000000000..d57738de9be --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/AgentCommerceScopeRegistryTest.php @@ -0,0 +1,106 @@ +:" scope vocabulary + * (acp:checkout/acp:catalog, ucp:checkout/ucp:cart/ucp:catalog/ucp:identity), + * rejection of malformed scopes, and that protocol crossover is denied. + */ +final class AgentCommerceScopeRegistryTest extends TestCase +{ + private AgentCommerceScopeRegistry $registry; + + protected function setUp(): void + { + parent::setUp(); + $this->registry = new AgentCommerceScopeRegistry(); + } + + #[DataProvider(methodName: 'validScopeProvider')] + public function testIsValidScopeAcceptsCanonicalScopes(string $scope): void + { + $this->assertTrue($this->registry->isValidScope($scope), sprintf('"%s" is a canonical : scope and must be valid', $scope)); + } + + public static function validScopeProvider(): \Iterator + { + yield ['acp:checkout']; + yield ['acp:catalog']; + yield ['ucp:checkout']; + yield ['ucp:cart']; + yield ['ucp:catalog']; + yield ['ucp:identity']; + } + + #[DataProvider(methodName: 'invalidScopeProvider')] + public function testIsValidScopeRejectsMalformedOrUnknownScopes(string $scope): void + { + $this->assertFalse($this->registry->isValidScope($scope), sprintf('"%s" is not a canonical scope and must be rejected', $scope)); + } + + public static function invalidScopeProvider(): \Iterator + { + yield 'three segments legacy form' => ['agent:catalog:read']; + yield 'unknown protocol' => ['foo:checkout']; + yield 'unknown capability for acp' => ['acp:identity']; + yield 'unknown capability for ucp' => ['ucp:feed']; + yield 'cart not valid for acp' => ['acp:cart']; + yield 'no colon' => ['checkout']; + yield 'empty string' => ['']; + yield 'colon only' => [':']; + } + + public function testScopesForProtocol(): void + { + $this->assertEqualsCanonicalizing(['acp:checkout', 'acp:catalog'], $this->registry->scopesForProtocol('acp'), 'scopesForProtocol("acp") must list every acp scope in : form'); + $this->assertEqualsCanonicalizing(['ucp:checkout', 'ucp:cart', 'ucp:catalog', 'ucp:identity'], $this->registry->scopesForProtocol('ucp'), 'scopesForProtocol("ucp") must list every ucp scope in : form'); + } + + public function testScopesForUnknownProtocolIsEmpty(): void + { + $this->assertSame([], $this->registry->scopesForProtocol('unknown'), 'An unknown protocol must yield no scopes'); + } + + public function testSupportsGrantsWhenScopePresentForMatchingProtocol(): void + { + $granted = ['ucp:checkout', 'ucp:cart']; + $this->assertTrue($this->registry->supports('ucp', 'checkout', $granted), 'supports must be true when grantedScopes contains the exact : and the protocol matches'); + } + + public function testSupportsDeniesWhenCapabilityNotGranted(): void + { + $granted = ['ucp:cart']; + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'supports must be false when the required capability scope was not granted'); + } + + public function testSupportsDeniesProtocolCrossover(): void + { + $granted = ['acp:checkout']; + $this->assertFalse($this->registry->supports('ucp', 'checkout', $granted), 'Protocol crossover must be denied: an acp:checkout grant must not satisfy a ucp checkout request'); + } + + public function testSupportsDeniesEmptyGrants(): void + { + $this->assertFalse($this->registry->supports('acp', 'catalog', []), 'supports must be false when no scopes were granted'); + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php new file mode 100644 index 00000000000..69248b02a65 --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Security/UcpMessageSignerTest.php @@ -0,0 +1,156 @@ +createKeyStore(), self::PURPOSE); + $base = '"@method": POST'."\n".'"@path": /checkout-sessions'; + + $signature = $signer->sign($base); + + $this->assertNotSame('', $signature, 'sign must return a non-empty base64url signature'); + $this->assertTrue($signer->verify($base, $signature), 'A signature produced by sign must verify against the same signature base'); + } + + public function testVerifyFailsOnTamperedSignatureBase(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'original-signature-base'; + + $signature = $signer->sign($base); + + $this->assertFalse($signer->verify('tampered-signature-base', $signature), 'A signature must not verify against a tampered signature base'); + } + + public function testVerifyFailsOnTamperedSignature(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + $base = 'signature-base'; + + $signature = $signer->sign($base); + // Flip the first character to corrupt the signature while keeping it base64url-ish. + $corrupted = ($signature[0] === 'A' ? 'B' : 'A').substr($signature, 1); + + $this->assertFalse($signer->verify($base, $corrupted), 'A corrupted signature must not verify'); + } + + public function testGetPublicJwksExposesEcPublicKeyOnly(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $jwks = $signer->getPublicJwks(); + + $this->assertNotEmpty($jwks, 'getPublicJwks must return at least the current key'); + foreach ($jwks as $jwk) { + $this->assertSame('EC', $jwk['kty'], 'UCP signing keys must be EC keys (kty=EC)'); + $this->assertSame('P-256', $jwk['crv'], 'UCP signing keys must use the P-256 curve'); + $this->assertArrayHasKey('x', $jwk, 'EC public JWK must expose the x coordinate'); + $this->assertArrayHasKey('y', $jwk, 'EC public JWK must expose the y coordinate'); + $this->assertArrayNotHasKey('d', $jwk, 'Published JWK must never contain the private key parameter d'); + } + } + + public function testGetCurrentKidIsStableAndPresentInJwks(): void + { + $signer = new UcpMessageSigner($this->createKeyStore(), self::PURPOSE); + + $kid = $signer->getCurrentKid(); + $this->assertNotSame('', $kid, 'getCurrentKid must return a non-empty key id'); + + $kids = array_map(static fn (array $jwk) => $jwk['kid'] ?? null, $signer->getPublicJwks()); + $this->assertContains($kid, $kids, 'The current kid must appear among the published JWKs'); + } + + /** + * Rotation: a signature created with the previous key must still verify on + * a signer whose current key is the new key and whose grace set contains + * the old public key. + */ + public function testKeyRotationVerifiesWithGracePublicKey(): void + { + // Old signer with its own (auto-generated) private key. + $oldStore = $this->createKeyStore(); + $oldSigner = new UcpMessageSigner($oldStore, self::PURPOSE); + $base = 'rotation-signature-base'; + $oldSignature = $oldSigner->sign($base); + + $oldPrivatePem = $oldStore->read(self::PURPOSE); + $this->assertNotNull($oldPrivatePem, 'The old key store must hold the generated private key'); + $oldPublicPem = $this->derivePublicPem($oldPrivatePem); + + // New signer with a fresh key, carrying the old public key in its grace set. + $newStore = $this->createKeyStore(); + $newSigner = new UcpMessageSigner($newStore, self::PURPOSE, [$oldPublicPem]); + + $this->assertTrue($newSigner->verify($base, $oldSignature), 'A signature made with the rotated-out key must still verify while its public key is in the grace set'); + + // And the grace public key must be advertised in the JWKs without leaking d. + $jwks = $newSigner->getPublicJwks(); + $this->assertGreaterThanOrEqual(2, count($jwks), 'During grace, both the current and the grace public key must be published'); + foreach ($jwks as $jwk) { + $this->assertArrayNotHasKey('d', $jwk, 'No published JWK (current or grace) may contain the private parameter d'); + } + } + + private function derivePublicPem(string $privatePem): string + { + $key = PublicKeyLoader::load($privatePem); + + return $key->getPublicKey()->toString('PKCS8'); + } + + /** + * In-memory KeyStore stub: persists PEMs in an associative array keyed by + * purpose. No filesystem or DB access. + */ + private function createKeyStore(): KeyStoreInterface + { + return new class implements KeyStoreInterface { + /** @var array */ + private array $store = []; + + public function read(string $purpose): ?string + { + return $this->store[$purpose] ?? null; + } + + public function write(string $purpose, string $pem): void + { + $this->store[$purpose] = $pem; + } + }; + } +} diff --git a/tests/Eccube/Tests/Service/AgentCommerce/Stub/InMemoryAccessTokenHandler.php b/tests/Eccube/Tests/Service/AgentCommerce/Stub/InMemoryAccessTokenHandler.php new file mode 100644 index 00000000000..642ab7a128a --- /dev/null +++ b/tests/Eccube/Tests/Service/AgentCommerce/Stub/InMemoryAccessTokenHandler.php @@ -0,0 +1,59 @@ +}> + */ + private const TOKENS = [ + 'acp-checkout-token' => ['identifier' => 'acp-client-1', 'scopes' => ['acp:checkout', 'acp:catalog']], + // 別クライアント。他エージェントのセッションへの越境アクセス遮断を検証する用途。 + 'acp-checkout-token-2' => ['identifier' => 'acp-client-9', 'scopes' => ['acp:checkout', 'acp:catalog']], + 'acp-catalog-token' => ['identifier' => 'acp-client-2', 'scopes' => ['acp:catalog']], + 'ucp-checkout-token' => ['identifier' => 'ucp-client-1', 'scopes' => ['ucp:checkout']], + ]; + + public function getUserBadgeFrom(string $accessToken): UserBadge + { + $token = self::TOKENS[$accessToken] ?? null; + if ($token === null) { + throw new BadCredentialsException('Invalid access token.'); + } + + return new UserBadge($token['identifier'], null, ['scopes' => $token['scopes']]); + } +} diff --git a/tests/Eccube/Tests/Web/AgentCommerce/AcpCheckoutControllerTest.php b/tests/Eccube/Tests/Web/AgentCommerce/AcpCheckoutControllerTest.php new file mode 100644 index 00000000000..2002822beb5 --- /dev/null +++ b/tests/Eccube/Tests/Web/AgentCommerce/AcpCheckoutControllerTest.php @@ -0,0 +1,303 @@ +get(BaseInfoRepository::class)->get(); + $baseInfo->setAcpCheckoutEnabled(true); + $this->entityManager->flush(); + } + + private function createPurchasableProductClass(string $stock = '100'): ProductClass + { + $Product = $this->createProduct('ACP チェックアウトテスト商品', 1); + /** @var ProductClass $ProductClass */ + $ProductClass = $Product->getProductClasses()[0]; + $ProductClass->setStock($stock); + $ProductClass->setStockUnlimited(false); + $ProductClass->getProductStock()->setStock($stock); + $this->entityManager->flush(); + + return $ProductClass; + } + + /** + * @param array $body + * @param array $server 追加サーバパラメータ (Idempotency-Key 等) + * + * @return array + */ + private function postJson(string $uri, array $body, array $server = [], bool $auth = true): array + { + $headers = ['CONTENT_TYPE' => 'application/json']; + if ($auth) { + $headers['HTTP_AUTHORIZATION'] = 'Bearer '.self::TOKEN; + } + // Idempotency-Key は POST で必須。明示指定が無ければ一意キーを自動付与する。 + if (!isset($server['HTTP_Idempotency-Key'])) { + $server['HTTP_Idempotency-Key'] = 'idem-'.bin2hex(random_bytes(8)); + } + + $this->client->request(Request::METHOD_POST, $uri, [], [], array_merge($headers, $server), (string) json_encode($body)); + + /** @var array $decoded */ + $decoded = json_decode((string) $this->client->getResponse()->getContent(), true) ?? []; + + return $decoded; + } + + /** + * @return array + */ + private function getJson(string $uri, bool $auth = true): array + { + $server = $auth ? ['HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN] : []; + $this->client->request(Request::METHOD_GET, $uri, [], [], $server); + + /** @var array $decoded */ + $decoded = json_decode((string) $this->client->getResponse()->getContent(), true) ?? []; + + return $decoded; + } + + /** + * @return array + */ + private function createPayload(int $productClassId, bool $withAddress = true): array + { + $payload = [ + 'currency' => 'jpy', + 'line_items' => [['id' => (string) $productClassId, 'quantity' => 1]], + 'buyer' => ['first_name' => '太郎', 'last_name' => '山田', 'email' => 'acp-agent@example.com', 'phone_number' => '0612345678'], + ]; + + if ($withAddress) { + $payload['fulfillment_details'] = [ + 'name' => '山田 太郎', + 'phone_number' => '0612345678', + 'email' => 'acp-agent@example.com', + 'address' => [ + 'name' => '山田 太郎', + 'line_one' => '梅田1-1-1', + 'line_two' => '', + 'city' => '大阪市北区', + 'state' => '大阪府', + 'country' => 'JP', + 'postal_code' => '5300001', + ], + ]; + } + + return $payload; + } + + public function testCreateGetCompleteHappyPath(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId())); + $this->assertSame(Response::HTTP_CREATED, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent()); + $this->assertSame('2026-04-17', $created['protocol']['version'], 'ACP バージョンを広告する'); + $this->assertSame('ready_for_payment', $created['status'], '住所と在庫が揃えば ready_for_payment'); + $this->assertSame('jpy', $created['currency'], 'currency は小文字 ISO 4217'); + $this->assertNotEmpty($created['id']); + + // totals: subtotal/total が必ず存在し display_text を持つ (ACP MUST)。 + $types = array_column($created['totals'], 'type'); + $this->assertContains('subtotal', $types); + $this->assertContains('total', $types); + $this->assertArrayHasKey('display_text', $created['totals'][0], 'Total は display_text 必須'); + + $sessionId = $created['id']; + + $got = $this->getJson('/acp/checkout_sessions/'.$sessionId); + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), 'get は 200'); + $this->assertSame($sessionId, $got['id']); + + $completed = $this->postJson('/acp/checkout_sessions/'.$sessionId.'/complete', []); + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent()); + $this->assertSame('completed', $completed['status'], 'complete 後は completed'); + $this->assertArrayHasKey('order', $completed, 'complete 後は order を含む'); + $this->assertNotEmpty($completed['order']['id']); + $this->assertSame($sessionId, $completed['order']['checkout_session_id']); + } + + public function testCreateWithoutAddressIsNotReadyForPayment(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId(), withAddress: false)); + + $this->assertSame(Response::HTTP_CREATED, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent()); + $this->assertSame('not_ready_for_payment', $created['status'], '住所未確定 (ブロッキングエラー) は not_ready_for_payment'); + $this->assertNotEmpty($created['messages'], '住所要求のメッセージを含む'); + } + + public function testGetUnknownSessionReturns404(): void + { + $this->getJson('/acp/checkout_sessions/acp_cs_does_not_exist'); + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), '存在しない/他マーチャントのセッションは 404'); + } + + public function testOtherAgentSessionIsNotAccessible(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId())); + $sessionId = $created['id']; + + // 別クライアント (acp-checkout-token-2) で他エージェントのセッションを GET → 存在を秘匿して 404。 + $server = ['HTTP_AUTHORIZATION' => 'Bearer acp-checkout-token-2']; + $this->client->request(Request::METHOD_GET, '/acp/checkout_sessions/'.$sessionId, [], [], $server); + + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), '他エージェントのセッションは越境アクセス不可 (404)'); + } + + public function testNegativeQuantityReturns400(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $payload = $this->createPayload((int) $ProductClass->getId()); + // quantity は正の整数のみ許可。負数はプロトコルエラー (400) に寄せる。 + $payload['line_items'][0]['quantity'] = -1; + + $error = $this->postJson('/acp/checkout_sessions', $payload); + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), '不正な line_item は 400'); + $this->assertSame('empty_line_items', $error['code']); + } + + public function testMalformedJsonOnCompleteReturns400(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId())); + + // complete に壊れた JSON を送る → 500 ではなく 400 プロトコルエラー (create/update と一貫)。 + $headers = [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN, + 'HTTP_Idempotency-Key' => 'idem-'.bin2hex(random_bytes(8)), + ]; + $this->client->request(Request::METHOD_POST, '/acp/checkout_sessions/'.$created['id'].'/complete', [], [], $headers, '{not-json'); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), '不正 JSON の complete は 400 (500 にしない)'); + } + + public function testMissingIdempotencyKeyReturns400(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $headers = ['CONTENT_TYPE' => 'application/json', 'HTTP_AUTHORIZATION' => 'Bearer '.self::TOKEN]; + $this->client->request(Request::METHOD_POST, '/acp/checkout_sessions', [], [], $headers, (string) json_encode($this->createPayload((int) $ProductClass->getId()))); + + $this->assertSame(Response::HTTP_BAD_REQUEST, $this->client->getResponse()->getStatusCode(), 'Idempotency-Key 欠落 POST は 400 (MUST)'); + $body = json_decode((string) $this->client->getResponse()->getContent(), true); + $this->assertSame('idempotency_key_required', $body['code']); + } + + public function testIdempotentCreateReplaysSameSession(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $payload = $this->createPayload((int) $ProductClass->getId()); + + $first = $this->postJson('/acp/checkout_sessions', $payload, ['HTTP_Idempotency-Key' => 'idem-acp-1']); + $second = $this->postJson('/acp/checkout_sessions', $payload, ['HTTP_Idempotency-Key' => 'idem-acp-1']); + + $this->assertSame($first['id'], $second['id'], 'MUST NOT re-execute: 同一キー+同一内容は同じセッションをリプレイ'); + $this->assertSame('true', $this->client->getResponse()->headers->get('Idempotent-Replayed'), 'リプレイは Idempotent-Replayed: true を付す (SHOULD)'); + } + + public function testIdempotencyConflictReturns422(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + + $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId()), ['HTTP_Idempotency-Key' => 'idem-acp-2']); + // 同一キーで異なる内容 (数量変更) → 422 idempotency_conflict (MUST)。 + $payload2 = $this->createPayload((int) $ProductClass->getId()); + $payload2['line_items'][0]['quantity'] = 2; + $conflict = $this->postJson('/acp/checkout_sessions', $payload2, ['HTTP_Idempotency-Key' => 'idem-acp-2']); + + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $this->client->getResponse()->getStatusCode(), '同一キー+異内容は 422'); + $this->assertSame('idempotency_conflict', $conflict['code']); + } + + public function testCancel(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId())); + + $canceled = $this->postJson('/acp/checkout_sessions/'.$created['id'].'/cancel', []); + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), (string) $this->client->getResponse()->getContent()); + $this->assertSame('canceled', $canceled['status'], 'cancel 後は canceled'); + } + + public function testCancelCompletedSessionReturns405(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $created = $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId())); + $this->postJson('/acp/checkout_sessions/'.$created['id'].'/complete', []); + + $result = $this->postJson('/acp/checkout_sessions/'.$created['id'].'/cancel', []); + $this->assertSame(Response::HTTP_METHOD_NOT_ALLOWED, $this->client->getResponse()->getStatusCode(), '完了済セッションの cancel は 405'); + $this->assertSame('not_allowed', $result['code']); + } + + public function testMissingBearerReturns401(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId()), [], auth: false); + + $this->assertSame(Response::HTTP_UNAUTHORIZED, $this->client->getResponse()->getStatusCode(), 'Bearer トークン欠落は 401'); + } + + public function testInsufficientScopeReturns403(): void + { + $ProductClass = $this->createPurchasableProductClass('100'); + // ucp:checkout のみのトークンで acp:checkout を要求 → protocol 越境で 403。 + $this->postJson('/acp/checkout_sessions', $this->createPayload((int) $ProductClass->getId()), ['HTTP_AUTHORIZATION' => 'Bearer ucp-checkout-token'], auth: false); + + $this->assertSame(Response::HTTP_FORBIDDEN, $this->client->getResponse()->getStatusCode(), 'scope 不足・protocol 越境は 403'); + } + + public function testDisabledFlagReturns404(): void + { + $baseInfo = self::getContainer()->get(BaseInfoRepository::class)->get(); + $baseInfo->setAcpCheckoutEnabled(false); + $this->entityManager->flush(); + + $this->getJson('/acp/checkout_sessions/acp_cs_anything'); + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), 'acp_checkout_enabled=false なら 404'); + } +} diff --git a/tests/Eccube/Tests/Web/AgentCommerce/AcpDiscoveryControllerTest.php b/tests/Eccube/Tests/Web/AgentCommerce/AcpDiscoveryControllerTest.php new file mode 100644 index 00000000000..d9a91f3997e --- /dev/null +++ b/tests/Eccube/Tests/Web/AgentCommerce/AcpDiscoveryControllerTest.php @@ -0,0 +1,92 @@ +get(BaseInfoRepository::class)->get(); + $baseInfo->setAcpCheckoutEnabled(true); + $this->entityManager->flush(); + } + + /** + * @return array + */ + private function fetchDiscovery(): array + { + // MUST NOT require authentication: Authorization ヘッダ無しで取得する。 + $this->client->request(Request::METHOD_GET, '/.well-known/acp.json'); + + return json_decode((string) $this->client->getResponse()->getContent(), true) ?? []; + } + + public function testDiscoveryIsServedWithoutAuth(): void + { + $doc = $this->fetchDiscovery(); + + $this->assertSame(Response::HTTP_OK, $this->client->getResponse()->getStatusCode(), 'MUST NOT require authentication for /.well-known/acp.json'); + $this->assertSame('acp', $doc['protocol']['name'], 'MUST return protocol.name "acp"'); + $this->assertMatchesRegularExpression('/\A\d{4}-\d{2}-\d{2}\z/', $doc['protocol']['version'], 'MUST return protocol.version in YYYY-MM-DD'); + $this->assertNotEmpty($doc['protocol']['supported_versions'], 'MUST return non-empty supported_versions'); + $this->assertContains('rest', $doc['transports'], 'MUST include at least "rest" in transports'); + $this->assertContains('checkout', $doc['capabilities']['services'], 'MUST include checkout service'); + $this->assertNotEmpty($doc['api_base_url']); + } + + public function testDiscoveryDoesNotExposeMerchantId(): void + { + $this->client->request(Request::METHOD_GET, '/.well-known/acp.json'); + $raw = (string) $this->client->getResponse()->getContent(); + + // MUST NOT accept or return merchant_id or merchant-specific identifiers (列挙・指紋採取防止)。 + $this->assertStringNotContainsStringIgnoringCase('merchant_id', $raw, 'MUST NOT return merchant_id (rfc.discovery.md §7.4)'); + } + + public function testDiscoveryIsCacheable(): void + { + $this->client->request(Request::METHOD_GET, '/.well-known/acp.json'); + $cacheControl = (string) $this->client->getResponse()->headers->get('Cache-Control'); + + // SHOULD include Cache-Control public, max-age >= 3600 (固定値ではなく下限保証で判定)。 + $this->assertStringContainsString('public', $cacheControl, 'SHOULD be publicly cacheable'); + $matches = []; + $this->assertSame(1, preg_match('/max-age=(\d+)/', $cacheControl, $matches), 'Cache-Control must include max-age'); + $this->assertGreaterThanOrEqual(3600, (int) $matches[1], 'max-age must be >= 3600'); + } + + public function testDiscoveryReturns404WhenDisabled(): void + { + $baseInfo = self::getContainer()->get(BaseInfoRepository::class)->get(); + $baseInfo->setAcpCheckoutEnabled(false); + $this->entityManager->flush(); + + $this->client->request(Request::METHOD_GET, '/.well-known/acp.json'); + $this->assertSame(Response::HTTP_NOT_FOUND, $this->client->getResponse()->getStatusCode(), 'acp_checkout_enabled=false なら 404'); + } +}