Skip to content

Commit 50f0257

Browse files
authored
Merge branch 'main' into fix/discounts-on-update
2 parents 40ba90e + 4d8c469 commit 50f0257

File tree

32 files changed

+852
-471
lines changed

32 files changed

+852
-471
lines changed

server/src/internal/billing/v2/providers/stripe/setup/fetchStripeDiscountsForBilling.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ export const filterDeletedCouponDiscounts = async ({
6565
if (
6666
error instanceof Stripe.errors.StripeError &&
6767
error.code?.includes("resource_missing")
68-
) return false;
68+
)
69+
return false;
6970
throw error;
7071
}
7172
}),
@@ -76,8 +77,8 @@ export const filterDeletedCouponDiscounts = async ({
7677

7778
/**
7879
* Fetches discounts for billing, combining existing Stripe discounts with optional param discounts.
79-
* Resolves param discounts via Stripe API and merges with existing subscription/customer discounts.
80-
* Deduplicates by coupon ID. Filters out discounts with deleted coupons.
80+
* Deduplicates by coupon ID — logs and skips param discounts already on the subscription.
81+
* Filters out discounts with deleted coupons.
8182
*/
8283
export const fetchStripeDiscountsForBilling = async ({
8384
ctx,
@@ -109,13 +110,19 @@ export const fetchStripeDiscountsForBilling = async ({
109110
discounts: paramDiscounts,
110111
});
111112

112-
// Merge with existing discounts, deduplicating by coupon ID
113+
// Deduplicate by coupon ID — skip param discounts already on the subscription
113114
const existingCouponIds = new Set(
114115
existingDiscounts.map((d) => d.source.coupon.id),
115116
);
116-
const newDiscounts = resolvedParamDiscounts.filter(
117-
(d) => !existingCouponIds.has(d.source.coupon.id),
118-
);
117+
const newDiscounts = resolvedParamDiscounts.filter((d) => {
118+
if (existingCouponIds.has(d.source.coupon.id)) {
119+
ctx.logger.warn(
120+
`[fetchStripeDiscountsForBilling] Skipping duplicate discount ${d.source.coupon.id} — already applied to subscription`,
121+
);
122+
return false;
123+
}
124+
return true;
125+
});
119126

120127
const allDiscounts = [...existingDiscounts, ...newDiscounts];
121128
return filterDeletedCouponDiscounts({ stripeCli, discounts: allDiscounts });

server/src/queue/initWorkers.ts

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,12 @@ const isFifoQueue = QUEUE_URL.endsWith(".fifo");
2525

2626
// Stats tracking
2727
let messagesProcessed = 0;
28+
let totalMessagesProcessed = 0;
2829
let lastStatsTime = Date.now();
2930

31+
// Process recycling — exit after processing this many messages to prevent memory leaks
32+
const MAX_MESSAGES_BEFORE_RECYCLE = 500_000;
33+
3034
// Stale connection detection
3135
let consecutiveEmptyPolls = 0;
3236
let lastHeartbeatTime = Date.now();
@@ -56,8 +60,9 @@ const alertZeroMessages = () => {
5660

5761
const logStatsAndCheckZeroMessages = () => {
5862
const elapsedSeconds = ((Date.now() - lastStatsTime) / 1000).toFixed(0);
63+
const mem = process.memoryUsage();
5964
console.log(
60-
`${logPrefix()} Processed ${messagesProcessed} messages in ${elapsedSeconds}s`,
65+
`${logPrefix()} Processed ${messagesProcessed} messages in ${elapsedSeconds}s | rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB total=${totalMessagesProcessed}`,
6166
);
6267

6368
if (messagesProcessed === 0) {
@@ -123,6 +128,7 @@ const handleSingleMessage = async ({
123128

124129
await processMessage({ message, db });
125130
messagesProcessed++;
131+
totalMessagesProcessed++;
126132

127133
// Return delete info (skip migration jobs - already deleted)
128134
if (message.ReceiptHandle && job.name !== JobName.Migration) {
@@ -243,6 +249,20 @@ const startPollingLoop = async ({ db }: { db: DrizzleCli }) => {
243249
}));
244250

245251
await batchDeleteMessages({ sqs, toDelete });
252+
253+
// Clear Sentry scope to prevent memory accumulation from breadcrumbs/tags
254+
Sentry.getCurrentScope().clear();
255+
256+
// Recycle process to prevent memory leaks from long-running workers
257+
// Exit with code 0 so cluster primary respawns a fresh worker
258+
if (totalMessagesProcessed >= MAX_MESSAGES_BEFORE_RECYCLE) {
259+
const mem = process.memoryUsage();
260+
console.log(
261+
`${logPrefix()} Recycling after ${totalMessagesProcessed} messages (rss=${(mem.rss / 1024 / 1024).toFixed(0)}MB heap=${(mem.heapUsed / 1024 / 1024).toFixed(0)}MB)`,
262+
);
263+
clearInterval(statsInterval);
264+
process.exit(0);
265+
}
246266
} else {
247267
const newClient = handleEmptyPoll();
248268
if (newClient) sqs = newClient;
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
/**
2+
* Integration tests for duplicate discount handling across billing cycles.
3+
*
4+
* Tests that when a discount code already applied to a subscription is passed
5+
* again on a subsequent billing cycle, it is silently ignored (not applied twice)
6+
* and the attach still succeeds.
7+
*/
8+
9+
import { expect, test } from "bun:test";
10+
import type { ApiCustomerV3 } from "@autumn/shared";
11+
import { expectCustomerProducts } from "@tests/integration/billing/utils/expectCustomerProductCorrect.js";
12+
import { hoursToFinalizeInvoice } from "@tests/utils/constants.js";
13+
import { items } from "@tests/utils/fixtures/items.js";
14+
import { products } from "@tests/utils/fixtures/products.js";
15+
import { advanceTestClock } from "@tests/utils/stripeUtils.js";
16+
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
17+
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
18+
import chalk from "chalk";
19+
import { addHours, addMonths } from "date-fns";
20+
import { createStripeCli } from "@/external/connect/createStripeCli.js";
21+
import {
22+
createPercentCoupon,
23+
getStripeSubscription,
24+
} from "../../utils/discounts/discountTestUtils.js";
25+
26+
// ═══════════════════════════════════════════════════════════════════════════════
27+
// TEST 1: billing.attach - duplicate discount on next cycle is silently ignored
28+
// ═══════════════════════════════════════════════════════════════════════════════
29+
30+
/**
31+
* Scenario:
32+
* - Customer on free (no subscription)
33+
* - Cycle 1: Attach pro WITH a 20% forever coupon → subscription created with coupon
34+
* - Advance to next invoice (cycle 2)
35+
* - Cycle 2: Upgrade to premium WITH same coupon → attach succeeds, duplicate ignored
36+
*
37+
* Expected:
38+
* - No error — attach succeeds
39+
* - premium is active, coupon not double-applied
40+
*/
41+
test.concurrent(`${chalk.yellowBright("attach-discount-double-use 1: billing.attach - duplicate discount on next cycle silently ignored")}`, async () => {
42+
const customerId = "att-disc-double-use-attach";
43+
44+
const free = products.base({
45+
id: "free",
46+
items: [items.monthlyMessages({ includedUsage: 100 })],
47+
});
48+
49+
const pro = products.pro({
50+
id: "pro",
51+
items: [items.monthlyMessages({ includedUsage: 500 })],
52+
});
53+
54+
const premium = products.premium({
55+
id: "premium",
56+
items: [items.monthlyMessages({ includedUsage: 1000 })],
57+
});
58+
59+
const { autumnV1, advancedTo, testClockId } = await initScenario({
60+
customerId,
61+
setup: [
62+
s.customer({ paymentMethod: "success" }),
63+
s.products({ list: [free, pro, premium] }),
64+
],
65+
actions: [s.billing.attach({ productId: free.id })],
66+
});
67+
68+
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
69+
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
70+
71+
// Cycle 1: Attach pro with discount
72+
await autumnV1.billing.attach({
73+
customer_id: customerId,
74+
product_id: pro.id,
75+
discounts: [{ reward_id: coupon.id }],
76+
});
77+
78+
// Advance to next invoice (start of cycle 2)
79+
await advanceTestClock({
80+
stripeCli: ctx.stripeCli,
81+
testClockId: testClockId!,
82+
advanceTo: addHours(
83+
addMonths(new Date(advancedTo), 1),
84+
hoursToFinalizeInvoice,
85+
).getTime(),
86+
waitForSeconds: 30,
87+
});
88+
89+
// Cycle 2: Upgrade with same discount — should succeed, duplicate silently ignored
90+
await autumnV1.billing.attach({
91+
customer_id: customerId,
92+
product_id: premium.id,
93+
discounts: [{ reward_id: coupon.id }],
94+
});
95+
96+
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
97+
await expectCustomerProducts({
98+
customer,
99+
active: [premium.id],
100+
notPresent: [pro.id],
101+
});
102+
103+
// Coupon must appear exactly once — not double-applied
104+
const { subscription } = await getStripeSubscription({ customerId });
105+
const couponDiscounts = subscription.discounts.filter((d) => {
106+
if (typeof d === "string") return false;
107+
const c = d.source?.coupon;
108+
return typeof c !== "string" && c?.id === coupon.id;
109+
});
110+
expect(couponDiscounts).toHaveLength(1);
111+
});
112+
113+
// ═══════════════════════════════════════════════════════════════════════════════
114+
// TEST 2: billing.multiAttach - duplicate discount on next cycle is silently ignored
115+
// ═══════════════════════════════════════════════════════════════════════════════
116+
117+
/**
118+
* Scenario:
119+
* - Customer on free (no subscription)
120+
* - Cycle 1: multiAttach [pro] WITH a 20% forever coupon → subscription created with coupon
121+
* - Advance to next invoice (cycle 2)
122+
* - Cycle 2: multiAttach [addon] WITH same coupon → attach succeeds, duplicate ignored
123+
*
124+
* Expected:
125+
* - No error — attach succeeds
126+
* - addon is active
127+
*/
128+
test.concurrent(`${chalk.yellowBright("attach-discount-double-use 2: billing.multiAttach - duplicate discount on next cycle silently ignored")}`, async () => {
129+
const customerId = "att-disc-double-use-multi";
130+
131+
const free = products.base({
132+
id: "free",
133+
items: [items.monthlyMessages({ includedUsage: 100 })],
134+
});
135+
136+
const pro = products.pro({
137+
id: "pro",
138+
items: [items.monthlyMessages({ includedUsage: 500 })],
139+
});
140+
141+
const addon = products.recurringAddOn({
142+
id: "addon",
143+
items: [items.monthlyWords({ includedUsage: 100 })],
144+
});
145+
146+
const { autumnV1, advancedTo, testClockId } = await initScenario({
147+
customerId,
148+
setup: [
149+
s.customer({ paymentMethod: "success" }),
150+
s.products({ list: [free, pro, addon] }),
151+
],
152+
actions: [s.billing.attach({ productId: free.id })],
153+
});
154+
155+
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
156+
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
157+
158+
// Cycle 1: multiAttach pro with discount
159+
await autumnV1.billing.multiAttach({
160+
customer_id: customerId,
161+
plans: [{ plan_id: pro.id }],
162+
discounts: [{ reward_id: coupon.id }],
163+
});
164+
165+
// Advance to next invoice (start of cycle 2)
166+
await advanceTestClock({
167+
stripeCli: ctx.stripeCli,
168+
testClockId: testClockId!,
169+
advanceTo: addHours(
170+
addMonths(new Date(advancedTo), 1),
171+
hoursToFinalizeInvoice,
172+
).getTime(),
173+
waitForSeconds: 30,
174+
});
175+
176+
// Cycle 2: multiAttach addon with same discount — should succeed, duplicate silently ignored
177+
await autumnV1.billing.multiAttach({
178+
customer_id: customerId,
179+
plans: [{ plan_id: addon.id }],
180+
discounts: [{ reward_id: coupon.id }],
181+
});
182+
183+
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
184+
await expectCustomerProducts({
185+
customer,
186+
active: [pro.id, addon.id],
187+
notPresent: [free.id],
188+
});
189+
190+
// Coupon must appear exactly once on the pro subscription — not double-applied
191+
const { subscription } = await getStripeSubscription({ customerId });
192+
const couponDiscounts = subscription.discounts.filter((d) => {
193+
if (typeof d === "string") return false;
194+
const c = d.source?.coupon;
195+
return typeof c !== "string" && c?.id === coupon.id;
196+
});
197+
expect(couponDiscounts).toHaveLength(1);
198+
});
199+
200+
// ═══════════════════════════════════════════════════════════════════════════════
201+
// TEST 3: same coupon on a different customer's subscription → allowed
202+
// ═══════════════════════════════════════════════════════════════════════════════
203+
204+
/**
205+
* Scenario:
206+
* - Customer 1 attaches pro WITH a 20% forever coupon → their subscription gets the coupon
207+
* - Customer 2 (different subscription) attaches pro with the SAME coupon → should succeed
208+
*
209+
* This confirms the check is per-subscription, not a global coupon blacklist.
210+
* The same coupon ID may be freely used across different customers' subscriptions.
211+
*/
212+
test.concurrent(`${chalk.yellowBright("attach-discount-double-use 3: same coupon on different customer subscription → allowed")}`, async () => {
213+
const customerId1 = "att-disc-double-use-c1";
214+
const customerId2 = "att-disc-double-use-c2";
215+
216+
// Separate product objects per customer — initScenario mutates product.id in-place
217+
const free1 = products.base({
218+
id: "free",
219+
items: [items.monthlyMessages({ includedUsage: 100 })],
220+
});
221+
const pro1 = products.pro({
222+
id: "pro",
223+
items: [items.monthlyMessages({ includedUsage: 500 })],
224+
});
225+
226+
const free2 = products.base({
227+
id: "free",
228+
items: [items.monthlyMessages({ includedUsage: 100 })],
229+
});
230+
const pro2 = products.pro({
231+
id: "pro",
232+
items: [items.monthlyMessages({ includedUsage: 500 })],
233+
});
234+
235+
const { autumnV1 } = await initScenario({
236+
customerId: customerId1,
237+
setup: [
238+
s.customer({ paymentMethod: "success" }),
239+
s.products({ list: [free1, pro1] }),
240+
],
241+
actions: [s.billing.attach({ productId: free1.id })],
242+
});
243+
244+
await initScenario({
245+
customerId: customerId2,
246+
setup: [
247+
s.customer({ paymentMethod: "success" }),
248+
s.products({ list: [free2, pro2] }),
249+
],
250+
actions: [s.billing.attach({ productId: free2.id })],
251+
});
252+
253+
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
254+
const coupon = await createPercentCoupon({ stripeCli, percentOff: 20 });
255+
256+
// Customer 1: attach pro with coupon → their subscription gets the coupon
257+
await autumnV1.billing.attach({
258+
customer_id: customerId1,
259+
product_id: pro1.id,
260+
discounts: [{ reward_id: coupon.id }],
261+
});
262+
263+
// Customer 2: attach pro with the SAME coupon → different subscription → must succeed
264+
await autumnV1.billing.attach({
265+
customer_id: customerId2,
266+
product_id: pro2.id,
267+
discounts: [{ reward_id: coupon.id }],
268+
});
269+
270+
const customer2 = await autumnV1.customers.get<ApiCustomerV3>(customerId2);
271+
await expectCustomerProducts({
272+
customer: customer2,
273+
active: [pro2.id],
274+
notPresent: [free2.id],
275+
});
276+
});

shared/api/products/items/mappers/planItemV0ToProductItem.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export const planItemV0ToProductItem = ({
145145
tiers: planItem.price?.tiers?.map((tier) => ({
146146
amount: tier.amount,
147147
to: tier.to,
148+
flat_amount: tier.flat_amount,
148149
})),
149150
tier_behavior: planItem.price?.tier_behavior,
150151

shared/utils/cusEntUtils/cusEntUtils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ export const isEntityCusEnt = ({
2121
}): boolean => {
2222
return !!(
2323
cusEnt.entitlement.entity_feature_id ||
24-
cusEnt.customer_product?.internal_entity_id
24+
cusEnt.customer_product?.internal_entity_id ||
25+
cusEnt?.internal_entity_id
2526
);
2627
};
2728

0 commit comments

Comments
 (0)