|
|
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' }); |
|
|
} |
|
|
|
|
|
|
|
|
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)}`; |
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
}); |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async verifyTransaction(req: Request, res: Response) { |
|
|
try { |
|
|
const { reference } = req.params; |
|
|
|
|
|
if (!reference) { |
|
|
return res.status(400).json({ error: 'Payment reference is required' }); |
|
|
} |
|
|
|
|
|
|
|
|
const verification = await getPaystackService().verifyTransaction(reference); |
|
|
|
|
|
|
|
|
if (verification.status !== 'success') { |
|
|
return res.status(400).json({ |
|
|
error: 'Payment verification failed', |
|
|
status: verification.status, |
|
|
gatewayResponse: verification.gatewayResponse, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
const userId = verification.metadata?.userId; |
|
|
if (!userId) { |
|
|
return res.status(400).json({ error: 'Missing userId in payment metadata' }); |
|
|
} |
|
|
|
|
|
|
|
|
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), |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05'); |
|
|
const amountInNaira = verification.amount / 100; |
|
|
const fx = await exchangeRateService.getNGNToUSDRate(); |
|
|
const amountInDollars = amountInNaira / fx; |
|
|
const points = Math.round(amountInDollars / pointValueUsd); |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'); |
|
|
} |
|
|
|
|
|
|
|
|
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; |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
|
|
|
|
|
|
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05'); |
|
|
const amountInNaira = amount / 100; |
|
|
const fx = await exchangeRateService.getNGNToUSDRate(); |
|
|
const amountInDollars = amountInNaira / fx; |
|
|
const points = Math.round(amountInDollars / pointValueUsd); |
|
|
|
|
|
|
|
|
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 { |
|
|
|
|
|
console.log('Paystack webhook: Unhandled event', { |
|
|
event: event.event, |
|
|
reference: event.data?.reference, |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
res.json({ received: true }); |
|
|
} catch (error: any) { |
|
|
console.error('Payment verification error:', error); |
|
|
res.status(500).json({ error: error.message || 'Payment verification failed' }); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async paymentCallback(req: Request, res: Response) { |
|
|
try { |
|
|
|
|
|
const reference = (req.query.reference || req.query.trxref) as string; |
|
|
|
|
|
if (!reference) { |
|
|
|
|
|
|
|
|
const frontendUrl = process.env.FRONTEND_URL || 'https://zurri-mock-frontend.vercel.app'; |
|
|
return res.redirect(`${frontendUrl}/payment/failed?error=missing_reference`); |
|
|
} |
|
|
|
|
|
|
|
|
try { |
|
|
const verification = await getPaystackService().verifyTransaction(reference); |
|
|
|
|
|
|
|
|
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')}` |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
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}` |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
const existingTx = await walletService.getTransactionByReference(reference); |
|
|
if (existingTx && existingTx.status === TransactionStatus.COMPLETED) { |
|
|
|
|
|
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)}` |
|
|
); |
|
|
} |
|
|
|
|
|
|
|
|
const pointValueUsd = parseFloat(process.env.POINT_VALUE_USD || '0.05'); |
|
|
const amountInNaira = verification.amount / 100; |
|
|
const fx = await exchangeRateService.getNGNToUSDRate(); |
|
|
const amountInDollars = amountInNaira / fx; |
|
|
const points = Math.round(amountInDollars / pointValueUsd); |
|
|
|
|
|
|
|
|
const transaction = await walletService.addPoints(userId, points, reference, { |
|
|
paystackVerification: verification, |
|
|
amountInNaira, |
|
|
amountInDollars, |
|
|
fx, |
|
|
pointValueUsd, |
|
|
}); |
|
|
|
|
|
const wallet = await walletService.getOrCreateWallet(userId); |
|
|
|
|
|
|
|
|
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')}`); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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' }); |
|
|
} |
|
|
} |
|
|
} |
|
|
|