Skip to content

YeboMart Billing — Stripe Integration

Multi-country pricing with PPP adjustment and Stripe Checkout.


Overview

YeboMart uses Stripe for payment processing with:

  • 15 African countries with localized pricing
  • PPP-adjusted prices making software affordable
  • Launch discounts (75-86% off regular prices)
  • One-time payments that upgrade the shop's tier

Pricing Configuration

Country Pricing Matrix

typescript
// src/config/pricing.ts

export const COUNTRY_PRICING: Record<string, CountryPricing> = {
  SZ: {
    countryCode: 'SZ', 
    country: 'Eswatini', 
    flag: '🇸🇿', 
    currency: 'SZL', 
    currencySymbol: 'E', 
    phoneCode: '+268', 
    timezone: 'Africa/Mbabane',
    tiers: { 
      LITE: 499, STARTER: 1499, BUSINESS: 3999, PRO: 7999, ENTERPRISE: 15999 
    },
    discountTiers: { 
      LITE: 99, STARTER: 499, BUSINESS: 2499, PRO: 4999, ENTERPRISE: 9999 
    },
    discountLabel: 'Launch Special', 
    discountPercent: 80,
  },
  ZA: {
    countryCode: 'ZA', 
    country: 'South Africa', 
    flag: '🇿🇦', 
    currency: 'ZAR', 
    currencySymbol: 'R', 
    phoneCode: '+27', 
    timezone: 'Africa/Johannesburg',
    tiers: { 
      LITE: 699, STARTER: 2099, BUSINESS: 5599, PRO: 11199, ENTERPRISE: 22399 
    },
    discountTiers: { 
      LITE: 99, STARTER: 499, BUSINESS: 3499, PRO: 6999, ENTERPRISE: 13999 
    },
    discountLabel: 'Launch Special', 
    discountPercent: 86,
  },
  // ... 13 more countries
  // KE, NG, GH, TZ, UG, RW, ET, CI, SN, ZM, ZW, BW, MZ
};

Tier Structure

TierFeaturesLimits
LITEBasic POS, Stock, Reports, AI (100/mo)100 products, 1 user
STARTER+ Barcode, Alerts, Staff500 products, 3 users
BUSINESS+ WhatsApp, Advanced Analytics2,500 products, 10 users
PRO+ AI Voice, Multi-location10,000 products, 25 users
ENTERPRISE+ API, Dedicated SupportUnlimited

Billing Service

Get Plans

Returns pricing plans localized to the shop's country.

typescript
// src/services/billing.service.ts

static getPlans(countryCode: string) {
  const pricing = getPricingForCountry(countryCode);
  const tiers = ['LITE', 'STARTER', 'BUSINESS', 'PRO', 'ENTERPRISE'];

  return {
    country: pricing.country,
    countryCode: pricing.countryCode,
    currency: pricing.currency,
    currencySymbol: pricing.currencySymbol,
    discountLabel: pricing.discountLabel,
    discountPercent: pricing.discountPercent,
    plans: tiers.map((tier) => ({
      tier,
      name: TIER_NAMES[tier],
      price: pricing.tiers[tier],              // Original price
      discountPrice: pricing.discountTiers[tier],  // Launch special
      activePrice: getActiveTierPrice(countryCode, tier),
    })),
  };
}

Create Checkout Session

Creates a Stripe Checkout session for tier upgrade.

typescript
static async createCheckout(opts: {
  shopId: string;
  shopEmail?: string;
  countryCode: string;
  tier: ShopTier;
  successUrl: string;
  cancelUrl: string;
}) {
  const pricing = getPricingForCountry(opts.countryCode);
  const activePrice = getActiveTierPrice(opts.countryCode, opts.tier);
  const currency = getCurrencyForCountry(opts.countryCode);
  
  // Use Stripe-supported currency or fallback to USD
  const chargeCurrency = currency.stripeSupported 
    ? currency 
    : { code: 'USD', rate: 1, decimals: 100 };

  // Convert to smallest unit (cents/kobo/etc.)
  let amount: number;
  if (currency.stripeSupported) {
    amount = Math.round(activePrice * (currency.decimals === 1 ? 1 : 100));
  } else {
    // Convert to USD cents
    const usdAmount = activePrice / currency.rate;
    amount = Math.round(usdAmount * 100);
  }

  const session = await getStripe().checkout.sessions.create({
    payment_method_types: ['card'],
    customer_email: opts.shopEmail || undefined,
    line_items: [
      {
        price_data: {
          currency: chargeCurrency.code.toLowerCase(),
          product_data: {
            name: `YeboMart ${TIER_NAMES[opts.tier]} Plan`,
            description: `Monthly subscription — ${pricing.currencySymbol}${activePrice.toLocaleString()}`,
          },
          unit_amount: amount,
        },
        quantity: 1,
      },
    ],
    mode: 'payment',
    success_url: opts.successUrl,
    cancel_url: opts.cancelUrl,
    client_reference_id: opts.shopId,
    metadata: {
      shopId: opts.shopId,
      tier: opts.tier,
      countryCode: opts.countryCode,
    },
  });

  return { sessionId: session.id, url: session.url! };
}

Handle Webhook

Process Stripe webhooks to upgrade shop tier.

typescript
static async handleWebhookEvent(payload: Buffer, signature: string) {
  const event = getStripe().webhooks.constructEvent(
    payload, 
    signature, 
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === 'checkout.session.completed') {
    const session = event.data.object as Stripe.Checkout.Session;
    const shopId = session.client_reference_id;
    const tier = session.metadata?.tier as ShopTier;

    if (shopId && tier) {
      const now = new Date();
      const expiry = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); // +30 days

      await prisma.shop.update({
        where: { id: shopId },
        data: {
          tier,
          licenseExpiry: expiry,
        },
      });

      console.log(`[Billing] Shop ${shopId} upgraded to ${tier}, expires ${expiry}`);
    }
  }

  return { received: true, type: event.type };
}

Stripe Configuration

Environment Variables

bash
# Stripe Mode
STRIPE_MODE="live"  # or "test"

# Keys
STRIPE_TEST_SECRET_KEY="sk_test_..."
STRIPE_LIVE_SECRET_KEY="sk_live_..."
STRIPE_SECRET_KEY="sk_..."  # Fallback

# Webhook Secret
STRIPE_WEBHOOK_SECRET="whsec_..."

Dynamic Client

typescript
// Picks live or test key based on STRIPE_MODE
function getStripe(): Stripe {
  const mode = process.env.STRIPE_MODE || 'test';
  const key = mode === 'live'
    ? process.env.STRIPE_LIVE_SECRET_KEY
    : (process.env.STRIPE_TEST_SECRET_KEY || process.env.STRIPE_SECRET_KEY);
  
  if (!key) throw new Error('No Stripe key configured for mode: ' + mode);
  
  return new Stripe(key, { apiVersion: '2024-11-20.acacia' as any });
}

Currency Handling

Stripe-Supported Currencies

typescript
// African currencies supported by Stripe
const STRIPE_SUPPORTED = ['ZAR', 'KES', 'NGN', 'GHS', 'TZS', 'UGX', 'RWF', 'ETB', 'BWP'];

// For unsupported currencies (SZL, MZN, XOF), convert to USD

Currency Conversion

typescript
// src/utils/currencies.ts

export function getCurrencyForCountry(countryCode: string) {
  const currencies = {
    SZ: { code: 'SZL', symbol: 'E', rate: 18.5, decimals: 100, stripeSupported: false },
    ZA: { code: 'ZAR', symbol: 'R', rate: 18.5, decimals: 100, stripeSupported: true },
    KE: { code: 'KES', symbol: 'KSh', rate: 130, decimals: 100, stripeSupported: true },
    NG: { code: 'NGN', symbol: '₦', rate: 1500, decimals: 100, stripeSupported: true },
    // ... more countries
    ZW: { code: 'USD', symbol: '$', rate: 1, decimals: 100, stripeSupported: true },
  };
  
  return currencies[countryCode] || currencies['SZ'];
}

API Endpoints

GET /billing/plans

Returns pricing for a country.

typescript
// Request
GET /api/v1/billing/plans?country=KE

// Response
{
  "success": true,
  "data": {
    "country": "Kenya",
    "countryCode": "KE",
    "currency": "KES",
    "currencySymbol": "KSh",
    "discountLabel": "Launch Special",
    "discountPercent": 75,
    "plans": [
      {
        "tier": "LITE",
        "name": "Lite",
        "price": 3999,
        "discountPrice": 999,
        "activePrice": 999
      },
      {
        "tier": "STARTER",
        "name": "Starter",
        "price": 11999,
        "discountPrice": 4999,
        "activePrice": 4999
      },
      // ... more tiers
    ],
    "mode": "live"
  }
}

POST /billing/checkout

Create a Stripe Checkout session.

typescript
// Request
POST /api/v1/billing/checkout
Authorization: Bearer eyJ...
{
  "tier": "BUSINESS",
  "successUrl": "https://app.yebomart.com/billing/success",
  "cancelUrl": "https://app.yebomart.com/billing"
}

// Response
{
  "success": true,
  "data": {
    "sessionId": "cs_test_a1b2c3...",
    "url": "https://checkout.stripe.com/c/pay/cs_test_a1b2c3..."
  }
}

POST /billing/webhook

Stripe webhook handler (raw body, no auth).

typescript
// Stripe sends POST with raw JSON body
// Headers: stripe-signature: t=1234567890,v1=abc123...

// Response
{
  "received": true,
  "type": "checkout.session.completed"
}

Frontend Integration

Pricing Page

tsx
// app/src/pages/Billing.tsx

function Billing() {
  const { shop } = useAuthStore();
  const [plans, setPlans] = useState<Plan[]>([]);
  
  useEffect(() => {
    api.getPlans(shop.countryCode).then(setPlans);
  }, []);
  
  const handleUpgrade = async (tier: string) => {
    const { url } = await api.createCheckout({ tier });
    window.location.href = url; // Redirect to Stripe
  };
  
  return (
    <div>
      <h1>Upgrade Your Plan</h1>
      {plans.map(plan => (
        <PlanCard 
          key={plan.tier}
          {...plan}
          current={shop.tier === plan.tier}
          onSelect={() => handleUpgrade(plan.tier)}
        />
      ))}
    </div>
  );
}

Success Handler

tsx
// app/src/pages/BillingSuccess.tsx

function BillingSuccess() {
  const { loadUser } = useAuthStore();
  const sessionId = new URLSearchParams(location.search).get('session_id');
  
  useEffect(() => {
    // Reload user to get updated tier
    loadUser();
  }, []);
  
  return (
    <div className="text-center">
      <CheckCircleIcon className="w-16 h-16 text-green-500 mx-auto" />
      <h1>Payment Successful!</h1>
      <p>Your plan has been upgraded.</p>
      <Link to="/">Go to Dashboard</Link>
    </div>
  );
}

Webhook Setup

Creating Webhook Endpoint

bash
# Via Stripe Dashboard or CLI
stripe webhooks create \
  --url="https://api.yebomart.com/api/v1/billing/webhook" \
  --events="checkout.session.completed"

Webhook Route

typescript
// src/routes/billing.routes.ts

// Use express.raw() for webhook to get raw body
router.post('/webhook', 
  express.raw({ type: 'application/json' }), 
  async (req: Request, res: Response) => {
    try {
      const signature = req.headers['stripe-signature'] as string;
      if (!signature) {
        return res.status(400).json({ error: 'Missing stripe-signature header' });
      }

      const result = await BillingService.handleWebhookEvent(req.body, signature);
      return res.json(result);
    } catch (error: any) {
      console.error('[Billing] Webhook error:', error.message);
      return res.status(400).json({ error: 'Webhook verification failed' });
    }
  }
);

License Key System

For offline activation without Stripe.

Generate License Key

typescript
// LicenseService.generateLicenseKey()

static generateLicenseKey(data: LicenseData): string {
  const payload = {
    shopId: data.shopId,
    tier: data.tier,
    expiresAt: data.expiresAt.toISOString(),
    features: data.features,
    issuedAt: new Date().toISOString(),
  };

  const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64url');
  
  const signature = crypto
    .createHmac('sha256', LICENSE_SECRET)
    .update(base64Payload)
    .digest('base64url');

  return `YM${base64Payload}.${signature}`;
  // Example: YMeyJzaG9wSWQiOiJjbHguLi4.abc123def456
}

Validate License Key

typescript
static validateLicenseKey(licenseKey: string): LicenseData | null {
  if (!licenseKey.startsWith('YM')) return null;

  const parts = licenseKey.substring(2).split('.');
  if (parts.length !== 2) return null;

  const [payload, signature] = parts;

  // Verify signature
  const expectedSignature = crypto
    .createHmac('sha256', LICENSE_SECRET)
    .update(payload)
    .digest('base64url');

  if (signature !== expectedSignature) return null;

  // Decode payload
  const data = JSON.parse(Buffer.from(payload, 'base64url').toString());

  return {
    shopId: data.shopId,
    tier: data.tier as ShopTier,
    expiresAt: new Date(data.expiresAt),
    features: data.features,
  };
}

One chat. Everything done.