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' }); } } }