Skip to content

Paid trials#478

Open
Blaumaus wants to merge 29 commits intomainfrom
feature/paid-trials
Open

Paid trials#478
Blaumaus wants to merge 29 commits intomainfrom
feature/paid-trials

Conversation

@Blaumaus
Copy link
Member

@Blaumaus Blaumaus commented Feb 22, 2026

Changes

Related:

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

    • Checkout page and route with inline payment; Paddle integration hook added
    • Server + UI subscription cancellation endpoint with optional feedback; cancellation feedback persisted
    • Cancellation feedback capture and admin-facing record storage
  • Bug Fixes / Behavior

    • Removed password repeat from signup
    • Trial reminder window extended to 48 hours and broadened targeting
    • Additional redirects/gating to checkout for users without a plan
    • Default new-user plan set to "none"
  • Content & Messaging

    • Updated signup/trial email content and subjects; new checkout/trial UI strings and translations

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Default user plan changed to none; adds a Checkout page/route with Paddle integration, subscription cancellation API/UI with optional feedback persisted, onboarding/redirect gating to checkout for non‑self‑hosted users, updated emails/templates, trial reminder adjustments, and a DB migration adding cancellation feedback and onboarding enum update.

Changes

Cohort / File(s) Summary
User entity & DB migration
admin/src/entities/user.entity.ts, backend/apps/cloud/src/user/entities/user.entity.ts, backend/migrations/mysql/2026_02_22_trial_with_cc.sql
Default planCode changed from trialnone; added SELECT_PLAN onboarding step; migration updates onboardingStep enum and creates cancellation_feedback table.
Checkout UI & routes
web/app/pages/Checkout/Checkout.tsx, web/app/pages/Checkout/index.tsx, web/app/routes/checkout.tsx, web/app/utils/routes.ts
New Checkout component, route, loader and routes.checkout; Paddle inline integration, plan selection UI, pricing logic, and server loader guards.
Auth, onboarding & routing
web/app/pages/Auth/Signin/Signin.tsx, web/app/pages/Auth/Signup/Signup.tsx, web/app/pages/Onboarding/Onboarding.tsx, web/app/routes/login.tsx, web/app/routes/signup.tsx, web/app/routes/onboarding.tsx, web/app/routes/dashboard.tsx
Redirect logic updated to gate non‑self‑hosted users without a plan to /checkout; signup removed password repeat handling and adjusted onboarding redirect targets.
Subscription cancellation (API + service + entity + DTO + controller)
backend/apps/cloud/src/user/dto/cancel-subscription.dto.ts, backend/apps/cloud/src/user/entities/cancellation-feedback.entity.ts, backend/apps/cloud/src/user/user.module.ts, backend/apps/cloud/src/user/user.service.ts, backend/apps/cloud/src/user/user.controller.ts, web/app/routes/user-settings.tsx
Adds CancelSubscription DTO, CancellationFeedback entity and repo wiring; service method to cancel Paddle subscriptions and save feedback; controller POST /user/subscription/cancel; frontend action to call it.
User settings UI & header
web/app/pages/UserSettings/UserSettings.tsx, web/app/components/Header/index.tsx, web/app/hooks/usePaddle.ts
Refactors cancellation flow to server call with optional feedback (removes direct Paddle cancel UX), adds feedback input/state/validation, integrates usePaddle hook, and adjusts trial detection and header banner condition.
Emails, task manager & webhook
backend/apps/cloud/src/common/templates/en/customer_sign-up.html, backend/apps/cloud/src/common/templates/en/trial-ends-tomorrow.html, backend/apps/cloud/src/mailer/mailer.service.ts, backend/apps/cloud/src/task-manager/task-manager.service.ts, backend/apps/cloud/src/webhook/webhook.controller.ts
Rewrote signup/trial email bodies and subjects; task-manager broadened filter (planCode != none) and extended reminder window to 48h; webhook treats trialing status and preserves trialEndDate.
Localization & small frontend edits
web/public/locales/en.json, web/app/pages/Auth/Signup/Signup.tsx, web/app/pages/UserSettings/..., web/app/routes/*, web/app/components/Header/index.tsx
Added checkout/trial/cancellation i18n keys, updated signup copy, deduplicated CONTACT_US_URL, and adjusted multiple loaders/redirects to route to checkout for non‑self‑hosted users.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant Frontend as Web Frontend
    participant Backend
    participant Paddle
    participant DB as Database

    User->>Frontend: Visit app / sign in
    Frontend->>Backend: Loader GET (check onboarding & plan)
    Backend->>DB: Read user (onboardingStep, planCode)
    alt non-self-hosted && planCode is missing or 'none'
        Backend-->>Frontend: 302 -> /checkout
        Frontend->>User: Show Checkout UI
        User->>Frontend: Initiate Paddle checkout
        Frontend->>Paddle: Load & open inline checkout (user info)
        Paddle-->>Frontend: Checkout success event
        Paddle->>Backend: Webhook (subscription created)
        Backend->>DB: Update user planCode, trialEndDate
        Backend-->>Frontend: Auth reflects updated subscription
        Frontend->>User: Redirect to dashboard
    else
        Backend-->>Frontend: Serve dashboard
    end
Loading
sequenceDiagram
    participant User
    participant Frontend as Web Frontend
    participant Backend
    participant Paddle
    participant DB as Database

    User->>Frontend: Open Settings -> Cancel subscription
    Frontend->>User: Show cancel modal (optional feedback)
    User->>Frontend: Submit cancellation
    Frontend->>Backend: POST /user/subscription/cancel { feedback? }
    Backend->>DB: Verify user & subscription
    Backend->>Paddle: POST cancel subscription API
    Paddle-->>Backend: Cancellation confirmed
    Backend->>DB: Insert CancellationFeedback row
    Backend-->>Frontend: 204 No Content
    Frontend->>Backend: GET /auth/user (loadUser)
    Backend->>DB: Read updated subscription state
    Frontend->>User: Show success toast / updated UI
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Poem

🐰 I hopped through routes and paddles bright,

I nudged a plan from shadow into light,
I listened when a user waved goodbye,
I tucked their feedback gently in the sky,
A little hop — a soft and thoughtful sigh.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is sparse and provides minimal context. While it correctly marks Community Edition support and database migrations, it lacks detailed explanations of the changes and their purpose. Add a comprehensive 'Changes' section describing the paid trials feature implementation, key changes (trial flow, redirect logic, email templates, subscription cancellation, etc.), and how these changes affect the user experience and system behavior.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Paid trials' directly describes the main feature being implemented throughout the changeset, clearly summarizing the primary change.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/paid-trials

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@Blaumaus Blaumaus marked this pull request as ready for review February 24, 2026 21:55
@Blaumaus Blaumaus self-assigned this Feb 24, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 8

Caution

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

⚠️ Outside diff range comments (3)
web/app/pages/Onboarding/Onboarding.tsx (1)

1177-1179: ⚠️ Potential issue | 🟡 Minor

Hardcoded, non-localised error string.

'Please, verify your email address first' bypasses the i18n system. All other user-visible strings in this file use t(...).

♻️ Proposed fix
-                          toast.error(
-                            'Please, verify your email address first',
-                          )
+                          toast.error(t('onboarding.confirm.notVerifiedError'))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Onboarding/Onboarding.tsx` around lines 1177 - 1179, The code
in the Onboarding component uses a hardcoded string 'Please, verify your email
address first' instead of the i18n helper; find the email-verification check in
Onboarding.tsx (inside the Onboarding component / the submit or continue handler
where that return occurs) and replace the literal with the localized call using
the same translator used elsewhere in the file (e.g., t('Please, verify your
email address first') or preferably a new key like
t('onboarding.verifyEmailFirst')), adding the key to the translation resource
files as needed so the message is localized.
web/public/locales/en.json (1)

554-558: ⚠️ Potential issue | 🟠 Major

FAQ answer contradicts the new paid-trial flow.

The FAQ item "Is there a free trial?" still states "no credit card required", but this PR specifically requires credit card details before starting a trial. This will confuse users.

Proposed fix
-          "a": "Yep, you can try Swetrix for free for {{freeTrialDays}} days, no credit card required!"
+          "a": "Yep, you can try Swetrix for free for {{freeTrialDays}} days! Just add a payment method to get started — you won't be charged until the trial ends, and you can cancel anytime."
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/public/locales/en.json` around lines 554 - 558, Update the FAQ answer for
the "Is there a free trial?" item in the locales file: change the translation
value associated with the "q": "Is there a free trial?" entry so it no longer
says "no credit card required" and instead states that a trial is available for
{{freeTrialDays}} days but requires credit card details (or similar phrasing).
Locate the object containing that question/answer pair in
web/public/locales/en.json and replace the "a" value accordingly to reflect the
paid-trial flow.
backend/apps/cloud/src/user/user.service.ts (1)

440-443: ⚠️ Potential issue | 🔴 Critical

Critical bug in isPaidTier: comma operator prevents proper plan code validation.

The code [(PlanCode.none, PlanCode.free, PlanCode.trial)] uses the comma operator, which evaluates to only PlanCode.trial, creating array ['trial']. This allows PlanCode.none and PlanCode.free users to incorrectly pass the paid-tier check and access restricted features (unnamed projects and shared subscriptions).

Fix by removing the parentheses:

Proposed fix
  isPaidTier(user: User) {
    if (!user) {
      return false
    }

-    return ![(PlanCode.none, PlanCode.free, PlanCode.trial)].includes(
+    return ![PlanCode.none, PlanCode.free, PlanCode.trial].includes(
      user.planCode,
    )
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/user.service.ts` around lines 440 - 443,
isPaidTier currently constructs the array using the comma operator which
collapses to only PlanCode.trial; change the array expression in isPaidTier to
list the enum values directly (e.g. [PlanCode.none, PlanCode.free,
PlanCode.trial]) so that the includes check correctly tests user.planCode,
preserving the existing negation (!...includes(...)) and returning true only for
paid plans; update the array literal where isPaidTier is defined to remove the
parentheses and commas misuse.
♻️ Duplicate comments (1)
web/app/pages/Checkout/Checkout.tsx (1)

71-74: Cookie without Secure flag — low risk for preference data.

These cookies store UI preferences (selected plan and billing frequency), not authentication tokens or sensitive data. The CodeQL "sensitive cookie" warning is a false positive for this context. However, adding Secure is trivial and a good defensive practice for production deployments over HTTPS.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 71 - 74, The cookie
assignments inside the useEffect that set document.cookie for
swetrix_selected_plan and swetrix_selected_billing (controlled by selectedPlan
and selectedBillingFrequency) should include the Secure flag (and optionally
SameSite=Lax) so they are only sent over HTTPS; update the two document.cookie
strings in Checkout.tsx (the useEffect block) to append "; Secure" (and consider
adding "; SameSite=Lax") to each cookie assignment.
🧹 Nitpick comments (9)
backend/migrations/mysql/2026_02_22_trial_with_cc.sql (2)

1-10: No rollback migration provided.

The ALTER TABLE for onboardingStep modifies enum values — reverting it requires knowing the exact prior enum definition. Consider adding a corresponding down migration or documenting the rollback steps.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/migrations/mysql/2026_02_22_trial_with_cc.sql` around lines 1 - 10,
Add a down/rollback migration that reverts the changes made: for the ALTER TABLE
on the user table revert the onboardingStep enum back to its previous exact
definition (update the ALTER TABLE `user` MODIFY COLUMN `onboardingStep` ...
back to the original enum values) and drop the newly created
cancellation_feedback table (DROP TABLE `cancellation_feedback`); if you don’t
have the prior enum values, document the exact prior enum set in the migration
comment or source control so the down migration can use the precise enum list
when implementing the rollback.

3-10: No index on cancellation_feedback.email or createdAt.

If support or analytics queries ever filter by email (e.g., "show all feedback from user X") or by date range, full table scans will occur. Consider adding at least a createdAt index now while the table is empty.

♻️ Proposed addition
  PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+
+CREATE INDEX `idx_cancellation_feedback_email` ON `cancellation_feedback` (`email`);
+CREATE INDEX `idx_cancellation_feedback_createdAt` ON `cancellation_feedback` (`createdAt`);
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/migrations/mysql/2026_02_22_trial_with_cc.sql` around lines 3 - 10,
The new cancellation_feedback table lacks indexes on email and createdAt which
will cause full table scans for common queries; update the migration that
creates `cancellation_feedback` (or add a follow-up ALTER TABLE) to add an index
on the `email` column (e.g., `idx_cancellation_feedback_email`) and an index on
the `createdAt` column (e.g., `idx_cancellation_feedback_created_at`) so queries
like "feedback by user" and date-range scans use indexes; ensure the index names
are unique and use the same col names `email` and `createdAt` as defined in the
CREATE TABLE.
backend/apps/cloud/src/user/dto/cancel-subscription.dto.ts (1)

4-10: Consider trimming or rejecting whitespace-only feedback.

An empty string or a string of spaces passes @IsString and @MaxLength, and would be saved to cancellation_feedback.feedback as an empty/whitespace row, which adds noise to analytics. Adding @Transform to trim and @IsNotEmpty (or skipping persistence when the trimmed string is blank at the service layer) would prevent this.

♻️ Proposed fix
+import { Transform } from 'class-transformer'
 import { ApiPropertyOptional } from '@nestjs/swagger'
-import { IsOptional, IsString, MaxLength } from 'class-validator'
+import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'

 export class CancelSubscriptionDTO {
   `@ApiPropertyOptional`()
   `@IsOptional`()
+  `@Transform`(({ value }) => (typeof value === 'string' ? value.trim() : value))
   `@IsString`()
+  `@IsNotEmpty`()
   `@MaxLength`(2000)
   feedback?: string
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/dto/cancel-subscription.dto.ts` around lines 4 -
10, The feedback field on CancelSubscriptionDTO can be whitespace-only and pass
`@IsString/`@MaxLength, so add a `@Transform` to trim the incoming value (e.g.,
transform to value?.trim()) and then ensure validation treats blank trimmed
strings as absent by keeping `@IsOptional`() and adding `@IsNotEmpty`() (so empty
strings fail validation or are skipped); alternatively convert trimmed empty
string to undefined in the `@Transform` to let `@IsOptional`() skip it. Update the
decorators on the feedback property in CancelSubscriptionDTO accordingly and
ensure the transform runs before validators.
web/app/pages/Auth/Signup/Signup.tsx (1)

202-206: SSO post-sign-up navigates to /dashboard for users with planCode === 'none', causing an extra redirect hop.

After a successful SSO sign-up where onboarding is already complete but planCode is 'none', the code navigates to routes.dashboard. The dashboard loader then immediately redirects to /checkout. The routing chain works correctly but adds a round-trip. Consider aligning with the complete-onboarding intent logic by checking planCode here.

♻️ Proposed fix
  if (!user.hasCompletedOnboarding) {
    navigate(routes.onboarding)
  } else {
-   navigate(routes.dashboard)
+   navigate(
+     !isSelfhosted && (!user.planCode || user.planCode === 'none')
+       ? routes.checkout
+       : routes.dashboard,
+   )
  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Auth/Signup/Signup.tsx` around lines 202 - 206, After SSO
sign-up, the post-sign-up navigation currently sends users to
navigate(routes.dashboard) whenever user.hasCompletedOnboarding is true, which
causes an immediate redirect for users with user.planCode === 'none'; update the
logic in the Signup post-sign-up block to check user.planCode (e.g.,
user.planCode === 'none') and, when onboarding is complete but planCode is
'none', navigate directly to the checkout route (navigate(routes.checkout))
instead of routes.dashboard; keep the existing navigate(routes.onboarding)
branch for hasCompletedOnboarding === false.
web/app/pages/Checkout/Checkout.tsx (2)

199-208: Hardcoded "-17%" discount label.

This percentage should be derived from the actual monthly vs. yearly price difference, or at minimum defined as a constant, to prevent it from drifting out of sync with actual pricing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 199 - 208, The "-17%"
discount label is hardcoded in Checkout.tsx and can drift from actual prices;
update the UI to compute the discount dynamically (or use a named constant)
instead of the literal string: derive percentage = (monthlyPrice * 12 -
yearlyPrice) / (monthlyPrice * 12) * 100 (or define DISCOUNT_PERCENT = ... in
the same module) and render that computed value in place of the "-17%" span;
reference the discount span and the selectedBillingFrequency/Switch UI so the
displayed discount always reflects the monthlyPrice and yearlyPrice values used
by the component.

114-117: Unsafe as any cast for product ID — could silently pass undefined to Paddle.

(tier as any).pid / (tier as any).ypid bypasses type checking. If the selected plan tier doesn't have these properties, product will be undefined, and Paddle.Checkout.open will receive an invalid product. Consider adding a guard or typing PLAN_LIMITS entries to include pid/ypid.

Proposed guard
    const product =
      selectedBillingFrequency === BillingFrequency.monthly
        ? (tier as any).pid
        : (tier as any).ypid

+   if (!product) {
+     toast.error('Selected plan is not available. Please choose a different plan.')
+     return
+   }
+
    setIsCheckoutOpen(true)
web/app/pages/Auth/Signin/Signin.tsx (1)

230-239: Duplicated redirect logic — consider extracting a helper.

The same planCode-based redirect decision (!planCode || 'trial' || 'none' → checkout, else dashboard) appears twice here and three more times in login.tsx. A small helper like getPostAuthRoute(user, isSelfhosted) would centralise this, making future plan-code changes (e.g. adding a new intermediate state) less error-prone.

Also applies to: 313-322

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Auth/Signin/Signin.tsx` around lines 230 - 239, Extract the
duplicated planCode redirect logic into a single helper function (e.g.
getPostAuthRoute(user, isSelfhosted)) that returns either routes.checkout or
routes.dashboard based on user.planCode and isSelfhosted; replace the repeated
conditionals in Signin.tsx (the navigate(...) branches) and the similar blocks
in login.tsx with navigate(getPostAuthRoute(user, isSelfhosted)); ensure the
helper is exported/usable where needed and update any call sites to pass the
existing user and isSelfhosted variables so future planCode changes are handled
in one place.
backend/apps/cloud/src/user/user.controller.ts (1)

739-784: Well-structured cancellation endpoint; consider PII cleanup on account deletion.

The endpoint correctly validates the subscription state, cancels via the service, saves feedback non-blockingly, and tracks analytics.

However, saveCancellationFeedback persists the user's email (PII). The deleteSelf method (line 203) doesn't clean up cancellationFeedback records. Compare with saveDeleteFeedback which only stores anonymous feedback text. Consider either:

  • Anonymising the email in cancellation feedback on account deletion, or
  • Removing the email column and relying on the timestamp + planCode for analysis.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/user.controller.ts` around lines 739 - 784, The
cancellation feedback flow persists PII (user email) via
userService.saveCancellationFeedback called from cancelSubscription; ensure
cancellationFeedback records are cleaned or anonymised on account deletion by
updating the deleteSelf flow (and related service/repository) to either remove
associated cancellationFeedback rows or replace the email with an anonymised
token/timestamp; alternatively, stop storing email by changing
saveCancellationFeedback to persist only non-PII (feedback text, planCode,
timestamp) and remove the email column and any usages; update the UserService
methods (saveCancellationFeedback, cancelSubscription, deleteSelf) and
corresponding DB migration/repository code and tests to reflect the chosen
approach.
web/app/routes/checkout.tsx (1)

40-40: redirectIfNotAuthenticated return value is silently discarded.

The function's signature is (): Response | null. It throws a redirect when unauthenticated (so the discard is functionally harmless), but dropping the return value without acknowledgement is misleading. Consider prefixing with void to signal intentional discard, or — since getAuthenticatedUser on the next line already handles auth failure — consider removing the early redirectIfNotAuthenticated call entirely and handling the null authResult explicitly (see comment above).

-  redirectIfNotAuthenticated(request)
+  void redirectIfNotAuthenticated(request)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/routes/checkout.tsx` at line 40, The call to
redirectIfNotAuthenticated(request) returns Response|null but its value is
ignored; either explicitly mark the discard by prefixing the call with void
(void redirectIfNotAuthenticated(request)) to signal intentional drop, or remove
the call entirely and rely on getAuthenticatedUser(request) to handle auth by
checking its result (authResult) and performing a redirect/throw when null;
update the code in checkout.tsx around redirectIfNotAuthenticated and
getAuthenticatedUser to use one of these approaches and remove the silent-return
confusion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/apps/cloud/src/user/entities/user.entity.ts`:
- Around line 231-235: Add a new migration that alters the database default for
the user entity's planCode column from 'trial' to 'none' so DB behavior matches
the TypeORM entity (enum PlanCode, column planCode). The migration should update
the column default constraint for the users table to use PlanCode.none (not rely
on the old 2025_11_03.sql), include a safe down() to restore 'trial' if rolled
back, and run the migration to ensure raw inserts/omitted fields receive 'none'
by default.

In `@backend/apps/cloud/src/webhook/webhook.controller.ts`:
- Around line 176-178: The code only sets updateParams.trialEndDate when
isTrialing is true, so when a subscription moves from 'trialing' to 'active' the
old trialEndDate remains; update the webhook handling logic in
webhook.controller.ts so that after computing isTrialing and nextBillDate you
explicitly set updateParams.trialEndDate = nextBillDate when isTrialing is true,
and set updateParams.trialEndDate = null when isTrialing is false (i.e., clear
the field on transition out of trial). Ensure you update the same updateParams
object used to persist the subscription change so downstream checks
(UserSettings.tsx / Header/index.tsx) see a null trialEndDate.

In `@web/app/pages/Checkout/Checkout.tsx`:
- Line 312: The JSX in the Checkout component is rendering a hardcoded English
string "Redirecting..." next to {t('common.success')}; replace the literal with
a translation call (e.g. use t('common.redirecting') or a similarly named key)
and add that key to your localization files so all languages show the proper
text; locate the usage in the Checkout component where t is already
imported/used and update the string there.
- Around line 76-95: The Paddle polling loop in the useEffect that calls
loadScript(PADDLE_JS_URL) needs an upper bound so it doesn’t poll forever; add a
max-attempt counter or timeout inside the effect that increments on each
interval tick and clears the interval when exceeded, then set an error/loading
state (e.g. introduce setPaddleLoadError or setIsPaddleLoaded(false)) and stop
calling (window as any).Paddle.Setup or setHasCompletedCheckout; ensure the
created interval is always cleared on success or timeout and that the timeout
branch surfaces an error state to the UI.

In `@web/app/routes/checkout.tsx`:
- Around line 40-53: The current flow calls redirectIfNotAuthenticated then uses
getAuthenticatedUser, but if getAuthenticatedUser returns null we incorrectly
treat the user as unauthenticated-onboarded and redirect to /onboarding with
stale cookies; change the logic in the checkout loader to detect when authResult
is null (or authResult.user is missing) and instead redirect to '/login' while
clearing auth cookies (use the same cookie-clearing/header behavior as
redirectIfNotAuthenticated), i.e., when authResult is null and cookies exist
send redirect('/login', { headers: createHeadersWithCookies([]) }) or the
equivalent cookie-clear headers, otherwise redirect to '/login' without cookies.
- Line 26: The checkout route is using the signup i18n key; update the call to
getDescription so it uses the checkout key (replace t('description.signup') with
t('description.checkout') in the checkout route where getDescription(...) is
invoked) and add the corresponding "description.checkout" entry to your locale
files so the meta description renders correctly for the checkout page.

In `@web/app/routes/login.tsx`:
- Around line 43-44: The loader's plan-check is missing the 'trial' value and
should be updated to match the 2FA and regular login checks; in the loader
function (where isSelfhosted and user.planCode are evaluated) include planCode
=== 'trial' in the redirect-to-checkout condition (or use a single includes
check like ['none','trial'].includes(user.planCode)) so authenticated users on a
'trial' plan are redirected to '/checkout' consistently.

In `@web/public/locales/en.json`:
- Around line 2182-2194: The subtitle at onboarding.selectPlan.subtitle
currently hardcodes "14-day" and should use interpolation to match other
strings; update the JSON value to include an interpolation placeholder (e.g.,
"{{days}}") instead of the literal "14-day" and ensure the code that renders
this string passes the TRIAL_DAYS value (same source used by
checkout.subtitle/checkout.freeTrialAnytime) so the subtitle displays the
runtime TRIAL_DAYS consistently.

---

Outside diff comments:
In `@backend/apps/cloud/src/user/user.service.ts`:
- Around line 440-443: isPaidTier currently constructs the array using the comma
operator which collapses to only PlanCode.trial; change the array expression in
isPaidTier to list the enum values directly (e.g. [PlanCode.none, PlanCode.free,
PlanCode.trial]) so that the includes check correctly tests user.planCode,
preserving the existing negation (!...includes(...)) and returning true only for
paid plans; update the array literal where isPaidTier is defined to remove the
parentheses and commas misuse.

In `@web/app/pages/Onboarding/Onboarding.tsx`:
- Around line 1177-1179: The code in the Onboarding component uses a hardcoded
string 'Please, verify your email address first' instead of the i18n helper;
find the email-verification check in Onboarding.tsx (inside the Onboarding
component / the submit or continue handler where that return occurs) and replace
the literal with the localized call using the same translator used elsewhere in
the file (e.g., t('Please, verify your email address first') or preferably a new
key like t('onboarding.verifyEmailFirst')), adding the key to the translation
resource files as needed so the message is localized.

In `@web/public/locales/en.json`:
- Around line 554-558: Update the FAQ answer for the "Is there a free trial?"
item in the locales file: change the translation value associated with the "q":
"Is there a free trial?" entry so it no longer says "no credit card required"
and instead states that a trial is available for {{freeTrialDays}} days but
requires credit card details (or similar phrasing). Locate the object containing
that question/answer pair in web/public/locales/en.json and replace the "a"
value accordingly to reflect the paid-trial flow.

---

Duplicate comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 71-74: The cookie assignments inside the useEffect that set
document.cookie for swetrix_selected_plan and swetrix_selected_billing
(controlled by selectedPlan and selectedBillingFrequency) should include the
Secure flag (and optionally SameSite=Lax) so they are only sent over HTTPS;
update the two document.cookie strings in Checkout.tsx (the useEffect block) to
append "; Secure" (and consider adding "; SameSite=Lax") to each cookie
assignment.

---

Nitpick comments:
In `@backend/apps/cloud/src/user/dto/cancel-subscription.dto.ts`:
- Around line 4-10: The feedback field on CancelSubscriptionDTO can be
whitespace-only and pass `@IsString/`@MaxLength, so add a `@Transform` to trim the
incoming value (e.g., transform to value?.trim()) and then ensure validation
treats blank trimmed strings as absent by keeping `@IsOptional`() and adding
`@IsNotEmpty`() (so empty strings fail validation or are skipped); alternatively
convert trimmed empty string to undefined in the `@Transform` to let `@IsOptional`()
skip it. Update the decorators on the feedback property in CancelSubscriptionDTO
accordingly and ensure the transform runs before validators.

In `@backend/apps/cloud/src/user/user.controller.ts`:
- Around line 739-784: The cancellation feedback flow persists PII (user email)
via userService.saveCancellationFeedback called from cancelSubscription; ensure
cancellationFeedback records are cleaned or anonymised on account deletion by
updating the deleteSelf flow (and related service/repository) to either remove
associated cancellationFeedback rows or replace the email with an anonymised
token/timestamp; alternatively, stop storing email by changing
saveCancellationFeedback to persist only non-PII (feedback text, planCode,
timestamp) and remove the email column and any usages; update the UserService
methods (saveCancellationFeedback, cancelSubscription, deleteSelf) and
corresponding DB migration/repository code and tests to reflect the chosen
approach.

In `@backend/migrations/mysql/2026_02_22_trial_with_cc.sql`:
- Around line 1-10: Add a down/rollback migration that reverts the changes made:
for the ALTER TABLE on the user table revert the onboardingStep enum back to its
previous exact definition (update the ALTER TABLE `user` MODIFY COLUMN
`onboardingStep` ... back to the original enum values) and drop the newly
created cancellation_feedback table (DROP TABLE `cancellation_feedback`); if you
don’t have the prior enum values, document the exact prior enum set in the
migration comment or source control so the down migration can use the precise
enum list when implementing the rollback.
- Around line 3-10: The new cancellation_feedback table lacks indexes on email
and createdAt which will cause full table scans for common queries; update the
migration that creates `cancellation_feedback` (or add a follow-up ALTER TABLE)
to add an index on the `email` column (e.g., `idx_cancellation_feedback_email`)
and an index on the `createdAt` column (e.g.,
`idx_cancellation_feedback_created_at`) so queries like "feedback by user" and
date-range scans use indexes; ensure the index names are unique and use the same
col names `email` and `createdAt` as defined in the CREATE TABLE.

In `@web/app/pages/Auth/Signin/Signin.tsx`:
- Around line 230-239: Extract the duplicated planCode redirect logic into a
single helper function (e.g. getPostAuthRoute(user, isSelfhosted)) that returns
either routes.checkout or routes.dashboard based on user.planCode and
isSelfhosted; replace the repeated conditionals in Signin.tsx (the navigate(...)
branches) and the similar blocks in login.tsx with
navigate(getPostAuthRoute(user, isSelfhosted)); ensure the helper is
exported/usable where needed and update any call sites to pass the existing user
and isSelfhosted variables so future planCode changes are handled in one place.

In `@web/app/pages/Auth/Signup/Signup.tsx`:
- Around line 202-206: After SSO sign-up, the post-sign-up navigation currently
sends users to navigate(routes.dashboard) whenever user.hasCompletedOnboarding
is true, which causes an immediate redirect for users with user.planCode ===
'none'; update the logic in the Signup post-sign-up block to check user.planCode
(e.g., user.planCode === 'none') and, when onboarding is complete but planCode
is 'none', navigate directly to the checkout route (navigate(routes.checkout))
instead of routes.dashboard; keep the existing navigate(routes.onboarding)
branch for hasCompletedOnboarding === false.

In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 199-208: The "-17%" discount label is hardcoded in Checkout.tsx
and can drift from actual prices; update the UI to compute the discount
dynamically (or use a named constant) instead of the literal string: derive
percentage = (monthlyPrice * 12 - yearlyPrice) / (monthlyPrice * 12) * 100 (or
define DISCOUNT_PERCENT = ... in the same module) and render that computed value
in place of the "-17%" span; reference the discount span and the
selectedBillingFrequency/Switch UI so the displayed discount always reflects the
monthlyPrice and yearlyPrice values used by the component.

In `@web/app/routes/checkout.tsx`:
- Line 40: The call to redirectIfNotAuthenticated(request) returns Response|null
but its value is ignored; either explicitly mark the discard by prefixing the
call with void (void redirectIfNotAuthenticated(request)) to signal intentional
drop, or remove the call entirely and rely on getAuthenticatedUser(request) to
handle auth by checking its result (authResult) and performing a redirect/throw
when null; update the code in checkout.tsx around redirectIfNotAuthenticated and
getAuthenticatedUser to use one of these approaches and remove the silent-return
confusion.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 4571d9b and 9a85c8b.

📒 Files selected for processing (28)
  • admin/src/entities/user.entity.ts
  • backend/apps/cloud/src/common/templates/en/customer_sign-up.html
  • backend/apps/cloud/src/common/templates/en/trial-ends-tomorrow.html
  • backend/apps/cloud/src/mailer/mailer.service.ts
  • backend/apps/cloud/src/task-manager/task-manager.service.ts
  • backend/apps/cloud/src/user/dto/cancel-subscription.dto.ts
  • backend/apps/cloud/src/user/entities/cancellation-feedback.entity.ts
  • backend/apps/cloud/src/user/entities/user.entity.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_02_22_trial_with_cc.sql
  • web/app/components/Header/index.tsx
  • web/app/pages/Auth/Signin/Signin.tsx
  • web/app/pages/Auth/Signup/Signup.tsx
  • web/app/pages/Checkout/Checkout.tsx
  • web/app/pages/Checkout/index.tsx
  • web/app/pages/Onboarding/Onboarding.tsx
  • web/app/pages/UserSettings/UserSettings.tsx
  • web/app/routes/checkout.tsx
  • web/app/routes/dashboard.tsx
  • web/app/routes/login.tsx
  • web/app/routes/onboarding.tsx
  • web/app/routes/signup.tsx
  • web/app/routes/user-settings.tsx
  • web/app/utils/routes.ts
  • web/public/locales/en.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

♻️ Duplicate comments (2)
web/app/pages/Checkout/Checkout.tsx (2)

298-299: “Redirecting…” is still hardcoded (already raised).

Please replace with a translation key and update locale files accordingly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 298 - 299, Replace the
hardcoded "Redirecting..." literal inside the Text element with a translation
lookup (use the existing i18n t function) instead of plain text; for example,
call t('checkout.redirecting') where the current hardcoded string appears
alongside t('common.success'), and add a matching key "checkout.redirecting" to
your locale files (e.g., en.json and any other supported locales) with the
appropriate translated strings.

62-78: Paddle polling still lacks a timeout (already raised).

This keeps polling forever if the script never loads (ad‑blockers/network errors). Please apply the previously suggested max‑attempt/timeout with an error state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 62 - 78, The polling loop
in useEffect that calls loadScript(PADDLE_JS_URL) and checks (window as
any)?.Paddle currently never times out; add a max-attempts or timeout mechanism
to stop polling and set an error state (e.g., setPaddleLoadError /
setIsPaddleLoaded(false)) if Paddle doesn't appear within the limit, ensure you
clearInterval(interval) on both success (where you call (window as
any).Paddle.Setup with PADDLE_VENDOR_ID and eventCallback that sets
setHasCompletedCheckout) and on timeout, and log or surface the failure so the
UI can show an error instead of polling forever.
🧹 Nitpick comments (1)
web/app/pages/Checkout/Checkout.tsx (1)

55-60: Avoid a hardcoded “-17%” badge.

If pricing changes or varies by plan/currency, this can become inaccurate. Compute it from monthly/yearly prices (or hide if unavailable).

♻️ Suggested refactor
   const displayPrice =
     selectedBillingFrequency === 'yearly' ? yearlyPrice : monthlyPrice
+  const yearlyDiscountPct =
+    monthlyPrice && yearlyPrice
+      ? Math.round((1 - yearlyPrice / (monthlyPrice * 12)) * 100)
+      : null
-                <span className='rounded-md bg-emerald-100 px-1.5 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'>
-                  -17%
-                </span>
+                {yearlyDiscountPct && yearlyDiscountPct > 0 ? (
+                  <span className='rounded-md bg-emerald-100 px-1.5 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-300'>
+                    -{yearlyDiscountPct}%
+                  </span>
+                ) : null}

Also applies to: 185-190

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 55 - 60, The UI currently
shows a hardcoded “-17%” badge; instead compute a dynamic discount using
monthlyPrice and yearlyPrice (both defined above) by comparing (monthlyPrice *
12) to yearlyPrice and deriving percent = round((1 - yearlyPrice / (monthlyPrice
* 12)) * 100), and only render the badge when both prices are present and
percent > 0 (or hide it if prices are unavailable); update any other occurrences
(e.g., around lines 185-190) that currently show the hardcoded value to use this
computed percent and ensure selectedBillingFrequency/displayPrice logic remains
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 93-95: Replace the hardcoded toast message inside the
payment-loading guard in Checkout.tsx (the block checking isPaddleLoaded and
(window as any).Paddle) with a localized string using the t(...) i18n function
(e.g. t('checkout.paymentLoading')), and ensure the component has access to the
t function (via the existing i18n hook or import) and that a corresponding
translation key (checkout.paymentLoading) is added to the locales; update the
toast.error call to use t(...) instead of the literal string so the message is
localizable.
- Around line 32-34: formatEventsLong currently hardcodes 'en-US' which forces
US number formatting; change it to use the component's locale (i18n.language)
instead — either by making formatEventsLong accept a locale parameter (e.g.
formatEventsLong(value, locale)) and passing i18n.language from the Checkout
component, or by reading i18n.language inside the function via the
useTranslation hook; ensure you keep a sensible fallback (like 'en-US') if
i18n.language is undefined; update all call sites to pass the locale if you
choose the parameter approach and leave INITIAL_VISIBLE_PLANS unchanged.
- Around line 92-118: handleStartCheckout sets setIsCheckoutOpen(true) before
calling (window as any).Paddle.Checkout.open, which can throw and leave the UI
stuck; wrap the Paddle.Checkout.open call in a try-catch inside
handleStartCheckout, call setIsCheckoutOpen(false) in the catch (and optionally
after a failed open), show an error toast (e.g., toast.error with the caught
error.message) so the button/modal can be retried, and only keep
setIsCheckoutOpen(true) if the open call succeeds; reference
handleStartCheckout, setIsCheckoutOpen, and (window as any).Paddle.Checkout.open
when making the change.

---

Duplicate comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 298-299: Replace the hardcoded "Redirecting..." literal inside the
Text element with a translation lookup (use the existing i18n t function)
instead of plain text; for example, call t('checkout.redirecting') where the
current hardcoded string appears alongside t('common.success'), and add a
matching key "checkout.redirecting" to your locale files (e.g., en.json and any
other supported locales) with the appropriate translated strings.
- Around line 62-78: The polling loop in useEffect that calls
loadScript(PADDLE_JS_URL) and checks (window as any)?.Paddle currently never
times out; add a max-attempts or timeout mechanism to stop polling and set an
error state (e.g., setPaddleLoadError / setIsPaddleLoaded(false)) if Paddle
doesn't appear within the limit, ensure you clearInterval(interval) on both
success (where you call (window as any).Paddle.Setup with PADDLE_VENDOR_ID and
eventCallback that sets setHasCompletedCheckout) and on timeout, and log or
surface the failure so the UI can show an error instead of polling forever.

---

Nitpick comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 55-60: The UI currently shows a hardcoded “-17%” badge; instead
compute a dynamic discount using monthlyPrice and yearlyPrice (both defined
above) by comparing (monthlyPrice * 12) to yearlyPrice and deriving percent =
round((1 - yearlyPrice / (monthlyPrice * 12)) * 100), and only render the badge
when both prices are present and percent > 0 (or hide it if prices are
unavailable); update any other occurrences (e.g., around lines 185-190) that
currently show the hardcoded value to use this computed percent and ensure
selectedBillingFrequency/displayPrice logic remains unchanged.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9a85c8b and dbf4fbf.

📒 Files selected for processing (2)
  • backend/migrations/mysql/2026_02_22_trial_with_cc.sql
  • web/app/pages/Checkout/Checkout.tsx
🚧 Files skipped from review as they are similar to previous changes (1)
  • backend/migrations/mysql/2026_02_22_trial_with_cc.sql

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

♻️ Duplicate comments (2)
web/app/pages/Checkout/Checkout.tsx (2)

290-290: ⚠️ Potential issue | 🟡 Minor

Hardcoded English string "Redirecting..." — use i18n.

The rest of the component uses t(...) for translations.

Proposed fix
-                    {t('common.success')}! Redirecting...
+                    {t('common.success')}! {t('common.redirecting')}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` at line 290, Replace the hardcoded
"Redirecting..." string with a call to the i18n translator used in this
component (e.g., use t('common.redirecting') or t('checkout.redirecting'))
inside the JSX in Checkout (Checkout.tsx); also add the corresponding key and
translations to your locale JSON files so the text is available in all
languages.

97-110: ⚠️ Potential issue | 🟠 Major

Wrap Paddle.Checkout.open() in a try-catch to prevent stuck UI.

setIsCheckoutOpen(true) runs before the Paddle call. If Paddle.Checkout.open() throws, the button stays hidden and users can't retry. This was flagged previously and appears to still be unaddressed.

Proposed fix
     setIsCheckoutOpen(true)
+    try {
     ;(window as any).Paddle.Checkout.open({
       product,
       method: 'inline',
       frameTarget: 'checkout-container',
       frameInitialHeight: 416,
       frameStyle:
         'width:100%; min-width:312px; background-color: transparent; border: none; border-radius: 10px;',
       email: user?.email,
       passthrough: JSON.stringify({ uid: user?.id }),
       locale: paddleLanguageMapping[i18n.language] || i18n.language,
       displayModeTheme: theme,
       country: metainfo.country,
     })
+    } catch (err) {
+      setIsCheckoutOpen(false)
+      toast.error(t('billing.paddleLoadError'))
+      return
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 97 - 110, The call to
Paddle.Checkout.open is unprotected so if it throws after
setIsCheckoutOpen(true) the UI stays in "open" state; wrap the
Paddle.Checkout.open(...) invocation inside a try-catch around the existing call
in Checkout.tsx (the block that calls setIsCheckoutOpen(true) and (window as
any).Paddle.Checkout.open) and in the catch setIsCheckoutOpen(false) (or
otherwise restore UI state) and log or surface the error so the button becomes
usable again; ensure you only wrap the Paddle.Checkout.open call (leave
setIsCheckoutOpen call placement intact) and reference the same
paddleLanguageMapping, passthrough and props when invoking the call inside the
try block.
🧹 Nitpick comments (1)
web/app/pages/UserSettings/UserSettings.tsx (1)

1803-1811: Raw <textarea> inconsistent with the rest of the file.

The account-deletion modal at Line 1728 uses the project's <Textarea> component, but this cancellation feedback uses a raw <textarea> with inline Tailwind classes. Consider using the <Textarea> component for visual consistency and to inherit any shared styling/behavior (dark mode, focus rings, etc.).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/UserSettings/UserSettings.tsx` around lines 1803 - 1811,
Replace the raw HTML <textarea> used for cancellation feedback with the
project's <Textarea> component to ensure consistent styling/behavior (dark mode,
focus rings, etc.); in the UserSettings component update the element at the
cancellation-feedback id to use <Textarea> and pass the same props
(id='cancellation-feedback', rows={3},
placeholder={t('billing.cancellationFeedbackPlaceholder')},
value={cancellationFeedback}, onChange={(e) =>
setCancellationFeedback(e.target.value)}, disabled={isCancellingSubscription})
and remove the inline Tailwind className so the shared <Textarea> styling is
applied.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@web/app/hooks/usePaddle.ts`:
- Around line 24-51: The usePaddle hook currently calls Paddle.Setup per
instance which allows later mounts to overwrite the global Paddle eventCallback;
change usePaddle to a singleton pattern by moving setup logic out of the
instance effect into module-level initialization that calls
loadScript(PADDLE_JS_URL) once and calls (window as any).Paddle.Setup only once;
maintain a module-level array of listeners and expose subscribe/unsubscribe
functions that use onEventRef (or similar) so each hook instance registers its
handler and receives events; ensure setIsPaddleLoaded and setPaddleLoadError are
driven by the shared initialization state so individual useEffect hooks only
subscribe/unsubscribe instead of re-calling Paddle.Setup.

In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 92-95: The code uses unsafe casts (tier as any).pid/ypid to set
product which can be undefined and break Paddle.Checkout.open; update the
product assignment in Checkout.tsx to perform a runtime guard on tier for the
expected properties (check selectedBillingFrequency === BillingFrequency.monthly
? 'pid' in tier && typeof (tier as any).pid === 'string' : 'ypid' in tier &&
typeof (tier as any).ypid === 'string'), then set product to that value only if
present; if missing, log or surface an error and avoid calling
Paddle.Checkout.open (or use a safe fallback SKU), and update the Tier type (or
make pid/ypid optional) so the compiler reflects the runtime check (referencing
product, selectedBillingFrequency, BillingFrequency, tier, pid, ypid, and the
Paddle.Checkout.open call).
- Around line 119-121: The page currently recalculates trialEndDate client-side
using TRIAL_DAYS (variables trialEndDate and today); instead, read and use the
server-provided value user.trialEndDate when available. Update the Checkout
component to set trialEndDate = new Date(user.trialEndDate) if
user?.trialEndDate exists, and only fall back to computing today + TRIAL_DAYS
when the server value is absent; ensure you reference the existing trialEndDate
and TRIAL_DAYS symbols and keep date conversion via new Date(...) to preserve
correct formatting.
- Around line 50-54: The onPaddleEvent handler only handles 'Checkout.Complete'
so closing the Paddle modal leaves isCheckoutOpen true; update the onPaddleEvent
function to also handle eventData.event === 'Checkout.Close' and call
setIsCheckoutOpen(false) (and optionally reset setHasCompletedCheckout(false) if
you want to clear completion state), and add setIsCheckoutOpen (and
setHasCompletedCheckout if used) to the useCallback dependency array so the
handler uses the latest setters; the symbols to change are onPaddleEvent,
setIsCheckoutOpen, and setHasCompletedCheckout.

---

Duplicate comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Line 290: Replace the hardcoded "Redirecting..." string with a call to the
i18n translator used in this component (e.g., use t('common.redirecting') or
t('checkout.redirecting')) inside the JSX in Checkout (Checkout.tsx); also add
the corresponding key and translations to your locale JSON files so the text is
available in all languages.
- Around line 97-110: The call to Paddle.Checkout.open is unprotected so if it
throws after setIsCheckoutOpen(true) the UI stays in "open" state; wrap the
Paddle.Checkout.open(...) invocation inside a try-catch around the existing call
in Checkout.tsx (the block that calls setIsCheckoutOpen(true) and (window as
any).Paddle.Checkout.open) and in the catch setIsCheckoutOpen(false) (or
otherwise restore UI state) and log or surface the error so the button becomes
usable again; ensure you only wrap the Paddle.Checkout.open call (leave
setIsCheckoutOpen call placement intact) and reference the same
paddleLanguageMapping, passthrough and props when invoking the call inside the
try block.

---

Nitpick comments:
In `@web/app/pages/UserSettings/UserSettings.tsx`:
- Around line 1803-1811: Replace the raw HTML <textarea> used for cancellation
feedback with the project's <Textarea> component to ensure consistent
styling/behavior (dark mode, focus rings, etc.); in the UserSettings component
update the element at the cancellation-feedback id to use <Textarea> and pass
the same props (id='cancellation-feedback', rows={3},
placeholder={t('billing.cancellationFeedbackPlaceholder')},
value={cancellationFeedback}, onChange={(e) =>
setCancellationFeedback(e.target.value)}, disabled={isCancellingSubscription})
and remove the inline Tailwind className so the shared <Textarea> styling is
applied.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between dbf4fbf and f0fc2ea.

📒 Files selected for processing (4)
  • web/app/hooks/usePaddle.ts
  • web/app/pages/Checkout/Checkout.tsx
  • web/app/pages/UserSettings/UserSettings.tsx
  • web/public/locales/en.json
🚧 Files skipped from review as they are similar to previous changes (1)
  • web/public/locales/en.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

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

⚠️ Outside diff range comments (1)
web/public/locales/en.json (1)

556-558: ⚠️ Potential issue | 🟡 Minor

FAQ text contradicts the new paid-trial checkout flow.

The FAQ answer states "no credit card required!" but the new checkout flow (checkout.title, onboarding.selectPlan.subtitle, billing.trialChargeWarning) explicitly requires payment details upfront and warns about automatic charges. This will confuse users.

The same issue exists at line 2074 in pricing.adv: "No credit card required. No strings attached."

Both strings should be updated to reflect the paid-trial model (e.g., "Your card won't be charged during the trial").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/public/locales/en.json` around lines 556 - 558, Update the FAQ and
pricing copy to reflect the paid-trial checkout flow: change the FAQ answer for
the entry with q "Is there a free trial?" (key under the FAQ block shown) to
indicate the card is collected but not charged during the trial (e.g., "Your
card won't be charged during the {{freeTrialDays}}-day trial; you'll be billed
automatically afterward unless you cancel"), and also update the "pricing.adv"
string that currently reads "No credit card required. No strings attached." to
the same paid-trial wording; ensure the wording aligns with related keys
checkout.title, onboarding.selectPlan.subtitle, and billing.trialChargeWarning
so messaging is consistent about collecting payment details upfront but not
charging during the trial.
♻️ Duplicate comments (5)
web/app/pages/Checkout/Checkout.tsx (3)

120-122: ⚠️ Potential issue | 🟡 Minor

Trial end date is recalculated client-side instead of using the server value.

For users who revisit the checkout page after their trial has already started, today + TRIAL_DAYS will show a later date than the actual trial end. The server provides user.trialEndDate — prefer that when available, falling back to the local calculation for new users.

- const today = new Date()
- const trialEndDate = new Date()
- trialEndDate.setDate(today.getDate() + TRIAL_DAYS)
+ const trialEndDate = user?.trialEndDate
+   ? new Date(user.trialEndDate)
+   : (() => {
+       const d = new Date()
+       d.setDate(d.getDate() + TRIAL_DAYS)
+       return d
+     })()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 120 - 122, Replace the
client-only calculation of trialEndDate with the server-provided value when
available: if user.trialEndDate exists, parse it (e.g., new
Date(user.trialEndDate)) and use that for trialEndDate; otherwise fall back to
the existing calculation using today and TRIAL_DAYS. Update the code that
currently defines today, trialEndDate (and any downstream uses) to prefer parsed
user.trialEndDate to ensure revisiting users see the real trial end date.

93-111: ⚠️ Potential issue | 🟠 Major

No error handling around Paddle.Checkout.open() — UI can get stuck.

Two unaddressed issues from prior review remain here:

  1. No product guard: (tier as any).pid / (tier as any).ypid can be undefined if the plan tier doesn't carry these Paddle IDs, causing a silent failure or exception.

  2. No try-catch: If Paddle.Checkout.open() throws, isCheckoutOpen remains true and the button stays hidden.

Proposed fix
     const product =
       selectedBillingFrequency === BillingFrequency.monthly
         ? (tier as any).pid
         : (tier as any).ypid
 
+    if (!product) {
+      toast.error(t('apiNotifications.somethingWentWrong'))
+      return
+    }
+
     setIsCheckoutOpen(true)
-    ;(window as any).Paddle.Checkout.open({
+    try {
+      ;(window as any).Paddle.Checkout.open({
       ...
-    })
+      })
+    } catch (err) {
+      setIsCheckoutOpen(false)
+      toast.error(t('billing.paddleLoadError'))
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 93 - 111, The code
currently sets setIsCheckoutOpen(true) before calling (window as
any).Paddle.Checkout.open and uses (tier as any).pid / ypid without validating
them; add a guard that resolves the correct productId from tier (use
selectedBillingFrequency, BillingFrequency.monthly, and tier.pid / tier.ypid)
and if productId is falsy return early and do not change UI, then wrap
Paddle.Checkout.open(...) in a try/catch/finally so setIsCheckoutOpen(true) is
only set if open succeeds (or set it optimistically but ensure in catch you
setIsCheckoutOpen(false) and log the error), and include safe fallback logging
for errors to avoid leaving the checkout button hidden; target symbols: product
variable, setIsCheckoutOpen, Paddle.Checkout.open, selectedBillingFrequency,
BillingFrequency.monthly, and tier.pid/ypid.

51-55: ⚠️ Potential issue | 🟠 Major

Missing Checkout.Close event handler — user gets stuck if they dismiss the Paddle modal.

When the user closes the Paddle overlay without completing payment, isCheckoutOpen stays true, which hides the "Start free trial" button permanently. Handle the close event to reset state:

 const onPaddleEvent = useCallback((eventData: any) => {
   if (eventData?.event === 'Checkout.Complete') {
     setHasCompletedCheckout(true)
+  } else if (eventData?.event === 'Checkout.Close') {
+    setIsCheckoutOpen(false)
   }
 }, [])
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 51 - 55, The onPaddleEvent
callback only handles 'Checkout.Complete' and doesn't reset UI when the user
dismisses the Paddle modal; update onPaddleEvent to also handle eventData.event
=== 'Checkout.Close' and in that branch call the state setters to revert the
checkout UI (e.g., setIsCheckoutOpen(false) and setHasCompletedCheckout(false)
or whatever the local setters are named) so the "Start free trial" button is
shown again after the overlay is closed; keep both branches inside the same
useCallback (onPaddleEvent) so the component responds to both complete and close
events.
web/app/routes/checkout.tsx (1)

45-54: ⚠️ Potential issue | 🟠 Major

Null user still incorrectly redirected to /onboarding — potential redirect loop.

When authResult?.user?.user is null (e.g. expired tokens that passed the shallow check), !user?.hasCompletedOnboarding evaluates to true, sending the user to /onboarding. If /onboarding performs a similar auth check and redirects back, this creates a loop.

The safe fallback is to redirect to /login and clear stale cookies when user is null:

  const user = authResult?.user?.user

+ if (!user) {
+   return redirect('/login', {
+     headers: cookies.length > 0 ? createHeadersWithCookies(cookies) : undefined,
+   })
+ }
+
- if (!user?.hasCompletedOnboarding) {
+ if (!user.hasCompletedOnboarding) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/routes/checkout.tsx` around lines 45 - 54, The current logic treats a
null authResult?.user?.user as an incomplete onboarding and redirects to
'/onboarding', which can cause a redirect loop; update the check in the checkout
route to explicitly handle a null user: if user is null (authResult?.user?.user
=== null) then redirect to '/login' and clear stale cookies via
createHeadersWithCookies([]) (or equivalent) instead of sending them to
'/onboarding'; only fall back to redirect('/onboarding') when user exists but
hasCompletedOnboarding is false.
web/public/locales/en.json (1)

2185-2197: Previous hardcoded "14-day" issue is resolved — uses {{days}} interpolation now.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/public/locales/en.json` around lines 2185 - 2197, The hardcoded "14-day"
text was replaced with interpolation "{{days}}" in the selectPlan block; confirm
the change is applied to selectPlan.title/subtitle and timeline keys
(timeline.todayDesc, timeline.reminder, timeline.reminderDesc, timeline.charge,
timeline.chargeDesc) across all locale files and update any remaining duplicates
or stale entries, then run your i18n extraction/validation step to ensure no
translation keys expect the old hardcoded string before merging.
🧹 Nitpick comments (1)
web/app/pages/Checkout/Checkout.tsx (1)

178-183: Hardcoded "-17%" discount badge will drift if pricing changes.

The yearly discount percentage is hardcoded in the UI. If plan prices are updated, this label won't reflect the actual savings. Consider computing it from the monthly/yearly price data, or at minimum extracting it as a named constant.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Checkout/Checkout.tsx` around lines 178 - 183, The "-17%" badge
is hardcoded and will become incorrect if prices change; replace it by computing
the percentage savings from the monthly and yearly price values used to render
the page (e.g., compute savings = Math.round((1 - yearlyPrice / (monthlyPrice *
12)) * 100) and format as `-{savings}%`) and render that result instead of the
literal "-17%". Update the Checkout component code that renders the badge (the
span next to t('pricing.billedYearly')) to read the monthly/yearly price
variables (or pricing.monthly/pricing.yearly) and fall back to a named constant
only if those values are unavailable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@web/public/locales/en.json`:
- Around line 556-558: Update the FAQ and pricing copy to reflect the paid-trial
checkout flow: change the FAQ answer for the entry with q "Is there a free
trial?" (key under the FAQ block shown) to indicate the card is collected but
not charged during the trial (e.g., "Your card won't be charged during the
{{freeTrialDays}}-day trial; you'll be billed automatically afterward unless you
cancel"), and also update the "pricing.adv" string that currently reads "No
credit card required. No strings attached." to the same paid-trial wording;
ensure the wording aligns with related keys checkout.title,
onboarding.selectPlan.subtitle, and billing.trialChargeWarning so messaging is
consistent about collecting payment details upfront but not charging during the
trial.

---

Duplicate comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 120-122: Replace the client-only calculation of trialEndDate with
the server-provided value when available: if user.trialEndDate exists, parse it
(e.g., new Date(user.trialEndDate)) and use that for trialEndDate; otherwise
fall back to the existing calculation using today and TRIAL_DAYS. Update the
code that currently defines today, trialEndDate (and any downstream uses) to
prefer parsed user.trialEndDate to ensure revisiting users see the real trial
end date.
- Around line 93-111: The code currently sets setIsCheckoutOpen(true) before
calling (window as any).Paddle.Checkout.open and uses (tier as any).pid / ypid
without validating them; add a guard that resolves the correct productId from
tier (use selectedBillingFrequency, BillingFrequency.monthly, and tier.pid /
tier.ypid) and if productId is falsy return early and do not change UI, then
wrap Paddle.Checkout.open(...) in a try/catch/finally so setIsCheckoutOpen(true)
is only set if open succeeds (or set it optimistically but ensure in catch you
setIsCheckoutOpen(false) and log the error), and include safe fallback logging
for errors to avoid leaving the checkout button hidden; target symbols: product
variable, setIsCheckoutOpen, Paddle.Checkout.open, selectedBillingFrequency,
BillingFrequency.monthly, and tier.pid/ypid.
- Around line 51-55: The onPaddleEvent callback only handles 'Checkout.Complete'
and doesn't reset UI when the user dismisses the Paddle modal; update
onPaddleEvent to also handle eventData.event === 'Checkout.Close' and in that
branch call the state setters to revert the checkout UI (e.g.,
setIsCheckoutOpen(false) and setHasCompletedCheckout(false) or whatever the
local setters are named) so the "Start free trial" button is shown again after
the overlay is closed; keep both branches inside the same useCallback
(onPaddleEvent) so the component responds to both complete and close events.

In `@web/app/routes/checkout.tsx`:
- Around line 45-54: The current logic treats a null authResult?.user?.user as
an incomplete onboarding and redirects to '/onboarding', which can cause a
redirect loop; update the check in the checkout route to explicitly handle a
null user: if user is null (authResult?.user?.user === null) then redirect to
'/login' and clear stale cookies via createHeadersWithCookies([]) (or
equivalent) instead of sending them to '/onboarding'; only fall back to
redirect('/onboarding') when user exists but hasCompletedOnboarding is false.

In `@web/public/locales/en.json`:
- Around line 2185-2197: The hardcoded "14-day" text was replaced with
interpolation "{{days}}" in the selectPlan block; confirm the change is applied
to selectPlan.title/subtitle and timeline keys (timeline.todayDesc,
timeline.reminder, timeline.reminderDesc, timeline.charge, timeline.chargeDesc)
across all locale files and update any remaining duplicates or stale entries,
then run your i18n extraction/validation step to ensure no translation keys
expect the old hardcoded string before merging.

---

Nitpick comments:
In `@web/app/pages/Checkout/Checkout.tsx`:
- Around line 178-183: The "-17%" badge is hardcoded and will become incorrect
if prices change; replace it by computing the percentage savings from the
monthly and yearly price values used to render the page (e.g., compute savings =
Math.round((1 - yearlyPrice / (monthlyPrice * 12)) * 100) and format as
`-{savings}%`) and render that result instead of the literal "-17%". Update the
Checkout component code that renders the badge (the span next to
t('pricing.billedYearly')) to read the monthly/yearly price variables (or
pricing.monthly/pricing.yearly) and fall back to a named constant only if those
values are unavailable.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f0fc2ea and 303b6b2.

📒 Files selected for processing (3)
  • web/app/pages/Checkout/Checkout.tsx
  • web/app/routes/checkout.tsx
  • web/public/locales/en.json

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
web/app/routes/login.tsx (1)

43-44: Missing 'trial' planCode in all three redirect checks.

All three redirect paths (loader line 43, 2FA action line 118, regular login line 171) only gate on 'none', but 'trial' is the exact plan code this "paid trials" PR introduces. If the intent is that trial-plan users must also complete checkout, all three conditions need to include it. They are at least consistently omitting it, but that consistency may itself be a bug.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/routes/login.tsx` around lines 43 - 44, The redirect checks in
web/app/routes/login.tsx (the loader, the 2FA action, and the regular login
flow) currently only treat user.planCode === 'none' as requiring checkout; add
the 'trial' plan to those gates so trial users are redirected as well (e.g.,
change the condition that checks user.planCode === 'none' to also test for
user.planCode === 'trial' or test for an allowedPlans set). Update the three
locations referenced (the loader, the 2FA action, and the normal login branch)
to include the 'trial' case in their redirect logic to /checkout.
web/app/pages/Auth/Signin/Signin.tsx (1)

230-234: Missing 'trial' planCode — same gap as in login.tsx.

Both SSO-login and account-linking redirect branches apply the same !user.planCode || user.planCode === 'none' condition, which is also missing 'trial'. This is consistent with login.tsx but the omission is duplicated here too.

Also applies to: 311-315

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/Auth/Signin/Signin.tsx` around lines 230 - 234, The redirect
condition in Signin.tsx that sends users to routes.checkout treats missing plans
and 'none' but omits 'trial'; update the conditional used in the
SSO-login/account-linking branches (the block around the
navigate(routes.checkout) check) to also consider user.planCode === 'trial'
(e.g., change !user.planCode || user.planCode === 'none' to include ||
user.planCode === 'trial'), and make the same change in the duplicate location
later in the file (the second occurrence around lines 311-315) so both branches
handle 'trial' consistently.
🧹 Nitpick comments (1)
web/app/routes/login.tsx (1)

41-45: Extract the repeated redirect-decision logic into a shared helper.

The same three-branch hasCompletedOnboarding → planCode → dashboard decision is duplicated three times in this file and twice more in Signin.tsx (lines 228–237 and 309–318), making future plan-code additions error-prone (as the missing 'trial' check already illustrates).

♻️ Proposed helper (add to a shared util, e.g. ~/utils/auth.ts)
+// ~/utils/auth.ts
+import { isSelfhosted } from '~/lib/constants'
+
+const PAID_PLAN_CODES = new Set(['none', 'trial'])
+
+export function getPostLoginRedirect(user: {
+  hasCompletedOnboarding: boolean
+  planCode?: string
+}): '/onboarding' | '/checkout' | '/dashboard' {
+  if (!user.hasCompletedOnboarding) return '/onboarding'
+  if (!isSelfhosted && (!user.planCode || PAID_PLAN_CODES.has(user.planCode))) {
+    return '/checkout'
+  }
+  return '/dashboard'
+}

Then in login.tsx loader, 2FA action, and regular login action — and in Signin.tsx SSO/linking flows — replace each block with:

-    if (!user.hasCompletedOnboarding) {
-      redirectTo = '/onboarding'
-    } else if (!isSelfhosted && (!user.planCode || user.planCode === 'none')) {
-      redirectTo = '/checkout'
-    }
+    redirectTo = getPostLoginRedirect(user)

Also applies to: 112-122, 164-175

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/routes/login.tsx` around lines 41 - 45, Extract the repeated
three-branch redirect logic into a single shared helper (e.g.
decidePostAuthRedirect(user, isSelfhosted) in a common utils/auth module) that
returns the appropriate path ('/onboarding', '/checkout', or '/dashboard') based
on user.hasCompletedOnboarding and valid planCode values; then replace the
duplicated blocks in login.tsx (loader, 2FA action, regular login action) and
the duplicated branches in Signin.tsx (SSO/linking flows) to call
decidePostAuthRedirect and use its result for redirect. Ensure the helper treats
planCode values consistently (include 'none', 'trial', etc.) and update all
callsites to import and use the helper so the logic is centralized.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@web/app/pages/Auth/Signin/Signin.tsx`:
- Around line 230-234: The redirect condition in Signin.tsx that sends users to
routes.checkout treats missing plans and 'none' but omits 'trial'; update the
conditional used in the SSO-login/account-linking branches (the block around the
navigate(routes.checkout) check) to also consider user.planCode === 'trial'
(e.g., change !user.planCode || user.planCode === 'none' to include ||
user.planCode === 'trial'), and make the same change in the duplicate location
later in the file (the second occurrence around lines 311-315) so both branches
handle 'trial' consistently.

In `@web/app/routes/login.tsx`:
- Around line 43-44: The redirect checks in web/app/routes/login.tsx (the
loader, the 2FA action, and the regular login flow) currently only treat
user.planCode === 'none' as requiring checkout; add the 'trial' plan to those
gates so trial users are redirected as well (e.g., change the condition that
checks user.planCode === 'none' to also test for user.planCode === 'trial' or
test for an allowedPlans set). Update the three locations referenced (the
loader, the 2FA action, and the normal login branch) to include the 'trial' case
in their redirect logic to /checkout.

---

Nitpick comments:
In `@web/app/routes/login.tsx`:
- Around line 41-45: Extract the repeated three-branch redirect logic into a
single shared helper (e.g. decidePostAuthRedirect(user, isSelfhosted) in a
common utils/auth module) that returns the appropriate path ('/onboarding',
'/checkout', or '/dashboard') based on user.hasCompletedOnboarding and valid
planCode values; then replace the duplicated blocks in login.tsx (loader, 2FA
action, regular login action) and the duplicated branches in Signin.tsx
(SSO/linking flows) to call decidePostAuthRedirect and use its result for
redirect. Ensure the helper treats planCode values consistently (include 'none',
'trial', etc.) and update all callsites to import and use the helper so the
logic is centralized.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 303b6b2 and 82047be.

📒 Files selected for processing (2)
  • web/app/pages/Auth/Signin/Signin.tsx
  • web/app/routes/login.tsx

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