Skip to content

Commit d2d2043

Browse files
committed
fix(sync): preserve matched plan version
1 parent 90273f6 commit d2d2043

2 files changed

Lines changed: 291 additions & 0 deletions

File tree

server/src/internal/billing/v2/actions/sync/subscriptionToSyncParams.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const matchedPlanToSyncPlan = ({
4343
: matchedPlan.customize;
4444
return {
4545
plan_id: matchedPlan.product.id,
46+
version: matchedPlan.product.version,
4647
quantity: matchedPlan.quantity,
4748
customize,
4849
expire_previous: true,
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/** Exact Stripe Price IDs must select and persist the matching plan version. */
2+
3+
import { expect, test } from "bun:test";
4+
import {
5+
filterCustomerProductsByActiveStatuses,
6+
filterCustomerProductsByStripeSubscriptionId,
7+
findCustomerEntitlementByFeature,
8+
isFixedPrice,
9+
type SyncParamsV1,
10+
} from "@autumn/shared";
11+
import { TestFeature } from "@tests/setup/v2Features";
12+
import { items } from "@tests/utils/fixtures/items";
13+
import { products } from "@tests/utils/fixtures/products";
14+
import ctx from "@tests/utils/testInitUtils/createTestContext";
15+
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
16+
import chalk from "chalk";
17+
import { billingActions } from "@/internal/billing/v2/actions";
18+
import { subscriptionToSyncParams } from "@/internal/billing/v2/actions/sync/subscriptionToSyncParams";
19+
import { CusService } from "@/internal/customers/CusService";
20+
import { CusProductService } from "@/internal/customers/cusProducts/CusProductService";
21+
import { ProductService } from "@/internal/products/ProductService";
22+
import { PriceService } from "@/internal/products/prices/PriceService";
23+
import {
24+
createStripeFixedPriceUnderProduct,
25+
createStripeSubscriptionSchedule,
26+
getBaseStripePriceId,
27+
getProductStripeProductId,
28+
getStripeCustomerId,
29+
} from "./utils/syncProductHelpers";
30+
31+
const setupVersionedPlan = async ({
32+
planId,
33+
customerId,
34+
}: {
35+
planId: string;
36+
customerId: string;
37+
}) => {
38+
const plan = products.pro({
39+
id: planId,
40+
items: [items.monthlyMessages({ includedUsage: 100 })],
41+
});
42+
const { autumnV2_2 } = await initScenario({
43+
customerId,
44+
setup: [
45+
s.customer({ paymentMethod: "success" }),
46+
s.products({ list: [plan], prefix: "" }),
47+
],
48+
actions: [],
49+
});
50+
51+
await autumnV2_2.post("/catalog.update", {
52+
plans: [
53+
{
54+
plan_id: planId,
55+
name: plan.name,
56+
force_version: true,
57+
items: [
58+
{
59+
feature_id: TestFeature.Messages,
60+
included: 200,
61+
reset: { interval: "month" },
62+
},
63+
],
64+
},
65+
],
66+
});
67+
68+
const [v1, v2] = await Promise.all(
69+
[1, 2].map((version) =>
70+
ProductService.getFull({
71+
db: ctx.db,
72+
orgId: ctx.org.id,
73+
env: ctx.env,
74+
idOrInternalId: planId,
75+
version,
76+
}),
77+
),
78+
);
79+
80+
const v1PriceId = getBaseStripePriceId({ fullProduct: v1 });
81+
const v2BasePrice = v2.prices[0];
82+
if (!v2BasePrice || !isFixedPrice(v2BasePrice)) {
83+
throw new Error(`Expected a fixed v2 base price for ${planId}`);
84+
}
85+
const v2StripePrice = await createStripeFixedPriceUnderProduct({
86+
ctx,
87+
stripeProductId: getProductStripeProductId({ fullProduct: v2 }),
88+
unitAmount: 2_000,
89+
});
90+
await PriceService.updateConfig({
91+
db: ctx.db,
92+
id: v2BasePrice.id,
93+
config: {
94+
...v2BasePrice.config,
95+
stripe_price_id: v2StripePrice.id,
96+
},
97+
});
98+
99+
return { v1, v2, v1PriceId, v2PriceId: v2StripePrice.id };
100+
};
101+
102+
const createSubscription = async ({
103+
customerId,
104+
priceId,
105+
}: {
106+
customerId: string;
107+
priceId: string;
108+
}) =>
109+
ctx.stripeCli.subscriptions.create({
110+
customer: await getStripeCustomerId({ ctx, customerId }),
111+
items: [{ price: priceId }],
112+
});
113+
114+
const getPhases = ({ params }: { params: SyncParamsV1 }) => {
115+
if (!params.phases) throw new Error("Expected sync phases");
116+
return params.phases;
117+
};
118+
119+
const expectLinkedVersion = async ({
120+
customerId,
121+
subscriptionId,
122+
internalProductId,
123+
expectedAllowance,
124+
}: {
125+
customerId: string;
126+
subscriptionId: string;
127+
internalProductId: string;
128+
expectedAllowance: number;
129+
}) => {
130+
const customer = await CusService.getFull({
131+
ctx,
132+
idOrInternalId: customerId,
133+
withEntities: true,
134+
withSubs: true,
135+
});
136+
const linked = filterCustomerProductsByActiveStatuses({
137+
customerProducts: filterCustomerProductsByStripeSubscriptionId({
138+
customerProducts: customer.customer_products,
139+
stripeSubscriptionId: subscriptionId,
140+
}),
141+
});
142+
expect(linked).toHaveLength(1);
143+
expect(linked[0]?.internal_product_id).toBe(internalProductId);
144+
const messages = findCustomerEntitlementByFeature({
145+
cusEnts: linked[0]?.customer_entitlements ?? [],
146+
featureId: TestFeature.Messages,
147+
errorOnNotFound: true,
148+
});
149+
expect(messages.entitlement.allowance).toBe(expectedAllowance);
150+
};
151+
152+
test(`${chalk.yellowBright("billing.sync: exact Stripe Price selects plan version")}`, async () => {
153+
const suffix = Math.random().toString(36).slice(2, 9);
154+
const planId = `sync_price_version_${suffix}`;
155+
const v1CustomerId = `sync-price-version-v1-${suffix}`;
156+
const v2CustomerId = `sync-price-version-v2-${suffix}`;
157+
const { v1, v2, v1PriceId, v2PriceId } = await setupVersionedPlan({
158+
planId,
159+
customerId: v1CustomerId,
160+
});
161+
await initScenario({
162+
customerId: v2CustomerId,
163+
setup: [s.customer({ paymentMethod: "success" })],
164+
actions: [],
165+
});
166+
const allVersions = await ProductService.listFull({
167+
db: ctx.db,
168+
orgId: ctx.org.id,
169+
env: ctx.env,
170+
returnAll: true,
171+
});
172+
173+
const v1Subscription = await createSubscription({
174+
customerId: v1CustomerId,
175+
priceId: v1PriceId,
176+
});
177+
const v1Proposal = await subscriptionToSyncParams({
178+
ctx,
179+
customerId: v1CustomerId,
180+
subscription: v1Subscription,
181+
fullProducts: allVersions,
182+
});
183+
expect(getPhases({ params: v1Proposal.params })[0]?.plans[0]).toMatchObject({
184+
plan_id: planId,
185+
version: 1,
186+
});
187+
await billingActions.syncV2({ ctx, params: v1Proposal.params });
188+
await expectLinkedVersion({
189+
customerId: v1CustomerId,
190+
subscriptionId: v1Subscription.id,
191+
internalProductId: v1.internal_id,
192+
expectedAllowance: 100,
193+
});
194+
195+
const v2Subscription = await createSubscription({
196+
customerId: v2CustomerId,
197+
priceId: v2PriceId,
198+
});
199+
const v2Proposal = await subscriptionToSyncParams({
200+
ctx,
201+
customerId: v2CustomerId,
202+
subscription: v2Subscription,
203+
fullProducts: allVersions,
204+
});
205+
expect(getPhases({ params: v2Proposal.params })[0]?.plans[0]).toMatchObject({
206+
plan_id: planId,
207+
version: 2,
208+
});
209+
await billingActions.syncV2({ ctx, params: v2Proposal.params });
210+
await expectLinkedVersion({
211+
customerId: v2CustomerId,
212+
subscriptionId: v2Subscription.id,
213+
internalProductId: v2.internal_id,
214+
expectedAllowance: 200,
215+
});
216+
217+
await billingActions.syncV2({ ctx, params: v2Proposal.params });
218+
await expectLinkedVersion({
219+
customerId: v2CustomerId,
220+
subscriptionId: v2Subscription.id,
221+
internalProductId: v2.internal_id,
222+
expectedAllowance: 200,
223+
});
224+
});
225+
226+
test(`${chalk.yellowBright("billing.sync: schedule phases select exact plan versions")}`, async () => {
227+
const suffix = Math.random().toString(36).slice(2, 9);
228+
const planId = `sync_schedule_version_${suffix}`;
229+
const customerId = `sync-schedule-version-${suffix}`;
230+
const { v1, v2, v1PriceId, v2PriceId } = await setupVersionedPlan({
231+
planId,
232+
customerId,
233+
});
234+
const { subscription, schedule } = await createStripeSubscriptionSchedule({
235+
ctx,
236+
customerId,
237+
phases: [
238+
{ items: [{ price: v1PriceId }] },
239+
{ items: [{ price: v2PriceId }] },
240+
],
241+
});
242+
const allVersions = await ProductService.listFull({
243+
db: ctx.db,
244+
orgId: ctx.org.id,
245+
env: ctx.env,
246+
returnAll: true,
247+
});
248+
const proposal = await subscriptionToSyncParams({
249+
ctx,
250+
customerId,
251+
subscription,
252+
schedule,
253+
fullProducts: allVersions,
254+
});
255+
256+
const phases = getPhases({ params: proposal.params });
257+
expect(phases).toHaveLength(2);
258+
expect(phases[0]?.plans[0]).toMatchObject({
259+
plan_id: planId,
260+
version: 1,
261+
});
262+
expect(phases[1]?.plans[0]).toMatchObject({
263+
plan_id: planId,
264+
version: 2,
265+
});
266+
267+
const result = await billingActions.syncV2({ ctx, params: proposal.params });
268+
await expectLinkedVersion({
269+
customerId,
270+
subscriptionId: subscription.id,
271+
internalProductId: v1.internal_id,
272+
expectedAllowance: 100,
273+
});
274+
expect(result.scheduled_phases).toHaveLength(2);
275+
const scheduledId =
276+
result.scheduled_phases[result.scheduled_phases.length - 1]
277+
?.customer_product_ids[0];
278+
if (!scheduledId) throw new Error("Expected a scheduled customer product");
279+
const scheduled = await CusProductService.getFull({
280+
db: ctx.db,
281+
id: scheduledId,
282+
});
283+
expect(scheduled?.internal_product_id).toBe(v2.internal_id);
284+
const scheduledMessages = findCustomerEntitlementByFeature({
285+
cusEnts: scheduled?.customer_entitlements ?? [],
286+
featureId: TestFeature.Messages,
287+
errorOnNotFound: true,
288+
});
289+
expect(scheduledMessages.entitlement.allowance).toBe(200);
290+
});

0 commit comments

Comments
 (0)