Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
name: Playwright Tests
on:
push:
branches: [ '4.2' ]
branches: [ '4.4' ]
pull_request:
paths:
- '**'
Expand All @@ -10,38 +10,38 @@ jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
env:
TAG: ${{ matrix.tag }}
strategy:
fail-fast: false
matrix:
operating-system: [ ubuntu-20.04 ]
tag: [ '8.2-apache-4.4', '8.3-apache-4.4', '8.4-apache-4.4', '8.5-apache-4.4' ]
db: [ mysql, pgsql, sqlite3 ]
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Setup environment
if: matrix.db != 'sqlite3'
env:
DB_TYPE: ${{ matrix.db }}
run: echo "COMPOSE_FILE=docker-compose.yml:docker-compose.${DB_TYPE}.yml:docker-compose.dev.yml" >> $GITHUB_ENV
- name: Setup environment
if: matrix.db == 'sqlite3'
env:
DB_TYPE: ${{ matrix.db }}
run: echo "COMPOSE_FILE=docker-compose.yml:docker-compose.dev.yml" >> $GITHUB_ENV
- name: Setup EC-CUBE
run: docker compose up -d --wait
- name: Install dependencies
run: npm ci
run: yarn install --frozen-lockfile
- name: Install Playwright Browsers
run: npx playwright install
run: yarn playwright install
- run: docker compose logs ec-cube
- name: Run Playwright tests
env:
CI: 1
FORCE_COLOR: 1
run: yarn playwright test
- uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
name: playwright-report-${{ matrix.tag }}-${{ matrix.db }}
path: playwright-report/
retention-days: 30
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
composer.phar
/vendor/
.php-cs-fixer.cache

# Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
# You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
# composer.lock
# 本プラグインはライブラリ (type: eccube-plugin) のため composer.lock はコミットしない
composer.lock
node_modules/
/test-results/
/playwright-report/
/playwright/.cache/
/test-results/
/playwright-report/
/playwright/.cache/
package-lock.json
122 changes: 122 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## このリポジトリについて

EC-CUBE 4 系の**決済プラグイン実装サンプル**。実際の決済代行会社向けプラグインを実装する開発者の参考実装として、3 種類の決済方式を 1 プラグインに収めている。

- **リンク型クレジットカード決済** (`LinkCreditCard`) — 外部決済サーバの入力画面へリダイレクトする方式
- **トークン型クレジットカード決済** (`CreditCard`) — トークンを受け取り自サイト内で完結する方式
- **コンビニ決済** (`Convenience`) — 入金待ちステータスを持つ方式

プラグインコードは `SamplePayment44`、Composer パッケージ名は `ec-cube/samplepayment44`。コード中の Twig 名前空間・クラス名前空間・トランス キーはすべて `SamplePayment44` 接頭辞を使う。

### ブランチ運用

ブランチ名が対応する EC-CUBE 本体バージョンを表す (`4.2`, `support-4.3`, `4.4` など)。`4.2` がデフォルトブランチ。本体 API の差異に応じて各バージョン用ブランチを保守している。各バージョンで動作する Docker イメージは `docker-compose.yml` の `image:` タグ (例 `ghcr.io/ec-cube/ec-cube-php:7.4-apache-4.2`) で固定されている。

## 開発・テストコマンド

このプラグイン単体では動作せず、**EC-CUBE 本体に組み込んだ状態**で開発・テストする。本体への組み込みと有効化は `docker-compose.dev.yml` の entrypoint が自動実行する (`eccube:composer:require` → `eccube:plugin:enable` → `dtb_payment_option` への INSERT)。

```bash
# 開発環境 (SQLite) の起動 — 本体取得・プラグイン有効化まで自動
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait

# MySQL / PostgreSQL で起動する場合は対応ファイルを重ねる
docker compose -f docker-compose.yml -f docker-compose.mysql.yml -f docker-compose.dev.yml up -d --wait
docker compose -f docker-compose.yml -f docker-compose.pgsql.yml -f docker-compose.dev.yml up -d --wait

# 起動確認・ログ
docker compose logs ec-cube
```

起動後のフロント URL は `https://localhost:4430` (自己署名証明書のため `ignoreHTTPSErrors`)、メールは MailCatcher (`http://localhost:1080`) で受信。

### E2E テスト (Playwright)

テストは `tests/*.test.ts` の Playwright E2E のみ (PHPUnit は無い)。`baseURL` は `https://localhost:4430`、対象は起動中の Docker 環境。

```bash
npm ci # 依存インストール
npx playwright install # ブラウザ取得
yarn playwright test # 全 E2E 実行
yarn playwright test tests/guest_credit_token.test.ts # 単一ファイル実行
npx playwright test -g "テスト名" # タイトル一致で単一テスト実行
```

テストファイルは決済方式ごと: `guest_credit_link` (リンク型) / `guest_credit_token` (トークン型) / `guest_convini` (コンビニ)。CI (`.github/workflows/playwright.yml`) は mysql / pgsql / sqlite3 のマトリクスで実行される。

## 決済プラグインのアーキテクチャ

### 決済処理ライフサイクル (最重要)

各決済方式は `Eccube\Service\Payment\PaymentMethodInterface` を実装する `Service/Method/*.php` で、本体の購入フロー (`PurchaseFlow`) と連携して 3 段階で呼ばれる。**この 3 メソッドの責務分担が決済プラグイン実装の核心**:

1. **`verify()`** — 注文確認画面遷移時。カードの有効性チェック等。`PaymentResult` を返す。トークン型はここでカード下 4 桁を取得・保持する。リンク型は実質何もしない。
2. **`apply()`** — 注文確定時、決済実行**前**。受注ステータスを「決済処理中(PENDING)」、決済ステータスを「未決済(OUTSTANDING)」に変更し `purchaseFlow->prepare()` を呼ぶ。**リンク型はここで `PaymentDispatcher` に `RedirectResponse` を載せて返し、外部決済画面へ遷移させる**(`checkout()` は使わない)。
3. **`checkout()`** — 注文確定時、決済実行。決済成功時は受注ステータスを NEW、決済ステータスを「仮売上(PROVISIONAL_SALES)」にして `purchaseFlow->commit()`、失敗時は `purchaseFlow->rollback()` してエラーを `PaymentResult` に詰める。

成功/失敗は必ず `PaymentResult::setSuccess()` で表現し、`purchaseFlow` の `prepare`/`commit`/`rollback` と受注・決済ステータス更新を**対で**行うのが規約。各 Method は本体の `PurchaseFlow $shoppingPurchaseFlow` を DI で受け取る。

### リンク型決済のリダイレクトフロー

リンク型は `apply()` でのリダイレクト後、外部決済サーバとのやり取りを `Controller/PaymentController.php` (注文/戻る/完了通知) と `Controller/PaymentCompanyController.php` (決済会社画面の模擬) で処理する。本物の決済プラグインではここが Webhook / コールバック受信に相当する。

### Entity 拡張 (trait + @EntityExtension)

本体の既存 Entity にカラムを追加する際は `Entity/*Trait.php` に trait を定義し、クラス DocComment に `@EntityExtension("Eccube\Entity\Order")` を付与する。本サンプルでは:

- `OrderTrait` — `Order` にトークン・カード下 4 桁・コンビニ種別・決済ステータスを追加 (`dtb_order.sample_payment_*` カラム)。下 4 桁のみ永続化せず確認画面表示用。
- `CustomerTrait` — `Customer` にカード情報変更機能用のカラムを追加。

プラグイン独自 Entity (`Config`, `PaymentStatus`, `CvsPaymentStatus`, `CvsType`) は通常の Doctrine Entity として `Entity/` に置き、対応する `Repository/` を持つ。

### 画面への介入 (TemplateEvent)

`SamplePaymentEvent.php` (`EventSubscriberInterface`) が `getSubscribedEvents()` でフックする Twig を宣言し、`TemplateEvent::addSnippet()` で `Resource/template/*.twig` を差し込む。本体テンプレートを直接編集せずに購入画面・確認画面・管理画面注文編集・マイページナビへ UI を追加する。`SamplePaymentNav.php` が管理画面メニュー、`SamplePaymentTwigBlock.php` がブロックを追加する。

### PluginManager (インストール時処理)

`PluginManager.php` の `enable()` が有効化時に実行される。決済方法 (`Payment` レコード) 3 種・初期設定・各種マスタ (PaymentStatus / CvsPaymentStatus / CvsType) ・マイページのカード情報変更ページ (`createPages()`) を登録する。決済方法と `Service/Method/*` クラスの紐付けもここで行う。

### スロットリング設定

`Resource/config/services.yaml` の `eccube.rate_limiter` でルート単位のレート制限を宣言できる (本サンプルではカード情報変更 `sample_payment_mypage_card_info` を ip/customer で 60 分 5 回に制限)。EC-CUBE 4.2+ のレートリミッタ機能を使う実装例。

### 開発ツール設定ファイルは `Resource/` 配下に置く (rector.php / .php-cs-fixer.dist.php)

`rector.php` や `.php-cs-fixer.dist.php` を**プラグインのルート直下に置いてはならない**。`Resource/rector.php` のように `Resource/` 配下に置く。

**理由**: EC-CUBE 本体の `config/eccube/services.yaml` がプラグインを丸ごと PSR-4 サービス検出対象として読み込む:

```yaml
Plugin\:
resource: '../../../app/Plugin/*'
exclude: '../../../app/Plugin/*/{Entity,Resource,ServiceProvider,Tests,Codeception,DoctrineMigrations}'
```

`app/Plugin/SamplePayment44/` 直下のすべての `*.php` が「サービスクラス」として読み込まれるため、ルートに `rector.php` を置くと Symfony が `Plugin\SamplePayment44\rector` クラスを期待し、見つからず **EC-CUBE 全体が 500 エラー**になる (実際に遭遇したエラー):

```
Expected to find class "Plugin\SamplePayment44\rector" in file
".../app/Plugin/SamplePayment44/rector.php" while importing services from
resource "../../../app/Plugin/*", but it was not found!
```

上記 `exclude` に `Resource` が含まれるため、`Resource/` 配下に置けばサービス検出から外れ衝突しない。

**本体では問題にならない理由**: 本体の autoconfigure 対象は `src/Eccube/*` で、プロジェクトルートはその外。ルート直下の `rector.php` / `.php-cs-fixer.dist.php` はグロブにかからないため本体では慣習どおりルートに置ける。プラグインは「ルートディレクトリ自体が PSR-4 ルートかつサービス検出対象」という点が決定的に異なる。

**トレードオフ / 運用上の注意**:
- 代償として `__DIR__` 基準のパスを `dirname(__DIR__)` に変更し、実行時に `--config=Resource/rector.php` を明示する必要がある。
- 代替案 (ルートに置いて本体の `exclude` に追記) は本体改変が必要でプラグインの独立性を損なうため不可。プラグイン側の `services.yaml` では本体の `Plugin\:` 定義を上書きできない。
- `.php-cs-fixer.dist.php` はドット始まりのため単体ではグロブに当たらない可能性もあるが (未検証)、`rector.php` と配置・運用を揃える一貫性のため同じ `Resource/` に置く。
- **将来「本体に合わせてルートへ戻す」とリグレッションするため、この配置を変更しないこと。**

## 規約メモ

- 命名規約は本体の `eccube:plugin:generate` が生成する推奨ディレクトリ構成に合わせる (詳細は README.md および [issue #6](https://github.com/EC-CUBE/sample-payment-plugin/issues/6))。
- 翻訳は `Resource/locale/messages.ja.yaml` / `validators.ja.yaml`。コードからは `trans('sample_payment.xxx')` で参照する。
- 全 PHP ファイル冒頭に EC-CUBE 標準のライセンスヘッダを付与する。
24 changes: 8 additions & 16 deletions Controller/Admin/ConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,36 +11,28 @@
* file that was distributed with this source code.
*/

namespace Plugin\SamplePayment42\Controller\Admin;
namespace Plugin\SamplePayment44\Controller\Admin;

use Eccube\Controller\AbstractController;
use Plugin\SamplePayment42\Form\Type\Admin\ConfigType;
use Plugin\SamplePayment42\Repository\ConfigRepository;
use Symfony\Component\Routing\Annotation\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Plugin\SamplePayment44\Form\Type\Admin\ConfigType;
use Plugin\SamplePayment44\Repository\ConfigRepository;
use Symfony\Bridge\Twig\Attribute\Template;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

class ConfigController extends AbstractController
{
/**
* @var ConfigRepository
*/
protected $configRepository;

/**
* ConfigController constructor.
*
* @param ConfigRepository $configRepository
*/
public function __construct(ConfigRepository $configRepository)
public function __construct(protected ConfigRepository $configRepository)
{
$this->configRepository = $configRepository;
}

/**
* @Route("/%eccube_admin_route%/sample_payment/config", name="sample_payment_admin_config")
* @Template("@SamplePayment/admin/config.twig")
*/
#[Route(path: '/%eccube_admin_route%/sample_payment/config', name: 'sample_payment_admin_config')]
#[Template(template: '@SamplePayment44/admin/config.twig')]
public function index(Request $request)
{
$Config = $this->configRepository->get();
Expand Down
39 changes: 30 additions & 9 deletions Controller/Admin/OrderController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,22 +11,22 @@
* file that was distributed with this source code.
*/

namespace Plugin\SamplePayment42\Controller\Admin;
namespace Plugin\SamplePayment44\Controller\Admin;

use Eccube\Controller\AbstractController;
use Eccube\Entity\Order;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\Routing\Attribute\Route;

class OrderController extends AbstractController
{
/**
* 受注編集 > 決済のキャンセル処理
*
* @Route("/%eccube_admin_route%/sample_payment/order/cancel/{id}", requirements={"id" = "\d+"}, name="sample_payment_admin_order_cancel", methods={"POST"})
*/
public function cancel(Request $request, Order $Order)
#[Route(path: '/%eccube_admin_route%/sample_payment/order/cancel/{id}', requirements: ['id' => '\d+'], name: 'sample_payment_admin_order_cancel', methods: ['POST'])]
public function cancel(Request $request, Order $Order): JsonResponse
{
if ($request->isXmlHttpRequest() && $this->isTokenValid()) {
// 通信処理
Expand All @@ -41,17 +41,38 @@ public function cancel(Request $request, Order $Order)

/**
* 受注編集 > 決済の金額変更
*
* @Route("/%eccube_admin_route%/sample_payment/order/change_price/{id}", requirements={"id" = "\d+"}, name="sample_payment_admin_order_change_price", methods={"POST"})
*/
public function changePrice(Request $request, Order $Order)
#[Route(path: '/%eccube_admin_route%/sample_payment/order/change_price/{id}', requirements: ['id' => '\d+'], name: 'sample_payment_admin_order_change_price', methods: ['POST'])]
public function changePrice(Request $request, Order $Order): JsonResponse
{
if ($request->isXmlHttpRequest() && $this->isTokenValid()) {
// 通信処理

// 決済金額の計算は, 浮動小数点演算の丸め誤差を避けるため bcmath を使用する.
// EC-CUBE 本体も金額計算を bcmath で行っており, bcmath 拡張が無い環境では
// nanasess/bcmath-polyfill が関数を提供する (本体が依存に含むため別途要求は不要).
// 値は文字列で受け渡し, 第3引数 scale で小数桁を明示するのが本体の慣習.
// 以下はコンビニ決済手数料を例にした加減乗除 (bcadd/bcsub/bcmul/bcdiv) のサンプル.
$paymentTotal = $Order->getPaymentTotal(); // 本体の getPaymentTotal(): string

// 乗算・除算: 手数料 = 決済総額 × 手数料率(3.5%) ÷ 100 (小数以下切り捨て)
$feeRate = '3.5';
$fee = bcdiv(bcmul($paymentTotal, $feeRate, 4), '100', 0);

// 加算: 手数料を加えた金額
$totalWithFee = bcadd($paymentTotal, $fee, 0);

// 減算: キャンペーン割引(固定100円)を差し引いた最終請求額
$discount = '100';
$newPrice = bcsub($totalWithFee, $discount, 0);

// 実際のプラグインでは, ここで決済サーバへ変更後の金額を通知し,
// PurchaseFlow で受注金額を再計算・確定する.
// 本サンプルでは計算結果を返すのみで受注金額は変更しない.

$this->addSuccess('sample_payment.admin.order.change_price.success', 'admin');

return $this->json([]);
return $this->json(['price' => $newPrice]);
}

throw new BadRequestHttpException();
Expand Down
Loading
Loading