Stripe Subscriptions
Stripe subscription flow internals
Stripe Subscriptions
The subscription management system is central to Odys's business model, enabling users to upgrade their accounts to access advanced features and higher usage limits. This document delves into the core components that facilitate the Stripe subscription flow, from defining available plans and initializing the Stripe API client to handling the checkout process for new subscriptions.
Overview
At its heart, the subscription system relies on a clear definition of service tiers and a robust integration with Stripe for payment processing. The src/lib/stripe/plans.ts file serves as the single source of truth for all available plans, detailing their features, pricing, and associated Stripe Price IDs. The src/lib/stripe/index.ts module provides a lazily initialized Stripe client, ensuring that API calls are only made when necessary and that the application can start even if Stripe credentials are not immediately available.
The user-facing interaction for initiating a subscription is handled by the src/app/api/stripe/checkout/route.ts API endpoint. This endpoint orchestrates the creation of a Stripe Checkout Session, guiding users through the payment process.
Stripe Client Initialization
The src/lib/stripe/index.ts file is responsible for setting up the Stripe API client. Instead of directly instantiating Stripe on module load, it employs a clever lazy initialization pattern. A private _stripe variable is used to hold the Stripe instance, which is only created the first time getStripe() is called. This function checks if _stripe is null and, if so, initializes it with new Stripe(process.env.STRIPE_SECRET_KEY!).
To maintain a consistent API surface, a Proxy is then used to expose the stripe object. This Proxy intercepts property access, deferring the actual Stripe client construction until its first method call. This design choice is particularly useful in environments where STRIPE_SECRET_KEY might not always be present, such as during CI runs where secrets are often stripped for security, or in local development setups where a contributor might not have configured all environment variables yet. It prevents module load failures and allows the application to start gracefully.
This module also re-exports PLAN_FEATURES, PLANS, and PlanKey from src/lib/stripe/plans.ts, centralizing access to plan-related data for other parts of the application.
Plan Definitions
The src/lib/stripe/plans.ts file meticulously defines the various subscription tiers available to Odys users. It contains two primary exports:
-
PLAN_FEATURES: This array serves as a master, ordered list of all features offered across any plan. Each entry includes alabeland an optionalcomingSoonflag. This canonical list is primarily intended for rendering comparison tables in the user interface, ensuring a consistent display of features and their availability. For example, "Múltiplos profissionais" is explicitly marked ascomingSoon: true. -
PLANS: This object provides a detailed definition for each subscription plan, keyed by aPlanKey(e.g.,free,basic,pro,premium). Each plan object includes:label: A human-readable name for the plan.price: The numerical price of the plan, such as39forbasicor149forpremium. Thefreeplan, naturally, has apriceof0.priceId: This crucial field stores the Stripe Price ID associated with the plan. For paid plans likebasic,pro, andpremium, these IDs are sourced from environment variables (STRIPE_BASIC_PRICE_ID,STRIPE_PRO_PRICE_ID,STRIPE_PREMIUM_PRICE_ID), allowing for flexible configuration across different Stripe accounts or environments. It's critical that these environment variables are correctly configured for paid plans. If aSTRIPE_PRICE_IDenvironment variable is missing, the correspondingpriceIdwill benull, which will cause a runtime error whenstripe.checkout.sessions.create()is invoked for that plan. Thefreeplan has apriceIdofnull.note: A brief descriptive note, currently used for thefreeplan to indicate its limits ("Limitado a 10 clientes e 20 agend./mês").limits: An object detailing specific usage constraints, such asclientsandappointmentsPerMonth. Paid plans typically haveInfinityfor these limits, while thefreeplan has concrete limits likeclients: 10andappointmentsPerMonth: 20.features: An array of strings listing the specific features included in that plan. These strings correspond to thelabelvalues inPLAN_FEATURES.
This structured approach ensures that plan data is consistently defined and easily accessible throughout the application, from UI rendering to backend payment processing.
Stripe Checkout API Endpoint
The src/app/api/stripe/checkout/route.ts file defines a Next.js API route that handles POST requests to initiate a Stripe subscription checkout session. This is the primary entry point for users wishing to upgrade their plan.
Upon receiving a request, the endpoint first applies rate limiting using getApiLimiter() and getIp() to prevent abuse. It then authenticates the user via Supabase, ensuring that only logged-in users can proceed. If no user is found, it returns a 401 unauthorized response.
The endpoint expects a plan key in the request body, which is validated against the PLANS object. Attempts to subscribe to an invalid plan or the free plan are rejected with a 400 bad request status.
Next, the system retrieves the professional record associated with the user from the Drizzle ORM, using db.select().from(professionals).where(eq(professionals.userId, user.id)). This record is essential for linking the subscription to the correct professional account and for evaluating trial status.
A key piece of logic involves handling trial periods. If the requested plan is pro and the professional is still within their trial period (checked via trialDaysLeft(professional.trialEndsAt)), the Stripe Checkout Session is configured to defer payment collection. The trialEnd timestamp is calculated and passed to Stripe, setting payment_method_collection to "if_required". Otherwise, payment_method_collection is "always", meaning payment details are required immediately.
Finally, the endpoint creates a Stripe Checkout Session using stripe.checkout.sessions.create(). This call specifies:
mode: "subscription"to indicate a recurring payment.payment_method_types: ["card"].line_itemsreferencing thepriceIdof the selected plan fromPLANS.success_urlandcancel_urlto redirect the user after checkout, usingprocess.env.NEXT_PUBLIC_APP_URL.metadataincludingprofessionalIdand the chosenplan.customer_emailandlocale: "pt-BR".
Upon successful creation, the endpoint returns the session.url to the client, allowing them to be redirected to Stripe's hosted checkout page. Any errors during this process are caught, logged using logger.error("stripe_checkout_failed", { err }), and returned as a 500 internal server error.
Design decisions
The current implementation reflects several deliberate design choices aimed at flexibility, maintainability, and user experience:
-
Lazy Stripe Client Initialization: The use of a
Proxyforstripeinsrc/lib/stripe/index.tsis a pragmatic choice. It allows the application to load and function even if theSTRIPE_SECRET_KEYenvironment variable is not set, which is common in CI environments or during initial local development. This avoids hard crashes on startup and defers the dependency on a valid Stripe key until an actual API call is made. The trade-off is a minor, almost imperceptible, overhead on the very first Stripe API interaction. -
Separation of
PLAN_FEATURESandPLANS: DefiningPLAN_FEATURESas a distinct, ordered array insrc/lib/stripe/plans.tsfrom thePLANSobject itself serves a specific purpose.PLAN_FEATURESprovides a canonical list for UI rendering, ensuring that feature comparison tables are always consistent in their ordering and content.PLANS, on the other hand, focuses on the operational details of each plan, including its StripepriceIdand specific limits. This separation prevents duplication and allows for independent evolution of the UI presentation versus the backend plan logic. -
Environment Variables for Stripe Price IDs: Storing Stripe Price IDs (e.g.,
STRIPE_BASIC_PRICE_ID) as environment variables within thePLANSdefinition insrc/lib/stripe/plans.tsdecouples the application code from specific Stripe configurations. This is critical for managing different Stripe accounts for development, staging, and production environments, or for A/B testing different pricing strategies without modifying the codebase. -
Conditional Trial Logic in Checkout: Integrating the
trialDaysLeftcheck directly into thestripe.checkout.sessions.create()call insrc/app/api/stripe/checkout/route.tsfor theproplan allows for a tailored user experience. By settingsubscription_data: { trial_end: trialEnd }andpayment_method_collection: "if_required", users still within their trial period for theproplan are not immediately prompted for payment details. This reduces friction during the upgrade process, potentially improving conversion rates by allowing users to commit to a plan without immediate financial obligation if they are already trialing.
Potential improvements
-
Enhanced Type Safety for
stripeProxy: Thestripeproxy insrc/lib/stripe/index.tsusesas unknown as Record<string | symbol, unknown>to cast the target, effectively bypassing TypeScript's type checking for theStripeobject's methods. While functional, this reduces compile-time safety. A more robust approach could involve a custom type definition for the proxy that correctly infers or asserts theStripetype, or by using a factory function that returns theStripeinstance directly, avoiding the proxy's type complexities. -
Centralized Plan Validation Logic: The
if (!PLANS[plan] || plan === "free")validation insrc/app/api/stripe/checkout/route.tsis currently embedded directly within the API route handler. As the application grows, other parts of the system might need to validate plan keys or determine if a plan is purchasable. Extracting this logic into a shared utility function or a Zod schema would promote consistency, reduce duplication, and make validation rules easier to manage and test across the codebase. -
Programmatic Link for
comingSoonFeatures: ThePLAN_FEATURESarray insrc/lib/stripe/plans.tsincludes acomingSoonflag, which is useful for UI presentation. However, thePLANSobject'sfeaturesarray is simply a list of strings. There isn't a direct programmatic link that prevents acomingSoonfeature from being accidentally listed as "available" in a plan'sfeaturesarray, or to automatically filter them out based on the flag. Introducing a more structured way to link features to theirPLAN_FEATURESdefinition (e.g., by using feature IDs instead of labels in thefeaturesarray) could enable runtime checks or automated filtering based on thecomingSoonstatus.
References
src/lib/stripe/index.tssrc/lib/stripe/plans.tssrc/app/api/stripe/checkout/route.ts