Skip to content

Design: 有料投票フロー(複数パッケージ組み合わせ購入)

要件は requirements.md、ビジュアルは ui-design.md 参照。データ層の契約は ../data-model/design.md を唯一の正として参照する。


Overview

Purpose: 候補者を指定した「決済 → 投票」の単一トランザクションを、data-model で定義された 3 系統(microCMS / Neon / Vercel ENV)と Stripe の上で実装する。決済成功 Webhook を 唯一の権威 として Purchase.status='SUCCEEDED' 遷移と Vote.createMany を同一トランザクション内で行う。

Users: 一般来訪ユーザー(認証なし)。home / candidate-detail から起動される投票導線の本体。

Impact: 過去 design(features/voting/services/creditPackageService.ts + votingPeriodService.ts の Prisma 直叩き、Purchase の total* 列、PurchaseItem の *Snapshot 列、Vote の status 列)を撤回し、CreditPackage は microCMS、投票期間は Vercel ENV、Vote 有効性は Purchase.status の JOIN で表現する構成に置き換える。

Goals

  • 決済 = 投票 のワンショットを Stripe Webhook 駆動の単一経路 で実装し、サーバー権威と冪等性を両立
  • クライアント側は Vote を生成しない(楽観的更新のみ)
  • 合計票数は Webhook 受信時に microCMS から CreditPackage を取得して再計算 し、改竄を許容しない
  • ニックネームは nicknameService.normalizeNickname(data-model 所有)で必ず正規化してから保存
  • 投票期間外の受付拒否を Server Action のサーバーガード で実施(UI 抑止が破られた場合も拒否)

Non-Goals

  • 票数パッケージのスキーマ・編集権限(data-model 委譲)
  • 投票期間値の保持・バリデーション(data-model 委譲)
  • 候補者一覧・カード UI(home / candidate-detail 委譲)
  • 応援者ランキング画面(candidate-detail 委譲、voteAggregationService は data-model 所有)
  • 投票期間の業務ルール本体(steering/product.md 委譲)

Boundary Commitments

This Spec Owns

  • 購入モーダル(<PurchaseFlowContainer> / <PurchaseCreditsModal> / <StripePaymentModal>)の UI 配置と状態機械
  • Server Action createPaymentIntent / getPurchaseStatus の契約(入力検証・期間ガード・冪等化)
  • Stripe Webhook ハンドラ(app/api/webhooks/stripe/route.ts)の状態遷移ロジックと Vote 一括生成
  • フック usePurchaseFlow / usePurchaseCart / useNickname / useVoteFlow / useVotingPeriodStatus の単独所有(本 spec がファイルを生成し、features/voting/hooks/ 配下に配置する)。home / candidate-detail 等の他 spec は Allowed Dependencies として参照するのみで、再実装・再定義を禁止
  • 決済プロバイダ(Stripe)固有設定(Payment Element / Link 優先 / Webhook 署名検証)

Out of Boundary

  • microCMS の creditPackages コレクション・ロール権限(data-model)
  • Neon の Vote / Purchase / PurchaseItem のスキーマ・UNIQUE 制約・enum(data-model)
  • nicknameService.normalizeNickname の実装(data-model 所有、本 spec は call するのみ)
  • 候補者カードの「投票する」ボタンラベル文言(candidate-detail 所有、本 spec は呼び出し契約のみ)
  • 投票期間値の取得実装(lib/voting-period.ts 所有)

Allowed Dependencies

  • data-model services: creditPackageService.getActiveCreditPackages / getCreditPackagenicknameService.normalizeNickname(Client/Server 両用の純関数として data-model が exportuseNickname フック内で事前バリデーションに使用)、lib/voting-period.getVotingPeriodlib/prisma.prisma
  • Stripe Node.js SDK(Server Action / Webhook)
  • Stripe.js + @stripe/react-stripe-js(クライアント Payment Element)
  • React 19(useOptimistic は home 側、本 spec ではフォーム制御のみ)
  • Zod(Server Action 入力検証、steering 方針に従う)

Revalidation Triggers

  • data-modelPaymentStatus enum 値変更
  • Purchase / PurchaseItem / Vote のスキーマ変更
  • creditPackageService のシグネチャ変更
  • nicknameService.normalizeNickname の正規化規則変更
  • Stripe Webhook イベント名・ペイロード形式の変更

Architecture

コンポーネント階層と責務分離

mermaid
graph TB
    subgraph UI[Client UI Layer]
        Container[<PurchaseFlowContainer>]
        CreditsModal[<PurchaseCreditsModal>]
        StripeModal[<StripePaymentModal>]
        NicknameInput[<NicknameInput>]
    end

    subgraph Hooks[features/voting/hooks]
        UseFlow[usePurchaseFlow]
        UseCart[usePurchaseCart]
        UseNick[useNickname]
        UseVote[useVoteFlow]
        UsePeriod[useVotingPeriodStatus]
    end

    subgraph ServerActions[Server Actions]
        CreatePI[createPaymentIntent]
        GetStatus[getPurchaseStatus]
    end

    subgraph Webhook[API Route]
        WebhookHandler[app/api/webhooks/stripe/route.ts]
    end

    subgraph DataModel[data-model spec services]
        PkgSvc[creditPackageService]
        NickSvc[nicknameService]
        PeriodLib[lib/voting-period]
        PrismaClient[lib/prisma]
    end

    subgraph External[External]
        Stripe[Stripe API]
    end

    Container --> UseFlow
    Container --> UseCart
    Container --> UseNick
    Container --> CreditsModal
    Container --> StripeModal
    CreditsModal --> NicknameInput
    UseFlow --> CreatePI
    UseFlow --> GetStatus
    CreatePI --> PeriodLib
    CreatePI --> NickSvc
    CreatePI --> PkgSvc
    CreatePI --> PrismaClient
    CreatePI --> Stripe
    GetStatus --> PrismaClient
    Stripe --> WebhookHandler
    WebhookHandler --> PkgSvc
    WebhookHandler --> NickSvc
    WebhookHandler --> PrismaClient

Architecture Pattern & Boundary Map

  • Pattern: 「Stripe Webhook 駆動の単一権威 + クライアントは状態反映のみ」
  • Boundary Map:
    • クライアント: 入力収集・状態機械・楽観反映・成功フィードバック
    • Server Action: PaymentIntent 作成 + Purchase レコード(PROCESSING)生成 + 期間ガード
    • Webhook: SUCCEEDED / FAILED / REFUNDED 遷移 + Vote 一括生成
    • data-model: スキーマ・集計・正規化(全契約の所有者)
  • Steering 整合: tech.md の Stripe 採用、structure.md の features/components 分離、product.md の投票期間業務ルール

Technology Stack

LayerChoiceRole
決済プロバイダStripe (Payment Intents + Webhook)カード機微情報を持たない、Link 優先
クライアント SDK@stripe/react-stripe-js / @stripe/stripe-jsPayment Element
サーバー SDKstripe@^22PaymentIntent 作成 / Webhook 署名検証
入力検証Zod ^4Server Action 入力
データ層data-model spec(microCMS / Neon / Vercel ENV)直接アクセスせずサービス経由

File Structure Plan

Directory Structure

app/
├── actions/
│   └── purchase.ts                            # 'use server' createPaymentIntent / getPurchaseStatus
└── api/
    └── webhooks/
        └── stripe/
            └── route.ts                       # Stripe Webhook ハンドラ

features/voting/
├── hooks/
│   ├── usePurchaseFlow.ts                     # 状態機械 (select → payment → processing → success/error)
│   ├── usePurchaseCart.ts                     # 数量入力 + 合計計算 (クライアントローカル)
│   ├── useNickname.ts                         # ニックネーム入力 + localStorage 永続化
│   ├── useVoteFlow.ts                         # 購入モーダル開閉 + Confetti (home/detail で使用)
│   └── useVotingPeriodStatus.ts               # 期間内/外の UI ヒント
├── components/
│   ├── PurchaseFlowContainer.tsx              # フロー制御コンテナ
│   ├── PurchaseCreditsModal.tsx               # 数量入力 + ニックネーム入力 + 合計表示
│   ├── CreditPackageRow.tsx                   # 個別パッケージ行
│   ├── NicknameInput.tsx                      # ニックネーム入力フィールド
│   └── StripePaymentModal.tsx                 # Stripe Payment Element 内包 (Link 優先)
└── types.ts                                   # CartItem / PurchaseStep / PurchaseState

Modified Files

  • next.config.ts — Stripe.js を読み込むため script-src CSP 設定が必要なら追記(なくても動作するが本番運用で要検討)
  • package.jsonstripe(既存)/ @stripe/react-stripe-js / @stripe/stripe-js を依存追加

Out-of-scope(再導入禁止)

  • features/voting/services/creditPackageService.ts(features/cms/services/ に統合済み、data-model 所有)
  • features/voting/services/votingPeriodService.ts(lib/voting-period.ts に統合済み、data-model 所有)
  • features/voting/services/nicknameService.ts を本 spec 側に重複定義すること(data-model 所有)
  • Purchase 上の totalAmountJpy / totalCredits カラム参照
  • PurchaseItem 上の *Snapshot カラム参照
  • Vote 上の status カラム参照

System Flows

Flow 1: 購入モーダル起動 → PaymentIntent 作成

mermaid
sequenceDiagram
    participant User
    participant Card as <CandidateCard>
    participant Container as <PurchaseFlowContainer>
    participant Cart as usePurchaseCart
    participant Nick as useNickname
    participant Flow as usePurchaseFlow
    participant Action as createPaymentIntent (Server Action)
    participant Period as lib/voting-period
    participant NickSvc as nicknameService
    participant PkgSvc as creditPackageService
    participant Db as Neon
    participant Stripe as Stripe API

    User->>Card: 投票ボタン押下
    Card->>Container: open(candidate)
    Container->>Cart: 数量入力
    User->>Nick: ニックネーム入力(または自動補完)
    User->>Container: 「決済へ進む」
    Container->>Flow: submit(cartItems, nickname, candidateId)
    Flow->>Action: createPaymentIntent(input)
    Action->>Period: getVotingPeriod()
    Period-->>Action: { startsAt, endsAt }
    Action-->>Action: 期間内チェック(失敗なら VotingPeriodClosedError)
    Action->>NickSvc: normalizeNickname(nickname)
    NickSvc-->>Action: 正規化済み or InvalidNicknameError
    par 並列取得
        Action->>PkgSvc: getCreditPackage(id) × cartItems
    end
    PkgSvc-->>Action: CreditPackage[] (priceJpy / credits / isActive)
    Action-->>Action: isActive=true 検証、合計金額再計算
    Action->>Stripe: paymentIntents.create({ amount, metadata })
    Stripe-->>Action: { id, client_secret }
    Action->>Db: INSERT Purchase(status=PROCESSING, paymentIntentId, candidateId, nickname)
    Action->>Db: INSERT PurchaseItem × cartItems
    Action-->>Flow: { clientSecret, purchaseId }
    Flow-->>Container: step='payment'

Key Decision:

  • 合計金額は クライアント値を信用せず Server で再計算(要件 Feature 5)
  • Purchase / PurchaseItem の INSERT は同一 prisma.$transaction 内で実施
  • nicknameService.normalizeNickname は data-model 所有の純関数を call

Flow 2: 決済確定 → Webhook → Vote 一括生成

mermaid
sequenceDiagram
    participant Browser as Stripe.js
    participant Stripe as Stripe API
    participant Webhook as app/api/webhooks/stripe
    participant Db as Neon (transaction)
    participant PkgSvc as creditPackageService
    participant Poll as getPurchaseStatus (Client polling)

    Browser->>Stripe: confirmPayment(clientSecret)
    Stripe-->>Browser: success(処理開始)
    Stripe->>Webhook: payment_intent.succeeded
    Webhook->>Webhook: 署名検証
    Webhook->>Db: SELECT Purchase WHERE paymentIntentId=:id FOR UPDATE
    alt status=PROCESSING
        Webhook->>PkgSvc: getCreditPackages(items[*].creditPackageId)
        PkgSvc-->>Webhook: CreditPackage[] (credits)
        Webhook->>Webhook: totalCredits = Σ(item.quantity × pkg.credits)
        Webhook->>Db: BEGIN
        Webhook->>Db: UPDATE Purchase SET status='SUCCEEDED'
        Webhook->>Db: INSERT Vote × totalCredits (candidateId, purchaseId, nickname)
        Webhook->>Db: COMMIT
    else status=SUCCEEDED (重複配信)
        Webhook->>Webhook: 何もしない(冪等)
    end
    Webhook-->>Stripe: 200 OK
    Poll->>Db: SELECT Purchase status WHERE id=:id
    Db-->>Poll: SUCCEEDED
    Poll-->>Browser: 成功反映 → Confetti / 楽観更新

Key Decision:

  • Webhook は FOR UPDATE で Purchase 行ロックし、status が PROCESSING の場合のみ遷移(重複配信の冪等化)
  • 合計票数は Webhook 時点で microCMS から CreditPackage を取得して再計算(CreditPackage は完全イミュータブルなので値は変わらない、data-model Req 3.3)
  • Vote の nickname は Purchase に保存済みの値を全 Vote に継承

Flow 3: 決済失敗 / 払戻

mermaid
stateDiagram-v2
    [*] --> PROCESSING: createPaymentIntent
    PROCESSING --> SUCCEEDED: payment_intent.succeeded
    PROCESSING --> FAILED: payment_intent.payment_failed
    SUCCEEDED --> REFUNDED: charge.refunded
    REFUNDED --> [*]: Vote はそのまま残るが集計から除外
    FAILED --> [*]: Vote 生成しない

Key Decision:

  • REFUNDED 遷移は Purchase.status の更新のみ。Vote レコードは削除も変更もしない(data-model Req 4.4 / 4.7)
  • payment_intent.payment_failed で Purchase.status を FAILED にし、Vote は生成しない

Flow 4: Webhook 未着時のクライアント polling

mermaid
sequenceDiagram
    participant Client as usePurchaseFlow
    participant Action as getPurchaseStatus
    participant Db as Neon

    loop 最大 30s (1s 間隔)
        Client->>Action: getPurchaseStatus(purchaseId)
        Action->>Db: SELECT status WHERE id=:id
        Action-->>Client: status
        alt status=SUCCEEDED
            Client->>Client: step='success' → Confetti → close
            break
        else status=FAILED
            Client->>Client: step='error'
            break
        else status=PROCESSING and 30s 経過
            Client->>Client: step='pending_settlement'
            break
        end
    end

Key Decision:

  • 30 秒で pending_settlement に遷移し、ユーザーに「完了通知が後ほど反映される」旨を案内
  • 5 分以内に SUCCEEDED が観測されなければサポート連絡導線を提示(UI 文言は ui-design.md)

Requirements Traceability

ReqSummaryComponentsInterfacesFlows
1.1候補者選択+パッケージ+ニックネーム+決済を一フローとして扱う<PurchaseFlowContainer> + usePurchaseFlow状態機械 select→payment→successFlow 1, 2
1.2決済成功時に合計票数分を加算Webhook ハンドラvote.createManyFlow 2
1.3決済失敗時は投票を成立させないWebhook ハンドラstatus='FAILED' のみ更新Flow 3
1.4投票券残数の消費は提供しないアーキテクチャ全体Vote は Purchase 由来のみ
1.5連続購入を抑止usePurchaseFlowstep≠'select' のとき再起動不可
1.6投票期間外はサーバー側で拒否createPaymentIntentgetVotingPeriod() + VotingPeriodClosedErrorFlow 1
2.1候補者選択時にモーダル起動useVoteFlow.openPurchaseModalactiveModal='purchase'Flow 1
2.2閉じる操作で選択リセット<PurchaseFlowContainer>onClose → setSelectedCandidate(null)
2.3決済処理中は閉じる操作を無効化usePurchaseFlowstep='processing' のとき disableFlow 2
3.1モーダル内コンテンツ(候補者・パッケージ・ニックネーム・決済導線)<PurchaseCreditsModal>props で各要素Flow 1
4.1有効パッケージを表示順で表示<PurchaseCreditsModal> + creditPackageServicegetActiveCreditPackages()(displayOrder 昇順)
4.2パッケージごとに票数・価格表示<CreditPackageRow>props で表示
4.3具体値は運営管理(data-model 委譲)
5.1-5.4数量 ≥0 整数、増減ボタン + テキスト、合計即時更新、合計 0 で disableusePurchaseCart + <CreditPackageRow>クライアントローカル状態
5.5合計はクライアント表示+サーバー再計算<PurchaseCreditsModal> + createPaymentIntentサーバーで CreditPackage 再取得し合計再計算Flow 1
6.1-6.5ニックネーム必須+自動補完+1-32 文字+空白無効+永続化useNickname + nicknameServicelocalStorage + normalizeNicknameFlow 1, 2
6.6同一文字列は同一支援者の補助テキスト<NicknameInput>helper text
7.1-7.3Stripe 埋め込み UI(Link / カード / モバイル)<StripePaymentModal><PaymentElement>Flow 2
7.4カード番号フォーマット等は Stripe に委譲<StripePaymentModal>Payment ElementFlow 2
7.5カード機微情報を持たないアーキテクチャ全体サーバー側に Card データを送らないFlow 2
8.1決済金額・対象・合計票数・内訳を明示<StripePaymentModal>props で表示Flow 2
8.2第三者プロバイダの安全性を明示<StripePaymentModal>UI 文言
9.1決済確定で処理中状態に遷移usePurchaseFlowstep='processing'Flow 2
9.2決済成功で成功状態+フィードバックusePurchaseFlow + <Confetti>step='success'Flow 2
9.3成功表示後に投票数加算+クローズusePurchaseFlow.onSuccess + useVoteFlow.handlePaidVoteConfirmedFlow 2
10.1投票加算+永続化+フィードバック+クローズ+リセットusePurchaseFlow + useNickname.persist + useVoteFlowFlow 2
10.2加算量 = 合計票数Webhook ハンドラvote.createMany(count=totalCredits)Flow 2
10.3フィードバック終了で選択リセットuseVoteFlow.handlePaidVoteConfirmedsetSelectedCandidate(null)
11.1購入モーダル閉じで数量・合計・候補者リセットusePurchaseCart.reset + <PurchaseFlowContainer>
11.2決済モーダル閉じで決済状態リセットusePurchaseFlow.reset
11.3処理中は閉じる操作無効usePurchaseFlowstep='processing' で disable
非機能-トランザクションPurchase + Vote の原子性Webhook ハンドラprisma.$transactionFlow 2
非機能-冪等性Webhook 重複配信での二重生成防止Webhook ハンドラpaymentIntentId UNIQUE + status FOR UPDATEFlow 2

Components and Interfaces

Summary

ComponentDomain/LayerIntentReq CoverageKey DependenciesContracts
createPaymentIntentServer ActionPurchase 作成 + Stripe PaymentIntent 発行 + 期間ガード1.1, 1.6, 5.5, 6.1-6.3Stripe(P0), data-model services(P0)Service
getPurchaseStatusServer ActionPurchase.status のポーリング窓口9.x, Flow 4Prisma(P0)Service
app/api/webhooks/stripe/route.tsAPI Route状態遷移 + Vote 一括生成 + 冪等化1.2, 1.3, 10.1, 10.2, 非機能Stripe Webhook(P0), Prisma(P0)Service
usePurchaseFlowHook状態機械 (select→payment→processing→success/error)1.1, 1.5, 2.3, 9.x, 11.2, 11.3(純フック)Service
usePurchaseCartHook数量入力 + 合計計算 + リセット5.1-5.4, 11.1(純フック)Service
useNicknameHook入力 + localStorage 永続化 + 補完6.1, 6.2, 6.5localStorage(P0)Service
useVoteFlowHookモーダル開閉 + Confetti + 候補者リセット2.1, 2.2, 10.3(純フック)Service
useVotingPeriodStatusHook期間内/外の UI ヒント(補助)clockService
<PurchaseFlowContainer>Componentフロー制御 + 子モーダル切替1.1, 2.1, 11.1hooks(P0)UI
<PurchaseCreditsModal>Component数量入力 + ニックネーム入力 + 合計表示3.1, 4.1, 4.2, 5.x, 6.1-6.4<CreditPackageRow>(P0), <NicknameInput>(P0)UI
<CreditPackageRow>Component個別パッケージ行 + ± 数量入力4.2, 5.1-5.4UI
<NicknameInput>Componentニックネーム入力フィールド + helper text6.1, 6.6useNicknameUI
<StripePaymentModal>ComponentStripe Payment Element 内包7.x, 8.x@stripe/react-stripe-js(P0)UI

Server Action: createPaymentIntent

FieldDetail
Intentクライアントからのカート明細を受け取り、期間ガード・正規化・サーバー側合計再計算を経て Stripe PaymentIntent を発行、Purchase を PROCESSING で永続化
Requirements1.1, 1.6, 5.5, 6.1, 6.3, 非機能-トランザクション

Contracts: Service [x]

Interface

typescript
// app/actions/purchase.ts
'use server';

import { z } from 'zod';

export const CartItemInputSchema = z.object({
  creditPackageId: z.string().min(1),
  quantity: z.number().int().min(1),
});

export const CreatePaymentIntentInputSchema = z.object({
  candidateId: z.string().min(1),
  nickname: z.string().min(1).max(64),     // 正規化前の上限。サーバーで再 trim/length
  items: z.array(CartItemInputSchema).min(1).max(20),
});

export type CreatePaymentIntentInput = z.infer<typeof CreatePaymentIntentInputSchema>;

export type CreatePaymentIntentResult =
  | { ok: true; purchaseId: string; clientSecret: string; totalCredits: number; totalAmountJpy: number }
  | { ok: false; error: 'VOTING_PERIOD_CLOSED' | 'INVALID_NICKNAME' | 'INVALID_PACKAGE' | 'CANDIDATE_NOT_FOUND' | 'PAYMENT_PROVIDER_ERROR' };

export async function createPaymentIntent(
  input: CreatePaymentIntentInput,
): Promise<CreatePaymentIntentResult>;

Algorithm

typescript
export async function createPaymentIntent(input: CreatePaymentIntentInput) {
  const parsed = CreatePaymentIntentInputSchema.safeParse(input);
  if (!parsed.success) return { ok: false, error: 'INVALID_PACKAGE' };

  // 1. 投票期間ガード
  const period = getVotingPeriod();
  const now = new Date();
  if (now < period.startsAt || now >= period.endsAt) {
    return { ok: false, error: 'VOTING_PERIOD_CLOSED' };
  }

  // 2. ニックネーム正規化
  let nickname: string;
  try {
    nickname = normalizeNickname(parsed.data.nickname);
  } catch {
    return { ok: false, error: 'INVALID_NICKNAME' };
  }

  // 3. 候補者存在チェック(microCMS)
  const candidate = await getCandidate(parsed.data.candidateId);
  if (!candidate) return { ok: false, error: 'CANDIDATE_NOT_FOUND' };

  // 4. パッケージ取得・isActive 検証・合計再計算
  const packages = await Promise.all(
    parsed.data.items.map((i) => getCreditPackage(i.creditPackageId)),
  );
  if (packages.some((p) => !p || !p.isActive)) {
    return { ok: false, error: 'INVALID_PACKAGE' };
  }
  const totalAmountJpy = parsed.data.items.reduce(
    (sum, i, idx) => sum + packages[idx]!.priceJpy * i.quantity,
    0,
  );
  const totalCredits = parsed.data.items.reduce(
    (sum, i, idx) => sum + packages[idx]!.credits * i.quantity,
    0,
  );

  // 5. Stripe PaymentIntent 作成
  const intent = await stripe.paymentIntents.create({
    amount: totalAmountJpy,
    currency: 'jpy',
    automatic_payment_methods: { enabled: true },
    metadata: {
      candidateId: parsed.data.candidateId,
      nickname,
    },
  });

  // 6. Purchase + PurchaseItem 永続化
  const purchase = await prisma.$transaction(async (tx) => {
    const p = await tx.purchase.create({
      data: {
        candidateId: parsed.data.candidateId,
        nickname,
        paymentProvider: 'stripe',
        paymentIntentId: intent.id,
        status: 'PROCESSING',
      },
    });
    await tx.purchaseItem.createMany({
      data: parsed.data.items.map((i) => ({
        purchaseId: p.id,
        creditPackageId: i.creditPackageId,
        quantity: i.quantity,
      })),
    });
    return p;
  });

  return {
    ok: true,
    purchaseId: purchase.id,
    clientSecret: intent.client_secret!,
    totalCredits,
    totalAmountJpy,
  };
}

Implementation Notes

  • Integration: data-modelgetVotingPeriod / normalizeNickname / getCandidate / getCreditPackage を call。直接 microCMS / Prisma に触らない
  • Validation: Zod スキーマで形式検査、ビジネス検証は分岐で early return
  • Risks: PaymentIntent 作成と DB 永続化の間でクラッシュした場合、Stripe に「orphan PaymentIntent」が残る → Stripe 側で 1 時間後に自動 cancel、害は最小

Server Action: getPurchaseStatus

typescript
export type PurchaseStatusResult = {
  status: 'PROCESSING' | 'SUCCEEDED' | 'FAILED' | 'REFUNDED';
};

export async function getPurchaseStatus(purchaseId: string): Promise<PurchaseStatusResult | null>;
  • Preconditions: purchaseIdcreatePaymentIntent の戻り値
  • Postconditions: 該当 Purchase の最新 status を返す。存在しなければ null
  • Implementation Notes: クライアントの polling 窓口。unstable_cache は使わない(最新値を要求)

Webhook Handler: app/api/webhooks/stripe/route.ts

FieldDetail
IntentStripe Webhook を唯一の権威として Purchase.status 遷移と Vote 一括生成を担う
Requirements1.2, 1.3, 10.1, 10.2, 非機能-トランザクション, 非機能-冪等性

Algorithm

typescript
// app/api/webhooks/stripe/route.ts
export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature');
  const body = await req.text();

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig!, process.env.STRIPE_WEBHOOK_SECRET!);
  } catch {
    return new Response('invalid signature', { status: 400 });
  }

  switch (event.type) {
    case 'payment_intent.succeeded':
      await handleSucceeded(event.data.object as Stripe.PaymentIntent);
      break;
    case 'payment_intent.payment_failed':
      await handleFailed(event.data.object as Stripe.PaymentIntent);
      break;
    case 'charge.refunded':
      await handleRefunded(event.data.object as Stripe.Charge);
      break;
  }

  return new Response('ok', { status: 200 });
}

async function handleSucceeded(intent: Stripe.PaymentIntent) {
  await prisma.$transaction(async (tx) => {
    // 行ロック取得: SELECT ... FOR UPDATE で Purchase 行をロック
    // findUnique はロックを取らないため $queryRaw で FOR UPDATE を明示
    const locked = await tx.$queryRaw<
      Array<{ id: string; status: PaymentStatus; candidateId: string; nickname: string }>
    >`
      SELECT id, status, "candidateId", nickname
      FROM "Purchase"
      WHERE "paymentIntentId" = ${intent.id}
      FOR UPDATE
    `;
    const purchase = locked[0];
    if (!purchase || purchase.status !== 'PROCESSING') return;  // 冪等: 既に遷移済み or 存在せず

    const items = await tx.purchaseItem.findMany({
      where: { purchaseId: purchase.id },
    });

    // CreditPackage を microCMS から再取得して合計票数を計算
    const packages = await Promise.all(
      items.map((i) => getCreditPackage(i.creditPackageId)),
    );
    const totalCredits = items.reduce(
      (sum, i, idx) => sum + (packages[idx]?.credits ?? 0) * i.quantity,
      0,
    );

    // 条件付き UPDATE による二段ガード(FOR UPDATE と合わせて確実な冪等化)
    const updated = await tx.purchase.updateMany({
      where: { id: purchase.id, status: 'PROCESSING' },
      data: { status: 'SUCCEEDED' },
    });
    if (updated.count === 0) return;  // 競合した別の Webhook が先に遷移済み

    await tx.vote.createMany({
      data: Array.from({ length: totalCredits }, () => ({
        candidateId: purchase.candidateId,
        purchaseId: purchase.id,
        nickname: purchase.nickname,
      })),
    });
  }, { isolationLevel: 'Serializable' });  // 重複配信時の race を強制シリアライズ
}

async function handleFailed(intent: Stripe.PaymentIntent) {
  await prisma.purchase.updateMany({
    where: { paymentIntentId: intent.id, status: 'PROCESSING' },
    data: { status: 'FAILED' },
  });
}

async function handleRefunded(charge: Stripe.Charge) {
  if (!charge.payment_intent) return;
  await prisma.purchase.updateMany({
    where: {
      paymentIntentId: charge.payment_intent as string,
      status: 'SUCCEEDED',
    },
    data: { status: 'REFUNDED' },
  });
}

Implementation Notes

  • Integration: Stripe Webhook を Vercel Serverless Function として動かす。runtime = 'nodejs' を明示
  • Validation: 署名検証必須(stripe.webhooks.constructEvent)
  • Risks:
    • Webhook 重複配信 → paymentIntentId UNIQUE + status='PROCESSING' チェックで冪等
    • 大量 Vote 生成 → createMany 一括 INSERT、合計票数の上限を運用上想定範囲(数千)に収める
    • microCMS が一時的に応答しない → 再試行は Stripe が自動で行う(2xx を返さなければ再配信される)

Hook: usePurchaseFlow

FieldDetail
Intent購入モーダル全体の状態機械を管理
Requirements1.1, 1.5, 2.3, 9.1-9.3, 11.2, 11.3

Interface

typescript
// features/voting/hooks/usePurchaseFlow.ts
export type PurchaseStep =
  | 'select'                  // 数量入力 + ニックネーム入力
  | 'payment'                 // Stripe Payment Element 表示
  | 'processing'              // confirmPayment 後 → Webhook 待ち
  | 'pending_settlement'      // 30s 経過後の後続反映待ち
  | 'success'
  | 'error';

export type PurchaseState = Readonly<{
  step: PurchaseStep;
  clientSecret: string | null;
  purchaseId: string | null;
  totalCredits: number;
  errorMessage?: string;
}>;

export function usePurchaseFlow(): PurchaseState & {
  submit: (input: { candidateId: string; nickname: string; items: CartItemInput[] }) => Promise<void>;
  confirmStripeSucceeded: () => Promise<void>;     // polling 開始
  reset: () => void;
  setError: (message: string) => void;
};

State Transitions: select → payment → processing → success | error | pending_settlement → success | error

Implementation Notes

  • Integration: submit 内で createPaymentIntent を呼び、戻り値で step='payment' へ
  • confirmStripeSucceeded 内で getPurchaseStatus を 1 秒間隔で最大 30 回 polling
  • 30 秒で pending_settlement、5 分以内に SUCCEEDED が観測されなければ error
  • Risks: タブ切替・スリープで polling が止まる → visibilitychange で resume

Hook: usePurchaseCart

typescript
// features/voting/hooks/usePurchaseCart.ts
export type CartItem = Readonly<{ pkg: CreditPackage; quantity: number }>;

export function usePurchaseCart(packages: CreditPackage[]): {
  items: CartItem[];
  totalCredits: number;
  totalAmountJpy: number;
  setQuantity: (packageId: string, quantity: number) => void;
  increment: (packageId: string) => void;
  decrement: (packageId: string) => void;
  reset: () => void;
};

Algorithm: 内部状態は Map<packageId, quantity>。合計は memoize 計算

Implementation Notes

  • 数量は 0 以上の整数。負値・小数は入力で拒否
  • リセット時は全 quantity を 0 に戻す

Hook: useNickname

typescript
// features/voting/hooks/useNickname.ts
const STORAGE_KEY = 'mwj.nickname.v1';

export function useNickname(): {
  value: string;
  setValue: (v: string) => void;
  isValid: boolean;        // 1-32 + trim 非空
  errorMessage: string | null;
  persist: () => void;     // localStorage 書き込み(決済成功時に呼ぶ)
};

Algorithm:

  • 初回マウントで localStorage.getItem(STORAGE_KEY) から自動補完
  • 入力時にクライアント側で normalizeNickname 適用結果の長さ検証(エラー文言は内部で UI ヒント用に持つ)
  • persist() は決済成功時のみ呼ぶ(失敗時は永続化しない)

Implementation Notes

  • サーバー側の normalizeNickname と同じ規則をクライアント側でも適用(features/voting/services/ に純関数として置くか、data-model 側の関数を直接 import)
  • Risks: localStorage 無効環境(プライベートモード)では補完が効かないが、入力フィールドはそのまま使える

Hook: useVoteFlow

typescript
// features/voting/hooks/useVoteFlow.ts
export type VoteFlowState = Readonly<{
  selectedCandidate: Candidate | null;
  activeModal: 'purchase' | null;
  showConfetti: boolean;
}>;

export function useVoteFlow(): VoteFlowState & {
  openPurchaseModal: (candidate: Candidate) => void;
  closePurchaseModal: () => void;
  handlePaidVoteConfirmed: (params: { totalCredits: number; candidateId: string }) => void;
};

Implementation Notes

  • handlePaidVoteConfirmed は Confetti 表示 + 一定時間後にモーダルクローズ + 候補者リセットの順
  • ホーム / 候補者詳細から同じフックを使うため、楽観更新 callback は引数で受け取る形にしない(home 側で別に addOptimisticVote を呼ぶ)

Hook: useVotingPeriodStatus

typescript
// features/voting/hooks/useVotingPeriodStatus.ts
export type VotingPeriodStatus = Readonly<{
  isOpen: boolean;
  buttonLabel: '投票する' | '投票期間外';
}>;

export function useVotingPeriodStatus(period: {
  startsAt: Date;
  endsAt: Date;
}): VotingPeriodStatus;

Implementation Notes

  • UI ヒント専用(サーバー側でも createPaymentIntent が同じ判定を行う)
  • 1 秒間隔のタイマーで再評価(期間境界の動的反映)

Component: <PurchaseFlowContainer>

typescript
export type PurchaseFlowContainerProps = Readonly<{
  candidate: Candidate;
  packages: CreditPackage[];
  isOpen: boolean;
  onClose: () => void;
  onComplete: (params: { totalCredits: number; candidateId: string }) => void;
}>;

責務:

  • usePurchaseFlow / usePurchaseCart / useNickname の合成
  • step に応じて <PurchaseCreditsModal> / <StripePaymentModal> を出し分け
  • step='processing' のとき onClose を無効化

Component: <PurchaseCreditsModal>

typescript
export type PurchaseCreditsModalProps = Readonly<{
  candidate: Candidate;
  packages: CreditPackage[];
  cart: ReturnType<typeof usePurchaseCart>;
  nickname: ReturnType<typeof useNickname>;
  onProceed: () => void;
  onClose: () => void;
  isProceedDisabled: boolean;
}>;

表示要素:

  • 見出し: ${candidate.displayName} さんへの有料投票
  • <CreditPackageRow> × packages(displayOrder 昇順)
  • 合計票数 / 合計金額(cart.totalCredits / cart.totalAmountJpy)
  • <NicknameInput>
  • 「決済へ進む」ボタン(isProceedDisabled で disable)

Component: <StripePaymentModal>

typescript
export type StripePaymentModalProps = Readonly<{
  clientSecret: string;
  candidate: Candidate;
  cart: { items: CartItem[]; totalCredits: number; totalAmountJpy: number };
  onSucceeded: () => void;
  onFailed: (message: string) => void;
  onClose: () => void;
  isCloseDisabled: boolean;
}>;

責務:

  • <Elements stripe={...} options={{ clientSecret }}> で wrap し <PaymentElement> を表示
  • 決済金額・対象候補者・合計票数・パッケージ内訳を表示
  • 第三者プロバイダ表示テキスト

Data Models

本 spec はデータ層に新規モデルを追加しない。data-model/design.mdVote / Purchase / PurchaseItem をそのまま使う。

クライアント側ローカル型:

typescript
// features/voting/types.ts
export type CartItem = Readonly<{
  pkg: CreditPackage;        // microCMS 由来
  quantity: number;
}>;

export type CartItemInput = Readonly<{
  creditPackageId: string;
  quantity: number;
}>;

export type CreatePaymentIntentInput = Readonly<{
  candidateId: string;
  nickname: string;
  items: CartItemInput[];
}>;

Error Handling

シナリオ検出箇所対応
投票期間外の Server Action 受信createPaymentIntent{ ok: false, error: 'VOTING_PERIOD_CLOSED' } を返却、UI で文言表示
ニックネーム無効createPaymentIntent + <NicknameInput>クライアント側で事前 disable、サーバー側で INVALID_NICKNAME
パッケージ無効(isActive=false / 削除済み)createPaymentIntentINVALID_PACKAGE、UI で「販売が終了したパッケージが含まれます」
候補者が microCMS で非公開化createPaymentIntentCANDIDATE_NOT_FOUND、UI で「投票できなくなりました」
Stripe API エラーcreatePaymentIntent / confirmPaymentPAYMENT_PROVIDER_ERROR / step='error'
Webhook 署名検証失敗Webhook ハンドラ400 を返却、ログ
Webhook 重複配信Webhook ハンドラstatus='PROCESSING' 以外は no-op で 200
決済成功後の polling タイムアウトusePurchaseFlowstep='pending_settlement' → 5 分後に error → サポート連絡導線
クライアントの二重送信usePurchaseFlowstep≠'select' のとき submit 不可

Testing Strategy

Unit

  • usePurchaseCart: 数量増減・合計計算・リセット(要件 5.x)
  • useNickname: localStorage 補完・正規化済み長さ判定・persist タイミング(要件 6.x)
  • usePurchaseFlow: 状態遷移(select→payment→processing→success/error/pending_settlement)(要件 9.x, 11.x)
  • useVoteFlow: モーダル開閉・候補者リセット(要件 2.x, 10.3)

Integration

  • createPaymentIntent: 期間ガード / ニックネーム正規化 / パッケージ検証 / 合計再計算 / Purchase+Item 永続化を一気通貫で検証(要件 1.1, 1.6, 5.5, 6.x)
  • Webhook ハンドラ: payment_intent.succeeded → Vote 一括生成 / 重複配信冪等 / payment_intent.payment_failed → status=FAILED / charge.refunded → status=REFUNDED で Vote 未変更(要件 1.2, 1.3, 10.1, 10.2, 非機能)
  • getPurchaseStatus polling: SUCCEEDED 観測で step='success'、30s 経過で 'pending_settlement'

E2E(Playwright + Stripe テストキー)

  • 候補者選択 → 購入モーダル → ニックネーム入力 → 「決済へ進む」 → Stripe Payment Element → 成功 → Confetti → 得票数反映(要件 1.1, 2.x, 10.x)
  • 二重送信抑止: 「決済へ進む」連続クリックで 1 件だけ Purchase が生成される(要件 1.5, 非機能-冪等性)
  • 投票期間外: Server Action が VOTING_PERIOD_CLOSED を返し UI で文言表示(要件 1.6)

Security Considerations

  • CSRF: Server Action は Next.js が自動で CSRF トークンを付与
  • Webhook 署名検証: STRIPE_WEBHOOK_SECRET で検証、未検証リクエストは 400
  • 金額改竄: サーバー側で CreditPackage を再取得して合計再計算(クライアント値を信用しない)
  • PCI DSS: カード情報はクライアントから直接 Stripe に送信、サーバーは触らない(SAQ A 範囲)
  • ニックネーム XSS: 表示時は React の自動エスケープに依存、HTML 直挿入を行わない

Performance

  • Server Action: PaymentIntent 作成と DB 書き込みで 100-300ms を想定。並列 microCMS 取得でレイテンシを抑制
  • Webhook: Vote 生成は createMany で一括 INSERT。合計票数が大きい場合(数千)でも 1 秒以内
  • クライアント polling: 1 秒間隔 × 最大 30 回。visibilitychange でスリープ復帰時に即座 1 回取得

Migration Strategy

  1. features/voting/services/creditPackageService.ts 削除(data-model の features/cms/services/creditPackageService.ts に統合)
  2. features/voting/services/votingPeriodService.ts 削除(lib/voting-period.ts に統合)
  3. Purchase / PurchaseItem の total* / *Snapshot 列を参照しているコードを削除(クライアント計算は CreditPackage から都度算出)
  4. Vote の status 列参照を削除(集計は voteAggregationService の JOIN フィルタに統一)
  5. Stripe Webhook ハンドラを新規実装(または既存実装を data-model 契約に合わせて更新)
  6. useNickname の正規化関数を data-model の normalizeNickname に切替
  7. E2E でフローを通す

Supporting References

  • requirements.md — 機能要件
  • ui-design.md — モーダルのビジュアル仕様
  • ../data-model/design.mdVote / Purchase / PurchaseItem / creditPackageService / nicknameService / lib/voting-period
  • ../home/design.md — 投票導線の起点
  • ../candidate-detail/design.md — 投票導線の起点(詳細画面)
  • steering/product.md「投票期間」 — 期間内/外の業務ルール
  • Stripe Payment Intents — API リファレンス
  • Stripe Webhooks 署名検証 — セキュリティ