Stripe Subscription Flow Internals
A deep dive into how Odys manages professional subscriptions using Stripe, covering plan definitions, checkout session creation, and webhook interactions.
Stripe Subscription Flow Internals
In Odys, the ability for professionals to subscribe to various plans is a core monetization strategy, unlocking advanced features and removing usage limits. This document aims to provide Tiago with a comprehensive understanding of the internal workings of this Stripe-powered subscription system, from how plans are defined to the creation of checkout sessions and the critical role of webhooks.
Overview
The subscription mechanism in Odys is built around Stripe, a robust payment processing platform. At its heart, the system allows professionals to upgrade their accounts from a free tier to paid basic, pro, or premium plans, each offering a distinct set of features and capabilities. This entire flow is orchestrated through a combination of client-side interactions that initiate a Stripe checkout, server-side API routes that communicate with Stripe, and webhook endpoints that listen for asynchronous updates from Stripe.
The professionals table, with its 24 columns, serves as the central repository for professional data, including crucial Stripe-related identifiers such as stripeCustomerId and stripeSubscriptionId. These columns are essential for linking an Odys professional account to their corresponding Stripe customer and subscription records. Additionally, the trialEndsAt column in the professionals table plays a role in managing trial periods, particularly for the pro plan.
Odys exposes two primary API routes for managing this flow: /api/stripe/checkout for initiating new subscriptions or plan upgrades, and /api/stripe/webhook for processing events sent by Stripe. The system is configured to listen for specific Stripe webhook events, including checkout.session.completed, customer.subscription.updated, and customer.subscription.deleted, which are vital for keeping Odys's internal state synchronized with Stripe.
Plan Definitions and Structure
The various subscription plans available in Odys are meticulously defined within src/lib/stripe/plans.ts. This file serves as the single source of truth for all plan-related data, ensuring consistency across the application's UI and backend logic.
You'll find two main exports here:
PLAN_FEATURES: This is an array of objects, each representing a distinct feature offered by Odys. Its primary purpose is to provide a master list that can be used to render comparison tables or feature lists in the user interface. Each feature has alabeland an optionalcomingSoonflag. This separation from thePLANSobject allows for flexible UI rendering without coupling it directly to the plan's backend configuration.PLANS: This object is a dictionary where each key (free,basic,pro,premium) corresponds to a specific subscription plan. Each plan object contains:label: A human-readable name for the plan.price: The monthly cost of the plan in local currency.priceId: This is the critical link to Stripe. It holds the Stripe Price ID, which is retrieved from environment variables likeSTRIPE_BASIC_PRICE_ID. Using environment variables for these IDs (STRIPE_BASIC_PRICE_ID,STRIPE_PRO_PRICE_ID,STRIPE_PREMIUM_PRICE_ID) allows for easy configuration across different environments (development, staging, production) and ensures that sensitive Stripe IDs are not hardcoded into the codebase. Thefreeplan, naturally, has anullpriceIdas it doesn't involve a Stripe subscription.note: A brief description or disclaimer, particularly for thefreeplan's limitations.limits: An object defining usage caps for the plan, such asclientsandappointmentsPerMonth. For paid plans, these are set toInfinity, indicating no practical limits.features: An array of strings, listing the specific features included in that plan. These strings directly correspond to thelabelvalues inPLAN_FEATURES.
The PlanKey type is also exported, providing type safety for referencing plans by their keys.
Stripe Integration Setup
The core Stripe client is initialized in src/lib/stripe/index.ts. This file is straightforward:
- It imports the
Stripelibrary. - It creates and exports a
stripeinstance, configured withprocess.env.STRIPE_SECRET_KEY!. This ensures that all interactions with the Stripe API are authenticated and secure. The use of an environment variable for the secret key is a standard security practice. - It re-exports
PLAN_FEATURESandPLANSfromsrc/lib/stripe/plans.ts. This centralizes the Stripe-related exports, making it convenient for other parts of the application to import both the Stripe client and the plan definitions from a single location.
Initiating a Subscription Checkout
The /api/stripe/checkout API route, defined in src/app/api/stripe/checkout/route.ts, is responsible for creating a Stripe Checkout Session when a professional decides to subscribe or upgrade their plan. This is a POST endpoint that handles the server-side logic for initiating the payment process.
Here's a breakdown of its operation:
- Authentication and Authorization: The route first authenticates the user using
supabase.auth.getUser(). If no user is found, it returns a401Unauthorized response, ensuring that only logged-in professionals can initiate a checkout. - Plan Validation: It expects a
plankey (e.g.,"basic","pro") in the request body. It validates thisplanagainst thePLANSobject and rejects requests for invalid plans or thefreeplan, as thefreeplan doesn't require a Stripe checkout. - Professional Data Retrieval: The system fetches the professional's record from the
professionalstable using theiruserId. This is crucial for accessing existing Stripe customer IDs or trial information. - Trial Management: A key piece of logic here is the handling of trial periods. If the selected
planis"pro"and the professional still hastrialDaysLeft(determined by thetrialEndsAtcolumn), the checkout session is configured to include a trial period. ThetrialEndtimestamp is calculated and passed to Stripe. This also influencespayment_method_collection, which is set to"if_required"during a trial, meaning Stripe will only collect payment details if necessary, rather than immediately. - Stripe Checkout Session Creation: The
stripe.checkout.sessions.createmethod is called to generate a new checkout session.mode: "subscription": Specifies that this session is for creating a subscription.payment_method_types: ["card"]: Indicates that only card payments are accepted.line_items: Uses thepriceIdfrom the selected plan (e.g.,PLANS[plan].priceId!) to specify the product being purchased.success_urlandcancel_url: These URLs (/dashboard/plans?success=trueand/dashboard/plans?cancelled=true) are where Stripe redirects the user after completing or canceling the checkout process, allowing Odys to provide appropriate feedback.metadata: This object is vital for passing custom data to Stripe, which will be included in subsequent webhook events. Here,professionalIdand the chosenplanare stored, enabling the webhook handler to easily identify the professional and plan associated with the session.customer_email: The user's email is passed to Stripe, pre-filling the checkout form and potentially linking to an existing Stripe customer.locale: "pt-BR": Sets the language of the Stripe checkout page.subscription_data: This is where thetrial_endis passed if a trial is active.
- Response: The route returns a JSON object containing the
urlof the Stripe Checkout Session. The client-side application then redirects the user to this URL to complete the payment.
Stripe Webhook Handling
The /api/stripe/webhook route is a critical component for maintaining data consistency between Odys and Stripe. While the implementation details of this route are not provided in the current code context, the FACTS indicate that Odys is configured to listen for specific Stripe webhook events:
checkout.session.completed: This event fires when a customer successfully completes a checkout session. For Odys, this would typically trigger updates to theprofessionalstable, storing the newstripeCustomerIdandstripeSubscriptionId, and updating theplancolumn.customer.subscription.updated: This event is crucial for handling changes to an existing subscription, such as plan upgrades, downgrades, or changes in billing status. The webhook handler would update theplanand potentiallytrialEndsAtcolumns in theprofessionalstable accordingly.customer.subscription.deleted: When a subscription is canceled or expires, this event is sent. The webhook handler would update the professional'splantofreeand clearstripeSubscriptionIdin theprofessionalstable, effectively revoking access to paid features.
These webhooks ensure that Odys's internal representation of a professional's subscription status is always up-to-date, even for events that happen asynchronously or outside of a direct user interaction with the Odys application.
Design decisions
The current design of the Stripe subscription flow reflects several deliberate choices aimed at clarity, maintainability, and leveraging Stripe's capabilities:
- Separation of Plan Features and Plan Data (
PLAN_FEATURESvs.PLANS): By definingPLAN_FEATURESas a distinct list, you've created a flexible way to manage and display features independently of their inclusion in specific plans. This allows for easier UI development, such as feature comparison tables, without tightly coupling the display logic to the backend plan structure. ThePLANSobject then focuses purely on the operational aspects of each plan, likeprice,priceId, andlimits. - Environment Variables for Stripe Price IDs: Using
process.env.STRIPE_BASIC_PRICE_IDand similar variables forpriceIds insrc/lib/stripe/plans.tsis a standard and robust practice. It allows for different Stripe product configurations across development, staging, and production environments without code changes. It also keeps sensitive Stripe IDs out of the codebase, improving security. - Storing Stripe IDs in
professionalsTable: Theprofessionalstable includesstripeCustomerIdandstripeSubscriptionIdcolumns. This direct linkage within the coreprofessionalsentity simplifies data retrieval and ensures that a professional's Stripe identity and subscription status are readily available alongside their other profile information. This avoids complex joins or external lookups when checking a professional's plan. - Leveraging Stripe Checkout for Trials: The logic in
/api/stripe/checkoutthat setssubscription_data: { trial_end: trialEnd }andpayment_method_collection: "if_required"for theproplan during a trial period is a smart use of Stripe's native trial features. This offloads the complexity of trial management to Stripe, reducing the amount of custom logic needed in Odys and ensuring that trial periods are handled consistently and correctly by the payment gateway itself. - Metadata in Checkout Sessions: Including
professionalIdandplanin themetadataof the Stripe Checkout Session is a crucial design choice. This metadata will be passed along to subsequent Stripe webhook events, allowing the/api/stripe/webhookhandler to easily identify which professional and plan a particular event relates to, without needing to query the database based on less direct identifiers.
Potential improvements
- Implement the
/api/stripe/webhookhandler: TheFACTSclearly state that Odys listens tocheckout.session.completed,customer.subscription.updated, andcustomer.subscription.deletedevents. However, the implementation of the/api/stripe/webhookroute is not provided in the current code. A robust webhook handler is essential for a complete subscription system to:- Update
professionals.stripeCustomerIdandprofessionals.stripeSubscriptionIduponcheckout.session.completed. - Adjust
professionals.planand potentiallyprofessionals.trialEndsAtforcustomer.subscription.updatedevents (e.g., upgrades, downgrades, trial expirations). - Set
professionals.plantofreeand clear Stripe IDs forcustomer.subscription.deletedevents. Without this, the Odys database will quickly fall out of sync with Stripe, leading to incorrect plan statuses and feature access for professionals.
- Update
- Enforce Plan Limits: The
PLANSobject insrc/lib/stripe/plans.tsdefineslimitsforclientsandappointmentsPerMonthfor thefreeplan. While these limits are defined, the provided code does not show where these limits are actually enforced within the application. For example, when a professional tries to add a new client or schedule an appointment, the system should check their currentplan(fromprofessionals.plan) and compare it against thePLANS[currentPlan].limits.clientsorPLANS[currentPlan].limits.appointmentsPerMonth. Implementing this enforcement logic is critical to ensure that the subscription tiers provide the intended value and restrictions. - Robust
priceIdValidation in Checkout: Insrc/app/api/stripe/checkout/route.ts, the code usesPLANS[plan].priceId!. The non-null assertion operator!assumes thatpriceIdwill always be present for a paid plan. WhilePLANSis defined withprocess.env.STRIPE_BASIC_PRICE_ID ?? null, if an environment variable likeSTRIPE_BASIC_PRICE_IDis missing,priceIdwould benull. This would cause a runtime error whenstripe.checkout.sessions.createis called. A more explicit check, such asif (!PLANS[plan].priceId) { return NextResponse.json({ error: "Stripe Price ID not configured for this plan" }, { status: 500 }); }, would make the system more resilient to configuration issues.
References
src/lib/stripe/index.tssrc/lib/stripe/plans.tssrc/app/api/stripe/checkout/route.ts
Payment System Overview
Understanding the two distinct payment flows in Odys: professional subscriptions and client appointment payments, and the rationale behind their design.
PIX Payment Flow
Deep dive into Odys's PIX EMV BR Code generation, CRC16 calculation, and QR rendering for seamless payment processing.