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
| Tier | Features | Limits |
|---|---|---|
| LITE | Basic POS, Stock, Reports, AI (100/mo) | 100 products, 1 user |
| STARTER | + Barcode, Alerts, Staff | 500 products, 3 users |
| BUSINESS | + WhatsApp, Advanced Analytics | 2,500 products, 10 users |
| PRO | + AI Voice, Multi-location | 10,000 products, 25 users |
| ENTERPRISE | + API, Dedicated Support | Unlimited |
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 USDCurrency 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,
};
}