Skip to content

Introduce billing tiers, bundles; rework pricing - #561

Merged
Blaumaus merged 27 commits into
mainfrom
improvement/billing
Jun 4, 2026
Merged

Blaumaus merged 27 commits into
mainfrom
improvement/billing

Conversation

@Blaumaus

@Blaumaus Blaumaus commented May 30, 2026 •

Copy link
Copy Markdown
Member

Changes

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

  • New Features

    • Launched Standard / Plus / Enterprise plans with event-tier pricing and plan-type selection.
    • Purchasable Website Add‑ons: preview, billing intervals, renewals, and in-app management.
    • Plan-gated Feature Flags and Experiments (Plus/Enterprise) with UI gating and upgrade prompts.
    • Admin UI: edit user plan type and add-on/entitlement overrides.
  • Bug Fixes / Behavior

    • API rate-limit and quota displays now reflect effective plan entitlements.
  • Documentation

    • Updated billing guides, FAQs, API rate‑limit and pricing docs.

@coderabbitai

coderabbitai Bot commented May 30, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Introduces plan-type and event-tier billing catalog and entitlement logic, website add-on entities and lifecycle, project-owner feature-access checks enforced in controllers and tools, admin override editing, catalog-driven pricing UI and routes, usage-info expansions, schema migrations, and documentation/localization updates.

Changes

Billing platform overhaul

Layer / File(s) Summary
Plan, entitlement, and add-on contracts
backend/apps/cloud/src/user/entities/user.entity.ts, backend/apps/cloud/src/user/entities/user-addon*.ts, backend/apps/cloud/src/user/dto/*, backend/apps/cloud/src/user/interfaces/usage-info.ts, backend/migrations/mysql/*, web/app/lib/pricing/catalog.ts, web/app/lib/models/*, web/app/lib/pricing/features.ts, web/app/lib/constants/index.ts, admin/src/entities/user.entity.ts, admin/src/billing/pricing.ts
Adds plan-type and feature enums, entitlement helpers, new website add-on entities and charge entities, DTOs, usage-info types, MySQL migrations, shared web pricing catalog, and admin-side billing constants and limit calculators.
Backend billing engine and add-on lifecycle
backend/apps/cloud/src/user/user.service.ts, backend/apps/cloud/src/user/user.controller.ts, backend/apps/cloud/src/user/user.module.ts, backend/apps/cloud/src/task-manager/task-manager.service.ts, backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts
Implements plan-aware subscription previews/updates, website add-on preview/update/charge/renewal/cancellation flows, entitlement syncing, scheduled renewal processing, and rate-limit resolution from effective entitlements.
Backend project feature access and usage responses
backend/apps/cloud/src/project/*, backend/apps/cloud/src/feature-flag/feature-flag.controller.ts, backend/apps/cloud/src/experiment/experiment.controller.ts, backend/apps/cloud/src/ai/ai.service.ts, backend/apps/cloud/src/user/user.controller.ts
Computes project-owner feature availability, exposes featureAccess on project payloads, enforces feature access across feature-flag and experiment endpoints and AI tools, and expands usage-info to include last-30-days breakdowns.
Admin billing inspection and editing tools
admin/src/billing/pricing.ts, admin/src/commands/UsersCommand.tsx, admin/src/entities/user.entity.ts
Adds admin functions to compute effective plan/type limits, displays computed limits in admin UI, and extends the users command to edit planType and JSON override fields.
Shared web billing plumbing
web/app/routes/user-settings.tsx, web/app/routes/user-settings_.choose-plan.tsx, web/app/routes/billing_.choose-plan.tsx, web/app/api/api.server.ts, web/app/utils/routes.ts
Validates catalog-based plan selections in route actions, adds website add-on actions, exposes project feature access in web types, and adds authenticated choose-plan routing and redirects.
Pricing and checkout UI
web/app/components/pricing/*, web/app/pages/Subscribe/Subscribe.tsx, web/package.json, web/public/locales/en.json
Rebuilds pricing and checkout flows around planType, eventTier, billingInterval, and currency using a shared catalog; updates marketing and subscribe pages to use catalog-driven selection and pay-link generation.
User settings billing and website add-on management
web/app/pages/UserSettings/UserSettings.tsx, web/app/ui/Select.tsx, web/app/ui/Tooltip.tsx, web/public/locales/en.json
Reworks the Billing tab to show usage and quota summaries, adds website add-on preview/update modal and flows, and wires supporting controls and localized copy.
Project feature lock UI
web/app/hooks/useProjectFeatureAccess.ts, web/app/pages/Project/View/ViewProject.tsx, web/app/pages/Project/View/components/PlanFeatureLocked.tsx, web/app/routes/projects.$id.tsx, web/public/locales/en.json
Uses backend featureAccess data to gate project tabs and render owner/viewer-specific locked UI for feature flags and experiments.
Docs and supporting text updates
docs/content/docs/..., web/app/components/marketing/Integrations.tsx, web/public/locales/en.json
Updates documentation and localized copy for pricing, rate limits, feature availability, plan changes, trials, cancellations, and add-on behavior; minor marketing text style tweak.

Sequence Diagram(s)

sequenceDiagram
  participant BillingUI as Billing UI
  participant UserSettingsRoute as user-settings action
  participant UserController as UserController
  participant UserService as UserService
  participant Paddle as Paddle

  BillingUI->>UserSettingsRoute: preview-website-addon
  UserSettingsRoute->>UserController: POST addons/websites/preview
  UserController->>UserService: previewWebsiteAddon(quantity, billingInterval)
  UserService-->>BillingUI: preview data

  BillingUI->>UserSettingsRoute: update-website-addon
  UserSettingsRoute->>UserController: PUT addons/websites
  UserController->>UserService: updateWebsiteAddon(quantity, billingInterval)
  UserService->>Paddle: create addon charge
  Paddle-->>UserService: charge result
  UserService-->>BillingUI: updated user + websiteAddon summary
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

Poem

🐇 I nibbled the pricing map with care,
plans and tiers hopping into the air.
Add-ons scheduled, renewals in queue,
flags stay locked till a Plus badge shines through.
A happy rabbit—billing tidy and fair.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch improvement/billing

@Blaumaus Blaumaus self-assigned this Jun 2, 2026
@Blaumaus
Blaumaus marked this pull request as ready for review June 2, 2026 18:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/apps/cloud/src/feature-flag/feature-flag.controller.ts (1)

519-532: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the public empty response shape identical here.

The branch added at Line 525 returns { flags: {}, experiments: {} }, while the nonexistent/inactive branches above return only { flags: {} }. That makes “real project without feature-flag entitlement” distinguishable again, which undermines the enumeration protection noted in the comment above.

Suggested fix
     if (
       !this.projectService.projectOwnerHasFeature(
         project,
         PlanFeatureCode.featureFlags,
       )
     ) {
-      return { flags: {}, experiments: {} }
+      return { flags: {} }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/feature-flag/feature-flag.controller.ts` around lines
519 - 532, The public responses differ between the "project missing/inactive"
branch and the "owner lacks feature" branch, leaking entitlement info; update
the branch that calls this.projectService.projectOwnerHasFeature(project,
PlanFeatureCode.featureFlags) to return the identical empty shape used for
non-existent/inactive projects (i.e., return { flags: {} } instead of { flags:
{}, experiments: {} }) so the API always exposes the same response shape for
unauthorized or missing projects.
🧹 Nitpick comments (6)
docs/content/docs/billing/update-subscription.mdx (1)

14-16: 💤 Low value

Optional: Consider varying sentence structure.

The static analysis tool noted that lines 14-16 all begin with "Choose." While this is acceptable in a numbered instructional list, you could optionally rephrase for variety (e.g., "Select the monthly event volume...").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/content/docs/billing/update-subscription.mdx` around lines 14 - 16, The
three numbered list items that each start with "Choose" (the steps that specify
monthly event volume, plan type, and billing cadence) should be rephrased for
variety; replace one or more "Choose" occurrences with alternatives like "Select
the monthly event volume...", "Pick a plan type: Standard, Plus, or Enterprise",
and "Opt for monthly or yearly billing" so the instructions read more varied
while keeping the same meaning and ordering.
web/app/routes/_index.tsx (1)

561-561: 💤 Low value

Redundant class: items-center already in Button base styles.

The Button component already includes items-center in its base classes. Adding it here is harmless but unnecessary.

♻️ Optional cleanup
-                className='h-12 items-center border-white/25 bg-white/10 px-5 text-center text-base font-semibold text-white shadow-none ring-white/25 backdrop-blur-md hover:bg-white/20 dark:border-white/25 dark:bg-white/10 dark:text-white dark:hover:bg-white/20'
+                className='h-12 border-white/25 bg-white/10 px-5 text-center text-base font-semibold text-white shadow-none ring-white/25 backdrop-blur-md hover:bg-white/20 dark:border-white/25 dark:bg-white/10 dark:text-white dark:hover:bg-white/20'
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/routes/_index.tsx` at line 561, Remove the redundant "items-center"
token from the className applied to this Button instance in routes/_index.tsx;
locate the Button usage where className='h-12 items-center border-white/25 ...'
and edit the string to omit items-center since the Button base styles already
include it, leaving the other utility classes intact.
admin/src/commands/UsersCommand.tsx (1)

408-413: 💤 Low value

Label/value mismatch on "Event Bucket"; computed eventBucket is unused.

Line 408 labels the field "Event Bucket" but renders selectedUser.planCode, while the numeric effectiveLimits.eventBucket is never displayed. Either relabel to "Plan Code" or show the bucket value.

Proposed tweak
-          <Text><Text color="cyan">Event Bucket:</Text> {selectedUser.planCode}</Text>
+          <Text><Text color="cyan">Plan Code:</Text> {selectedUser.planCode}</Text>
+          <Text><Text color="cyan">Event Bucket:</Text> {effectiveLimits.eventBucket.toLocaleString()}</Text>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@admin/src/commands/UsersCommand.tsx` around lines 408 - 413, The "Event
Bucket" label currently displays selectedUser.planCode while
effectiveLimits.eventBucket is unused; update the UI in UsersCommand.tsx to show
the correct value by replacing the displayed value for that line with
effectiveLimits.eventBucket (or if you prefer to keep showing planCode, change
the label to "Plan Code"); locate the line rendering <Text><Text
color="cyan">Event Bucket:</Text> {selectedUser.planCode}</Text> and either swap
selectedUser.planCode for effectiveLimits.eventBucket or change the label text
to "Plan Code" to make label and value consistent.
web/app/lib/pricing/catalog.ts (2)

479-510: ⚡ Quick win

Price fallback could mask configuration errors.

createPlanLimit() uses ?? 0 (lines 494-503) when prices are missing from PLAN_PRICES. While this prevents runtime errors, a price of 0 could be confusing and might mask incomplete pricing configuration. Consider logging a warning or throwing an error during development.

⚠️ Add development-time validation
 const createPlanLimit = (
   eventTierCode: EventTierCode,
   index: number,
 ): PlanLimit => {
   const tier = EVENT_TIERS[eventTierCode]
   const monthly = PLAN_PRICES.standard[eventTierCode]?.monthly
   const yearly = PLAN_PRICES.standard[eventTierCode]?.yearly
+
+  // Warn in development if pricing is incomplete
+  if (process.env.NODE_ENV === 'development' && (!monthly || !yearly)) {
+    console.warn(`Missing price configuration for event tier: ${eventTierCode}`)
+  }
 
   return {
     index,
     planCode: tier.planCode,
     monthlyUsageLimit: tier.monthlyEvents,
     legacy: false,
     price: {
       USD: {
         monthly: monthly?.USD.amount ?? 0,
         yearly: yearly?.USD.amount ?? 0,
       },
       EUR: {
         monthly: monthly?.EUR.amount ?? 0,
         yearly: yearly?.EUR.amount ?? 0,
       },
       GBP: {
         monthly: monthly?.GBP.amount ?? 0,
         yearly: yearly?.GBP.amount ?? 0,
       },
     },
     ...(monthly?.USD.paddlePlanId ? { pid: monthly.USD.paddlePlanId } : {}),
     ...(yearly?.USD.paddlePlanId ? { ypid: yearly.USD.paddlePlanId } : {}),
     maxAlerts: 50,
   }
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/lib/pricing/catalog.ts` around lines 479 - 510, createPlanLimit
currently masks missing pricing by using "?? 0" for amounts from PLAN_PRICES
which can hide config errors; update createPlanLimit to validate PLAN_PRICES for
the given eventTierCode (check monthly and yearly objects and required currency
amounts like monthly?.USD.amount, yearly?.USD.amount, etc.) and in development
throw or at least processLogger.warn (or console.warn) when a price or
paddlePlanId is missing instead of silently returning 0; keep the existing
fallback only for production (NODE_ENV === 'production') if you need a safe
runtime value, and reference createPlanLimit, PLAN_PRICES, and EVENT_TIERS when
adding the check.

526-539: 💤 Low value

Redundant return statement.

Both branches return 'standard' (lines 535 and 538), making the final return redundant. The logic can be simplified.

♻️ Simplify to single return
 export const getEffectivePlanType = (
   planType?: string | null,
   planCode?: string | null,
 ): PlanTypeCode => {
   if (planType && planType in PLAN_TYPES) {
     return planType as PlanTypeCode
   }
-
-  if (isPaidPlanCode(planCode)) {
-    return 'standard'
-  }
-
+  // Paid plans default to standard; free/trial/none also default to standard
   return 'standard'
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/lib/pricing/catalog.ts` around lines 526 - 539, The function
getEffectivePlanType contains redundant returns of 'standard' — simplify by
keeping the initial check for a valid planType (using PLAN_TYPES) and the
isPaidPlanCode(planCode) branch if needed, then have a single default return
'standard' at the end; update logic in getEffectivePlanType so only one final
return('standard') remains and remove the duplicate return in the isPaidPlanCode
branch while preserving the existing guards and type casts.
backend/apps/cloud/src/user/dto/change-plan.dto.ts (1)

16-19: 💤 Low value

Clarify eventTier validation impact

eventTier is only @IsOptional() @IsString() and is forwarded to Paddle solely as passthrough.eventTier (user.service.ts). The Paddle webhook handler that processes passthrough reads uid (and optionally planType) but does not handle eventTier, so constraining it mainly provides boundary hardening with no current server-side behavior change. If/when eventTier is used for backend logic, consider adding an @IsIn(EVENT_TIERS)-style check (or removing the field if it’s truly unused).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/apps/cloud/src/user/dto/change-plan.dto.ts` around lines 16 - 19, The
DTO exposes eventTier as an optional string (eventTier in change-plan.dto.ts)
but the Paddle passthrough consumer (in user.service.ts / the Paddle webhook
handler) currently ignores eventTier, so either tighten validation to prevent
invalid values or remove the field; update change-plan.dto.ts to either add a
whitelist validation like `@IsIn`(EVENT_TIERS) referencing your EVENT_TIERS
enum/array or remove eventTier entirely and remove its forwarding in
user.service.ts so the DTO and paddle passthrough remain consistent with the
webhook handler behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@admin/src/commands/UsersCommand.tsx`:
- Around line 217-233: handlePlanTypeSelect currently overwrites
update.maxProjects with the base planEntitlements[nextPlanType].websites and
drops any purchased website add-ons or addonOverrides, which can reduce the
enforced initiatingUser.maxProjects; change the logic inside
handlePlanTypeSelect so that when computing update.maxProjects you combine the
base entitlement (planEntitlements[nextPlanType].websites) with any existing
purchasedWebsiteAddons/addonOverrides on the selected user (or preserve the
current selectedUser.maxProjects if it is larger), ensuring update.maxProjects
reflects base + purchased add-ons (or the larger existing max) before calling
updateSelectedUser; reference the selected user object and the symbols
planEntitlements, purchasedWebsiteAddons, addonOverrides, handlePlanTypeSelect,
and updateSelectedUser when implementing this.

In `@admin/src/entities/user.entity.ts`:
- Around line 92-93: The admin User entity's maxApiKeyRequestsPerHour default
(property maxApiKeyRequestsPerHour in admin/src/entities/user.entity.ts) is out
of sync with the cloud User entity and getDefaultAccountLimitUpdates(); change
its `@Column` default from 600 to 300 so the ORM contracts match across admin and
cloud (ensure the annotation on the maxApiKeyRequestsPerHour property is updated
to default: 300).

In `@backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts`:
- Around line 18-30: When computing reqAmount in api-key-rate-limit.guard, honor
any user.entitlementOverrides before falling back to plan-type entitlements or
the user column: first read a numeric apiRateLimitPerHour from
user.entitlementOverrides (safely parse/convert if stored as string), then if
absent use entitlements?.apiRateLimitPerHour from
getPlanTypeEntitlements(effectivePlanType), and finally fall back to
user.maxApiKeyRequestsPerHour; keep the existing early-zero checks
(user.planCode === PlanCode.none, user.isAccountBillingSuspended,
user.dashboardBlockReason) and ensure the computed planTypeLimit variable
reflects this override-first precedence.

In `@backend/apps/cloud/src/user/entities/user.entity.ts`:
- Around line 106-127: getEffectivePlanType currently returns user.planType
without verifying the account is actually on a paid plan; change the logic in
getEffectivePlanType so you first check user.planCode and treat PlanCode.none,
PlanCode.free, and PlanCode.trial as unpaid (return null), and only if
user.planCode indicates a paid plan should you honor user.planType (or map paid
planCodes to PlanType.standard as currently done); reference
getEffectivePlanType, user.planCode, user.planType, and
PlanCode.none/PlanCode.free/PlanCode.trial when making the change.

In `@backend/apps/cloud/src/user/user.controller.ts`:
- Around line 908-924: The updateWebsiteAddon path can create duplicate Paddle
one-off charges because updateWebsiteAddon -> userService.updateWebsiteAddon
calls chargeWebsiteAddon without idempotency or DB locking; fix by adding an
idempotency/guard: require an idempotencyKey (or derive one from
user+addon+period) on the request and have
userService.updateWebsiteAddon/chargeWebsiteAddon check for an existing
UserAddonCharge with that key before creating/POSTing, or enforce a DB
uniqueness constraint on UserAddonCharge (e.g., addonId + chargeKind + period
window) and wrap the read-update-charge sequence in a single transaction that
locks the UserAddon row via SELECT ... FOR UPDATE so only one concurrent request
proceeds to create the charge and update UserAddon; ensure the change points are
in userService.updateWebsiteAddon and chargeWebsiteAddon and that any created
uniqueness constraint is added to the UserAddonCharge model/migration.

In `@backend/apps/cloud/src/user/user.service.ts`:
- Around line 1024-1055: The renewal path is vulnerable to double-billing
because rows are not claimed before calling chargeWebsiteAddon and the DB update
happens only after charging; change
processWebsiteAddonRenewal/processDueWebsiteAddonRenewals to first atomically
claim the addon row (e.g., userAddonRepository.update(...) or a conditional SQL
update that sets a "claim" state like status = 'charging' or sets nextChargeDate
to a reserved value) using a conditional WHERE that ensures you only claim when
the addon is still overdue and unchanged, then only call chargeWebsiteAddon
after the claim succeeds; after a successful charge perform the existing update
(quantity, periodStart, periodEnd, nextChargeDate, status, failedChargeAttempts,
etc.), and on failure use handleWebsiteAddonRenewalFailure to update the claimed
row (or clear the claim) and record failure metadata—also add an idempotency
guard (e.g., lastChargeId or lastChargeAt) checked before charging so retries or
concurrent workers will not re-bill an already-recorded charge.

In `@backend/apps/cloud/src/webhook/webhook.controller.ts`:
- Around line 106-116: The code currently forces requestedPlanType to
PlanType.standard when passthrough.planType is missing, which strips
Plus/Enterprise on updates; instead, keep requestedPlanType null when
passthrough.planType is absent/invalid and use the existing
subscription.planType from the DB (or the subscription object) when applying
updates. Concretely: in the webhook handler that parses passthrough (variable
requestedPlanType, passthrough), only set requestedPlanType when
passthroughData.planType exists and is a valid PlanType; do not assign a default
there. Later, when applying the subscription update (where you currently fall
back to PlanType.standard), read the current subscription record’s planType and
use that as the effective plan when requestedPlanType is null. Apply the same
change to the other parsing block referenced (the similar passthrough handling
around the 173-190 range).

In `@web/app/components/pricing/MarketingPricing.tsx`:
- Line 290: The boolean state showAllBenefits (and its setter
setShowAllBenefits) is currently shared across all plan cards, causing expanding
one card to expand all; change the implementation to scope expansion per plan by
storing the state keyed by planType (e.g., a map/object like { [planType]:
boolean }) or move the state into a card-level component (the PlanCard or
BenefitList component) so each card manages its own local showAll state; update
all places referencing showAllBenefits (including the handlers and conditional
rendering around benefit lists) to use the per-plan value (or the card's local
state) and adjust setter usage accordingly.

In `@web/app/lib/pricing/catalog.ts`:
- Around line 546-554: The current getPlanPrice function in which PLAN_PRICES is
accessed silently falls back to USD when the requested currency is missing;
change getPlanPrice to stop the implicit USD fallback by returning null when
PLAN_PRICES[planType]?.[eventTier]?.[billingInterval]?.[currencyCode] is
undefined, and (optionally) emit a warning via your logger when the requested
currency is unavailable so callers can handle localization appropriately; update
any callers/tests that relied on the implicit USD fallback to handle null
responses.
- Around line 116-132: Update the documentation/comment for the price helper to
state that the paddlePlanId value passed into price (and subsequently sent to
the backend as planId/product_id in product/generate_pay_link) represents a
single Paddle product_id that must be configured with Paddle localized
pricing/overrides; clarify that the same paddlePlanId is intentionally shared
across USD/EUR/GBP and no currency-specific Paddle IDs are sent (checkout relies
on country/locale), and add a short JSDoc or inline comment above the price
function to make this assumption explicit so integrators know to set up
localized prices in Paddle.

In `@web/app/pages/Project/View/components/PlanFeatureLocked.tsx`:
- Around line 14-47: The PlanFeatureLocked component currently assumes feature
only maps to certain translation keys and a billing route; narrow and validate
the prop and locale coverage: change the feature prop/type used by
PlanFeatureLocked (and the source that passes it) to a strict union of the
allowed feature identifiers (e.g., the exact keys under project.featureAccess
like featureFlags and experiments) and add a runtime guard that falls back to a
safe key or hides the component if an unexpected value appears; ensure
routes.billing_choose_plan is the intended route (it exists in routes.ts) and
that corresponding
project.featureAccess.*.{ownerTitle,viewerTitle,ownerDescription,viewerDescription,upgradePlan}
keys are present in every shipped locale file (add missing translations or map
unknown features to a default translation).

In `@web/app/pages/Subscribe/Subscribe.tsx`:
- Around line 243-244: The code currently falls back to 0 for
custom/contact-sales tiers (using selectedPrice?.amount ?? 0 and rendering
currency symbols) which produces "$0" or "$ /month" for non-published prices;
update the logic so that where getPlanMonthlyPrice/getPlanPrice or selectedPrice
is missing (selectedPrice === undefined or selectedPrice.amount == null) you
render t('pricing.custom') (or the contact-sales copy) instead of rendering a
currency symbol and a zero amount. Concretely, change places that compute
dueEndAmount (and the selector/monthly price renderers) to detect missing price
and branch to return the localized custom string; update the UI render paths
that currently use selectedPrice?.amount ?? 0 to instead conditionally render
either formatted currency (when amount exists) or t('pricing.custom') (when
amount missing), and apply this same fix to the other occurrences noted (the
plan selector, monthly display, and trial-end amount renderings that use
getPlanMonthlyPrice/getPlanPrice and selectedPrice).

In `@web/app/pages/UserSettings/UserSettings.tsx`:
- Around line 642-653: The active add-on recurring amount must not be recomputed
from catalog values; replace the calculation in
activeWebsiteAddonRecurringAmount (and analogous code around lines 2207-2218)
with the persisted billing value returned by the backend (e.g., use
activeWebsiteAddon?.recurringAmount or the
subscription.billing.activeAddonRecurringAmount field) and fall back to that
persisted field only if present; remove use of websiteAddonPrice,
websiteAddonBundle, and currentWebsiteAddonBillingInterval for the active amount
so grandfathered/custom prices are shown exactly as stored.
- Around line 577-584: The conditional order in the useMemo that computes
websiteAddonDisabledReason is wrong: legacy trial users (isTrial true,
isSubscriber false) hit the !isSubscriber branch and never see the trial
message. In the useMemo for websiteAddonDisabledReason, check isTrial before
checking !isSubscriber (i.e., evaluate isTrial first and return
t('billing.websiteAddonUnavailableDuringTrial') when true), then fall back to
the subscription check; keep the useMemo, t(...) calls and other branches
unchanged.
- Around line 1974-1979: The caption is still checking purchasedWebsiteAddons
directly; change the conditional to use the normalized active quantity
(websiteAddon?.quantity ?? purchasedWebsiteAddons) so users coming from the new
websiteAddon flow see the correct copy. Locate the JSX rendering that references
purchasedWebsiteAddons and replace the ternary check with the normalized value
(using websiteAddon?.quantity ?? purchasedWebsiteAddons) and use that value for
toLocaleString() as well as the truthiness check.

In `@web/app/routes/user-settings_.choose-plan.tsx`:
- Around line 23-34: The meta() export currently calls the React hook
useTranslation(), which violates Rules of Hooks; remove useTranslation() from
meta and instead obtain translated strings in a server-safe place (e.g.,
translate in the route loader or use a server-side i18n helper) and pass them
into meta via the loader's returned data; update meta to accept those translated
values (title, description, preview image text) and call getTitle,
getDescription, and getPreviewImage/getOgImageUrl using those strings rather
than calling useTranslation() inside meta.

---

Outside diff comments:
In `@backend/apps/cloud/src/feature-flag/feature-flag.controller.ts`:
- Around line 519-532: The public responses differ between the "project
missing/inactive" branch and the "owner lacks feature" branch, leaking
entitlement info; update the branch that calls
this.projectService.projectOwnerHasFeature(project,
PlanFeatureCode.featureFlags) to return the identical empty shape used for
non-existent/inactive projects (i.e., return { flags: {} } instead of { flags:
{}, experiments: {} }) so the API always exposes the same response shape for
unauthorized or missing projects.

---

Nitpick comments:
In `@admin/src/commands/UsersCommand.tsx`:
- Around line 408-413: The "Event Bucket" label currently displays
selectedUser.planCode while effectiveLimits.eventBucket is unused; update the UI
in UsersCommand.tsx to show the correct value by replacing the displayed value
for that line with effectiveLimits.eventBucket (or if you prefer to keep showing
planCode, change the label to "Plan Code"); locate the line rendering
<Text><Text color="cyan">Event Bucket:</Text> {selectedUser.planCode}</Text> and
either swap selectedUser.planCode for effectiveLimits.eventBucket or change the
label text to "Plan Code" to make label and value consistent.

In `@backend/apps/cloud/src/user/dto/change-plan.dto.ts`:
- Around line 16-19: The DTO exposes eventTier as an optional string (eventTier
in change-plan.dto.ts) but the Paddle passthrough consumer (in user.service.ts /
the Paddle webhook handler) currently ignores eventTier, so either tighten
validation to prevent invalid values or remove the field; update
change-plan.dto.ts to either add a whitelist validation like `@IsIn`(EVENT_TIERS)
referencing your EVENT_TIERS enum/array or remove eventTier entirely and remove
its forwarding in user.service.ts so the DTO and paddle passthrough remain
consistent with the webhook handler behavior.

In `@docs/content/docs/billing/update-subscription.mdx`:
- Around line 14-16: The three numbered list items that each start with "Choose"
(the steps that specify monthly event volume, plan type, and billing cadence)
should be rephrased for variety; replace one or more "Choose" occurrences with
alternatives like "Select the monthly event volume...", "Pick a plan type:
Standard, Plus, or Enterprise", and "Opt for monthly or yearly billing" so the
instructions read more varied while keeping the same meaning and ordering.

In `@web/app/lib/pricing/catalog.ts`:
- Around line 479-510: createPlanLimit currently masks missing pricing by using
"?? 0" for amounts from PLAN_PRICES which can hide config errors; update
createPlanLimit to validate PLAN_PRICES for the given eventTierCode (check
monthly and yearly objects and required currency amounts like
monthly?.USD.amount, yearly?.USD.amount, etc.) and in development throw or at
least processLogger.warn (or console.warn) when a price or paddlePlanId is
missing instead of silently returning 0; keep the existing fallback only for
production (NODE_ENV === 'production') if you need a safe runtime value, and
reference createPlanLimit, PLAN_PRICES, and EVENT_TIERS when adding the check.
- Around line 526-539: The function getEffectivePlanType contains redundant
returns of 'standard' — simplify by keeping the initial check for a valid
planType (using PLAN_TYPES) and the isPaidPlanCode(planCode) branch if needed,
then have a single default return 'standard' at the end; update logic in
getEffectivePlanType so only one final return('standard') remains and remove the
duplicate return in the isPaidPlanCode branch while preserving the existing
guards and type casts.

In `@web/app/routes/_index.tsx`:
- Line 561: Remove the redundant "items-center" token from the className applied
to this Button instance in routes/_index.tsx; locate the Button usage where
className='h-12 items-center border-white/25 ...' and edit the string to omit
items-center since the Button base styles already include it, leaving the other
utility classes intact.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ea5e6d57-f650-4cb6-87c5-68740a5c55b5

📥 Commits

Reviewing files that changed from the base of the PR and between 78f44aa and ae697e8.

⛔ Files ignored due to path filters (1)
  • web/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (59)
  • admin/src/billing/pricing.ts
  • admin/src/commands/UsersCommand.tsx
  • admin/src/entities/user.entity.ts
  • backend/apps/cloud/src/ai/ai.service.ts
  • backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts
  • backend/apps/cloud/src/experiment/experiment.controller.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/cloud/src/project/project.controller.ts
  • backend/apps/cloud/src/project/project.service.ts
  • backend/apps/cloud/src/task-manager/task-manager.service.ts
  • backend/apps/cloud/src/user/dto/change-plan.dto.ts
  • backend/apps/cloud/src/user/dto/update-website-addon.dto.ts
  • backend/apps/cloud/src/user/entities/user-addon-charge.entity.ts
  • backend/apps/cloud/src/user/entities/user-addon.entity.ts
  • backend/apps/cloud/src/user/entities/user.entity.ts
  • backend/apps/cloud/src/user/interfaces/usage-info.ts
  • backend/apps/cloud/src/user/user.controller.ts
  • backend/apps/cloud/src/user/user.module.ts
  • backend/apps/cloud/src/user/user.service.ts
  • backend/apps/cloud/src/webhook/webhook.controller.ts
  • backend/migrations/mysql/2026_05_30_pricing_plan_types.sql
  • backend/migrations/mysql/2026_06_01_api_rate_limit_default.sql
  • backend/migrations/mysql/2026_06_01_user_addons.sql
  • docs/content/docs/analytics-dashboard/experiments.mdx
  • docs/content/docs/analytics-dashboard/feature-flags.mdx
  • docs/content/docs/api/admin.mdx
  • docs/content/docs/api/stats.mdx
  • docs/content/docs/billing/cancel-subscription.mdx
  • docs/content/docs/billing/exceeding-plan-limits.mdx
  • docs/content/docs/billing/faq.mdx
  • docs/content/docs/billing/update-subscription.mdx
  • docs/content/docs/billing/upgrade-from-trial.mdx
  • docs/content/docs/script-reference.mdx
  • web/app/api/api.server.ts
  • web/app/components/marketing/Integrations.tsx
  • web/app/components/pricing/BillingPricing.tsx
  • web/app/components/pricing/MarketingPricing.tsx
  • web/app/hooks/useProjectFeatureAccess.ts
  • web/app/lib/constants/index.ts
  • web/app/lib/models/Project.ts
  • web/app/lib/models/Usageinfo.ts
  • web/app/lib/models/User.ts
  • web/app/lib/pricing/catalog.ts
  • web/app/lib/pricing/features.ts
  • web/app/pages/Project/View/ViewProject.tsx
  • web/app/pages/Project/View/components/PlanFeatureLocked.tsx
  • web/app/pages/Subscribe/Subscribe.tsx
  • web/app/pages/UserSettings/UserSettings.tsx
  • web/app/routes/_index.tsx
  • web/app/routes/billing_.choose-plan.tsx
  • web/app/routes/projects.$id.tsx
  • web/app/routes/user-settings.tsx
  • web/app/routes/user-settings_.choose-plan.tsx
  • web/app/ui/FAQ.tsx
  • web/app/ui/Select.tsx
  • web/app/ui/Tooltip.tsx
  • web/app/utils/routes.ts
  • web/package.json
  • web/public/locales/en.json

Comment thread admin/src/commands/UsersCommand.tsx Resolved
Comment thread admin/src/entities/user.entity.ts Resolved
Comment thread web/app/pages/Subscribe/Subscribe.tsx Resolved

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
web/app/lib/pricing/catalog.ts (1)

320-346: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Sync the advertised API quotas with the backend entitlements.

The backend now enforces standard = 300 and plus = 6000 in backend/apps/cloud/src/user/entities/user.entity.ts, and ApiKeyRateLimitGuard reads those values. This catalog still exposes 600 and 5000, so billing/settings UI will present the wrong API limits.

Suggested fix
   standard: {
     websites: 50,
     teamMembers: 10,
     organisations: 3,
-    apiRateLimitPerHour: 600,
+    apiRateLimitPerHour: 300,
     sessionReplaysIncluded: 0,
@@
   plus: {
     websites: 100,
     teamMembers: 25,
     organisations: 10,
-    apiRateLimitPerHour: 5000,
+    apiRateLimitPerHour: 6000,
     sessionReplaysIncluded: 'byEventTier',
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/lib/pricing/catalog.ts` around lines 320 - 346, The frontend
catalog's PLAN_ENTITLEMENTS object is out of sync with backend entitlements:
update the apiRateLimitPerHour values in PLAN_ENTITLEMENTS for the standard and
plus plans to match the backend (set standard.apiRateLimitPerHour to 300 and
plus.apiRateLimitPerHour to 6000) so the UI/billing reflects the same quotas
enforced by ApiKeyRateLimitGuard and backend user.entity.ts.
♻️ Duplicate comments (1)
web/app/lib/pricing/catalog.ts (1)

593-602: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Ignore stale paid planType values when planCode is unpaid.

The backend helper was fixed to stop trusting planType on none/free/trial, but this mirror still ignores planCode entirely. If a downgrade leaves planType = 'plus' or 'enterprise' behind, the web billing screens can keep rendering the paid tier after the backend has already treated the account as unpaid.

Suggested fix
 export const getEffectivePlanType = (
   planType?: string | null,
-  _planCode?: string | null,
+  planCode?: string | null,
 ): PlanTypeCode => {
+  if (
+    planCode &&
+    ['none', 'free', 'trial'].includes(planCode as PlanCode)
+  ) {
+    return 'standard'
+  }
+
   if (planType && planType in PLAN_TYPES) {
     return planType as PlanTypeCode
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/app/lib/pricing/catalog.ts` around lines 593 - 602, getEffectivePlanType
currently trusts planType even when _planCode indicates an unpaid account;
update the function to first inspect _planCode and, if it represents an unpaid
code (e.g., 'none', 'free', 'trial' or whatever unpaid codes your backend uses),
return 'standard' immediately, otherwise fall back to the existing logic that
checks planType against PLAN_TYPES; modify getEffectivePlanType to treat
null/undefined _planCode as before and to use the explicit unpaid-code check
before using planType.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/migrations/mysql/2026_06_02_user_addon_charge_guards.sql`:
- Around line 6-21: The UPDATE uses a linear formula to compute
`recurringAmount` in `user_addon`, but pricing is stepped and defined in the
canonical website-bundle price table used by web/app/lib/pricing/catalog.ts (not
a formula); change the migration to join `user_addon` with the canonical bundle
price table (the same table/source that contains the stepped prices for 250 and
1000 site bundles) to pick the correct base price per `quantity` tier, apply the
`currency` and `billingInterval` adjustments (yearly multiplier), ROUND to 2
decimals, and update `recurringAmount` only for rows where `quantity` > 0 and
`status` <> 'cancelled' so stored values match the actual catalog prices rather
than the linear `(quantity/50)*base` approximation.

---

Outside diff comments:
In `@web/app/lib/pricing/catalog.ts`:
- Around line 320-346: The frontend catalog's PLAN_ENTITLEMENTS object is out of
sync with backend entitlements: update the apiRateLimitPerHour values in
PLAN_ENTITLEMENTS for the standard and plus plans to match the backend (set
standard.apiRateLimitPerHour to 300 and plus.apiRateLimitPerHour to 6000) so the
UI/billing reflects the same quotas enforced by ApiKeyRateLimitGuard and backend
user.entity.ts.

---

Duplicate comments:
In `@web/app/lib/pricing/catalog.ts`:
- Around line 593-602: getEffectivePlanType currently trusts planType even when
_planCode indicates an unpaid account; update the function to first inspect
_planCode and, if it represents an unpaid code (e.g., 'none', 'free', 'trial' or
whatever unpaid codes your backend uses), return 'standard' immediately,
otherwise fall back to the existing logic that checks planType against
PLAN_TYPES; modify getEffectivePlanType to treat null/undefined _planCode as
before and to use the explicit unpaid-code check before using planType.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cf3c8ad8-f606-45de-8d93-f7cc0dd9665c

📥 Commits

Reviewing files that changed from the base of the PR and between ae697e8 and 5093a2a.

📒 Files selected for processing (20)
  • admin/src/billing/pricing.ts
  • admin/src/commands/UsersCommand.tsx
  • admin/src/entities/user.entity.ts
  • backend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.ts
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/cloud/src/user/dto/change-plan.dto.ts
  • backend/apps/cloud/src/user/entities/user-addon-charge.entity.ts
  • backend/apps/cloud/src/user/entities/user-addon.entity.ts
  • backend/apps/cloud/src/user/entities/user.entity.ts
  • backend/apps/cloud/src/user/user.service.ts
  • backend/apps/cloud/src/webhook/webhook.controller.ts
  • backend/migrations/mysql/2026_06_02_rename_plan_codes.sql
  • backend/migrations/mysql/2026_06_02_user_addon_charge_guards.sql
  • docs/content/docs/billing/update-subscription.mdx
  • web/app/components/pricing/MarketingPricing.tsx
  • web/app/lib/models/User.ts
  • web/app/lib/pricing/catalog.ts
  • web/app/pages/Project/View/components/PlanFeatureLocked.tsx
  • web/app/pages/Subscribe/Subscribe.tsx
  • web/app/pages/UserSettings/UserSettings.tsx
🚧 Files skipped from review as they are similar to previous changes (13)
  • web/app/pages/Project/View/components/PlanFeatureLocked.tsx
  • web/app/lib/models/User.ts
  • backend/apps/cloud/src/user/entities/user-addon.entity.ts
  • docs/content/docs/billing/update-subscription.mdx
  • backend/apps/cloud/src/feature-flag/feature-flag.controller.ts
  • backend/apps/cloud/src/webhook/webhook.controller.ts
  • backend/apps/cloud/src/user/entities/user-addon-charge.entity.ts
  • admin/src/billing/pricing.ts
  • admin/src/commands/UsersCommand.tsx
  • web/app/components/pricing/MarketingPricing.tsx
  • web/app/pages/Subscribe/Subscribe.tsx
  • web/app/pages/UserSettings/UserSettings.tsx
  • backend/apps/cloud/src/user/user.service.ts

@Blaumaus
Blaumaus merged commit 6f11363 into main Jun 4, 2026
11 of 12 checks passed
This was referenced Jun 5, 2026
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.

1 participant