テーマ
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-modelservices:creditPackageService.getActiveCreditPackages/getCreditPackage、nicknameService.normalizeNickname(Client/Server 両用の純関数として data-model が export、useNicknameフック内で事前バリデーションに使用)、lib/voting-period.getVotingPeriod、lib/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-modelのPaymentStatusenum 値変更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 --> PrismaClientArchitecture 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
| Layer | Choice | Role |
|---|---|---|
| 決済プロバイダ | Stripe (Payment Intents + Webhook) | カード機微情報を持たない、Link 優先 |
| クライアント SDK | @stripe/react-stripe-js / @stripe/stripe-js | Payment Element |
| サーバー SDK | stripe@^22 | PaymentIntent 作成 / Webhook 署名検証 |
| 入力検証 | Zod ^4 | Server 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 / PurchaseStateModified Files
next.config.ts— Stripe.js を読み込むためscript-srcCSP 設定が必要なら追記(なくても動作するが本番運用で要検討)package.json—stripe(既存)/@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-modelReq 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-modelReq 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
endKey Decision:
- 30 秒で
pending_settlementに遷移し、ユーザーに「完了通知が後ほど反映される」旨を案内 - 5 分以内に SUCCEEDED が観測されなければサポート連絡導線を提示(UI 文言は
ui-design.md)
Requirements Traceability
| Req | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | 候補者選択+パッケージ+ニックネーム+決済を一フローとして扱う | <PurchaseFlowContainer> + usePurchaseFlow | 状態機械 select→payment→success | Flow 1, 2 |
| 1.2 | 決済成功時に合計票数分を加算 | Webhook ハンドラ | vote.createMany | Flow 2 |
| 1.3 | 決済失敗時は投票を成立させない | Webhook ハンドラ | status='FAILED' のみ更新 | Flow 3 |
| 1.4 | 投票券残数の消費は提供しない | アーキテクチャ全体 | Vote は Purchase 由来のみ | — |
| 1.5 | 連続購入を抑止 | usePurchaseFlow | step≠'select' のとき再起動不可 | — |
| 1.6 | 投票期間外はサーバー側で拒否 | createPaymentIntent | getVotingPeriod() + VotingPeriodClosedError | Flow 1 |
| 2.1 | 候補者選択時にモーダル起動 | useVoteFlow.openPurchaseModal | activeModal='purchase' | Flow 1 |
| 2.2 | 閉じる操作で選択リセット | <PurchaseFlowContainer> | onClose → setSelectedCandidate(null) | — |
| 2.3 | 決済処理中は閉じる操作を無効化 | usePurchaseFlow | step='processing' のとき disable | Flow 2 |
| 3.1 | モーダル内コンテンツ(候補者・パッケージ・ニックネーム・決済導線) | <PurchaseCreditsModal> | props で各要素 | Flow 1 |
| 4.1 | 有効パッケージを表示順で表示 | <PurchaseCreditsModal> + creditPackageService | getActiveCreditPackages()(displayOrder 昇順) | — |
| 4.2 | パッケージごとに票数・価格表示 | <CreditPackageRow> | props で表示 | — |
| 4.3 | 具体値は運営管理 | (data-model 委譲) | — | — |
| 5.1-5.4 | 数量 ≥0 整数、増減ボタン + テキスト、合計即時更新、合計 0 で disable | usePurchaseCart + <CreditPackageRow> | クライアントローカル状態 | — |
| 5.5 | 合計はクライアント表示+サーバー再計算 | <PurchaseCreditsModal> + createPaymentIntent | サーバーで CreditPackage 再取得し合計再計算 | Flow 1 |
| 6.1-6.5 | ニックネーム必須+自動補完+1-32 文字+空白無効+永続化 | useNickname + nicknameService | localStorage + normalizeNickname | Flow 1, 2 |
| 6.6 | 同一文字列は同一支援者の補助テキスト | <NicknameInput> | helper text | — |
| 7.1-7.3 | Stripe 埋め込み UI(Link / カード / モバイル) | <StripePaymentModal> | <PaymentElement> | Flow 2 |
| 7.4 | カード番号フォーマット等は Stripe に委譲 | <StripePaymentModal> | Payment Element | Flow 2 |
| 7.5 | カード機微情報を持たない | アーキテクチャ全体 | サーバー側に Card データを送らない | Flow 2 |
| 8.1 | 決済金額・対象・合計票数・内訳を明示 | <StripePaymentModal> | props で表示 | Flow 2 |
| 8.2 | 第三者プロバイダの安全性を明示 | <StripePaymentModal> | UI 文言 | — |
| 9.1 | 決済確定で処理中状態に遷移 | usePurchaseFlow | step='processing' | Flow 2 |
| 9.2 | 決済成功で成功状態+フィードバック | usePurchaseFlow + <Confetti> | step='success' | Flow 2 |
| 9.3 | 成功表示後に投票数加算+クローズ | usePurchaseFlow.onSuccess + useVoteFlow.handlePaidVoteConfirmed | — | Flow 2 |
| 10.1 | 投票加算+永続化+フィードバック+クローズ+リセット | usePurchaseFlow + useNickname.persist + useVoteFlow | — | Flow 2 |
| 10.2 | 加算量 = 合計票数 | Webhook ハンドラ | vote.createMany(count=totalCredits) | Flow 2 |
| 10.3 | フィードバック終了で選択リセット | useVoteFlow.handlePaidVoteConfirmed | setSelectedCandidate(null) | — |
| 11.1 | 購入モーダル閉じで数量・合計・候補者リセット | usePurchaseCart.reset + <PurchaseFlowContainer> | — | — |
| 11.2 | 決済モーダル閉じで決済状態リセット | usePurchaseFlow.reset | — | — |
| 11.3 | 処理中は閉じる操作無効 | usePurchaseFlow | step='processing' で disable | — |
| 非機能-トランザクション | Purchase + Vote の原子性 | Webhook ハンドラ | prisma.$transaction | Flow 2 |
| 非機能-冪等性 | Webhook 重複配信での二重生成防止 | Webhook ハンドラ | paymentIntentId UNIQUE + status FOR UPDATE | Flow 2 |
Components and Interfaces
Summary
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies | Contracts |
|---|---|---|---|---|---|
createPaymentIntent | Server Action | Purchase 作成 + Stripe PaymentIntent 発行 + 期間ガード | 1.1, 1.6, 5.5, 6.1-6.3 | Stripe(P0), data-model services(P0) | Service |
getPurchaseStatus | Server Action | Purchase.status のポーリング窓口 | 9.x, Flow 4 | Prisma(P0) | Service |
app/api/webhooks/stripe/route.ts | API Route | 状態遷移 + Vote 一括生成 + 冪等化 | 1.2, 1.3, 10.1, 10.2, 非機能 | Stripe Webhook(P0), Prisma(P0) | Service |
usePurchaseFlow | Hook | 状態機械 (select→payment→processing→success/error) | 1.1, 1.5, 2.3, 9.x, 11.2, 11.3 | (純フック) | Service |
usePurchaseCart | Hook | 数量入力 + 合計計算 + リセット | 5.1-5.4, 11.1 | (純フック) | Service |
useNickname | Hook | 入力 + localStorage 永続化 + 補完 | 6.1, 6.2, 6.5 | localStorage(P0) | Service |
useVoteFlow | Hook | モーダル開閉 + Confetti + 候補者リセット | 2.1, 2.2, 10.3 | (純フック) | Service |
useVotingPeriodStatus | Hook | 期間内/外の UI ヒント | (補助) | clock | Service |
<PurchaseFlowContainer> | Component | フロー制御 + 子モーダル切替 | 1.1, 2.1, 11.1 | hooks(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.4 | — | UI |
<NicknameInput> | Component | ニックネーム入力フィールド + helper text | 6.1, 6.6 | useNickname | UI |
<StripePaymentModal> | Component | Stripe Payment Element 内包 | 7.x, 8.x | @stripe/react-stripe-js(P0) | UI |
Server Action: createPaymentIntent
| Field | Detail |
|---|---|
| Intent | クライアントからのカート明細を受け取り、期間ガード・正規化・サーバー側合計再計算を経て Stripe PaymentIntent を発行、Purchase を PROCESSING で永続化 |
| Requirements | 1.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-modelのgetVotingPeriod/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:
purchaseIdはcreatePaymentIntentの戻り値 - Postconditions: 該当 Purchase の最新 status を返す。存在しなければ null
- Implementation Notes: クライアントの polling 窓口。
unstable_cacheは使わない(最新値を要求)
Webhook Handler: app/api/webhooks/stripe/route.ts
| Field | Detail |
|---|---|
| Intent | Stripe Webhook を唯一の権威として Purchase.status 遷移と Vote 一括生成を担う |
| Requirements | 1.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 重複配信 →
paymentIntentIdUNIQUE + status='PROCESSING' チェックで冪等 - 大量 Vote 生成 →
createMany一括 INSERT、合計票数の上限を運用上想定範囲(数千)に収める - microCMS が一時的に応答しない → 再試行は Stripe が自動で行う(2xx を返さなければ再配信される)
- Webhook 重複配信 →
Hook: usePurchaseFlow
| Field | Detail |
|---|---|
| Intent | 購入モーダル全体の状態機械を管理 |
| Requirements | 1.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.md の Vote / 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 / 削除済み) | createPaymentIntent | INVALID_PACKAGE、UI で「販売が終了したパッケージが含まれます」 |
| 候補者が microCMS で非公開化 | createPaymentIntent | CANDIDATE_NOT_FOUND、UI で「投票できなくなりました」 |
| Stripe API エラー | createPaymentIntent / confirmPayment | PAYMENT_PROVIDER_ERROR / step='error' |
| Webhook 署名検証失敗 | Webhook ハンドラ | 400 を返却、ログ |
| Webhook 重複配信 | Webhook ハンドラ | status='PROCESSING' 以外は no-op で 200 |
| 決済成功後の polling タイムアウト | usePurchaseFlow | step='pending_settlement' → 5 分後に error → サポート連絡導線 |
| クライアントの二重送信 | usePurchaseFlow | step≠'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, 非機能)
getPurchaseStatuspolling: 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
features/voting/services/creditPackageService.ts削除(data-model のfeatures/cms/services/creditPackageService.tsに統合)features/voting/services/votingPeriodService.ts削除(lib/voting-period.tsに統合)- Purchase / PurchaseItem の
total*/*Snapshot列を参照しているコードを削除(クライアント計算は CreditPackage から都度算出) - Vote の
status列参照を削除(集計はvoteAggregationServiceの JOIN フィルタに統一) - Stripe Webhook ハンドラを新規実装(または既存実装を data-model 契約に合わせて更新)
useNicknameの正規化関数を data-model のnormalizeNicknameに切替- E2E でフローを通す
Supporting References
requirements.md— 機能要件ui-design.md— モーダルのビジュアル仕様../data-model/design.md—Vote/Purchase/PurchaseItem/creditPackageService/nicknameService/lib/voting-period../home/design.md— 投票導線の起点../candidate-detail/design.md— 投票導線の起点(詳細画面)steering/product.md「投票期間」 — 期間内/外の業務ルール- Stripe Payment Intents — API リファレンス
- Stripe Webhooks 署名検証 — セキュリティ