fix credits package bug and improve stale payment logic

This commit is contained in:
jwinterm
2026-02-19 07:05:15 -05:00
parent bc55f0355e
commit 5776c58357
6 changed files with 102 additions and 24 deletions
+23 -6
View File
@@ -810,7 +810,14 @@ class GameModeManager {
*/
async createPaymentRequest(socketId, paymentType, options = {}) {
try {
const user = await this.getOrCreateUser(socketId);
// Prefer session-resolved userId (stable across reconnects) over socket_id lookup
let user;
if (options.userId) {
const result = await this.db.query('SELECT * FROM users WHERE id = $1', [options.userId]);
user = result.rows[0] || await this.getOrCreateUser(socketId);
} else {
user = await this.getOrCreateUser(socketId);
}
const reuseExisting = options.reuseExisting !== false;
const requestedPackageId = options.packageId;
@@ -864,6 +871,15 @@ class GameModeManager {
}
}
// Expire any stale pending payments for this user before creating a new one.
// A user should only have one active pending payment at a time.
const expiredResult = await this.db.query(`
UPDATE payments SET status = 'expired'
WHERE user_id = $1 AND status = 'pending'
RETURNING subaddress
`, [user.id]);
const expiredAddresses = expiredResult.rows.map(r => r.subaddress);
// Create payment request using wallet RPC with correct parameters
const paymentResult = await this.walletService.createPaymentRequest(
amount,
@@ -874,12 +890,12 @@ class GameModeManager {
const expiresAt = paymentResult.expiresAt || new Date(Date.now() + 30 * 60 * 1000);
// Store payment info in database
// Store payment info in database (address_index enables monitoring restoration after restart)
const insertResult = await this.db.query(`
INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at)
VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7)
INSERT INTO payments (user_id, socket_id, subaddress, expected_amount, payment_type, status, description, created_at, expires_at, address_index)
VALUES ($1, $2, $3, $4, $5, 'pending', $6, NOW(), $7, $8)
RETURNING id, expires_at
`, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt]);
`, [user.id, socketId, paymentResult.address, amount, paymentType, description, expiresAt, paymentResult.addressIndex ?? null]);
const insertedRow = insertResult.rows[0];
@@ -893,7 +909,8 @@ class GameModeManager {
package: this._serializePackageInfo(packageInfo),
paymentType,
description,
reused: false
reused: false,
expiredAddresses
};
} catch (error) {
+23
View File
@@ -52,6 +52,7 @@ const activeGames = new Map(); // Maps socketId to Game objects
let alertService = null; // Initialized later when payment system starts
let payoutRetryService = null; // Initialized later when payment system starts
let batchPayoutInterval = null; // Initialized later when payment system starts
let paymentExpiryInterval = null; // Initialized later when payment system starts
const socketHandlers = new SocketHandlers(io, activeGames, broadcastManager, debugManager, gameModeManager, walletRPCService);
// Configure static file serving
const htmlPath = path.join(__dirname, '../html');
@@ -1835,6 +1836,27 @@ async function startServer() {
// Start periodic alert checks (every 5 minutes)
alertService.startPeriodicChecks(300000);
}
// Periodic job: expire old pending payments (every 5 minutes)
paymentExpiryInterval = setInterval(async () => {
try {
const result = await databaseManager.query(`
UPDATE payments SET status = 'expired'
WHERE status = 'pending' AND expires_at < NOW()
RETURNING id, subaddress
`);
if (result.rows.length > 0) {
for (const row of result.rows) {
walletRPCService.stopPaymentMonitoring(row.subaddress);
walletRPCService.addressToUser.delete(row.subaddress);
walletRPCService.addressToSocket.delete(row.subaddress);
}
console.log(`[PaymentExpiry] Expired ${result.rows.length} stale payment(s)`);
}
} catch (e) {
console.error('[PaymentExpiry] Error:', e.message);
}
}, 5 * 60 * 1000);
} catch (error) {
console.error('❌ Failed to start server:', error);
@@ -1862,6 +1884,7 @@ async function gracefulShutdown(signal) {
// Stop scheduled timers
if (batchPayoutInterval) clearInterval(batchPayoutInterval);
if (paymentExpiryInterval) clearInterval(paymentExpiryInterval);
if (gameModeManager._batchPayoutTimer) clearTimeout(gameModeManager._batchPayoutTimer);
if (payoutRetryService) payoutRetryService.stop();
if (alertService) alertService.stopPeriodicChecks?.();
@@ -0,0 +1,5 @@
-- Add address_index column to payments table
-- Stores the wallet subaddress index so payment monitoring can be fully
-- restored from DB after server restart (needed for get_transfers RPC call)
ALTER TABLE payments ADD COLUMN IF NOT EXISTS address_index INTEGER;
+19 -2
View File
@@ -190,11 +190,28 @@ class PaymentHandlers {
return;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, {
// Resolve authoritative userId from session manager (stable across socket reconnects)
let sessionUserId = null;
if (this.sessionManager?.sessions?.has(socket.id)) {
sessionUserId = this.sessionManager.sessions.get(socket.id).id;
}
const paymentRequest = await this.gameModeManager.createPaymentRequest(socket.id, paymentType, {
reuseExisting: true,
packageId: options.packageId
packageId: options.packageId,
userId: sessionUserId
});
const reused = !!paymentRequest.reused;
// Stop monitoring any expired addresses from previous payment requests
if (paymentRequest.expiredAddresses?.length > 0) {
for (const addr of paymentRequest.expiredAddresses) {
this.walletService.stopPaymentMonitoring(addr);
this.walletService.addressToUser.delete(addr);
this.walletService.addressToSocket.delete(addr);
}
}
const formattedAmount = paymentRequest.amountFormatted ?? this.gameModeManager.formatAtomicHuman(amount, 3);
let qrDataUrl = null;
try {
+20 -6
View File
@@ -753,10 +753,9 @@ class SocketHandlers {
this.paymentHandlers.stopMonitoringForSocket(socketId);
}
// Clean up wallet Maps to prevent memory leaks (addressToUser, addressToSocket)
if (this.walletService && typeof this.walletService.cleanupUserPayments === 'function') {
this.walletService.cleanupUserPayments(socketId);
}
// NOTE: Do NOT call walletService.cleanupUserPayments() here.
// addressToUser entries must persist so checkPaymentStatus() can query the wallet
// RPC after session resumption. They get cleaned up when payments are confirmed or expired.
// Clean up movement timestamps
this.playerMoveTimestamps.delete(socketId);
@@ -815,7 +814,7 @@ class SocketHandlers {
try {
// Query for pending (unpaid) payment requests for this user
const pendingResult = await this.gameModeManager.db.query(`
SELECT id, subaddress, expected_amount, payment_type, description, expires_at
SELECT id, subaddress, expected_amount, payment_type, description, expires_at, address_index
FROM payments
WHERE user_id = $1
AND status = 'pending'
@@ -880,10 +879,25 @@ class SocketHandlers {
package: null
};
// Re-register address in wallet service so checkPaymentStatus() can query the wallet RPC.
// This is essential after server restart or if the entry was lost during disconnect.
if (this.walletService && payment.subaddress) {
this.walletService.addressToUser.set(payment.subaddress, {
userId: String(dbUserId),
socketId: socket.id,
amount: Number(payment.expected_amount),
addressIndex: payment.address_index ?? 0,
accountIndex: 0,
detected: false,
confirmed: false,
status: 'pending'
});
}
// Restart payment monitoring
// First stop any existing monitoring (unlikely but safe)
this.paymentHandlers.stopMonitoringForSocket(socket.id);
// Restart monitoring via paymentHandlers internal method
if (typeof this.paymentHandlers._monitorAddress === 'function') {
this.paymentHandlers._monitorAddress(
+12 -10
View File
@@ -432,18 +432,19 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('creates single_game payment request correctly', async () => {
const user = { id: 1, credits: 0 };
gmm.singleGamePrice = 100000000000;
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
address: 'wow1paymentaddr',
expiresAt: new Date()
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999, expires_at: new Date() }]
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 999, expires_at: new Date() }]
}); // INSERT payment
const result = await gmm.createPaymentRequest('socket1', 'single_game');
@@ -462,18 +463,19 @@ describe('Payment & Payout Comprehensive Tests', () => {
test('creates credits_package payment request correctly', async () => {
const user = { id: 1, credits: 0 };
gmm.creditsPackagePrice = 500000000000;
mockDb.query.mockResolvedValueOnce({ rows: [user] }); // getOrCreateUser
mockDb.query.mockResolvedValueOnce({ rows: [] }); // update last_active
mockDb.query.mockResolvedValueOnce({ rows: [] }); // no existing payment
mockDb.query.mockResolvedValueOnce({ rows: [] }); // expire stale payments
mockWalletService.createPaymentRequest.mockResolvedValueOnce({
address: 'wow1creditspayment',
expiresAt: new Date()
});
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1000, expires_at: new Date() }]
mockDb.query.mockResolvedValueOnce({
rows: [{ id: 1000, expires_at: new Date() }]
}); // INSERT payment
const result = await gmm.createPaymentRequest('socket1', 'credits_package');