Skip to content

Refund prorated credit on a plan switch - #3252

Open
charlietlamb wants to merge 6 commits into
devfrom
charlie/refund-instead-of-credit
Open

Refund prorated credit on a plan switch#3252
charlietlamb wants to merge 6 commits into
devfrom
charlie/refund-instead-of-credit

Conversation

@charlietlamb

@charlietlamb charlietlamb commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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:

updateSubscription → finalizeUpdateSubscriptionPlan → computeRefundPlan ✅
attach             → finalizeAttachPlan            → finalizeLineItems only ❌

computeRefundPlan and buildStripeRefundAction had no cancellation-specific logic — attach simply never called them, and refund_last_payment was not part of the attach params.

Changes

  • refund_last_payment added to attach params (V0 + V1). Absent means invoice credit, so existing behaviour is unchanged.
  • finalizeAttachPlan now runs computeRefundPlan, so the outgoing plan's prorated credit is refunded to the payment method instead of held as balance. This makes computeAttachPlan async.
  • computeRefundPlan now takes the base BillingContext rather than UpdateSubscriptionBillingContextrefundLastPayment already lived on the base type.
  • A refund failure outside a cancel now throws instead of being logged and swallowed. On a plan change the customer is already on the new plan, so a silent failure would strand their money; cancels keep the tolerant behaviour.
  • Dashboard: "Refund Instead of Credit" toggle in the attach sheet's advanced section, shown only when proration is on.

Verification

New scenario downgrade-refund-instead-of-credit-scenario.test.ts — attach annual → monthly at mid-cycle:

  • $100.27 refunded to the card against the $200 annual invoice
  • Stripe credit balance ends at $0
  • new monthly plan billed $19.35

Also run: cancel-immediately-refund (7 pass), multi-update-cancel-params-schema (8 pass), tests/unit/billing (1081 pass).

immediate-switch shows 87 pass / 3 fail — those 3 fail identically on the parent commit without these changes (verified by checking out HEAD~1), so they are pre-existing and unrelated.

Notes for review

Two behaviours worth a decision, both pre-existing in computeRefundPlan and unchanged here:

  • The refund is capped at the last invoice (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.
  • A downgrade produces two money movements — a $100.27 refund and a separate $19.35 charge — 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

  • Adds optional refund_last_payment to V0 and V1 attach requests and threads it through the dashboard form.
  • Rejects refunds that cannot pay out: end-of-cycle switches (including omitted schedules that default there), attaches with no outgoing plan on the same subscription (add-ons, first attaches, cross-group remove_plan_ids removals), already-cancelled outgoing plans, no_billing_changes, or alongside billing_behavior/proration_behavior.
  • full refunds require a paid outgoing plan; prorated on a free source yields no refund.
  • Surfaces non-cancellation refund failures, while cancellation refunds remain tolerant.
  • Adds a dashboard toggle for eligible switches and preserves it when loading approval links.
  • Refunds remain capped at the last invoice, and downgrades create separate refund and charge transactions.
  • Adds coverage for the refund flow and request validation.

Written for commit 8d76d35. Summary will update on new commits.

Review in cubic

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.

  • [API changes, Improvements] Adds refund_last_payment to V0 and V1 attach requests and exposes the option in the dashboard attach form.
  • [Bug fixes] Rejects refund requests for scheduled switches, missing or invalid outgoing plans, and incompatible billing options.
  • [Improvements] Computes and executes refund actions during immediate plan switches and surfaces non-cancellation refund failures.

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

Filename Overview
server/src/internal/billing/v2/actions/attach/utils/attachRefundSourceCustomerProduct.ts Restricts refund eligibility to same-group transitions, leaving the previously reported same-subscription remove_plan_ids case unsupported.
server/src/internal/billing/v2/actions/attach/errors/handleRefundLastPaymentErrors.ts Adds refund eligibility guards, but rejects a supported same-subscription cross-group removal because its outgoing plan is not selected as the refund source.
server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts Extends attach-plan finalization to compute refund actions for eligible outgoing plans.
shared/api/billing/attachV2/attachParamsV1.ts Adds the refund request field and rejects explicitly scheduled end-of-cycle refunds and conflicting proration options.
vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx Adds a dashboard toggle for eligible immediate prorated switches and clears stale refund state when eligibility changes.
server/tests/scenarios/attach/downgrade-refund-instead-of-credit-scenario.test.ts Covers same-group downgrade refunds and omitted-schedule rejection, but not the outstanding same-subscription cross-group removal behavior.

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]
Loading

Reviews (5): Last reviewed commit: "fix: keep the refund to the subscription..." | Re-trigger Greptile

Context used (3)

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.
Comment thread shared/api/billing/attachV2/attachParamsV1.ts

@cubic-dev-ai cubic-dev-ai 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.

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 hydrate refund_last_payment, so opening an approval link resets the field to null and 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

Comment thread vite/src/components/forms/attach-v2/attachFormSchema.ts
Comment thread shared/api/billing/attachV2/attachParamsV1.ts
Comment thread server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts Outdated
Comment thread vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx Outdated
Comment thread vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx Outdated
Comment thread shared/api/billing/attachV2/attachParamsV1.ts
Comment thread shared/api/billing/attachV2/attachParamsV0.ts Outdated
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.
@vercel

vercel Bot commented Sep 3, 2026

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated
checkout Ignored Ignored Sep 3, 2026 3:34pm UTC
landing-page Ignored Ignored Sep 3, 2026 3:34pm UTC

Request Review

Comment thread shared/api/billing/attachV2/attachParamsV1.ts

@cubic-dev-ai cubic-dev-ai 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.

All reported issues were addressed across 8 files (changes from recent commits).

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts Outdated
Comment thread server/tests/scenarios/attach/downgrade-refund-instead-of-credit-scenario.test.ts Outdated
Comment thread shared/api/billing/attachV2/attachParamsV1.ts
Comment thread shared/api/billing/attachV2/attachParamsV1.ts
Comment thread shared/api/billing/attachV2/attachParamsV0.ts
…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.

@cubic-dev-ai cubic-dev-ai 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.

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

Comment thread server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts Outdated
Comment thread server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts Outdated
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.

@cubic-dev-ai cubic-dev-ai 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.

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.
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 3, 2026 15:22 Inactive

@cubic-dev-ai cubic-dev-ai 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.

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.
@vercel
vercel Bot temporarily deployed to Preview – autumn-vite September 3, 2026 15:34 Inactive
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