Repository navigation
Introduce billing tiers, bundles; rework pricing - #561
Conversation
📝 WalkthroughWalkthroughIntroduces 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. ChangesBilling platform overhaul
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
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 winKeep 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 valueOptional: 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 valueRedundant class:
items-centeralready in Button base styles.The
Buttoncomponent already includesitems-centerin 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 valueLabel/value mismatch on "Event Bucket"; computed
eventBucketis unused.Line 408 labels the field "Event Bucket" but renders
selectedUser.planCode, while the numericeffectiveLimits.eventBucketis 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 winPrice fallback could mask configuration errors.
createPlanLimit()uses?? 0(lines 494-503) when prices are missing fromPLAN_PRICES. While this prevents runtime errors, a price of0could be confusing and might mask incomplete pricing configuration. Consider logging a warning or throwing an error during development.
⚠️ Add development-time validationconst 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 valueRedundant 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 valueClarify
eventTiervalidation impact
eventTieris only@IsOptional()@IsString()and is forwarded to Paddle solely aspassthrough.eventTier(user.service.ts). The Paddle webhook handler that processespassthroughreadsuid(and optionallyplanType) but does not handleeventTier, so constraining it mainly provides boundary hardening with no current server-side behavior change. If/wheneventTieris 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
⛔ Files ignored due to path filters (1)
web/package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (59)
admin/src/billing/pricing.tsadmin/src/commands/UsersCommand.tsxadmin/src/entities/user.entity.tsbackend/apps/cloud/src/ai/ai.service.tsbackend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.tsbackend/apps/cloud/src/experiment/experiment.controller.tsbackend/apps/cloud/src/feature-flag/feature-flag.controller.tsbackend/apps/cloud/src/project/project.controller.tsbackend/apps/cloud/src/project/project.service.tsbackend/apps/cloud/src/task-manager/task-manager.service.tsbackend/apps/cloud/src/user/dto/change-plan.dto.tsbackend/apps/cloud/src/user/dto/update-website-addon.dto.tsbackend/apps/cloud/src/user/entities/user-addon-charge.entity.tsbackend/apps/cloud/src/user/entities/user-addon.entity.tsbackend/apps/cloud/src/user/entities/user.entity.tsbackend/apps/cloud/src/user/interfaces/usage-info.tsbackend/apps/cloud/src/user/user.controller.tsbackend/apps/cloud/src/user/user.module.tsbackend/apps/cloud/src/user/user.service.tsbackend/apps/cloud/src/webhook/webhook.controller.tsbackend/migrations/mysql/2026_05_30_pricing_plan_types.sqlbackend/migrations/mysql/2026_06_01_api_rate_limit_default.sqlbackend/migrations/mysql/2026_06_01_user_addons.sqldocs/content/docs/analytics-dashboard/experiments.mdxdocs/content/docs/analytics-dashboard/feature-flags.mdxdocs/content/docs/api/admin.mdxdocs/content/docs/api/stats.mdxdocs/content/docs/billing/cancel-subscription.mdxdocs/content/docs/billing/exceeding-plan-limits.mdxdocs/content/docs/billing/faq.mdxdocs/content/docs/billing/update-subscription.mdxdocs/content/docs/billing/upgrade-from-trial.mdxdocs/content/docs/script-reference.mdxweb/app/api/api.server.tsweb/app/components/marketing/Integrations.tsxweb/app/components/pricing/BillingPricing.tsxweb/app/components/pricing/MarketingPricing.tsxweb/app/hooks/useProjectFeatureAccess.tsweb/app/lib/constants/index.tsweb/app/lib/models/Project.tsweb/app/lib/models/Usageinfo.tsweb/app/lib/models/User.tsweb/app/lib/pricing/catalog.tsweb/app/lib/pricing/features.tsweb/app/pages/Project/View/ViewProject.tsxweb/app/pages/Project/View/components/PlanFeatureLocked.tsxweb/app/pages/Subscribe/Subscribe.tsxweb/app/pages/UserSettings/UserSettings.tsxweb/app/routes/_index.tsxweb/app/routes/billing_.choose-plan.tsxweb/app/routes/projects.$id.tsxweb/app/routes/user-settings.tsxweb/app/routes/user-settings_.choose-plan.tsxweb/app/ui/FAQ.tsxweb/app/ui/Select.tsxweb/app/ui/Tooltip.tsxweb/app/utils/routes.tsweb/package.jsonweb/public/locales/en.json
There was a problem hiding this comment.
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 winSync the advertised API quotas with the backend entitlements.
The backend now enforces
standard = 300andplus = 6000inbackend/apps/cloud/src/user/entities/user.entity.ts, andApiKeyRateLimitGuardreads those values. This catalog still exposes600and5000, 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 winIgnore stale paid
planTypevalues whenplanCodeis unpaid.The backend helper was fixed to stop trusting
planTypeonnone/free/trial, but this mirror still ignoresplanCodeentirely. If a downgrade leavesplanType = '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
📒 Files selected for processing (20)
admin/src/billing/pricing.tsadmin/src/commands/UsersCommand.tsxadmin/src/entities/user.entity.tsbackend/apps/cloud/src/auth/guards/api-key-rate-limit.guard.tsbackend/apps/cloud/src/feature-flag/feature-flag.controller.tsbackend/apps/cloud/src/user/dto/change-plan.dto.tsbackend/apps/cloud/src/user/entities/user-addon-charge.entity.tsbackend/apps/cloud/src/user/entities/user-addon.entity.tsbackend/apps/cloud/src/user/entities/user.entity.tsbackend/apps/cloud/src/user/user.service.tsbackend/apps/cloud/src/webhook/webhook.controller.tsbackend/migrations/mysql/2026_06_02_rename_plan_codes.sqlbackend/migrations/mysql/2026_06_02_user_addon_charge_guards.sqldocs/content/docs/billing/update-subscription.mdxweb/app/components/pricing/MarketingPricing.tsxweb/app/lib/models/User.tsweb/app/lib/pricing/catalog.tsweb/app/pages/Project/View/components/PlanFeatureLocked.tsxweb/app/pages/Subscribe/Subscribe.tsxweb/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
Changes
If applicable, please describe what changes were made in this pull request.
Community Edition support
Database migrations
Documentation
Summary by CodeRabbit
New Features
Bug Fixes / Behavior
Documentation