[← Docs](/docs/)  |  [Home](/)

# Stripe Webhook Plan Update (v2) — Phase3-2

> ℹ️ **主に開発者・運用担当者向けの技術資料です。**  
> ご利用方法やお申し込みの確認は **/subscribe.htm** をご覧ください。  



## 目的
Phase3-1（metadata 前提の最小）を保ったまま、Phase3-2 として **安全に運用しやすい** 形へ寄せる。

- metadata が無い、または欠落するイベントがある
- test/live の ID 差（price_id/product_id）が運用差分を生みやすい
- Stripe の再送は 4xx/5xx で増えやすい

そこで v2 では次を追加します：
- `stripe_customer_id -> project_id` / `subscription_id -> project_id` の解決経路（D1）
- Stripe の ID を internal plan 語彙（free/basic/team）へ正規化（Workers vars）
- ignore 条件とレスポンス方針を凍結（署名不一致だけ 400、それ以外は 200）
- **test/live の混線防止**（`STRIPE_EXPECT_LIVEMODE`）

## エンドポイント
- `POST /v1/stripe/webhook`

## 必須シークレット（Workers）
- `STRIPE_WEBHOOK_SECRET`（Stripe Dashboard -> Webhooks -> Signing secret）

## 追加の安全弁（推奨）
### test/live 混線防止
Stripeイベントには `livemode: boolean` が含まれる。  
Workers 側で期待値を固定しておくと、**別モードのイベントが混入した場合でも安全側で扱えます**。

- テスト環境：`STRIPE_EXPECT_LIVEMODE="false"`
- 本番環境：`STRIPE_EXPECT_LIVEMODE="true"`

※不一致は **200で ignore**（不要な再送の増加を避ける）

## 凍結ルール（v2）
- **署名不一致だけ 400**（fail-close）
- それ以外は **原則 200**（不要な再送の増加を避ける）
- `project_id` が解決できない / `plan` が解決できないイベントは **200で ignore**（reason を返す）

## project_id の解決（metadata 依存を減らす）
優先順位：
1) `metadata.project_id`（あれば最優先）
2) `stripe_customers`（`customer -> project_id`）
3) `subscriptions`（`subscription_id -> project_id`）

### D1テーブル（既存）
- `stripe_customers(stripe_customer_id, project_id, created_at)`
- `subscriptions(stripe_subscription_id, project_id, status, plan, current_period_end, updated_at)`

Webhook は受信時に **best effort** で関連付けを更新します：
- `customer` が取れたら `stripe_customers` に upsert
- `subscription_id` が取れたら `subscriptions` を更新（表が無ければ無視）

## plan の正規化（internal plan語彙に固定）
internal plan語彙は `free/basic/team` に固定（凍結）。

優先順位：
1) `metadata.plan`（free/basic/team のみ受理）
2) subscription 更新イベントの `items.data[0].price.lookup_key`（推奨）
3) `price.id` / `price.product` を Workers vars の写像で変換（予備）

### Workers vars（非secret）
例：

```toml
# lookup_key を主軸にする
STRIPE_LOOKUPKEY_TO_PLAN_JSON = '{"basic":"basic","team":"team"}'

# 必要な場合のみ ID 直指定を使う
STRIPE_PRICEID_TO_PLAN_JSON   = "{}"
STRIPE_PRODUCTID_TO_PLAN_JSON = "{}"

# test/live 混線防止（推奨）
STRIPE_EXPECT_LIVEMODE        = "false"
```

## 受けるイベント（v2）

### A) `checkout.session.completed`（関連付け / mapping）
目的は **plan 更新ではなく、customer / subscription → project の関連付けを整える**ことです。

推奨：Checkout 作成時に `project_id` を以下へ入れる。
- `client_reference_id = <project_id>`（最優先）
- `metadata.project_id = <project_id>`（保険）

Webhook受信時の動作：
- `action=mapped` / `reason=by_checkout_session_completed` を監査に残す
- `stripe_customers`（customer→project）を upsert
- `subscriptions`（subscription→project）を upsert
- **plan はここでは確定しない**（後続の subscription.updated が lookup_key で確定）

### B) `customer.subscription.updated`（plan確定）
plan 更新の中心となるイベントです。lookup_key を主軸にして運用を安定させます。

### C) `customer.subscription.deleted`（freeへ戻す）
解約時は plan を `free` に戻します。

## 運用の要点（Phase3-1 と互換）
- `checkout.session.completed` は **関連付け（mapping）イベント**として扱う
  - project_id が取れなければ ignore（fail-close）
  - plan は触らず、後続の `customer.subscription.updated` で確定
- `customer.subscription.deleted` は `plan=free` に戻す

## 検収テンプレ（v2）
1) Stripe 側からイベントを1回通す（checkout / subscription.updated / deleted）
2) `.\scripts\proof_bundle_v3.ps1` を実行
3) `out\proof_bundle_v3.json` の `d1.projects.parsed` で `plan` が更新されていることを確認



---

## 監査ログ（D1）

継続運用しやすいよう、Webhook の処理結果（applied / ignored / dedup / error）を **D1 に最小監査行として保存**します。

- テーブル：`stripe_webhook_audit`
- 保存するのは **event_id / type / livemode / 解決できた project_id / plan / action / reason** などの最小情報のみ
- **Stripeの生ペイロードは保存しません**（個人情報や決済情報を持ち込まない）

### 作成方法
- 通常：Webhook受信時に `CREATE TABLE IF NOT EXISTS ...` を best-effort で実行（初回のみ）
- 明示的に作る：`docs/migrate_stripe_webhook_audit.sql` を D1 remote に適用

### 例：直近の監査行
```sql
SELECT last_seen_at, attempts, event_id, event_type, livemode, resolved_project_id, resolved_plan, action, reason
FROM stripe_webhook_audit
ORDER BY last_seen_at DESC
LIMIT 20;
```


## 監査ログ（D1）：reason（解決経路）の刻印
Webhook は `stripe_webhook_audit` に 1行/イベントで監査を残します。

`action="applied"` のとき、`reason` には **plan をどう解決したか（経路）** が入ります：

- `by_metadata_plan`：`metadata.plan` で決定
- `by_checkout_session_completed`：`checkout.session.completed` により **刻印（mapping）** を実行
- `by_lookup_key`：`price.lookup_key -> plan` で決定（推奨）
- `by_price_id`：`price.id -> plan`（保険）
- `by_product_id`：`price.product -> plan`（保険）
- `by_subscription_deleted`：`customer.subscription.deleted` により free へ戻した

`action="mapped"` のときは、`reason=by_checkout_session_completed` が入り、customer / subscription → project の自動関連付けが行われたことを示します。

`action="ignored"` のときは `livemode_mismatch` / `project_unresolved` / `plan_unresolved_on_subscription` などの理由が入ります（不要な再送の増加を避けるため、原則 200 で ignore）。

## Pricing / lookup_key (monthly only)

- Monthly lookup_key: `basic`, `team`
- 公開料金（JPY / 月額）：Basic ¥1,980 / 月、Pro ¥9,800 / 月

> 公開表示は **Pro**、内部識別子は `team` を利用します。

Recommended Worker mapping:

```json
{"basic":"basic","team":"team"}
```
