Repository navigation
feat: require a reason before self-service refund - #3230
Conversation
Adds a required freeform "Why are you refunding?" field to every self-service refund dialog (credits, DevPass, chat plans). The answer is stored in a new refund_feedback table along with the product kind, so feedback can be read per surface without joining back to the transaction type. The reason is validated server-side (non-empty, max 1000 chars) on both refund endpoints and persisted before the Stripe refund is issued, so the feedback survives a Stripe failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019XSErvAhCo3gyR9ULqNqDe
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
WalkthroughSelf-refund flows now collect standardized reasons and optional comments, validate them in API routes and UI dialogs, persist feedback by transaction, and record it before issuing Stripe refunds. ChangesSelf-refund feedback
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant User
participant RefundDialog
participant RefundRoute
participant executeSelfRefund
participant Database
participant Stripe
User->>RefundDialog: select reason and enter comments
RefundDialog->>RefundRoute: submit JSON feedback
RefundRoute->>executeSelfRefund: pass validated feedback
executeSelfRefund->>Database: upsert refund_feedback
executeSelfRefund->>Stripe: create refund
Stripe-->>RefundRoute: refund result
RefundRoute-->>RefundDialog: success response
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
apps/api/src/lib/self-refund.spec.ts (1)
700-710: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winCover feedback persistence when Stripe rejects the refund.
Add a case where
stripeMock.refunds.createrejects and assert the feedback row still exists. The current success-only assertion does not guard the required pre-Stripe persistence ordering.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/api/src/lib/self-refund.spec.ts` around lines 700 - 710, Extend the self-refund test coverage around the existing feedback persistence assertions to include a Stripe rejection case: configure stripeMock.refunds.create to reject, invoke the refund flow, and verify the feedback row remains persisted with the expected transaction, user, kind, and reason fields. Keep the assertion focused on persistence despite the Stripe failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@apps/api/src/lib/self-refund.spec.ts`:
- Around line 700-710: Extend the self-refund test coverage around the existing
feedback persistence assertions to include a Stripe rejection case: configure
stripeMock.refunds.create to reject, invoke the refund flow, and verify the
feedback row remains persisted with the expected transaction, user, kind, and
reason fields. Keep the assertion focused on persistence despite the Stripe
failure.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fc255101-be13-485e-94bf-732378ebba41
📒 Files selected for processing (13)
apps/api/src/lib/self-refund.spec.tsapps/api/src/lib/self-refund.tsapps/api/src/routes/dev-plans.tsapps/api/src/routes/organization.tsapps/code/src/app/dashboard/components/DevPassInvoices.tsxapps/playground/src/components/pricing/chat-billing-history.tsxapps/ui/src/components/billing/transactions-client.tsxpackages/db/migrations/1784979478_serious_doorman.sqlpackages/db/migrations/meta/1784979478_snapshot.jsonpackages/db/migrations/meta/_journal.jsonpackages/db/src/schema.tspackages/shared/src/index.tspackages/shared/src/refunds.ts
The required freeform "Why are you refunding? *" box sat directly above a
disabled Request refund button, which reads as a toll gate — the shape
that reliably produces "n/a" and "asdf". Replace it with a required
one-click category plus a contextual, optional follow-up:
- Six reason chips, so every refund yields comparable data even from
people who won't type. Required, but one tap.
- The follow-up question is picked from the chosen reason ("What broke?",
"What would have been worth paying for?") and only appears once a chip
is selected. A specific question gets a specific answer.
- Details are optional, except for "Something else", which carries no
signal on its own.
- "This won't affect your refund — we just want to know what to fix."
sits above the chips: people soften or skip when they suspect the
answer gates their money.
- Cancel becomes "Never mind" on the credits and chat dialogs, where
"Cancel" next to a refund was ambiguous.
refund_feedback now stores reason as an enum plus nullable comments,
matching the existing dev/chat plan cancellation feedback tables. All
copy and the option list live in @llmgateway/shared so the three dialogs
and the API agree.
Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/db/src/schema.ts (1)
577-609: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winUse the shared refund reason list in the schema
REFUND_FEEDBACK_REASONScontains the same literals aspackages/shared/src/refunds.ts:REFUND_REASONS, which is already exported via@llmgateway/shared. Reuse that canonical list inpackages/db/src/schema.tsfor therefundFeedback.reasonenum to avoid keeping two independent sources of truth.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/db/src/schema.ts` around lines 577 - 609, Update refundFeedback.reason and the local REFUND_FEEDBACK_REASONS declaration to reuse the canonical REFUND_REASONS export from `@llmgateway/shared`. Remove the duplicate local reason list while preserving the existing enum values and schema behavior.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/code/src/app/dashboard/components/DevPassInvoices.tsx`:
- Around line 243-288: Extract the duplicated refund-reason UI and related
selectedReason, trimmedComments, and canSubmit handling into a shared React
RefundReasonFieldset component in `@llmgateway/shared`, accepting the reason
options, selected reason, comments value/handlers, and an id prefix. Replace the
blocks in apps/code/src/app/dashboard/components/DevPassInvoices.tsx lines
243-288, apps/playground/src/components/pricing/chat-billing-history.tsx lines
285-330, and apps/ui/src/components/billing/transactions-client.tsx lines
235-280 with the shared component, passing invoice.id or transaction.id as the
respective id prefix and preserving existing behavior.
---
Nitpick comments:
In `@packages/db/src/schema.ts`:
- Around line 577-609: Update refundFeedback.reason and the local
REFUND_FEEDBACK_REASONS declaration to reuse the canonical REFUND_REASONS export
from `@llmgateway/shared`. Remove the duplicate local reason list while preserving
the existing enum values and schema behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: fc71de69-983a-4add-ac64-c18e4d725bd2
📒 Files selected for processing (13)
apps/api/src/lib/self-refund.spec.tsapps/api/src/lib/self-refund.tsapps/api/src/routes/dev-plans.tsapps/api/src/routes/organization.tsapps/code/src/app/dashboard/components/DevPassInvoices.tsxapps/playground/src/components/pricing/chat-billing-history.tsxapps/ui/src/components/billing/transactions-client.tsxpackages/db/migrations/1784981850_equal_phalanx.sqlpackages/db/migrations/meta/1784981850_snapshot.jsonpackages/db/migrations/meta/_journal.jsonpackages/db/src/schema.tspackages/shared/src/index.tspackages/shared/src/refunds.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- apps/api/src/lib/self-refund.ts
- apps/api/src/lib/self-refund.spec.ts
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/db/migrations/meta/1785077898_snapshot.json (1)
17480-17518: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win
refund_feedback.reason/kindlack DB-level enum validation.The new columns are plain
textwith noCHECKconstraint, unlike other enum-like text columns in this same schema (e.g.,provider_listing_request_payment_status_check,chat_support_message_reaction_check). Validation appears to rely solely on the shared/app-layer Zod schema, so direct DB writes could insert invalidreason/kindvalues.If a DB-level constraint is desired, it must be added via
packages/db/schema.tsand the migration regenerated — this snapshot/meta file itself should never be hand-edited.As per coding guidelines, "Never manually create migrations from scratch or manually resolve migration, journal, or snapshot conflicts. Reset migrations before merging and regenerate them afterward; if generated SQL needs adaptation, edit only the generated
.sqlfile."Also applies to: 28387-28456
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/db/migrations/meta/1785077898_snapshot.json` around lines 17480 - 17518, Add database-level enum validation for refund_feedback.kind and refund_feedback.reason in the schema definition, using the established CHECK-constraint pattern for enum-like text columns. Then regenerate the migration and its metadata snapshot through the migration tooling; do not edit the snapshot, journal, or migration metadata manually.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/db/migrations/meta/1785077898_snapshot.json`:
- Around line 17480-17518: Add database-level enum validation for
refund_feedback.kind and refund_feedback.reason in the schema definition, using
the established CHECK-constraint pattern for enum-like text columns. Then
regenerate the migration and its metadata snapshot through the migration
tooling; do not edit the snapshot, journal, or migration metadata manually.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: dbfd09e8-92f9-4f20-9a8f-9f933cd4cb57
📒 Files selected for processing (5)
packages/db/migrations/1785077898_aromatic_lake.sqlpackages/db/migrations/meta/1785077898_snapshot.jsonpackages/db/migrations/meta/_journal.jsonpackages/db/src/schema.tspackages/shared/src/index.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- packages/shared/src/index.ts
- packages/db/src/schema.ts
Per review: the reason chips, conditional follow-up textarea and canSubmit derivation were duplicated verbatim in all three refund dialogs. @llmgateway/shared/components already hosts shared React components, so the markup belongs there — I'd wrongly assumed the repo had no shared UI package. - packages/shared/src/components/refund-reason-fieldset.tsx holds the whole question; the three dialogs pass an id prefix plus state. - isRefundFeedbackComplete() in refunds.ts is now the single rule for "is this answer usable?", used by the dialogs to gate the confirm button and by executeSelfRefund to reject the request. - apps/playground was the one app whose globals.css did not @source packages/shared, so shared markup would have rendered unstyled there. Added it, matching apps/ui and apps/code. Also adds the review's suggested test: feedback must survive a Stripe rejection, which is the whole reason it is written before the refund call. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
🧹 Nitpick comments (1)
packages/shared/src/components/refund-reason-fieldset.tsx (1)
26-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueRemove the redundant narrative JSDoc.
It restates behavior evident from the component and props; retain only non-obvious API constraints. As per coding guidelines, “avoid unnecessary comments.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/shared/src/components/refund-reason-fieldset.tsx` around lines 26 - 35, Remove the narrative JSDoc block above the refund-reason fieldset component, including the behavioral and accessibility explanations. Retain only documentation describing non-obvious API constraints, if any; do not alter the component implementation or props.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@packages/shared/src/components/refund-reason-fieldset.tsx`:
- Around line 26-35: Remove the narrative JSDoc block above the refund-reason
fieldset component, including the behavioral and accessibility explanations.
Retain only documentation describing non-obvious API constraints, if any; do not
alter the component implementation or props.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 0e376eb1-763a-40ce-bdb9-7da0509ef4da
📒 Files selected for processing (10)
apps/api/src/lib/self-refund.spec.tsapps/api/src/lib/self-refund.tsapps/code/src/app/dashboard/components/DevPassInvoices.tsxapps/playground/src/app/globals.cssapps/playground/src/components/pricing/chat-billing-history.tsxapps/ui/src/components/billing/transactions-client.tsxpackages/shared/src/components/index.tsxpackages/shared/src/components/refund-reason-fieldset.tsxpackages/shared/src/index.tspackages/shared/src/refunds.ts
🚧 Files skipped from review as they are similar to previous changes (6)
- packages/shared/src/refunds.ts
- packages/shared/src/index.ts
- apps/playground/src/components/pricing/chat-billing-history.tsx
- apps/code/src/app/dashboard/components/DevPassInvoices.tsx
- apps/api/src/lib/self-refund.ts
- apps/api/src/lib/self-refund.spec.ts
What
Before a self-service refund goes through, we ask why — in a shape designed to get an honest answer rather than a keystroke that unblocks the button.
The first pass on this PR used a single required freeform box ("Why are you refunding? *" / "Required. Your feedback helps us improve.") sitting directly above a disabled Request refund. That reads as a toll gate, and a toll gate is what reliably produces
n/a,asdf, and-. This replaces it with a required one-click category plus a contextual follow-up.The dialog
Progressive disclosure keeps the dialog compact until a chip is picked; `max-h-[85vh] overflow-y-auto` covers the tall plan-payment variant on small viewports.
Changes
Database — `refund_feedback` (`packages/db/src/schema.ts` + generated migration):
Same enum-plus-comments shape as the existing `dev_plan_cancellation_feedback` and `chat_plan_cancellation_feedback` tables, so the three datasets line up.
API — both self-refund endpoints (`POST /orgs/{id}/transactions/{transactionId}/refund` and `POST /dev-plans/invoices/{invoiceId}/refund`) take `{ reason, comments? }` via a shared `refundFeedbackBodySchema`. `executeSelfRefund` rejects `other` with no comments and writes the feedback row before calling Stripe, so the answer survives a Stripe failure; a retry upserts on `transaction_id`.
UI — all three refund dialogs (`apps/ui` credits, `apps/code` DevPass + Reset Passes, `apps/playground` chat plans). The chips are native radios styled with `peer-checked:`, so arrow-key navigation and screen-reader semantics come for free with no new dependency.
Shared — `REFUND_REASONS`, `REFUND_REASON_OPTIONS` (label + follow-up prompt + placeholder), `REFUND_REASON_HEADING`, `REFUND_REASON_ASSURANCE`, `REFUND_COMMENTS_MAX_LENGTH` and `refundCommentsRequired()` live in `@llmgateway/shared` so the API and the three dialogs can't drift.
Note
The client-side gate is UX only — the endpoints independently reject a missing or unknown `reason`, and `other` with no `comments` (400), both covered by tests.
Testing
Summary by CodeRabbit