zurri / src /controllers /walletController.ts
nexusbert's picture
push
b494470
import { Request, Response } from 'express';
import { WalletService } from '../services/walletService';
import { PaystackService } from '../services/paystackService';
import { ExchangeRateService } from '../services/exchangeRateService';
import { AppDataSource } from '../config/database';
import { User } from '../entities/User';
import { TransactionStatus } from '../entities/Transaction';
import crypto from 'crypto';
const walletService = new WalletService();
let paystackService: PaystackService | null = null;
const getPaystackService = () => {
if (!paystackService) {
paystackService = new PaystackService();
}
return paystackService;
};
const exchangeRateService = new ExchangeRateService();
const userRepository = AppDataSource.getRepository(User);
export class WalletController {
async getWallet(req: Request, res: Response) {
try {
const userId = (req as any).user.id;
const wallet = await walletService.getOrCreateWallet(userId);
const freeTasksRemaining = await walletService.getFreeTasksRemaining(userId);
res.json({
wallet: {
id: wallet.id,
balance: Number(wallet.balance),
balanceInDollars: walletService.pointsToDollars(Number(wallet.balance)),
freeTasksRemaining,
},
});
} catch (error) {
console.error('Get wallet error:', error);
res.status(500).json({ error: 'Failed to get wallet' });
}
}
async fundWallet(req: Request, res: Response) {
try {
const userId = (req as any).user.id;
const { amount } = req.body;
if (!amount || amount <= 0) {
return res.status(400).json({ error: 'Invalid amount. Must be greater than 0' });
}
// Get user email
const user = await userRepository.findOne({ where: { id: userId } });
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05');
const fx = await exchangeRateService.getNGNToUSDRate();
const amountInDollars = amount / fx;
const points = Math.round(amountInDollars / pointValueUsd);
const paymentReference = `wallet_${userId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Paystack callback should point to backend endpoint, which then redirects to frontend
// Use environment variable or detect hosted URL
const baseUrl = process.env.API_BASE_URL || process.env.BACKEND_URL || 'https://nexusbert-zurri.hf.space';
const backendUrl = process.env.PAYSTACK_CALLBACK_URL
|| `${baseUrl}/api/wallet/callback`;
const paymentData = await getPaystackService().initializeTransaction({
email: user.email,
amount: Math.round(amount * 100),
reference: paymentReference,
metadata: {
userId: userId,
points: points,
amountInNaira: amount,
amountInDollars: amountInDollars,
},
callback_url: backendUrl,
});
// Return payment info for frontend
res.json({
publicKey: getPaystackService().getPublicKey(),
reference: paymentData.reference,
authorization_url: paymentData.authorization_url,
access_code: paymentData.access_code,
payment: {
reference: paymentData.reference,
amount: amount,
currency: 'NGN',
points: points,
amountInDollars: amountInDollars.toFixed(2),
},
message: 'Use authorization_url to redirect or use Paystack inline widget with access_code',
});
} catch (error: any) {
console.error('Fund wallet error:', error);
res.status(500).json({ error: error.message || 'Failed to initiate wallet funding' });
}
}
/**
* Verify payment manually (for callback after payment)
* This is called by the frontend after Paystack redirects back
*/
async verifyTransaction(req: Request, res: Response) {
try {
const { reference } = req.params;
if (!reference) {
return res.status(400).json({ error: 'Payment reference is required' });
}
// Verify with Paystack
const verification = await getPaystackService().verifyTransaction(reference);
// Check if transaction was successful
if (verification.status !== 'success') {
return res.status(400).json({
error: 'Payment verification failed',
status: verification.status,
gatewayResponse: verification.gatewayResponse,
});
}
// Extract userId from metadata
const userId = verification.metadata?.userId;
if (!userId) {
return res.status(400).json({ error: 'Missing userId in payment metadata' });
}
// Check if already processed (idempotency)
const existingTx = await walletService.getTransactionByReference(reference);
if (existingTx && existingTx.status === TransactionStatus.COMPLETED) {
return res.json({
success: true,
message: 'Payment already processed',
transaction: existingTx,
wallet: await walletService.getOrCreateWallet(userId),
});
}
// Calculate points from amount using current exchange rate
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05');
const amountInNaira = verification.amount / 100; // amount is in kobo
const fx = await exchangeRateService.getNGNToUSDRate(); // Dynamic rate
const amountInDollars = amountInNaira / fx;
const points = Math.round(amountInDollars / pointValueUsd);
// Add points to wallet
const transaction = await walletService.addPoints(userId, points, reference, {
paystackVerification: verification,
amountInNaira,
amountInDollars,
fx,
pointValueUsd,
});
const wallet = await walletService.getOrCreateWallet(userId);
res.json({
success: true,
message: 'Payment verified and points added',
transaction: {
id: transaction.id,
type: transaction.type,
amount: Number(transaction.amount),
balanceAfter: Number(transaction.balanceAfter),
},
wallet: {
balance: Number(wallet.balance),
balanceInDollars: walletService.pointsToDollars(Number(wallet.balance)),
},
});
} catch (error: any) {
console.error('Verify transaction error:', error);
res.status(500).json({ error: error.message || 'Payment verification failed' });
}
}
/**
* Verify payment and add points (Paystack webhook)
* This endpoint is called by Paystack when a payment event occurs
* Webhook URL: https://yourdomain.com/api/wallet/webhook/paystack
*/
async verifyPayment(req: Request, res: Response) {
try {
const secret = process.env.PAYSTACK_SECRET_KEY;
if (!secret) {
console.error('Paystack webhook: Secret key not configured');
throw new Error('Paystack secret not configured');
}
// Verify webhook signature (HMAC SHA-512)
const hash = crypto
.createHmac('sha512', secret)
.update(JSON.stringify(req.body))
.digest('hex');
const signature = req.headers['x-paystack-signature'] as string;
if (!signature || hash !== signature) {
console.error('Paystack webhook: Invalid signature');
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.body;
console.log('Paystack webhook received:', {
event: event.event,
reference: event.data?.reference,
timestamp: new Date().toISOString(),
});
if (event.event === 'charge.success') {
const { reference, amount, metadata, customer } = event.data;
// Prefer userId in metadata
const userId = metadata?.userId;
if (!userId) {
console.error('Paystack webhook: Missing userId in metadata', {
reference,
metadata,
});
return res.status(400).json({ error: 'Missing userId in metadata' });
}
// Verify with Paystack API (server-to-server verification)
try {
const verification = await getPaystackService().verifyTransaction(reference);
if (verification.status !== 'success') {
console.error('Paystack verification failed:', verification);
return res.status(400).json({ error: 'Transaction verification failed' });
}
} catch (verifyError) {
console.error('Paystack API verification error:', verifyError);
return res.status(500).json({ error: 'Failed to verify with Paystack' });
}
// Calculate points from amount using current exchange rate
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05');
const amountInNaira = amount / 100;
const fx = await exchangeRateService.getNGNToUSDRate(); // Dynamic rate
const amountInDollars = amountInNaira / fx;
const points = Math.round(amountInDollars / pointValueUsd);
// Add points to wallet (idempotent - checks for existing transaction)
const transaction = await walletService.addPoints(userId, points, reference, {
paystackEvent: event.event,
amountInNaira,
amountInDollars,
fx,
pointValueUsd,
});
console.log('Paystack webhook: Payment processed successfully', {
reference,
userId,
points,
transactionId: transaction.id,
});
} else {
// Log other events for monitoring
console.log('Paystack webhook: Unhandled event', {
event: event.event,
reference: event.data?.reference,
});
}
// Always return success to Paystack (to prevent retries for processed events)
res.json({ received: true });
} catch (error: any) {
console.error('Payment verification error:', error);
res.status(500).json({ error: error.message || 'Payment verification failed' });
}
}
/**
* Payment callback handler (public endpoint for Paystack redirect)
* This handles the redirect from Paystack after payment
*/
async paymentCallback(req: Request, res: Response) {
try {
// Paystack sends reference as 'reference' or 'trxref' query param
const reference = (req.query.reference || req.query.trxref) as string;
if (!reference) {
// Redirect to frontend error page
// Default to Vercel frontend if not set
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(`${frontendUrl}/payment/failed?error=missing_reference`);
}
// Verify with Paystack
try {
const verification = await getPaystackService().verifyTransaction(reference);
// Check if transaction was successful
if (verification.status !== 'success') {
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(
`${frontendUrl}/payment/failed?reference=${reference}&status=${verification.status}&message=${encodeURIComponent(verification.gatewayResponse || 'Payment failed')}`
);
}
// Extract userId from metadata
const userId = verification.metadata?.userId;
if (!userId) {
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(
`${frontendUrl}/payment/failed?error=missing_user_id&reference=${reference}`
);
}
// Check if already processed (idempotency)
const existingTx = await walletService.getTransactionByReference(reference);
if (existingTx && existingTx.status === TransactionStatus.COMPLETED) {
// Already processed, redirect to success
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
const wallet = await walletService.getOrCreateWallet(userId);
return res.redirect(
`${frontendUrl}/payment/success?reference=${reference}&already_processed=true&balance=${Number(wallet.balance)}`
);
}
// Calculate points from amount using current exchange rate
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05');
const amountInNaira = verification.amount / 100; // amount is in kobo
const fx = await exchangeRateService.getNGNToUSDRate(); // Dynamic rate
const amountInDollars = amountInNaira / fx;
const points = Math.round(amountInDollars / pointValueUsd);
// Add points to wallet
const transaction = await walletService.addPoints(userId, points, reference, {
paystackVerification: verification,
amountInNaira,
amountInDollars,
fx,
pointValueUsd,
});
const wallet = await walletService.getOrCreateWallet(userId);
// Redirect to frontend success page with details
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(
`${frontendUrl}/payment/success?reference=${reference}&points=${points}&balance=${Number(wallet.balance)}&amount=${amountInNaira}`
);
} catch (verifyError: any) {
console.error('Payment verification error in callback:', verifyError);
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(
`${frontendUrl}/payment/failed?reference=${reference}&error=${encodeURIComponent(verifyError.message || 'Verification failed')}`
);
}
} catch (error: any) {
console.error('Payment callback error:', error);
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app';
return res.redirect(`${frontendUrl}/payment/failed?error=${encodeURIComponent(error.message || 'Unknown error')}`);
}
}
/**
* Get transaction history
*/
async getTransactions(req: Request, res: Response) {
try {
const userId = (req as any).user.id;
const { limit } = req.query;
const transactions = await walletService.getTransactionHistory(
userId,
limit ? Number(limit) : 50
);
res.json({
transactions: transactions.map((t) => ({
id: t.id,
type: t.type,
status: t.status,
amount: Number(t.amount),
balanceAfter: Number(t.balanceAfter),
description: t.description,
agentId: t.agentId,
agent: t.agent
? {
id: t.agent.id,
name: t.agent.name,
}
: null,
createdAt: t.createdAt,
})),
});
} catch (error) {
console.error('Get transactions error:', error);
res.status(500).json({ error: 'Failed to get transactions' });
}
}
}