Skip to content

YeboCars Billing System

Deep dive into Stripe integration and dealer subscription plans.

Overview

YeboCars uses Stripe for dealer subscription billing with three tiers:

PlanPrice (USD)Listing LimitFeatures
Basic$10/month10 carsBasic analytics, standard support
Dealer$25/month50 carsLead management, featured slots, priority support
Pro$60/monthUnlimitedPriority 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

One chat. Everything done.