Skip to content

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):

TierNameCPM (USD)Poster RateMin Budget
tier1Premium Markets$3.00$0.75/1000$50
tier2Large Markets$2.00$0.50/1000$30
tier3Emerging 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:

ProcessorCountriesType
MTN MoMoZA, UG, GHMobile Money
EWalletSZMobile Money
Vodacom M-PesaTZ, KEMobile Money
Airtel MoneyUG, KEMobile Money

Configuration is stored in payment_processors and payment_processor_countries tables.

One chat. Everything done.