diff --git a/backend/src/service/wallet_service.rs b/backend/src/service/wallet_service.rs index 00efdda..d036b2d 100644 --- a/backend/src/service/wallet_service.rs +++ b/backend/src/service/wallet_service.rs @@ -4,9 +4,11 @@ use crate::models::{ use anyhow::Result; use chrono::Utc; // EventBus is used via crate::realtime::event_bus::EventBus +use reqwest::{Client, Url}; use rust_decimal::Decimal; +use serde::{de::DeserializeOwned, Deserialize}; use sqlx::PgPool; -use std::sync::Arc; +use std::{env, sync::Arc, time::Duration}; use thiserror::Error; use uuid::Uuid; @@ -30,6 +32,50 @@ pub enum WalletError { pub type DbPool = Arc; +const PAYSTACK_BASE_URL: &str = "https://api.paystack.co"; +const FLUTTERWAVE_BASE_URL: &str = "https://api.flutterwave.com/v3"; +const PAYMENT_PROVIDER_TIMEOUT_SECS: u64 = 5; + +#[derive(Debug, Deserialize)] +struct PaystackVerificationResponse { + status: bool, + message: Option, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct PaystackTransactionData { + status: String, + reference: Option, + amount: i64, + currency: Option, + paid_at: Option, + gateway_response: Option, + refund_status: Option, + amount_refunded: Option, +} + +#[derive(Debug, Deserialize)] +struct FlutterwaveVerificationResponse { + status: String, + message: Option, + data: Option, +} + +#[derive(Debug, Deserialize)] +struct FlutterwaveTransactionData { + tx_ref: Option, + flw_ref: Option, + amount: Option, + charged_amount: Option, + currency: Option, + status: Option, + created_at: Option, + charge_response_message: Option, + refund_status: Option, + amount_refunded: Option, +} + #[derive(Clone)] pub struct WalletService { db_pool: DbPool, @@ -436,36 +482,154 @@ impl WalletService { // PAYMENT VERIFICATION // ======================================================================== + fn paystack_base_url() -> String { + env::var("PAYSTACK_BASE_URL").unwrap_or_else(|_| PAYSTACK_BASE_URL.to_string()) + } + + fn flutterwave_base_url() -> String { + env::var("FLUTTERWAVE_BASE_URL").unwrap_or_else(|_| FLUTTERWAVE_BASE_URL.to_string()) + } + + fn validate_paystack_reference(reference: &str) -> Result<(), WalletError> { + let sanitized = reference.trim(); + if sanitized.is_empty() { + return Err(WalletError::PaymentVerificationFailed); + } + if !sanitized + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | '/')) + { + return Err(WalletError::PaymentVerificationFailed); + } + Ok(()) + } + + fn validate_flutterwave_reference(reference: &str) -> Result<(), WalletError> { + let sanitized = reference.trim(); + if sanitized.is_empty() || !sanitized.chars().all(|c| c.is_ascii_digit()) { + return Err(WalletError::PaymentVerificationFailed); + } + Ok(()) + } + + async fn fetch_provider_json(&self, url: Url, secret_key: &str) -> Result + where + T: DeserializeOwned, + { + let client = Client::builder() + .timeout(Duration::from_secs(PAYMENT_PROVIDER_TIMEOUT_SECS)) + .build() + .map_err(|_| WalletError::PaymentVerificationFailed)?; + + let response = client + .get(url) + .bearer_auth(secret_key) + .header("Accept", "application/json") + .send() + .await + .map_err(|err| { + tracing::warn!("Payment provider request failed", ?err); + WalletError::PaymentVerificationFailed + })?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::warn!( + "Payment provider returned non-success status", + ?status, + body = body.as_str() + ); + return Err(WalletError::PaymentVerificationFailed); + } + + response.json::().await.map_err(|err| { + tracing::warn!("Failed to parse payment provider response", ?err); + WalletError::PaymentVerificationFailed + }) + } + /// Verify payment with Paystack pub async fn verify_paystack_payment( &self, reference: &str, expected_amount: i64, ) -> Result { - // TODO: Implement actual Paystack API call - // For now, this is a placeholder + Self::validate_paystack_reference(reference)?; - // let client = reqwest::Client::new(); - // let paystack_secret = std::env::var("PAYSTACK_SECRET_KEY") - // .expect("PAYSTACK_SECRET_KEY must be set"); + let secret = env::var("PAYSTACK_SECRET") + .map_err(|_| WalletError::PaymentVerificationFailed)?; + let base_url = Self::paystack_base_url(); - // let response = client - // .get(&format!("https://api.paystack.co/transaction/verify/{}", reference)) - // .header("Authorization", format!("Bearer {}", paystack_secret)) - // .send() - // .await - // .map_err(|e| WalletError::PaymentVerificationFailed)?; + let mut url = Url::parse(&base_url).map_err(|_| WalletError::PaymentVerificationFailed)?; + url.path_segments_mut() + .map_err(|_| WalletError::PaymentVerificationFailed)? + .pop_if_empty() + .push("transaction") + .push("verify") + .push(reference); - // if !response.status().is_success() { - // return Err(WalletError::PaymentVerificationFailed); - // } + tracing::info!("Verifying Paystack transaction", reference = reference); - // let data: PaystackResponse = response.json().await - // .map_err(|e| WalletError::PaymentVerificationFailed)?; + let response: PaystackVerificationResponse = self + .fetch_provider_json(url, &secret) + .await?; - // Ok(data.data.status == "success" && data.data.amount == expected_amount) + if !response.status { + tracing::warn!( + "Paystack verification response reported failure", + message = response.message.as_deref() + ); + return Ok(false); + } + + let data = match response.data { + Some(data) => data, + None => { + tracing::warn!("Paystack verification response missing data"); + return Ok(false); + } + }; + + let tx_status = data.status.to_lowercase(); + if tx_status != "success" { + tracing::warn!("Paystack transaction status invalid", status = %tx_status); + return Ok(false); + } + + if data.amount != expected_amount { + tracing::warn!( + "Paystack amount mismatch", + amount = data.amount, + expected_amount = expected_amount + ); + return Ok(false); + } + + if let Some(refund_status) = data.refund_status.as_deref() { + let refund_status = refund_status.to_lowercase(); + if refund_status.contains("refund") || refund_status.contains("reverse") { + tracing::warn!("Paystack transaction refunded or reversed", refund_status = %refund_status); + return Ok(false); + } + } + + if data.amount_refunded.unwrap_or(0) > 0 { + tracing::warn!("Paystack transaction has refunded amount", refunded = data.amount_refunded.unwrap_or(0)); + return Ok(false); + } + + if let Some(gateway_response) = data.gateway_response.as_deref() { + let gateway_response = gateway_response.to_lowercase(); + if gateway_response.contains("failed") || gateway_response.contains("declined") { + tracing::warn!( + "Paystack gateway response indicates failure", + gateway_response = %gateway_response + ); + return Ok(false); + } + } - tracing::warn!("Paystack verification not implemented, returning true for testing"); Ok(true) } @@ -475,8 +639,95 @@ impl WalletService { transaction_id: &str, expected_amount: i64, ) -> Result { - // TODO: Implement actual Flutterwave API call - tracing::warn!("Flutterwave verification not implemented, returning true for testing"); + Self::validate_flutterwave_reference(transaction_id)?; + + let secret = env::var("FLUTTERWAVE_SECRET") + .map_err(|_| WalletError::PaymentVerificationFailed)?; + let base_url = Self::flutterwave_base_url(); + + let mut url = Url::parse(&base_url).map_err(|_| WalletError::PaymentVerificationFailed)?; + url.path_segments_mut() + .map_err(|_| WalletError::PaymentVerificationFailed)? + .pop_if_empty() + .push("transactions") + .push(transaction_id) + .push("verify"); + + tracing::info!("Verifying Flutterwave transaction", transaction_id = transaction_id); + + let response: FlutterwaveVerificationResponse = self + .fetch_provider_json(url, &secret) + .await?; + + if response.status.to_lowercase() != "success" { + tracing::warn!( + "Flutterwave verification response reported failure", + status = response.status.as_str(), + message = response.message.as_deref() + ); + return Ok(false); + } + + let data = match response.data { + Some(data) => data, + None => { + tracing::warn!("Flutterwave verification response missing data"); + return Ok(false); + } + }; + + let tx_status = data.status.unwrap_or_default().to_lowercase(); + if tx_status != "successful" { + tracing::warn!("Flutterwave transaction status invalid", status = %tx_status); + return Ok(false); + } + + let amount = data + .amount + .or(data.charged_amount) + .unwrap_or_default(); + let amount_kobo = (amount * 100.0).round() as i64; + + if amount_kobo != expected_amount { + tracing::warn!( + "Flutterwave amount mismatch", + amount_kobo = amount_kobo, + expected_amount = expected_amount + ); + return Ok(false); + } + + if let Some(currency) = data.currency.as_deref() { + if currency.to_uppercase() != "NGN" { + tracing::warn!("Flutterwave currency mismatch", currency = currency); + return Ok(false); + } + } + + if let Some(refund_status) = data.refund_status.as_deref() { + let refund_status = refund_status.to_lowercase(); + if refund_status.contains("refund") || refund_status.contains("reverse") { + tracing::warn!("Flutterwave transaction refunded or reversed", refund_status = %refund_status); + return Ok(false); + } + } + + if data.amount_refunded.unwrap_or(0.0) > 0.0 { + tracing::warn!("Flutterwave transaction has refunded amount", refunded = data.amount_refunded.unwrap_or(0.0)); + return Ok(false); + } + + if let Some(gateway_message) = data.charge_response_message.as_deref() { + let gateway_message = gateway_message.to_lowercase(); + if gateway_message.contains("failed") || gateway_message.contains("declined") { + tracing::warn!( + "Flutterwave gateway response indicates failure", + gateway_message = %gateway_message + ); + return Ok(false); + } + } + Ok(true) } diff --git a/server/src/controllers/tournament.controller.ts b/server/src/controllers/tournament.controller.ts index f72202b..c990774 100644 --- a/server/src/controllers/tournament.controller.ts +++ b/server/src/controllers/tournament.controller.ts @@ -151,7 +151,14 @@ export class TournamentController { return; } - const registration = await tournamentService.registerPlayer(id, playerId); + const { paymentProvider, paymentReference } = req.body; + + const payment = + paymentProvider && paymentReference + ? { provider: paymentProvider as 'paystack' | 'flutterwave', reference: String(paymentReference) } + : undefined; + + const registration = await tournamentService.registerPlayer(id, playerId, payment); res.status(201).json({ success: true, diff --git a/server/src/services/payment-verification.service.ts b/server/src/services/payment-verification.service.ts new file mode 100644 index 0000000..443f998 --- /dev/null +++ b/server/src/services/payment-verification.service.ts @@ -0,0 +1,319 @@ +/** + * Payment Verification Service + * + * Verifies payment transactions against Paystack and Flutterwave provider APIs. + * All verification is performed server-side — client-supplied payment data is + * never trusted for final confirmation. + * + * Supported providers: + * - paystack → GET https://api.paystack.co/transaction/verify/:reference + * - flutterwave → GET https://api.flutterwave.com/v3/transactions/:id/verify + */ + +import { logger } from './logger.service'; +import { HttpError } from '../utils/http-error'; +import { getEnv } from '../config/env'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type PaymentProvider = 'paystack' | 'flutterwave'; + +export interface VerifyPaymentParams { + provider: PaymentProvider; + /** Provider-specific transaction reference (Paystack) or numeric ID (Flutterwave). */ + reference: string; + /** Expected amount in the smallest currency unit (kobo for NGN). */ + expectedAmountKobo: number; +} + +export interface PaymentVerificationResult { + verified: boolean; + provider: PaymentProvider; + reference: string; + /** Amount charged, in kobo. */ + amountKobo: number; + currency: string; + status: string; + /** ISO-8601 payment date from provider. */ + paidAt: string | null; + /** Raw provider response for audit storage — stripped of sensitive keys. */ + providerData: Record; +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const PAYSTACK_BASE = 'https://api.paystack.co'; +const FLUTTERWAVE_BASE = 'https://api.flutterwave.com/v3'; + +/** Perform a GET request with Bearer auth and a 10-second timeout. */ +async function authorisedGet(url: string, secretKey: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10_000); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Authorization: `Bearer ${secretKey}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + }); + + const body = await response.json() as unknown; + + if (!response.ok) { + const msg = (body as any)?.message ?? `HTTP ${response.status}`; + throw new HttpError(response.status >= 500 ? 502 : 400, `Provider error: ${msg}`); + } + + return body; + } catch (err: any) { + if (err?.name === 'AbortError') { + throw new HttpError(504, 'Payment provider request timed out'); + } + if (err instanceof HttpError) throw err; + throw new HttpError(502, 'Unable to reach payment provider'); + } finally { + clearTimeout(timer); + } +} + +// --------------------------------------------------------------------------- +// Paystack +// --------------------------------------------------------------------------- + +/** Verify a Paystack transaction reference. */ +async function verifyPaystack( + reference: string, + expectedAmountKobo: number, +): Promise { + const env = getEnv(); + if (!env.PAYSTACK_SECRET_KEY) { + throw new HttpError(500, 'Paystack is not configured'); + } + + // Sanitise: Paystack references must not contain path-traversal characters + if (!/^[A-Za-z0-9_\-./]+$/.test(reference)) { + throw new HttpError(400, 'Invalid payment reference format'); + } + + const url = `${PAYSTACK_BASE}/transaction/verify/${encodeURIComponent(reference)}`; + + logger.info('Verifying Paystack transaction', { reference }); + + const body = await authorisedGet(url, env.PAYSTACK_SECRET_KEY) as any; + + if (!body?.status || body.status !== true) { + throw new HttpError(400, body?.message ?? 'Paystack verification failed'); + } + + const data = body.data ?? {}; + const txStatus: string = (data.status ?? '').toLowerCase(); + const amountKobo: number = typeof data.amount === 'number' ? data.amount : 0; + const currency: string = data.currency ?? 'NGN'; + const paidAt: string | null = data.paid_at ?? null; + + // Build safe audit record — omit the raw secret key / authorization object + const providerData: Record = { + id: data.id, + reference: data.reference, + status: data.status, + amount: data.amount, + currency: data.currency, + paid_at: data.paid_at, + gateway_response: data.gateway_response, + channel: data.channel, + ip_address: data.ip_address, + }; + + logger.info('Paystack verification result', { + reference, + txStatus, + amountKobo, + currency, + expectedAmountKobo, + }); + + // --- Validation checks --- + + if (txStatus !== 'success') { + return { + verified: false, + provider: 'paystack', + reference, + amountKobo, + currency, + status: txStatus, + paidAt, + providerData, + }; + } + + if (amountKobo !== expectedAmountKobo) { + logger.warn('Paystack amount mismatch', { reference, amountKobo, expectedAmountKobo }); + return { + verified: false, + provider: 'paystack', + reference, + amountKobo, + currency, + status: 'amount_mismatch', + paidAt, + providerData, + }; + } + + return { + verified: true, + provider: 'paystack', + reference, + amountKobo, + currency, + status: txStatus, + paidAt, + providerData, + }; +} + +// --------------------------------------------------------------------------- +// Flutterwave +// --------------------------------------------------------------------------- + +/** Verify a Flutterwave transaction by numeric ID. */ +async function verifyFlutterwave( + reference: string, + expectedAmountKobo: number, +): Promise { + const env = getEnv(); + if (!env.FLUTTERWAVE_SECRET_KEY) { + throw new HttpError(500, 'Flutterwave is not configured'); + } + + // Flutterwave uses numeric transaction IDs + if (!/^\d+$/.test(reference)) { + throw new HttpError(400, 'Invalid Flutterwave transaction ID — must be numeric'); + } + + const url = `${FLUTTERWAVE_BASE}/transactions/${encodeURIComponent(reference)}/verify`; + + logger.info('Verifying Flutterwave transaction', { reference }); + + const body = await authorisedGet(url, env.FLUTTERWAVE_SECRET_KEY) as any; + + if (body?.status !== 'success') { + throw new HttpError(400, body?.message ?? 'Flutterwave verification failed'); + } + + const data = body.data ?? {}; + const txStatus: string = (data.status ?? '').toLowerCase(); + // Flutterwave returns the amount in major units (NGN), convert to kobo + const amountNgn: number = typeof data.amount === 'number' ? data.amount : 0; + const amountKobo: number = Math.round(amountNgn * 100); + const currency: string = data.currency ?? 'NGN'; + const paidAt: string | null = data.created_at ?? null; + + const providerData: Record = { + id: data.id, + tx_ref: data.tx_ref, + flw_ref: data.flw_ref, + status: data.status, + amount: data.amount, + charged_amount: data.charged_amount, + currency: data.currency, + created_at: data.created_at, + payment_type: data.payment_type, + ip: data.ip, + }; + + logger.info('Flutterwave verification result', { + reference, + txStatus, + amountKobo, + currency, + expectedAmountKobo, + }); + + // --- Validation checks --- + + if (txStatus !== 'successful') { + return { + verified: false, + provider: 'flutterwave', + reference, + amountKobo, + currency, + status: txStatus, + paidAt, + providerData, + }; + } + + if (amountKobo !== expectedAmountKobo) { + logger.warn('Flutterwave amount mismatch', { reference, amountKobo, expectedAmountKobo }); + return { + verified: false, + provider: 'flutterwave', + reference, + amountKobo, + currency, + status: 'amount_mismatch', + paidAt, + providerData, + }; + } + + return { + verified: true, + provider: 'flutterwave', + reference, + amountKobo, + currency, + status: txStatus, + paidAt, + providerData, + }; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Verify a payment with the specified provider. + * + * Throws HttpError on: + * - unknown provider + * - provider not configured + * - invalid reference format + * - network/timeout errors + * - non-2xx responses from the provider API + * + * Returns a result with `verified: false` (rather than throwing) when the + * transaction exists but fails business validation (wrong status, wrong amount). + */ +export async function verifyPayment( + params: VerifyPaymentParams, +): Promise { + const { provider, reference, expectedAmountKobo } = params; + + if (!reference || reference.trim() === '') { + throw new HttpError(400, 'Payment reference is required'); + } + if (!Number.isInteger(expectedAmountKobo) || expectedAmountKobo <= 0) { + throw new HttpError(400, 'Expected payment amount must be a positive integer (kobo)'); + } + + switch (provider) { + case 'paystack': + return verifyPaystack(reference.trim(), expectedAmountKobo); + case 'flutterwave': + return verifyFlutterwave(reference.trim(), expectedAmountKobo); + default: + throw new HttpError(400, `Unknown payment provider: ${provider}`); + } +} diff --git a/server/src/services/tournament.service.ts b/server/src/services/tournament.service.ts index 939c412..65f4930 100644 --- a/server/src/services/tournament.service.ts +++ b/server/src/services/tournament.service.ts @@ -1,5 +1,7 @@ import { PrismaClient, TournamentFormat, TournamentStatus, TournamentRound } from '@prisma/client'; import { logger } from './logger.service'; +import { verifyPayment, PaymentProvider } from './payment-verification.service'; +import { HttpError } from '../utils/http-error'; const prisma = new PrismaClient(); @@ -96,9 +98,22 @@ export class TournamentService { } /** - * Register a player for a tournament + * Register a player for a tournament. + * + * For paid tournaments the caller must supply `paymentProvider` and + * `paymentReference`. The reference is verified server-side against the + * provider API before registration is confirmed. Client-supplied amounts + * are never trusted — the expected amount is always read from the tournament + * record. */ - async registerPlayer(tournamentId: string, playerId: string) { + async registerPlayer( + tournamentId: string, + playerId: string, + payment?: { + provider: PaymentProvider; + reference: string; + }, + ) { try { // Get tournament const tournament = await prisma.tournament.findUnique({ @@ -112,24 +127,93 @@ export class TournamentService { }); if (!tournament) { - throw new Error('Tournament not found'); + throw new HttpError(404, 'Tournament not found'); } // Check if registration is open const now = new Date(); if (now < tournament.registrationStart) { - throw new Error('Registration has not started yet'); + throw new HttpError(400, 'Registration has not started yet'); } if (now > tournament.registrationEnd) { - throw new Error('Registration is closed'); + throw new HttpError(400, 'Registration is closed'); } // Check if already registered if (tournament.registrations.length > 0) { - throw new Error('Already registered for this tournament'); + throw new HttpError(409, 'Already registered for this tournament'); } - // Check capacity + // ── Payment verification ────────────────────────────────────────────── + + let paymentStatus = 'PAID'; + let paymentTxHash: string | null = null; + let paymentMetadata: Record = {}; + + if (tournament.entryFee) { + if (!payment?.provider || !payment?.reference) { + throw new HttpError(400, 'Payment provider and reference are required for paid tournaments'); + } + + // Convert entry fee (stored as NGN decimal) to kobo for comparison. + // entryFee is stored as a Decimal; multiply by 100 and round to kobo. + const expectedAmountKobo = Math.round(Number(tournament.entryFee) * 100); + + // Check for duplicate reference (prevent replay attacks) + const existingPayment = await prisma.tournamentRegistration.findFirst({ + where: { + paymentTxHash: payment.reference, + paymentStatus: 'PAID', + }, + }); + + if (existingPayment) { + throw new HttpError(409, 'Payment reference has already been used'); + } + + const result = await verifyPayment({ + provider: payment.provider, + reference: payment.reference, + expectedAmountKobo, + }); + + if (!result.verified) { + logger.warn('Payment verification failed', { + tournamentId, + playerId, + provider: payment.provider, + reference: payment.reference, + status: result.status, + amountKobo: result.amountKobo, + expectedAmountKobo, + }); + + const statusMessages: Record = { + amount_mismatch: `Payment amount does not match the tournament entry fee`, + failed: 'Payment was not successful', + abandoned: 'Payment was abandoned', + reversed: 'Payment has been reversed', + refunded: 'Payment has been refunded', + }; + + const msg = statusMessages[result.status] ?? `Payment verification failed (status: ${result.status})`; + throw new HttpError(402, msg); + } + + paymentStatus = 'PAID'; + paymentTxHash = payment.reference; + paymentMetadata = { + provider: result.provider, + verifiedAt: new Date().toISOString(), + currency: result.currency, + amountKobo: result.amountKobo, + paidAt: result.paidAt, + providerData: result.providerData, + }; + } + + // ── Check capacity ─────────────────────────────────────────────────── + const participantCount = tournament.participants.filter( (p) => p.status !== 'DISQUALIFIED' ).length; @@ -145,12 +229,15 @@ export class TournamentService { waitlistPosition: isFull ? (await this.getNextWaitlistPosition(tournamentId)) : null, - paymentStatus: tournament.entryFee ? 'PENDING' : 'PAID', + paymentStatus: tournament.entryFee ? paymentStatus : 'PAID', + paymentTxHash, + metadata: paymentMetadata, + confirmedAt: tournament.entryFee && !isFull ? new Date() : null, }, }); - // If not full and no entry fee, create participant directly - if (!isFull && !tournament.entryFee) { + // If not full and no entry fee (or payment confirmed), create participant directly + if (!isFull && (!tournament.entryFee || paymentStatus === 'PAID')) { await this.createParticipant(tournamentId, playerId, registration.id); } @@ -159,6 +246,7 @@ export class TournamentService { playerId, status: registration.status, isWaitlist: isFull, + paymentVerified: !!tournament.entryFee, }); return registration;