-
Notifications
You must be signed in to change notification settings - Fork 498
Partial refunds frontend #1123
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Partial refunds frontend #1123
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds USD-aware refund end-to-end support: backend validates per-entry USD refunds, computes Stripe-unit amounts, performs Stripe refunds and subscription quantity updates, and persists metadata; dashboard UI enables per-entry quantity selection and USD amount input; admin/client interfaces and tests updated to pass structured refund entries. Changes
Sequence DiagramsequenceDiagram
participant Dashboard as Dashboard UI
participant AdminAPI as Admin Interface
participant Backend as Backend Refund Handler
participant Stripe as Stripe API
participant DB as Database
Dashboard->>Dashboard: User selects refund entries\nand enters amount_usd
Dashboard->>AdminAPI: POST refundTransaction\n{ refundEntries, amountUsd }
AdminAPI->>Backend: POST /refund\n{ refund_entries, amount_usd }
Backend->>Backend: validateRefundEntries\n(check bounds, existence)
Backend->>Backend: getTotalUsdStripeUnits\n(convert USD -> Stripe units)
Backend->>Stripe: Create refund (amount in Stripe units)
Stripe-->>Backend: Refund confirmed
Backend->>Backend: compute per-entry refunded quantities
Backend->>Stripe: Update subscription item quantities (if needed)
Stripe-->>Backend: Quantity update confirmed
Backend->>DB: Persist refund metadata\n(timestamps, cancelled flags)
DB-->>Backend: Persisted
Backend-->>AdminAPI: 200 OK
AdminAPI-->>Dashboard: Success
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 1 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (1 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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 |
Greptile SummaryThis PR adds partial refund functionality to the payments system, allowing admins to specify custom USD refund amounts instead of being limited to full refunds. The implementation includes both backend validation and a polished frontend UI. Key Changes:
Implementation Quality:
Confidence Score: 5/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User as Admin User
participant UI as Transaction Table UI
participant Dialog as Action Dialog
participant App as Admin App
participant API as Refund API Route
participant Validation as Amount Validator
participant Stripe as Stripe API
participant DB as Database
User->>UI: Click refund button
UI->>Dialog: Open refund dialog
Dialog->>UI: Load charged amount (USD)
UI->>Dialog: Pre-populate input field
User->>Dialog: Enter/modify refund amount
Dialog->>Validation: Validate amount format
Validation-->>Dialog: Check schema validity
Validation->>Validation: Check amount > 0
Validation->>Validation: Check amount <= charged total
Validation-->>Dialog: Return validation result
Dialog->>Dialog: Update error state & button disabled
User->>Dialog: Click "Refund" button
Dialog->>App: refundTransaction({type, id, amountUsd})
App->>API: POST /internal/payments/transactions/refund
API->>DB: Fetch subscription/purchase details
DB-->>API: Return product, priceId, quantity
API->>Validation: Calculate total USD stripe units
Validation-->>API: Return total amount in cents
API->>Validation: Convert refund amount to stripe units
Validation-->>API: Return refund amount in cents
API->>API: Validate refund <= total
API->>Stripe: Create refund with amount
Stripe-->>API: Refund created
API->>DB: Update refundedAt timestamp
DB-->>API: Updated
API-->>App: {success: true}
App-->>Dialog: Success
Dialog->>Dialog: Close dialog
Dialog-->>UI: Refresh transaction list
|
|
📝 Documentation updates detected! New suggestion: Document partial refunds for USD transactions |
N2D4
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
can you make it so you can't only partially refund money, but also choose which and how many products, etc.?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In
`@apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx`:
- Around line 143-146: The code calls validateRefundEntries({ entries:
transaction.entries, refundEntries }) but then uses amount_usd to build the
Stripe refund, causing possible mismatch; update the refund flow in route.tsx to
compute the refund amount from refundEntries (using transaction.entries
prices/quantities) and use that computed value for the Stripe refund, or
alternatively compute the total implied by refundEntries and assert it equals
amount_usd before proceeding (throw/return error if mismatch). Locate the logic
around validateRefundEntries, refundEntries, amount_usd and the Stripe refund
creation and either replace amount_usd with the computedTotalFromRefundEntries
or add a validation step that compares computedTotalFromRefundEntries ===
amount_usd and fails on mismatch.
In `@apps/dashboard/src/components/data-table/transaction-table.tsx`:
- Around line 271-300: The validation currently checks refundAmountUsd-derived
refundUnits and selectedEntries-derived selectedUnits separately, which can
conflict; update validateRefund (the logic around refundAmountUsd, refundUnits,
selectedEntries, selectedUnits, maxUnits) to enforce consistency by computing
expectedRefundUnits from selectedEntries (use
moneyAmountToStripeUnits(entry.unitPriceUsd, USD_CURRENCY) *
entry.selectedQuantity) and comparing it to refundUnits, and if they differ
return canSubmit: false with an error like "Entered refund amount does not match
selected product quantities; please adjust amount or quantities." Alternatively
(if desired) auto-set refundAmountUsd to the computed amount before returning
success; ensure you reference refundAmountUsd, refundUnits, selectedEntries,
selectedUnits, and maxUnits when making the change.
🧹 Nitpick comments (6)
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx (3)
141-162: Duplicated refund validation logic between subscription and one-time-purchase branches.The validation and conversion logic (lines 141-162 for subscriptions and 189-209 for one-time purchases) is nearly identical. Consider extracting this into a shared helper function to reduce duplication and improve maintainability.
♻️ Suggested refactor
+function computeAndValidateRefundAmount(options: { + refundAmountUsd: MoneyAmount, + product: InferType<typeof productSchema>, + priceId: string | null, + quantity: number, +}): number { + const totalStripeUnits = getTotalUsdStripeUnits({ + product: options.product, + priceId: options.priceId, + quantity: options.quantity, + }); + const refundAmountStripeUnits = moneyAmountToStripeUnits(options.refundAmountUsd, USD_CURRENCY); + if (refundAmountStripeUnits <= 0) { + throw new KnownErrors.SchemaError("Refund amount must be greater than zero."); + } + if (refundAmountStripeUnits > totalStripeUnits) { + throw new KnownErrors.SchemaError("Refund amount cannot exceed the charged amount."); + } + return refundAmountStripeUnits; +}Then use this helper in both branches.
Also applies to: 189-209
148-151: Unsafe type casting of database JSON field.The
subscription.productis cast directly toInferType<typeof productSchema>without validation. If the stored JSON doesn't match the expected schema (due to schema evolution or data corruption), this could cause runtime errors downstream.Consider validating the product data before use:
♻️ Suggested validation
- const totalStripeUnits = getTotalUsdStripeUnits({ - product: subscription.product as InferType<typeof productSchema>, - priceId: subscription.priceId ?? null, - quantity: subscription.quantity, - }); + const validatedProduct = productSchema.validateSync(subscription.product); + const totalStripeUnits = getTotalUsdStripeUnits({ + product: validatedProduct, + priceId: subscription.priceId ?? null, + quantity: subscription.quantity, + });Based on learnings, code defensively with good error messages.
141-141: Unnecessaryletdeclaration with unusednullinitialization.
refundAmountStripeUnitsis declared aslet ... | null = nullbut is immediately assigned without the null ever being read. Consider usingconstat the point of assignment:♻️ Suggested fix
- let refundAmountStripeUnits: number | null = null; // ... validation code ... - refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + const refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);Also applies to: 189-189
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts (2)
134-135: Avoidanytype for transaction filtering.The
anytype here bypasses type checking. Per coding guidelines, avoidanyand leave a comment if unavoidable.♻️ Suggested fix
- const purchaseTransaction = transactionsRes.body.transactions.find((tx: any) => tx.type === "purchase"); + // Transaction type from API response - typed loosely since this is test code + const purchaseTransaction = transactionsRes.body.transactions.find( + (tx: { type: string }) => tx.type === "purchase" + );
326-390: Consider adding negative test cases for refund validation.The tests cover successful partial and quantity-based refunds, but there are no tests verifying the validation errors for:
- Refund amount exceeding the charged amount
- Refund quantity exceeding the purchased quantity
- Invalid entry_index values
These would help ensure the backend validation logic is working correctly.
apps/dashboard/src/components/data-table/transaction-table.tsx (1)
43-43:USD_CURRENCYcould beundefinedwithout a fallback.Unlike the backend which uses
?? throwErr(...), the frontendUSD_CURRENCYcould beundefinedif USD is not found inSUPPORTED_CURRENCIES. While this is unlikely, defensive coding would catch configuration errors early.♻️ Suggested fix
-const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === 'USD'); +const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === 'USD') + ?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");Based on learnings, prefer
?? throwErr(...)over potentially undefined values.
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 0
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
apps/e2e/tests/backend/endpoints/api/v1/email-themes.test.ts (1)
232-257: Fix the display_name inconsistency in the test and backend.The snapshot change reveals a real API inconsistency: the PATCH response at line 202 returns
"display_name": "Default Light", but the subsequent GET response at line 236 returns"display_name": "Unnamed Theme"for the same theme.This occurs because the backend's
onUpdatehandler (inapps/backend/src/app/api/latest/internal/email-themes/cud.tsx) only overrides thetsxSourcein the config and doesn't preserve or explicitly set thedisplayName. When the GET request is made, the theme'sdisplayNamemay fall back to the schema default value of"Unnamed Theme"rather than the original"Default Light".Either:
- Update the backend to preserve the theme's
displayNamewhen updatingtsxSource, or- Update the PATCH response snapshot to match the GET response and document this behavior change
Currently, the snapshots don't reflect a consistent API contract, which will cause confusion for API consumers.
apps/dashboard/src/components/data-table/transaction-table.tsx (1)
225-325: Non‑USD refunds can currently throw despite UI implying a full refund.When
chargedAmountUsdis missing, validation returns noamountUsd/refundEntries, but the dialog still allows “Refund” and then throws. Either disable refunds for non‑USD charges or implement a supported full‑refund path, and align the messaging accordingly.🛠️ One way to guard the UI
- const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntries.length > 0; + const canRefund = !!target && !transaction.test_mode && !alreadyRefunded && productEntries.length > 0 && !!chargedAmountUsd;- <Alert> - <AlertDescription> - Partial refunds are only available for USD charges. This will issue a full refund. - </AlertDescription> - </Alert> + <Alert> + <AlertDescription> + Refunds are currently supported only for USD charges. + </AlertDescription> + </Alert>Also applies to: 382-387
🧹 Nitpick comments (2)
apps/dashboard/src/components/data-table/transaction-table.tsx (1)
42-42: PreferinterfaceforRefundEntrySelection.This is an object shape and should use
interfaceto match the project’s TypeScript style. As per coding guidelines, please prefer interfaces for object shapes.♻️ Suggested refactor
-type RefundEntrySelection = { entryIndex: number, quantity: number }; +interface RefundEntrySelection { + entryIndex: number; + quantity: number; +}apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx (1)
29-32: PreferinterfaceforRefundEntrySelection.This is an object shape and should use
interfaceto align with the TypeScript style rules. As per coding guidelines, please prefer interfaces for object shapes.♻️ Suggested refactor
-type RefundEntrySelection = { - entry_index: number, - quantity: number, -}; +interface RefundEntrySelection { + entry_index: number; + quantity: number; +}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx (1)
256-259: Same issue: SettingrefundedAtblocks subsequent partial refunds.Similar to the subscription flow, setting
refundedAtimmediately after any refund (partial or full) will block future partial refunds due to the check at line 220-221.For one-time purchases especially, users may want to refund items incrementally. Consider tracking cumulative refunded amounts instead.
🤖 Fix all issues with AI agents
In
`@apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx`:
- Around line 200-212: The current update sets refundedAt for any refund, which
blocks future refunds; change the logic in the refund handler so refundedAt is
only set when the subscription is fully refunded (i.e., newQuantity === 0) —
update the prisma.subscription.update call in the else branch (where newQuantity
!== 0) to omit refundedAt and only set cancelAtPeriodEnd/refundedAt in the
branch that handles full refunds; alternatively, if you prefer cumulative
tracking implement and use a numeric refundedQuantity/amount field on the
subscription and compare cumulative refunded amount to total before setting
refundedAt, but do not mark refundedAt true on partial refunds.
- Around line 162-164: The validation currently uses refundAmountStripeUnits >
totalStripeUnits against the original total; update the logic to compare the
requested refund against the remaining refundable amount by computing cumulative
refunded (e.g., sum previous refunds from the transaction or use a stored
refundedAmount) and enforcing cumulativeRefunded + refundAmountStripeUnits <=
totalStripeUnits; adjust the checks around refundedAt (and any logic in the same
file near the second occurrence at lines 245-247) so partial refunds are allowed
and refundedAt is only set when cumulativeRefunded reaches totalStripeUnits.
🧹 Nitpick comments (3)
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx (3)
17-27: Type assertion bypasses MoneyAmount format validation.The
usdPriceis checked to be astring, but theas MoneyAmountcast at line 26 assumes it's in a valid money format (e.g.,"10.00"). If the product's price data is malformed, this could produce unexpected results frommoneyAmountToStripeUnits.Consider validating the format or using a parsing function that throws on invalid input.
29-32: Preferinterfaceovertypefor object shapes.Per coding guidelines, use
interfacefor defining object shapes in TypeScript.Suggested change
-type RefundEntrySelection = { - entry_index: number, - quantity: number, -}; +interface RefundEntrySelection { + entry_index: number; + quantity: number; +}
146-146: Unnecessarynullinitialization.
refundAmountStripeUnitsis initialized tonullbut immediately assigned a value on the next few lines. Thenullinitialization is unnecessary.Suggested change (example for line 146)
- let refundAmountStripeUnits: number | null = null; const transaction = buildSubscriptionTransaction({ subscription }); validateRefundEntries({ entries: transaction.entries, refundEntries, }); const refundedQuantity = getRefundedQuantity(refundEntries); const totalStripeUnits = getTotalUsdStripeUnits({ product: subscription.product as InferType<typeof productSchema>, priceId: subscription.priceId ?? null, quantity: subscription.quantity, }); - refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY); + const refundAmountStripeUnits = moneyAmountToStripeUnits(refundAmountUsd as MoneyAmount, USD_CURRENCY);Also applies to: 230-230
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 2
🤖 Fix all issues with AI agents
In `@apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts`:
- Line 366: Remove the debug console.log that prints OAuth provider details;
specifically delete the statement logging
configWithoutGithub.auth.oauth.providers in the test so secrets like
clientSecret are not emitted to CI logs and test output stays clean.
In
`@apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts`:
- Around line 134-135: Replace the inline any usage when iterating transaction
results with the shared Transaction type: import Transaction from
'@stackframe/stack-shared/dist/interface/crud/transactions' and change
occurrences like transactionsRes.body.transactions.find((tx: any) => ...) to
transactionsRes.body.transactions.find((tx: Transaction) => ...), and update
variables such as purchaseTransaction (and the other listed occurrences) to be
typed as Transaction | undefined to preserve nullability; ensure the import is
added at the top and used consistently across all occurrences (e.g., the find
predicates and variable declarations).
apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts
Outdated
Show resolved
Hide resolved
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
Show resolved
Hide resolved
There was a problem hiding this 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
🤖 Fix all issues with AI agents
In `@apps/dashboard/src/components/data-table/transaction-table.tsx`:
- Around line 323-331: The Refund flow can throw when chargedAmountUsd is falsy
or refundValidation.refundEntries is missing; update the click handler and
button props to fully guard against submitting invalid refunds: in the handler
that calls app.refundTransaction (the anonymous async function around
refundValidation/refundEntries), early-return if !refundValidation.canSubmit and
avoid using throwErr to surface missing refundEntries; instead either construct
a valid full-refund payload if backend supports it or surface a user-facing
error before calling app.refundTransaction. Also change the Refund button props
logic (currently using chargedAmountUsd ? { disabled:
!refundValidation.canSubmit } : undefined) so the button is disabled whenever
!refundValidation.canSubmit (not only when chargedAmountUsd exists). Apply the
same fixes for the other refund block referenced by
refundValidation/app.refundTransaction further down.
https://www.loom.com/share/bb7abfde507f40d386ee856f5ffbd506
Summary by CodeRabbit
✏️ Tip: You can customize this high-level summary in your review settings.