Skip to content

fix(billing): Account for gifted quantities in productIsEnabled check#113142

Merged
dashed merged 6 commits intomasterfrom
dashed/fix/billing-gifted-monitors-upgrade-required
Apr 16, 2026
Merged

fix(billing): Account for gifted quantities in productIsEnabled check#113142
dashed merged 6 commits intomasterfrom
dashed/fix/billing-gifted-monitors-upgrade-required

Conversation

@dashed
Copy link
Copy Markdown
Member

@dashed dashed commented Apr 16, 2026

Problem

On the subscription page, orgs on am3_t_ent with legacy soft cap see "Upgrade required — You currently do not have access to this feature" for Cron Monitors and Uptime Monitors, even when those monitors are gifted.

Root Cause

productIsEnabled() (frontend) decides whether a billing category is active or locked. Three bugs compounded:

Bug 1: Gifted quantities ignored

// billing.tsx:1043 (before)
const isPaygOnly = metricHistory.reserved === 0;

Only checks reserved (plan-included quota), ignoring free (gifted quota).

Bug 2: Per-category soft cap type ignored

A category with softCapType = 'TRUE_FORWARD' or 'ON_DEMAND' represents soft cap billing — the org can use the product via forward/ondemand billing. But the function didn't check softCapType.

Bug 3: Subscription-level legacy soft cap flag ignored (the main miss)

subscription.soft_cap (legacy boolean, serialized as hasSoftCap) and metric_history.soft_cap_type (per-category enum) are orthogonal fields in getsentry that are not kept in sync. Verified across the getsentry codebase:

  • change_soft_cap() (getsentry/billing/soft_cap.py:17-54) toggles subscription.soft_cap but does NOT touch any metric history's soft_cap_type.
  • Salesforce provisioning sets subscription.soft_cap = "enable soft-cap" in deal_terms independently from per-category soft_cap_type_* fields.
  • create_new_category_histories (getsentry/models/billingmetrichistory.py:301) creates rows for backfilled categories (like MONITOR_SEAT, UPTIME) with soft_cap_type = None — it does NOT inherit from siblings or from the subscription-level soft_cap flag.

So legacy soft cap orgs whose Cron/Uptime categories were backfilled after original provisioning have hasSoftCap = true but softCapType = null on those categories. This is exactly the am3_t_ent scenario reported.

Data for the affected orgs

Field Value Meaning
hasSoftCap true Subscription-level legacy soft cap flag
reserved 0 Plan tier includes 0 monitors by default
free 0 No gifted monitors
prepaid 0 reserved + free
softCapType null Not set on backfilled category
onDemandBudget 0 Legacy soft cap orgs have no PAYG budget

Fix

billing.tsx:1043

// Before:
const isPaygOnly = metricHistory.reserved === 0;

// After:
const isPaygOnly =
  (metricHistory.prepaid ?? 0) === 0 &&
  !metricHistory.softCapType &&
  !subscription.hasSoftCap;
  • prepaid = reserved + free — accounts for gifted quantities
  • !softCapType — treats soft cap categories as enabled
  • !subscription.hasSoftCap — treats legacy soft cap subscriptions as enabled

This matches the backend-wide pattern: subscription.soft_cap OR metric_history.soft_cap_type is not None used in:

  • getsentry/quotas.py
  • getsentry/billing/seat_policy/monitor.py
  • getsentry/billing/tally_usage.py
  • getsentry/models/billingseatassignment.py

index.tsx (URL product selection)

Parallel changes to the selectability check: use prepaid, add softCapType check, add hasSoftCap check (guarded on the category existing on the subscription).

Tests Added (16 new across 4 files)

billing.spec.tsx (6 new in productIsEnabled describe):

  • Gifted-only category (reserved=0, free>0) → enabled
  • No quota at all → disabled
  • Mixed reserved + gifted → enabled
  • softCapType TRUE_FORWARD with no prepaid → enabled
  • softCapType ON_DEMAND with no prepaid → enabled
  • hasSoftCap=true with null softCapType and no prepaid → enabled (the am3_t_ent bug)

index.spec.tsx (4 new):

  • URL selects gifted-only product
  • URL does not select zero-quota product
  • URL selects product with softCapType
  • URL selects product with hasSoftCap=true and null softCapType (the am3_t_ent bug)

panel.spec.tsx (4 new):

  • Gifted monitors don't show "Upgrade required"
  • Gifted uptime doesn't show "Upgrade required"
  • Soft cap monitors don't show "Upgrade required"
  • hasSoftCap=true with null softCapType doesn't show "Upgrade required" (the am3_t_ent bug)

table.spec.tsx (2 new):

  • Gifted monitors render as enabled rows
  • hasSoftCap=true with null softCapType renders as enabled (the am3_t_ent bug)

Why this matches the backend

The backend serializer (getsentry/api/serializers/customer.py:499) sends hasSoftCap: sub.soft_cap directly. Every quota/seat check in the backend uses the OR pattern. The frontend now matches that invariant.

productIsEnabled() checked only `reserved === 0` to determine if a
product was PAYG-only, ignoring the `free` (gifted) field. For orgs
on am3_t_ent with legacy soft cap and gifted monitors, this caused
Cron Monitors and Uptime Monitors to incorrectly show "Upgrade
required" even though they had available quota via gifted quantities.

Use `prepaid` (which equals `reserved + free`) instead of `reserved`
alone, consistent with how the backend quota logic already works.
Also fix the same issue in URL-based product selection on the
subscription usage overview page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@github-actions github-actions Bot added the Scope: Frontend Automatically applied to PRs that change frontend components label Apr 16, 2026
Legacy soft cap orgs with TRUE_FORWARD or ON_DEMAND billing on a
category can use that product without prepaid quota. Add softCapType
to the productIsEnabled check and URL-based product selection so
these orgs don't see "Upgrade required" on the subscription page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@krithikravi krithikravi left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not ultra familiar with react but the code changes look good to me

subscription.soft_cap (legacy flag, serialized as hasSoftCap) and
metric_history.soft_cap_type (per-category enum) are orthogonal
fields in getsentry that are not kept in sync. Legacy soft cap orgs
can have hasSoftCap=true with soft_cap_type=null on newer categories
like MONITOR_SEAT and UPTIME, because create_new_category_histories
does not inherit soft_cap_type from siblings or from the subscription
flag.

This matches the backend pattern used in getsentry quotas.py,
tally_usage.py, seat_policy/monitor.py, and billingseatassignment.py:
    subscription.soft_cap OR metric_history.soft_cap_type is not None

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pull the repeated subscription.categories[productFromQuery as DataCategory]
lookup and the productFromQuery as DataCategory cast into locals. No
behavior change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The isPaygOnly name read awkwardly through a double negation
(!isPaygOnly) and was semantically misleading for the per-category
soft cap case (the category is billed via soft cap, not PAYG). Split
the condition into two positively-named tiers that mirror the actual
access model:

- hasNonPaygAccess: prepaid quota, per-category soft cap billing,
  or subscription-level legacy soft cap
- hasPaygBudget: per-category or shared on-demand budget

A category is enabled if either tier grants access. Add a regression
test for UNLIMITED_RESERVED (-1) prepaid, since the inversion from
=== 0 to > 0 would silently exclude unlimited (the correct inversion
is !== 0).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dashed dashed marked this pull request as ready for review April 16, 2026 20:37
@dashed dashed requested a review from a team as a code owner April 16, 2026 20:37
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit c8d7ebe. Configure here.

Comment thread static/gsApp/views/subscriptionPage/usageOverview/index.tsx Outdated
… prepaid

The prepaid check in index.tsx used `> 0`, which treats the
UNLIMITED_RESERVED=-1 sentinel as "no quota" and silently rewrites the
URL back to the current selectedProduct. Real paying customers have
reserved=-1 on categories like INSTALLABLE_BUILD and SIZE_ANALYSIS
(via correct_enterprise_reserved in getsentry/billing/plans/__init__.py),
so deep-linking to those products on the subscription page didn't work.

Change to `!== 0` to match productIsEnabled in billing.tsx. Add a
regression test that fails without the fix.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dashed dashed merged commit 54c7a72 into master Apr 16, 2026
65 checks passed
@dashed dashed deleted the dashed/fix/billing-gifted-monitors-upgrade-required branch April 16, 2026 22:18
dashed added a commit that referenced this pull request Apr 16, 2026
checkBudgetUsageFor returned UNAVAILABLE for orgs whose profileDuration.reserved is
UNLIMITED_RESERVED (-1) because the existing guard required `reserved > 0`. That
misclassification caused ContinuousProfilingBillingRequirementBanner to render a
misleading "profiling unavailable" banner to customers with unlimited quota —
including am3_t_ent / am3_t trial cohorts and enterprise orgs promoted to unlimited
reserved via correct_enterprise_reserved().

Replace the `reserved > 0` clause with `(reserved ?? 0) !== 0`, matching the pattern
#113142 landed in productIsEnabled (static/gsApp/utils/billing.tsx:1044) and the
usage-overview URL selector (static/gsApp/views/subscriptionPage/usageOverview/index.tsx:64).

Add unit tests covering every branch of checkBudgetUsageFor and component tests for
ContinuousProfilingBillingRequirementBanner pinning the regression case.

Refs #113142

Co-Authored-By: Claude <noreply@anthropic.com>
dashed added a commit that referenced this pull request Apr 16, 2026
`usagePercentage` in `static/gsApp/views/subscriptionPage/usageHistory.tsx` returned
`>100%` for historical billing-period records where `prepaid` is `UNLIMITED_RESERVED`
(-1), because `usage > -1` is always true for non-negative usage. The call site at
the "Used (%)" cell special-cased `RESERVED_BUDGET_QUOTA` (-2) but not
`UNLIMITED_RESERVED`, so unlimited categories rendered as ">100%" — the opposite of
the truth — on every past-period row in the usage history table.

Apply the fix at both layers:
- Call site: add an `isUnlimitedReserved(metricHistory.reserved) → UNLIMITED`
  branch parallel to the existing `RESERVED_BUDGET_QUOTA → 'N/A'` guard.
- Helper (`usagePercentage`): early-return `UNLIMITED` when
  `isUnlimitedReserved(prepaid)`, as defense in depth. Export the helper so the
  unit-test layer can pin this contract.

Affected cohort: enterprise orgs promoted to unlimited via
`correct_enterprise_reserved()`, and any billing-history row from a past
`am3_t` / `am3_t_ent` trial period. Same cohort as #113142 and #113254.

Add unit tests for the now-exported `usagePercentage` (7 cases: null, 0, unlimited,
overage, under-quota, zero usage, exact match) and component regression tests for
both the fully-unlimited row and the defense-in-depth `reserved > 0, prepaid = -1`
case.

Refs #113142
Refs #113254

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Scope: Frontend Automatically applied to PRs that change frontend components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants