mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-26 01:28:33 +01:00
fix: recover unconsumed single-game payments; make game preservation deterministic
Two disconnect-related money/UX gaps: 1. A confirmed single_game payment whose game never started (user paid, then disconnected) was money-in-nothing-given with no recovery path. recover- PendingPayments now detects confirmed single_game payments with no linked games row and grants the equivalent credits — idempotently (per-payment reason key, row-locked re-check of both the game link and prior recovery). 2. In-progress games could be dropped on disconnect when a fresh getBySocket lookup failed. The stable dbUserId is now stamped on the game at creation, so suspend/restore keys on it directly without a failure-prone disconnect- time lookup. Any game (paid or free) is preserved/reconnectable whenever a session exists; the remaining drop-branch is only the true no-session edge (e.g. DB unavailable at connect), which is logged for audit rather than masked. Adds singleGameRecovery.test.js. Full suite: 13 suites / 119 tests.
This commit is contained in:
@@ -213,11 +213,9 @@ class SessionManager {
|
||||
});
|
||||
}
|
||||
|
||||
if (unprocessedPayments.rows.length === 0) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const payment of unprocessedPayments.rows) {
|
||||
// NOTE: do not early-return when there are no credits_package payments — the
|
||||
// single_game recovery below must still run.
|
||||
for (const payment of (unprocessedPayments.rows.length === 0 ? [] : unprocessedPayments.rows)) {
|
||||
try {
|
||||
// Determine credits: use stored value from orphaned record, or parse from description
|
||||
let creditsToAdd = payment._creditsFromRecord || 0;
|
||||
@@ -304,6 +302,71 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 0.5: Recover confirmed single_game payments that were never consumed.
|
||||
// If a user paid for a single game and disconnected before a game started (no
|
||||
// games row references the payment), the money was taken with nothing given and
|
||||
// there was previously no recovery path. Grant the equivalent credits (one paid
|
||||
// game) so they get what they paid for. Idempotent via a per-payment reason key.
|
||||
try {
|
||||
const creditsPerGame = (this.gameModeManager && this.gameModeManager.creditsPerGameCost) || 1;
|
||||
const unconsumed = await this.db.query(`
|
||||
SELECT p.id, p.confirmed_at
|
||||
FROM payments p
|
||||
WHERE p.user_id = $1
|
||||
AND p.status = 'confirmed'
|
||||
AND p.payment_type = 'single_game'
|
||||
AND p.confirmed_at > NOW() - INTERVAL '7 days'
|
||||
AND NOT EXISTS (SELECT 1 FROM games g WHERE g.payment_id = p.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM credit_transactions ct
|
||||
WHERE ct.user_id = p.user_id AND ct.reason = 'single_game_recovered:' || p.id
|
||||
)
|
||||
ORDER BY p.confirmed_at ASC
|
||||
`, [userId]);
|
||||
|
||||
for (const payment of unconsumed.rows) {
|
||||
try {
|
||||
const recovered = await this.db.withTransaction(async (client) => {
|
||||
// Lock the payment and re-check under the lock that it is still unconsumed
|
||||
// and not already recovered (prevents double-credit across instances/races).
|
||||
const lock = await client.query(`SELECT id FROM payments WHERE id = $1 FOR UPDATE`, [payment.id]);
|
||||
if (!lock.rows[0]) return null;
|
||||
|
||||
const gameCheck = await client.query(`SELECT 1 FROM games WHERE payment_id = $1 LIMIT 1`, [payment.id]);
|
||||
if (gameCheck.rows.length > 0) return null; // a game was started for it after all
|
||||
|
||||
const reason = `single_game_recovered:${payment.id}`;
|
||||
const dup = await client.query(
|
||||
`SELECT 1 FROM credit_transactions WHERE user_id = $1 AND reason = $2 LIMIT 1`,
|
||||
[userId, reason]
|
||||
);
|
||||
if (dup.rows.length > 0) return null; // already recovered
|
||||
|
||||
const upd = await client.query(
|
||||
`UPDATE users SET credits = credits + $1 WHERE id = $2 RETURNING credits`,
|
||||
[creditsPerGame, userId]
|
||||
);
|
||||
const newBalance = upd.rows[0]?.credits ?? 0;
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, $3, $4, 'recovery')
|
||||
`, [userId, creditsPerGame, reason, newBalance]);
|
||||
return { creditsToAdd: creditsPerGame };
|
||||
});
|
||||
|
||||
if (recovered) {
|
||||
result.creditsRecovered += recovered.creditsToAdd;
|
||||
result.paymentsProcessed++;
|
||||
console.log(`💰 [PaymentRecovery] Recovered unconsumed single_game payment ${payment.id} as ${recovered.creditsToAdd} credit(s) for user ${userId}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[PaymentRecovery] Failed to recover single_game payment ${payment.id}:`, e.message);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[PaymentRecovery] single_game recovery error:', e.message);
|
||||
}
|
||||
|
||||
if (result.creditsRecovered > 0) {
|
||||
console.log(`💰 [PaymentRecovery] Total recovered for user ${userId}: ${result.creditsRecovered} credits from ${result.paymentsProcessed} payments`);
|
||||
}
|
||||
|
||||
@@ -86,7 +86,19 @@ class SocketHandlers {
|
||||
debugManager: this.debugManager,
|
||||
broadcastManager: this.broadcastManager,
|
||||
io: this.io,
|
||||
createGameForUser: (userObj, gameType, options) => this.gameManager.createGameForUser(userObj, gameType, options),
|
||||
createGameForUser: async (userObj, gameType, options) => {
|
||||
const game = await this.gameManager.createGameForUser(userObj, gameType, options);
|
||||
// Stamp the stable DB user id onto the game at creation. Suspend/restore keys
|
||||
// on this id; capturing it now (when we reliably have the session) means the
|
||||
// disconnect path never needs a fresh, failure-prone DB lookup to preserve a
|
||||
// game — so ANY game, paid or free, is reconnectable.
|
||||
try {
|
||||
const sid = userObj?.id;
|
||||
const dbUid = sid != null ? this.sessionManager?.sessions?.get(sid)?.id : null;
|
||||
if (game && dbUid != null) game.dbUserId = dbUid;
|
||||
} catch (_) {}
|
||||
return game;
|
||||
},
|
||||
getUserBySocket: (socketId) => this.connectionHandler.getUserBySocket(socketId),
|
||||
activeGames: this.activeGames,
|
||||
gameModeManager: this.gameModeManager,
|
||||
@@ -752,38 +764,45 @@ class SocketHandlers {
|
||||
this.connectionHandler.handleDisconnect(socket, async (socket) => {
|
||||
const socketId = socket.id;
|
||||
|
||||
// Try to get DB user ID for session-based preservation
|
||||
let dbUserId = null;
|
||||
if (this.sessionManager) {
|
||||
try {
|
||||
const user = await this.sessionManager.getBySocket(socketId);
|
||||
dbUserId = user?.id;
|
||||
} catch (e) {
|
||||
// Non-fatal, proceed without DB user
|
||||
const activeGame = this.activeGames.get(socketId);
|
||||
|
||||
// Resolve the stable DB user id that suspend/restore keys on. Preference order:
|
||||
// 1. the id stamped on the game at creation (always present for any game
|
||||
// started with a session) — no lookup, can't fail,
|
||||
// 2. the in-memory session cache,
|
||||
// 3. a DB lookup as a last resort.
|
||||
// Every game — paid or free — is preserved and reconnectable as long as a stable
|
||||
// id resolves, which it does whenever a session was ever established.
|
||||
let dbUserId = activeGame?.dbUserId || null;
|
||||
if (!dbUserId && this.sessionManager) {
|
||||
try { dbUserId = this.sessionManager.sessions?.get(socketId)?.id || null; } catch (_) {}
|
||||
if (!dbUserId) {
|
||||
try { const u = await this.sessionManager.getBySocket(socketId); dbUserId = u?.id || null; } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has an active game that should be suspended
|
||||
const activeGame = this.activeGames.get(socketId);
|
||||
|
||||
if (activeGame && dbUserId && this.suspendedGameManager) {
|
||||
// Suspend the game for potential reconnection
|
||||
// Suspend the game so it can be restored on reconnect (keyed by dbUserId).
|
||||
const suspended = this.suspendedGameManager.suspendGame(dbUserId, socketId, activeGame, {
|
||||
paymentMonitoringActive: this.paymentHandlers?.hasActiveMonitoring?.(socketId) || false
|
||||
});
|
||||
|
||||
this.activeGames.delete(socketId);
|
||||
if (suspended) {
|
||||
// Remove from activeGames (now in suspended state)
|
||||
this.activeGames.delete(socketId);
|
||||
|
||||
if (this.debugManager.CONSOLE_LOGGING) {
|
||||
console.log(`[SocketHandlers] Game suspended for user ${dbUserId} (socket: ${socketId})`);
|
||||
}
|
||||
} else {
|
||||
// Couldn't suspend, just delete
|
||||
this.activeGames.delete(socketId);
|
||||
console.error(`[SocketHandlers] suspendGame returned false for user ${dbUserId} (socket ${socketId}); game ${activeGame.id || 'unknown'} not restorable.`);
|
||||
}
|
||||
} else {
|
||||
// No DB user or no game, just clean up
|
||||
if (activeGame) {
|
||||
// Reachable only when no stable identity resolved at all — i.e. a session
|
||||
// was never established (e.g. DB unavailable at connect), so there is
|
||||
// genuinely no id to restore the game under. Record it for auditing.
|
||||
console.error(`[SocketHandlers] Active game dropped without suspension — no stable user id `
|
||||
+ `(socket=${socketId}, mode=${activeGame.paymentMode || activeGame.gameMode || 'unknown'}, `
|
||||
+ `gameId=${activeGame.id || 'unknown'}). Cannot restore a game without a session identity.`);
|
||||
}
|
||||
this.activeGames.delete(socketId);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user