YeboLink Billing Deep Dive
YeboLink uses a credit-based prepaid billing system integrated with Stripe. This page covers the complete billing architecture.
Credit System Overview
Credit Costs by Channel
| Channel | Credits | Notes |
|---|---|---|
| SMS | 1.0 | Standard rate |
| 0.5 | Lower cost messaging | |
| 0.1 | Bulk-friendly | |
| Voice | 2.0 | Premium channel |
| Push | 0.05 | Near-free |
| Web | 0.0 | Free (websocket) |
// message.service.ts
private static CREDIT_COSTS: Record<string, number> = {
sms: 1,
whatsapp: 0.5,
email: 0.1,
voice: 2,
push: 0.05,
web: 0,
};Credit Packages
Volume discounts encourage larger purchases:
| Package | Credits | Price (USD) | Per Credit | Discount |
|---|---|---|---|---|
| Starter | 125 | $10 | $0.080 | Base rate |
| Growth | 340 | $25 | $0.074 | 8% bonus |
| Business | 715 | $50 | $0.070 | 13% bonus |
| Pro | 1,500 | $100 | $0.067 | 17% bonus |
| Scale | 3,200 | $200 | $0.063 | 22% bonus |
| Enterprise | 8,500 | $500 | $0.059 | 26% bonus |
// billing.service.ts
private static CREDIT_PACKAGES = [
{ credits: 125, price: 1000, name: 'Starter Pack' },
{ credits: 340, price: 2500, name: 'Growth Pack' },
{ credits: 715, price: 5000, name: 'Business Pack' },
{ credits: 1500, price: 10000, name: 'Pro Pack' },
{ credits: 3200, price: 20000, name: 'Scale Pack' },
{ credits: 8500, price: 50000, name: 'Enterprise Pack' },
];Multi-Currency Support
YeboLink localizes pricing for African currencies with automatic conversion.
Supported Currencies
// utils/currencies.ts
export const AFRICAN_CURRENCIES: Record<string, CurrencyInfo> = {
ZA: { code: 'ZAR', symbol: 'R', rate: 18.50, stripeSupported: true },
NG: { code: 'NGN', symbol: '₦', rate: 1580, stripeSupported: true },
KE: { code: 'KES', symbol: 'KSh', rate: 130, stripeSupported: true },
GH: { code: 'GHS', symbol: 'GH₵', rate: 15.4, stripeSupported: true },
EG: { code: 'EGP', symbol: 'E£', rate: 48, stripeSupported: true },
TZ: { code: 'TZS', symbol: 'TSh', rate: 2580, stripeSupported: false },
UG: { code: 'UGX', symbol: 'USh', rate: 3700, stripeSupported: false },
RW: { code: 'RWF', symbol: 'Fr', rate: 1350, stripeSupported: false },
SZ: { code: 'SZL', symbol: 'L', rate: 18.50, stripeSupported: false },
// ... more countries
};Pricing Logic
- Display Price — Always shown in user's local currency
- Charge Currency — Uses local currency if Stripe supports it, otherwise USD
- Conversion — Based on static exchange rates (updated periodically)
export function localisePackage(
pkg: { credits: number; price: number; name: string },
countryCode?: string | null
) {
const currency = getCurrencyForCountry(countryCode);
const chargeCurrency = currency.stripeSupported ? currency : DEFAULT_CURRENCY;
const chargeAmount = convertFromUSD(pkg.price, chargeCurrency);
const displayAmount = convertFromUSD(pkg.price, currency);
return {
...pkg,
// Stripe payment details
stripeCurrency: chargeCurrency.code.toLowerCase(),
stripeAmount: chargeAmount,
// User-facing display
displayCurrency: currency.code,
displaySymbol: currency.symbol,
displayAmount,
displayFormatted: formatLocalAmount(displayAmount, currency),
// Reference
usdPrice: pkg.price,
usdFormatted: `$${(pkg.price / 100).toFixed(0)}`,
showUsdNote: currency.code !== 'USD',
};
}Example for South Africa (ZAR):
{
"credits": 125,
"name": "Starter Pack",
"stripeCurrency": "zar",
"stripeAmount": 18500,
"displayFormatted": "R185",
"usdFormatted": "$10",
"showUsdNote": true
}Stripe Integration
Configuration
private static getStripe(): Stripe {
const mode = process.env.STRIPE_MODE || 'test';
const key = mode === 'live'
? (process.env.STRIPE_LIVE_SECRET_KEY || env.STRIPE_SECRET_KEY)
: (process.env.STRIPE_TEST_SECRET_KEY || env.STRIPE_SECRET_KEY);
if (!key) throw new Error('No Stripe key configured');
return new Stripe(key, { apiVersion: '2023-10-16' });
}Environment Variables:
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_MODE=live # or 'test'Checkout Flow
Step 1: Create Checkout Session
static async createCheckoutSession(
workspaceId: string,
credits: number
): Promise<{ sessionId: string; url: string }> {
const workspace = await WorkspaceModel.findById(workspaceId);
// Find package or calculate custom price
const basePkg = this.CREDIT_PACKAGES.find((p) => p.credits === credits)
|| this.calculateCustomPackage(credits);
// Localize to user's country
const localised = localisePackage(basePkg, workspace.country);
const session = await this.stripe.checkout.sessions.create({
payment_method_types: ['card'],
line_items: [{
price_data: {
currency: localised.stripeCurrency,
product_data: {
name: basePkg.name,
description: `${credits} messaging credits for YeboLink`,
},
unit_amount: localised.stripeAmount,
},
quantity: 1,
}],
mode: 'payment',
success_url: `${env.FRONTEND_URL}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.FRONTEND_URL}/credits`,
client_reference_id: workspaceId,
metadata: {
workspaceId,
credits: credits.toString(),
currency: localised.stripeCurrency,
localAmount: localised.stripeAmount.toString(),
},
});
return { sessionId: session.id, url: session.url! };
}Step 2: Handle Webhook
static async handleWebhook(signature: string, payload: Buffer): Promise<void> {
let event: Stripe.Event;
try {
event = this.stripe.webhooks.constructEvent(
payload,
signature,
env.STRIPE_WEBHOOK_SECRET
);
} catch (error: any) {
throw new Error(`Webhook signature verification failed: ${error.message}`);
}
switch (event.type) {
case 'checkout.session.completed':
await this.handleCheckoutCompleted(event.data.object);
break;
case 'payment_intent.succeeded':
logger.info('Payment intent succeeded', { paymentIntentId: event.data.object.id });
break;
case 'payment_intent.payment_failed':
logger.warn('Payment intent failed', { paymentIntentId: event.data.object.id });
break;
}
}Step 3: Add Credits
private static async handleCheckoutCompleted(
session: Stripe.Checkout.Session
): Promise<void> {
const workspaceId = session.metadata?.workspaceId || session.client_reference_id;
const credits = parseInt(session.metadata?.credits || '0');
// Idempotency check
const existing = await TransactionModel.findByStripeCheckoutSession(session.id);
if (existing) {
logger.warn('Checkout session already processed', { sessionId: session.id });
return;
}
// Add credits atomically
const newBalance = await WorkspaceModel.addCredits(
workspaceId,
credits,
session.payment_intent as string,
`Purchase of ${credits} credits`
);
// Send confirmation email
const workspace = await WorkspaceModel.findById(workspaceId);
if (workspace) {
await this.sendPurchaseConfirmationEmail(
workspace.email,
workspace.name,
credits,
newBalance
);
}
}Credit Operations
Atomic Credit Deduction
Happens during message processing (via stored procedure):
// workspace.ts
static async deductCredits(
workspaceId: string,
amount: number,
messageId: string,
description: string
): Promise<boolean> {
const result = await db.query(
'SELECT deduct_credits($1, $2, $3, $4)',
[workspaceId, amount, messageId, description]
);
return result.rows[0].deduct_credits;
}PostgreSQL Stored Procedure:
CREATE OR REPLACE FUNCTION deduct_credits(
p_workspace_id UUID,
p_amount DECIMAL,
p_message_id UUID,
p_description TEXT
) RETURNS BOOLEAN AS $$
DECLARE
v_balance DECIMAL;
BEGIN
-- Lock row to prevent race conditions
SELECT credits_balance INTO v_balance
FROM workspaces WHERE id = p_workspace_id
FOR UPDATE;
IF v_balance < p_amount THEN
RETURN FALSE;
END IF;
-- Deduct
UPDATE workspaces
SET credits_balance = credits_balance - p_amount
WHERE id = p_workspace_id;
-- Log transaction
INSERT INTO transactions (workspace_id, type, amount, balance_after, message_id, description)
VALUES (p_workspace_id, 'usage', -p_amount, v_balance - p_amount, p_message_id, p_description);
RETURN TRUE;
END;
$$ LANGUAGE plpgsql;Atomic Credit Addition
CREATE OR REPLACE FUNCTION add_credits(
p_workspace_id UUID,
p_amount DECIMAL,
p_stripe_payment_id TEXT,
p_description TEXT
) RETURNS DECIMAL AS $$
DECLARE
v_new_balance DECIMAL;
BEGIN
UPDATE workspaces
SET credits_balance = credits_balance + p_amount
WHERE id = p_workspace_id
RETURNING credits_balance INTO v_new_balance;
INSERT INTO transactions (workspace_id, type, amount, balance_after, stripe_payment_id, description)
VALUES (p_workspace_id, 'purchase', p_amount, v_new_balance, p_stripe_payment_id, p_description);
RETURN v_new_balance;
END;
$$ LANGUAGE plpgsql;Admin Credit Management
The CEO dashboard can add credits manually:
// dashboard.routes.ts
router.post(
'/workspaces/:id/credits',
asyncHandler(async (req: Request, res: Response) => {
const { id } = req.params;
const { amount, description } = req.body;
const newBalance = await WorkspaceModel.addCredits(
id,
amount,
null, // No Stripe payment
description || `Admin credit top-up: ${amount} credits`
);
// Send notification email
await sendCreditsAddedEmail({
to: workspace.email,
name: workspace.name,
creditsAdded: amount,
newBalance,
description,
});
res.json({
success: true,
data: { workspace_id: id, credits_added: amount, new_balance: newBalance },
});
})
);Transaction Tracking
All credit movements are logged in the transactions table:
| Type | Amount | Description |
|---|---|---|
purchase | +500 | Purchase of 500 credits |
usage | -1 | Message to +26878... via sms |
refund | +100 | Refund for failed messages |
adjustment | +50 | Admin credit top-up |
Query Transactions
static async getTransactions(
workspaceId: string,
filters: {
type?: string;
startDate?: Date;
endDate?: Date;
page?: number;
limit?: number;
}
): Promise<any> {
const result = await TransactionModel.findByWorkspace(workspaceId, {
...filters,
limit: filters.limit || 50,
offset: ((filters.page || 1) - 1) * (filters.limit || 50),
});
return {
transactions: result.transactions,
total: result.total,
page: filters.page || 1,
pages: Math.ceil(result.total / (filters.limit || 50)),
};
}Low Balance Alerts
When credits drop below threshold, send email:
// email.service.ts
static async sendLowBalanceAlert(
email: string,
name: string,
balance: number
): Promise<void> {
const html = `
<h2>Low Balance Alert</h2>
<div class="warning">
<p><strong>Hi ${name},</strong></p>
<p>Your YeboLink account balance is running low:
<strong>${balance} credits remaining</strong></p>
</div>
<p>To avoid service interruption, please top up your account:</p>
<a href="${env.FRONTEND_URL}/billing" class="button">Add Credits</a>
`;
await this.sendEmail(email, 'YeboLink: Low Balance Alert', html);
}Triggered via webhook event credit.low.
Purchase Confirmation Email
private static async sendPurchaseConfirmationEmail(
email: string,
name: string,
credits: number,
newBalance: number
): Promise<void> {
const html = `
<h2>Purchase Successful!</h2>
<div class="success">
<p><strong>Hi ${name},</strong></p>
<p>Your credit purchase was successful!</p>
</div>
<div class="details">
<p><strong>Credits Purchased:</strong> ${credits}</p>
<p><strong>New Balance:</strong> ${newBalance} credits</p>
</div>
<p>Thank you for using YeboLink!</p>
<a href="${env.FRONTEND_URL}/dashboard">Go to Dashboard</a>
`;
await EmailService.sendEmail(email, 'YeboLink: Purchase Confirmation', html);
}API Endpoints
GET /api/v1/billing/packages
Returns available packages with localized pricing.
Query Parameters:
country— ISO 3166-1 alpha-2 code
Response:
{
"success": true,
"data": {
"packages": [
{
"credits": 125,
"price": 1000,
"name": "Starter Pack",
"stripeCurrency": "zar",
"stripeAmount": 18500,
"displayFormatted": "R185",
"usdFormatted": "$10"
}
],
"mode": "live",
"country": "ZA"
}
}POST /api/v1/billing/checkout
Creates Stripe checkout session.
Request:
{
"credits": 715
}Response:
{
"success": true,
"data": {
"session_id": "cs_live_...",
"url": "https://checkout.stripe.com/pay/cs_live_..."
}
}POST /api/v1/billing/webhook
Stripe webhook endpoint (called by Stripe).
GET /api/v1/billing/transactions
Returns transaction history with pagination.