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:
jwinterm
2026-01-02 14:19:11 -05:00
parent aec1c71a8c
commit 00f8057cb5
7 changed files with 226 additions and 56 deletions
+59 -13
View File
@@ -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);
}
}
}
}
+15 -4
View File
@@ -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);
+1 -28
View File
@@ -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)
*/
+2 -2
View File
@@ -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);
}
/**
+50 -4
View File
@@ -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');
+93 -3
View File
@@ -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
+6 -2
View File
@@ -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);
}
/**