Download src/controllers/walletController.ts from nexusbert/zurri: direct link, hf CLI and curl.
- Browser
- Download file 15.1 kB
-
https://huggingface.co/spaces/nexusbert/zurri/resolve/main/src/controllers/walletController.ts
- Command line
-
hf download hf://spaces/nexusbert/zurri/src/controllers/walletController.ts
-
curl -L -o walletController.ts https://huggingface.co/spaces/nexusbert/zurri/resolve/main/src/controllers/walletController.ts
15.1 kB
| 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' }); | |
| } | |
| } | |
| } | |