From 13b18574c12225feadb87cdb61f84044dd8b91e3 Mon Sep 17 00:00:00 2001 From: Florian Hines Date: Wed, 26 Aug 2026 21:17:52 -0500 Subject: [PATCH 1/2] fix(kilo-pass): prevent duplicate checkout sessions --- .../web/src/lib/kilo-pass/checkout-session.ts | 79 ++++++++ apps/web/src/routers/kilo-pass-router.test.ts | 178 ++++++++++++++++++ apps/web/src/routers/kilo-pass-router.ts | 56 +++--- .../routers/kiloclaw-billing-router.test.ts | 48 ++++- apps/web/src/routers/kiloclaw-router.ts | 57 +++--- 5 files changed, 355 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/lib/kilo-pass/checkout-session.ts diff --git a/apps/web/src/lib/kilo-pass/checkout-session.ts b/apps/web/src/lib/kilo-pass/checkout-session.ts new file mode 100644 index 00000000000..67ab3e208dd --- /dev/null +++ b/apps/web/src/lib/kilo-pass/checkout-session.ts @@ -0,0 +1,79 @@ +import { db } from '@/lib/drizzle'; +import { getKiloPassStateForUser } from '@/lib/kilo-pass/state'; +import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status'; +import { client as stripe } from '@/lib/stripe-client'; +import { TRPCError } from '@trpc/server'; +import { sql } from 'drizzle-orm'; +import type Stripe from 'stripe'; + +type CreateOrReuseKiloPassCheckoutSessionParams = { + userId: string; + stripeCustomerId: string; + createSession: () => Promise; +}; + +export async function createOrReuseKiloPassCheckoutSession( + params: CreateOrReuseKiloPassCheckoutSessionParams +): Promise<{ url: string | null }> { + return db.transaction(async tx => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtextextended(${`kilo-pass:checkout:${params.userId}`}, 0))` + ); + + const existing = await getKiloPassStateForUser(tx, params.userId); + if (existing && !isStripeSubscriptionEnded(existing.status)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'You already have an active Kilo Pass subscription.', + }); + } + + const isUserKiloPassSession = (session: Stripe.Checkout.Session) => + session.metadata?.type === 'kilo-pass' && session.metadata.kiloUserId === params.userId; + let checkoutCursor: string | undefined; + do { + const openSessions = await stripe.checkout.sessions.list({ + customer: params.stripeCustomerId, + status: 'open', + limit: 100, + ...(checkoutCursor ? { starting_after: checkoutCursor } : {}), + }); + const openKiloPassSession = openSessions.data.find(isUserKiloPassSession); + if (typeof openKiloPassSession?.url === 'string') { + return { url: openKiloPassSession.url }; + } + checkoutCursor = openSessions.has_more ? openSessions.data.at(-1)?.id : undefined; + } while (checkoutCursor); + + let subscriptionCursor: string | undefined; + let hasLiveStripeSubscription = false; + do { + const subscriptions = await stripe.subscriptions.list({ + customer: params.stripeCustomerId, + status: 'all', + limit: 100, + ...(subscriptionCursor ? { starting_after: subscriptionCursor } : {}), + }); + hasLiveStripeSubscription = subscriptions.data.some( + subscription => + subscription.metadata.type === 'kilo-pass' && + subscription.metadata.kiloUserId === params.userId && + !isStripeSubscriptionEnded(subscription.status) + ); + subscriptionCursor = + !hasLiveStripeSubscription && subscriptions.has_more + ? subscriptions.data.at(-1)?.id + : undefined; + } while (subscriptionCursor); + + if (hasLiveStripeSubscription) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'You already have an active Kilo Pass subscription.', + }); + } + + const session = await params.createSession(); + return { url: typeof session.url === 'string' ? session.url : null }; + }); +} diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 390b59bb379..513f25987ed 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -68,6 +68,7 @@ let mockKiloPassNowIso: string | null = null; type StripeMock = { subscriptions: { + list: ReturnType; retrieve: ReturnType; update: ReturnType; }; @@ -80,6 +81,7 @@ type StripeMock = { checkout: { sessions: { create: ReturnType; + list: ReturnType; retrieve: ReturnType; }; }; @@ -355,6 +357,7 @@ jest.mock('@/lib/kilo-pass/dayjs', () => { jest.mock('@/lib/stripe-client', () => { const stripeMock = { subscriptions: { + list: jest.fn(), retrieve: jest.fn(), update: jest.fn(), }, @@ -367,6 +370,7 @@ jest.mock('@/lib/stripe-client', () => { checkout: { sessions: { create: jest.fn(), + list: jest.fn(), retrieve: jest.fn(), }, }, @@ -683,6 +687,8 @@ describe('kiloPassRouter', () => { beforeEach(() => { const stripeMock = getStripeMock(); + stripeMock.subscriptions.list.mockReset(); + stripeMock.subscriptions.list.mockResolvedValue({ data: [], has_more: false }); stripeMock.subscriptions.retrieve.mockReset(); stripeMock.subscriptions.update.mockReset(); stripeMock.subscriptionSchedules.create.mockReset(); @@ -690,6 +696,8 @@ describe('kiloPassRouter', () => { stripeMock.subscriptionSchedules.release.mockReset(); stripeMock.subscriptionSchedules.retrieve.mockReset(); stripeMock.checkout.sessions.create.mockReset(); + stripeMock.checkout.sessions.list.mockReset(); + stripeMock.checkout.sessions.list.mockResolvedValue({ data: [] }); stripeMock.checkout.sessions.retrieve.mockReset(); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.invoices.list.mockReset(); @@ -5297,6 +5305,176 @@ describe('kiloPassRouter', () => { ).rejects.toThrow('You already have an active Kilo Pass subscription.'); }); + it('reuses one open Stripe session for concurrent initial checkout requests', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-concurrent@example.com', + }); + const session = { + status: 'open', + url: 'https://stripe.example.test/checkout/concurrent', + metadata: { + type: 'kilo-pass', + kiloUserId: user.id, + }, + }; + stripeMock.checkout.sessions.create.mockResolvedValue(session); + stripeMock.checkout.sessions.list.mockImplementation(async () => ({ + data: stripeMock.checkout.sessions.create.mock.calls.length > 0 ? [session] : [], + })); + const caller = await createCallerForUser(user.id); + + const results = await Promise.all([ + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }), + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Yearly, + }), + ]); + + expect(results).toEqual([{ url: session.url }, { url: session.url }]); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + expect(stripeMock.checkout.sessions.list).toHaveBeenCalledTimes(2); + }); + + it('finds an open Kilo Pass session on a later Stripe page', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-paginated@example.com', + }); + stripeMock.checkout.sessions.list + .mockResolvedValueOnce({ + data: [{ id: 'cs_other', metadata: {}, status: 'open', url: 'https://example.test' }], + has_more: true, + }) + .mockResolvedValueOnce({ + data: [ + { + id: 'cs_kilo_pass', + metadata: { type: 'kilo-pass', kiloUserId: user.id }, + status: 'open', + url: 'https://stripe.example.test/checkout/existing', + }, + ], + has_more: false, + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }) + ).resolves.toEqual({ url: 'https://stripe.example.test/checkout/existing' }); + expect(stripeMock.checkout.sessions.list).toHaveBeenNthCalledWith(2, { + customer: user.stripe_customer_id, + status: 'open', + limit: 100, + starting_after: 'cs_other', + }); + expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + it('rejects checkout while a live Stripe subscription is awaiting webhook persistence', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-completed@example.com', + }); + stripeMock.subscriptions.list.mockResolvedValue({ + data: [ + { + status: 'active', + metadata: { type: 'kilo-pass', kiloUserId: user.id }, + }, + ], + has_more: false, + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }) + ).rejects.toThrow('You already have an active Kilo Pass subscription.'); + expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + it('finds a live Kilo Pass subscription on a later Stripe page', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-subscription-page@example.com', + }); + stripeMock.subscriptions.list + .mockResolvedValueOnce({ + data: [{ id: 'sub_other', status: 'active', metadata: {} }], + has_more: true, + }) + .mockResolvedValueOnce({ + data: [ + { + id: 'sub_kilo_pass', + status: 'active', + metadata: { type: 'kilo-pass', kiloUserId: user.id }, + }, + ], + has_more: false, + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }) + ).rejects.toThrow('You already have an active Kilo Pass subscription.'); + expect(stripeMock.subscriptions.list).toHaveBeenNthCalledWith(2, { + customer: user.stripe_customer_id, + status: 'all', + limit: 100, + starting_after: 'sub_other', + }); + expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); + }); + + it('allows checkout after the Stripe subscription has ended', async () => { + const stripeMock = getStripeMock(); + stripeMock.checkout.sessions.create.mockResolvedValue({ + url: 'https://stripe.example.test/checkout/resubscribe', + }); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-resubscribe@example.com', + }); + await insertSubscription({ + kiloUserId: user.id, + stripeSubscriptionId: 'sub_completed_then_ended', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'canceled', + }); + stripeMock.subscriptions.list.mockResolvedValue({ + data: [ + { + status: 'canceled', + metadata: { type: 'kilo-pass', kiloUserId: user.id }, + }, + ], + has_more: false, + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }) + ).resolves.toEqual({ url: 'https://stripe.example.test/checkout/resubscribe' }); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + }); + it('creates a checkout session with empty affiliate metadata when attribution is absent', async () => { const stripeMock = getStripeMock(); stripeMock.checkout.sessions.create.mockResolvedValue({ diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index eacac0ca48c..7d4a6064a6a 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -57,6 +57,7 @@ import { getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus'; import { computeKiloPassBonusCreditsUsd } from '@/lib/kilo-pass/bonus-decision'; import { KiloPassError } from '@/lib/kilo-pass/errors'; import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status'; +import { createOrReuseKiloPassCheckoutSession } from '@/lib/kilo-pass/checkout-session'; import { isTerminalKiloPassScheduleStatus, maybeMapStripeScheduleStatusToDb, @@ -2640,14 +2641,6 @@ export const kiloPassRouter = createTRPCRouter({ const { tier, cadence } = input; - const existing = await getKiloPassStateForUser(db, ctx.user.id); - if (existing && !isStripeSubscriptionEnded(existing.status)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'You already have an active Kilo Pass subscription.', - }); - } - const stripeCustomerId = ctx.user.stripe_customer_id; if (!stripeCustomerId) { throw new TRPCError({ @@ -2666,29 +2659,32 @@ export const kiloPassRouter = createTRPCRouter({ affiliateTrackingId: attribution?.tracking_id ?? '', }; - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - customer: stripeCustomerId, - allow_promotion_codes: true, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, - subscription_data: { - metadata: sessionMetadata, - }, - metadata: sessionMetadata, + return createOrReuseKiloPassCheckoutSession({ + userId: ctx.user.id, + stripeCustomerId, + createSession: () => + stripe.checkout.sessions.create({ + mode: 'subscription', + customer: stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, + subscription_data: { + metadata: sessionMetadata, + }, + metadata: sessionMetadata, + }), }); - - return { url: typeof session.url === 'string' ? session.url : null }; }), getChurnkeyAuthHash: baseProcedure diff --git a/apps/web/src/routers/kiloclaw-billing-router.test.ts b/apps/web/src/routers/kiloclaw-billing-router.test.ts index e41eae79433..209f1a04b94 100644 --- a/apps/web/src/routers/kiloclaw-billing-router.test.ts +++ b/apps/web/src/routers/kiloclaw-billing-router.test.ts @@ -290,14 +290,14 @@ beforeEach(async () => { // Reset stripe mocks stripeMock.checkout.sessions.create.mockReset(); stripeMock.checkout.sessions.list.mockReset(); - stripeMock.checkout.sessions.list.mockResolvedValue({ data: [] }); + stripeMock.checkout.sessions.list.mockResolvedValue({ data: [], has_more: false }); stripeMock.checkout.sessions.expire.mockReset(); stripeMock.checkout.sessions.expire.mockResolvedValue({}); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.subscriptions.retrieve.mockReset(); stripeMock.subscriptions.update.mockReset(); stripeMock.subscriptions.list.mockReset(); - stripeMock.subscriptions.list.mockResolvedValue({ data: [] }); + stripeMock.subscriptions.list.mockResolvedValue({ data: [], has_more: false }); stripeMock.subscriptionSchedules.create.mockReset(); stripeMock.subscriptionSchedules.update.mockReset(); stripeMock.subscriptionSchedules.release.mockReset(); @@ -2780,6 +2780,50 @@ describe('createSubscriptionCheckout', () => { }); describe('createKiloPassUpsellCheckout', () => { + it('shares checkout coordination with the generic Kilo Pass entrypoint', async () => { + const instance = await createKiloclawInstance(user.id); + await db.insert(kiloclaw_subscriptions).values({ + user_id: user.id, + instance_id: instance.id, + plan: 'trial', + status: 'trialing', + trial_started_at: new Date().toISOString(), + trial_ends_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), + kiloclaw_price_version: LEGACY_KILOCLAW_PRICE_VERSION, + }); + let createdSession: Record | undefined; + stripeMock.checkout.sessions.create.mockImplementation(async input => { + createdSession = { + id: 'cs_shared_kilo_pass', + status: 'open', + url: 'https://checkout.stripe.com/shared', + metadata: input.metadata, + }; + return createdSession; + }); + stripeMock.checkout.sessions.list.mockImplementation(async () => ({ + data: createdSession ? [createdSession] : [], + has_more: false, + })); + + const caller = await createCallerForUser(user.id); + const results = await Promise.all([ + caller.kiloPass.createCheckoutSession({ tier: 'tier_19', cadence: 'monthly' }), + caller.kiloclaw.createKiloPassUpsellCheckout({ + instanceId: instance.id, + tier: '19', + cadence: 'monthly', + hostingPlan: 'standard', + }), + ]); + + expect(results).toEqual([ + { url: 'https://checkout.stripe.com/shared' }, + { url: 'https://checkout.stripe.com/shared' }, + ]); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + }); + it('rejects Commit hosting intent after the cutoff before creating Kilo Pass checkout', async () => { jest.useFakeTimers({ doNotFake: [ diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 5f96fbbb210..76f7831c167 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -108,6 +108,7 @@ import { KiloPassTier, KiloPassCadence, KiloPassPaymentProvider } from '@/lib/ki import { getMonthlyPriceUsd } from '@/lib/kilo-pass/bonus'; import { isStripeSubscriptionEnded } from '@/lib/kilo-pass/stripe-subscription-status'; import { getKiloPassStateForUser, type KiloPassSubscriptionState } from '@/lib/kilo-pass/state'; +import { createOrReuseKiloPassCheckoutSession } from '@/lib/kilo-pass/checkout-session'; import { ensureAutoIntroSchedule, resolvePhasePrice } from '@/lib/kiloclaw/stripe-handlers'; import { getKiloClawEarlybirdStateForUser, @@ -5219,15 +5220,6 @@ export const kiloclawRouter = createTRPCRouter({ }); } - // Reject if user already has an active Kilo Pass subscription - const existingKiloPass = await getKiloPassStateForUser(db, ctx.user.id); - if (existingKiloPass && !isStripeSubscriptionEnded(existingKiloPass.status)) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'You already have an active Kilo Pass subscription.', - }); - } - const kiloPassTier = KILO_PASS_UPSELL_TIER_MAP[input.tier]; const kiloPassCadence = KILO_PASS_UPSELL_CADENCE_MAP[input.cadence]; const intendedPriceVersion = resolveKiloClawEnrollmentPriceVersion( @@ -5300,29 +5292,32 @@ export const kiloclawRouter = createTRPCRouter({ kiloclawPriceVersion: intendedPriceVersion, }; - const session = await stripe.checkout.sessions.create({ - mode: 'subscription', - customer: stripeCustomerId, - allow_promotion_codes: true, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/claw?checkout=cancelled`, - subscription_data: { - metadata: sessionMetadata, - }, - metadata: sessionMetadata, + return createOrReuseKiloPassCheckoutSession({ + userId: ctx.user.id, + stripeCustomerId, + createSession: () => + stripe.checkout.sessions.create({ + mode: 'subscription', + customer: stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/claw?checkout=cancelled`, + subscription_data: { + metadata: sessionMetadata, + }, + metadata: sessionMetadata, + }), }); - - return { url: typeof session.url === 'string' ? session.url : null }; }), cancelSubscription: baseProcedure.mutation(async ({ ctx }) => { From a91aa63fef553823a9c2ca856870c99310c590a4 Mon Sep 17 00:00:00 2001 From: Florian Hines Date: Thu, 27 Aug 2026 09:03:03 -0500 Subject: [PATCH 2/2] fix(kilo-pass): match checkout request metadata --- .../web/src/lib/kilo-pass/checkout-session.ts | 66 ++++++-- apps/web/src/routers/kilo-pass-router.test.ts | 153 ++++++++++++++++-- apps/web/src/routers/kilo-pass-router.ts | 42 ++--- .../routers/kiloclaw-billing-router.test.ts | 49 +++--- apps/web/src/routers/kiloclaw-router.ts | 42 ++--- 5 files changed, 258 insertions(+), 94 deletions(-) diff --git a/apps/web/src/lib/kilo-pass/checkout-session.ts b/apps/web/src/lib/kilo-pass/checkout-session.ts index 67ab3e208dd..53afccb359f 100644 --- a/apps/web/src/lib/kilo-pass/checkout-session.ts +++ b/apps/web/src/lib/kilo-pass/checkout-session.ts @@ -9,9 +9,20 @@ import type Stripe from 'stripe'; type CreateOrReuseKiloPassCheckoutSessionParams = { userId: string; stripeCustomerId: string; + metadata: Stripe.MetadataParam; createSession: () => Promise; }; +const STRIPE_CHECKOUT_TIMEOUT_MS = 10_000; +const MAX_STRIPE_PAGES = 3; +const PRODUCT_METADATA_KEYS = [ + 'tier', + 'cadence', + 'kiloclawHostingPlan', + 'kiloclawInstanceId', + 'kiloclawPriceVersion', +] as const; + export async function createOrReuseKiloPassCheckoutSession( params: CreateOrReuseKiloPassCheckoutSessionParams ): Promise<{ url: string | null }> { @@ -30,30 +41,49 @@ export async function createOrReuseKiloPassCheckoutSession( const isUserKiloPassSession = (session: Stripe.Checkout.Session) => session.metadata?.type === 'kilo-pass' && session.metadata.kiloUserId === params.userId; + const matchesRequestedProduct = (session: Stripe.Checkout.Session) => + PRODUCT_METADATA_KEYS.every(key => session.metadata?.[key] === params.metadata[key]); let checkoutCursor: string | undefined; + let checkoutPageCount = 0; do { - const openSessions = await stripe.checkout.sessions.list({ - customer: params.stripeCustomerId, - status: 'open', - limit: 100, - ...(checkoutCursor ? { starting_after: checkoutCursor } : {}), - }); - const openKiloPassSession = openSessions.data.find(isUserKiloPassSession); - if (typeof openKiloPassSession?.url === 'string') { - return { url: openKiloPassSession.url }; + const openSessions = await stripe.checkout.sessions.list( + { + customer: params.stripeCustomerId, + status: 'open', + limit: 100, + ...(checkoutCursor ? { starting_after: checkoutCursor } : {}), + }, + { timeout: STRIPE_CHECKOUT_TIMEOUT_MS } + ); + checkoutPageCount++; + for (const session of openSessions.data.filter(isUserKiloPassSession)) { + if (matchesRequestedProduct(session) && typeof session.url === 'string') { + return { url: session.url }; + } + await stripe.checkout.sessions.expire(session.id, { + timeout: STRIPE_CHECKOUT_TIMEOUT_MS, + }); } - checkoutCursor = openSessions.has_more ? openSessions.data.at(-1)?.id : undefined; + checkoutCursor = + openSessions.has_more && checkoutPageCount < MAX_STRIPE_PAGES + ? openSessions.data.at(-1)?.id + : undefined; } while (checkoutCursor); let subscriptionCursor: string | undefined; let hasLiveStripeSubscription = false; + let subscriptionPageCount = 0; do { - const subscriptions = await stripe.subscriptions.list({ - customer: params.stripeCustomerId, - status: 'all', - limit: 100, - ...(subscriptionCursor ? { starting_after: subscriptionCursor } : {}), - }); + const subscriptions = await stripe.subscriptions.list( + { + customer: params.stripeCustomerId, + status: 'all', + limit: 100, + ...(subscriptionCursor ? { starting_after: subscriptionCursor } : {}), + }, + { timeout: STRIPE_CHECKOUT_TIMEOUT_MS } + ); + subscriptionPageCount++; hasLiveStripeSubscription = subscriptions.data.some( subscription => subscription.metadata.type === 'kilo-pass' && @@ -61,7 +91,9 @@ export async function createOrReuseKiloPassCheckoutSession( !isStripeSubscriptionEnded(subscription.status) ); subscriptionCursor = - !hasLiveStripeSubscription && subscriptions.has_more + !hasLiveStripeSubscription && + subscriptions.has_more && + subscriptionPageCount < MAX_STRIPE_PAGES ? subscriptions.data.at(-1)?.id : undefined; } while (subscriptionCursor); diff --git a/apps/web/src/routers/kilo-pass-router.test.ts b/apps/web/src/routers/kilo-pass-router.test.ts index 513f25987ed..f76ef8d9d57 100644 --- a/apps/web/src/routers/kilo-pass-router.test.ts +++ b/apps/web/src/routers/kilo-pass-router.test.ts @@ -81,6 +81,7 @@ type StripeMock = { checkout: { sessions: { create: ReturnType; + expire: ReturnType; list: ReturnType; retrieve: ReturnType; }; @@ -370,6 +371,7 @@ jest.mock('@/lib/stripe-client', () => { checkout: { sessions: { create: jest.fn(), + expire: jest.fn(), list: jest.fn(), retrieve: jest.fn(), }, @@ -696,6 +698,8 @@ describe('kiloPassRouter', () => { stripeMock.subscriptionSchedules.release.mockReset(); stripeMock.subscriptionSchedules.retrieve.mockReset(); stripeMock.checkout.sessions.create.mockReset(); + stripeMock.checkout.sessions.expire.mockReset(); + stripeMock.checkout.sessions.expire.mockResolvedValue({}); stripeMock.checkout.sessions.list.mockReset(); stripeMock.checkout.sessions.list.mockResolvedValue({ data: [] }); stripeMock.checkout.sessions.retrieve.mockReset(); @@ -5305,6 +5309,29 @@ describe('kiloPassRouter', () => { ).rejects.toThrow('You already have an active Kilo Pass subscription.'); }); + it('rejects when an active store subscription already exists', async () => { + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-store-active@example.com', + }); + await insertSubscription({ + kiloUserId: user.id, + paymentProvider: KiloPassPaymentProvider.AppStore, + providerSubscriptionId: 'app-store-active-subscription', + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + status: 'active', + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Monthly, + }) + ).rejects.toThrow('You already have an active Kilo Pass subscription.'); + expect(getStripeMock().checkout.sessions.list).not.toHaveBeenCalled(); + }); + it('reuses one open Stripe session for concurrent initial checkout requests', async () => { const stripeMock = getStripeMock(); const user = await insertTestUser({ @@ -5316,6 +5343,8 @@ describe('kiloPassRouter', () => { metadata: { type: 'kilo-pass', kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, }, }; stripeMock.checkout.sessions.create.mockResolvedValue(session); @@ -5330,8 +5359,8 @@ describe('kiloPassRouter', () => { cadence: KiloPassCadence.Monthly, }), caller.kiloPass.createCheckoutSession({ - tier: KiloPassTier.Tier49, - cadence: KiloPassCadence.Yearly, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, }), ]); @@ -5340,6 +5369,81 @@ describe('kiloPassRouter', () => { expect(stripeMock.checkout.sessions.list).toHaveBeenCalledTimes(2); }); + it('expires a checkout for another tier before creating the requested product', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-tier-change@example.com', + }); + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [ + { + id: 'cs_tier_19', + status: 'open', + url: 'https://stripe.example.test/checkout/tier-19', + metadata: { + type: 'kilo-pass', + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, + }, + ], + has_more: false, + }); + stripeMock.checkout.sessions.create.mockResolvedValue({ + url: 'https://stripe.example.test/checkout/tier-49', + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier49, + cadence: KiloPassCadence.Yearly, + }) + ).resolves.toEqual({ url: 'https://stripe.example.test/checkout/tier-49' }); + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_tier_19', { + timeout: 10_000, + }); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + }); + + it('expires a matching checkout without a reusable URL', async () => { + const stripeMock = getStripeMock(); + const user = await insertTestUser({ + google_user_email: 'kilo-pass-create-session-null-url@example.com', + }); + stripeMock.checkout.sessions.list.mockResolvedValue({ + data: [ + { + id: 'cs_null_url', + status: 'open', + url: null, + metadata: { + type: 'kilo-pass', + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, + }, + ], + has_more: false, + }); + stripeMock.checkout.sessions.create.mockResolvedValue({ + url: 'https://stripe.example.test/checkout/replacement', + }); + + const caller = await createCallerForUser(user.id); + await expect( + caller.kiloPass.createCheckoutSession({ + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }) + ).resolves.toEqual({ url: 'https://stripe.example.test/checkout/replacement' }); + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_null_url', { + timeout: 10_000, + }); + }); + it('finds an open Kilo Pass session on a later Stripe page', async () => { const stripeMock = getStripeMock(); const user = await insertTestUser({ @@ -5354,7 +5458,12 @@ describe('kiloPassRouter', () => { data: [ { id: 'cs_kilo_pass', - metadata: { type: 'kilo-pass', kiloUserId: user.id }, + metadata: { + type: 'kilo-pass', + kiloUserId: user.id, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, status: 'open', url: 'https://stripe.example.test/checkout/existing', }, @@ -5369,12 +5478,16 @@ describe('kiloPassRouter', () => { cadence: KiloPassCadence.Monthly, }) ).resolves.toEqual({ url: 'https://stripe.example.test/checkout/existing' }); - expect(stripeMock.checkout.sessions.list).toHaveBeenNthCalledWith(2, { - customer: user.stripe_customer_id, - status: 'open', - limit: 100, - starting_after: 'cs_other', - }); + expect(stripeMock.checkout.sessions.list).toHaveBeenNthCalledWith( + 2, + { + customer: user.stripe_customer_id, + status: 'open', + limit: 100, + starting_after: 'cs_other', + }, + { timeout: 10_000 } + ); expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); }); @@ -5431,12 +5544,16 @@ describe('kiloPassRouter', () => { cadence: KiloPassCadence.Monthly, }) ).rejects.toThrow('You already have an active Kilo Pass subscription.'); - expect(stripeMock.subscriptions.list).toHaveBeenNthCalledWith(2, { - customer: user.stripe_customer_id, - status: 'all', - limit: 100, - starting_after: 'sub_other', - }); + expect(stripeMock.subscriptions.list).toHaveBeenNthCalledWith( + 2, + { + customer: user.stripe_customer_id, + status: 'all', + limit: 100, + starting_after: 'sub_other', + }, + { timeout: 10_000 } + ); expect(stripeMock.checkout.sessions.create).not.toHaveBeenCalled(); }); @@ -5521,7 +5638,8 @@ describe('kiloPassRouter', () => { cadence: 'yearly', affiliateTrackingId: '', }, - }) + }), + { timeout: 10_000 } ); }); @@ -5564,7 +5682,8 @@ describe('kiloPassRouter', () => { cadence: 'yearly', affiliateTrackingId: 'impact-click-123', }, - }) + }), + { timeout: 10_000 } ); }); diff --git a/apps/web/src/routers/kilo-pass-router.ts b/apps/web/src/routers/kilo-pass-router.ts index 7d4a6064a6a..05f7b1ad337 100644 --- a/apps/web/src/routers/kilo-pass-router.ts +++ b/apps/web/src/routers/kilo-pass-router.ts @@ -2662,28 +2662,32 @@ export const kiloPassRouter = createTRPCRouter({ return createOrReuseKiloPassCheckoutSession({ userId: ctx.user.id, stripeCustomerId, + metadata: sessionMetadata, createSession: () => - stripe.checkout.sessions.create({ - mode: 'subscription', - customer: stripeCustomerId, - allow_promotion_codes: true, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, - subscription_data: { + stripe.checkout.sessions.create( + { + mode: 'subscription', + customer: stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/profile?kilo_pass_checkout=cancelled`, + subscription_data: { + metadata: sessionMetadata, + }, metadata: sessionMetadata, }, - metadata: sessionMetadata, - }), + { timeout: 10_000 } + ), }); }), diff --git a/apps/web/src/routers/kiloclaw-billing-router.test.ts b/apps/web/src/routers/kiloclaw-billing-router.test.ts index 209f1a04b94..e2b207e2cf3 100644 --- a/apps/web/src/routers/kiloclaw-billing-router.test.ts +++ b/apps/web/src/routers/kiloclaw-billing-router.test.ts @@ -2780,7 +2780,7 @@ describe('createSubscriptionCheckout', () => { }); describe('createKiloPassUpsellCheckout', () => { - it('shares checkout coordination with the generic Kilo Pass entrypoint', async () => { + it('expires a generic checkout before creating an upsell checkout with hosting intent', async () => { const instance = await createKiloclawInstance(user.id); await db.insert(kiloclaw_subscriptions).values({ user_id: user.id, @@ -2791,37 +2791,40 @@ describe('createKiloPassUpsellCheckout', () => { trial_ends_at: new Date(Date.now() + 7 * 86_400_000).toISOString(), kiloclaw_price_version: LEGACY_KILOCLAW_PRICE_VERSION, }); - let createdSession: Record | undefined; + const createdSessions: Record[] = []; stripeMock.checkout.sessions.create.mockImplementation(async input => { - createdSession = { - id: 'cs_shared_kilo_pass', + const session = { + id: `cs_shared_kilo_pass_${createdSessions.length + 1}`, status: 'open', url: 'https://checkout.stripe.com/shared', metadata: input.metadata, }; - return createdSession; + createdSessions.push(session); + return session; }); stripeMock.checkout.sessions.list.mockImplementation(async () => ({ - data: createdSession ? [createdSession] : [], + data: createdSessions, has_more: false, })); const caller = await createCallerForUser(user.id); - const results = await Promise.all([ - caller.kiloPass.createCheckoutSession({ tier: 'tier_19', cadence: 'monthly' }), - caller.kiloclaw.createKiloPassUpsellCheckout({ - instanceId: instance.id, - tier: '19', - cadence: 'monthly', - hostingPlan: 'standard', - }), - ]); + const genericResult = await caller.kiloPass.createCheckoutSession({ + tier: 'tier_19', + cadence: 'monthly', + }); + const upsellResult = await caller.kiloclaw.createKiloPassUpsellCheckout({ + instanceId: instance.id, + tier: '19', + cadence: 'monthly', + hostingPlan: 'standard', + }); - expect(results).toEqual([ - { url: 'https://checkout.stripe.com/shared' }, - { url: 'https://checkout.stripe.com/shared' }, - ]); - expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + expect(genericResult).toEqual({ url: 'https://checkout.stripe.com/shared' }); + expect(upsellResult).toEqual({ url: 'https://checkout.stripe.com/shared' }); + expect(stripeMock.checkout.sessions.expire).toHaveBeenCalledWith('cs_shared_kilo_pass_1', { + timeout: 10_000, + }); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(2); }); it('rejects Commit hosting intent after the cutoff before creating Kilo Pass checkout', async () => { @@ -3072,7 +3075,8 @@ describe('createKiloPassUpsellCheckout', () => { cadence: 'monthly', affiliateTrackingId: '', }), - }) + }), + { timeout: 10_000 } ); }); @@ -3122,7 +3126,8 @@ describe('createKiloPassUpsellCheckout', () => { cadence: 'monthly', affiliateTrackingId: 'impact-click-123', }), - }) + }), + { timeout: 10_000 } ); }); diff --git a/apps/web/src/routers/kiloclaw-router.ts b/apps/web/src/routers/kiloclaw-router.ts index 76f7831c167..55c7f4f98c1 100644 --- a/apps/web/src/routers/kiloclaw-router.ts +++ b/apps/web/src/routers/kiloclaw-router.ts @@ -5295,28 +5295,32 @@ export const kiloclawRouter = createTRPCRouter({ return createOrReuseKiloPassCheckoutSession({ userId: ctx.user.id, stripeCustomerId, + metadata: sessionMetadata, createSession: () => - stripe.checkout.sessions.create({ - mode: 'subscription', - customer: stripeCustomerId, - allow_promotion_codes: true, - billing_address_collection: 'required', - line_items: [{ price: priceId, quantity: 1 }], - customer_update: { - name: 'auto', - address: 'auto', - }, - tax_id_collection: { - enabled: true, - required: 'never', - }, - success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, - cancel_url: `${APP_URL}/claw?checkout=cancelled`, - subscription_data: { + stripe.checkout.sessions.create( + { + mode: 'subscription', + customer: stripeCustomerId, + allow_promotion_codes: true, + billing_address_collection: 'required', + line_items: [{ price: priceId, quantity: 1 }], + customer_update: { + name: 'auto', + address: 'auto', + }, + tax_id_collection: { + enabled: true, + required: 'never', + }, + success_url: `${APP_URL}/payments/kilo-pass/awarding?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${APP_URL}/claw?checkout=cancelled`, + subscription_data: { + metadata: sessionMetadata, + }, metadata: sessionMetadata, }, - metadata: sessionMetadata, - }), + { timeout: 10_000 } + ), }); }),