Skip to content

Implementation Plan

本 spec は data-model のサービス契約に完全依拠する。データ層モデル・creditPackageService / nicknameService / lib/voting-period / lib/prisma の実装は data-model/tasks.md で完了している前提で構成する。

境界遵守: nicknameService.normalizeNickname を本 spec 側で再定義しない。lib/voting-period.ts を本 spec 側で複製しない。

  • [ ] 1. プロジェクト依存・環境変数の追加

  • [x] 1.1 (P) Stripe SDK の依存を追加

    • stripe / @stripe/stripe-js / @stripe/react-stripe-jspackage.json に追加し、lockfile を更新する
    • TypeScript で import Stripe from 'stripe'import { Elements, PaymentElement } from '@stripe/react-stripe-js' が型解決されることを確認する
    • Requirements: 7.1, 7.2, 7.3
    • Boundary: package.json
  • [x] 1.2 (P) Stripe 関連環境変数の定義

    • STRIPE_SECRET_KEY / STRIPE_WEBHOOK_SECRET / NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY をローカル .env.local と Vercel 環境に投入する手順を運用ドキュメントに残す
    • Stripe テストキーで stripe.paymentIntents.create({ amount: 100, currency: 'jpy' }) が成功することを確認する
    • Requirements: 7.5
    • Boundary: env配信
  • [ ] 2. クライアントローカル型の宣言

  • [x] 2.1 投票フロー共通型を定義

    • features/voting/types.tsCartItem / CartItemInput / CreatePaymentIntentInput を Readonly 型で宣言する
    • pkg: CreditPackage の型は @/features/cms/types から import する(再宣言しない)
    • 他コンポーネント・フックから import type { CartItem } from '@/features/voting/types' で参照可能になることを確認する
    • Requirements: 5.1, 5.2, 5.3, 5.4
    • Boundary: features/voting/types.ts
  • [ ] 3. Server Action 層

  • [x] 3.1 PaymentIntent 作成 Server Action

    • app/actions/purchase.ts'use server' 宣言で createPaymentIntent(input) を実装する
    • Zod スキーマ CreatePaymentIntentInputSchemacandidateId / nickname(1〜64 文字) / items(1〜20 要素)を検証する
    • getVotingPeriod() で取得した期間外なら VOTING_PERIOD_CLOSED を返す(UI 抑止が破られた場合の最終ガード)
    • normalizeNickname(input.nickname) を呼び、InvalidNicknameErrorINVALID_NICKNAME を返す
    • getCandidate(candidateId) で候補者公開状態を確認し、getCreditPackage(id) を並列取得して isActive=true を検証
    • サーバー側で合計金額 totalAmountJpy = Σ(priceJpy × quantity) を再計算(クライアント値は使わない)
    • stripe.paymentIntents.create({ amount, currency: 'jpy', automatic_payment_methods: { enabled: true }, metadata }) を呼ぶ
    • prisma.$transaction で Purchase(status='PROCESSING')+ PurchaseItem を一括 INSERT する
    • 戻り値型 CreatePaymentIntentResult の各エラー分岐が型上網羅されていることを確認する
    • Requirements: 1.1, 1.6, 5.5, 6.1, 6.2, 6.3
    • Boundary: app/actions/purchase.ts
    • Depends: 1.1
  • [x] 3.2 Purchase ステータス取得 Server Action

    • 同ファイルに getPurchaseStatus(purchaseId) を実装し、prisma.purchase.findUnique({ select: { status: true } }) で最新 status を返す
    • unstable_cache を使わず、常に DB から最新値を返すことを確認する
    • 存在しない purchaseId に対して null を返す
    • Requirements: 9.1, 9.2, 9.3, 10.1
    • Boundary: app/actions/purchase.ts
  • [ ] 4. Stripe Webhook ハンドラ

  • [x] 4.1 Webhook ルートと署名検証

    • app/api/webhooks/stripe/route.tsPOST ハンドラを実装し、runtime = 'nodejs' を明示する
    • req.text() で生 body を読み、stripe.webhooks.constructEvent(body, signature, STRIPE_WEBHOOK_SECRET) で署名検証する
    • 検証失敗時に 400 を返し、検証成功時のみ event.type で handleSucceeded / handleFailed / handleRefunded に分岐する
    • Stripe CLI stripe trigger payment_intent.succeeded でハンドラが 200 を返すことを確認する
    • Requirements: 7.5
    • Boundary: app/api/webhooks/stripe/route.ts
  • [x] 4.2 決済成功ハンドラ(行ロック + Vote 一括生成 + 冪等化)

    • handleSucceeded(intent)prisma.$transaction(..., { isolationLevel: 'Serializable' }) 内で実装する
    • tx.$queryRawSELECT ... FROM "Purchase" WHERE "paymentIntentId" = ${intent.id} FOR UPDATE で行ロックを取得する
    • purchase.status !== 'PROCESSING' なら no-op で戻る(既遷移 / 重複配信の冪等化)
    • tx.purchaseItem.findMany で明細を取得し、getCreditPackage(id) で CreditPackage を再取得して totalCredits = Σ(quantity × credits) を計算する
    • tx.purchase.updateMany({ where: { id, status: 'PROCESSING' }, data: { status: 'SUCCEEDED' } }) で条件付き UPDATE し、count === 0 なら no-op(FOR UPDATE と合わせた二段ガード)
    • tx.vote.createManytotalCredits 件の Vote を INSERT する(各 Vote は candidateId / purchaseId / nickname を Purchase から継承)
    • Stripe CLI で同一 event を 2 回 trigger しても Vote 件数が 2 倍にならないことを確認する
    • Requirements: 1.2, 10.1, 10.2
    • Boundary: app/api/webhooks/stripe/route.ts
    • Depends: 3.1
  • [x] 4.3 決済失敗・払戻ハンドラ

    • handleFailed(intent)prisma.purchase.updateMany({ where: { paymentIntentId: intent.id, status: 'PROCESSING' }, data: { status: 'FAILED' } }) で実装する
    • handleRefunded(charge) を同じく updateMany({ where: { paymentIntentId, status: 'SUCCEEDED' }, data: { status: 'REFUNDED' } }) で実装する
    • 失敗時に Vote が生成されないこと、払戻時に Vote が削除も変更もされないことを統合テストで確認する
    • Requirements: 1.3, 1.4
    • Boundary: app/api/webhooks/stripe/route.ts
  • [ ] 5. クライアントフック層

  • [x] 5.1 (P) 数量入力・合計計算フック

    • features/voting/hooks/usePurchaseCart.ts を実装し、内部状態 Map<packageId, quantity> を持つ
    • setQuantity / increment / decrement / reset を提供する
    • 数量は 0 以上の整数のみ許可(負値・小数は拒否)
    • totalCredits / totalAmountJpyuseMemo で計算し、引数の packages 順を保ったまま items: CartItem[] を返す
    • reset() 呼び出しで全 quantity が 0 に戻り、合計が 0 となることを確認する
    • Requirements: 5.1, 5.2, 5.3, 5.4, 11.1
    • Boundary: features/voting/hooks/usePurchaseCart.ts
  • [x] 5.2 (P) ニックネーム入力フック

    • features/voting/hooks/useNickname.ts を実装し、localStorage キー mwj.nickname.v1 から初回マウント時に補完する
    • 入力ごとに normalizeNickname(data-model 所有)を試行し、isValid / errorMessage を導出する
    • persist()localStorage.setItem で永続化する(決済成功時のみ呼ぶことをコメントで明示)
    • 初回マウント時は isOpen=false 相当の SSR 安全な初期値で start し、useEffect 内で localStorage を読むことで Hydration mismatch を回避する
    • プライベートモード等で localStorage 例外が出ても入力フィールドが機能することを確認する
    • Requirements: 6.1, 6.2, 6.3, 6.4, 6.5
    • Boundary: features/voting/hooks/useNickname.ts
  • [x] 5.3 (P) 投票期間ステータスフック

    • features/voting/hooks/useVotingPeriodStatus.ts を実装し、引数 { startsAt, endsAt } から isOpen / buttonLabel('投票する' | '投票期間外')を返す
    • Hydration 安全性: SSR 時の初期値は isOpen=false(最も保守的)とし、useEffect のマウント時に現在時刻を評価して更新する
    • 1 秒間隔の setInterval で再評価し、境界時刻をまたいで isOpen が遷移することを確認する
    • クリーンアップで clearInterval を呼び、アンマウント後のタイマー残留が無いことを確認する
    • Requirements: 1.6
    • Boundary: features/voting/hooks/useVotingPeriodStatus.ts
  • [x] 5.4 購入フロー状態機械フック

    • features/voting/hooks/usePurchaseFlow.ts を実装し、step: 'select' | 'payment' | 'processing' | 'pending_settlement' | 'success' | 'error' を持つ
    • submit(input)createPaymentIntent を呼び、成功時 step='payment'、エラー時 step='error' + errorMessage に遷移する
    • confirmStripeSucceeded()getPurchaseStatus を 1 秒間隔 × 最大 30 回 polling し、SUCCEEDEDstep='success'FAILEDstep='error'、タイムアウトで step='pending_settlement' に遷移する
    • step !== 'select' のとき submit が no-op で戻る(連続送信抑止)
    • visibilitychange イベントでタブ復帰時に polling を即時 1 回実行する
    • reset()step='select' に戻し、clientSecret / purchaseId / errorMessage を null にする
    • Requirements: 1.1, 1.5, 9.1, 9.2, 9.3, 11.2, 11.3
    • Boundary: features/voting/hooks/usePurchaseFlow.ts
    • Depends: 3.1, 3.2
  • [x] 5.5 (P) 投票モーダル開閉フック

    • features/voting/hooks/useVoteFlow.ts を実装し、selectedCandidate / activeModal / showConfetti を管理する
    • openPurchaseModal(candidate)selectedCandidate をセットし activeModal='purchase' に遷移する
    • closePurchaseModal() で activeModal を null、selectedCandidate を null にする
    • handlePaidVoteConfirmed({ totalCredits, candidateId })showConfetti=true → 一定時間後にモーダルクローズ + 候補者リセットを順次行う
    • home / candidate-detail 双方から同じフックを import できるよう Server-only 依存を持たない純フックとして実装する
    • Requirements: 2.1, 2.2, 10.3
    • Boundary: features/voting/hooks/useVoteFlow.ts
  • [ ] 6. UI コンポーネント層

  • [x] 6.1 (P) パッケージ行コンポーネント

    • features/voting/components/CreditPackageRow.tsx を実装し、props { pkg, quantity, onChange } を取る
    • 票数 / 価格 / +・− ボタン / テキスト入力を表示し、数量 0 以下では - ボタンを disable する
    • onChangeusePurchaseCartsetQuantity を呼ぶ
    • 表示順は親が packages.sort((a, b) => a.displayOrder - b.displayOrder) で渡す前提とコメントで明示する
    • Requirements: 4.1, 4.2, 5.1, 5.2
    • Boundary: features/voting/components/CreditPackageRow.tsx
  • [x] 6.2 (P) ニックネーム入力コンポーネント

    • features/voting/components/NicknameInput.tsx を実装し、useNickname を内部で呼ぶ
    • エラー時に errorMessage を入力フィールド下に表示する
    • 「同一文字列のニックネームは同一支援者として集計されます」の補助テキストを常時表示する
    • 入力 / 補完 / エラー表示の各状態を Storybook 等で目視確認できる単一コンポーネントとして完結させる
    • Requirements: 6.1, 6.6
    • Boundary: features/voting/components/NicknameInput.tsx
    • Depends: 5.2
  • [x] 6.3 票数選択モーダル

    • features/voting/components/PurchaseCreditsModal.tsx を実装し、{ candidate, packages, cart, nickname, onProceed, onClose, isProceedDisabled } を受け取る
    • 見出し「${candidate.displayName} さんへの有料投票」、<CreditPackageRow> × displayOrder 昇順、合計票数・合計金額、<NicknameInput>、「決済へ進む」ボタンを配置する
    • 「決済へ進む」は cart.totalCredits === 0 または !nickname.isValid または isProceedDisabled のとき disabled
    • モーダル外クリック / ESC / ×ボタンで onClose を呼ぶ
    • Requirements: 3.1, 4.1, 4.2, 5.3, 5.4, 6.4
    • Boundary: features/voting/components/PurchaseCreditsModal.tsx
    • Depends: 6.1, 6.2
  • [x] 6.4 Stripe Payment モーダル

    • features/voting/components/StripePaymentModal.tsx を実装し、<Elements stripe={loadStripe(publishableKey)} options={{ clientSecret }}><PaymentElement> を wrap する
    • 決済金額(税込円表記) / 対象候補者名 / 合計票数 / パッケージ内訳を表示する
    • 「Stripe による安全な決済」旨の補助テキストを常時表示する
    • stripe.confirmPayment の success コールバックで onSucceeded を呼ぶ
    • isCloseDisabled=true のとき ×ボタンと外側クリックを無効化する
    • Requirements: 7.1, 7.2, 7.3, 7.4, 7.5, 8.1, 8.2
    • Boundary: features/voting/components/StripePaymentModal.tsx
  • [x] 6.5 フロー制御コンテナ

    • features/voting/components/PurchaseFlowContainer.tsx を実装し、{ candidate, packages, isOpen, onClose, onComplete } を受け取る
    • 内部で usePurchaseFlow / usePurchaseCart(packages) / useNickname を合成する
    • step に応じて <PurchaseCreditsModal> / <StripePaymentModal> を出し分ける
    • step==='processing' のとき onClose 呼び出しを無効化する
    • step==='success'nickname.persist() を呼び、onComplete({ totalCredits, candidateId }) をトリガしてモーダルを閉じる
    • onClose 時に cart.reset() を呼び数量・合計をリセットする
    • Requirements: 1.1, 2.1, 2.2, 2.3, 10.1, 11.1, 11.2, 11.3
    • Boundary: features/voting/components/PurchaseFlowContainer.tsx
    • Depends: 5.1, 5.2, 5.4, 6.3, 6.4
  • [ ] 7. 検証

  • [x] 7.1 (P) フックのユニットテスト

    • usePurchaseCart: 数量増減・合計計算・リセット(要件 5.x)
    • useNickname: localStorage 補完・正規化済み長さ判定・persist タイミング(要件 6.x)
    • usePurchaseFlow: select→payment→processing→success/error/pending_settlement の状態遷移と二重 submit 抑止(要件 9.x, 11.x, 1.5)
    • useVoteFlow: モーダル開閉・候補者リセット(要件 2.x, 10.3)
    • useVotingPeriodStatus: 期間内/外境界 + Hydration 安全性(SSR 初期値が isOpen=false
    • vitest で全件 green になる
    • Requirements: 1.5, 2.1, 2.2, 5.1, 5.2, 5.3, 5.4, 6.1, 6.2, 6.3, 6.4, 6.5, 9.1, 9.2, 9.3, 10.3, 11.1, 11.2, 11.3
    • Boundary: features/voting/hooks/**
    • Depends: 5.1, 5.2, 5.3, 5.4, 5.5
  • [x] 7.2 (P) Server Action / Webhook の統合テスト

    • createPaymentIntent: 期間外で VOTING_PERIOD_CLOSED、無効ニックネームで INVALID_NICKNAME、無効パッケージで INVALID_PACKAGE、正常系で clientSecret + Purchase/Item 永続化を確認
    • クライアント送信値の priceJpy を改竄しても、サーバーが microCMS 値で再計算するため Stripe amount が正しい値で発行されることを確認
    • Webhook 重複配信(同一 payment_intent.succeeded を 2 回送信)で Vote が 1 度しか生成されないことを確認
    • payment_intent.payment_failedstatus='FAILED' に遷移し Vote 件数 0 を確認
    • charge.refundedstatus='REFUNDED' に遷移し Vote 件数が不変、voteAggregationService の集計結果から消えることを確認
    • Requirements: 1.1, 1.2, 1.3, 1.6, 5.5, 6.1, 6.2, 6.3, 10.1, 10.2
    • Boundary: app/actions/purchase.ts, app/api/webhooks/stripe/route.ts
    • Depends: 3.1, 3.2, 4.2, 4.3
  • [ ] 7.3 E2E(Playwright + Stripe テストキー)

    • 候補者選択 → 購入モーダル → ニックネーム入力 → 数量入力 → 「決済へ進む」 → Stripe Payment Element → 4242 4242 4242 4242 で成功 → Confetti → 得票数反映の通しシナリオが完走する
    • 「決済へ進む」連続クリック時に Purchase が 1 件のみ生成されることを DB で確認する
    • 投票期間外(環境変数を一時的に未来日付に書き換え)で VOTING_PERIOD_CLOSED の UI 文言が表示され決済画面に進めないことを確認する
    • Requirements: 1.1, 1.5, 1.6, 2.1, 9.1, 9.2, 9.3, 10.1
    • Boundary: features/voting/**, app/actions/purchase.ts
    • Depends: 6.5, 7.2
    • Blocked: page 統合(app/page.tsx<PurchaseFlowContainer> を呼ぶ、<Confetti> レンダリング、得票数表示)が home / candidate-detail spec 側のタスクであり、いずれも未実装。Playwright も未導入。voting spec の boundary (features/voting/**, app/actions/purchase.ts) を超えるため本 spec 内で 7.3 を完了不可。home / candidate-detail の E2E タスク (Task 4.4) で page-level E2E をカバーすべき。

Implementation Notes

  • 7.2: Task 7.2 の統合テスト (app/api/webhooks/stripe/handlers.integration.test.ts) を実 PostgreSQL に対して実行したところ、handlers.ts:66 の raw SQL が FROM "Purchase" (PascalCase) を使っていたため relation "Purchase" does not exist で失敗した。Prisma schema は @@map("purchases") (lowercase) で物理テーブル名をマッピングしているため、raw SQL は FROM "purchases" を使う必要がある。Task 4.2 の handlers.test.ts は $queryRaw をモックしていたため unit test では検出できなかった。これを bd0e44e で fix し Task 7.2 を完了。今後 raw SQL を書く場合は必ず Prisma schema の @@map 指定と一致させること。
  • 6.4: <StripePaymentModal> の props 形状が design.md の cart: { items: CartItem[]; totalCredits; totalAmountJpy } から逸脱し、totalCredits / totalAmountJpy / breakdown: Array<{ name, quantity, credits, priceJpy }> の 3 prop に flatten された。Task 6.5 <PurchaseFlowContainer> 側で cart.items.map(item => ({ name: item.pkg.name, quantity: item.quantity, credits: item.pkg.credits, priceJpy: item.pkg.priceJpy })) という adapter を入れて <StripePaymentModal> に渡す必要がある。
  • 6.4: <StripePaymentModal> 内の confirmPayment 結果ハンドリングで、paymentIntent.status === 'processing' の場合も onSucceeded を呼ぶ実装になっている。design.md は polling 経由を前提としているため、Task 6.5 統合時にこの早期成功扱いをどう扱うかを再検討すること(usePurchaseFlowconfirmStripeSucceeded が onSucceeded をトリガに polling を開始する想定なので、processing→onSucceeded はその polling 入口になり許容と判断することも可)。
  • 6.2: <NicknameInput> は tasks.md 文言に従い内部で useNickname() を呼ぶ。Task 6.3 / 6.5 では <PurchaseFlowContainer> 側でも useNickname() を呼んで <PurchaseCreditsModal> に props で渡す設計のため、同一 React tree 内で 2 つの useNickname() インスタンスが共存することになる。両者は同じ localStorage キー (mwj.nickname.v1) を共有するが、React state は独立する。「決済へ進む」ボタンを !nickname.isValid で gate しても <NicknameInput> 内のキー入力は親に反映されないので、6.3 実装時に以下のいずれかで整合させる必要がある:
    1. <NicknameInput> を controlled component に refactor し、useNickname の返り値を props で受け取る形に変える (Task 6.2 仕様変更が必要)
    2. <PurchaseFlowContainer>useNickname() を呼んで Context 経由で <NicknameInput><PurchaseCreditsModal> に共有する
    3. <PurchaseCreditsModal> 内で useNickname() を直接呼んで「決済へ進む」ボタンを gate し、Container には渡さない