# 画面設計書 81-プラン選択ページ

## 概要

本ドキュメントは、Ghost会員向けポータル（Portal）のプラン選択ページ（AccountPlanPage）の画面設計書である。会員が購読プランを選択、変更、またはキャンセルするための画面を定義する。

### 本画面の処理概要

プラン選択ページは、Ghost会員サイトのPortalウィジェット内で、会員が購読プラン（Tier）を管理するための画面である。

**業務上の目的・背景**：サブスクリプション型コンテンツサイトにおいて、会員が自身の購読状態を把握し、必要に応じてプランのアップグレード・ダウングレード・キャンセルを行えることは、収益化とユーザー体験の両面で重要である。この画面は、管理者への問い合わせなしに会員自身がプラン変更を完結できるセルフサービス機能を提供し、運営コストの削減と会員満足度の向上を実現する。

**画面へのアクセス方法**：Portalウィジェットを開き、アカウントホームページから「プランを変更」ボタンをクリック、またはURL直接アクセス（`#/portal/accountPlan`）によりアクセスする。サインイン済みの会員のみがアクセス可能であり、未サインイン時はサインインページにリダイレクトされる。

**主要な操作・処理内容**：
1. 利用可能な購読プラン一覧の表示（月額/年額）
2. 無料会員の場合：有料プランへのアップグレード（Stripe決済へ遷移）
3. 有料会員の場合：プラン変更の選択と確認
4. 有料会員の場合：サブスクリプションのキャンセル申請
5. キャンセル時のリテンションオファー（解約防止割引）の表示・適用
6. プラン変更・キャンセルの確認ダイアログ表示

**画面遷移**：
- 遷移元：アカウントホームページ（accountHome）
- 遷移先：Stripe決済ページ（外部）、アカウントホームページ、サインインページ（未認証時）

**権限による表示制御**：
- 無料会員：アップグレード用プラン選択セクションを表示
- 有料会員：プラン変更セクションとキャンセルボタンを表示
- Complimentary（無料招待）会員：アップグレードセクションを表示
- 未認証：サインインページへリダイレクト

## 関連機能

| 機能No | 機能名 | 関連種別 | 関連する操作・処理 |
|--------|--------|----------|------------------|
| 15 | Tier（プラン）管理 | 主機能 | 購読プランの選択・変更処理 |
| 14 | Stripe連携 | 補助機能 | 決済処理、Checkoutセッション作成 |
| 71 | Portal | 補助機能 | Portalウィジェット内でのUI表示 |

## 画面種別

設定・選択

## URL/ルーティング

- ハッシュルート：`#/portal/accountPlan`
- Pages.jsでの登録キー：`accountPlan`

## 入出力項目

| 項目名 | 項目ID | 型 | 必須 | 入力/出力 | 説明 |
|--------|--------|-----|------|----------|------|
| 選択プランID | selectedPlan | string | - | 入力 | 選択されたプラン（価格）のID |
| キャンセル理由 | cancellationReason | string | - | 入力 | サブスクリプションキャンセル時の理由（最大500文字） |

## 表示項目

| 項目名 | データソース | 説明 |
|--------|-------------|------|
| ページタイトル | - | "Choose a plan" / "Change plan" / "Confirm subscription" / "Cancel subscription" |
| プラン一覧 | site.products | 利用可能なTier（プラン）と価格の一覧 |
| アカウントメール | member.email | 確認画面でのアカウント情報表示 |
| プラン開始日 | subscription.current_period_end | プラン変更時の適用開始日 |
| 現在の購読状態 | member.subscriptions | 現在の購読プラン情報 |
| リテンションオファー | offers | キャンセル前に表示される割引オファー |

## イベント仕様

### 1-プラン選択

**トリガー**：プランカードのクリック/選択

**処理フロー**：
- 無料会員：選択プランをstateに保存（チェックボックス動作）
- 有料会員：確認画面（showConfirmation=true）に遷移し、選択プランを表示

**データ更新**：
- state.selectedPlan: 選択されたプランID
- state.confirmationPlan: 確認対象のプラン情報
- state.confirmationType: 'changePlan' または 'subscribe'

### 2-プランチェックアウト（無料会員→有料）

**トリガー**：プラン選択後の「Continue」ボタンクリック

**処理フロー**：
1. `doAction('checkoutPlan', {plan: selectedPlan})` 実行
2. Stripe Checkout セッション作成API呼び出し
3. Stripe決済ページへリダイレクト

**API呼び出し**：
- POST `/members/api/create-stripe-checkout-session/`

### 3-プラン変更確認（有料会員）

**トリガー**：確認画面での「Confirm」ボタンクリック

**処理フロー**：
1. `doAction('updateSubscription', {planId, subscriptionId, cancelAtPeriodEnd: false})` 実行
2. Stripe Subscription更新API呼び出し
3. 成功時：会員データ更新、通知表示

**API呼び出し**：
- PUT `/members/api/subscriptions/{subscriptionId}/`

### 4-サブスクリプションキャンセル

**トリガー**：「Cancel subscription」ボタンクリック

**処理フロー**：
1. リテンションオファーがある場合：オファー画面表示
2. オファー拒否または無い場合：キャンセル確認画面表示
3. キャンセル理由入力後「Confirm cancellation」クリック
4. `doAction('cancelSubscription', {subscriptionId, cancelAtPeriodEnd: true, cancellationReason})` 実行

**API呼び出し**：
- PUT `/members/api/subscriptions/{subscriptionId}/`

### 5-リテンションオファー受諾

**トリガー**：「Accept offer」ボタンクリック

**処理フロー**：
1. `doAction('applyOffer', {subscriptionId, offerId})` 実行
2. オファー適用API呼び出し
3. 成功時：割引適用、ポップアップ閉じ

**API呼び出し**：
- POST `/members/api/subscriptions/{subscriptionId}/apply-offer/`

### 6-戻るボタン

**トリガー**：BackButtonクリック

**処理フロー**：
- 確認画面表示中：確認画面を閉じてプラン選択に戻る
- それ以外：前のページ（accountHome）に戻る

## データベース更新仕様

### 操作別データベース影響一覧

| 操作（イベント） | 対象テーブル | 操作種別 | 概要 |
|----------------|-------------|---------|------|
| プラン変更確定 | members_stripe_customers_subscriptions | UPDATE | サブスクリプションのプラン変更 |
| キャンセル確定 | members_stripe_customers_subscriptions | UPDATE | cancel_at_period_endをtrueに更新 |
| オファー適用 | members_stripe_customers_subscriptions | UPDATE | 割引オファーの適用 |

### テーブル別更新項目詳細

#### members_stripe_customers_subscriptions

| 操作 | 項目（カラム名） | 更新値・取得条件 | 備考 |
|-----|-----------------|-----------------|------|
| UPDATE | stripe_price_id | 新しいプランの価格ID | プラン変更時 |
| UPDATE | cancel_at_period_end | true/false | キャンセル時true |
| UPDATE | cancellation_reason | 入力されたキャンセル理由 | キャンセル時 |

## メッセージ仕様

| メッセージID | 種別 | メッセージ内容 | 表示条件 |
|-------------|------|--------------|---------|
| MSG-001 | 情報 | "Starting {startDate}" | プラン変更時の適用開始日表示 |
| MSG-002 | 情報 | "Starting today" | 新規プラン選択時 |
| MSG-003 | 確認 | "If you cancel your subscription now, you will continue to have access until {periodEnd}." | キャンセル確認時 |
| MSG-004 | 情報 | "We'd hate to see you go! How about a special offer to stay?" | リテンションオファー表示時 |
| MSG-005 | 情報 | "{amount} off" | オファー割引額表示 |

## 例外処理

| 例外条件 | 処理内容 | 表示メッセージ |
|---------|---------|--------------|
| 未認証状態 | サインインページへリダイレクト | - |
| API通信エラー | エラー状態表示、リトライボタン表示 | "Retry" |
| Stripe決済失敗 | エラーページ表示 | Stripeエラーメッセージ |

## 備考

- Stripe連携が無効な場合、有料プランは表示されない
- 通貨が異なるプランは、会員の現在のプランと同一通貨のもののみ表示される
- リテンションオファーはredemption_type='retention'のオファーのみ対象
- Complimentary会員はキャンセルボタンが非表示

---

## コードリーディングガイド

本画面を理解するために参照すべきファイルと、推奨する読み解き順序を以下に示す。

### 推奨読解順序

#### Step 1: データ構造を理解する

プラン選択ページで扱う主要なデータ構造を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 1-1 | app-context.js | `apps/portal/src/app-context.js` | AppContextの構造（site, member, offers, doAction） |
| 1-2 | helpers.js | `apps/portal/src/utils/helpers.js` | getMemberSubscription、getAvailablePrices等のヘルパー関数 |

**読解のコツ**: AppContextがReact Contextとして全コンポーネントに共有されるデータストアであることを理解する。site、member、offersオブジェクトの構造が重要。

#### Step 2: エントリーポイントを理解する

ページコンポーネントの構造と初期化処理を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 2-1 | account-plan-page.js | `apps/portal/src/components/pages/account-plan-page.js` | AccountPlanPageクラスコンポーネント |
| 2-2 | pages.js | `apps/portal/src/pages.js` | ページルーティング定義 |

**主要処理フロー**:
1. **424-469行**: コンストラクタでgetInitialState()を呼び出し、初期状態を設定
2. **432-439行**: componentDidMountで未認証チェック、必要に応じてサインインページへリダイレクト
3. **618-644行**: render()でPlansContainerコンポーネントに状態とコールバックを渡す

#### Step 3: プラン表示ロジックを理解する

プラン一覧の表示とユーザー種別による分岐を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 3-1 | account-plan-page.js | `apps/portal/src/components/pages/account-plan-page.js` | PlansContainer、ChangePlanSection、UpgradePlanSection |

**主要処理フロー**:
- **380-422行**: PlansContainerで会員種別（無料/有料/Complimentary）による表示分岐
- **239-255行**: ChangePlanSectionで有料会員向けのプラン変更UI
- **348-378行**: UpgradePlanSectionで無料会員向けのアップグレードUI

#### Step 4: 確認・キャンセルフローを理解する

プラン変更確認とサブスクリプションキャンセルの処理を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 4-1 | account-plan-page.js | `apps/portal/src/components/pages/account-plan-page.js` | PlanConfirmationSection、RetentionOfferSection |

**主要処理フロー**:
- **145-236行**: PlanConfirmationSectionでプラン変更/キャンセルの確認UI
- **298-345行**: RetentionOfferSectionでリテンションオファー表示
- **541-566行**: onCancelSubscriptionでキャンセルフロー開始（オファー有無で分岐）

#### Step 5: API連携を理解する

バックエンドAPIとの連携処理を把握する。

| 順序 | ファイル | パス | 読解ポイント |
|-----|---------|------|-------------|
| 5-1 | api.js | `apps/portal/src/utils/api.js` | checkoutPlan、updateSubscription、applyOffer関数 |

**主要処理フロー**:
- **445-509行**: checkoutPlan - Stripe Checkoutセッション作成
- **631-656行**: updateSubscription - サブスクリプション更新
- **679-701行**: applyOffer - リテンションオファー適用

### プログラム呼び出し階層図

```
AccountPlanPage (account-plan-page.js)
    │
    ├─ getInitialState()
    │      └─ getAvailablePrices() (helpers.js)
    │      └─ getMemberActivePrice() (helpers.js)
    │
    ├─ PlansContainer
    │      ├─ UpgradePlanSection (無料会員)
    │      │      └─ PlansOrProductSection
    │      │             └─ MultipleProductsPlansSection
    │      │
    │      ├─ ChangePlanSection (有料会員)
    │      │      ├─ PlansOrProductSection
    │      │      └─ CancelSubscriptionButton
    │      │
    │      ├─ RetentionOfferSection (キャンセル時オファー)
    │      │
    │      └─ PlanConfirmationSection (確認画面)
    │
    └─ doAction() (AppContext)
           ├─ 'checkoutPlan' → api.member.checkoutPlan()
           ├─ 'updateSubscription' → api.member.updateSubscription()
           ├─ 'cancelSubscription' → api.member.updateSubscription()
           └─ 'applyOffer' → api.member.applyOffer()
```

### データフロー図

```
[入力]                    [処理]                           [出力]

site.products ─────────▶ getAvailablePrices() ──────────▶ 利用可能プラン一覧
member.subscriptions ──▶ getMemberSubscription() ───────▶ 現在の購読情報
offers ────────────────▶ filter(retention) ─────────────▶ リテンションオファー

ユーザー操作
  │
  ├─ プラン選択 ────────▶ onPlanSelect() ───────────────▶ state更新
  │                                                        └─▶ 確認画面表示
  │
  ├─ 確認ボタン ────────▶ onConfirm() ──────────────────▶ API呼び出し
  │                           │                              └─▶ 画面更新
  │                           ├─ onPlanCheckout()
  │                           └─ onCancelSubscriptionConfirmation()
  │
  └─ オファー受諾 ──────▶ onAcceptRetentionOffer() ─────▶ API呼び出し
                                                            └─▶ オファー適用
```

### 関連ファイル一覧

| ファイル | パス | 種別 | 役割 |
|---------|------|------|------|
| account-plan-page.js | `apps/portal/src/components/pages/account-plan-page.js` | ソース | メインページコンポーネント |
| pages.js | `apps/portal/src/pages.js` | ソース | ページルーティング定義 |
| app-context.js | `apps/portal/src/app-context.js` | ソース | アプリケーションコンテキスト定義 |
| api.js | `apps/portal/src/utils/api.js` | ソース | API通信処理 |
| helpers.js | `apps/portal/src/utils/helpers.js` | ソース | ヘルパー関数群 |
| plans-section.js | `apps/portal/src/components/common/plans-section.js` | ソース | プラン表示コンポーネント |
| action-button.js | `apps/portal/src/components/common/action-button.js` | ソース | アクションボタンコンポーネント |
| i18n.js | `apps/portal/src/utils/i18n.js` | ソース | 国際化ユーティリティ |
