mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-18 06:41:27 +02:00
fix credits package bug and improve stale payment logic
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user