edtech / apps /api /src /services /payment-gateway.ts
CognxSafeTrack
feat: backlog P0→P3 — toast system, payments, tenant isolation, feedback handler, i18n parity
6dd9bad
import { logger } from '../logger';
export type PaymentProvider = 'ORANGE_MONEY' | 'WAVE';
export interface PaymentInitRequest {
amount: number;
currency: string;
reference: string;
notifyUrl: string;
returnUrl: string;
description: string;
customerPhone?: string;
}
export interface PaymentInitResult {
paymentUrl: string;
sessionId: string;
provider: PaymentProvider;
}
export interface WebhookPayload {
status: 'SUCCESS' | 'FAILED' | 'PENDING';
reference: string;
transactionId?: string;
amount?: number;
provider: PaymentProvider;
}
// Orange Money Merchant API (West Africa)
async function initOrangeMoney(req: PaymentInitRequest): Promise<PaymentInitResult> {
const apiKey = process.env.ORANGE_MONEY_API_KEY;
const merchantId = process.env.ORANGE_MONEY_MERCHANT_ID;
const apiUrl = process.env.ORANGE_MONEY_API_URL || 'https://api.orange.com/orange-money-webpay/dev/v1';
if (!apiKey || !merchantId) throw new Error('Orange Money credentials not configured');
const res = await fetch(`${apiUrl}/webpayment`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
merchant_key: merchantId,
currency: req.currency,
order_id: req.reference,
amount: req.amount,
return_url: req.returnUrl,
cancel_url: req.returnUrl,
notif_url: req.notifyUrl,
lang: 'fr',
reference: req.reference,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}) ) as { message?: string };
throw new Error(`Orange Money init failed: ${err.message || res.status}`);
}
const data = await res.json() as { payment_url: string; pay_token: string };
return {
paymentUrl: data.payment_url,
sessionId: data.pay_token,
provider: 'ORANGE_MONEY',
};
}
// Wave API (Senegal)
async function initWave(req: PaymentInitRequest): Promise<PaymentInitResult> {
const apiKey = process.env.WAVE_API_KEY;
const apiUrl = 'https://api.wave.com/v1/checkout/sessions';
if (!apiKey) throw new Error('Wave API key not configured');
const res = await fetch(apiUrl, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
amount: String(req.amount),
currency: req.currency,
error_url: req.returnUrl,
success_url: `${req.returnUrl}?success=1&ref=${req.reference}`,
client_reference: req.reference,
}),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}) ) as { message?: string };
throw new Error(`Wave init failed: ${err.message || res.status}`);
}
const data = await res.json() as { wave_launch_url: string; id: string };
return {
paymentUrl: data.wave_launch_url,
sessionId: data.id,
provider: 'WAVE',
};
}
export async function initiatePayment(provider: PaymentProvider, req: PaymentInitRequest): Promise<PaymentInitResult> {
logger.info({ provider, reference: req.reference, amount: req.amount }, '[PAYMENT] Initiating payment');
if (provider === 'ORANGE_MONEY') return initOrangeMoney(req);
if (provider === 'WAVE') return initWave(req);
throw new Error(`Unsupported provider: ${provider}`);
}
// Parse and normalize webhooks from Orange Money and Wave into a unified format
export function parseWebhook(provider: PaymentProvider, body: Record<string, unknown>): WebhookPayload {
if (provider === 'ORANGE_MONEY') {
const statusMap: Record<string, WebhookPayload['status']> = {
SUCCESS: 'SUCCESS',
FAILED: 'FAILED',
INITIATED: 'PENDING',
};
return {
provider,
reference: String(body.order_id || body.reference || ''),
transactionId: String(body.txnid || ''),
amount: Number(body.amount || 0),
status: statusMap[String(body.status)] ?? 'FAILED',
};
}
if (provider === 'WAVE') {
return {
provider,
reference: String(body.client_reference || ''),
transactionId: String(body.id || ''),
amount: Number(body.amount || 0),
status: body.payment_status === 'succeeded' ? 'SUCCESS' : 'FAILED',
};
}
throw new Error(`Unknown provider: ${provider}`);
}