Skip to content

feat: require a reason before self-service refund - #3230

Merged
steebchen merged 8 commits into
mainfrom
claude/self-refund-feedback-form-c8ai5k
Jul 26, 2026
Merged

steebchen merged 8 commits into
mainfrom
claude/self-refund-feedback-form-c8ai5k

Conversation

@steebchen

@steebchen steebchen commented Jul 25, 2026 •

Copy link
Copy Markdown
Member

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

  1. "What made you ask for a refund?" — six chips, single choice, required. One tap, so even people who won't type produce comparable data.
    • It didn't work · Missing a model or feature · Too expensive · Bought it by mistake · Went with something else · Something else
  2. "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; saying otherwise up front is the single biggest lever on honesty here.
  3. A follow-up question chosen from the chip, revealed only after a chip is picked. A specific question gets a specific answer:
    • It didn't work → "What broke?" / "Which model, and what did it do? Errors, timeouts, bad output — specifics help us fix it."
    • Too expensive → "What would have been worth paying for?"
    • Went with something else → "What are you using instead?" / "And what does it do better than us?"
  4. Details are optional — except for "Something else", which carries no signal on its own. Requiring freeform across the board is what generates junk you can't distinguish from signal.
  5. "Cancel" → "Never mind" on the credits and chat top-up dialogs, where "Cancel" next to a refund was ambiguous. Plan payments keep the sharper "Keep my subscription" / "Keep my membership" / "Keep my DevPass" labels from fix(ui): warn refunds cancel the subscription #3227.

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

column notes
`organization_id`, `user_id`, `transaction_id` FKs, cascade on delete; unique on `transaction_id`
`kind` `credits` | `devpass` | `chat`, derived from the transaction type
`reason` enum-backed category
`comments` nullable freeform detail

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

  • `pnpm exec vitest run apps/api/src/lib/self-refund.spec.ts` — 37 passed, including feedback persistence (`credits` and `devpass` kinds), category-only submissions, and the three 400 paths that never reach Stripe. `apps/api/src/routes/dev-plans.spec.ts` and `organization.spec.ts` also pass.
  • `pnpm build`, `pnpm lint`, `pnpm format` pass.
  • Drove the credits dialog end-to-end against a local stack: picked Too expensive, typed a detail, submitted — `refund_feedback` got `kind=credits`, `reason=too_expensive` and the comment, written before the (deliberately failing) Stripe call. Also checked the `Something else` required-detail path and the disabled-until-selected button.

Summary by CodeRabbit

  • New Features
    • Refund requests now require a standardized reason, with optional comments for most reasons and required comments for “Other.”
    • Refund dialogs across billing areas now include consistent reason selection and contextual comment input.
    • Submitted refund feedback is persisted for eligible self-service refunds.
  • Bug Fixes
    • Invalid refund payloads are rejected with clear validation before any refund call.
    • Refund UI now disables submission until the selected reason/comment requirements are satisfied, and resets after success.
  • Tests
    • Added/updated endpoint tests to verify validation, persistence, and behavior when the external refund call fails.

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
Copilot AI review requested due to automatic review settings July 25, 2026 12:09
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Self-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.

Changes

Self-refund feedback

Layer / File(s) Summary
Feedback contract and persistence
packages/shared/src/refunds.ts, packages/shared/src/index.ts, packages/db/src/schema.ts, packages/db/migrations/*
Defines shared refund reasons, comment rules, UI text, feedback kinds, and the refund_feedback table with indexes, uniqueness, and foreign keys.
Feedback recording during refund execution
apps/api/src/lib/self-refund.ts
Validates feedback, maps transaction types to feedback kinds, upserts feedback by transaction, and records it before calling Stripe.
Validated feedback through refund routes
apps/api/src/routes/dev-plans.ts, apps/api/src/routes/organization.ts
Adds JSON body validation and forwards reason and comments to refund execution.
Reason collection in refund dialogs
apps/code/.../DevPassInvoices.tsx, apps/playground/.../chat-billing-history.tsx, apps/ui/.../transactions-client.tsx, packages/shared/src/components/*
Adds controlled refund dialogs with shared reason selection, conditional comments, submission validation, loading states, and feedback request bodies.
Refund feedback behavior tests
apps/api/src/lib/self-refund.spec.ts
Verifies persisted feedback, feedback kinds, invalid-input rejection, required comments, omitted comments, Stripe failure behavior, and existing authorization/ineligibility behavior.

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
Loading

Possibly related PRs

Suggested reviewers: smakosh

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: self-service refunds now require a reason.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/self-refund-feedback-form-c8ai5k

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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
apps/api/src/lib/self-refund.spec.ts (1)

700-710: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover feedback persistence when Stripe rejects the refund.

Add a case where stripeMock.refunds.create rejects 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

📥 Commits

Reviewing files that changed from the base of the PR and between 53465fe and 431e866.

📒 Files selected for processing (13)
  • apps/api/src/lib/self-refund.spec.ts
  • apps/api/src/lib/self-refund.ts
  • apps/api/src/routes/dev-plans.ts
  • apps/api/src/routes/organization.ts
  • apps/code/src/app/dashboard/components/DevPassInvoices.tsx
  • apps/playground/src/components/pricing/chat-billing-history.tsx
  • apps/ui/src/components/billing/transactions-client.tsx
  • packages/db/migrations/1784979478_serious_doorman.sql
  • packages/db/migrations/meta/1784979478_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/schema.ts
  • packages/shared/src/index.ts
  • packages/shared/src/refunds.ts

steebchen and others added 2 commits July 25, 2026 13:44
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>
@steebchen steebchen changed the title feat: collect refund reason before self-refund feat: ask why, not just that, before a refund Jul 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/db/src/schema.ts (1)

577-609: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Use the shared refund reason list in the schema

REFUND_FEEDBACK_REASONS contains the same literals as packages/shared/src/refunds.ts:REFUND_REASONS, which is already exported via @llmgateway/shared. Reuse that canonical list in packages/db/src/schema.ts for the refundFeedback.reason enum 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

📥 Commits

Reviewing files that changed from the base of the PR and between 431e866 and 5586ed4.

📒 Files selected for processing (13)
  • apps/api/src/lib/self-refund.spec.ts
  • apps/api/src/lib/self-refund.ts
  • apps/api/src/routes/dev-plans.ts
  • apps/api/src/routes/organization.ts
  • apps/code/src/app/dashboard/components/DevPassInvoices.tsx
  • apps/playground/src/components/pricing/chat-billing-history.tsx
  • apps/ui/src/components/billing/transactions-client.tsx
  • packages/db/migrations/1784981850_equal_phalanx.sql
  • packages/db/migrations/meta/1784981850_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/schema.ts
  • packages/shared/src/index.ts
  • packages/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

Comment thread apps/code/src/app/dashboard/components/DevPassInvoices.tsx Outdated
@steebchen steebchen changed the title feat: ask why, not just that, before a refund feat: require a reason before self-service refund Jul 25, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/db/migrations/meta/1785077898_snapshot.json (1)

17480-17518: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

refund_feedback.reason/kind lack DB-level enum validation.

The new columns are plain text with no CHECK constraint, 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 invalid reason/kind values.

If a DB-level constraint is desired, it must be added via packages/db/schema.ts and 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 .sql file."

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

📥 Commits

Reviewing files that changed from the base of the PR and between 5586ed4 and 67d4ae8.

📒 Files selected for processing (5)
  • packages/db/migrations/1785077898_aromatic_lake.sql
  • packages/db/migrations/meta/1785077898_snapshot.json
  • packages/db/migrations/meta/_journal.json
  • packages/db/src/schema.ts
  • packages/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>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
packages/shared/src/components/refund-reason-fieldset.tsx (1)

26-35: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove 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

📥 Commits

Reviewing files that changed from the base of the PR and between 67d4ae8 and 9d4f23d.

📒 Files selected for processing (10)
  • apps/api/src/lib/self-refund.spec.ts
  • apps/api/src/lib/self-refund.ts
  • apps/code/src/app/dashboard/components/DevPassInvoices.tsx
  • apps/playground/src/app/globals.css
  • apps/playground/src/components/pricing/chat-billing-history.tsx
  • apps/ui/src/components/billing/transactions-client.tsx
  • packages/shared/src/components/index.tsx
  • packages/shared/src/components/refund-reason-fieldset.tsx
  • packages/shared/src/index.ts
  • packages/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

@steebchen
steebchen merged commit 220ffea into main Jul 26, 2026
22 checks passed
@steebchen
steebchen deleted the claude/self-refund-feedback-form-c8ai5k branch July 26, 2026 16:13
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.

3 participants