YeboCars Billing System
Deep dive into Stripe integration and dealer subscription plans.
Overview
YeboCars uses Stripe for dealer subscription billing with three tiers:
| Plan | Price (USD) | Listing Limit | Features |
|---|---|---|---|
| Basic | $10/month | 10 cars | Basic analytics, standard support |
| Dealer | $25/month | 50 cars | Lead management, featured slots, priority support |
| Pro | $60/month | Unlimited | Priority placement, advanced analytics, account manager |
Plan Configuration
typescript
const PLANS = [
{
id: 'BASIC',
name: 'Basic',
priceUsdCents: 1000, // $10
description: '10 car listings, basic analytics',
features: [
'10 car listings',
'Basic analytics',
'Standard support'
],
listingLimit: 10,
durationDays: 30
},
{
id: 'DEALER',
name: 'Dealer',
priceUsdCents: 2500, // $25
description: '50 car listings, lead management, featured slots',
features: [
'50 car listings',
'Lead management',
'Featured listing slots',
'Priority support'
],
listingLimit: 50,
durationDays: 30
},
{
id: 'PRO',
name: 'Pro',
priceUsdCents: 6000, // $60
description: 'Unlimited listings, priority placement, advanced analytics',
features: [
'Unlimited car listings',
'Priority placement',
'Advanced analytics',
'Dedicated account manager'
],
listingLimit: -1, // Unlimited
durationDays: 30
}
];Multi-Currency Support
Prices are displayed in local currencies while charged in USD (or supported Stripe currency).
Currency Service
typescript
// Get currency configuration for a country
function getCurrencyForCountry(countryCode?: string): Currency {
const currencies = {
ZA: { code: 'ZAR', symbol: 'R', rate: 18.5, stripeSupported: true },
SZ: { code: 'SZL', symbol: 'E', rate: 18.5, stripeSupported: false },
NG: { code: 'NGN', symbol: '₦', rate: 1500, stripeSupported: true },
KE: { code: 'KES', symbol: 'KSh', rate: 150, stripeSupported: true },
GH: { code: 'GHS', symbol: 'GH₵', rate: 15, stripeSupported: true },
// ... more countries
};
return currencies[countryCode] || { code: 'USD', symbol: '$', rate: 1 };
}
// Convert USD cents to local currency
function convertFromUSD(usdCents: number, currency: Currency): number {
return Math.round((usdCents / 100) * currency.rate * 100);
}
// Format amount with currency symbol
function formatLocalAmount(amount: number, currency: Currency): string {
const dollars = amount / 100;
return `${currency.symbol}${dollars.toLocaleString()}`;
}Get Plans with Localized Pricing
typescript
static getPlans(countryCode?: string) {
const currency = getCurrencyForCountry(countryCode);
// If currency not supported by Stripe, charge in USD
const chargeCurrency = currency.stripeSupported ? currency : DEFAULT_CURRENCY;
return PLANS.map(plan => {
const displayAmount = convertFromUSD(plan.priceUsdCents, currency);
const chargeAmount = convertFromUSD(plan.priceUsdCents, chargeCurrency);
return {
...plan,
// Stripe payment currency
stripeCurrency: chargeCurrency.code.toLowerCase(),
stripeAmount: chargeAmount,
// Display currency (local)
displayCurrency: currency.code,
displaySymbol: currency.symbol,
displayAmount,
displayFormatted: formatLocalAmount(displayAmount, currency),
// Original USD
usdPrice: plan.priceUsdCents,
usdFormatted: `$${(plan.priceUsdCents / 100).toFixed(0)}`,
// Show USD note if different currency
showUsdNote: currency.code !== 'USD'
};
});
}Stripe Integration
Configuration
typescript
class BillingService {
// Dynamic Stripe client based on mode
private static getStripeClient(): Stripe {
const mode = process.env.STRIPE_MODE || 'test';
const key = mode === 'live'
? process.env.STRIPE_LIVE_SECRET_KEY
: process.env.STRIPE_TEST_SECRET_KEY;
if (!key) throw new Error('No Stripe key configured');
return new Stripe(key, { apiVersion: '2024-11-20.acacia' });
}
}Create Checkout Session
typescript
static async createCheckout(opts: {
dealerId: string;
email: string;
countryCode?: string;
planId: string;
successUrl: string;
cancelUrl: string;
}) {
const plan = PLANS.find(p => p.id === opts.planId);
if (!plan) throw new Error('Invalid plan');
const currency = getCurrencyForCountry(opts.countryCode);
const chargeCurrency = currency.stripeSupported ? currency : DEFAULT_CURRENCY;
const amount = convertFromUSD(plan.priceUsdCents, chargeCurrency);
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ['card'],
customer_email: opts.email,
line_items: [{
price_data: {
currency: chargeCurrency.code.toLowerCase(),
product_data: {
name: `YeboCars ${plan.name} Plan`,
description: plan.description
},
unit_amount: amount
},
quantity: 1
}],
mode: 'payment',
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
client_reference_id: opts.dealerId,
metadata: {
dealerId: opts.dealerId,
planId: opts.planId
}
});
return {
sessionId: session.id,
url: session.url
};
}Webhook Handler
typescript
static async handleWebhookEvent(payload: Buffer, signature: string) {
// Verify webhook signature
const event = this.stripe.webhooks.constructEvent(
payload,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object as Stripe.Checkout.Session;
const dealerId = session.client_reference_id;
const planId = session.metadata?.planId;
if (dealerId && planId) {
const plan = PLANS.find(p => p.id === planId);
if (plan) {
// Calculate expiry date
const expiry = new Date();
expiry.setDate(expiry.getDate() + plan.durationDays);
// Update dealer plan
await prisma.dealer.update({
where: { id: dealerId },
data: {
plan: planId,
planExpiry: expiry,
stripeCustomerId: session.customer as string
}
});
console.log(`✅ Dealer ${dealerId} upgraded to ${planId}`);
}
}
break;
}
case 'payment_intent.payment_failed': {
// Handle failed payment
const paymentIntent = event.data.object as Stripe.PaymentIntent;
console.error(`❌ Payment failed: ${paymentIntent.id}`);
break;
}
}
return { received: true, type: event.type };
}API Endpoints
Get Available Plans
http
GET /api/billing/plans?countryCode=ZA
Response:
{
"plans": [
{
"id": "BASIC",
"name": "Basic",
"priceUsdCents": 1000,
"stripeCurrency": "zar",
"stripeAmount": 18500,
"displayCurrency": "ZAR",
"displaySymbol": "R",
"displayAmount": 18500,
"displayFormatted": "R185.00",
"usdPrice": 1000,
"usdFormatted": "$10",
"showUsdNote": true,
"features": ["10 car listings", "Basic analytics", "Standard support"],
"listingLimit": 10
},
// ... more plans
]
}Create Checkout Session
http
POST /api/billing/checkout
Authorization: Bearer <token>
Content-Type: application/json
{
"planId": "DEALER",
"successUrl": "https://app.yebocars.com/billing/success",
"cancelUrl": "https://app.yebocars.com/billing/cancel"
}
Response:
{
"sessionId": "cs_test_...",
"url": "https://checkout.stripe.com/c/pay/cs_test_..."
}Webhook Endpoint
http
POST /api/billing/webhook
Stripe-Signature: t=...,v1=...,v0=...
Content-Type: application/json
(raw Stripe event payload)Billing Flow
┌─────────────────────────────────────────────────────────────────┐
│ Billing Flow │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. DEALER SELECTS PLAN │
│ ┌──────────────┐ │
│ │ Frontend │ GET /api/billing/plans?countryCode=ZA │
│ │ Shows │◄──────────────────────────────────────────── │
│ │ Plans │ Returns plans with local pricing │
│ └──────────────┘ │
│ │ │
│ │ User clicks "Subscribe" │
│ ▼ │
│ 2. CREATE CHECKOUT │
│ ┌──────────────┐ │
│ │ Backend │ POST /api/billing/checkout │
│ │ Creates │ { planId: "DEALER" } │
│ │ Session │────────────────────────────────────────────► │
│ └──────────────┘ Returns { url: "https://checkout.stripe..." }│
│ │ │
│ │ Redirect to Stripe │
│ ▼ │
│ 3. STRIPE CHECKOUT │
│ ┌──────────────┐ │
│ │ Stripe │ User enters payment details │
│ │ Hosted │ Card validation, 3D Secure │
│ │ Page │ │
│ └──────────────┘ │
│ │ │
│ │ Payment successful │
│ ▼ │
│ 4. WEBHOOK NOTIFICATION │
│ ┌──────────────┐ │
│ │ Stripe │ POST /api/billing/webhook │
│ │ Sends │ checkout.session.completed event │
│ │ Event │────────────────────────────────────────────► │
│ └──────────────┘ │
│ │ │
│ │ Backend processes webhook │
│ ▼ │
│ 5. UPDATE DEALER │
│ ┌──────────────┐ │
│ │ Database │ UPDATE Dealer SET plan = 'DEALER', │
│ │ Update │ planExpiry = NOW() + 30 days │
│ └──────────────┘ │
│ │ │
│ │ Redirect to success page │
│ ▼ │
│ 6. SUCCESS │
│ ┌──────────────┐ │
│ │ Frontend │ Shows confirmation, unlocks features │
│ │ Success │ │
│ │ Page │ │
│ └──────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘Plan Enforcement
Check Listing Limits
typescript
async function checkDealerCanCreateListing(dealerId: string): Promise<boolean> {
const dealer = await prisma.dealer.findUnique({
where: { id: dealerId },
include: { _count: { select: { cars: { where: { status: 'ACTIVE' } } } } }
});
if (!dealer) return false;
const plan = PLANS.find(p => p.id === dealer.plan);
if (!plan) return false;
// Unlimited plan
if (plan.listingLimit === -1) return true;
// Check plan expiry
if (dealer.planExpiry && dealer.planExpiry < new Date()) {
// Plan expired, fall back to free tier
return dealer._count.cars < 3; // Free tier: 3 listings
}
return dealer._count.cars < plan.listingLimit;
}Middleware
typescript
const requireActivePlan = async (req, res, next) => {
const dealer = await prisma.dealer.findFirst({
where: { userId: req.user.id }
});
if (!dealer) {
return res.status(403).json({ error: 'Dealer account required' });
}
// Check plan expiry
if (dealer.planExpiry && dealer.planExpiry < new Date()) {
return res.status(403).json({
error: 'Plan expired',
message: 'Please renew your subscription'
});
}
next();
};Environment Variables
bash
# Stripe Mode (test or live)
STRIPE_MODE=live
# Test keys
STRIPE_TEST_SECRET_KEY=sk_test_...
STRIPE_TEST_PUBLISHABLE_KEY=pk_test_...
# Live keys
STRIPE_LIVE_SECRET_KEY=sk_live_...
STRIPE_LIVE_PUBLISHABLE_KEY=pk_live_...
# Webhook secret
STRIPE_WEBHOOK_SECRET=whsec_...GCP Secret Manager
Stripe keys are stored in GCP Secret Manager:
bash
# Secrets in project yebocars:
yebocars-stripe-secret-key # Live secret key
yebocars-stripe-publishable-key # Live publishable key
yebocars-stripe-webhook-secret # Webhook signing secret