diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml new file mode 100644 index 0000000..751176f --- /dev/null +++ b/.github/workflows/phpunit.yml @@ -0,0 +1,98 @@ +name: PHPUnit +on: + push: + branches: + - '*' + tags: + - '*' + paths: + - '**' + - '!*.md' + pull_request: + paths: + - '**' + - '!*.md' +jobs: + # PHPStan は本体へ組み込んだ状態のパスを前提とする phpstan.neon.dist (docker 実行用) を使うため、 + # 本ワークフローでは実行しない (README のコマンドで docker から実行する)。 + phpunit: + name: PHPUnit + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + eccube_version: [ '4.4' ] + php: [ '8.2', '8.3', '8.4', '8.5' ] + plugin_code: [ 'SamplePayment44' ] + services: + postgres: + image: postgres:18 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: eccube_db + ports: + - 5432:5432 + options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 + env: + APP_ENV: 'test' + APP_DEBUG: 0 + DATABASE_URL: 'postgres://postgres:password@127.0.0.1:5432/eccube_db' + DATABASE_SERVER_VERSION: 18 + DATABASE_CHARSET: utf8 + ECCUBE_PACKAGE_API_URL: 'http://127.0.0.1:8080' + PLUGIN_CODE: ${{ matrix.plugin_code }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + github-token: '' + extensions: :xdebug + + - name: Archive Plugin + run: tar cvzf ${GITHUB_WORKSPACE}/${PLUGIN_CODE}.tar.gz ./* + + - name: Setup mock-package-api + run: | + mkdir -p /tmp/repos + cp ${GITHUB_WORKSPACE}/${PLUGIN_CODE}.tar.gz /tmp/repos/${PLUGIN_CODE}.tgz + docker run --name package-api -d -v /tmp/repos:/repos -e MOCK_REPO_DIR=/repos -p 8080:8080 eccube/mock-package-api:composer2 + + - name: Checkout EC-CUBE + uses: actions/checkout@v4 + with: + repository: 'EC-CUBE/ec-cube' + ref: ${{ matrix.eccube_version }} + path: 'ec-cube' + + - name: Install to composer + working-directory: 'ec-cube' + run: composer install --dev --no-interaction -o --apcu-autoloader + + - name: Setup EC-CUBE + working-directory: 'ec-cube' + run: | + # postgres サービスが POSTGRES_DB で作成済みのため --if-not-exists を付ける + bin/console doctrine:database:create --if-not-exists + bin/console doctrine:schema:create + bin/console eccube:fixtures:load + + - name: Setup Plugin + working-directory: 'ec-cube' + run: | + bin/console doctrine:query:sql "update dtb_base_info set authentication_key='dummy'" + bin/console eccube:composer:require ec-cube/samplepayment44 + bin/console eccube:plugin:enable --code=${PLUGIN_CODE} + bin/console doctrine:schema:update --force --dump-sql + bin/console cache:clear --no-warmup + + + - name: Run PHPUnit + working-directory: 'ec-cube' + run: | + bin/console cache:clear --no-warmup + ./vendor/bin/phpunit -c app/Plugin/${PLUGIN_CODE}/phpunit.xml.dist app/Plugin/${PLUGIN_CODE}/Tests diff --git a/Resource/config/services.yaml b/Resource/config/services.yaml index f5b7d4a..ef0e7bf 100644 --- a/Resource/config/services.yaml +++ b/Resource/config/services.yaml @@ -3,7 +3,15 @@ # sample_payment.xxx: 1 # コンテナ定義 -#services: +# 具象クラス (ハンドラ・ゲートウェイ) は本体の Plugin\ glob (app/config/eccube/services.php) が +# autowire+autoconfigure で自動登録するため、ここでは型の解決に必要なエイリアスのみ宣言する。 +# 決済ハンドラの agent_commerce.payment_handler タグは本体 Kernel::build() の +# registerForAutoconfiguration が付与する (services.yaml の _instanceof はファイルスコープのため +# services.php で登録されるプラグインの具象クラスには届かない)。 +services: + # エージェント決済ゲートウェイ実装の束ね先 (Stripe 実装へ差し替える場合はここを変更)。 + Plugin\SamplePayment44\Service\AgentCommerce\Gateway\AgentPaymentGatewayInterface: + alias: Plugin\SamplePayment44\Service\AgentCommerce\Gateway\MockAgentPaymentGateway # スロットリングの定義 eccube: diff --git a/Service/AgentCommerce/AbstractAgentCardHandler.php b/Service/AgentCommerce/AbstractAgentCardHandler.php new file mode 100644 index 0000000..0f80508 --- /dev/null +++ b/Service/AgentCommerce/AbstractAgentCardHandler.php @@ -0,0 +1,254 @@ + $paymentData + * + * @return array + * + * @throws InvalidPaymentDataException + */ + abstract protected function toGatewayInstrument(array $paymentData): array; + + /** + * 本サンプルは通常購入の {@link CreditCard} (トークン決済) を流用するため、 + * その method_class が割り当たり、かつ注文が自プロトコルのエージェント注文のときに扱う。 + */ + public function supports(Order $order): bool + { + $payment = $order->getPayment(); + if ($payment === null || $payment->getMethodClass() !== CreditCard::class) { + return false; + } + + return $order->getAgentProtocol()?->getId() === $this->protocolId(); + } + + /** + * @param array $paymentData + * @param array $paymentReference 中断前の complete で保持した PSP 参照 (再開時のみ非空) + */ + public function authorize(Order $order, array $paymentData, array $paymentReference = []): PaymentOutcome + { + try { + $instrument = $this->toGatewayInstrument($paymentData); + } catch (InvalidPaymentDataException $e) { + // 支払データを解決できない要求は与信成功にしない (fail-closed)。エージェントが + // payment_data を直して再送すれば回復できるため retryable (セッションは ready へ戻る)。 + return PaymentOutcome::failed('invalid_payment_data', $e->getMessage(), true); + } + + // 再開 complete では中断前の取引を続行する。実 PSP では「既存の PaymentIntent を confirm する」に + // 相当し、支払トークンの再償還を避けるための情報。エージェント入力ではなくサーバ側の記録。 + $transactionId = $paymentReference['transaction_id'] ?? null; + if (is_string($transactionId) && '' !== $transactionId) { + $instrument['transaction_id'] = $transactionId; + } + + try { + $result = $this->gateway->authorize( + $order->getCurrencyCode(), + $this->amount($order), + $instrument, + $this->context($order), + ); + } catch (\Throwable $e) { + // PSP 通信の失敗。コントローラは AgentCheckoutException しか捕捉しないため、ここで + // 捕まえないと 500 になりエージェントへ決済エラーとして返らない。与信は成立していないので retryable。 + $this->logger->error('Agent payment authorization failed.', ['exception' => $e, 'order_no' => $order->getOrderNo()]); + + return PaymentOutcome::failed('payment_gateway_error', 'The payment gateway could not be reached.', true); + } + + return $this->toAuthorizeOutcome($result); + } + + /** + * @param array $paymentData + */ + public function capture(Order $order, array $paymentData, PaymentOutcome $authorization): PaymentOutcome + { + $transactionId = $authorization->transactionId; + if ($transactionId === null || $transactionId === '') { + // 取引識別子を返さない与信は capture できない (ハンドラ実装の誤り)。再試行しても回復しない。 + return PaymentOutcome::failed('missing_transaction_reference', 'The authorization did not return a transaction reference.', false); + } + + try { + $result = $this->gateway->capture( + $order->getCurrencyCode(), + $this->amount($order), + $transactionId, + $this->context($order), + ); + } catch (\Throwable $e) { + // 与信は PSP 側に残るため、取消・照会できるよう取引識別子と metadata を引き継ぐ。 + // 再試行可否は「新規 authorize をやり直せるか」で決まる ({@link captureFailureIsRetryable()})。 + $this->logger->error('Agent payment capture failed.', ['exception' => $e, 'order_no' => $order->getOrderNo(), 'transaction_id' => $transactionId]); + + return PaymentOutcome::failed( + 'payment_capture_error', + 'The payment gateway could not be reached.', + $this->captureFailureIsRetryable(), + $transactionId, + $authorization->metadata, + ); + } + + return $this->toCaptureOutcome($result, $order); + } + + /** + * 注文の支払総額を minor unit 整数へ変換する. + */ + private function amount(Order $order): int + { + return $this->minorUnitConverter->toMinorUnits($order->getPaymentTotal(), $order->getCurrencyCode()); + } + + /** + * @return array + */ + private function context(Order $order): array + { + return [ + 'order_id' => $order->getId(), + 'order_no' => $order->getOrderNo(), + ]; + } + + /** + * capture のゲートウェイ結果をコアの {@link PaymentOutcome} へ写像する. + * + * **コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」**。中間状態を返してもコアは + * 失敗として扱い在庫を回収するため、authorize 用の {@link toAuthorizeOutcome()} は流用せず + * ここで終端 2 値へ畳む。REQUIRES_CAPTURE / REQUIRES_ACTION / PROCESSING が返るのは + * ゲートウェイ実装の誤りなので、ログに残したうえで失敗にする (fail-closed)。 + */ + private function toCaptureOutcome(GatewayResult $result, Order $order): PaymentOutcome + { + if ($result->status === GatewayStatus::SUCCEEDED) { + return PaymentOutcome::completed($result->transactionId, $result->metadata); + } + + if ($result->status === GatewayStatus::FAILED) { + return PaymentOutcome::failed( + $result->errorCode ?? 'capture_failed', + $result->errorMessage ?? '', + // ゲートウェイが不可逆と判断した失敗 (金額不一致等) は、再 authorize できても再試行させない。 + $result->retryable && $this->captureFailureIsRetryable(), + $result->transactionId, + $result->metadata, + ); + } + + $this->logger->error('The payment gateway returned a non-terminal status from capture.', [ + 'order_no' => $order->getOrderNo(), + 'status' => $result->status->value, + 'transaction_id' => $result->transactionId, + ]); + + return PaymentOutcome::failed( + 'capture_unexpected_status', + 'The payment could not be captured.', + $this->captureFailureIsRetryable(), + $result->transactionId, + $result->metadata, + ); + } + + /** + * authorize のゲートウェイ結果をコアの {@link PaymentOutcome} へ写像する. + * + * 与信のみ (REQUIRES_CAPTURE) と売上確定済 (SUCCEEDED) を区別する点が要。潰して COMPLETED に + * すると、auto-capture 型 PSP へ差し替えたときにコアが capture を二重発行する。 + */ + private function toAuthorizeOutcome(GatewayResult $result): PaymentOutcome + { + return match ($result->status) { + GatewayStatus::REQUIRES_CAPTURE => PaymentOutcome::authorized($result->transactionId, $result->metadata), + GatewayStatus::SUCCEEDED => PaymentOutcome::completed($result->transactionId, $result->metadata), + GatewayStatus::REQUIRES_ACTION => PaymentOutcome::requiresAction($result->actionData, $result->metadata, $result->transactionId), + GatewayStatus::PROCESSING => PaymentOutcome::pending($result->metadata, $result->transactionId), + GatewayStatus::FAILED => PaymentOutcome::failed( + $result->errorCode ?? 'payment_failed', + $result->errorMessage ?? '', + $result->retryable, + $result->transactionId, + $result->metadata, + ), + }; + } +} diff --git a/Service/AgentCommerce/Acp/AcpSampleCardHandler.php b/Service/AgentCommerce/Acp/AcpSampleCardHandler.php new file mode 100644 index 0000000..e4d6020 --- /dev/null +++ b/Service/AgentCommerce/Acp/AcpSampleCardHandler.php @@ -0,0 +1,75 @@ + PaymentTokenExtractor::requireToken($paymentData), + 'authentication_result' => $paymentData['authentication_result'] ?? null, + 'redeemed' => true, + ]; + } + + protected function protocolId(): int + { + return AgentProtocol::ACP; + } + + /** + * ACP の capture 失敗は**再試行させない**. + * + * ready からの再 complete は新規 authorize から始まるが、その入口である + * {@link redeemSharedPaymentToken()} は Shared Payment Token の償還であり、SPT はワンショットで + * 2 度目が失敗する。再試行を許しても必ず失敗し、与信だけが PSP 側に残るため canceled にする + * (与信の取消は PSP 側の運用に委ねる)。 + */ + protected function captureFailureIsRetryable(): bool + { + return false; + } + + protected function toGatewayInstrument(array $paymentData): array + { + // SPT の償還はワンショットなので authorize からの 1 度だけ。capture は与信結果を使う + // (基底が capture でこのメソッドを呼ばないことでそれを保証している)。 + return $this->redeemSharedPaymentToken($paymentData); + } +} diff --git a/Service/AgentCommerce/Exception/InvalidPaymentDataException.php b/Service/AgentCommerce/Exception/InvalidPaymentDataException.php new file mode 100644 index 0000000..afb3baf --- /dev/null +++ b/Service/AgentCommerce/Exception/InvalidPaymentDataException.php @@ -0,0 +1,26 @@ + $instrument 中立な支払データ (token・3DS の authentication_result 等) + * @param array $context 注文番号等の付帯情報 (取引識別子の導出・追跡用) + */ + public function authorize(string $currencyCode, int $amount, array $instrument, array $context = []): GatewayResult; + + /** + * 売上確定 (キャプチャ) を行う. {@link authorize()} が成功した取引に対してのみ呼ぶ. + * + * **支払データ (トークン) ではなく、authorize が返した取引識別子を受け取る**。実 PSP の capture は + * 既存取引に対する操作であり、支払トークンの再償還 (ACP の Shared Payment Token 等) はワンショットで + * 2 度目が失敗するため、capture でトークンを再利用する実装にならないよう引数で強制している。 + * + * @param string $currencyCode ISO 4217 + * @param int $amount minor unit 整数 + * @param string $transactionId {@link authorize()} が返した取引識別子 + * @param array $context 付帯情報 + */ + public function capture(string $currencyCode, int $amount, string $transactionId, array $context = []): GatewayResult; +} diff --git a/Service/AgentCommerce/Gateway/GatewayResult.php b/Service/AgentCommerce/Gateway/GatewayResult.php new file mode 100644 index 0000000..dc836b9 --- /dev/null +++ b/Service/AgentCommerce/Gateway/GatewayResult.php @@ -0,0 +1,82 @@ + $metadata payment_data へ保持する PSP 参照等 (機微情報はマスキング済) + * @param array $actionData REQUIRES_ACTION 時の追加認証データ (3DS authentication_metadata 等) + */ + public function __construct( + public GatewayStatus $status, + public ?string $transactionId = null, + public array $metadata = [], + public array $actionData = [], + public ?string $errorCode = null, + public ?string $errorMessage = null, + public bool $retryable = true, + ) { + } + + /** + * @param array $metadata + */ + public static function succeeded(string $transactionId, array $metadata = []): self + { + return new self(GatewayStatus::SUCCEEDED, $transactionId, $metadata); + } + + /** + * @param array $metadata + */ + public static function requiresCapture(string $transactionId, array $metadata = []): self + { + return new self(GatewayStatus::REQUIRES_CAPTURE, $transactionId, $metadata); + } + + /** + * @param array $actionData + * @param array $metadata + */ + public static function requiresAction(array $actionData, ?string $transactionId = null, array $metadata = []): self + { + return new self(GatewayStatus::REQUIRES_ACTION, $transactionId, $metadata, $actionData); + } + + /** + * @param array $metadata + */ + public static function processing(?string $transactionId = null, array $metadata = []): self + { + return new self(GatewayStatus::PROCESSING, $transactionId, $metadata); + } + + /** + * 失敗時も PSP 参照を残せるよう、他のファクトリと同じく transactionId / metadata を受け取る + * (与信済みの取引を capture で失敗させた場合、取消・照会に取引識別子が要る). + * + * @param array $metadata + */ + public static function failed(string $errorCode, string $errorMessage = '', bool $retryable = true, ?string $transactionId = null, array $metadata = []): self + { + return new self(GatewayStatus::FAILED, $transactionId, $metadata, [], $errorCode, $errorMessage, $retryable); + } +} diff --git a/Service/AgentCommerce/Gateway/GatewayStatus.php b/Service/AgentCommerce/Gateway/GatewayStatus.php new file mode 100644 index 0000000..7c5f247 --- /dev/null +++ b/Service/AgentCommerce/Gateway/GatewayStatus.php @@ -0,0 +1,40 @@ + + */ + private array $transactions = []; + + /** + * 償還済みトークン → 紐づく注文参照 (共有支払トークンのワンショット性を模す). + * + * @var array + */ + private array $redeemedTokens = []; + + public function authorize(string $currencyCode, int $amount, array $instrument, array $context = []): GatewayResult + { + $token = $this->token($instrument); + if ($token === '') { + return GatewayResult::failed('invalid_payment_data', 'A payment token is required to authorize.', true); + } + + $orderReference = $this->orderReference($context); + $redeemedFor = $this->redeemedTokens[$token] ?? null; + if ($redeemedFor !== null && $redeemedFor !== $orderReference) { + // 共有支払トークンは 1 取引にしか使えない。別注文での再利用は不可逆な失敗とする。 + return GatewayResult::failed('token_already_redeemed', 'The payment token has already been redeemed for another order.', false); + } + $this->redeemedTokens[$token] = $orderReference; + + // 再開 complete では中断前の取引を続行する (実 PSP の「既存 PaymentIntent を confirm」に相当)。 + // 与えられなければ入力から決定的に導出する。 + $transactionId = $this->existingTransactionId($instrument) + ?? $this->transactionId($currencyCode, $amount, $token, $orderReference); + $metadata = $this->metadata($transactionId, $currencyCode, $amount); + + if (str_contains($token, self::MARKER_FRAUD)) { + return GatewayResult::failed('card_not_supported', 'The card was blocked by fraud detection.', false, $transactionId); + } + + if (str_contains($token, self::MARKER_DECLINE)) { + return GatewayResult::failed('card_declined', 'The card was declined.', true, $transactionId); + } + + if (str_contains($token, self::MARKER_3DS) && !$this->isAuthenticated($instrument)) { + // 追加認証の中断。再開時に PSP 参照を辿れるよう metadata も返す。 + return GatewayResult::requiresAction($this->authenticationActionData(), $transactionId, $metadata); + } + + if (str_contains($token, self::MARKER_PROCESSING)) { + return GatewayResult::processing($transactionId, $metadata); + } + + $this->transactions[$transactionId] = [ + 'token' => $token, + 'currency' => $currencyCode, + 'amount' => $amount, + 'captured' => false, + ]; + + return GatewayResult::requiresCapture($transactionId, $metadata); + } + + public function capture(string $currencyCode, int $amount, string $transactionId, array $context = []): GatewayResult + { + $transaction = $this->transactions[$transactionId] ?? null; + if ($transaction === null) { + return GatewayResult::failed('transaction_not_found', 'No authorized transaction matches the given reference.', false, $transactionId); + } + + if ($transaction['captured']) { + return GatewayResult::failed('transaction_already_captured', 'The transaction has already been captured.', false, $transactionId); + } + + if ($transaction['currency'] !== $currencyCode || $transaction['amount'] !== $amount) { + return GatewayResult::failed('capture_amount_mismatch', 'The capture amount does not match the authorized amount.', false, $transactionId); + } + + if (str_contains($transaction['token'], self::MARKER_CAPTURE_FAIL)) { + // 与信は通ったが売上確定に失敗する系。与信自体は PSP 側に残るため再試行可とする。 + return GatewayResult::failed('capture_failed', 'The capture was rejected by the gateway.', true, $transactionId); + } + + $this->transactions[$transactionId]['captured'] = true; + + return GatewayResult::succeeded( + $transactionId, + array_merge($this->metadata($transactionId, $currencyCode, $amount), ['captured' => true]), + ); + } + + /** + * @param array $instrument + */ + private function token(array $instrument): string + { + $token = $instrument['token'] ?? ''; + + return is_string($token) ? trim($token) : ''; + } + + /** + * 追加認証が完了しているか. + * + * ACP の `authentication_result` は文字列とも構造体とも成り得るため、**空でない文字列**または + * **空でない配列**のみ認証済みと見なす。`null` / `''` / `[]` / `false` / 数値は未認証扱い + * (緩い判定は 3DS 中断シナリオを誤って成功させる)。 + * + * @param array $instrument + */ + private function isAuthenticated(array $instrument): bool + { + $result = $instrument['authentication_result'] ?? null; + + if (is_string($result)) { + return trim($result) !== ''; + } + + if (is_array($result)) { + return $result !== []; + } + + return false; + } + + /** + * 中断前の complete から引き継がれた取引識別子 (再開時のみ存在する). + * + * @param array $instrument + */ + private function existingTransactionId(array $instrument): ?string + { + $transactionId = $instrument['transaction_id'] ?? null; + if (!is_string($transactionId) || '' === trim($transactionId)) { + return null; + } + + return trim($transactionId); + } + + /** + * 取引識別子を決定的に導出する (状態を持たずに authorize と capture・再開 complete で一致させる). + * + * 通貨・金額・トークンだけでは、同額・同トークンの別注文が同じ識別子になってしまうため + * 注文参照も混ぜる。同一注文なら値は変わらないので、3DS 再開時にも同じ識別子が得られる。 + */ + private function transactionId(string $currencyCode, int $amount, string $token, string $orderReference): string + { + $seed = implode(':', [$currencyCode, (string) $amount, $token, $orderReference]); + + return 'pi_mock_'.substr(hash('sha256', $seed), 0, 24); + } + + /** + * 注文を識別する文字列 (受注番号 → 受注 ID の順に採用). + * + * @param array $context + */ + private function orderReference(array $context): string + { + foreach (['order_no', 'order_id'] as $key) { + $value = $context[$key] ?? null; + if (is_string($value) && trim($value) !== '') { + return trim($value); + } + if (is_int($value)) { + return (string) $value; + } + } + + return ''; + } + + /** + * @return array + */ + private function metadata(string $transactionId, string $currencyCode, int $amount): array + { + return [ + 'gateway' => 'mock', + 'transaction_id' => $transactionId, + 'currency' => $currencyCode, + 'amount' => $amount, + ]; + } + + /** + * EMV-3DS の追加認証メタデータ (ACP の `authentication_metadata` に整合). + * + * @return array + */ + private function authenticationActionData(): array + { + return [ + 'type' => '3ds', + 'authentication_required' => true, + 'authentication_metadata' => [ + 'acquirer_details' => [ + 'acquirer_bin' => '000000', + 'merchant_id' => 'mock_merchant', + ], + 'directory_server' => [ + 'name' => 'visa', + 'id' => 'A000000003', + ], + ], + ]; + } +} diff --git a/Service/AgentCommerce/PaymentTokenExtractor.php b/Service/AgentCommerce/PaymentTokenExtractor.php new file mode 100644 index 0000000..671ae0c --- /dev/null +++ b/Service/AgentCommerce/PaymentTokenExtractor.php @@ -0,0 +1,93 @@ + $source ACP の payment_data / UCP の credential + * + * @throws InvalidPaymentDataException トークンが存在しない、または空のとき + */ + public static function requireToken(array $source): string + { + $token = self::findToken($source); + if ($token === null) { + throw new InvalidPaymentDataException('A payment token is required but was not present in the payment data.'); + } + + return $token; + } + + /** + * 支払トークンを取り出す. 解決できない場合は null を返す. + * + * 対応する形 (先に見つかったものを採用): + * - `['token' => 'tok_x']` + * - `['credential' => 'tok_x']` / `['credential' => ['token' => 'tok_x']]` + * - `['instrument' => ['credential' => 'tok_x']]` / `['instrument' => ['credential' => ['token' => 'tok_x']]]` + * + * @param array $source + */ + public static function findToken(array $source): ?string + { + $candidates = [ + $source['token'] ?? null, + $source['credential'] ?? null, + $source['instrument']['credential'] ?? null, + ]; + + foreach ($candidates as $candidate) { + $token = self::normalize($candidate); + if ($token !== null) { + return $token; + } + } + + return null; + } + + /** + * 文字列またはトークンを持つ配列を、空でないトークン文字列へ正規化する. + */ + private static function normalize(mixed $candidate): ?string + { + if (is_array($candidate)) { + $candidate = $candidate['token'] ?? null; + } + + if (!is_string($candidate)) { + return null; + } + + $token = trim($candidate); + + return $token === '' ? null : $token; + } +} diff --git a/Service/AgentCommerce/Ucp/UcpSampleCardHandler.php b/Service/AgentCommerce/Ucp/UcpSampleCardHandler.php new file mode 100644 index 0000000..28af692 --- /dev/null +++ b/Service/AgentCommerce/Ucp/UcpSampleCardHandler.php @@ -0,0 +1,82 @@ + PaymentTokenExtractor::findToken($credential), + // UCP は ACP と異なり、追加認証の結果もクレデンシャル経由でしか届かない。ここで落とすと + // 再開 complete で認証済みと判定できず、requires_action から永久に復帰できなくなる。 + 'authentication_result' => $credential['authentication_result'] ?? null, + 'exchanged' => true, + ]; + } + + protected function protocolId(): int + { + return AgentProtocol::UCP; + } + + /** + * UCP の capture 失敗は**再試行を許す**. + * + * ready からの再 complete は新規 authorize から始まるが、UCP はエージェントが complete のたびに + * payment.instruments[].credential を送り直し、controller が {@link exchangePaymentToken()} で + * 交換をやり直す。つまり同じ入力から instrument を作り直せるため ready へ戻して再試行できる。 + * + * ワンショットのクレデンシャルを扱う PSP へ差し替える場合は false を返すこと。 + */ + protected function captureFailureIsRetryable(): bool + { + return true; + } + + protected function toGatewayInstrument(array $paymentData): array + { + // UCP は controller の resolvePaymentData() が exchangePaymentToken() 済みの中立データを渡す。 + // ただし handler_id を解決できないときは空配列が渡るため、トークンの検証はここでも行う (fail-closed)。 + return array_merge($paymentData, [ + 'token' => PaymentTokenExtractor::requireToken($paymentData), + ]); + } +} diff --git a/Tests/Service/AgentCommerce/Acp/AcpSampleCardHandlerTest.php b/Tests/Service/AgentCommerce/Acp/AcpSampleCardHandlerTest.php new file mode 100644 index 0000000..6a86dc8 --- /dev/null +++ b/Tests/Service/AgentCommerce/Acp/AcpSampleCardHandlerTest.php @@ -0,0 +1,245 @@ +handler(new MockAgentPaymentGateway()); + + $this->assertTrue($handler->supports($this->createOrder(AgentProtocol::ACP, CreditCard::class))); + $this->assertFalse($handler->supports($this->createOrder(AgentProtocol::UCP, CreditCard::class)), '別プロトコルの受注は扱わない'); + $this->assertFalse($handler->supports($this->createOrder(AgentProtocol::ACP, Convenience::class)), '別の支払方法は扱わない'); + $this->assertFalse($handler->supports($this->createOrder(null, CreditCard::class)), '通常購入の受注は扱わない'); + $this->assertFalse($handler->supports($this->createOrder(AgentProtocol::ACP, null)), '支払方法未割当の受注は扱わない'); + } + + public function testAuthorizeWithoutTokenFailsClosed(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('should_not_be_used')); + $outcome = $this->handler($spy)->authorize($this->createOrder(AgentProtocol::ACP, CreditCard::class), ['handler_id' => AcpSampleCardHandler::HANDLER_ID]); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status, 'token を伴わない complete を与信成功にしてはならない'); + $this->assertSame('invalid_payment_data', $outcome->errorCode); + $this->assertTrue($outcome->retryable, 'payment_data を直して再送すれば回復するため ready へ戻す'); + $this->assertSame([], $spy->authorizeCalls, '支払データを解決できない要求は PSP へ送らない'); + } + + public function testAuthorizeReturnsAuthorizedNotCompleted(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_1', ['gateway' => 'spy'])); + $outcome = $this->handler($spy)->authorize($this->createOrder(AgentProtocol::ACP, CreditCard::class), ['token' => 'tok_ok']); + + $this->assertSame(PaymentOutcomeStatus::AUTHORIZED, $outcome->status, '与信のみの結果を COMPLETED に潰さない (auto-capture 型 PSP での二重売上を防ぐ)'); + $this->assertSame('pi_1', $outcome->transactionId); + $this->assertSame(['gateway' => 'spy'], $outcome->metadata); + $this->assertSame(self::AMOUNT_IN_MINOR_UNITS, $spy->authorizeCalls[0]['amount'], '金額は minor unit 整数で渡す'); + $this->assertSame('tok_ok', $spy->authorizeCalls[0]['instrument']['token'] ?? null); + } + + public function testAutoCaptureGatewayIsMappedToCompleted(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::succeeded('ch_1')); + $outcome = $this->handler($spy)->authorize($this->createOrder(AgentProtocol::ACP, CreditCard::class), ['token' => 'tok_ok']); + + $this->assertSame(PaymentOutcomeStatus::COMPLETED, $outcome->status, '売上まで確定した PSP 応答は COMPLETED (コアは capture を呼ばない)'); + } + + public function testCaptureUsesAuthorizationReferenceWithoutRedeemingTokenAgain(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_2')); + $handler = new class(new MinorUnitConverter(), $spy, new NullLogger()) extends AcpSampleCardHandler { + public int $redeemCount = 0; + + public function redeemSharedPaymentToken(array $paymentData): array + { + ++$this->redeemCount; + + return parent::redeemSharedPaymentToken($paymentData); + } + }; + + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + $authorization = $handler->authorize($order, ['token' => 'tok_ok']); + $capture = $handler->capture($order, ['token' => 'tok_ok'], $authorization); + + $this->assertSame(1, $handler->redeemCount, 'SPT の償還はワンショット。capture で再償還してはならない'); + $this->assertSame('pi_2', $spy->captureCalls[0]['transactionId'] ?? null, 'capture は authorize が返した取引識別子を使う'); + $this->assertSame(PaymentOutcomeStatus::COMPLETED, $capture->status); + } + + public function testCaptureFailsWhenAuthorizationHasNoTransactionReference(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_3')); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], PaymentOutcome::authorized(null)); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status); + $this->assertSame('missing_transaction_reference', $outcome->errorCode); + $this->assertFalse($outcome->retryable, '再試行しても回復しない実装エラー'); + $this->assertSame([], $spy->captureCalls, '取引識別子が無ければ PSP を呼ばない'); + } + + public function testGatewayExceptionOnAuthorizeIsMappedToRetryableFailure(): void + { + $spy = new SpyAgentPaymentGateway(new \RuntimeException('connection reset')); + $outcome = $this->handler($spy)->authorize($this->createOrder(AgentProtocol::ACP, CreditCard::class), ['token' => 'tok_ok']); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status, 'PSP 通信例外は 500 でなく決済エラーとして返す'); + $this->assertSame('payment_gateway_error', $outcome->errorCode); + $this->assertTrue($outcome->retryable); + $this->assertStringNotContainsString('connection reset', $outcome->errorMessage ?? '', 'PSP の内部メッセージをエージェントへ露出しない'); + } + + public function testGatewayExceptionOnCaptureIsNotRetryableAndKeepsTransactionReference(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_4', ['gateway' => 'spy']), new \RuntimeException('timeout')); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']); + $outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization); + + $this->assertSame('payment_capture_error', $outcome->errorCode); + // コアに capture 単独の再実行入口は無く、ready からの再試行は新規 authorize = SPT の再償還になる。 + // ワンショットのため必ず失敗するので、ready へ戻さず canceled にする。 + $this->assertFalse($outcome->retryable, 'SPT は再償還できないため capture 失敗を再試行させない'); + $this->assertSame('pi_4', $outcome->transactionId, '取消・照会のため取引識別子を残す'); + $this->assertSame(['gateway' => 'spy'], $outcome->metadata, '照会に必要な metadata も引き継ぐ'); + } + + public function testGatewayCaptureFailureIsForcedNonRetryable(): void + { + // ゲートウェイが「再試行可」と言っても、ACP では再 authorize が成立しないため上書きする。 + $spy = new SpyAgentPaymentGateway( + GatewayResult::requiresCapture('pi_6'), + GatewayResult::failed('capture_failed', 'The capture was rejected.', true, 'pi_6'), + ); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']); + $outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status); + $this->assertSame('capture_failed', $outcome->errorCode); + $this->assertFalse($outcome->retryable); + } + + #[DataProvider('nonTerminalCaptureResults')] + public function testCaptureNeverReturnsNonTerminalOutcome(GatewayResult $captureResult): void + { + // コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」。中間状態を返すとコアは + // 失敗として扱うが、errorCode / errorMessage が無いぶん理由を伝えられない。 + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_7'), $captureResult); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']); + $outcome = $this->handler($spy)->capture($order, ['token' => 'tok_ok'], $authorization); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status, 'capture は COMPLETED か FAILED しか返さない'); + $this->assertSame('capture_unexpected_status', $outcome->errorCode); + $this->assertNotSame('', $outcome->errorMessage ?? '', '理由を伝えられるようメッセージを載せる'); + } + + /** + * capture が返してはならないゲートウェイ結果. + * + * @return \Iterator + */ + public static function nonTerminalCaptureResults(): \Iterator + { + yield 'requires_capture (未 capture のまま)' => [GatewayResult::requiresCapture('pi_7')]; + yield 'requires_action (capture 中の追加認証)' => [GatewayResult::requiresAction(['type' => '3ds'], 'pi_7')]; + yield 'processing (非同期確定)' => [GatewayResult::processing('pi_7')]; + } + + public function testRequiresActionCarriesActionDataMetadataAndReference(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresAction(['type' => '3ds'], 'pi_5', ['gateway' => 'spy'])); + $outcome = $this->handler($spy)->authorize($this->createOrder(AgentProtocol::ACP, CreditCard::class), ['token' => 'tok-3ds']); + + $this->assertSame(PaymentOutcomeStatus::REQUIRES_ACTION, $outcome->status); + $this->assertSame(['type' => '3ds'], $outcome->actionData); + $this->assertSame(['gateway' => 'spy'], $outcome->metadata, '再開時に PSP 参照を辿れるよう metadata を落とさない'); + $this->assertSame('pi_5', $outcome->transactionId); + } + + public function testRedeemSharedPaymentTokenCarriesAuthenticationResult(): void + { + $instrument = $this->handler(new MockAgentPaymentGateway()) + ->redeemSharedPaymentToken(['token' => 'tok-3ds', 'authentication_result' => ['outcome' => 'authenticated']]); + + $this->assertSame('tok-3ds', $instrument['token']); + $this->assertSame(['outcome' => 'authenticated'], $instrument['authentication_result'], '再開に必要な認証結果を落とさない'); + } + + public function testRedeemSharedPaymentTokenRejectsMissingToken(): void + { + $this->expectException(InvalidPaymentDataException::class); + $this->handler(new MockAgentPaymentGateway())->redeemSharedPaymentToken(['handler_id' => AcpSampleCardHandler::HANDLER_ID]); + } + + public function testThreeDomainSecureInterruptsThenResumesWithMockGateway(): void + { + $handler = $this->handler(new MockAgentPaymentGateway()); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $interrupted = $handler->authorize($order, ['token' => 'e2e-acp-spt-3ds']); + $this->assertSame(PaymentOutcomeStatus::REQUIRES_ACTION, $interrupted->status, '3DS は失敗でなく中断'); + + // 本体は中断時の PSP 参照を payment_data に保持し、再開 complete で第 3 引数として渡す。 + $paymentReference = ['transaction_id' => $interrupted->transactionId]; + $resumed = $handler->authorize($order, ['token' => 'e2e-acp-spt-3ds', 'authentication_result' => ['outcome' => 'authenticated']], $paymentReference); + $this->assertSame(PaymentOutcomeStatus::AUTHORIZED, $resumed->status, '認証結果を伴う再開で与信が成立する'); + $this->assertSame($interrupted->transactionId, $resumed->transactionId, '再開は中断前と同じ取引を続行する'); + + $captured = $handler->capture($order, ['token' => 'e2e-acp-spt-3ds'], $resumed); + $this->assertSame(PaymentOutcomeStatus::COMPLETED, $captured->status); + } + + public function testPaymentReferenceFromInterruptedAttemptIsPassedToGateway(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_resumed')); + $order = $this->createOrder(AgentProtocol::ACP, CreditCard::class); + + $this->handler($spy)->authorize($order, ['token' => 'tok_ok'], ['transaction_id' => 'pi_prior', 'gateway' => 'spy']); + + $this->assertSame('pi_prior', $spy->authorizeCalls[0]['instrument']['transaction_id'] ?? null, '再開時は中断前の取引識別子を PSP へ引き継ぐ (トークンの再償還を避ける)'); + } + + private function handler(AgentPaymentGatewayInterface $gateway): AcpSampleCardHandler + { + return new AcpSampleCardHandler(new MinorUnitConverter(), $gateway, new NullLogger()); + } +} diff --git a/Tests/Service/AgentCommerce/AgentCardHandlerTestCase.php b/Tests/Service/AgentCommerce/AgentCardHandlerTestCase.php new file mode 100644 index 0000000..d9192b3 --- /dev/null +++ b/Tests/Service/AgentCommerce/AgentCardHandlerTestCase.php @@ -0,0 +1,61 @@ +setCurrencyCode(self::CURRENCY) + ->setPaymentTotal(self::PAYMENT_TOTAL) + ->setOrderNo(self::ORDER_NO); + + if ($methodClass !== null) { + $Order->setPayment((new Payment())->setMethodClass($methodClass)); + } + + if ($protocolId !== null) { + $Order->setAgentProtocol((new AgentProtocol())->setId($protocolId)); + } + + return $Order; + } +} diff --git a/Tests/Service/AgentCommerce/Gateway/MockAgentPaymentGatewayTest.php b/Tests/Service/AgentCommerce/Gateway/MockAgentPaymentGatewayTest.php new file mode 100644 index 0000000..d4a355d --- /dev/null +++ b/Tests/Service/AgentCommerce/Gateway/MockAgentPaymentGatewayTest.php @@ -0,0 +1,269 @@ +gateway = new MockAgentPaymentGateway(); + } + + public function testAuthorizeWithoutTokenFailsClosed(): void + { + $result = $this->authorize([]); + + $this->assertSame(GatewayStatus::FAILED, $result->status, 'トークン無しの与信を成功させてはならない (無与信での受注確定を防ぐ)'); + $this->assertSame('invalid_payment_data', $result->errorCode); + $this->assertTrue($result->retryable, 'payment_data を直して再送すれば回復するため retryable'); + } + + /** + * @return array + */ + public static function provideBlankTokens(): array + { + return [ + '空文字' => [''], + '空白のみ' => [' '], + 'null' => [null], + '配列' => [[]], + '数値' => [0], + ]; + } + + #[DataProvider('provideBlankTokens')] + public function testAuthorizeWithBlankTokenFailsClosed(mixed $token): void + { + $result = $this->authorize(['token' => $token]); + + $this->assertSame(GatewayStatus::FAILED, $result->status, '空・型違いのトークンで与信を成功させてはならない'); + $this->assertSame('invalid_payment_data', $result->errorCode); + } + + public function testPlainTokenIsAuthorizedButNotCaptured(): void + { + $result = $this->authorize(['token' => 'tok_plain']); + + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $result->status, '通常のトークンは与信のみ成功し capture を要する'); + $this->assertNotNull($result->transactionId); + $this->assertSame('mock', $result->metadata['gateway'] ?? null); + } + + public function testFraudTokenIsUnrecoverableFailure(): void + { + $result = $this->authorize(['token' => 'tok-fraud']); + + $this->assertSame(GatewayStatus::FAILED, $result->status); + $this->assertSame('card_not_supported', $result->errorCode); + $this->assertFalse($result->retryable, '不正検知は再試行しても回復しない'); + } + + public function testDeclineTokenIsRetryableFailure(): void + { + $result = $this->authorize(['token' => 'tok-decline']); + + $this->assertSame(GatewayStatus::FAILED, $result->status); + $this->assertSame('card_declined', $result->errorCode); + $this->assertTrue($result->retryable, '与信拒否は別カードでの再試行が可能'); + } + + public function testProcessingTokenIsPending(): void + { + $result = $this->authorize(['token' => 'tok-processing']); + + $this->assertSame(GatewayStatus::PROCESSING, $result->status); + } + + public function testMarkerEvaluationOrderIsDocumentedAndDeterministic(): void + { + // 複数マーカーを含むトークンは docblock の表の順 (fraud → decline → 3ds → processing) で解決する。 + $this->assertSame('card_not_supported', $this->authorize(['token' => 'tok-fraud-decline-3ds'])->errorCode, 'fraud が最優先'); + $this->assertSame('card_declined', $this->authorize(['token' => 'tok-decline-3ds'])->errorCode, 'decline は 3ds より優先'); + $this->assertSame(GatewayStatus::REQUIRES_ACTION, $this->authorize(['token' => 'tok-3ds-processing'])->status, '3ds は processing より優先'); + } + + public function testMarkerMatchingIsCaseSensitiveAndHyphenated(): void + { + // docblock の規約 (`*-3ds*`・大文字小文字を区別) と実装を一致させる。 + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $this->authorize(['token' => 'tok-3DS'])->status, '大文字の 3DS はマーカーに一致しない'); + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $this->authorize(['token' => 'tok3ds'])->status, 'ハイフンの無い 3ds はマーカーに一致しない'); + $this->assertSame(GatewayStatus::REQUIRES_ACTION, $this->authorize(['token' => 'tok-3ds'])->status, 'ハイフン付きの -3ds のみ一致する'); + } + + public function test3dsTokenRequiresActionAndCarriesReference(): void + { + $result = $this->authorize(['token' => 'tok-3ds']); + + $this->assertSame(GatewayStatus::REQUIRES_ACTION, $result->status); + $this->assertSame('3ds', $result->actionData['type'] ?? null); + $this->assertNotNull($result->transactionId, '再開時に辿れるよう取引識別子を返す'); + $this->assertNotSame([], $result->metadata, '中断時も PSP 参照を payment_data へ残せるよう metadata を返す'); + } + + /** + * @return array + */ + public static function provideAuthenticationResults(): array + { + return [ + '構造化された認証結果' => [['outcome' => 'authenticated'], true], + '文字列の認証結果' => ['authenticated', true], + 'null' => [null, false], + '空文字' => ['', false], + '空白のみ' => [' ', false], + '空配列' => [[], false], + 'false' => [false, false], + '数値の 1' => [1, false], + ]; + } + + #[DataProvider('provideAuthenticationResults')] + public function testAuthenticationResultIsEvaluatedStrictly(mixed $authenticationResult, bool $expectAuthenticated): void + { + $result = $this->authorize(['token' => 'tok-3ds', 'authentication_result' => $authenticationResult]); + + $this->assertSame( + $expectAuthenticated ? GatewayStatus::REQUIRES_CAPTURE : GatewayStatus::REQUIRES_ACTION, + $result->status, + '認証済み判定は「空でない文字列/配列」のみ。緩い判定は 3DS 中断シナリオを誤って成功させる', + ); + } + + public function testTransactionIdDependsOnOrderReference(): void + { + $first = $this->authorize(['token' => 'tok_same'], ['order_no' => 'A-1']); + $second = $this->authorize(['token' => 'tok_same'], ['order_no' => 'A-2']); + + $this->assertNotSame($first->transactionId, $second->transactionId, '同額・同トークンでも別注文なら取引識別子は衝突しない'); + } + + public function testTransactionIdIsStableForSameOrder(): void + { + // 3DS の中断→再開は別リクエストになるため、同じ入力から同じ識別子が導ける必要がある。 + $first = $this->authorize(['token' => 'tok-3ds'], ['order_no' => 'A-1']); + $resumed = $this->authorize(['token' => 'tok-3ds', 'authentication_result' => ['outcome' => 'authenticated']], ['order_no' => 'A-1']); + + $this->assertSame($first->transactionId, $resumed->transactionId, '同一注文の再開では同じ取引識別子になる'); + } + + public function testExistingTransactionIdIsReusedOnResume(): void + { + // 再開 complete では本体が中断前の PSP 参照を渡す。導出し直さずその取引を続行する。 + $result = $this->authorize([ + 'token' => 'tok-3ds', + 'authentication_result' => ['outcome' => 'authenticated'], + 'transaction_id' => 'pi_mock_from_prior_attempt', + ]); + + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $result->status); + $this->assertSame('pi_mock_from_prior_attempt', $result->transactionId, '引き継がれた取引識別子をそのまま使う'); + } + + public function testSameTokenCannotBeRedeemedForAnotherOrder(): void + { + $this->authorize(['token' => 'tok_shared'], ['order_no' => 'A-1']); + $result = $this->authorize(['token' => 'tok_shared'], ['order_no' => 'A-2']); + + $this->assertSame(GatewayStatus::FAILED, $result->status, '共有支払トークンは 1 取引限り (実 PSP のワンショット償還を模す)'); + $this->assertSame('token_already_redeemed', $result->errorCode); + $this->assertFalse($result->retryable); + } + + public function testSameTokenIsAcceptedForSameOrderOnResume(): void + { + $this->authorize(['token' => 'tok-3ds'], ['order_no' => 'A-1']); + $result = $this->authorize(['token' => 'tok-3ds', 'authentication_result' => ['outcome' => 'authenticated']], ['order_no' => 'A-1']); + + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $result->status, '同一注文の再開 complete は同じトークンで続行できる'); + } + + public function testCaptureSucceedsForAuthorizedTransaction(): void + { + $authorization = $this->authorize(['token' => 'tok_plain']); + $result = $this->gateway->capture(self::CURRENCY, self::AMOUNT, (string) $authorization->transactionId); + + $this->assertSame(GatewayStatus::SUCCEEDED, $result->status); + $this->assertTrue($result->metadata['captured'] ?? false); + } + + public function testCaptureRejectsUnknownTransaction(): void + { + $result = $this->gateway->capture(self::CURRENCY, self::AMOUNT, 'pi_mock_unknown'); + + $this->assertSame(GatewayStatus::FAILED, $result->status, '与信していない取引は capture できない'); + $this->assertSame('transaction_not_found', $result->errorCode); + $this->assertFalse($result->retryable); + } + + public function testCaptureIsNotIdempotentlyRepeatable(): void + { + $authorization = $this->authorize(['token' => 'tok_plain']); + $this->gateway->capture(self::CURRENCY, self::AMOUNT, (string) $authorization->transactionId); + $result = $this->gateway->capture(self::CURRENCY, self::AMOUNT, (string) $authorization->transactionId); + + $this->assertSame(GatewayStatus::FAILED, $result->status, '二重 capture は実 PSP で失敗するためモックでも失敗させる'); + $this->assertSame('transaction_already_captured', $result->errorCode); + } + + public function testCaptureRejectsAmountMismatch(): void + { + $authorization = $this->authorize(['token' => 'tok_plain']); + $result = $this->gateway->capture(self::CURRENCY, self::AMOUNT + 1, (string) $authorization->transactionId); + + $this->assertSame(GatewayStatus::FAILED, $result->status); + $this->assertSame('capture_amount_mismatch', $result->errorCode); + } + + public function testCaptureFailTokenAuthorizesThenFailsOnCapture(): void + { + $authorization = $this->authorize(['token' => 'tok-capture-fail']); + $this->assertSame(GatewayStatus::REQUIRES_CAPTURE, $authorization->status, 'capture 失敗シナリオでも与信は成功する'); + + $result = $this->gateway->capture(self::CURRENCY, self::AMOUNT, (string) $authorization->transactionId); + + $this->assertSame(GatewayStatus::FAILED, $result->status, '本体の capture 失敗分岐を結合テストで通せるようにする'); + $this->assertSame('capture_failed', $result->errorCode); + $this->assertTrue($result->retryable, '与信は PSP 側に残るため ready へ戻して再試行・取消を可能にする'); + $this->assertSame($authorization->transactionId, $result->transactionId, '失敗時も取引識別子を返す (照会・取消に必要)'); + } + + /** + * @param array $instrument + * @param array $context + */ + private function authorize(array $instrument, array $context = ['order_no' => 'A-1']): GatewayResult + { + return $this->gateway->authorize(self::CURRENCY, self::AMOUNT, $instrument, $context); + } +} diff --git a/Tests/Service/AgentCommerce/Gateway/SpyAgentPaymentGateway.php b/Tests/Service/AgentCommerce/Gateway/SpyAgentPaymentGateway.php new file mode 100644 index 0000000..d8fa405 --- /dev/null +++ b/Tests/Service/AgentCommerce/Gateway/SpyAgentPaymentGateway.php @@ -0,0 +1,60 @@ +, context: array}> */ + public array $authorizeCalls = []; + + /** @var array}> */ + public array $captureCalls = []; + + public function __construct( + private readonly GatewayResult|\Throwable $authorizeResult, + private readonly GatewayResult|\Throwable|null $captureResult = null, + ) { + } + + public function authorize(string $currencyCode, int $amount, array $instrument, array $context = []): GatewayResult + { + $this->authorizeCalls[] = ['currency' => $currencyCode, 'amount' => $amount, 'instrument' => $instrument, 'context' => $context]; + + if ($this->authorizeResult instanceof \Throwable) { + throw $this->authorizeResult; + } + + return $this->authorizeResult; + } + + public function capture(string $currencyCode, int $amount, string $transactionId, array $context = []): GatewayResult + { + $this->captureCalls[] = ['currency' => $currencyCode, 'amount' => $amount, 'transactionId' => $transactionId, 'context' => $context]; + + if ($this->captureResult instanceof \Throwable) { + throw $this->captureResult; + } + + return $this->captureResult ?? GatewayResult::succeeded($transactionId, ['captured' => true]); + } +} diff --git a/Tests/Service/AgentCommerce/PaymentTokenExtractorTest.php b/Tests/Service/AgentCommerce/PaymentTokenExtractorTest.php new file mode 100644 index 0000000..3e3d47e --- /dev/null +++ b/Tests/Service/AgentCommerce/PaymentTokenExtractorTest.php @@ -0,0 +1,98 @@ +, string}> + */ + public static function provideResolvableSources(): array + { + return [ + 'token 直下' => [['token' => 'tok_1'], 'tok_1'], + 'credential が文字列' => [['credential' => 'tok_2'], 'tok_2'], + 'credential 配下の token' => [['credential' => ['token' => 'tok_3']], 'tok_3'], + 'instrument.credential が文字列' => [['instrument' => ['credential' => 'tok_4']], 'tok_4'], + 'instrument.credential 配下の token' => [['instrument' => ['credential' => ['token' => 'tok_5']]], 'tok_5'], + '前後の空白は除去する' => [['token' => " tok_6\n"], 'tok_6'], + 'token 直下を優先する' => [['token' => 'tok_7', 'credential' => 'tok_other'], 'tok_7'], + ]; + } + + /** + * @param array $source + */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideResolvableSources')] + public function testFindTokenResolvesSupportedShapes(array $source, string $expected): void + { + $this->assertSame($expected, PaymentTokenExtractor::findToken($source), 'ACP/UCP 双方の支払データ形からトークンを解決する'); + } + + /** + * @return array}> + */ + public static function provideUnresolvableSources(): array + { + return [ + '空配列' => [[]], + 'handler_id のみ (トークン無し)' => [['handler_id' => 'card_tokenized']], + 'token が空文字' => [['token' => '']], + 'token が空白のみ' => [['token' => ' ']], + 'token が null' => [['token' => null]], + 'token が数値' => [['token' => 123]], + 'token が真偽値' => [['token' => true]], + 'credential が空配列' => [['credential' => []]], + 'credential の token が空' => [['credential' => ['token' => '']]], + 'instrument.credential が空配列' => [['instrument' => ['credential' => []]]], + ]; + } + + /** + * @param array $source + */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideUnresolvableSources')] + public function testFindTokenReturnsNullWhenUnresolvable(array $source): void + { + $this->assertNull( + PaymentTokenExtractor::findToken($source), + '解決できない支払データで空文字を返してはならない (空文字はトークン規約に一致せず「正常な支払」と誤解される)', + ); + } + + /** + * @param array $source + */ + #[\PHPUnit\Framework\Attributes\DataProvider('provideUnresolvableSources')] + public function testRequireTokenThrowsWhenUnresolvable(array $source): void + { + $this->expectException(InvalidPaymentDataException::class); + PaymentTokenExtractor::requireToken($source); + } + + public function testRequireTokenReturnsResolvedToken(): void + { + $this->assertSame('tok_ok', PaymentTokenExtractor::requireToken(['token' => 'tok_ok'])); + } +} diff --git a/Tests/Service/AgentCommerce/Ucp/UcpSampleCardHandlerTest.php b/Tests/Service/AgentCommerce/Ucp/UcpSampleCardHandlerTest.php new file mode 100644 index 0000000..f412faf --- /dev/null +++ b/Tests/Service/AgentCommerce/Ucp/UcpSampleCardHandlerTest.php @@ -0,0 +1,166 @@ +handler(new MockAgentPaymentGateway()); + + $this->assertTrue($handler->supports($this->createOrder(AgentProtocol::UCP, CreditCard::class))); + $this->assertFalse($handler->supports($this->createOrder(AgentProtocol::ACP, CreditCard::class)), '別プロトコルの受注は扱わない'); + $this->assertFalse($handler->supports($this->createOrder(AgentProtocol::UCP, Convenience::class)), '別の支払方法は扱わない'); + $this->assertFalse($handler->supports($this->createOrder(null, CreditCard::class)), '通常購入の受注は扱わない'); + } + + public function testExchangePaymentTokenCarriesAuthenticationResult(): void + { + $exchanged = $this->handler(new MockAgentPaymentGateway()) + ->exchangePaymentToken(['token' => 'tok-3ds', 'authentication_result' => ['outcome' => 'authenticated']]); + + $this->assertSame('tok-3ds', $exchanged['token']); + $this->assertSame( + ['outcome' => 'authenticated'], + $exchanged['authentication_result'], + 'UCP は認証結果もクレデンシャル経由でしか届かない。落とすと requires_action から復帰できない', + ); + } + + public function testExchangePaymentTokenDoesNotThrowOnMissingToken(): void + { + // 本体 UcpCheckoutController::resolvePaymentData() は complete の状態機械の外側で本メソッドを + // 呼ぶため、ここで例外を投げるとビジネス系エラーでなく HTTP 500 になる。 + $exchanged = $this->handler(new MockAgentPaymentGateway())->exchangePaymentToken(['type' => 'card']); + + $this->assertNull($exchanged['token'], 'トークンを解決できなくても例外にせず null で返す'); + } + + /** + * @return array}> + */ + public static function provideUnresolvablePaymentData(): array + { + return [ + 'handler_id を解決できず空配列が渡る' => [[]], + 'credential に token が無い' => [['type' => 'card']], + 'token が空文字' => [['token' => '']], + ]; + } + + /** + * @param array $paymentData + */ + #[DataProvider('provideUnresolvablePaymentData')] + public function testAuthorizeWithUnresolvablePaymentDataFailsClosed(array $paymentData): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('should_not_be_used')); + $handler = $this->handler($spy); + $outcome = $handler->authorize($this->createOrder(AgentProtocol::UCP, CreditCard::class), $handler->exchangePaymentToken($paymentData)); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status, 'credential 不在の complete を与信成功にしてはならない'); + $this->assertSame('invalid_payment_data', $outcome->errorCode); + $this->assertTrue($outcome->retryable, 'credential を直して再送すれば回復するため ready へ戻す'); + $this->assertSame([], $spy->authorizeCalls, '支払データを解決できない要求は PSP へ送らない'); + } + + public function testAuthorizeReturnsAuthorizedNotCompleted(): void + { + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_1')); + $handler = $this->handler($spy); + $order = $this->createOrder(AgentProtocol::UCP, CreditCard::class); + + $outcome = $handler->authorize($order, $handler->exchangePaymentToken(['token' => 'tok_ok'])); + + $this->assertSame(PaymentOutcomeStatus::AUTHORIZED, $outcome->status, '与信のみの結果を COMPLETED に潰さない'); + $this->assertSame('tok_ok', $spy->authorizeCalls[0]['instrument']['token'] ?? null); + } + + public function testThreeDomainSecureInterruptsThenResumesThroughCredential(): void + { + $handler = $this->handler(new MockAgentPaymentGateway()); + $order = $this->createOrder(AgentProtocol::UCP, CreditCard::class); + + // 初回 complete: 認証結果なしのクレデンシャル。 + $interrupted = $handler->authorize($order, $handler->exchangePaymentToken(['token' => 'e2e-ucp-3ds'])); + $this->assertSame(PaymentOutcomeStatus::REQUIRES_ACTION, $interrupted->status, '3DS は失敗でなく中断'); + + // 再開 complete: エージェントは credential に認証結果を載せて再送する。 + $resumed = $handler->authorize($order, $handler->exchangePaymentToken([ + 'token' => 'e2e-ucp-3ds', + 'authentication_result' => ['outcome' => 'authenticated'], + ])); + $this->assertSame(PaymentOutcomeStatus::AUTHORIZED, $resumed->status, 'UCP でも認証結果を伴えば再開できる'); + + $captured = $handler->capture($order, [], $resumed); + $this->assertSame(PaymentOutcomeStatus::COMPLETED, $captured->status); + } + + public function testCaptureFailureIsRetryableAndKeepsReference(): void + { + $handler = $this->handler(new MockAgentPaymentGateway()); + $order = $this->createOrder(AgentProtocol::UCP, CreditCard::class); + + $authorization = $handler->authorize($order, $handler->exchangePaymentToken(['token' => 'e2e-ucp-capture-fail'])); + $this->assertSame(PaymentOutcomeStatus::AUTHORIZED, $authorization->status); + + $captured = $handler->capture($order, [], $authorization); + $this->assertSame(PaymentOutcomeStatus::FAILED, $captured->status, 'capture 失敗の分岐を検証できる規約を持つ'); + $this->assertSame('capture_failed', $captured->errorCode); + // UCP はエージェントが complete のたびに credential を送り直すため、ready からの再試行で + // exchange → authorize をやり直せる (ACP の SPT と非対称なのはここ)。 + $this->assertTrue($captured->retryable, 'credential を再送すれば新規 authorize からやり直せる'); + $this->assertSame($authorization->transactionId, $captured->transactionId); + } + + public function testCaptureNeverReturnsNonTerminalOutcome(): void + { + // コアの契約は「capture の戻り値は COMPLETED か FAILED のみ」。 + $spy = new SpyAgentPaymentGateway(GatewayResult::requiresCapture('pi_u1'), GatewayResult::processing('pi_u1')); + $order = $this->createOrder(AgentProtocol::UCP, CreditCard::class); + + $authorization = $this->handler($spy)->authorize($order, ['token' => 'tok_ok']); + $outcome = $this->handler($spy)->capture($order, [], $authorization); + + $this->assertSame(PaymentOutcomeStatus::FAILED, $outcome->status); + $this->assertSame('capture_unexpected_status', $outcome->errorCode); + $this->assertTrue($outcome->retryable, 'UCP は再 authorize できるため契約違反でも ready へ戻す'); + } + + private function handler(AgentPaymentGatewayInterface $gateway): UcpSampleCardHandler + { + return new UcpSampleCardHandler(new MinorUnitConverter(), $gateway, new NullLogger()); + } +} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php new file mode 100644 index 0000000..fc847de --- /dev/null +++ b/Tests/bootstrap.php @@ -0,0 +1,21 @@ +load($envFile); +} diff --git a/tests/admin_order_edit.test.ts b/e2e/admin_order_edit.test.ts similarity index 100% rename from tests/admin_order_edit.test.ts rename to e2e/admin_order_edit.test.ts diff --git a/tests/guest_convini.test.ts b/e2e/guest_convini.test.ts similarity index 100% rename from tests/guest_convini.test.ts rename to e2e/guest_convini.test.ts diff --git a/tests/guest_credit_link.test.ts b/e2e/guest_credit_link.test.ts similarity index 100% rename from tests/guest_credit_link.test.ts rename to e2e/guest_credit_link.test.ts diff --git a/tests/guest_credit_token.test.ts b/e2e/guest_credit_token.test.ts similarity index 100% rename from tests/guest_credit_token.test.ts rename to e2e/guest_credit_token.test.ts diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e262eef..80e7288 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -13,12 +13,15 @@ parameters: excludePaths: - vendor/* - node_modules/* + # 本体の autoload を読み込むだけのブートストラップ (解析対象外) + - Tests/bootstrap.php paths: - Controller - Entity - Form - Repository - Service + - Tests - Util - PluginManager.php - SamplePaymentEvent.php diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..274abad --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + ./Tests + + + + + + + ./ + + + ./Tests + ./Resource + ./PluginManager.php + + + + + + + + diff --git a/playwright.config.ts b/playwright.config.ts index 0b1b9a9..f60f676 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,7 +11,7 @@ import { devices } from '@playwright/test'; * See https://playwright.dev/docs/test-configuration. */ const config: PlaywrightTestConfig = { - testDir: './tests', + testDir: './e2e', /* Maximum time one test can run for. */ timeout: 30 * 1000, expect: {