Skip to content

Commit 7a103db

Browse files
authored
Merge pull request #3241 from useautumn/claude/timeline-incident-check-1b6fji
fix: drop cached subject on checkout.session.expired
2 parents e71ec31 + 6e7ba99 commit 7a103db

2 files changed

Lines changed: 34 additions & 11 deletions

File tree

server/src/external/stripe/webhookMiddlewares/stripeWebhookRefreshMiddleware.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,15 @@ const updateProductEvents = [
1111
"customer.subscription.updated",
1212
];
1313

14+
// checkout.session.expired included: the handler expires cusProducts that were
15+
// pre-inserted for enable_plan_immediately / pending checkouts, which must also
16+
// drop the cached subject or the API keeps serving the plan as active.
1417
const coreEvents = [
1518
"customer.subscription.deleted",
1619
"subscription_schedule.canceled",
1720
"subscription_schedule.updated",
1821
"checkout.session.completed",
22+
"checkout.session.expired",
1923
];
2024

2125
const updateInvoiceEvents = [

server/tests/integration/billing/attach/checkout/stripe-checkout/stripe-checkout-enable-plan-immediately.test.ts

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
/**
22
* Stripe Checkout — Top-level enable_plan_immediately
33
*
4-
* Feature under test (currently unimplemented — these tests are RED on purpose):
4+
* Feature under test:
55
* - Top-level `enable_plan_immediately` on attach params (no longer nested under `invoice_mode`).
66
* - When set on a stripe_checkout flow, the customer_product is inserted as Active
77
* BEFORE the customer completes the Stripe-hosted checkout, with a new
@@ -26,6 +26,7 @@ import { TestFeature } from "@tests/setup/v2Features";
2626
import { completeStripeCheckoutFormV2 as completeStripeCheckoutForm } from "@tests/utils/browserPool/completeStripeCheckoutFormV2";
2727
import { items } from "@tests/utils/fixtures/items";
2828
import { products } from "@tests/utils/fixtures/products";
29+
import { waitForStripeWebhook } from "@tests/utils/stripeUtils/waitForStripeWebhook";
2930
import { initScenario, s } from "@tests/utils/testInitUtils/initScenario";
3031
import chalk from "chalk";
3132
import { eq } from "drizzle-orm";
@@ -156,11 +157,12 @@ test.concurrent(`${chalk.yellowBright("stripe-checkout enable_plan_immediately:
156157
// TEST 2: Abandoned checkout — cusProduct cleaned up on session.expired
157158
// ═══════════════════════════════════════════════════════════════════════════════
158159

159-
// NOTE: Skipped until the implementation lands. Stripe checkout sessions auto-expire
160-
// 24h after creation; we'll drive expiry via `s.advanceTestClock` once the handler
161-
// for `checkout.session.expired` exists. The assertions are written out so flipping
162-
// `test.skip` → `test.concurrent` is the only change needed.
163-
test.skip(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: expired session cleans up cusProduct")}`, async () => {
160+
// Expiry is driven by expiring the session on Stripe, which emits
161+
// `checkout.session.expired`. The API assertion at the end is the regression
162+
// check for the subject cache: the handler expires the row in Postgres, and the
163+
// webhook refresh middleware must drop the cached subject so `customers.get`
164+
// stops reporting the plan as active.
165+
test.concurrent(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: expired session cleans up cusProduct")}`, async () => {
164166
const customerId = "stripe-checkout-eppi-expired";
165167

166168
const prepaidMessagesItem = items.prepaidMessages({
@@ -197,6 +199,9 @@ test.skip(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: expire
197199
const result = await autumnV1.billing.attach(attachParams);
198200
expect(result.payment_url).toBeDefined();
199201

202+
const checkoutSessionId = parseCheckoutSessionId(result.payment_url!);
203+
expect(checkoutSessionId).toBeTruthy();
204+
200205
// Sanity: cusProduct exists Active before expiry.
201206
const before = await CusProductService.list({
202207
db: ctx.db,
@@ -205,11 +210,25 @@ test.skip(`${chalk.yellowBright("stripe-checkout enable_plan_immediately: expire
205210
});
206211
expect(before.some((cp) => cp.product.id === pro.id)).toBe(true);
207212

208-
// 2. Drive past the Stripe session expiry (sessions auto-expire after 24h).
209-
// TODO: replace with the proper test-clock advance helper once we wire up
210-
// `checkout.session.expired` simulation alongside the implementation.
211-
// For now this test is `.skip`'d so the typed shape doesn't have to be exact.
212-
void s;
213+
// 2. Abandon the checkout: expire the session on Stripe, which emits
214+
// `checkout.session.expired`. Wait on the cache-backed API view, not the
215+
// DB row: the handler expires the row first and the refresh middleware
216+
// drops the cached subject after it returns, so a DB-only predicate can
217+
// resolve before `customers.get` stops reporting the plan.
218+
await ctx.stripeCli.checkout.sessions.expire(checkoutSessionId!);
219+
220+
const isProActiveInApi = async () => {
221+
const customer = await autumnV1.customers.get<ApiCustomerV3>(customerId);
222+
return (customer.products ?? []).some((product) => product.id === pro.id);
223+
};
224+
225+
await waitForStripeWebhook({
226+
stripeCli: ctx.stripeCli,
227+
env: ctx.env,
228+
types: ["checkout.session.expired"],
229+
objectId: checkoutSessionId!,
230+
until: async () => !(await isProActiveInApi()),
231+
});
213232

214233
// 3. After expiry: cusProduct should no longer be Active.
215234
const after = await CusProductService.list({

0 commit comments

Comments
 (0)