Refund prorated credit on a plan switch - #3252
Conversation
Switching plans mid-cycle left the unused portion of the outgoing plan as credit on the customer's Stripe balance, with no way to return it to the card. The refund pipeline already existed but only ran on cancellation. Attach now accepts refund_last_payment and runs computeRefundPlan in finalizeAttachPlan, so the outgoing plan's prorated credit is refunded to the payment method instead of held as balance. A refund failure outside a cancel now throws rather than being logged and swallowed, since the customer is already on the new plan by that point.
There was a problem hiding this comment.
1 issue found across 13 files
Confidence score: 3/5
- In
vite/src/components/forms/attach-v2/attachFormSchema.ts, the request-to-form mapper does not hydraterefund_last_payment, so opening an approval link resets the field tonulland resubmitting can lose the original request value—preserve this field when mapping requests into the form.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="vite/src/components/forms/attach-v2/attachFormSchema.ts">
<violation number="1" location="vite/src/components/forms/attach-v2/attachFormSchema.ts:59">
P1: When an attach request containing `refund_last_payment` is opened through an approval link, this field is reset to its default `null` because the request-to-form mapper does not hydrate it. Submitting the approval then omits the refund option and credits the outgoing plan instead of refunding it; add a `refundLastPayment: readEnum<"prorated" | "full">("refund_last_payment")` reader to the attach overrides.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Review found refund_last_payment was accepted on attaches where there is nothing to refund yet, or nothing to refund at all. An end-of-cycle switch leaves the outgoing plan active until the cycle ends, so refunding its payment now would hand back money for service the customer still has. Both attach schemas reject that combination, and the dashboard hides the toggle unless the switch is immediate and prorated. A "full" refund ignores the refund line items, so a plain add-on attach with no outgoing plan would return the last invoice. The refund plan is now computed only when the plan actually removes something. Also: uncancel no longer counts as a cancellation when deciding whether a refund failure may be swallowed, the attach form hydrates refund_last_payment from a request body so approval links keep the option, and a stale toggle is cleared when it stops being applicable.
|
The latest updates on your projects. Learn more about Vercel for GitHub. 2 Skipped Deployments
|
There was a problem hiding this comment.
All reported issues were addressed across 8 files (changes from recent commits).
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
…it cannot pay out Follow-up review found the toggle never reached the API: AttachFormProvider did not pass refundLastPayment into the request body, so enabling it did nothing. It is now threaded for single-plan attach, and left out of multi-attach, which has no refund support. A downgrade defaults to an end-of-cycle switch when plan_schedule is omitted, which the schema refinement cannot see. The resolved plan timing is now validated in the attach error pass, so the request is refused rather than silently dropping the refund. The outgoing-plan check now reads currentCustomerProduct instead of refund-direction line items, which can appear for siblings on an add-on attach. The dashboard hides the toggle when there is no outgoing plan or the attach is multi-plan, and a refund request no longer also sends billing_behavior, which the schema rejects.
There was a problem hiding this comment.
All reported issues were addressed across 7 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
The previous guard only refused an end-of-cycle switch, so a refund asked for on an add-on, a first attach, a free outgoing plan, or alongside no_billing_changes was still dropped in silence — the exact failure the guard exists to prevent. handleRefundLastPaymentErrors now covers all of those, so a refund either happens or the caller is told why it cannot. The dashboard also waits for a settled preview before clearing the toggle, so an approval link hydrated with refund_last_payment keeps it while hasOutgoing is still unknown.
There was a problem hiding this comment.
All reported issues were addressed across 4 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
currentCustomerProduct only covers same-group transitions, so an immediate attach that removed a paid plan through remove_plan_ids was rejected even though the removal produces prorated credit. The refund source is now derived once — the plan being transitioned away from, or the plan this attach removes — and shared by the guard and the compute step. A plan whose subscription is already cancelled is also refused, since its last payment may already be settled or refunded.
There was a problem hiding this comment.
All reported issues were addressed across 3 files (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
Refunding a plan removed cross-group does not work: computeRefundPlan refunds against billingContext.stripeSubscription, which is the subscription the attach touches, so a removed plan billing on another subscription would refund the wrong invoice or none at all. Supporting it means threading a per-plan subscription through the refund path, which is beyond this change — so the refund source is an in-group transition only, and anything else is refused with that reason. The paid-plan requirement now applies to "full" alone. "prorated" is derived from the refund line items, so a free source simply yields nothing rather than returning the last invoice.
Requested in Slack: an option to choose between a refund and an invoice credit when switching plans on proration.
Switching plans mid-cycle left the unused portion of the outgoing plan sitting as credit on the customer's Stripe balance, with no way to return it to the card. The screenshot in that thread is the attach sheet — a yearly → monthly downgrade showing
-$171.17 in invoice credits.What was already there
The refund pipeline existed end to end, but only ran on cancellation:
computeRefundPlanandbuildStripeRefundActionhad no cancellation-specific logic — attach simply never called them, andrefund_last_paymentwas not part of the attach params.Changes
refund_last_paymentadded to attach params (V0 + V1). Absent means invoice credit, so existing behaviour is unchanged.finalizeAttachPlannow runscomputeRefundPlan, so the outgoing plan's prorated credit is refunded to the payment method instead of held as balance. This makescomputeAttachPlanasync.computeRefundPlannow takes the baseBillingContextrather thanUpdateSubscriptionBillingContext—refundLastPaymentalready lived on the base type.Verification
New scenario
downgrade-refund-instead-of-credit-scenario.test.ts— attach annual → monthly at mid-cycle:$100.27refunded to the card against the$200annual invoice$0$19.35Also run:
cancel-immediately-refund(7 pass),multi-update-cancel-params-schema(8 pass),tests/unit/billing(1081 pass).immediate-switchshows 87 pass / 3 fail — those 3 fail identically on the parent commit without these changes (verified by checking outHEAD~1), so they are pre-existing and unrelated.Notes for review
Two behaviours worth a decision, both pre-existing in
computeRefundPlanand unchanged here:remainingRefundable). If an annual term was paid across several invoices, or partly with existing credit, the refund comes out smaller than the credit would have been rather than erroring.$100.27refund and a separate$19.35charge — rather than one netted invoice.Summary by cubic
Mid-cycle attach switches previously left unused value from the outgoing plan as Stripe balance credit; they can now optionally refund it to the payment method instead. Credit remains the default, and refunds are limited to immediate switches that remove an outgoing plan on the same subscription.
Behavior
refund_last_paymentto V0 and V1 attach requests and threads it through the dashboard form.remove_plan_idsremovals), already-cancelled outgoing plans,no_billing_changes, or alongsidebilling_behavior/proration_behavior.fullrefunds require a paid outgoing plan;proratedon a free source yields no refund.Written for commit 8d76d35. Summary will update on new commits.
Greptile Summary
This PR adds an option to return prorated plan-switch credit to the original payment method rather than keeping it on the customer’s Stripe balance.
refund_last_paymentto V0 and V1 attach requests and exposes the option in the dashboard attach form.Confidence Score: 4/5
The PR is not yet safe to merge because a supported same-subscription cross-group plan removal still rejects the requested refund.
The reply from charlietlamb states that removed-plan refunds were fixed, but the current helper again selects only the same-group current product; therefore an immediate attach that removes a paid plan from another group on the same Stripe subscription reaches the refund guard without a source and receives a 400 instead of returning its prorated credit.
Files Needing Attention: server/src/internal/billing/v2/actions/attach/utils/attachRefundSourceCustomerProduct.ts, server/src/internal/billing/v2/actions/attach/errors/handleRefundLastPaymentErrors.ts
Important Files Changed
remove_plan_idscase unsupported.Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A[Attach request] --> B[Resolve outgoing plan and timing] B --> C{Immediate switch with refund requested?} C -->|No| D[Use normal invoice credit behavior] C -->|Yes| E[Compute prorated refund] E --> F[Update Stripe subscription] F --> G[Refund latest eligible payment] G --> H[Apply Autumn plan transition]Reviews (5): Last reviewed commit: "fix: keep the refund to the subscription..." | Re-trigger Greptile
Context used (3)