Skip to content

Commit 606562f

Browse files
committed
fix: persist custom catalog rows before pending plans
A deferred attach with a custom plan failed with a customer_prices foreign key violation: the pending insert wrote customer_prices referencing custom prices that only executeAutumnBillingPlan persisted, which the deferred path never reaches. Extract insertCustomCatalogRows and call it from both paths, so custom prices, entitlements and free trials exist before any customer row references them. Both services upsert on conflict, so promotion inserting them again is a no-op.
1 parent b57c014 commit 606562f

5 files changed

Lines changed: 124 additions & 38 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { AutumnBillingPlan } from "@autumn/shared";
2+
import type { AutumnContext } from "@/honoUtils/HonoEnv";
3+
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
4+
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
5+
import { PriceService } from "@/internal/products/prices/PriceService";
6+
7+
/** Custom catalog rows must exist before any customer row can reference them. */
8+
export const insertCustomCatalogRows = async ({
9+
ctx,
10+
autumnBillingPlan,
11+
}: {
12+
ctx: AutumnContext;
13+
autumnBillingPlan: AutumnBillingPlan;
14+
}) => {
15+
const { customPrices, customEntitlements, customFreeTrial } =
16+
autumnBillingPlan;
17+
18+
if (customEntitlements) {
19+
await EntitlementService.insert({ db: ctx.db, data: customEntitlements });
20+
}
21+
22+
if (customPrices) {
23+
await PriceService.insert({ db: ctx.db, data: customPrices });
24+
}
25+
26+
if (customFreeTrial) {
27+
await FreeTrialService.insert({ db: ctx.db, data: customFreeTrial });
28+
}
29+
};

server/src/internal/billing/v2/execute/executeAutumnBillingPlan.ts

Lines changed: 4 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { executeCustomerLicenseUpdates } from "@/internal/billing/v2/execute/exe
1010
import { executeInsertPlanLicenses } from "@/internal/billing/v2/execute/executeAutumnActions/executeInsertPlanLicenses";
1111
import { executeOneOffPurchaseRebalance } from "@/internal/billing/v2/execute/executeAutumnActions/executeOneOffPurchaseRebalance";
1212
import { executePatchCustomerProducts } from "@/internal/billing/v2/execute/executeAutumnActions/executePatchCustomerProducts";
13+
import { insertCustomCatalogRows } from "@/internal/billing/v2/execute/executeAutumnActions/insertCustomCatalogRows";
1314
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
1415
import { updateCustomerEntitlements } from "@/internal/billing/v2/execute/executeAutumnActions/updateCustomerEntitlements";
1516
import { executePooledBalancePlan } from "@/internal/billing/v2/pooledBalances/execute/executePooledBalancePlan";
@@ -22,9 +23,6 @@ import { CusProductService } from "@/internal/customers/cusProducts/CusProductSe
2223
import { CusEntService } from "@/internal/customers/cusProducts/cusEnts/CusEntitlementService";
2324
import { replaceScheduledPhaseCustomerProductIds } from "@/internal/customers/schedules/repos/replaceScheduledPhaseCustomerProductIds";
2425
import { invoiceActions } from "@/internal/invoices/actions";
25-
import { EntitlementService } from "@/internal/products/entitlements/EntitlementService";
26-
import { FreeTrialService } from "@/internal/products/free-trials/FreeTrialService";
27-
import { PriceService } from "@/internal/products/prices/PriceService";
2826
import { SubService } from "@/internal/subscriptions/SubService";
2927
import { workflows } from "@/queue/workflows";
3028

@@ -48,40 +46,16 @@ export const executeAutumnBillingPlan = async ({
4846
// an update entry to persist it.
4947
await applyDerivedCustomerProductIsCustom({ ctx, autumnBillingPlan });
5048

51-
const {
52-
insertCustomerProducts,
53-
customPrices,
54-
customEntitlements,
55-
customFreeTrial,
56-
insertCustomerEntitlements,
57-
} = autumnBillingPlan;
49+
const { insertCustomerProducts, insertCustomerEntitlements } =
50+
autumnBillingPlan;
5851
const updateCustomerProducts = getUpdateCustomerProducts({
5952
autumnBillingPlan,
6053
});
6154
const deleteCustomerProducts = getDeleteCustomerProducts({
6255
autumnBillingPlan,
6356
});
6457

65-
if (customEntitlements) {
66-
await EntitlementService.insert({
67-
db,
68-
data: customEntitlements,
69-
});
70-
}
71-
72-
if (customPrices) {
73-
await PriceService.insert({
74-
db,
75-
data: customPrices,
76-
});
77-
}
78-
79-
if (customFreeTrial) {
80-
await FreeTrialService.insert({
81-
db,
82-
data: customFreeTrial,
83-
});
84-
}
58+
await insertCustomCatalogRows({ ctx, autumnBillingPlan });
8559

8660
await executeInsertPlanLicenses({
8761
ctx,

server/src/internal/billing/v2/execute/insertPendingCustomerProducts.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { type BillingPlan, CusProductStatus } from "@autumn/shared";
22
import type { AutumnContext } from "@/honoUtils/HonoEnv";
3+
import { insertCustomCatalogRows } from "@/internal/billing/v2/execute/executeAutumnActions/insertCustomCatalogRows";
34
import { insertNewCusProducts } from "@/internal/billing/v2/execute/executeAutumnActions/insertNewCusProducts";
45

56
export const insertPendingCustomerProducts = async ({
@@ -11,9 +12,13 @@ export const insertPendingCustomerProducts = async ({
1112
billingPlan: BillingPlan;
1213
metadataId: string;
1314
}) => {
14-
const { insertCustomerProducts } = billingPlan.autumn;
15+
const autumnBillingPlan = billingPlan.autumn;
16+
const { insertCustomerProducts } = autumnBillingPlan;
17+
1518
if (!insertCustomerProducts?.length) return;
1619

20+
await insertCustomCatalogRows({ ctx, autumnBillingPlan });
21+
1722
await insertNewCusProducts({
1823
ctx,
1924
newCusProducts: insertCustomerProducts.map((customerProduct) => ({

server/tests/integration/billing/attach/pending-customer-products.test.ts

Lines changed: 47 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { expectBalanceCorrect } from "@tests/integration/utils/expectBalanceCorr
99
import { TestFeature } from "@tests/setup/v2Features.js";
1010
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
1111
import { items } from "@tests/utils/fixtures/items.js";
12+
import { itemsV2 } from "@tests/utils/fixtures/itemsV2";
1213
import { products } from "@tests/utils/fixtures/products.js";
1314
import { timeout } from "@tests/utils/genUtils";
1415
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario.js";
@@ -131,24 +132,24 @@ test.concurrent(
131132
test.concurrent(
132133
`${chalk.yellowBright("stripe-checkout pending: deferred checkout inserts a pending plan that grants nothing")}`,
133134
async () => {
134-
const customerId = "stripe-checkout-pending-inserted";
135+
const customerId = `stripe-checkout-pending-inserted-${Date.now()}`;
135136
const pro = products.pro({
136137
id: "pro-checkout-pending",
137138
items: [items.monthlyMessages({ includedUsage: 100 })],
138139
});
139140

140-
const { ctx, autumnV2_2, customer } = await initScenario({
141+
const { ctx, autumnV1, autumnV2_2, customer } = await initScenario({
141142
customerId,
142-
setup: [s.customer({ testClock: false }), s.products({ list: [pro] })],
143+
setup: [s.customer({ testClock: true }), s.products({ list: [pro] })],
143144
actions: [],
144145
});
145146

146-
const result = await autumnV2_2.billing.attach<AttachParamsV1Input>({
147+
const result = await autumnV1.billing.attach({
147148
customer_id: customerId,
148-
plan_id: pro.id,
149+
product_id: pro.id,
149150
});
150151

151-
expect(result.payment_url).toBeDefined();
152+
expect(result.payment_url).toContain("checkout.stripe.com");
152153

153154
const customerProducts = await listCustomerProducts({
154155
ctx,
@@ -173,7 +174,7 @@ test.concurrent(
173174
test.concurrent(
174175
`${chalk.yellowBright("stripe-checkout pending: completing checkout promotes the pending plan to active")}`,
175176
async () => {
176-
const customerId = "stripe-checkout-pending-promoted";
177+
const customerId = `stripe-checkout-pending-promoted-${Date.now()}`;
177178
const pro = products.pro({
178179
id: "pro-checkout-promoted",
179180
items: [items.monthlyMessages({ includedUsage: 100 })],
@@ -213,3 +214,42 @@ test.concurrent(
213214
expect(check.allowed).toBe(true);
214215
},
215216
);
217+
218+
test.concurrent(
219+
`${chalk.yellowBright("pending custom plan: deferred attach with custom prices inserts a pending plan")}`,
220+
async () => {
221+
const customerId = "pending-custom-plan-prices";
222+
const pro = products.pro({
223+
id: "pro-pending-custom",
224+
items: [items.monthlyMessages({ includedUsage: 100 })],
225+
});
226+
227+
const { ctx, autumnV2_2, customer } = await initScenario({
228+
customerId,
229+
setup: [s.customer({ testClock: false }), s.products({ list: [pro] })],
230+
actions: [],
231+
});
232+
233+
await autumnV2_2.billing.attach<AttachParamsV1Input>({
234+
customer_id: customerId,
235+
plan_id: pro.id,
236+
customize: { price: itemsV2.monthlyPrice({ amount: 42 }) },
237+
invoice_mode: {
238+
enabled: true,
239+
enable_plan_immediately: false,
240+
finalize: true,
241+
},
242+
});
243+
244+
const customerProducts = await listCustomerProducts({
245+
ctx,
246+
internalCustomerId: customer?.internal_id ?? "",
247+
});
248+
const pendingCustomerProduct = customerProducts.find(
249+
(customerProduct) => customerProduct.product.id === pro.id,
250+
);
251+
252+
expect(pendingCustomerProduct?.status).toBe(CusProductStatus.Pending);
253+
expect(pendingCustomerProduct?.customer_prices.length).toBeGreaterThan(0);
254+
},
255+
);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { test } from "bun:test";
2+
import { items } from "@tests/utils/fixtures/items";
3+
import { products } from "@tests/utils/fixtures/products";
4+
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
5+
import chalk from "chalk";
6+
7+
/**
8+
* Past Due Scenario
9+
*
10+
* Sets up a customer whose renewal was declined, leaving the Stripe
11+
* subscription past_due. The initial charge has to succeed, so the card is
12+
* swapped for a failing one before the clock advances.
13+
*
14+
* Setup:
15+
* - Pro product: $20/month with 100 messages
16+
* - Customer attached with a working card, then moved onto pm_card_chargeCustomerFail
17+
* - Clock advanced one cycle so the renewal invoice fails
18+
*/
19+
20+
test(`${chalk.yellowBright("past-due: customer with a declined renewal")}`, async () => {
21+
const customerId = "past-due";
22+
const messagesItem = items.monthlyMessages({ includedUsage: 100 });
23+
24+
const pro = products.pro({ items: [messagesItem] });
25+
26+
await initScenario({
27+
customerId,
28+
setup: [
29+
s.customer({ paymentMethod: "success" }),
30+
s.products({ list: [pro] }),
31+
],
32+
actions: [
33+
s.billing.attach({ productId: pro.id }),
34+
s.attachPaymentMethod({ type: "fail" }),
35+
s.advanceToNextInvoice(),
36+
],
37+
});
38+
});

0 commit comments

Comments
 (0)