Billing & Credits System
YeboJobs has a hybrid monetization model:
- Credits System - Manual payments for African markets (MTN MoMo, M-Pesa, bank transfers)
- 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
| Action | Cost (Credits) | Notes |
|---|---|---|
| Apply for job | 5 | Deducted when applying |
| Post a job | 20 | Employers only |
| Contact worker | 3 | Start conversation |
| Boost job listing | 10/day | Increase visibility |
| Premium profile badge | 50/month | Stand out to employers |
Credit Packages
Defined in CreditPackage model:
| Package | Credits | Bonus | ZAR Price | USD Price |
|---|---|---|---|---|
| Starter | 50 | 0 | R49 | $3 |
| Popular | 200 | 20 | R149 | $9 |
| Pro | 500 | 100 | R299 | $18 |
| Business | 1000 | 250 | R499 | $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
- User selects package and currency
- User creates payment request (
PaymentRequestmodel) - System shows payment instructions (MTN MoMo number, bank details)
- User makes payment outside platform
- User submits reference number
- Admin verifies payment in admin dashboard
- 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
| Plan | Price/mo | Price/yr | Monthly Credits | Features |
|---|---|---|---|---|
| Free | $0 | $0 | 0 | Basic access |
| Pro | $9.99 | $99 | 100 | Priority matching, analytics |
| Business | $29.99 | $299 | 500 | Team accounts, API access |
| Enterprise | Custom | Custom | Custom | White-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:
| Code | Tier | Rate (per credit) | Markets |
|---|---|---|---|
| USD | 1 | $0.10 | US, Global |
| EUR | 1 | €0.09 | Europe |
| GBP | 1 | £0.08 | UK |
| ZAR | 3 | R1.50 | South Africa |
| SZL | 3 | E1.50 | Eswatini |
| KES | 4 | KSh10 | Kenya |
| NGN | 5 | ₦100 | Nigeria |