From 821054d82e763b34899f2c063c7346516982a5d5 Mon Sep 17 00:00:00 2001 From: Simplereally Date: Sat, 24 Jan 2026 20:34:00 +1100 Subject: [PATCH] refactor(stripe): use lookup keys instead of hardcoded price IDs - Replace NEXT_PUBLIC_STRIPE_PRO_*_PRICE_ID env vars with Stripe lookup keys - Add convex/lib/stripeHelpers.ts with price resolution logic - Add 27 unit tests for stripe helpers - Fix TypeScript strict mode violations (any -> unknown) - Delete obsolete lib/config/stripe.ts Stripe Dashboard is now the single source of truth for pricing. Set lookup_key: pro_monthly on the price in Stripe Dashboard. --- app/pricing/checkout-button.tsx | 13 +- convex/_generated/api.d.ts | 2 + convex/lib/stripeHelpers.test.ts | 323 +++++++++++++++++++++++++++++++ convex/lib/stripeHelpers.ts | 132 +++++++++++++ convex/stripe.ts | 95 +++++---- lib/config/stripe.ts | 24 --- 6 files changed, 518 insertions(+), 71 deletions(-) create mode 100644 convex/lib/stripeHelpers.test.ts create mode 100644 convex/lib/stripeHelpers.ts delete mode 100644 lib/config/stripe.ts diff --git a/app/pricing/checkout-button.tsx b/app/pricing/checkout-button.tsx index da659ff..97281ba 100644 --- a/app/pricing/checkout-button.tsx +++ b/app/pricing/checkout-button.tsx @@ -14,7 +14,6 @@ import { Button } from "@/components/ui/button" import { useUser } from "@clerk/nextjs" import { useAction } from "convex/react" import { api } from "@/convex/_generated/api" -import { STRIPE_CONFIG, isStripeConfigured } from "@/lib/config/stripe" import { ArrowRight, Loader2 } from "lucide-react" import { useSearchParams } from "next/navigation" import { useEffect, useState } from "react" @@ -54,22 +53,12 @@ export function CheckoutButton({ tierName, cta, highlighted, variant }: Checkout return } - // Check Stripe configuration - if (!isStripeConfigured()) { - console.error("Stripe configuration missing: NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID not set") - toast.error("Payment unavailable", { - description: "Please try again later or contact support.", - }) - return - } - // Pro tier - create Stripe checkout session via Convex setIsLoading(true) try { const { url } = await createCheckout({ - priceId: STRIPE_CONFIG.prices.proMonthly, - isAnnual: false, + planType: "monthly", successUrl: `${window.location.origin}/pricing?success=true`, cancelUrl: `${window.location.origin}/pricing?canceled=true`, }) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 1e711ee..4db8f37 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -28,6 +28,7 @@ import type * as lib_providerHealth from "../lib/providerHealth.js"; import type * as lib_providerHealthFunctions from "../lib/providerHealthFunctions.js"; import type * as lib_r2 from "../lib/r2.js"; import type * as lib_retry from "../lib/retry.js"; +import type * as lib_stripeHelpers from "../lib/stripeHelpers.js"; import type * as lib_subscription from "../lib/subscription.js"; import type * as lib_videoPreview from "../lib/videoPreview.js"; import type * as lib_videoThumbnail from "../lib/videoThumbnail.js"; @@ -75,6 +76,7 @@ declare const fullApi: ApiFromModules<{ "lib/providerHealthFunctions": typeof lib_providerHealthFunctions; "lib/r2": typeof lib_r2; "lib/retry": typeof lib_retry; + "lib/stripeHelpers": typeof lib_stripeHelpers; "lib/subscription": typeof lib_subscription; "lib/videoPreview": typeof lib_videoPreview; "lib/videoThumbnail": typeof lib_videoThumbnail; diff --git a/convex/lib/stripeHelpers.test.ts b/convex/lib/stripeHelpers.test.ts new file mode 100644 index 0000000..1f82427 --- /dev/null +++ b/convex/lib/stripeHelpers.test.ts @@ -0,0 +1,323 @@ +/** + * Tests for Stripe Helper Functions + * + * Tests the pure utility functions for Stripe price lookup. + * Uses mocked Stripe client to avoid hitting real Stripe API. + */ + +import { describe, it, expect, vi, beforeEach } from "vitest" +import Stripe from "stripe" +import { + getLookupKeyForPlan, + fetchPriceByLookupKey, + resolvePriceForPlan, + createStripeClient, + getStripeErrorMessage, + PRICE_LOOKUP_KEYS, + type PlanType, +} from "./stripeHelpers" + +// Mock price fixtures +const mockMonthlyPrice: Stripe.Price = { + id: "price_1ABC123monthly", + object: "price", + active: true, + currency: "usd", + unit_amount: 300, + lookup_key: "pro_monthly", + product: "prod_123", + type: "recurring", + recurring: { + interval: "month", + interval_count: 1, + usage_type: "licensed", + meter: null, + trial_period_days: null, + }, + billing_scheme: "per_unit", + created: 1640000000, + livemode: false, + metadata: {}, + nickname: null, + tax_behavior: null, + tiers_mode: null, + transform_quantity: null, + unit_amount_decimal: "300", + custom_unit_amount: null, +} + +const mockAnnualPrice: Stripe.Price = { + ...mockMonthlyPrice, + id: "price_1ABC123annual", + lookup_key: "pro_annual", + unit_amount: 2400, + recurring: { + interval: "year", + interval_count: 1, + usage_type: "licensed", + meter: null, + trial_period_days: null, + }, +} + +describe("PRICE_LOOKUP_KEYS", () => { + it("has correct lookup key for monthly plan", () => { + expect(PRICE_LOOKUP_KEYS.monthly).toBe("pro_monthly") + }) + + it("has correct lookup key for annual plan", () => { + expect(PRICE_LOOKUP_KEYS.annual).toBe("pro_annual") + }) +}) + +describe("getLookupKeyForPlan", () => { + it("returns pro_monthly for monthly plan", () => { + expect(getLookupKeyForPlan("monthly")).toBe("pro_monthly") + }) + + it("returns pro_annual for annual plan", () => { + expect(getLookupKeyForPlan("annual")).toBe("pro_annual") + }) + + it.each([ + ["monthly", "pro_monthly"], + ["annual", "pro_annual"], + ] as const)("maps %s plan to %s lookup key", (planType, expectedKey) => { + expect(getLookupKeyForPlan(planType as PlanType)).toBe(expectedKey) + }) +}) + +describe("fetchPriceByLookupKey", () => { + function createMockStripe(pricesData: Stripe.Price[] = []): Stripe { + return { + prices: { + list: vi.fn().mockResolvedValue({ + data: pricesData, + has_more: false, + }), + }, + } as unknown as Stripe + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + it("returns success with priceId when price found", async () => { + const stripe = createMockStripe([mockMonthlyPrice]) + + const result = await fetchPriceByLookupKey(stripe, "pro_monthly") + + expect(result.success).toBe(true) + if (result.success) { + expect(result.priceId).toBe("price_1ABC123monthly") + expect(result.price).toEqual(mockMonthlyPrice) + } + }) + + it("calls stripe.prices.list with correct parameters", async () => { + const stripe = createMockStripe([mockMonthlyPrice]) + + await fetchPriceByLookupKey(stripe, "pro_monthly") + + expect(stripe.prices.list).toHaveBeenCalledWith({ + lookup_keys: ["pro_monthly"], + active: true, + limit: 1, + }) + }) + + it("returns error when no price found for lookup key", async () => { + const stripe = createMockStripe([]) + + const result = await fetchPriceByLookupKey(stripe, "nonexistent_key") + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("No active Stripe price found") + expect(result.error).toContain("nonexistent_key") + } + }) + + it("returns error when stripe API throws", async () => { + const stripe = { + prices: { + list: vi.fn().mockRejectedValue(new Error("Network error")), + }, + } as unknown as Stripe + + const result = await fetchPriceByLookupKey(stripe, "pro_monthly") + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("Failed to fetch price from Stripe") + expect(result.error).toContain("Network error") + } + }) + + it("handles Stripe-specific errors", async () => { + const stripeError = new Stripe.errors.StripeError({ + message: "Rate limited", + type: "rate_limit_error", + }) + const stripe = { + prices: { + list: vi.fn().mockRejectedValue(stripeError), + }, + } as unknown as Stripe + + const result = await fetchPriceByLookupKey(stripe, "pro_monthly") + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("Failed to fetch price from Stripe") + } + }) + + it("handles unknown errors gracefully", async () => { + const stripe = { + prices: { + list: vi.fn().mockRejectedValue("String error"), + }, + } as unknown as Stripe + + const result = await fetchPriceByLookupKey(stripe, "pro_monthly") + + expect(result.success).toBe(false) + if (!result.success) { + expect(result.error).toContain("Unknown error") + } + }) +}) + +describe("resolvePriceForPlan", () => { + function createMockStripe(pricesData: Stripe.Price[] = []): Stripe { + return { + prices: { + list: vi.fn().mockResolvedValue({ + data: pricesData, + has_more: false, + }), + }, + } as unknown as Stripe + } + + it("resolves monthly plan to correct price", async () => { + const stripe = createMockStripe([mockMonthlyPrice]) + + const result = await resolvePriceForPlan(stripe, "monthly") + + expect(result.success).toBe(true) + if (result.success) { + expect(result.priceId).toBe("price_1ABC123monthly") + } + }) + + it("resolves annual plan to correct price", async () => { + const stripe = createMockStripe([mockAnnualPrice]) + + const result = await resolvePriceForPlan(stripe, "annual") + + expect(result.success).toBe(true) + if (result.success) { + expect(result.priceId).toBe("price_1ABC123annual") + } + }) + + it("calls prices.list with pro_monthly for monthly plan", async () => { + const stripe = createMockStripe([mockMonthlyPrice]) + + await resolvePriceForPlan(stripe, "monthly") + + expect(stripe.prices.list).toHaveBeenCalledWith( + expect.objectContaining({ + lookup_keys: ["pro_monthly"], + }) + ) + }) + + it("calls prices.list with pro_annual for annual plan", async () => { + const stripe = createMockStripe([mockAnnualPrice]) + + await resolvePriceForPlan(stripe, "annual") + + expect(stripe.prices.list).toHaveBeenCalledWith( + expect.objectContaining({ + lookup_keys: ["pro_annual"], + }) + ) + }) + + it("returns error when price not found", async () => { + const stripe = createMockStripe([]) + + const result = await resolvePriceForPlan(stripe, "monthly") + + expect(result.success).toBe(false) + }) +}) + +describe("createStripeClient", () => { + it("throws error when secret key is undefined", () => { + expect(() => createStripeClient(undefined)).toThrow( + "STRIPE_SECRET_KEY is missing" + ) + }) + + it("throws error when secret key is empty string", () => { + expect(() => createStripeClient("")).toThrow( + "STRIPE_SECRET_KEY is missing" + ) + }) + + it("returns Stripe instance when secret key provided", () => { + const client = createStripeClient("sk_test_12345") + + expect(client).toBeInstanceOf(Stripe) + }) + + it("configures correct API version", () => { + const client = createStripeClient("sk_test_12345") + + // Access internal config - this tests the configuration was applied + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((client as any)._api.version).toBe("2025-12-15.clover") + }) +}) + +describe("getStripeErrorMessage", () => { + it("extracts message from standard Error", () => { + const error = new Error("Something went wrong") + + expect(getStripeErrorMessage(error)).toBe("Something went wrong") + }) + + it("extracts message from Stripe error", () => { + const error = new Stripe.errors.StripeError({ + message: "Card declined", + type: "card_error", + }) + + expect(getStripeErrorMessage(error)).toBe("Card declined") + }) + + it("returns Unknown error for string", () => { + expect(getStripeErrorMessage("string error")).toBe( + "Unknown error occurred" + ) + }) + + it("returns Unknown error for null", () => { + expect(getStripeErrorMessage(null)).toBe("Unknown error occurred") + }) + + it("returns Unknown error for undefined", () => { + expect(getStripeErrorMessage(undefined)).toBe("Unknown error occurred") + }) + + it("returns Unknown error for object without message", () => { + expect(getStripeErrorMessage({ code: 500 })).toBe( + "Unknown error occurred" + ) + }) +}) diff --git a/convex/lib/stripeHelpers.ts b/convex/lib/stripeHelpers.ts new file mode 100644 index 0000000..69b869b --- /dev/null +++ b/convex/lib/stripeHelpers.ts @@ -0,0 +1,132 @@ +/** + * Stripe Helper Functions + * + * Utility functions for Stripe integration. + * Uses lookup keys for dynamic price resolution (Stripe best practice). + */ + +import Stripe from "stripe" + +/** + * Plan types supported by the subscription system. + */ +export type PlanType = "monthly" | "annual" + +/** + * Lookup key constants for Stripe prices. + * These should match the lookup_keys set in Stripe Dashboard. + */ +export const PRICE_LOOKUP_KEYS = { + monthly: "pro_monthly", + annual: "pro_annual", +} as const satisfies Record + +/** + * Get the Stripe lookup key for a given plan type. + */ +export function getLookupKeyForPlan(planType: PlanType): string { + return PRICE_LOOKUP_KEYS[planType] +} + +/** + * Result of a price lookup operation. + */ +export type PriceLookupResult = + | { success: true; priceId: string; price: Stripe.Price } + | { success: false; error: string } + +/** + * Fetch a Stripe price by its lookup key. + * + * This is the recommended Stripe pattern for dynamic pricing: + * - Set lookup_key on prices in Stripe Dashboard + * - Fetch by lookup_key at checkout time + * - Transfer lookup_key when creating new prices + * + * @param stripe - Stripe client instance + * @param lookupKey - The lookup key to search for + * @returns Price lookup result with priceId or error + */ +export async function fetchPriceByLookupKey( + stripe: Stripe, + lookupKey: string +): Promise { + try { + const prices = await stripe.prices.list({ + lookup_keys: [lookupKey], + active: true, + limit: 1, + }) + + if (!prices.data.length) { + return { + success: false, + error: `No active Stripe price found for lookup key "${lookupKey}". ` + + `Create a price in Stripe Dashboard with this lookup key.`, + } + } + + const price = prices.data[0] + return { + success: true, + priceId: price.id, + price, + } + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error" + return { + success: false, + error: `Failed to fetch price from Stripe: ${message}`, + } + } +} + +/** + * Resolve the Stripe price ID for a subscription plan. + * + * Combines plan type → lookup key → price ID resolution. + * + * @param stripe - Stripe client instance + * @param planType - The plan type (monthly or annual) + * @returns Price lookup result + */ +export async function resolvePriceForPlan( + stripe: Stripe, + planType: PlanType +): Promise { + const lookupKey = getLookupKeyForPlan(planType) + return fetchPriceByLookupKey(stripe, lookupKey) +} + +/** + * Create a configured Stripe client. + * + * @param secretKey - Stripe secret key from environment + * @returns Configured Stripe client + * @throws Error if secret key is missing + */ +export function createStripeClient(secretKey: string | undefined): Stripe { + if (!secretKey) { + throw new Error( + "STRIPE_SECRET_KEY is missing in environment variables. " + + "Please add it in your Convex dashboard." + ) + } + + return new Stripe(secretKey, { + apiVersion: "2025-12-15.clover", + }) +} + +/** + * Extract a user-friendly error message from an unknown error. + */ +export function getStripeErrorMessage(err: unknown): string { + if (err instanceof Stripe.errors.StripeError) { + return err.message + } + if (err instanceof Error) { + return err.message + } + return "Unknown error occurred" +} diff --git a/convex/stripe.ts b/convex/stripe.ts index ce475ec..7915c13 100644 --- a/convex/stripe.ts +++ b/convex/stripe.ts @@ -3,18 +3,33 @@ import { components } from "./_generated/api" import { StripeSubscriptions } from "@convex-dev/stripe" import { v } from "convex/values" import { getSubscriptionStatus } from "./lib/subscription" -import Stripe from "stripe" +import { + createStripeClient, + getStripeErrorMessage, + resolvePriceForPlan, + type PlanType, +} from "./lib/stripeHelpers" const stripeClient = new StripeSubscriptions(components.stripe, {}) /** - * Create a checkout session for a Pro subscription - * Uses raw Stripe SDK to enable promotion codes support + * Create a checkout session for a Pro subscription. + * + * Uses Stripe lookup keys to dynamically resolve prices at checkout time. + * This is the recommended Stripe pattern: + * - Prices are configured in Stripe Dashboard with lookup_key + * - No hardcoded price IDs in environment variables + * - Price changes in Dashboard take effect immediately + * + * Required Stripe Dashboard setup: + * - Create a price with lookup_key: "pro_monthly" + * - Create a price with lookup_key: "pro_annual" + * + * Uses raw Stripe SDK to enable promotion codes support. */ export const createSubscriptionCheckout = action({ args: { - priceId: v.string(), - isAnnual: v.boolean(), + planType: v.union(v.literal("monthly"), v.literal("annual")), successUrl: v.optional(v.string()), cancelUrl: v.optional(v.string()), }, @@ -28,7 +43,7 @@ export const createSubscriptionCheckout = action({ throw new Error("Not authenticated") } - // Get or create a Stripe customer (still use the component for this) + // Get or create a Stripe customer (use the component for this) const customer = await stripeClient.getOrCreateCustomer(ctx, { userId: identity.subject, email: identity.email, @@ -36,38 +51,48 @@ export const createSubscriptionCheckout = action({ }) // Determine success/cancel URLs - // Use provided URLs or fallback to environment (mostly for backward compatibility/prod) - const successUrl = args.successUrl || `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/pricing?success=true` - const cancelUrl = args.cancelUrl || `${process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000"}/pricing?canceled=true` + const baseUrl = process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000" + const successUrl = args.successUrl || `${baseUrl}/pricing?success=true` + const cancelUrl = args.cancelUrl || `${baseUrl}/pricing?canceled=true` - // Create checkout session using raw Stripe SDK for promotion code support - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { - apiVersion: "2025-12-15.clover", - }) + // Create Stripe client + const stripe = createStripeClient(process.env.STRIPE_SECRET_KEY) - const session = await stripe.checkout.sessions.create({ - mode: "subscription", - customer: customer.customerId, - line_items: [ - { - price: args.priceId, - quantity: 1, - }, - ], - success_url: successUrl, - cancel_url: cancelUrl, - allow_promotion_codes: true, // 🎉 Enable promo codes! - subscription_data: { - metadata: { - userId: identity.subject, - isAnnual: args.isAnnual.toString(), - }, - }, - }) + // Resolve price via lookup key (Stripe Dashboard is source of truth) + const priceResult = await resolvePriceForPlan(stripe, args.planType as PlanType) + if (!priceResult.success) { + throw new Error(priceResult.error) + } - return { - sessionId: session.id, - url: session.url, + try { + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + customer: customer.customerId, + line_items: [ + { + price: priceResult.priceId, + quantity: 1, + }, + ], + success_url: successUrl, + cancel_url: cancelUrl, + allow_promotion_codes: true, + subscription_data: { + metadata: { + userId: identity.subject, + isAnnual: (args.planType === "annual").toString(), + }, + }, + }) + + return { + sessionId: session.id, + url: session.url, + } + } catch (err: unknown) { + const message = getStripeErrorMessage(err) + console.error("Stripe checkout session creation failed:", err) + throw new Error(`Stripe checkout failed: ${message}`) } }, }) diff --git a/lib/config/stripe.ts b/lib/config/stripe.ts deleted file mode 100644 index ede447f..0000000 --- a/lib/config/stripe.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Stripe Configuration - * - * Price IDs should be created in Stripe Dashboard and set via environment variables. - * This ensures subscriptions are properly tracked by @convex-dev/stripe component. - */ - -export const STRIPE_CONFIG = { - prices: { - /** - * Pro subscription price ID from Stripe Dashboard - * Create a product in Stripe → Add a recurring price → Copy the price ID (price_xxx) - */ - proMonthly: process.env.NEXT_PUBLIC_STRIPE_PRO_MONTHLY_PRICE_ID ?? "", - proAnnual: process.env.NEXT_PUBLIC_STRIPE_PRO_ANNUAL_PRICE_ID ?? "", - }, -} as const - -/** - * Check if Stripe is properly configured - */ -export function isStripeConfigured(): boolean { - return Boolean(STRIPE_CONFIG.prices.proMonthly) -}