Skip to content

Commit ce3729a

Browse files
committed
fix: reject every refund request that cannot pay out
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.
1 parent 9bdbc78 commit ce3729a

4 files changed

Lines changed: 56 additions & 20 deletions

File tree

server/src/internal/billing/v2/actions/attach/compute/finalizeAttachPlan.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@ export const finalizeAttachPlan = async ({
3030
customLineItems: params.custom_line_items,
3131
});
3232

33-
// Refund lines can appear for siblings on an add-on attach, so the outgoing
34-
// plan itself is the signal — otherwise a "full" refund would return the
35-
// last invoice when nothing was actually replaced.
33+
// Compute runs before the error pass rejects an unpayable refund, so the
34+
// outgoing plan is checked here too. Sibling refund lines on an add-on attach
35+
// make line items an unreliable signal.
3636
const replacesExistingPlan = Boolean(
3737
attachBillingContext.currentCustomerProduct,
3838
);

server/src/internal/billing/v2/actions/attach/errors/handleAttachV2Errors.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export const handleAttachV2Errors = async ({
8181

8282
// 6. Scheduled switch with one-off prepaid quantities
8383
handleScheduledSwitchOneOffErrors({ ctx, billingContext });
84-
handleRefundLastPaymentErrors({ billingContext });
84+
handleRefundLastPaymentErrors({ billingContext, params });
8585
handleBillingCycleAnchorErrors({ billingContext, params });
8686
handleStartDateErrors({ billingContext, params, preview });
8787
handleEndDateErrors({ billingContext, params });
Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,61 @@
11
import {
22
type AttachBillingContext,
3+
type AttachParamsV1,
34
ErrCode,
5+
isCustomerProductFree,
46
RecaseError,
57
} from "@autumn/shared";
68
import { StatusCodes } from "http-status-codes";
79

10+
const refundRejected = ({ reason }: { reason: string }) =>
11+
new RecaseError({
12+
message: `refund_last_payment ${reason}`,
13+
code: ErrCode.InvalidRequest,
14+
statusCode: StatusCodes.BAD_REQUEST,
15+
});
16+
817
/**
9-
* Validates that a requested refund can actually be paid out.
10-
*
11-
* A downgrade defaults to "end_of_cycle" when no plan_schedule is passed, so
12-
* the schema alone cannot catch this — the outgoing plan stays active until the
13-
* cycle ends, and no refund would be issued.
18+
* Rejects refund requests that could not be paid out, so the caller hears about
19+
* it instead of the option being silently dropped.
1420
*/
1521
export const handleRefundLastPaymentErrors = ({
1622
billingContext,
23+
params,
1724
}: {
1825
billingContext: AttachBillingContext;
26+
params: AttachParamsV1;
1927
}) => {
20-
const { refundLastPayment, planTiming } = billingContext;
28+
const { refundLastPayment, planTiming, currentCustomerProduct } =
29+
billingContext;
2130

2231
if (!refundLastPayment) return;
2332

33+
if (params.no_billing_changes === true) {
34+
throw refundRejected({
35+
reason:
36+
"cannot be combined with no_billing_changes, which skips the billing changes the refund depends on.",
37+
});
38+
}
39+
40+
// A downgrade resolves to end_of_cycle unless plan_schedule says otherwise.
2441
if (planTiming === "end_of_cycle") {
25-
throw new RecaseError({
26-
message:
27-
"refund_last_payment requires an immediate plan switch. This attach resolves to an end-of-cycle switch, so the outgoing plan stays active until the cycle ends and its payment cannot be refunded. Pass plan_schedule: 'immediate' to switch and refund now.",
28-
code: ErrCode.InvalidRequest,
29-
statusCode: StatusCodes.BAD_REQUEST,
42+
throw refundRejected({
43+
reason:
44+
"requires an immediate plan switch. This attach resolves to an end-of-cycle switch, so the outgoing plan stays active until the cycle ends and its payment cannot be refunded. Pass plan_schedule: 'immediate' to switch and refund now.",
45+
});
46+
}
47+
48+
if (!currentCustomerProduct) {
49+
throw refundRejected({
50+
reason:
51+
"requires an outgoing plan to refund. This attach does not replace an existing plan.",
52+
});
53+
}
54+
55+
if (isCustomerProductFree(currentCustomerProduct)) {
56+
throw refundRejected({
57+
reason:
58+
"requires a paid outgoing plan. The plan being replaced was free, so there is no payment to return.",
3059
});
3160
}
3261
};

vite/src/components/forms/attach-v2/components/AttachAdvancedSection.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,8 @@ export function AttachAdvancedSection() {
211211
const resetsBillingCycleNow =
212212
resetBillingCycle && billingCycleAnchorMode === "now";
213213

214-
// Only an immediate, prorated switch produces credit that can go back to the card.
215-
// Multi-plan attach has no refund support, and without an outgoing plan
216-
// there is no credit to hand back.
214+
// Only an immediate, prorated switch off an outgoing plan produces credit
215+
// that can go back to the card. Multi-plan attach has no refund support.
217216
const canRefundOutgoingPlan =
218217
hasOutgoing &&
219218
!isMultiPlan &&
@@ -223,11 +222,19 @@ export function AttachAdvancedSection() {
223222
effectiveProrationBehavior === "prorate_immediately" &&
224223
effectivePlanSchedule !== "end_of_cycle";
225224

225+
// Wait for the preview before clearing, so a hydrated approval link keeps its
226+
// refund while `hasOutgoing` is still unknown.
227+
const previewSettled = Boolean(previewQuery.data);
228+
226229
useEffect(() => {
227-
if (!canRefundOutgoingPlan && refundLastPayment !== null) {
230+
if (
231+
previewSettled &&
232+
!canRefundOutgoingPlan &&
233+
refundLastPayment !== null
234+
) {
228235
form.setFieldValue("refundLastPayment", null);
229236
}
230-
}, [canRefundOutgoingPlan, refundLastPayment, form]);
237+
}, [previewSettled, canRefundOutgoingPlan, refundLastPayment, form]);
231238

232239
const handleAddDiscount = () => {
233240
form.setFieldValue("discounts", addDiscount(discounts));

0 commit comments

Comments
 (0)