mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
Fix credits mode payment processing and payout tracking
- Add payout database records: processPayout now inserts into payouts table with tx_hash, fee, status, and updates user statistics - Fix credits deduction: QueueManager game start methods now properly call processGameStart() to deduct credits (was being skipped) - Fix credits_update emission: moved from GameModeManager (no io access) to socketHandlers/queueManager where io is available - Add payment recovery: users who disconnect during payment confirmation now get credits recovered automatically on reconnect - Remove dead handlePaymentDetected method that referenced undefined this.db
This commit is contained in:
+59
-13
@@ -719,9 +719,7 @@ class GameModeManager {
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'game_entry', $3, 'spend')
|
||||
`, [user.id, -creditsToSpend, remainingCredits]);
|
||||
|
||||
// Emit credits update
|
||||
try { this.io && this.io.to(socketId).emit('credits_update', { balance: remainingCredits }); } catch(_) {}
|
||||
|
||||
console.log(`🎫 Deducted ${creditsToSpend} credit(s) from user ${user.id}, ${remainingCredits} remaining`);
|
||||
|
||||
return {
|
||||
@@ -788,16 +786,64 @@ class GameModeManager {
|
||||
if (payoutEligibleMode && outcome === 'escaped' && game.payout_address) {
|
||||
const { amount: payoutAmount, multiplier } = this.calculatePayout(recordedMode, { treasureFound });
|
||||
if (payoutAmount > 0) {
|
||||
await this.walletService.processPayout({
|
||||
userId: game.user_id,
|
||||
gameId,
|
||||
address: game.payout_address,
|
||||
amount: payoutAmount,
|
||||
multiplier,
|
||||
description: `Game ${gameId} payout`
|
||||
});
|
||||
|
||||
console.log(`💸 Created payout: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
|
||||
try {
|
||||
const payoutResult = await this.walletService.processPayout({
|
||||
userId: game.user_id,
|
||||
gameId,
|
||||
address: game.payout_address,
|
||||
amount: payoutAmount,
|
||||
multiplier,
|
||||
description: `Game ${gameId} payout`
|
||||
});
|
||||
|
||||
// Record payout in database
|
||||
const reason = treasureFound ? 'escape_with_treasure' : 'escape';
|
||||
await this.db.query(`
|
||||
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, tx_hash, fee, created_at, processed_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
|
||||
`, [
|
||||
game.user_id,
|
||||
gameId,
|
||||
game.payout_address,
|
||||
payoutAmount,
|
||||
multiplier,
|
||||
reason,
|
||||
payoutResult.success ? 'completed' : 'failed',
|
||||
payoutResult.txHash || null,
|
||||
payoutResult.fee || null
|
||||
]);
|
||||
|
||||
// Update user's total_amount_won
|
||||
if (payoutResult.success) {
|
||||
await this.db.query(`
|
||||
UPDATE users
|
||||
SET total_amount_won = COALESCE(total_amount_won, 0) + $1,
|
||||
total_payouts_received = COALESCE(total_payouts_received, 0) + 1
|
||||
WHERE id = $2
|
||||
`, [payoutAmount, game.user_id]);
|
||||
}
|
||||
|
||||
console.log(`💸 Payout ${payoutResult.success ? 'sent' : 'failed'}: ${payoutAmount} atomic units for game ${gameId} (multiplier ${multiplier}x)`);
|
||||
} catch (payoutError) {
|
||||
console.error(`❌ Payout processing failed for game ${gameId}:`, payoutError.message);
|
||||
// Record failed payout attempt
|
||||
try {
|
||||
await this.db.query(`
|
||||
INSERT INTO payouts (user_id, game_id, payout_address, amount, multiplier, reason, status, error_message, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 'failed', $7, NOW())
|
||||
`, [
|
||||
game.user_id,
|
||||
gameId,
|
||||
game.payout_address,
|
||||
payoutAmount,
|
||||
multiplier,
|
||||
treasureFound ? 'escape_with_treasure' : 'escape',
|
||||
payoutError.message
|
||||
]);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to record payout error:', dbErr.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -222,17 +222,28 @@ class ConnectionHandler {
|
||||
|
||||
// If payout address already set, echo confirmation for UI convenience
|
||||
if (sessionInfo.user.payout_address) {
|
||||
this.io.to(socket.id).emit('address_confirmed', {
|
||||
address: sessionInfo.user.payout_address,
|
||||
message: 'Payout address restored.'
|
||||
this.io.to(socket.id).emit('address_confirmed', {
|
||||
address: sessionInfo.user.payout_address,
|
||||
message: 'Payout address restored.'
|
||||
});
|
||||
}
|
||||
|
||||
// Credits convenience push (include creditsPerGame for games remaining calculation)
|
||||
this.io.to(socket.id).emit('credits_update', {
|
||||
this.io.to(socket.id).emit('credits_update', {
|
||||
balance: sessionInfo.user.credits || 0,
|
||||
creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1
|
||||
});
|
||||
|
||||
// Notify user if credits were recovered from unprocessed payments
|
||||
if (sessionInfo.recovered && sessionInfo.recovered.creditsRecovered > 0) {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'success',
|
||||
`💰 Payment recovered! ${sessionInfo.recovered.creditsRecovered} credits have been added to your balance.`);
|
||||
this.io.to(socket.id).emit('credits_recovered', {
|
||||
creditsRecovered: sessionInfo.recovered.creditsRecovered,
|
||||
paymentsProcessed: sessionInfo.recovered.paymentsProcessed,
|
||||
newBalance: sessionInfo.user.credits || 0
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// fallback legacy welcome
|
||||
this.io.to(socket.client.id).emit('welcome', socket.client.id);
|
||||
|
||||
@@ -282,7 +282,7 @@ class PaymentHandlers {
|
||||
// Attempt immediate game start so user doesn't wait another full block
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight ? this.debugManager.getCurrentBlockHeight() : null;
|
||||
if (currentBlock !== null) {
|
||||
const started = this.queueManager.startGameImmediately(socket.id, currentBlock);
|
||||
const started = await this.queueManager.startGameImmediately(socket.id, currentBlock);
|
||||
if (!started && this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[PaymentHandlers] Immediate start skipped (not queued or still unconfirmed) for ${socket.id}`);
|
||||
}
|
||||
@@ -306,33 +306,6 @@ class PaymentHandlers {
|
||||
this._expiryTimeouts.set(socket.id, expiryTimeout);
|
||||
}
|
||||
|
||||
async handlePaymentDetected(socketId, paymentRequest, paymentStatus) {
|
||||
const currentUser = this.queueManager.getUserBySocket ? this.queueManager.getUserBySocket(socketId) : null;
|
||||
if (!currentUser) return;
|
||||
const existingIndex = this.queueManager.getPlayerIndex(socketId);
|
||||
if (existingIndex !== -1) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, already queued!'); return; }
|
||||
if (this.queueManager.activeGames && this.queueManager.activeGames.has(socketId)) { this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Payment confirmed, but you are already in a game!'); return; }
|
||||
this.queueManager.addPlayer({ serverId: socketId, clientId: currentUser.clientId, paymentId: paymentRequest.id, requiresConfirmation: paymentStatus.in_mempool && !paymentStatus.confirmed, confirmed: paymentStatus.confirmed });
|
||||
const currentBlock = this.debugManager.getCurrentBlockHeight();
|
||||
const nextBlock = currentBlock + 1;
|
||||
if (paymentStatus.in_mempool && !paymentStatus.confirmed) {
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT DETECTED (MEMPOOL)\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'info', 'Waiting for block confirmation...');
|
||||
} else if (paymentStatus.confirmed) {
|
||||
this.broadcastManager.sendStatusUpdate(socketId, 'success', `💰 PAYMENT CONFIRMED IN BLOCK\n\n✅ Added to queue.\n🕒 Starts at block ${nextBlock}.\n📦 Current block: ${currentBlock}`);
|
||||
}
|
||||
const newBalRes = await this.db.query('SELECT credits FROM users WHERE id = $1', [currentUser.id]);
|
||||
const remaining = newBalRes.rows[0] ? newBalRes.rows[0].credits : currentUser.credits;
|
||||
if (this.io) {
|
||||
this.io.to(socketId).emit('credits_update', { balance: remaining });
|
||||
}
|
||||
if (!this.confirmedPayments.has(paymentRequest.id)) {
|
||||
this.confirmedPayments.add(paymentRequest.id);
|
||||
this.io.to(socketId).emit('payment_confirmed', { paymentId: paymentRequest.id, status: paymentStatus, nextBlock, currentBlock });
|
||||
this._confirmedTimestamps.set(paymentRequest.id, Date.now());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop monitoring (if any) for a given socket (disconnect/expiry)
|
||||
*/
|
||||
|
||||
@@ -106,8 +106,8 @@ class QueueHandler {
|
||||
/**
|
||||
* Start games for waiting players when a new block is found
|
||||
*/
|
||||
startGamesForWaiting(blockHeight) {
|
||||
return this.queueManager.startGamesForWaiting(blockHeight);
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
return await this.queueManager.startGamesForWaiting(blockHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ class QueueManager {
|
||||
getQueueLength() { return this._waitingPlayers.length; }
|
||||
getQueuePosition(serverId) { const i = this.getPlayerIndex(serverId); return i === -1 ? -1 : i + 1; }
|
||||
|
||||
startGamesForWaiting(blockHeight) {
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] Starting games for ${this._waitingPlayers.length} waiting players at block ${blockHeight}`);
|
||||
}
|
||||
@@ -91,6 +91,22 @@ class QueueManager {
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// Process game start (credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
|
||||
if (!startRes.success) {
|
||||
// Abort game
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
|
||||
continue;
|
||||
}
|
||||
// Emit credits_update if credits were spent
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] Game started for ${serverId}`);
|
||||
} catch (error) {
|
||||
@@ -123,7 +139,7 @@ class QueueManager {
|
||||
* the block tick already processed). This prevents an additional full-block wait.
|
||||
* Returns true if a game was started.
|
||||
*/
|
||||
startGameImmediately(serverId, blockHeight) {
|
||||
async startGameImmediately(serverId, blockHeight) {
|
||||
const idx = this.getPlayerIndex(serverId);
|
||||
if (idx === -1) return false; // not queued
|
||||
const entry = this._waitingPlayers[idx];
|
||||
@@ -144,6 +160,22 @@ class QueueManager {
|
||||
if (game.getProofCommitment) {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// Process game start (credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
|
||||
if (!startRes.success) {
|
||||
// Abort game
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
this.io.to(serverId).emit('message', 'Error starting game: ' + (startRes.reason || 'Payment processing failed'));
|
||||
return false;
|
||||
}
|
||||
// Emit credits_update if credits were spent
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
if (this.CONSOLE_LOGGING) console.log(`[QueueManager] (immediate) Game started for ${serverId} at block ${blockHeight}`);
|
||||
return true;
|
||||
@@ -189,12 +221,26 @@ class QueueManager {
|
||||
gameState.proof = game.getProofCommitment();
|
||||
}
|
||||
|
||||
// Process game start (credits deduction / payment link)
|
||||
if (this.gameModeManager) {
|
||||
const startRes = await this.gameModeManager.processGameStart(serverId, game.id);
|
||||
if (!startRes.success) {
|
||||
// Abort game
|
||||
if (this.activeGames) this.activeGames.delete(serverId);
|
||||
return { success: false, reason: startRes.reason || 'Payment processing failed' };
|
||||
}
|
||||
// Emit credits_update if credits were spent
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(serverId).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(serverId).emit('game_start', gameState);
|
||||
|
||||
|
||||
if (this.CONSOLE_LOGGING) {
|
||||
console.log(`[QueueManager] ⚡ Early entry game started for ${serverId} at block ${blockHeight} (dies at ${blockHeight + 1})`);
|
||||
}
|
||||
|
||||
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
const normalized = normalizeError(err, 'Failed to start early game');
|
||||
|
||||
@@ -32,7 +32,7 @@ class SessionManager {
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const user = result.rows[0];
|
||||
|
||||
|
||||
// Update last seen and socket_id
|
||||
await this.db.query(
|
||||
'UPDATE users SET socket_id = $1, last_seen = NOW() WHERE id = $2',
|
||||
@@ -41,7 +41,18 @@ class SessionManager {
|
||||
|
||||
// Update cache
|
||||
this.sessions.set(socketId, user);
|
||||
|
||||
|
||||
// Check for unprocessed payments (payment recovery)
|
||||
const recovered = await this.recoverPendingPayments(user.id, socketId);
|
||||
if (recovered.creditsRecovered > 0) {
|
||||
// Refresh user data after recovery
|
||||
const refreshed = await this.db.query('SELECT * FROM users WHERE id = $1', [user.id]);
|
||||
if (refreshed.rows.length > 0) {
|
||||
this.sessions.set(socketId, refreshed.rows[0]);
|
||||
user.credits = refreshed.rows[0].credits;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) console.log(`[SessionManager] Resumed session for user ${user.id}`);
|
||||
return {
|
||||
resumed: true,
|
||||
@@ -49,7 +60,8 @@ class SessionManager {
|
||||
user: {
|
||||
...user,
|
||||
socket_id: socketId
|
||||
}
|
||||
},
|
||||
recovered
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -133,6 +145,84 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover unprocessed confirmed payments for a user who disconnected
|
||||
* This handles credits_package payments that were confirmed but credits weren't added
|
||||
*/
|
||||
async recoverPendingPayments(userId, socketId) {
|
||||
const result = { creditsRecovered: 0, paymentsProcessed: 0 };
|
||||
|
||||
try {
|
||||
// Find confirmed credits_package payments that have no credits_purchased or 0
|
||||
// These are payments where the user disconnected before confirmation was processed
|
||||
const unprocessedPayments = await this.db.query(`
|
||||
SELECT p.id, p.payment_type, p.description, p.expected_amount, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'credits_package'
|
||||
AND (p.credits_purchased IS NULL OR p.credits_purchased = 0)
|
||||
AND p.confirmed_at > NOW() - INTERVAL '24 hours'
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
if (unprocessedPayments.rows.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const payment of unprocessedPayments.rows) {
|
||||
try {
|
||||
// Parse credits from description (e.g., "Wowngeon 10 credits package (WOW)")
|
||||
let creditsToAdd = 10; // Default fallback
|
||||
const desc = payment.description || '';
|
||||
const match = desc.match(/(\d+)\s*credits?/i);
|
||||
if (match) {
|
||||
creditsToAdd = parseInt(match[1], 10) || 10;
|
||||
}
|
||||
|
||||
// Add credits to user
|
||||
const updateResult = await this.db.query(`
|
||||
UPDATE users
|
||||
SET credits = credits + $1,
|
||||
total_credits_purchased = COALESCE(total_credits_purchased, 0) + $1
|
||||
WHERE id = $2
|
||||
RETURNING credits
|
||||
`, [creditsToAdd, userId]);
|
||||
|
||||
const newBalance = updateResult.rows[0]?.credits ?? 0;
|
||||
|
||||
// Mark payment as processed
|
||||
await this.db.query(`
|
||||
UPDATE payments
|
||||
SET credits_purchased = $1
|
||||
WHERE id = $2
|
||||
`, [creditsToAdd, payment.id]);
|
||||
|
||||
// Record credit transaction
|
||||
await this.db.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, 'package_purchase_recovered', $3, 'purchase')
|
||||
`, [userId, creditsToAdd, newBalance]);
|
||||
|
||||
result.creditsRecovered += creditsToAdd;
|
||||
result.paymentsProcessed++;
|
||||
|
||||
console.log(`💰 [PaymentRecovery] Recovered ${creditsToAdd} credits for user ${userId} from payment ${payment.id}`);
|
||||
} catch (paymentError) {
|
||||
console.error(`[PaymentRecovery] Failed to process payment ${payment.id}:`, paymentError.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.creditsRecovered > 0) {
|
||||
console.log(`💰 [PaymentRecovery] Total recovered for user ${userId}: ${result.creditsRecovered} credits from ${result.paymentsProcessed} payments`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PaymentRecovery] Error recovering payments:', error.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async cleanupExpiredSessions() {
|
||||
try {
|
||||
// SECURE: Parameterized query for cleanup
|
||||
|
||||
@@ -481,6 +481,10 @@ class SocketHandlers {
|
||||
this.broadcastManager.sendStatusUpdate(socket.id, 'error', startRes.reason || 'Failed to start game.');
|
||||
return;
|
||||
}
|
||||
// Emit credits_update if credits were spent
|
||||
if (startRes.creditsRemaining !== undefined) {
|
||||
this.io.to(socket.id).emit('credits_update', { balance: startRes.creditsRemaining });
|
||||
}
|
||||
}
|
||||
|
||||
this.io.to(socket.id).emit('game_start', state);
|
||||
@@ -712,8 +716,8 @@ class SocketHandlers {
|
||||
/**
|
||||
* Start games for waiting players when a new block is found
|
||||
*/
|
||||
startGamesForWaiting(blockHeight) {
|
||||
return this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
async startGamesForWaiting(blockHeight) {
|
||||
return await this.queueHandler.startGamesForWaiting(blockHeight);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user