npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@geckou/billing

v0.8.0

Published

Subscription entitlement and webhook handling (Stripe / RevenueCat) for Firebase-based projects

Readme

@geckou/billing

Stripe / RevenueCat のサブスク権利判定・Webhook 処理。 実行環境(Cloud Functions / Next.js Route Handler)に依存しない。

import Stripe from 'stripe'
import { getAuth } from 'firebase-admin/auth'
import { getFirestore } from 'firebase-admin/firestore'
import { createBilling } from '@geckou/billing'

const billing = createBilling({
  firestore: getFirestore(),
  auth: getAuth(),
  stripe: {
    client: new Stripe(process.env.STRIPE_SECRET_KEY!),
    webhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
    // 未設定のときに [''] にならないよう filter(Boolean) を通す
    allowedPriceIds: (process.env.STRIPE_PRICE_IDS ?? '')
      .split(',')
      .filter(Boolean),
    successUrl: process.env.STRIPE_SUCCESS_URL,
    cancelUrl: process.env.STRIPE_CANCEL_URL,
    portalReturnUrl: process.env.STRIPE_PORTAL_RETURN_URL,
    // develop 環境の Functions でのみ true にする(既定 false)
    allowTestMode: process.env.STRIPE_ALLOW_TEST_MODE === 'true',
  },
  revenuecat: {
    webhookAuth: process.env.REVENUECAT_WEBHOOK_AUTH!,
    // develop 環境の Functions でのみ true にする(既定 false)
    allowSandbox: process.env.REVENUECAT_ALLOW_SANDBOX === 'true',
  },
  syncClaims: process.env.SYNC_SUBSCRIPTION_CLAIMS === 'true',
  onSubscriptionDowngraded: async (uid) => {
    // 無料プランの制限にデータを収める後始末をここに書く
  },
})

// Express(Cloud Functions)
// Functions Framework が自前のハンドラより前に JSON をパースするため、
// 後段の express.raw() はスキップされ req.body はオブジェクトになる。
// 署名検証に使える生のボディは req.rawBody にしか無い。
// express.raw() は Functions Framework を介さない素の Express 用の保険として
// 残し、express.json() より前に置く
const rawBodyOf = (req: express.Request) =>
  (req as express.Request & { rawBody?: Buffer | string }).rawBody ??
  (req.body as Buffer)

app.post('/webhooks/stripe', express.raw({ type: '*/*' }), async (req, res) => {
  const result = await billing.handleStripeWebhook({
    rawBody: rawBodyOf(req),
    headers: req.headers,
  })
  res.status(result.status).json(result.body)
})

// RevenueCat も同じ(生のボディを JSON.parse するため、req.rawBody が要る)
app.post(
  '/webhooks/revenuecat',
  express.raw({ type: '*/*' }),
  async (req, res) => {
    const result = await billing.handleRevenueCatWebhook({
      rawBody: rawBodyOf(req),
      headers: req.headers,
    })
    res.status(result.status).json(result.body)
  }
)

// Next.js Route Handler でも同じ
export async function POST(req: Request) {
  const result = await billing.handleStripeWebhook({
    rawBody: Buffer.from(await req.arrayBuffer()),
    headers: Object.fromEntries(req.headers),
  })
  return Response.json(result.body, { status: result.status })
}

Stripe のテストモード

Webhook の event.livemode が false のイベントは、既定では適用せず 200 を返す。 テスト用の Webhook シークレットを本番の Functions に配線してしまったときに、 テストモードの購入で本番の権利が付くのを防ぐため(RevenueCat の allowSandbox と対称)。 テストモードの Stripe を叩く開発環境では allowTestMode: true にする。

400 ではなく 200 を返すのは、400 だと Stripe が再送し続けるため。無視したことは console.log に残る。

RevenueCat の環境

Webhook の environment が SANDBOX のイベントは、既定では適用せず 200 を返す。 TestFlight や開発ビルドが本番の Webhook URL を叩いたときに、サンドボックス購入で 本番の権利が付くのを防ぐため。開発環境の Functions では allowSandbox: true にする。

TRANSFER(権利が別の app_user_id へ移った)は、transferred_from の各ユーザーを expired にする。

移動先は TRANSFER のペイロードだけでは決まらない(期限も entitlement も乗らない)。 何もしないと次の購入・更新イベントまで「未購読」扱いのままで、年額なら最長 1 年かかる。 Restore の既定(Transfer to new App User ID)で普通に起きる導線なので、 revenuecat.fetchSubscriber を設定して現在の権利を取り直すこと。

revenuecat: {
  webhookAuth: process.env.REVENUECAT_WEBHOOK_AUTH!,
  // GET /subscribers/{app_user_id} などで今の権利を取り、反映する内容を返す
  fetchSubscriber: async (appUserId) => {
    const entitlement = await fetchEntitlementFromRevenueCat(appUserId)
    if (!entitlement) return null
    // source は返さない(型が Omit<..., 'source'>。実装側で 'revenuecat' を入れる)
    return { status: 'active', planId: entitlement.id }
  },
}

移動先の取得に失敗しても Webhook は 200 を返す(移動元の失効は確定させたいため)。 未設定なら警告のみで、従来どおり後続イベント待ちになる。

単発購入(NON_RENEWING_PURCHASE)

NON_RENEWING_PURCHASE は既定で active として反映する。ただし単発購入の ペイロードには期限が無く、期限の無い active は isSubscriptionActive が 無期限に有効と判定する。消費型・単発のアイテム(1 回分のアンロック等)を RevenueCat で売っていると、単発購入 1 件でプランの権利が永久に付く。

nonRenewingPurchase: 'ignore' にすると users/{uid}.subscription を変えずに 200 を返す。単発購入と買い切りプランを同じ Webhook で受けているなら、関数を渡して product_id ごとに振り分ける(関数が想定外の値を返した場合は 'ignore' に倒す)。

revenuecat: {
  webhookAuth: process.env.REVENUECAT_WEBHOOK_AUTH!,
  // 'entitlement'(既定・従来どおり)/ 'ignore' / イベントを見て決める関数
  nonRenewingPurchase: (event) =>
    event.product_id === 'lifetime_pro' ? 'entitlement' : 'ignore',
  // 単発購入を別の処理(クレジット付与等)に回す
  onNonRenewingPurchase: async (event, { eventId }) => {
    await grantCredits(event.app_user_id, { key: eventId })
  },
}

onNonRenewingPurchase は nonRenewingPurchase が 'entitlement' でも呼ばれる。 渡るのは認可(Authorization ヘッダー)・ペイロード検証・SANDBOX の判定を通った NON_RENEWING_PURCHASE だけ。ここで例外を投げると Webhook は 503 を返し、 RevenueCat に再送させる('ignore' のときはこのフックが唯一の処理系なので、 失敗を握り潰すと購入がどこにも残らないため)。権利への反映や nonRenewingPurchase の判定が例外で失敗した場合は呼ばない(5xx で再送され、 次の配信で最初からやり直す)。

⚠️ 同じイベントで複数回呼ばれうる。 'ignore' のイベントは billing_events に 記録しない(=冪等性の判定に載らない)ため、再送はそのまま再実行になる。 付与を伴う処理は第 2 引数の eventId で冪等にすること(event.id は古い設定だと 来ないので、無ければペイロードのハッシュで補った値が入る)。

⚠️ event は JSON をパースしたそのままの値。 type と app_user_id 以外は 検証していないので型は unknown。product_id や event_timestamp_ms を使うときは typeof / Array.isArray で絞ること。

app_user_id は Firebase の uid にすること。 Webhook が書き込む先は users/{app_user_id} で、Purchases.logIn(uid) していない匿名 ID のままだと users/$RCAnonymousID:... が作られ、そのユーザーの権利はどこからも参照されない。

app_user_id はクライアントが自由に決められる値なので、Firestore の ドキュメント ID にできない値(/ を含む・. / ..・__x__ の形・1500 バイト超)は 400 で弾く。そのまま doc() に渡すと同期 throw して 500 になり、 RevenueCat が再送を繰り返すため。

status は経路をまたいで同じ意味を持つ

SubscriptionStatus の cancelled は「自動更新が止まっている(currentPeriodEnd までは利用可)」。Stripe と RevenueCat で状態の表し方が違うため、次のように寄せている。

| 状態 | Stripe | RevenueCat | status | |---|---|---|---| | 有効 | active / trialing | INITIAL_PURCHASE 等 | active | | 自動更新を止めた(期間内は利用可) | active + cancel_at_period_end: true | CANCELLATION | cancelled | | 支払い失敗・猶予期間 | past_due | BILLING_ISSUE | in_grace_period | | 終了済み | canceled / unpaid | EXPIRATION | expired |

⚠️ Stripe の canceled と SubscriptionStatus の cancelled は別物。 Stripe は 自動更新を止めただけの状態を status: 'active' のまま cancel_at_period_end: true で 表し、canceled は既に終了した状態(ended_at を持つ)を指す。綴りで対応付けると、 解約済み(期間内)の Stripe ユーザーが active と表示され、終了済みのユーザーが 期間終了まで権利を持つ。

そのため mapStripeStatus は cancel_at_period_end を第 2 引数で受け取る。

mapStripeStatus(subscription.status, {
  cancelAtPeriodEnd: subscription.cancel_at_period_end ?? false,
})

past_due は cancel_at_period_end が立っていても in_grace_period のまま (猶予期間の判定を優先する)。

Checkout とプラン変更

createCheckoutSession は 新規契約のみを扱う。既に有効な購読を持つユーザーには 409 を返すので、プラン変更・解約・支払い方法の更新は createPortalSession (Stripe カスタマーポータル)へ誘導する。Checkout は重複を防がないため、 有効なまま再度作らせると同一顧客に 2 本目のサブスクリプションが作られる。

409 の判定と Checkout の作成の間にロックは無い(Firestore の読みと Stripe の 作成をまたぐため)。二重送信で判定を同時に通り抜けても Checkout が 2 本に ならないよう、作成には checkout:<uid>:<10 分の時間窓>:<パラメータの指紋> を idempotencyKey として渡している。キャンセル後の作り直しは次の時間窓で通る。

指紋(作成パラメータの SHA-256 の先頭 16 桁)を混ぜているのは、同じキーに 異なるパラメータを送ると Stripe が idempotency_error(400)を返し、そのキーが 24 時間残るため。顧客 ID が変わった直後などにこれを踏むと、利用者には 500 が 返り続ける。顧客の作成キー(customer_<uid>_<パラメータの指紋>)も同じ理由で 指紋を含む。

⚠️ 時間窓の境界をまたいだ二重送信は取りこぼす。 Firestore のロックではなく 確率的な防御なので、確実性が要るなら利用側でボタンを無効化すること。

権利判定だけなら factory 不要:

import { isSubscriptionActive, hasPlan } from '@geckou/billing'

ブラウザ(クライアントコンポーネント)からはサブパスを使う。 ルートは Webhook の署名検証で Node の crypto を読むため、バンドルに入ってしまう。

import { isSubscriptionActive } from '@geckou/billing/entitlement'
import type { Subscription } from '@geckou/billing/entitlement'

同一ユーザーが両経路で購入した場合

users/{uid}.subscription は 1 スロットしか持たないため、Stripe と RevenueCat の 購読は同じ場所を取り合う。Checkout の 409 は Stripe 側の重複しか防がず、 アプリ内課金(IAP)はゲートできないので、両方で購入された状態は起こりうる。

このとき別経路の書き込みは「今より強いとき」だけ通す。具体的には、現在の権利が 有効で、かつイベントの経路が違う場合、次のどれかを満たさないと適用しない。

  • 反映後も有効で、currentPeriodEnd が今より後ろへ伸びる
  • イベント側が active で期限を持たず(買い切り等の無期限)、今の権利は期限を持つ

期限を持たない in_grace_period / cancelled は「無期限」ではなく、単に期限 フィールドが欠けているだけの可能性がある。これを最強として扱うと、期限を持たない BILLING_ISSUE が生きている権利のスロットを奪い、直後の EXPIRATION で権利が消える。

日時と sequence だけで前後を決めると、次の 2 通りで生きている権利が消える。

  1. Stripe active → RevenueCat EXPIRATION(occurredAt が新しい)が直接上書きする
  2. Stripe active → RevenueCat CANCELLATION(まだ有効なのでスロットを奪う) → RevenueCat EXPIRATION(同一経路なので通る)

無視したイベントは applied: false で記録し、警告をログに出す (ApplyResult.status は 'ignored')。

反映後の副作用と再送

カスタムクレームの同期と権利変化フック(onSubscriptionUpgraded / onSubscriptionDowngraded)は、Firestore への反映が確定した後に実行する。 ここで失敗した場合は ApplyResult.effectsPending が true になり、Webhook ハンドラは 503 を返す。200 を返すとプロバイダは配信成功と見なして再送せず、失敗した副作用が 二度と実行されないため。権利状態そのものは書き込み済みなので、再送は 'duplicate' として扱われ、失敗した種類(billing_events/{id}.failedEffects)だけがやり直される。 成功済みのフックは二度呼ばれない。

再送の判定に使う遷移(wasActive / isActive)は初回適用時の値をイベント側に残して使う。 現在の状態から計算し直すと、期限付きの権利が切れた後の再送で「無効 → 無効」に見え、 失敗したままのフックが呼ばれない。

同じイベントが並行して届く場合に備えて、副作用の実行権(effectsClaimedAt、60 秒)を トランザクションの中で取る。先に取った側だけが実行し、実行中にプロセスが落ちても 期限切れで次の再送が引き継ぐ。

現在の subscription.lastEventId がそのイベントでなくなっている場合 (後続のイベントが既にスロットを上書きしている場合)はやり直さず、完了として記録する。 古い状態でクレームを書き戻さないため。

フックが恒久的に失敗する(実装のバグ等)と、プロバイダの再送が続く。 ログ(post-apply effects failed)を監視して、原因側を直すこと。

同じ経路の遷移は従来どおり全て適用する。 経路ごとに権利を保持して OR を取る形には していない(Subscription の形が変わるため)。両経路の購入を UI から防ぎたい場合は、 IAP の購入画面側でも権利を確認すること。

0.8.0 の変更

  • RevenueCat の nonRenewingPurchase / onNonRenewingPurchase が増えた (→「単発購入(NON_RENEWING_PURCHASE)」)。既定は従来どおり 'entitlement' なので 設定しなければ挙動は変わらない
  • mapStripeStatus が第 2 引数 { cancelAtPeriodEnd } を受け取るようになった (省略可。既存の呼び出しはそのまま動く)
  • Stripe の canceled が cancelled ではなく expired になった。 「自動更新を止めただけ」は active + cancel_at_period_end: true から cancelled になる(→「status は経路をまたいで同じ意味を持つ」)。 status === 'cancelled' で分岐している UI・集計は、Stripe 経路の答えが変わる (isSubscriptionActive の結果は変わらない)

0.6.0 の変更

  • ApplyResult に effectsPending が増えた。反映後の副作用が失敗したことを表す (Webhook ハンドラはこれを見て 503 を返す)。ApplyResult を自分で組み立てている コードは追従が要る
  • 副作用の再実行に実行権(billing_events/{id}.effectsClaimedAt)を使う
  • TRANSFER の派生イベント ID が <eventId>:from:<app_user_id のハッシュ> になった (長い app_user_id でドキュメント ID の上限を超えないようにするため)。 この変更より前に処理した TRANSFER は、再送されると 1 度だけ再適用されうる

0.3.0 の破壊的変更

  • Subscription の日時(currentPeriodEnd / lastEventAt / updatedAt)が Date から DateLike になった。読み出した値を Date として使っていた箇所は toDate() を通す(→「日時の型」)
  • ApplyStatus に 'ignored' が増えた。status を網羅的に分岐している箇所は追従する
  • 経路をまたぐイベントの扱いが変わった(→「同一ユーザーが両経路で購入した場合」)

日時の型(Date と Timestamp)

Subscription の日時(currentPeriodEnd / lastEventAt / updatedAt)は 書き込むときは Date だが、users/{uid}.subscription を Firestore から読み出すと Timestamp になる。公開型は両方を受ける DateLike にしてあるので、 値を使うときは export 済みの toDate() を通すこと。

import { toDate, type Subscription } from '@geckou/billing'

const subscription = snapshot.get('subscription') as Subscription | undefined
const periodEnd = toDate(subscription?.currentPeriodEnd) // Date | null

設計方針

  • users/{uid}.subscription / stripeCustomerId / billing_events はサーバー専用の 書き込みにすること。 Checkout の 409 判定・権利変化の検出・isSubscriptionActive は 全てこの値を信用する。クライアントから subscription を書けるセキュリティルールだと、 ユーザーが自分で権利を付与できる(project-starter の firestore.rules は これらを read-only にしている)
  • process.env を読まない(設定は全て createBilling の config で注入)
  • コレクション名は collections で差し替え可能(複数環境を 1 プロジェクトに相乗りさせる構成向け)
  • firebase-admin は peerDependencies(Firestore / Auth のインスタンスを注入して使うため、 利用側と同一インスタンスを共有する必要がある)。stripe は optional peer で、 型は StripeClientLike として構造的に要求するだけなので、RevenueCat だけの構成では入れなくてよい
  • Webhook は { rawBody, headers } → { status, body } の純粋な入出力