Skip to content

Commit caeaaf9

Browse files
fix(billing): allocate stored refunds to one charge
Prevent historical refunds from reducing multiple replacement charges when reconstructing prorated invoice credits. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 68c7e4c commit caeaaf9

4 files changed

Lines changed: 131 additions & 34 deletions

File tree

server/src/internal/billing/v2/utils/lineItems/storedInvoiceCreditForPrice.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
99
import { augmentBillingContextForAnchorResetRefund } from "./augmentBillingContextForAnchorResetRefund";
1010
import { chargeRowToRefundLineItem } from "./chargeRowToRefundLineItem";
1111
import {
12-
computeAlreadyRefundedForCharge,
12+
computeAlreadyRefundedByCharge,
1313
computeProratedCredit,
1414
splitMultiEntityAmount,
1515
} from "./storedLineItemUtils";
@@ -78,6 +78,10 @@ export const storedInvoiceCreditForPrice = ({
7878
const creditableRows = consumedChargeRowIds
7979
? usableRows.filter((row) => !consumedChargeRowIds.has(row.id))
8080
: usableRows;
81+
const alreadyRefundedByCharge = computeAlreadyRefundedByCharge({
82+
chargeRows: usableRows,
83+
refundRows: currentPeriodRefunds,
84+
});
8185

8286
for (const chargeRow of creditableRows) {
8387
const periodStart = chargeRow.effective_period_start;
@@ -93,10 +97,7 @@ export const storedInvoiceCreditForPrice = ({
9397
consumedChargeRowIds?.add(chargeRow.id);
9498
const effectiveNow =
9599
action.type === "use_snapped_now" ? action.snappedNow : now;
96-
const alreadyRefunded = computeAlreadyRefundedForCharge({
97-
chargeRow,
98-
refundRows: currentPeriodRefunds,
99-
});
100+
const alreadyRefunded = alreadyRefundedByCharge.get(chargeRow.id) ?? 0;
100101
const creditAmount = computeProratedCredit({
101102
chargeRow: {
102103
...chargeRow,

server/src/internal/billing/v2/utils/lineItems/storedLineItemUtils.ts

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ export const hasSamePrice = (
1919
(a.price_id != null && a.price_id === b.price_id) ||
2020
(a.stripe_price_id != null && a.stripe_price_id === b.stripe_price_id);
2121

22+
const sharesInvoice = (a: DbInvoiceLineItem, b: DbInvoiceLineItem): boolean =>
23+
a.invoice_id != null && b.invoice_id != null && a.invoice_id === b.invoice_id;
24+
2225
export const computeProratedCredit = ({
2326
chargeRow,
2427
now,
@@ -49,25 +52,40 @@ export const computeProratedCredit = ({
4952
return prorationFraction.mul(refundable).neg().toNumber();
5053
};
5154

52-
export const computeAlreadyRefundedForCharge = ({
53-
chargeRow,
55+
export const computeAlreadyRefundedByCharge = ({
56+
chargeRows,
5457
refundRows,
5558
}: {
56-
chargeRow: DbInvoiceLineItem;
59+
chargeRows: DbInvoiceLineItem[];
5760
refundRows: DbInvoiceLineItem[];
58-
}): number => {
59-
const matchingRefunds = refundRows.filter(
60-
(refund) =>
61-
isWithinPeriod(refund, chargeRow) && hasSamePrice(refund, chargeRow),
61+
}): Map<string, number> => {
62+
const alreadyRefundedByCharge = new Map<string, number>();
63+
const chronologicalRefunds = [...refundRows].sort(
64+
(a, b) => a.created_at - b.created_at,
6265
);
6366

64-
return matchingRefunds.reduce(
65-
(sum, r) =>
66-
new Decimal(sum)
67-
.plus(Math.abs(splitMultiEntityAmount(r)))
67+
for (const refund of chronologicalRefunds) {
68+
const matchingCharge = chargeRows
69+
.filter(
70+
(charge) =>
71+
charge.created_at < refund.created_at &&
72+
!sharesInvoice(charge, refund) &&
73+
isWithinPeriod(refund, charge) &&
74+
hasSamePrice(refund, charge),
75+
)
76+
.sort((a, b) => b.created_at - a.created_at)[0];
77+
if (!matchingCharge) continue;
78+
79+
const alreadyRefunded = alreadyRefundedByCharge.get(matchingCharge.id) ?? 0;
80+
alreadyRefundedByCharge.set(
81+
matchingCharge.id,
82+
new Decimal(alreadyRefunded)
83+
.plus(Math.abs(splitMultiEntityAmount(refund)))
6884
.toNumber(),
69-
0,
70-
);
85+
);
86+
}
87+
88+
return alreadyRefundedByCharge;
7189
};
7290

7391
export const splitMultiEntityAmount = (

server/tests/integration/licenses/billing/update/update-license-quantity.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
* -$60/+$100 — assigned-seat portions must NOT collapse into a
1616
* delta-only line.
1717
* - quantity 3 -> 5 partially assigned (1 included): pair -$40/+$80.
18+
* - sequential 5 -> 7 -> 9: the old 5-seat refund is not reused against
19+
* the 7-seat replacement charge; the second update bills only 2 seats.
1820
* - same quantity: pair cancels -> no line items, no new invoice.
1921
* - included-only pool (0 paid) grown: charge line only, no $0 refund.
2022
* - quantity below live assignments -> 400.
@@ -104,6 +106,45 @@ test.concurrent(
104106
},
105107
);
106108

109+
test.concurrent(
110+
`${chalk.yellowBright("license-update-quantity: sequential 5 -> 7 -> 9 changes credit each prior charge once")}`,
111+
async () => {
112+
const customerId = "license-update-quantity-sequential";
113+
const { autumnV2_3, parent, devSeat, advancedTo } =
114+
await setupLicenseUpdateScenario({
115+
customerId,
116+
idPrefix: "lic-qty-sequential",
117+
seatPrice: DEV_SEAT_PRICE,
118+
includedSeats: 0,
119+
attachedSeats: 5,
120+
});
121+
122+
await autumnV2_3.billing.update<UpdateSubscriptionV1ParamsInput>({
123+
customer_id: customerId,
124+
plan_id: parent.id,
125+
license_quantities: [{ license_plan_id: devSeat.id, quantity: 7 }],
126+
});
127+
128+
const preview =
129+
await autumnV2_3.subscriptions.previewUpdate<UpdateSubscriptionV1ParamsInput>(
130+
{
131+
customer_id: customerId,
132+
plan_id: parent.id,
133+
license_quantities: [{ license_plan_id: devSeat.id, quantity: 9 }],
134+
},
135+
);
136+
137+
await expectLicenseUpdatePreviewCorrect({
138+
preview,
139+
customerId,
140+
advancedTo,
141+
oldRecurringTotal: 7 * DEV_SEAT_PRICE,
142+
newRecurringTotal: 9 * DEV_SEAT_PRICE,
143+
expectQuantityLineItemPair: { oldQuantity: 7, newQuantity: 9 },
144+
});
145+
},
146+
);
147+
107148
test.concurrent(
108149
`${chalk.yellowBright("license-update-quantity: qty 3 -> 2 shrinks the pool in place")}`,
109150
async () => {

server/tests/unit/billing/invoice-matched-credits/invoice-credit-matcher.spec.ts

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import chalk from "chalk";
1111
import { getRefundLineItemsForPrice } from "@/internal/billing/v2/utils/lineItems/getRefundLineItemsForPrice";
1212
import { invoiceCreditFromStoredLineItems } from "@/internal/billing/v2/utils/lineItems/invoiceCreditFromStoredLineItems";
1313
import {
14-
computeAlreadyRefundedForCharge,
14+
computeAlreadyRefundedByCharge,
1515
computeProratedCredit,
1616
splitMultiEntityAmount,
1717
} from "@/internal/billing/v2/utils/lineItems/storedLineItemUtils";
@@ -25,6 +25,7 @@ const makeChargeRow = (
2525
): DbInvoiceLineItem =>
2626
({
2727
id: "li_charge_1",
28+
created_at: PERIOD_START,
2829
amount: 20,
2930
amount_after_discounts: 20,
3031
effective_period_start: PERIOD_START,
@@ -42,6 +43,7 @@ const makeRefundRow = (
4243
): DbInvoiceLineItem =>
4344
({
4445
id: "li_refund_1",
46+
created_at: PERIOD_START + 1000,
4547
amount: -10,
4648
amount_after_discounts: -10,
4749
effective_period_start: PERIOD_START,
@@ -129,33 +131,40 @@ describe(chalk.yellowBright("computeProratedCredit"), () => {
129131
});
130132
});
131133

132-
describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => {
134+
describe(chalk.yellowBright("computeAlreadyRefundedByCharge"), () => {
133135
test("sums matching refund rows by price and period", () => {
134-
const result = computeAlreadyRefundedForCharge({
135-
chargeRow: makeChargeRow(),
136+
const chargeRow = makeChargeRow();
137+
const result = computeAlreadyRefundedByCharge({
138+
chargeRows: [chargeRow],
136139
refundRows: [
137140
makeRefundRow({ amount_after_discounts: -5 }),
138-
makeRefundRow({ id: "li_refund_2", amount_after_discounts: -3 }),
141+
makeRefundRow({
142+
id: "li_refund_2",
143+
created_at: PERIOD_START + 2000,
144+
amount_after_discounts: -3,
145+
}),
139146
],
140147
});
141148

142-
expect(result).toBe(8);
149+
expect(result.get(chargeRow.id)).toBe(8);
143150
});
144151

145152
test("excludes refunds with different price_id", () => {
146-
const result = computeAlreadyRefundedForCharge({
147-
chargeRow: makeChargeRow(),
153+
const chargeRow = makeChargeRow();
154+
const result = computeAlreadyRefundedByCharge({
155+
chargeRows: [chargeRow],
148156
refundRows: [
149157
makeRefundRow({ price_id: "price_other", stripe_price_id: "other" }),
150158
],
151159
});
152160

153-
expect(result).toBe(0);
161+
expect(result.get(chargeRow.id)).toBeUndefined();
154162
});
155163

156164
test("excludes refunds outside the charge period", () => {
157-
const result = computeAlreadyRefundedForCharge({
158-
chargeRow: makeChargeRow(),
165+
const chargeRow = makeChargeRow();
166+
const result = computeAlreadyRefundedByCharge({
167+
chargeRows: [chargeRow],
159168
refundRows: [
160169
makeRefundRow({
161170
effective_period_start: PERIOD_END + 1000,
@@ -164,16 +173,44 @@ describe(chalk.yellowBright("computeAlreadyRefundedForCharge"), () => {
164173
],
165174
});
166175

167-
expect(result).toBe(0);
176+
expect(result.get(chargeRow.id)).toBeUndefined();
168177
});
169178

170-
test("returns 0 with no refund rows", () => {
171-
const result = computeAlreadyRefundedForCharge({
172-
chargeRow: makeChargeRow(),
179+
test("returns no allocations with no refund rows", () => {
180+
const result = computeAlreadyRefundedByCharge({
181+
chargeRows: [makeChargeRow()],
173182
refundRows: [],
174183
});
175184

176-
expect(result).toBe(0);
185+
expect(result.size).toBe(0);
186+
});
187+
188+
test("allocates a refund only to the latest matching earlier charge", () => {
189+
const originalCharge = makeChargeRow({
190+
id: "li_charge_5",
191+
created_at: PERIOD_START,
192+
invoice_id: "inv_initial",
193+
amount_after_discounts: 100,
194+
});
195+
const refund = makeRefundRow({
196+
created_at: PERIOD_START + 1000,
197+
invoice_id: "inv_update",
198+
amount_after_discounts: -100,
199+
});
200+
const replacementCharge = makeChargeRow({
201+
id: "li_charge_7",
202+
created_at: PERIOD_START + 500,
203+
invoice_id: "inv_update",
204+
amount_after_discounts: 140,
205+
});
206+
207+
const result = computeAlreadyRefundedByCharge({
208+
chargeRows: [originalCharge, replacementCharge],
209+
refundRows: [refund],
210+
});
211+
212+
expect(result.get(originalCharge.id)).toBe(100);
213+
expect(result.get(replacementCharge.id)).toBeUndefined();
177214
});
178215
});
179216

0 commit comments

Comments
 (0)