Eneza Billing & Payments
Eneza has a dual payment system:
- Advertisers pay via Stripe (credit card, international payments)
- Posters receive mobile money payouts (MTN MoMo, EWallet, etc.)
Advertiser Payments (Stripe)
Payment Flow
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ADVERTISER │────▶│ STRIPE HOSTED │────▶│ ENEZA API │
│ Dashboard │ │ CHECKOUT │ │ │
│ │ │ │ │ • Verify webhook│
│ 1. Create Ad │ │ 2. Enter Card │ │ • Credit Balance│
│ 4. Campaign Live│◀────│ 3. Payment OK │◀────│ • Activate Ad │
└─────────────────┘ └─────────────────┘ └─────────────────┘Stripe Integration
typescript
// stripeBillingService.ts
export class StripeBillingService {
// Get Stripe client based on mode (test/live)
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;
return new Stripe(key, { apiVersion: '2024-11-20.acacia' as any });
}
// Available credit packages
static getPlans(countryCode?: string) {
const PLANS = [
{ id: 'starter', name: 'Starter', price: 1000, credits: 100 },
{ id: 'growth', name: 'Growth', price: 3500, credits: 400 },
{ id: 'business', name: 'Business', price: 8000, credits: 1000 },
{ id: 'enterprise', name: 'Enterprise', price: 20000, credits: 3000 },
];
return PLANS.map(p => localisePackage(p, countryCode));
}
// Create Stripe Checkout session
static async createCheckout(opts: {
advertiserId: string;
email: string;
countryCode?: string;
planId: string;
successUrl: string;
cancelUrl: string;
}) {
const plan = PLANS.find(p => p.id === opts.planId);
const currency = getCurrencyForCountry(opts.countryCode);
const amount = convertFromUSD(plan.price, currency);
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ['card'],
customer_email: opts.email,
line_items: [{
price_data: {
currency: currency.code.toLowerCase(),
product_data: { name: `${plan.name} — ${plan.credits} Ad Credits` },
unit_amount: amount,
},
quantity: 1,
}],
mode: 'payment',
success_url: opts.successUrl,
cancel_url: opts.cancelUrl,
client_reference_id: opts.advertiserId,
metadata: {
advertiserId: opts.advertiserId,
planId: opts.planId,
credits: String(plan.credits),
amountUsdCents: String(plan.price),
},
});
return { sessionId: session.id, url: session.url };
}
}Campaign Payments
Direct campaign payment (pay for specific ad):
typescript
// campaignPaymentController.ts
static async createCheckoutSession(req: CustomRequest, res: Response) {
const { adId, successUrl, cancelUrl } = req.body;
const advertiserId = req.user._id;
// Get ad and invoice
const ad = await prisma.ad.findUnique({
where: { id: adId },
include: { invoice: true }
});
// Create Stripe session for invoice amount
const session = await stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: ad.invoice.currency.toLowerCase(),
product_data: {
name: `Campaign: ${ad.title}`,
description: `${ad.invoice.estimatedViews} estimated views`
},
unit_amount: Math.round(ad.invoice.amountLocal * 100),
},
quantity: 1,
}],
mode: 'payment',
success_url: successUrl,
cancel_url: cancelUrl,
metadata: {
adId: ad.id,
invoiceId: ad.invoice.id,
advertiserId
},
});
res.json({ sessionId: session.id, url: session.url });
}Webhook Handling
typescript
// Stripe webhook (raw body required)
static async handleWebhook(req: Request, res: Response) {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(
req.body, // Raw buffer
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
const { advertiserId, adId, invoiceId } = session.metadata;
// Credit advertiser balance
await prisma.advertiser.update({
where: { id: advertiserId },
data: { usdBalance: { increment: amountUsd } }
});
// Update invoice status
await prisma.invoice.update({
where: { id: invoiceId },
data: {
status: 'paid',
paidAt: new Date(),
paymentReference: session.payment_intent
}
});
// Activate the ad
await prisma.ad.update({
where: { id: adId },
data: { status: 'ACTIVE', active: true }
});
// Send confirmation email
await messagingService.publish('payment-receipt', {
advertiserId,
invoiceId,
amountPaid: amountUsd
});
break;
case 'payment_intent.payment_failed':
// Handle failed payment
break;
}
res.json({ received: true });
}USD-Based Tiered Pricing
Pricing Tiers
Pricing is stored in the database (pricing_tiers table):
| Tier | Name | CPM (USD) | Poster Rate | Min Budget |
|---|---|---|---|---|
| tier1 | Premium Markets | $3.00 | $0.75/1000 | $50 |
| tier2 | Large Markets | $2.00 | $0.50/1000 | $30 |
| tier3 | Emerging Markets | $1.00 | $0.25/1000 | $15 |
Pricing Calculation
typescript
// pricingService.ts
class PricingService {
async calculatePricing(amountUsd: number, countryCode: string): Promise<PricingCalculation> {
// Get tier for country
const tier = await this.getTierForCountry(countryCode);
// Calculate guaranteed views
// Formula: views = (budget / CPM) * 1000
const guaranteedViews = Math.floor((amountUsd / tier.cpmUsd) * 1000);
// Estimated views (25% buffer for over-delivery)
const estimatedViews = Math.floor(guaranteedViews * 1.25);
return {
amountUsd,
guaranteedViews,
estimatedViews,
effectiveCpm: tier.cpmUsd,
pricingTierId: tier.id,
tierName: tier.name,
minimumBudget: tier.minimumBudgetUsd
};
}
// Calculate poster payout for verified views
async calculatePosterPayout(countryCode: string, verifiedViews: number): Promise<number> {
const tier = await this.getTierForCountry(countryCode);
// Formula: payout = (views / 1000) * posterRate
return (verifiedViews / 1000) * tier.posterRatePerThousandViews;
}
}Invoice Generation
typescript
// invoiceService.ts
async createInvoice(advertiserId: string, adId: string, amountUsd: number) {
const advertiser = await prisma.advertiser.findUnique({
where: { id: advertiserId },
include: { country: true }
});
// Get exchange rate
const exchangeRate = await exchangeRateService.getRate(advertiser.country.currencyCode);
const amountLocal = amountUsd * exchangeRate.rate;
// Calculate campaign details
const pricing = await pricingService.calculatePricing(amountUsd, advertiser.countryCode);
// Generate invoice number: INV-YYYY-NNNNN
const year = new Date().getFullYear();
const sequence = await this.getNextSequence(year);
const invoiceNumber = `INV-${year}-${String(sequence).padStart(5, '0')}`;
return prisma.invoice.create({
data: {
invoiceNumber,
advertiserId,
adId,
amountUsd,
amountLocal,
currency: advertiser.country.currencyCode,
exchangeRate: exchangeRate.rate,
campaignDays: 7,
dailyBudgetUsd: amountUsd / 7,
estimatedViews: pricing.estimatedViews,
customerName: advertiser.contactPerson,
companyName: advertiser.companyName,
taxId: advertiser.taxId,
email: advertiser.emailAddress,
status: 'pending'
}
});
}Poster Payouts (Mobile Money)
Payout Flow
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ POSTER │────▶│ ENEZA API │────▶│ ADMIN │
│ Mobile App │ │ │ │ Dashboard │
│ │ │ • Validate bal │ │ │
│ 1. Request │ │ • Create txn │ │ 2. Review queue │
│ Withdrawal │ │ • Pending status│ │ 3. Process payout│
│ 4. Receive $$ │◀────│ │◀────│ (Manual MoMo) │
└─────────────────┘ └─────────────────┘ └─────────────────┘Withdrawal Request
typescript
// transactionService.ts
async createWithdrawal(userId: string, amount: number, paymentMethodId: string) {
const user = await prisma.user.findUnique({
where: { id: userId },
include: { country: true, paymentProcessor: true }
});
// Validate minimum withdrawal
if (amount < user.country.minimumWithdrawal) {
throw new Error(`Minimum withdrawal is ${user.country.minimumWithdrawal} ${user.country.currencyCode}`);
}
// Validate balance
if (amount > user.userBalance) {
throw new Error('Insufficient balance');
}
// Get exchange rate for local amount
const exchangeRate = await exchangeRateService.getRate(user.country.currencyCode);
const amountLocal = amount * exchangeRate.rate;
// Generate transaction code
const code = await this.generateTransactionCode();
// Create transaction and deduct balance atomically
const [transaction] = await prisma.$transaction([
prisma.transaction.create({
data: {
userId,
amount,
amountUsd: amount,
amountLocal,
currency: user.country.currencyCode,
exchangeRate: exchangeRate.rate,
status: 'pending',
type: 'withdrawal',
code,
paymentMethodId
}
}),
prisma.user.update({
where: { id: userId },
data: { userBalance: { decrement: amount } }
})
]);
// Notify admin
await this.notifyAdminNewWithdrawal(transaction);
return transaction;
}Admin Processing
typescript
// Admin processes withdrawal manually via MoMo
async processWithdrawal(transactionId: string, status: 'paid' | 'cancelled', reference?: string) {
const transaction = await prisma.transaction.findUnique({
where: { id: transactionId },
include: { user: true }
});
if (status === 'paid') {
// Mark as paid
await prisma.transaction.update({
where: { id: transactionId },
data: {
status: 'paid',
paymentTime: new Date(),
paymentConfirmationCode: reference
}
});
// Notify user
await messagingService.publish('push-notification', {
deviceToken: transaction.user.deviceToken,
title: 'Payment Received! 🎉',
body: `Your withdrawal of ${transaction.amountLocal} ${transaction.currency} has been sent to your ${transaction.paymentMethod} account.`
});
} else {
// Refund balance on cancellation
await prisma.$transaction([
prisma.transaction.update({
where: { id: transactionId },
data: { status: 'cancelled' }
}),
prisma.user.update({
where: { id: transaction.userId },
data: { userBalance: { increment: transaction.amount } }
})
]);
}
}Crediting Users for Views
When a screenshot is verified:
typescript
// After screenshot verification passes
async creditUserForViews(screenshotId: string, verifiedViews: number) {
const screenshot = await prisma.screenshot.findUnique({
where: { id: screenshotId },
include: {
user: { include: { country: true } },
subscription: { include: { ad: true } }
}
});
// Calculate payout based on country tier
const payoutAmount = await pricingService.calculatePosterPayout(
screenshot.user.country.code,
verifiedViews
);
// Credit user balance
await prisma.$transaction([
prisma.user.update({
where: { id: screenshot.userId },
data: { userBalance: { increment: payoutAmount } }
}),
prisma.screenshot.update({
where: { id: screenshotId },
data: {
acceptedViews: verifiedViews,
payoutAmountUsd: payoutAmount,
status: 'APPROVED',
isApproved: true
}
}),
prisma.transaction.create({
data: {
userId: screenshot.userId,
amount: payoutAmount,
amountUsd: payoutAmount,
type: 'ad_payout',
status: 'paid',
adId: screenshot.subscription.adId,
description: `Payout for ${verifiedViews} verified views`,
code: await generateCode()
}
})
]);
// Send push notification
await notifyUserPayout(screenshot.userId, payoutAmount, verifiedViews);
}Exchange Rates
Rate Service
typescript
// exchangeRateService.ts
class ExchangeRateService {
private rates: Map<string, { rate: number; fetchedAt: Date }> = new Map();
async initialize() {
// Load rates from database
const dbRates = await prisma.exchangeRate.findMany();
for (const rate of dbRates) {
this.rates.set(rate.currencyCode, {
rate: parseFloat(rate.rateToUsd),
fetchedAt: rate.fetchedAt
});
}
}
async refreshRates() {
// Fetch from external API (Swychr)
const response = await fetch('https://api.swychr.com/rates', {
headers: { 'Authorization': `Bearer ${process.env.SWYCHR_API_KEY}` }
});
const data = await response.json();
// Update database and cache
for (const [code, rate] of Object.entries(data.rates)) {
await prisma.exchangeRate.upsert({
where: { currencyCode: code },
update: { rateToUsd: rate, fetchedAt: new Date() },
create: { currencyCode: code, rateToUsd: rate }
});
this.rates.set(code, { rate, fetchedAt: new Date() });
}
}
async convertFromUsd(amountUsd: number, currencyCode: string): Promise<number> {
const rate = await this.getRate(currencyCode);
return amountUsd * rate.rate;
}
needsRefresh(): boolean {
// Refresh if any rate is >24h old
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
for (const [_, { fetchedAt }] of this.rates) {
if (fetchedAt < oneDayAgo) return true;
}
return false;
}
}Payment Processors
Supported mobile money providers:
| Processor | Countries | Type |
|---|---|---|
| MTN MoMo | ZA, UG, GH | Mobile Money |
| EWallet | SZ | Mobile Money |
| Vodacom M-Pesa | TZ, KE | Mobile Money |
| Airtel Money | UG, KE | Mobile Money |
Configuration is stored in payment_processors and payment_processor_countries tables.