テーマ
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-jsをpackage.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.tsにCartItem/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 スキーマ
CreatePaymentIntentInputSchemaでcandidateId/nickname(1〜64 文字) /items(1〜20 要素)を検証する getVotingPeriod()で取得した期間外ならVOTING_PERIOD_CLOSEDを返す(UI 抑止が破られた場合の最終ガード)normalizeNickname(input.nickname)を呼び、InvalidNicknameErrorでINVALID_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.tsにPOSTハンドラを実装し、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.$queryRawのSELECT ... 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.createManyでtotalCredits件の 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/totalAmountJpyをuseMemoで計算し、引数の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 し、SUCCEEDEDでstep='success'、FAILEDでstep='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 する onChangeでusePurchaseCartのsetQuantityを呼ぶ- 表示順は親が
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 値で再計算するため Stripeamountが正しい値で発行されることを確認 - Webhook 重複配信(同一
payment_intent.succeededを 2 回送信)で Vote が 1 度しか生成されないことを確認 payment_intent.payment_failedでstatus='FAILED'に遷移し Vote 件数 0 を確認charge.refundedでstatus='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-detailspec 側のタスクであり、いずれも未実装。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 統合時にこの早期成功扱いをどう扱うかを再検討すること(usePurchaseFlowのconfirmStripeSucceededが 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 実装時に以下のいずれかで整合させる必要がある:<NicknameInput>を controlled component に refactor し、useNicknameの返り値を props で受け取る形に変える (Task 6.2 仕様変更が必要)<PurchaseFlowContainer>でuseNickname()を呼んで Context 経由で<NicknameInput>と<PurchaseCreditsModal>に共有する<PurchaseCreditsModal>内でuseNickname()を直接呼んで「決済へ進む」ボタンを gate し、Container には渡さない