Skip to content

feat(billing): usage-based billing surfaces — plans page, titlebar, announcement#3487

Merged
trunk-io[bot] merged 8 commits into
mainfrom
posthog-code/usage-billing-surfaces
Jul 16, 2026
Merged

feat(billing): usage-based billing surfaces — plans page, titlebar, announcement#3487
trunk-io[bot] merged 8 commits into
mainfrom
posthog-code/usage-billing-surfaces

Conversation

@adboio

@adboio adboio commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

tl;dr

depends on _https://github.com/PostHog/posthog/pull/71404, so the gateway reports usage in usd_

  • update plan page to show usage-based details
  • one-time blocking announcement modal to tell users about the new usage-based pricing
    • no one will see this at first; they'll get weird errors, eventually update the app, and then see this on first launch of updated version

testing

announcement modal pr3487v2-5-announcement-subscribed-75.png

free tier usage bar

(ignore spend analysis error)
pr3487v2-1-plan-free-monthly-only.png
free tier exceeded

(ignore spend analysis error)
pr3487v2-4b-free-tier-used-up.png

org limit exceeded

(ignore toast)
pr3487v2-4a-org-quota-subscribed.png

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):

  • Plan & usage page is now the usage-based view — free-tier meters and "Add a payment method" for confirmed free-tier orgs, "billed to your organization" + manage/spend-limit links otherwise, and an org-limit callout off 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.
  • Titlebar meter shows org spend in dollars when billing reports them (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).
  • One-time blocking announcement of the billing change ($20 included / payment method unlocks premium models / $50 default spend limit), armed by the posthog-code-usage-billing flag as its launch switch — flip at cutover, delete after the fleet acknowledges. Acknowledgment persists per device and stamps code_usage_billing_acknowledged_at on 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.

  • Announcement modal shows the org's spend limit for subscribed orgs, read from ai_credits.limit_usd on the usage endpoint (PostHog/posthog#71404); free/unknown orgs keep the default-$50 copy (a free org's limit_usd is its included allocation, which the first bullet already covers).
  • What's New yields to the announcement: the post-update changelog dialog waits until the blocking announcement is acknowledged instead of stacking on top of it.
  • Threshold toasts get the cutover treatment: emitted only for confirmed free-tier orgs (a subscribed org's valve percentages are internal rails), copy reads "monthly/daily free usage", and the 100% path opens the modal with the org_limit cause ("Free usage used up").

How did you test this?

Automated only: new isCodeUsageFreeTier unit 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

  • Publish to changelog?
  • Alert Sales and Marketing teams?

Created with PostHog Code

@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

React Doctor found no issues in the changed files. 🎉

Reviewed by React Doctor for commit 8cc354b.

adboio commented Jul 16, 2026

Copy link
Copy Markdown
Contributor Author

@greptile-apps

greptile-apps Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Reviews (1): Last reviewed commit: "style(billing): satisfy biome ci formatt..." | Re-trigger Greptile

Comment thread packages/core/src/usage/usage-monitor.ts
Comment thread packages/ui/src/features/billing/useUsageMeter.ts
Comment thread packages/ui/src/features/billing/billingAnnouncementStore.ts
Comment thread packages/ui/src/features/billing/useUsageMeter.ts
@veria-ai

veria-ai Bot commented Jul 16, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No 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)*
Base automatically changed from posthog-code/usage-billing-limit-errors to main July 16, 2026 04:59
@trunk-io

trunk-io Bot commented Jul 16, 2026

Copy link
Copy Markdown

😎 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.

@graphite-app

graphite-app Bot commented Jul 16, 2026

Copy link
Copy Markdown

Merge activity

  • Jul 16, 5:00 AM UTC: Graphite disabled "merge when ready" on this PR due to: unsigned commits detected.

@adboio adboio force-pushed the posthog-code/usage-billing-surfaces branch from 66ffc18 to 783ed00 Compare July 16, 2026 05:08
adboio added 8 commits July 16, 2026 01:09
…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
@adboio adboio force-pushed the posthog-code/usage-billing-surfaces branch from 783ed00 to 8cc354b Compare July 16, 2026 05:09
@trunk-io trunk-io Bot merged commit e6485ac into main Jul 16, 2026
32 checks passed
@trunk-io trunk-io Bot deleted the posthog-code/usage-billing-surfaces branch July 16, 2026 05:15
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants