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..53afccb359f --- /dev/null +++ b/apps/web/src/lib/kilo-pass/checkout-session.ts @@ -0,0 +1,111 @@ +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; + 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 }> { + 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; + 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 } : {}), + }, + { 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 && 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 } : {}), + }, + { timeout: STRIPE_CHECKOUT_TIMEOUT_MS } + ); + subscriptionPageCount++; + hasLiveStripeSubscription = subscriptions.data.some( + subscription => + subscription.metadata.type === 'kilo-pass' && + subscription.metadata.kiloUserId === params.userId && + !isStripeSubscriptionEnded(subscription.status) + ); + subscriptionCursor = + !hasLiveStripeSubscription && + subscriptions.has_more && + subscriptionPageCount < MAX_STRIPE_PAGES + ? 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..f76ef8d9d57 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,8 @@ type StripeMock = { checkout: { sessions: { create: ReturnType; + expire: ReturnType; + list: ReturnType; retrieve: ReturnType; }; }; @@ -355,6 +358,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 +371,8 @@ jest.mock('@/lib/stripe-client', () => { checkout: { sessions: { create: jest.fn(), + expire: jest.fn(), + list: jest.fn(), retrieve: jest.fn(), }, }, @@ -683,6 +689,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 +698,10 @@ 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(); stripeMock.billingPortal.sessions.create.mockReset(); stripeMock.invoices.list.mockReset(); @@ -5297,6 +5309,289 @@ 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({ + 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, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, + }; + 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.Tier19, + cadence: KiloPassCadence.Monthly, + }), + ]); + + expect(results).toEqual([{ url: session.url }, { url: session.url }]); + expect(stripeMock.checkout.sessions.create).toHaveBeenCalledTimes(1); + 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({ + 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, + tier: KiloPassTier.Tier19, + cadence: KiloPassCadence.Monthly, + }, + 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', + }, + { timeout: 10_000 } + ); + 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', + }, + { timeout: 10_000 } + ); + 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({ @@ -5343,7 +5638,8 @@ describe('kiloPassRouter', () => { cadence: 'yearly', affiliateTrackingId: '', }, - }) + }), + { timeout: 10_000 } ); }); @@ -5386,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 eacac0ca48c..05f7b1ad337 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,36 @@ 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, - }, + 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: { + metadata: sessionMetadata, + }, + metadata: sessionMetadata, + }, + { timeout: 10_000 } + ), }); - - 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..e2b207e2cf3 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,53 @@ describe('createSubscriptionCheckout', () => { }); describe('createKiloPassUpsellCheckout', () => { + 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, + 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, + }); + const createdSessions: Record[] = []; + stripeMock.checkout.sessions.create.mockImplementation(async input => { + const session = { + id: `cs_shared_kilo_pass_${createdSessions.length + 1}`, + status: 'open', + url: 'https://checkout.stripe.com/shared', + metadata: input.metadata, + }; + createdSessions.push(session); + return session; + }); + stripeMock.checkout.sessions.list.mockImplementation(async () => ({ + data: createdSessions, + has_more: false, + })); + + const caller = await createCallerForUser(user.id); + 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(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 () => { jest.useFakeTimers({ doNotFake: [ @@ -3028,7 +3075,8 @@ describe('createKiloPassUpsellCheckout', () => { cadence: 'monthly', affiliateTrackingId: '', }), - }) + }), + { timeout: 10_000 } ); }); @@ -3078,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 5f96fbbb210..55c7f4f98c1 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,36 @@ 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, - }, + 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: { + metadata: sessionMetadata, + }, + metadata: sessionMetadata, + }, + { timeout: 10_000 } + ), }); - - return { url: typeof session.url === 'string' ? session.url : null }; }), cancelSubscription: baseProcedure.mutation(async ({ ctx }) => {