feat(billing): usage-based billing surfaces — plans page, titlebar, announcement#3487
Merged
Merged
Conversation
|
React Doctor found no issues in the changed files. 🎉 Reviewed by React Doctor for commit |
60dd11b to
d8c58e9
Compare
2 tasks
Contributor
Author
2 tasks
25c1733 to
c8ff7f0
Compare
charlesvien
approved these changes
Jul 16, 2026
Merged
2 tasks
Contributor
|
Reviews (1): Last reviewed commit: "style(billing): satisfy biome ci formatt..." | Re-trigger Greptile |
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
adboio
added a commit
to PostHog/posthog
that referenced
this pull request
Jul 16, 2026
…nt (#71404) ## Problem PostHog Code is cutting over to usage-based billing, and the desktop app wants to show real org spend ("used $12.40 of your $50 limit") in the titlebar and on the Plan & usage page. Today `GET /v1/usage/{product}` carries only per-user percentages against internal rate valves plus an `ai_credits.exhausted` boolean, so clients have nothing to render a dollar amount from. The numbers already exist in `organization.usage`, synced from billing. ## Changes - `GET /api/projects/{id}/quota_limits/` now returns `usage` and `limit` per resource, read from the org's synced billing numbers. `usage` is the same `usage + todays_usage` sum the quota limiter compares against the limit. The `limited` boolean stays authoritative for gating (grace periods and refund offsets live only in the limiting decision). Both fields are null when billing hasn't synced the resource. - The gateway's quota resolver parses those numbers, converts credit buckets to USD (1 credit = $0.01) and carries them through its Redis cache. Fail-open paths keep them as null, meaning unknown, never $0. - `GET /v1/usage/{product}` exposes them as `ai_credits.used_usd` and `ai_credits.limit_usd`. Additive and optional, so old clients are unaffected. The per-user burst/sustained buckets still expose only percentages (`test_response_has_no_usd_fields` continues to guard that). The valve limits stay internal; the org's own bucket spend is the user's billing data. > [!NOTE] > The wire change is additive on both hops. Old gateway against new Django ignores the new keys; new gateway against old Django reads null and reports unknown. ## How did you test this code? - Gateway: full unit suite passes locally (1240 tests). New regressions covered that no existing test did: Django responses without numbers read as unknown rather than $0, USD numbers survive the Redis cache round trip (a one-sided cache would flash the numbers in and out between hit and miss), fail-open cache entries carry null numbers, and the usage endpoint forwards the resolver's numbers onto `ai_credits`. - Django: extended `test_quota_limits.py` with a synced-org case (1500 + 200 credits of 2000 reports usage 1700, a synced-but-unlimited bucket reports a null limit, a never-synced resource reports nulls, not zeros). I could not run the Django suite in this sandbox; CI covers it. Ruff (pinned version) is clean. - mypy on the gateway files adds no new errors (two pre-existing ones in `quota_resolver.py` are untouched). ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? ## 🤖 Agent context **Autonomy:** Human-driven (agent-assisted) Authored by Claude (PostHog Code cloud session) at Adam's request, as the backend half of the usage-based billing client work in [PostHog/code#3487](PostHog/code#3487), which wants to render org spend for both free-tier and subscribed orgs. Main decision: Django reports raw native units generically for every resource, and the credits-to-USD conversion lives in the gateway where the client contract is defined, rather than special-casing credit buckets in Django. Absent-means-unknown is preserved end to end so a resolver blip or unsynced org never renders as $0 spent. --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
|
😎 Merged directly without going through the merge queue, as the queue was empty and the PR was up to date with the target branch - details. |
Merge activity
|
66ffc18 to
783ed00
Compare
…code_usage_billed posthog#71315 shipped the bit as code_usage_subscribed; the schema listened for code_usage_billed, which zod strips, so the client would have read "unknown" forever. Rename the field and align internal naming to subscribed. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
formatResetTime rolls 60 minutes into the next hour; isSameUsage compares code_usage_subscribed and ai_credits.exhausted so a mid-session subscribe flip emits UsageUpdated; the plans page keeps only the monthly free meter (the free valve is $20 on both windows, so the monthly cap binds); the announcement modal leads with the headline and tightens the bullets; the cloud preflight reset hint prefers the sustained window. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
…ehind it The announcement's spend-limit bullet shows the org's actual limit from ai_credits.limit_usd (posthog#71404) for subscribed orgs — a free org's limit is its included allocation, already the first bullet — falling back to the $50-default copy when unknown. The post-update What's New dialog now waits until the blocking announcement is acknowledged instead of stacking on top of it. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
codeUsageMeter picks what the meter shows: billing's org-level dollars (ai_credits.used_usd/limit_usd, posthog#71404) when present — for both tiers — else the free tier's per-user valve bucket, else nothing. The titlebar becomes "Usage: $12.40" with an "of $X used" hover card and shows for subscribed orgs too; the plans page meter reads "used of included/ limit" in dollars. Dollars are org-level truth — the valve fallback stays because null numbers are a permanent state (an org before its first billing sync), not just deploy lag. Upgrade-prompt analytics now fire only for free-tier orgs. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
The threshold-crossing toasts were the one seat-era billing surface the stack never touched: they fired on raw valve percentages regardless of subscription state and spoke in "your monthly limit" language. The monitor now emits thresholds only for confirmed free-tier orgs (a subscribed org's valves are internal rails; unknown never reads as free), the copy says "monthly/daily free usage", and the 100% path opens the modal with the org_limit cause so it renders "Free usage used up". Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
…r changes; fail hydration open Review fixes: isSameUsage now compares ai_credits.used_usd/limit_usd so a subscribed org's dollar meter doesn't freeze all period (nothing else in its snapshot ever changes); the usage monitor clears its snapshot on org transitions and the renderer drops the cached query on identity change, so a new sign-in never sees the previous account's spend; a failed announcement-store hydration now fails open as unacknowledged — re-showing the one-time modal beats never showing it, and in-memory state still dismisses it for the session. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
783ed00 to
8cc354b
Compare
adboio
added a commit
that referenced
this pull request
Jul 16, 2026
The usage-based billing cutover shipped (#3485, #3487, #3488) and the seat model is dead code: nothing rendered useSeat, the seat store was only fed by auth/onboarding sync calls, and the /api/seats/* client methods have no remaining product surface. Removed: shared seat types/plan keys, core SeatService/seatView/ seatErrors + tokens, the UI seat store/client/hook, api-client seat methods and seat error classes, the SUBSCRIPTION_STARTED/CANCELLED analytics events, the orphaned "upgrade_dialog" surface literal, and the gateway invalidate-plan-cache chain (router procedure, service method, endpoint, URL helper) whose only caller was the seat upgrade flow. Auth identity sync keeps the usage-snapshot cache clear (still load-bearing for usage billing); the llmGateway query invalidation it carried was a no-op (no queries live under that key) and is dropped. Generated-By: PostHog Code Task-Id: 1039ea23-9930-44e2-9888-b05cf8b129ec
2 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

tl;dr
depends on _https://github.com/PostHog/posthog/pull/71404, so the gateway reports usage in usd_
testing
(ignore spend analysis error)
(ignore spend analysis error)
(ignore toast)
Problem
The billing surfaces still sell the retired seat era: the Plan & usage page renders Free/$0 and Pro/$200 cards with a dead upgrade dialog, the titlebar meter keys on seat state, and settings show a "Free/Pro Plan" badge that no longer means anything. And nothing tells users the billing model changed.
Changes
Second slice of the cutover stack (stacked on #3485; ships in the same release):
ai_credits.exhausted. The Free/Pro cards, upgrade dialog, alpha banner, and all seat plumbing are deleted, along with the seat plan badges in settings.ai_credits.used_usd/limit_usd, PostHog/posthog#71404) — for subscribed orgs too: "Usage: $12.40" with an "of $50 used" hover card. A free-tier org without the numbers falls back to its valve-percentage card with "Unlock more"; subscribed/unknown without them shows nothing (absent means unknown, never free).posthog-code-usage-billingflag as its launch switch — flip at cutover, delete after the fleet acknowledges. Acknowledgment persists per device and stampscode_usage_billing_acknowledged_aton the person profile.Why: #3485 deliberately carries no seat-era handling on the promise that these surfaces ship in the same release; this is that half, plus the acknowledgment moment the cutover comms need.
ai_credits.limit_usdon the usage endpoint (PostHog/posthog#71404); free/unknown orgs keep the default-$50 copy (a free org'slimit_usdis its included allocation, which the first bullet already covers).org_limitcause ("Free usage used up").How did you test this?
Automated only: new
isCodeUsageFreeTierunit tests (tri-state: subscribed / free / absent-means-unknown); typecheck and Biome clean across shared/core/ui; suites green (shared 602, core 2295, ui 1659). Not covered: a visual pass over the reworked page and announcement modal.Automatic notifications
Created with PostHog Code