Skip to content

Add cancellation feedback feature for subscription management#938

Merged
goldflag merged 3 commits intomasterfrom
cancelation-flow
Mar 15, 2026
Merged

Add cancellation feedback feature for subscription management#938
goldflag merged 3 commits intomasterfrom
cancelation-flow

Conversation

@goldflag
Copy link
Collaborator

@goldflag goldflag commented Mar 15, 2026

  • Introduced a new cancellation feedback table in the database to collect user insights on subscription cancellations, aimed at reducing churn.
  • Updated server API to handle cancellation feedback submissions and integrated it into the Stripe routes.
  • Enhanced the PaidPlan component to include a CancellationDialog for users to provide feedback when canceling their subscriptions.
  • Added the @calcom/embed-react dependency to the client for improved integration with the cancellation feature.

Summary by CodeRabbit

  • New Features

    • Multi-step subscription cancellation dialog with reason selection, optional details, personalized retention offers, in-dialog actions (change plan, downgrade, scheduling help), and final preview of plan/impact.
  • Chores

    • Cancellation feedback is now submitted from the client and recorded server-side for reporting; new backend route and storage record added to persist feedback.

- Introduced a new cancellation feedback table in the database to collect user insights on subscription cancellations, aimed at reducing churn.
- Updated server API to handle cancellation feedback submissions and integrated it into the Stripe routes.
- Enhanced the PaidPlan component to include a CancellationDialog for users to provide feedback when canceling their subscriptions.
- Added the @calcom/embed-react dependency to the client for improved integration with the cancellation feature.
@vercel
Copy link

vercel bot commented Mar 15, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
rybbit Ready Ready Preview, Comment Mar 15, 2026 2:33am

Request Review

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 15, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1c7e1d5f-eeb1-4ac4-a10a-d573f495bf28

📥 Commits

Reviewing files that changed from the base of the PR and between d8d9973 and 4971386.

📒 Files selected for processing (1)
  • client/src/components/subscription/PaidPlain/CancellationDialog.tsx

📝 Walkthrough

Walkthrough

Adds a subscription cancellation feedback flow: a 3-step client dialog to collect reasons and retention choices, a client hook to submit feedback, a server endpoint and route to persist entries, a new database table to store feedback records, and one client dependency.

Changes

Cohort / File(s) Summary
Dependencies
client/package.json
Added @calcom/embed-react dependency (^1.5.3).
Client UI
client/src/components/subscription/PaidPlain/CancellationDialog.tsx, client/src/components/subscription/PaidPlain/PaidPlan.tsx
New CancellationDialog component (3-step reason → retention → confirmation) and PaidPlan updated to open the dialog on cancellation.
Client hook
client/src/lib/subscription/useCancellationFeedback.ts
New useSubmitCancellationFeedback React Query mutation and SubmitCancellationFeedbackParams interface to POST feedback to backend.
Server API
server/src/api/stripe/submitCancellationFeedback.ts, server/src/api/stripe/index.ts
New handler submitCancellationFeedback that authenticates, enforces org-owner, validates input, inserts feedback; re-exported from stripe index.
Server routes
server/src/index.ts
Added POST /stripe/cancellation-feedback route protected by authOnly within cloud context.
Database schema
server/src/db/postgres/schema.ts
Added cancellation_feedback table (cancellationFeedback) with fields: id, organizationId, userId, reason, reasonDetails, retentionOfferShown, retentionOfferAccepted, outcome, planNameAtCancellation, monthlyEventCountAtCancellation, createdAt.

Sequence Diagram

sequenceDiagram
    participant User as User
    participant Dialog as "CancellationDialog (Client)"
    participant Hook as "useSubmitCancellationFeedback (Client)"
    participant Server as "Server / POST /stripe/cancellation-feedback"
    participant DB as "Database (cancellation_feedback)"

    User->>Dialog: Open dialog, select reason & details
    Dialog->>Dialog: Show retention offers (step 2)
    User->>Dialog: Accept/decline or proceed to confirm
    Dialog->>Hook: Submit feedback payload
    Hook->>Server: POST /stripe/cancellation-feedback
    Server->>Server: Authenticate & authorize owner
    Server->>Server: Validate body (organizationId, reason, outcome)
    Server->>DB: Insert cancellation_feedback record
    DB-->>Server: Insert result
    Server-->>Hook: 200 OK { success: true }
    Hook-->>Dialog: Report success
    Dialog-->>User: Close / continue to portal or plan change
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I nudged a form with gentle paws, to ask why you roam,
Three little hops, a try to keep you close or let you go,
I tuck your reasons in a burrow, record them with a grin,
Offers, notes, and one small save — a soft farewell begins.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

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.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change—adding a cancellation feedback feature for subscription management—which is directly supported by the database schema additions, server API endpoint, client dialog component, and integration across the codebase.

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

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch cancelation-flow
📝 Coding Plan
  • Generate coding plan for human review comments

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.

Copy link
Contributor

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

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx`:
- Around line 82-125: The retain handlers (handleKeepPlan, handleSwitchToAnnual,
handleDowngrade) call submitFeedback.mutate() then immediately
toast/close/navigate which can swallow failures, and the "Keep My Plan" branch
currently skips submission; change these to wait for the submission to complete
(use submitFeedback.mutate with onSuccess/onError callbacks or await the
mutation promise) before calling toast/resetAndClose/onChangePlan, and make the
"Keep My Plan" branch perform the same submitFeedback.mutate call (with the
appropriate retentionOfferShown value and retentionOfferAccepted true) so
recovered users are reliably written to cancellation_feedback.
- Around line 127-145: The cancellation flow currently waits for
submitFeedback.mutate to succeed before calling onProceedToStripe inside
handleFinalCancel; instead ensure the Stripe portal is opened regardless of
feedback persistence by invoking onProceedToStripe outside of the mutate
onSuccess handler (either call it immediately after submitFeedback.mutate or use
mutate's onSettled/onError to always call it). Update handleFinalCancel so
submitFeedback.mutate still attempts to send the feedback (using the existing
payload) but does not gate or conditionally call onProceedToStripe based on
mutate success; keep references to submitFeedback.mutate, handleFinalCancel, and
onProceedToStripe to locate the change.
- Around line 31-38: The component currently hardcodes all user-facing strings
(e.g., the REASONS array and dialog/offer labels used in CancellationDialog,
plus text in functions/render blocks around lines referenced) instead of using
next-intl; import and call useTranslations() at the top of CancellationDialog,
replace every literal string (notably the REASONS entries and all visible
labels/buttons/headers in the dialog and retention offers) with t('key')
lookups, and add corresponding keys to messages/en.json (and other locales)
under a sensible namespace (e.g., "cancellation"). Ensure the REASONS array uses
t for each label (or maps values to translation keys) and update any JSX text,
button labels, and help copy between the noted ranges to use those translation
keys.

In `@client/src/components/subscription/PaidPlain/PaidPlan.tsx`:
- Around line 93-94: handleCancelSubscription currently only sets
showCancellationDialog and becomes a silent no-op when organizationId is
missing; restore the previous behavior by making handleCancelSubscription check
organizationId and if absent call createPortalSession() (which will surface the
toast) otherwise setShowCancellationDialog(true); update the rendering gating
(the showCancellationDialog/organizationId logic used when rendering the
cancellation dialog) to remain unchanged so the dialog only appears when org
exists.

In `@client/src/lib/subscription/useCancellationFeedback.ts`:
- Around line 15-31: The mutation function useSubmitCancellationFeedback's
mutationFn currently calls response.json() before checking response.ok, which
can throw on non-JSON error responses; update mutationFn to first check
response.ok and only attempt to parse JSON for successful responses, and for
error responses either try/catch response.json() to safely extract an error
message (falling back to response.text() or a default message) or read text when
JSON parsing fails, then throw a new Error with that extracted message so errors
from BACKEND_URL/stripe/cancellation-feedback are handled predictably.

In `@server/src/api/stripe/submitCancellationFeedback.ts`:
- Around line 71-76: The catch block in submitCancellationFeedback currently
returns error.message to the client; instead keep the detailed exception logged
server-side (e.g., console.error("Cancellation Feedback Error:", error)) but
change the response body to a generic message without error details: return
reply.status(500).send({ error: "Failed to submit cancellation feedback" }); —
remove or avoid sending error.message or other error internals to the client.
- Around line 6-15: Create a Zod schema that matches the
CancellationFeedbackBody shape and call schema.parse(request.body) at the start
of the submitCancellationFeedback handler before any destructuring; use the
parsed result for the rest of the function so malformed/null/non-object bodies
return a 400 with validation errors instead of throwing. Update the manual
validation branch (previously at the "validation at line 38") to use the parsed
data and remove trust in raw request.body. In the catch block of
submitCancellationFeedback, stop returning details: error.message to the
client—log the error internally (e.g., processLogger.error or console.error) and
return a generic error response/status to avoid leaking ORM/database internals.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 8f2f0e34-7a62-4559-832e-e805593a7f89

📥 Commits

Reviewing files that changed from the base of the PR and between 14cbf12 and 76534a3.

⛔ Files ignored due to path filters (1)
  • client/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • client/package.json
  • client/src/components/subscription/PaidPlain/CancellationDialog.tsx
  • client/src/components/subscription/PaidPlain/PaidPlan.tsx
  • client/src/lib/subscription/useCancellationFeedback.ts
  • server/src/api/stripe/index.ts
  • server/src/api/stripe/submitCancellationFeedback.ts
  • server/src/db/postgres/schema.ts
  • server/src/index.ts

Comment on lines +31 to +38
const REASONS: { value: CancellationReason; label: string }[] = [
{ value: "too_expensive", label: "It's too expensive" },
{ value: "missing_features", label: "Missing features I need" },
{ value: "not_using", label: "I'm not using it enough" },
{ value: "too_complex", label: "It's too hard to set up or use" },
{ value: "switching_competitor", label: "Switching to another tool" },
{ value: "other", label: "Other reason" },
];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the file exists and get its size
wc -l client/src/components/subscription/PaidPlain/CancellationDialog.tsx

Repository: rybbit-io/rybbit

Length of output: 131


🏁 Script executed:

# Read the entire file to understand its structure
cat -n client/src/components/subscription/PaidPlain/CancellationDialog.tsx

Repository: rybbit-io/rybbit

Length of output: 17953


🏁 Script executed:

# Check for next-intl imports in the file
rg "next-intl|useTranslations" client/src/components/subscription/PaidPlain/CancellationDialog.tsx

Repository: rybbit-io/rybbit

Length of output: 42


🏁 Script executed:

# Check if there are translation files for this component
fd -t f messages

Repository: rybbit-io/rybbit

Length of output: 129


Localize all user-facing text in this cancellation workflow.

This component has hardcoded English strings throughout the entire flow instead of using useTranslations(). The dialog and all retention offers will remain untranslated for non-English locales.

Add useTranslations() from next-intl and move all user-facing strings to the translation files in the messages/ directory (lines 31–38, 159–205, 216–318, 342–425).

This is required by the coding guideline: "client/src/**/*.{ts,tsx}: Use next-intl's 'useTranslations()' hook for i18n; maintain translation files in messages/ directory (en.json, de.json, etc.)".

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

In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx` around
lines 31 - 38, The component currently hardcodes all user-facing strings (e.g.,
the REASONS array and dialog/offer labels used in CancellationDialog, plus text
in functions/render blocks around lines referenced) instead of using next-intl;
import and call useTranslations() at the top of CancellationDialog, replace
every literal string (notably the REASONS entries and all visible
labels/buttons/headers in the dialog and retention offers) with t('key')
lookups, and add corresponding keys to messages/en.json (and other locales)
under a sensible namespace (e.g., "cancellation"). Ensure the REASONS array uses
t for each label (or maps values to translation keys) and update any JSX text,
button labels, and help copy between the noted ranges to use those translation
keys.

Comment on lines +93 to 94
const handleCancelSubscription = () => setShowCancellationDialog(true);

Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avoid the new silent no-op when there is no active organization.

Before this change, canceling always flowed through createPortalSession(), which surfaces a toast when organizationId is missing. Now handleCancelSubscription only flips local state, and the dialog is not rendered unless organizationId exists at Lines 129-138, so the button does nothing in that state.

Also applies to: 129-138

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

In `@client/src/components/subscription/PaidPlain/PaidPlan.tsx` around lines 93 -
94, handleCancelSubscription currently only sets showCancellationDialog and
becomes a silent no-op when organizationId is missing; restore the previous
behavior by making handleCancelSubscription check organizationId and if absent
call createPortalSession() (which will surface the toast) otherwise
setShowCancellationDialog(true); update the rendering gating (the
showCancellationDialog/organizationId logic used when rendering the cancellation
dialog) to remain unchanged so the dialog only appears when org exists.

Comment on lines +15 to +31
export function useSubmitCancellationFeedback() {
return useMutation<{ success: boolean }, Error, SubmitCancellationFeedbackParams>({
mutationFn: async (params) => {
const response = await fetch(`${BACKEND_URL}/stripe/cancellation-feedback`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify(params),
});

const data = await response.json();

if (!response.ok) {
throw new Error(data.error || "Failed to submit cancellation feedback");
}

return data;
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n client/src/lib/subscription/useCancellationFeedback.ts

Repository: rybbit-io/rybbit

Length of output: 1286


🏁 Script executed:

# Check for Zod usage patterns in similar files
fd --type f -e ts -e tsx 'use.*\.ts' client/src/lib/ | head -20 | xargs grep -l "zod\|z\." 2>/dev/null | head -10

Repository: rybbit-io/rybbit

Length of output: 42


🏁 Script executed:

# Check mutation patterns in React Query hooks
fd --type f -e ts -e tsx 'use.*Mutation' client/src/ | head -10 | xargs grep -A 10 "mutationFn:" 2>/dev/null | head -50

Repository: rybbit-io/rybbit

Length of output: 800


🏁 Script executed:

# Look for existing response validation patterns
rg "response\.json\(\)" client/src --type ts --type tsx -B 2 -A 2 | head -60

Repository: rybbit-io/rybbit

Length of output: 87


🏁 Script executed:

# Search for Zod imports and usage
rg "from.*zod\|import.*zod" client/src --type ts | head -20

Repository: rybbit-io/rybbit

Length of output: 42


🏁 Script executed:

# Search for try/catch patterns in mutations more broadly
rg "try\s*{" client/src/lib --type ts -A 15 | head -80

Repository: rybbit-io/rybbit

Length of output: 137


🏁 Script executed:

# Check complete mutation examples with error handling
fd . client/src/lib -name "use*.ts" | xargs cat | grep -A 20 "mutationFn:" | head -100

Repository: rybbit-io/rybbit

Length of output: 290


🏁 Script executed:

# Look at API response validation patterns
rg "response\.ok|response\.json" client/src/api --type ts -B 3 -A 3 | head -60

Repository: rybbit-io/rybbit

Length of output: 762


🏁 Script executed:

# Search for try/catch with different pattern
rg "try" client/src/lib --type ts -A 10 | head -60

Repository: rybbit-io/rybbit

Length of output: 3228


🏁 Script executed:

# Check more complete hook examples for comparison
rg "useMutation" client/src/lib -A 20 --type ts | head -80

Repository: rybbit-io/rybbit

Length of output: 6565


🏁 Script executed:

# Check if response.json is guarded elsewhere
rg "response.ok" client/src -B 5 -A 5 --type ts | head -80

Repository: rybbit-io/rybbit

Length of output: 5636


🏁 Script executed:

# Verify Zod is actually in dependencies
cat package.json | grep -i zod

Repository: rybbit-io/rybbit

Length of output: 104


Wrap response.json() in a try/catch block to handle non-JSON responses.

response.json() at line 25 runs before the response.ok check at line 27. If a proxy/CDN returns non-JSON content (HTML, plain text) on error responses, this throws a SyntaxError that bypasses the intended error handling. Wrap the JSON parsing in try/catch or reorder to check response.ok first.

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

In `@client/src/lib/subscription/useCancellationFeedback.ts` around lines 15 - 31,
The mutation function useSubmitCancellationFeedback's mutationFn currently calls
response.json() before checking response.ok, which can throw on non-JSON error
responses; update mutationFn to first check response.ok and only attempt to
parse JSON for successful responses, and for error responses either try/catch
response.json() to safely extract an error message (falling back to
response.text() or a default message) or read text when JSON parsing fails, then
throw a new Error with that extracted message so errors from
BACKEND_URL/stripe/cancellation-feedback are handled predictably.

Comment on lines +6 to +15
interface CancellationFeedbackBody {
organizationId: string;
reason: string;
reasonDetails?: string;
retentionOfferShown?: string;
retentionOfferAccepted?: boolean;
outcome: string;
planNameAtCancellation?: string;
monthlyEventCountAtCancellation?: number;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

find . -path "*/server/src/api/stripe/submitCancellationFeedback.ts" -type f

Repository: rybbit-io/rybbit

Length of output: 113


🏁 Script executed:

cat -n server/src/api/stripe/submitCancellationFeedback.ts | head -100

Repository: rybbit-io/rybbit

Length of output: 2892


Add Zod schema validation before destructuring the request body.

request.body is only validated at compile time by TypeScript. At runtime, if the request body is null, undefined, or not an object, the destructuring at line 27 will throw an error before reaching the manual validation at line 38, turning a client error into a 500. Per the backend guidelines, use Zod to parse and validate the payload first.

Additionally, the catch block at line 75 returns details: error.message, which exposes internal error details. Remove this to avoid leaking database or ORM error information.

🧩 Suggested hardening
+import { z } from "zod";
+
+const cancellationFeedbackBodySchema = z.object({
+  organizationId: z.string().min(1),
+  reason: z.string().min(1),
+  reasonDetails: z.string().trim().min(1).optional(),
+  retentionOfferShown: z.string().min(1).optional(),
+  retentionOfferAccepted: z.boolean().optional(),
+  outcome: z.enum(["cancelled", "retained"]),
+  planNameAtCancellation: z.string().min(1).optional(),
+  monthlyEventCountAtCancellation: z.number().int().nonnegative().optional(),
+});
...
-  const {
+  const parsedBody = cancellationFeedbackBodySchema.safeParse(request.body);
+  if (!parsedBody.success) {
+    return reply.status(400).send({ error: "Invalid cancellation feedback payload" });
+  }
+
+  const {
     organizationId,
     reason,
     reasonDetails,
     retentionOfferShown,
     retentionOfferAccepted,
     outcome,
     planNameAtCancellation,
     monthlyEventCountAtCancellation,
-  } = request.body;
-
-  if (!organizationId || !reason || !outcome) {
-    return reply.status(400).send({
-      error: "Missing required parameters: organizationId, reason, outcome",
-    });
-  }
+  } = parsedBody.data;
   } catch (error: any) {
     console.error("Cancellation Feedback Error:", error);
     return reply.status(500).send({
       error: "Failed to submit cancellation feedback",
-      details: error.message,
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@server/src/api/stripe/submitCancellationFeedback.ts` around lines 6 - 15,
Create a Zod schema that matches the CancellationFeedbackBody shape and call
schema.parse(request.body) at the start of the submitCancellationFeedback
handler before any destructuring; use the parsed result for the rest of the
function so malformed/null/non-object bodies return a 400 with validation errors
instead of throwing. Update the manual validation branch (previously at the
"validation at line 38") to use the parsed data and remove trust in raw
request.body. In the catch block of submitCancellationFeedback, stop returning
details: error.message to the client—log the error internally (e.g.,
processLogger.error or console.error) and return a generic error response/status
to avoid leaking ORM/database internals.

…nent

- Simplified the feedback submission process by creating a reusable feedbackPayload function to streamline data handling for various cancellation scenarios.
- Updated the mutation calls in handleKeepPlan, handleSwitchToAnnual, handleDowngrade, and handleFinalCancel to utilize the new feedbackPayload function, improving code maintainability.
- Removed redundant code in the CancellationDialog component for better clarity and efficiency.
Copy link
Contributor

@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: 1

♻️ Duplicate comments (4)
server/src/api/stripe/submitCancellationFeedback.ts (1)

6-42: ⚠️ Potential issue | 🟠 Major

Validate and parse the payload with Zod before destructuring.

request.body is destructured before runtime validation, so malformed/null bodies can fail before your 400 branch. Parse first, then use parsed data.

Suggested hardening
+import { z } from "zod";
+
+const cancellationFeedbackBodySchema = z.object({
+  organizationId: z.string().min(1),
+  reason: z.string().min(1),
+  reasonDetails: z.string().trim().min(1).optional(),
+  retentionOfferShown: z.string().min(1).optional(),
+  retentionOfferAccepted: z.boolean().optional(),
+  outcome: z.enum(["cancelled", "retained"]),
+  planNameAtCancellation: z.string().min(1).optional(),
+  monthlyEventCountAtCancellation: z.number().int().nonnegative().optional(),
+});
...
-  const {
+  const parsedBody = cancellationFeedbackBodySchema.safeParse(request.body);
+  if (!parsedBody.success) {
+    return reply.status(400).send({ error: "Invalid cancellation feedback payload" });
+  }
+
+  const {
     organizationId,
     reason,
     reasonDetails,
     retentionOfferShown,
     retentionOfferAccepted,
     outcome,
     planNameAtCancellation,
     monthlyEventCountAtCancellation,
-  } = request.body;
+  } = parsedBody.data;

Based on learnings: Backend: Use Fastify, Drizzle ORM with Postgres, ClickHouse, and Zod.

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

In `@server/src/api/stripe/submitCancellationFeedback.ts` around lines 6 - 42, In
submitCancellationFeedback, avoid destructuring request.body before runtime
validation—create a Zod schema (e.g., CancellationFeedbackSchema) matching
CancellationFeedbackBody, call CancellationFeedbackSchema.parse or safeParse on
request.body at the start of the function, handle validation errors by returning
reply.status(400).send(...) for malformed/null payloads, then destructure/use
the validated/parsed object (not raw request.body) for organizationId, reason,
outcome, etc.; ensure optional fields have proper Zod .optional() types and use
the parsed result throughout the function.
client/src/components/subscription/PaidPlain/CancellationDialog.tsx (3)

372-375: ⚠️ Potential issue | 🟠 Major

Step-3 “Keep My Plan” still skips feedback submission.

This retained path closes the dialog without writing a retained outcome, so recovered users from the final confirmation step can be undercounted.

Record retained outcome on final-step keep
-          <Button variant="success" onClick={resetAndClose}>
+          <Button variant="success" onClick={() => handleKeepPlan(getRetentionOfferType())}>
             Keep My Plan
           </Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx` around
lines 372 - 375, The "Keep My Plan" button currently calls resetAndClose and
skips writing a retained outcome; update CancellationDialog so that clicking
"Keep My Plan" records a retained outcome before closing. Add or reuse a handler
(e.g., handleKeepPlan or modify resetAndClose) to call the existing
feedback/telemetry function (e.g., submitFeedback, submitSurveyOutcome,
recordRetentionOutcome—whichever is used elsewhere in this component) with
outcome "retained" and any required metadata, await/handle its result, then call
resetAndClose to close the dialog; finally wire the Button's onClick to this new
handler instead of resetAndClose.

31-38: ⚠️ Potential issue | 🟠 Major

Localize all user-facing copy with useTranslations() instead of hardcoded English.

This dialog still hardcodes visible strings, so non-English locales won’t translate this flow.

As per coding guidelines: client/src/**/*.{ts,tsx}: Use next-intl's useTranslations() hook for i18n; maintain translation files in messages/ directory.

Also applies to: 137-185, 188-334, 341-404

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

In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx` around
lines 31 - 38, Replace hardcoded labels in the REASONS array with translated
strings using next-intl's useTranslations() in CancellationDialog.tsx: import
and call useTranslations (e.g., const t = useTranslations('CancellationDialog'))
and change each label to t('reason.too_expensive') /
t('reason.missing_features') / etc.; ensure keys map to a nested "reason"
namespace in your messages files and update other visible strings in the
component (lines referenced: 137-185, 188-334, 341-404) to use t('...') as well
so all user-facing copy is localized via the same translation keys.

93-118: ⚠️ Potential issue | 🟠 Major

Use onSuccess and onError callbacks instead of onSettled for mutation side effects.

All three handlers (handleKeepPlan, handleSwitchToAnnual, handleDowngrade) misuse onSettled, causing success UX (toasts, state resets, plan changes) to execute even when mutations fail. handleKeepPlan shows a success toast on both success and failure cases, silently misreporting failed writes. The other handlers execute resetAndClose() and onChangePlan() regardless of success/failure.

Safer mutation callbacks
   const handleKeepPlan = (offerShown: string) => {
     submitFeedback.mutate(feedbackPayload(offerShown, true, "retained"), {
-      onSettled: () => {
+      onSuccess: () => {
         toast.success("Great, we're glad you're staying!");
         resetAndClose();
       },
+      onError: () => {
+        toast.error("Could not save feedback. Please try again.");
+      },
     });
   };

   const handleSwitchToAnnual = () => {
     submitFeedback.mutate(feedbackPayload("switch_annual", true, "retained"), {
-      onSettled: () => {
+      onSuccess: () => {
         resetAndClose();
         onChangePlan();
       },
     });
   };

   const handleDowngrade = () => {
     submitFeedback.mutate(feedbackPayload("downgrade", true, "retained"), {
-      onSettled: () => {
+      onSuccess: () => {
         resetAndClose();
         onChangePlan();
       },
     });
   };

Per React Query best practices and coding guidelines: handle errors via onError callbacks; let React Query surface API errors appropriately.

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

In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx` around
lines 93 - 118, The three handlers (handleKeepPlan, handleSwitchToAnnual,
handleDowngrade) currently use submitFeedback.mutate with onSettled causing
success UI to run even on failure; change them to use onSuccess for post-success
actions (show toast, call resetAndClose(), and call onChangePlan() where
applicable) and add onError to handle failures (e.g., show an error toast and
avoid calling resetAndClose() or onChangePlan()). Specifically, move
toast.success("Great, we're glad you're staying!") into the onSuccess of
handleKeepPlan, move resetAndClose() and onChangePlan() into onSuccess for
handleSwitchToAnnual and handleDowngrade, and implement onError callbacks for
all three that surface the error (e.g., toast.error with the error message)
without performing success-only state changes; keep submitFeedback.mutate and
feedbackPayload usage unchanged.
🧹 Nitpick comments (1)
server/src/api/stripe/submitCancellationFeedback.ts (1)

71-72: Avoid any in catch; narrow from unknown and log structurally.

Using error: any weakens strict typing and makes error handling less safe.

Typed catch/logging example
-  } catch (error: any) {
-    console.error("Cancellation Feedback Error:", error);
+  } catch (error: unknown) {
+    request.log.error({ err: error }, "Cancellation feedback submission failed");
     return reply.status(500).send({
       error: "Failed to submit cancellation feedback",
     });
   }

As per coding guidelines: **/*.{ts,tsx}: Use TypeScript with strict typing throughout both client and server; Error handling: Use try/catch blocks with specific error types.

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

In `@server/src/api/stripe/submitCancellationFeedback.ts` around lines 71 - 72,
Change the catch clause to accept unknown (catch (error: unknown)) and then
narrow it before logging: use an instanceof Error check to log structured fields
like message and stack, and for non-Error values log the raw value under a
well-named key; update the catch in submitCancellationFeedback.ts (the catch
block currently using error: any) to perform these type-guards and structured
console/processLogger calls so typing is strict and logs are consistent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx`:
- Around line 125-133: The getRetentionOfferType() function returns "general"
for reason === "not_using" but the UI renders the setup-help offer for that
path, causing analytics mismatch; update getRetentionOfferType() so that when
reason === "not_using" it returns "setup_help" (keep other branches unchanged)
so the recorded retentionOfferShown matches the rendered offer in the
cancellation flow (e.g., when invoked from the step-3 cancellation handler).

---

Duplicate comments:
In `@client/src/components/subscription/PaidPlain/CancellationDialog.tsx`:
- Around line 372-375: The "Keep My Plan" button currently calls resetAndClose
and skips writing a retained outcome; update CancellationDialog so that clicking
"Keep My Plan" records a retained outcome before closing. Add or reuse a handler
(e.g., handleKeepPlan or modify resetAndClose) to call the existing
feedback/telemetry function (e.g., submitFeedback, submitSurveyOutcome,
recordRetentionOutcome—whichever is used elsewhere in this component) with
outcome "retained" and any required metadata, await/handle its result, then call
resetAndClose to close the dialog; finally wire the Button's onClick to this new
handler instead of resetAndClose.
- Around line 31-38: Replace hardcoded labels in the REASONS array with
translated strings using next-intl's useTranslations() in
CancellationDialog.tsx: import and call useTranslations (e.g., const t =
useTranslations('CancellationDialog')) and change each label to
t('reason.too_expensive') / t('reason.missing_features') / etc.; ensure keys map
to a nested "reason" namespace in your messages files and update other visible
strings in the component (lines referenced: 137-185, 188-334, 341-404) to use
t('...') as well so all user-facing copy is localized via the same translation
keys.
- Around line 93-118: The three handlers (handleKeepPlan, handleSwitchToAnnual,
handleDowngrade) currently use submitFeedback.mutate with onSettled causing
success UI to run even on failure; change them to use onSuccess for post-success
actions (show toast, call resetAndClose(), and call onChangePlan() where
applicable) and add onError to handle failures (e.g., show an error toast and
avoid calling resetAndClose() or onChangePlan()). Specifically, move
toast.success("Great, we're glad you're staying!") into the onSuccess of
handleKeepPlan, move resetAndClose() and onChangePlan() into onSuccess for
handleSwitchToAnnual and handleDowngrade, and implement onError callbacks for
all three that surface the error (e.g., toast.error with the error message)
without performing success-only state changes; keep submitFeedback.mutate and
feedbackPayload usage unchanged.

In `@server/src/api/stripe/submitCancellationFeedback.ts`:
- Around line 6-42: In submitCancellationFeedback, avoid destructuring
request.body before runtime validation—create a Zod schema (e.g.,
CancellationFeedbackSchema) matching CancellationFeedbackBody, call
CancellationFeedbackSchema.parse or safeParse on request.body at the start of
the function, handle validation errors by returning reply.status(400).send(...)
for malformed/null payloads, then destructure/use the validated/parsed object
(not raw request.body) for organizationId, reason, outcome, etc.; ensure
optional fields have proper Zod .optional() types and use the parsed result
throughout the function.

---

Nitpick comments:
In `@server/src/api/stripe/submitCancellationFeedback.ts`:
- Around line 71-72: Change the catch clause to accept unknown (catch (error:
unknown)) and then narrow it before logging: use an instanceof Error check to
log structured fields like message and stack, and for non-Error values log the
raw value under a well-named key; update the catch in
submitCancellationFeedback.ts (the catch block currently using error: any) to
perform these type-guards and structured console/processLogger calls so typing
is strict and logs are consistent.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3f7fab35-61b9-4748-968b-b9efe8df7305

📥 Commits

Reviewing files that changed from the base of the PR and between 76534a3 and d8d9973.

📒 Files selected for processing (2)
  • client/src/components/subscription/PaidPlain/CancellationDialog.tsx
  • server/src/api/stripe/submitCancellationFeedback.ts

- Changed the return value for the "not_using" reason from "general" to "setup_help" to better reflect user feedback and improve guidance for users canceling their subscriptions.
@goldflag goldflag merged commit 57808ba into master Mar 15, 2026
5 of 6 checks passed
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