Payment System
Stripe payment integration and subscription management
Payment System
Chameleon integrates with Stripe for payment processing, supporting one-time payments and recurring subscriptions.
Overview
The payment system provides:
- ✅ One-time product purchases
- ✅ Monthly and yearly subscriptions
- ✅ Subscription upgrades and downgrades
- ✅ Stripe Customer Portal for self-service
- ✅ Webhook handling for payment notifications
- ✅ Automatic credit allocation on payment
Stripe Setup
Step 1: Create Stripe Account
- Sign up at stripe.com
- Verify your email
- Complete business information
Step 2: Get API Keys
- Go to Developers > API keys
- Copy your keys:
- Publishable key:
pk_test_...(for client-side) - Secret key:
sk_test_...(for server-side)
- Publishable key:
Use test mode keys during development. Switch to live mode keys for production.
Step 3: Configure Environment Variables
Add to .env.development:
STRIPE_PRIVATE_KEY="sk_test_..."
STRIPE_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_WEB_URL="http://localhost:3000"
For Vercel deployment, add these in Environment Variables.
Step 4: Create Products and Prices
-
Go to Products in Stripe Dashboard
-
Click Add product
-
For each product category (Starter, Professional, Enterprise):
- Create one-time price
- Create monthly recurring price
- Create yearly recurring price
-
Copy the Price ID (starts with
price_...)
You need the Price ID, not the Product ID. Click into the product to see its prices.
Step 5: Configure Price IDs
Configure Stripe Price IDs directly in the pricing configuration files for each language.
Edit src/i18n/pages/pricing/en.json (or your target language file):
{
"pricing": {
"items": [
{
"title": "Starter",
"description": "Get started with your first SaaS startup.",
"interval": "one-time",
"amount": 19900,
"currency": "USD",
"price": "$199",
"product_id": "starter",
"product_name": "Chameleon Boilerplate Starter",
"stripe_price_id": "price_1Sxxxxxxxxxxxxxx",
"credits": 100,
"valid_months": 1
},
{
"title": "Starter Monthly",
"interval": "month",
"amount": 1900,
"currency": "USD",
"price": "$19",
"product_id": "starter-monthly",
"product_name": "Chameleon Boilerplate Starter Monthly",
"stripe_price_id": "price_1Sxxxxxxxxxxxxxx",
"credits": 100,
"valid_months": 1
},
{
"title": "Starter Yearly",
"interval": "year",
"amount": 19900,
"currency": "USD",
"price": "$199",
"product_id": "starter-yearly",
"product_name": "Chameleon Boilerplate Starter Yearly",
"stripe_price_id": "price_1Sxxxxxxxxxxxxxx",
"credits": 1200,
"valid_months": 12
}
]
}
}
Important:
amountis in cents (1900 = $19.00)stripe_price_idis your Stripe Price ID (starts withprice_...)product_idmust be unique for each pricing item- Configure pricing for each language you support
- Update both
en.jsonand other language files (e.g.,zh.json,de.json)
Step 6: Set Up Webhooks
Stripe webhooks notify your app about payment events.
-
Go to Developers > Webhooks
-
Click Add endpoint
-
Set endpoint URL:
https://your-domain.com/api/pay/notify/stripe -
Select events to listen for:
checkout.session.completedinvoice.payment_succeededcustomer.subscription.updatedcustomer.subscription.deleted
-
Copy the Signing secret (starts with
whsec_...) -
Add to environment variables:
STRIPE_WEBHOOK_SECRET="whsec_..."
Step 7: Configure Customer Portal
The Customer Portal allows users to manage their subscriptions.
-
Go to Settings > Billing > Customer portal
-
Configure settings:
- ✅ Allow customers to update payment methods
- ✅ Allow customers to update billing information
- ✅ Allow customers to view invoice history
- ✅ Allow customers to switch plans
- ✅ Allow customers to cancel subscriptions
-
Set cancellation behavior:
- Recommended: Cancel at end of billing period
- Or: Cancel immediately with prorated refund
-
Save settings
Payment Flow
One-Time Purchase
User clicks "Buy" → Creates checkout session → Redirects to Stripe →
Payment → Webhook notification → Credits added → User redirected back
Code Flow:
-
User clicks buy button on
/pricingpage -
Frontend calls
/api/checkout:const response = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ product_id: "starter", interval: "onetime", }), }); -
Backend creates Stripe checkout session
-
User completes payment on Stripe
-
Stripe sends webhook to
/api/pay/notify/stripe -
Order status updated to
paid -
Credits added to user account
-
User redirected to
/my-orders
Subscription Purchase
Same flow as one-time, but:
intervalismonthoryear- Creates Stripe subscription
- Subscription renews automatically
- Credits added on each billing cycle
Subscription Upgrade
-
User has active subscription (e.g., Starter Monthly $19)
-
User clicks "Upgrade" on Professional plan ($29)
-
System calculates proration:
- Remaining time on current plan
- Cost difference: $29 - $19 = $10
- Prorated charge for remaining period
-
Stripe Customer Portal handles the upgrade
-
Immediate effect, prorated charge applied
Subscription Downgrade
- User clicks "Switch Plan" on lower tier
- Redirected to Stripe Customer Portal
- User selects downgrade
- Takes effect on next billing cycle
- No refund for current period
Upgrades are immediate with prorated charges. Downgrades take effect at the end of the current billing period.
Webhook Handling
Events Handled
checkout.session.completed
- Triggered when user completes checkout
- Updates order status to
paid - Adds credits to user account
- Creates subscription record if applicable
invoice.payment_succeeded
- Triggered for subscription renewals
- Updates subscription status
- Adds credits for the new billing period
customer.subscription.updated
- Triggered when subscription changes (upgrade/downgrade)
- Updates subscription details
customer.subscription.deleted
- Triggered when subscription is cancelled
- Marks subscription as inactive
Webhook Security
The webhook endpoint verifies the signature:
const sig = headers.get("stripe-signature");
const event = stripe.webhooks.constructEvent(
body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
This prevents unauthorized webhook calls.
Customer Portal
Users can manage their subscriptions at /my-orders by clicking "Manage Subscription".
Available Actions:
- Update payment method
- View invoices and billing history
- Upgrade to higher plan (immediate)
- Downgrade to lower plan (next billing cycle)
- Cancel subscription
Implementation:
// In src/services/order.ts
export async function getStripeBilling(sub_id: string) {
const subscription = await stripe.subscriptions.retrieve(sub_id);
const billing = await stripe.billingPortal.sessions.create({
customer: subscription.customer as string,
return_url: `${baseUrl}/my-orders`,
});
return billing;
}
Testing
Test Mode
Use Stripe test mode during development:
-
Use test API keys (
sk_test_...,pk_test_...) -
Use test card numbers:
- Success:
4242 4242 4242 4242 - Requires authentication:
4000 0025 0000 3155 - Declined:
4000 0000 0000 9995
- Success:
-
Use any future expiry date and any CVC
Test Webhook Locally
Use Stripe CLI:
# Install Stripe CLI
brew install stripe/stripe-cli/stripe
# Login
stripe login
# Forward webhooks to localhost
stripe listen --forward-to localhost:3000/api/pay/notify/stripe
# In another terminal, trigger test event
stripe trigger checkout.session.completed
Credit Allocation
Credit Amounts
Configured in pricing files (e.g., src/i18n/pages/pricing/en.json):
{
"pricing": {
"items": [
{
"title": "Starter Monthly",
"amount": 1900,
"credits": 1000, // 1000 credits per month
"stripe_price_id": "price_1Sxxxxxxxxxxxxxx"
}
]
}
}
Credit Expiration
- One-time purchases: Credits expire in 1 year
- Subscriptions: Credits expire at next billing cycle
- Unused credits: Not carried over (subscriptions)
Allocation Logic
// On payment success
await increaseCredits({
user_uuid,
trans_type: CreditsTransType.OrderPay,
credits: pricingItem.credits,
expired_at: getOneYearLaterTimestr(), // or next billing date
});
Subscription Management
Active Subscription Check
import { getUserActiveSubscription } from "@/services/order";
const subscription = await getUserActiveSubscription(user_uuid);
if (subscription) {
// User has active subscription
console.log(subscription.product_id);
console.log(subscription.interval);
}
Pricing Page Logic
The pricing page shows different buttons based on subscription status:
- Current Plan: Badge, no button
- Higher Tier (Same Interval): "Upgrade" button with price difference
- Lower Tier (Same Interval): "Switch Plan" (effective next cycle)
- Different Interval: "Get Chameleon" (new subscription)
Common Issues
Checkout fails with "Invalid URL"
Problem: NEXT_PUBLIC_WEB_URL not set
Solution:
# Add to .env.development
NEXT_PUBLIC_WEB_URL="http://localhost:3000"
# Add to Vercel
NEXT_PUBLIC_WEB_URL="https://your-domain.com"
Webhook signature verification failed
Problem: Wrong STRIPE_WEBHOOK_SECRET
Solution:
- Get the correct secret from Stripe Dashboard > Webhooks
- Update environment variable
- Redeploy
Customer Portal not working
Problem: Stripe Portal not configured or Price IDs missing
Solution:
- Configure Customer Portal in Stripe Dashboard
- Ensure all Price IDs in pricing files are correct
- Use Price ID (price_...), not Product ID (prod_...)
- Check that
stripe_price_idis set in your pricing configuration files
Credits not added after payment
Problem: Webhook not received or failed
Solution:
- Check Vercel logs for webhook errors
- Verify webhook secret is correct
- Check Stripe Dashboard > Webhooks > Attempts for errors
- Ensure pricing files have credit amounts configured
Going Live
Production Checklist
- Switch to live API keys in Vercel
- Update webhook endpoint to production URL
- Configure Customer Portal in live mode
- Test a real payment (then refund)
- Set up Stripe Radar for fraud prevention
- Configure tax settings if applicable
- Set up email receipts
- Monitor Stripe Dashboard regularly
Security
- Never expose
STRIPE_PRIVATE_KEYin client-side code - Always verify webhook signatures
- Use HTTPS only in production
- Regularly review Stripe logs for suspicious activity
Next Steps
- Credit System - How credits work
- API Reference - Payment API docs
- Troubleshooting - Common issues
- Internationalization - Multi-language support