Chapter 3: Handling Upgrades & Token Top-ups
Theoretical Foundations
The Core Concept: From Static Subscriptions to Dynamic, Usage-Driven Economies
In the previous chapter, we established the foundational architecture of the Monetization Engine by integrating Stripe's core subscription billing capabilities. We treated subscriptions as static entities—fixed tiers with predictable monthly fees. However, the modern SaaS landscape, particularly in the AI domain, demands a more fluid economic model. This chapter introduces the shift from static subscriptions to dynamic, usage-based billing, a paradigm where revenue scales directly with customer value consumption.
The core concept here is Granular Value Exchange. Instead of a flat fee for access, customers pay for precise units of value consumed—be it AI token generation, API calls, or data processing time. This model aligns the provider's revenue with the customer's success, creating a fair and scalable growth engine. To implement this, we must master two interconnected mechanics: User-Initiated Growth (upgrades and top-ups) and Automated Revenue Protection (Smart Dunning and AI-assisted account management).
The Web Development Analogy: From Monolithic Apps to Microservices with an API Gateway
To understand usage-based billing, let's draw an analogy from web development architecture.
Imagine a traditional monolithic application. It's a single, large codebase deployed as one unit. You pay a fixed hosting fee regardless of whether you use 10% or 100% of its features. This is analogous to a static subscription tier (e.g., "Pro Plan" for $49/month). The cost is predictable for the user and the provider, but it doesn't scale with actual usage.
Now, consider a modern microservices architecture. Each service (e.g., Authentication, Image Processing, Data Analytics) is independently deployable and scalable. An API Gateway sits in front, routing requests, handling authentication, and—critically—metering usage. If a user makes 10,000 calls to the Image Processing service, the gateway tracks this and can bill them per API call.
In this analogy:
- The Microservices are the individual AI agents, tools, or API endpoints (e.g.,
generateText,analyzeSentiment). - The API Gateway is Stripe's Billing Engine. It intercepts every unit of consumption, validates the user's payment method, and records the usage against their account.
- Usage-Based Billing is the mechanism where the gateway (Stripe) aggregates the metered events and calculates the invoice at the end of the billing cycle, or in real-time for prepaid top-ups.
This shift from a monolithic billing model to a microservice-based, metered model is what allows an AI business to scale revenue infinitely, just as a microservice architecture allows a tech stack to scale infinitely.
The Mechanics of User-Initiated Growth: Upgrades and Token Top-ups
User-initiated growth is the primary driver of revenue expansion in a usage-based model. It occurs when a customer, experiencing increased value or approaching a limit, proactively increases their spending. We handle this through two distinct but related flows: Subscription Upgrades and Token Top-ups.
Subscription Upgrades: The Proration Engine
When a user upgrades their subscription mid-cycle (e.g., from a "Starter" plan with 10k monthly tokens to a "Pro" plan with 100k monthly tokens), the billing system must perform a complex financial calculation called proration. Proration ensures fairness by charging the user only for the value they consumed on the lower tier and the remaining value on the higher tier for the rest of the billing period.
The "Why": Without proration, a user upgrading on day 25 of a 30-day cycle would feel penalized, paying the full new price for only 5 days of service. Proration builds trust and encourages upgrades by making them financially logical at any point in the cycle.
The "Under the Hood": Stripe's Billing API handles this automatically. When a subscription is updated, Stripe calculates the time-weighted cost of both plans. It then generates an immediate invoice for the difference (the proration credit). For example:
- Starter Plan: $10/month
- Pro Plan: $50/month
- Upgrade Time: 15 days into a 30-day billing cycle.
- Calculation:
- Unused Starter Plan value: ($10 / 30 days) * 15 days = $5.00 credit.
- Remaining Pro Plan value: ($50 / 30 days) * 15 days = $25.00 charge.
- Immediate Invoice: $25.00 - \(5.00 = **\)20.00**.
This immediate invoice is charged against the customer's default payment method. The system must be robust enough to handle payment failures during this critical upgrade moment, which leads us to Smart Dunning.
Token Top-ups: The Prepaid Model
Token top-ups are a form of prepaid usage billing. This is ideal for AI services where consumption is highly variable. Users purchase a block of tokens (e.g., 100,000 tokens for $10) that sits in their account balance. As they use the service, tokens are deducted from this balance in real-time.
The "Why": This model provides ultimate flexibility. Users aren't locked into a monthly tier; they can buy exactly what they need, when they need it. For the provider, it reduces churn risk because the user has already paid for the service. It also creates a direct, immediate feedback loop between usage and cost.
The "Under the Hood": This requires a stateful system that tracks a token_balance for each user. Every AI interaction (e.g., a chat completion) must first check if the user has sufficient balance. If yes, the system deducts the cost of the interaction (e.g., 500 tokens) and proceeds. If not, it must gracefully deny the request and prompt the user to top up. This is a real-time transactional system, often implemented with a database that supports atomic operations (like PostgreSQL with row-level locking) to prevent race conditions where two simultaneous requests try to spend the same token.
Smart Dunning: The AI-Powered Revenue Recovery System
Failed payments are the silent killer of SaaS revenue. A "hard decline" (e.g., insufficient funds, expired card) can lead to involuntary churn if not handled intelligently. Smart Dunning is Stripe's sophisticated, AI-driven system for managing failed payments and maximizing recovery.
The "Why": A naive dunning system simply retries a failed payment once and then cancels the subscription. This is like a bouncer who, upon seeing an invalid ID, immediately kicks the person out of the club forever. Smart Dunning is like a skilled host who gently reminds the guest, checks for alternative payment methods, and guides them to a solution.
The "Under the Hood": Smart Dunning is not a simple retry logic. It's a multi-stage, intelligent process:
- Initial Failure: When a payment fails, Stripe's system analyzes the decline code (e.g.,
insufficient_funds,expired_card,generic_decline). - Intelligent Retries: Instead of a fixed schedule, Stripe's machine learning models analyze billions of payment attempts to predict the optimal time to retry. For example, a "insufficient funds" decline might be retried in 3 days (aligning with a typical pay cycle), while a "generic decline" might be retried sooner.
- Customer Communication: The system automatically sends a sequence of emails (or triggers in-app notifications) to the customer. These emails are not generic; they are tailored to the specific decline reason and include a direct, one-click link to a Stripe-hosted payment recovery page where the customer can update their card details without logging in.
- Payment Method Update: This is the most critical step. The recovery page is a frictionless experience that captures new payment information and automatically applies it to the outstanding invoice, often resulting in immediate successful payment.
This entire process is automated and requires no engineering effort to maintain, as Stripe continuously improves the retry timing and success rates based on its global data.
Integrating AI Agents for Account Management
The final piece of the theoretical foundation is using AI to streamline the user experience around billing and account management. This is where the useChat hook and Server-Sent Events (SSE) become pivotal.
The "Why": Billing-related support tickets (e.g., "How do I upgrade?", "Why was my payment declined?", "Where is my invoice?") are repetitive and high-volume. Using a human agent for these is inefficient. An AI agent, powered by a Large Language Model (LLM) and connected to the Stripe API, can handle these queries instantly, 24/7, reducing support costs and improving customer satisfaction.
The "Under the Hood": This is a sophisticated application of the useChat hook and asynchronous tool handling.
- The Interface: The user interacts with a chat widget in the application. The
useChathook manages the message state, user input, and streams the AI's response back to the UI. - The AI Brain: The AI agent is not just a text generator; it's a function-calling agent. When a user asks, "What's my current token balance?", the LLM doesn't try to answer from its training data. Instead, it recognizes the need for a tool call.
- Asynchronous Tool Handling: The LLM generates a structured request to call an external tool, for example,
get_user_balance. In a Node.js environment, this tool call is an asynchronous operation (a Promise) that queries the application's database or the Stripe API. The agentawaitsthis Promise, receives the data (e.g.,{ balance: 15000 }), and then uses this real-time data to formulate a natural language response: "You currently have 15,000 tokens remaining." - Streaming with SSE: To provide a fluid, conversational experience, the AI's response is not delivered in one chunk. It's streamed token-by-token from the server to the client. Server-Sent Events (SSE) is the protocol that enables this. Unlike WebSockets, which are bidirectional, SSE is a one-way street from server to client over a standard HTTP connection. This is perfect for streaming AI responses, as it's simpler, more efficient, and works seamlessly with standard web infrastructure. The server pushes each token as it's generated, and the
useChathook updates the UI in real-time, making the AI feel responsive and alive.
This integration transforms billing from a static, administrative task into a dynamic, conversational experience, directly embedded within the user's workflow.
Basic Code Example
In a SaaS application, especially one powered by AI, users often operate on a subscription model with a monthly allowance of credits (or tokens). When they exhaust this allowance, they need a seamless way to purchase more. This "Token Top-up" is a perfect use case for a Server Action.
A Server Action allows us to handle the payment logic (Stripe) and database updates securely on the server, triggered directly from a client-side form without manually building a complex API endpoint. We will use the stripe library to create a Payment Link or a Checkout Session that handles the specific top-up amount.
The flow is simple:
- User selects a top-up amount (e.g., $10 for 1,000 tokens).
- Client invokes a Server Action.
- Server Action creates a Stripe Checkout Session.
- Server Action returns the Stripe Session ID to the client.
- Client redirects the user to the Stripe hosted checkout page.
- Upon success, Stripe redirects back to our app, where we update the user's token balance (handled via a webhook or Stripe Billing Portal).
Code Example: Stripe Checkout via Server Action
This example uses Next.js App Router conventions. It assumes you have installed stripe (npm install stripe) and set up your environment variables (STRIPE_SECRET_KEY).
// app/actions/stripeActions.ts
'use server';
import Stripe from 'stripe';
import { redirect } from 'next/navigation';
// Initialize Stripe with the secret key from environment variables
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string);
/**
* @description Server Action to initiate a token top-up via Stripe Checkout.
* @param {number} amount - The amount in USD to top up (e.g., 10 for $10.00).
* @param {string} userId - The ID of the user performing the top-up.
* @returns {Promise<string>} The URL to redirect the user to Stripe Checkout.
*/
export async function createTokenTopupCheckout(amount: number, userId: string) {
try {
// 1. Validate input (Security Best Practice)
if (amount <= 0) {
throw new Error('Top-up amount must be greater than zero.');
}
// 2. Define the price ID for the top-up product in Stripe.
// In a real app, you might dynamically create a Price object,
// but for this example, we assume a standard 'Top-up' price exists in Stripe Dashboard.
// Note: Stripe Checkout requires a Price ID or a line item with a unit amount.
const PRICE_ID = 'price_1234567890'; // Replace with actual Stripe Price ID
// 3. Create a Checkout Session
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [
{
price: PRICE_ID,
quantity: 1,
// If using dynamic pricing without a saved Price ID:
// price_data: {
// currency: 'usd',
// product_data: { name: 'Token Top-up' },
// unit_amount: amount * 100, // Convert to cents
// },
},
],
mode: 'payment',
// 4. Metadata to track the transaction in your database
metadata: {
userId: userId,
type: 'token_topup',
amount: amount.toString(),
},
// 5. Success URL: Stripe redirects here after payment.
// We append the session_id to verify it later via a webhook or API route.
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard/billing?cancelled=true`,
});
// 6. Error handling for session creation
if (!session.url) {
throw new Error('Failed to create Stripe Checkout session.');
}
// 7. Return the URL to the client for redirection
return session.url;
} catch (error) {
console.error('Stripe Error:', error);
// Re-throwing allows the client to catch and display the error
throw new Error('An error occurred while processing your payment.');
}
}
Client-Side Implementation (TSX)
This is how you would consume the Server Action in a React component (e.g., app/dashboard/billing/page.tsx).
// app/dashboard/billing/page.tsx
'use client';
import { useState } from 'react';
import { createTokenTopupCheckout } from '@/app/actions/stripeActions';
export default function BillingPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleTopUp = async (amount: number) => {
setIsLoading(true);
setError(null);
try {
// 1. Call the Server Action directly
// We pass the amount and a mock userId (in a real app, this comes from auth context)
const redirectUrl = await createTokenTopupCheckout(amount, 'user_123');
// 2. Redirect the user to Stripe
window.location.href = redirectUrl;
} catch (err) {
// 3. Handle errors returned from the server
if (err instanceof Error) {
setError(err.message);
} else {
setError('An unknown error occurred.');
}
} finally {
setIsLoading(false);
}
};
return (
<div className="p-6 max-w-md mx-auto">
<h1 className="text-2xl font-bold mb-4">Token Top-up</h1>
{error && <div className="bg-red-100 text-red-700 p-3 rounded mb-4">{error}</div>}
<div className="flex gap-4">
<button
onClick={() => handleTopUp(10)}
disabled={isLoading}
className="px-4 py-2 bg-blue-600 text-white rounded disabled:opacity-50"
>
{isLoading ? 'Processing...' : 'Top up $10'}
</button>
<button
onClick={() => handleTopUp(25)}
disabled={isLoading}
className="px-4 py-2 bg-green-600 text-white rounded disabled:opacity-50"
>
{isLoading ? 'Processing...' : 'Top up $25'}
</button>
</div>
</div>
);
}
Detailed Line-by-Line Explanation
1. Server Action Setup (stripeActions.ts)
'use server';: This directive marks all exported functions in this file as Server Actions. This tells Next.js to execute them on the server, not the client. It ensures sensitive logic (like API keys) and database operations remain secure.import Stripe from 'stripe';: Imports the official Stripe Node.js library.const stripe = new Stripe(...): Initializes the Stripe client. We access the secret key viaprocess.env. Never expose your secret key on the client side.export async function createTokenTopupCheckout(...): Defines the asynchronous function. It acceptsamount(USD) anduserId(for tracking).- Input Validation:
if (amount <= 0)is a basic sanity check. Server Actions should always validate inputs to prevent malicious requests or database errors. - Stripe Checkout Configuration:
line_items: Defines what is being sold. We reference aPRICE_ID. This Price must be created in the Stripe Dashboard beforehand (or dynamically via the API).mode: 'payment': Indicates a one-time payment, not a subscription.metadata: Crucial for reconciliation. When the payment succeeds, Stripe sends this data back to us (via webhooks). We use it to identify which user needs their token balance increased.success_url/cancel_url: Defines where Stripe redirects the user after the flow. We append thesession_idto the success URL so we can look up the specific transaction details later if needed.
- Return Value: The function returns
session.url. This is the HTTPS link to the Stripe-hosted checkout page. The client uses this to navigate the user.
2. Client-Side Implementation (BillingPage.tsx)
'use client';: Marks this component as a Client Component (required for interactivity likeonClickanduseState).import { createTokenTopupCheckout } ...: We import the Server Action function directly. Next.js handles the communication layer (RPC) automatically.handleTopUp: This function is triggered by the button click.- It sets loading states for UI feedback.
await createTokenTopupCheckout(...): This is the magic of Server Actions. It looks like a local function call, but Next.js sends a request to the server, executes the function securely, and returns the result.window.location.href = redirectUrl: Once the server returns the Stripe URL, we perform a full-page redirect to the Stripe checkout. This is the standard flow for Stripe Checkout.
Common Pitfalls
-
Vercel/Next.js Timeout Limits:
- Issue: Server Actions running on Vercel's Hobby (free) plan have a strict timeout (usually 10 seconds). If you perform complex database queries or heavy processing inside the Server Action before creating the Stripe session, it might time out.
- Solution: Keep the Server Action lean. Its only job should be to create the Stripe session and return the URL. Move heavy logic (like calculating usage) to background jobs or Stripe Webhooks.
-
Missing Environment Variables:
- Issue:
STRIPE_SECRET_KEYis undefined. - Solution: Ensure your
.env.localfile containsSTRIPE_SECRET_KEY=sk_test_.... For Vercel deployments, add the variable in the project settings.
- Issue:
-
Client-Side Import Errors:
- Issue: Trying to import a Server Action into a Client Component without using the correct Next.js syntax, or accidentally importing server-only code (like
stripe) into the client bundle. - Solution: Ensure Server Actions are in files marked with
'use server'or in a separate directory. The Client Component should only import the exported function, not the underlying Stripe library.
- Issue: Trying to import a Server Action into a Client Component without using the correct Next.js syntax, or accidentally importing server-only code (like
-
Stale State on Return:
- Issue: After the user pays on Stripe and is redirected back to
success_url, the user's token balance on your app UI won't update automatically because the page is loaded fresh. - Solution: You must implement a Stripe Webhook (
/api/webhooks). When thecheckout.session.completedevent is received, update the user's database record (increase tokens). Thesuccess_urlpage should ideally fetch the latest user data on mount or use a webhook-triggered cache invalidation (e.g., via SWR or React Query).
- Issue: After the user pays on Stripe and is redirected back to
Logic Breakdown
- Initialization: The server initializes the Stripe SDK with the secret key. This happens once when the serverless function is "cold started."
- Validation: The function checks if the requested top-up amount is valid (positive number) to prevent bad data.
- Session Creation: The server sends a request to Stripe API to create a Checkout Session. Stripe returns a session object containing a unique URL.
- Metadata Attachment: We attach the
userIdandtypeto the session metadata. This is the bridge between the payment event and your application's database. - Client Redirection: The server action returns the URL. The client receives it and immediately navigates the browser to that URL.
- Payment & Webhook (Post-Flow):
- User pays on Stripe.
- Stripe redirects user to
success_url. - Simultaneously (asynchronously), Stripe sends a webhook event to your backend (
/api/webhooks). - Your webhook handler verifies the signature, checks for
checkout.session.completed, reads the metadata, and updates the user's token balance in your database.
Visualizing the Flow
The chapter continues with advanced code, exercises and solutions with analysis, you can find them on the ebook on Leanpub.com or Amazon
Loading knowledge check...
Code License: All code examples are released under the MIT License. Github repo.
Content Copyright: Copyright © 2026 Edgar Milvus | Privacy & Cookie Policy. All rights reserved.
All textual explanations, original diagrams, and illustrations are the intellectual property of the author. To support the maintenance of this site via AdSense, please read this content exclusively online. Copying, redistribution, or reproduction is strictly prohibited.