Skip to content

Billing & Credits System

YeboJobs has a hybrid monetization model:

  1. Credits System - Manual payments for African markets (MTN MoMo, M-Pesa, bank transfers)
  2. Stripe Subscriptions - For markets with card payments

Credits System

Overview

Credits are the in-app currency. Users buy credits through manual payment requests (admin-verified), then spend them on platform actions.

Credit Actions & Costs

ActionCost (Credits)Notes
Apply for job5Deducted when applying
Post a job20Employers only
Contact worker3Start conversation
Boost job listing10/dayIncrease visibility
Premium profile badge50/monthStand out to employers

Credit Packages

Defined in CreditPackage model:

PackageCreditsBonusZAR PriceUSD Price
Starter500R49$3
Popular20020R149$9
Pro500100R299$18
Business1000250R499$30

Prices are PPP-adjusted per currency tier.

Credit Service

File: credits.service.ts

typescript
export class CreditsService {
  // Get user's credit balance
  static async getBalance(userId: string): Promise<UserCredits> {
    return prisma.userCredits.upsert({
      where: { userId },
      create: { userId, balance: 0 },
      update: {},
    });
  }

  // Spend credits (returns false if insufficient)
  static async spendCredits(
    userId: string,
    amount: number,
    action: string,
    description: string,
    referenceType?: string,
    referenceId?: string
  ): Promise<boolean> {
    return prisma.$transaction(async (tx) => {
      const credits = await tx.userCredits.findUnique({ where: { userId } });
      
      if (!credits || credits.balance < amount) {
        return false;
      }

      // Deduct credits
      await tx.userCredits.update({
        where: { userId },
        data: {
          balance: { decrement: amount },
          totalSpent: { increment: amount },
        },
      });

      // Log transaction
      await tx.creditTransaction.create({
        data: {
          userId,
          type: 'spend',
          amount: -amount,
          action,
          description,
          referenceType,
          referenceId,
          balanceBefore: credits.balance,
          balanceAfter: credits.balance - amount,
        },
      });

      return true;
    });
  }

  // Add credits (purchase, bonus, reward)
  static async addCredits(
    userId: string,
    amount: number,
    type: 'purchase' | 'bonus' | 'earn' | 'refund',
    action: string,
    description: string
  ): Promise<UserCredits> {
    return prisma.$transaction(async (tx) => {
      const credits = await tx.userCredits.upsert({
        where: { userId },
        create: { userId, balance: amount },
        update: { balance: { increment: amount } },
      });

      await tx.creditTransaction.create({
        data: {
          userId,
          type,
          amount,
          action,
          description,
          balanceBefore: credits.balance - amount,
          balanceAfter: credits.balance,
        },
      });

      return credits;
    });
  }
}

Payment Request Flow

  1. User selects package and currency
  2. User creates payment request (PaymentRequest model)
  3. System shows payment instructions (MTN MoMo number, bank details)
  4. User makes payment outside platform
  5. User submits reference number
  6. Admin verifies payment in admin dashboard
  7. Admin confirms → Credits added to user's wallet
typescript
// Create payment request
static async createPaymentRequest(
  userId: string,
  packageId: string,
  currencyCode: string
): Promise<PaymentRequest> {
  const pkg = await prisma.creditPackage.findUnique({
    where: { id: packageId },
    include: { prices: { where: { currencyCode } } },
  });

  const price = pkg.prices[0];
  const totalCredits = pkg.credits + pkg.bonusCredits;

  return prisma.paymentRequest.create({
    data: {
      userId,
      packageId,
      currencyCode,
      amount: price.price,
      credits: totalCredits,
      status: 'pending',
      expiresAt: new Date(Date.now() + 48 * 60 * 60 * 1000), // 48 hours
    },
  });
}

// Admin confirms payment
static async confirmPayment(
  requestId: string,
  adminId: string
): Promise<PaymentRequest> {
  return prisma.$transaction(async (tx) => {
    const request = await tx.paymentRequest.update({
      where: { id: requestId },
      data: {
        status: 'confirmed',
        confirmedBy: adminId,
        confirmedAt: new Date(),
      },
    });

    // Add credits to user
    await CreditsService.addCredits(
      request.userId,
      request.credits,
      'purchase',
      'package_purchase',
      `Purchased ${request.credits} credits`
    );

    return request;
  });
}

Cashout (Workers)

Service workers can cash out earned credits:

typescript
static async createCashoutRequest(
  userId: string,
  credits: number,
  paymentMethod: string,
  accountDetails: string
): Promise<CashoutRequest> {
  const userCredits = await prisma.userCredits.findUnique({ where: { userId } });
  
  if (!userCredits || userCredits.balance < credits) {
    throw new Error('Insufficient credits');
  }

  // Freeze credits during cashout processing
  await prisma.userCredits.update({
    where: { userId },
    data: {
      balance: { decrement: credits },
      frozenBalance: { increment: credits },
    },
  });

  // Calculate amount based on exchange rate
  const currency = await prisma.currency.findFirst({ where: { code: 'ZAR' } });
  const amount = credits * Number(currency.creditRate);

  return prisma.cashoutRequest.create({
    data: {
      userId,
      credits,
      amount,
      currencyCode: 'ZAR',
      status: 'pending',
      paymentMethod,
      accountDetails, // Encrypted
    },
  });
}

Stripe Subscriptions

Overview

Stripe handles subscriptions for users with card access. Plans unlock features and provide monthly bonus credits.

Subscription Plans

PlanPrice/moPrice/yrMonthly CreditsFeatures
Free$0$00Basic access
Pro$9.99$99100Priority matching, analytics
Business$29.99$299500Team accounts, API access
EnterpriseCustomCustomCustomWhite-label, dedicated support

Billing Service

File: billing.service.ts

typescript
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});

export class BillingService {
  // Create Stripe checkout session
  static async createCheckoutSession(
    customerId: string,
    priceId: string,
    successUrl: string,
    cancelUrl: string
  ): Promise<Stripe.Checkout.Session> {
    return stripe.checkout.sessions.create({
      customer: customerId,
      mode: 'subscription',
      line_items: [{ price: priceId, quantity: 1 }],
      success_url: successUrl,
      cancel_url: cancelUrl,
      allow_promotion_codes: true,
    });
  }

  // Get or create Stripe customer
  static async getOrCreateCustomer(
    subscriberType: 'user' | 'employer',
    subscriberId: string,
    email: string,
    name: string
  ): Promise<StripeCustomer> {
    const existing = await prisma.stripeCustomer.findFirst({
      where: subscriberType === 'user' 
        ? { userId: subscriberId }
        : { employerId: subscriberId },
    });

    if (existing) return existing;

    const stripeCustomer = await stripe.customers.create({
      email,
      name,
      metadata: { subscriberType, subscriberId },
    });

    return prisma.stripeCustomer.create({
      data: {
        stripeCustomerId: stripeCustomer.id,
        subscriberType,
        userId: subscriberType === 'user' ? subscriberId : null,
        employerId: subscriberType === 'employer' ? subscriberId : null,
        email,
        name,
      },
    });
  }

  // Handle subscription webhook events
  static async handleSubscriptionEvent(event: Stripe.Event): Promise<void> {
    switch (event.type) {
      case 'checkout.session.completed':
        await this.handleCheckoutComplete(event.data.object);
        break;
      case 'customer.subscription.updated':
        await this.handleSubscriptionUpdate(event.data.object);
        break;
      case 'customer.subscription.deleted':
        await this.handleSubscriptionCanceled(event.data.object);
        break;
      case 'invoice.payment_failed':
        await this.handlePaymentFailed(event.data.object);
        break;
    }
  }

  private static async handleCheckoutComplete(session: Stripe.Checkout.Session) {
    const subscription = await stripe.subscriptions.retrieve(
      session.subscription as string
    );

    const customer = await prisma.stripeCustomer.findUnique({
      where: { stripeCustomerId: session.customer as string },
    });

    if (!customer) return;

    // Create subscription record
    await prisma.subscription.create({
      data: {
        stripeSubscriptionId: subscription.id,
        stripeCustomerId: customer.id,
        tier: this.getTierFromPriceId(subscription.items.data[0].price.id),
        status: 'active',
        currentPeriodStart: new Date(subscription.current_period_start * 1000),
        currentPeriodEnd: new Date(subscription.current_period_end * 1000),
      },
    });

    // Update user/employer tier
    if (customer.userId) {
      await prisma.user.update({
        where: { id: customer.userId },
        data: { 
          subscriptionTier: this.getTierFromPriceId(subscription.items.data[0].price.id),
          subscriptionStatus: 'active',
        },
      });
    }
  }
}

Webhook Endpoint

typescript
// POST /api/billing/webhook
static async handleWebhook(req: Request, res: Response) {
  const sig = req.headers['stripe-signature'] as string;
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
  } catch (err) {
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Idempotency check
  const existing = await prisma.webhookEvent.findUnique({
    where: { stripeEventId: event.id },
  });
  if (existing) return res.json({ received: true });

  // Process event
  await BillingService.handleSubscriptionEvent(event);

  // Record event
  await prisma.webhookEvent.create({
    data: { stripeEventId: event.id, eventType: event.type },
  });

  res.json({ received: true });
}

Vouchers & Promotions

Voucher Types

typescript
enum VoucherType {
  percentage     // 20% off
  fixed_amount   // $5 off
  free_trial     // 7 days free
  credits        // 50 bonus credits
}

Creating Vouchers

typescript
static async createVoucher(data: CreateVoucherInput): Promise<Voucher> {
  // Generate Stripe coupon if percentage or fixed
  let stripeCouponId: string | undefined;
  
  if (data.type === 'percentage' || data.type === 'fixed_amount') {
    const coupon = await stripe.coupons.create({
      percent_off: data.type === 'percentage' ? Number(data.discountValue) : undefined,
      amount_off: data.type === 'fixed_amount' ? Number(data.discountValue) * 100 : undefined,
      currency: 'usd',
      duration: 'once',
      name: data.name,
    });
    stripeCouponId = coupon.id;
  }

  return prisma.voucher.create({
    data: {
      code: data.code.toUpperCase(),
      name: data.name,
      type: data.type,
      discountValue: data.discountValue,
      bonusCredits: data.bonusCredits || 0,
      trialDays: data.trialDays || 0,
      maxTotalUses: data.maxTotalUses,
      expiresAt: data.expiresAt,
      stripeCouponId,
      createdBy: data.adminId,
    },
  });
}

Redeeming Vouchers

typescript
static async redeemVoucher(
  code: string,
  userId: string
): Promise<{ success: boolean; benefit: string }> {
  const voucher = await prisma.voucher.findUnique({ where: { code } });
  
  if (!voucher || !voucher.isActive) {
    throw new Error('Invalid voucher code');
  }

  if (voucher.expiresAt && voucher.expiresAt < new Date()) {
    throw new Error('Voucher has expired');
  }

  if (voucher.maxTotalUses && voucher.currentUses >= voucher.maxTotalUses) {
    throw new Error('Voucher usage limit reached');
  }

  // Check if user already used this voucher
  const existingUsage = await prisma.voucherUsage.findFirst({
    where: { voucherId: voucher.id, userId },
  });
  if (existingUsage) {
    throw new Error('You have already used this voucher');
  }

  // Apply benefit
  let benefit = '';
  
  if (voucher.bonusCredits > 0) {
    await CreditsService.addCredits(
      userId,
      voucher.bonusCredits,
      'bonus',
      'voucher_redemption',
      `Voucher ${code}: ${voucher.bonusCredits} bonus credits`
    );
    benefit = `${voucher.bonusCredits} credits added to your wallet`;
  }

  if (voucher.trialDays > 0) {
    await this.grantFreeTrial(userId, voucher.trialDays);
    benefit = `${voucher.trialDays}-day free trial activated`;
  }

  // Record usage
  await prisma.voucherUsage.create({
    data: { voucherId: voucher.id, userId },
  });

  await prisma.voucher.update({
    where: { id: voucher.id },
    data: { currentUses: { increment: 1 } },
  });

  return { success: true, benefit };
}

Admin Free Trials

Admins can grant free trials without Stripe:

typescript
static async grantFreeTrial(
  userId: string,
  durationDays: number,
  tier: SubscriptionTier = 'pro',
  grantedBy: string,
  reason?: string
): Promise<FreeTrial> {
  const expiresAt = new Date();
  expiresAt.setDate(expiresAt.getDate() + durationDays);

  const trial = await prisma.freeTrial.create({
    data: {
      userId,
      tier,
      durationDays,
      expiresAt,
      grantedBy,
      grantReason: reason,
    },
  });

  // Update user's subscription status
  await prisma.user.update({
    where: { id: userId },
    data: {
      subscriptionTier: tier,
      subscriptionStatus: 'trialing',
    },
  });

  return trial;
}

Currency Configuration

Multi-currency support with PPP adjustments:

CodeTierRate (per credit)Markets
USD1$0.10US, Global
EUR1€0.09Europe
GBP1£0.08UK
ZAR3R1.50South Africa
SZL3E1.50Eswatini
KES4KSh10Kenya
NGN5₦100Nigeria

One chat. Everything done.