mirror of
https://github.com/Such-Software/wownerogue.git
synced 2026-10-23 09:08:32 +02:00
add backup scripts and improve docs and .env.example
This commit is contained in:
+98
-65
@@ -1,116 +1,149 @@
|
||||
# =============================================================================
|
||||
# Wownerogue Server Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and customize for your environment.
|
||||
# The server validates these settings at startup and warns about misconfigurations.
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Network Selection (IMPORTANT for Monero users)
|
||||
# Network Selection
|
||||
# -----------------------------------------------------------------------------
|
||||
# For Wownero: Only mainnet exists, this setting is ignored
|
||||
# For Monero: Choose mainnet, stagenet, or testnet
|
||||
# For Wownero: Only mainnet exists (this setting is ignored)
|
||||
# For Monero: mainnet | stagenet | testnet
|
||||
# ⚠️ STAGENET WARNING: Stagenet XMR has no real value! Only for testing.
|
||||
MONERO_NETWORK=mainnet
|
||||
# MONERO_NETWORK=stagenet
|
||||
# MONERO_NETWORK=testnet
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Difficulty Settings
|
||||
# Difficulty Presets
|
||||
# -----------------------------------------------------------------------------
|
||||
# Presets: easy, normal, hard, casino
|
||||
# - easy: Small dungeon, slow monster, high player win rate (~70%)
|
||||
# - normal: Balanced difficulty (~45% player win rate)
|
||||
# - hard: Large dungeon, aggressive monster (~35% player win rate)
|
||||
# - casino: Maximum house edge (~30% player win rate) - DEFAULT for paid modes
|
||||
# Leave unset to auto-select based on PAYMENTS_ENABLED
|
||||
# Presets: easy | normal | hard | casino
|
||||
# easy - Small dungeon, slow monster (~70% player win rate)
|
||||
# normal - Balanced difficulty (~45% player win rate)
|
||||
# hard - Large dungeon, aggressive monster (~35% player win rate)
|
||||
# casino - Maximum house edge (~30% player win rate) - DEFAULT for paid modes
|
||||
# Leave unset to auto-select based on payment mode.
|
||||
# DIFFICULTY_PRESET=casino
|
||||
# DIFFICULTY_PRESET=normal
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Unified Payment System (recommended configuration)
|
||||
# Payment System
|
||||
# -----------------------------------------------------------------------------
|
||||
# Master switch for payments. Set false for FREE mode (no payments required).
|
||||
PAYMENTS_ENABLED=true
|
||||
|
||||
# Enable one or both monetized modes (comma separated list: direct,credits)
|
||||
# Payment modes: direct | credits | direct,credits (comma-separated)
|
||||
# direct - Pay per game, eligible for payouts on win
|
||||
# credits - Buy credit packages, spend 1 credit per game
|
||||
# Enabling both allows users to choose; users with credits use them first.
|
||||
PAYMENT_MODES=direct,credits
|
||||
|
||||
# Direct (per-run) mode settings
|
||||
# -----------------------------------------------------------------------------
|
||||
# Direct Mode (pay-per-game)
|
||||
# -----------------------------------------------------------------------------
|
||||
DIRECT_PAYMENT_ENABLED=true
|
||||
DIRECT_GAME_PRICE=100000000000 # 1 WOW in atomic units (1e11)
|
||||
DIRECT_REQUIRES_ADDRESS=true
|
||||
DIRECT_ALLOW_GUEST_PLAY=false
|
||||
DIRECT_PAYOUTS_ENABLED=true
|
||||
DIRECT_PAYOUT_ESCAPE=2.0
|
||||
DIRECT_PAYOUT_TREASURE=3.0
|
||||
DIRECT_GAME_PRICE=100000000000 # Price in atomic units (1 WOW = 1e11)
|
||||
DIRECT_REQUIRES_ADDRESS=true # Require payout address before playing
|
||||
DIRECT_ALLOW_GUEST_PLAY=false # Allow play without payout address
|
||||
|
||||
# Credits mode settings
|
||||
# Direct mode payouts
|
||||
# ⚠️ Requires PRIMARY_WALLET_ENDPOINT to be configured!
|
||||
DIRECT_PAYOUTS_ENABLED=true
|
||||
DIRECT_PAYOUT_ESCAPE=2.0 # Multiplier for escaping the dungeon
|
||||
DIRECT_PAYOUT_TREASURE=3.0 # Multiplier for finding treasure
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Credits Mode (buy packages, spend per game)
|
||||
# -----------------------------------------------------------------------------
|
||||
CREDITS_ENABLED=true
|
||||
CREDITS_PER_GAME=1
|
||||
CREDITS_REQUIRES_ADDRESS=true
|
||||
ALLOW_MIXED_MODE=true
|
||||
PREFER_CREDITS_FIRST=true
|
||||
CREDITS_PER_GAME=1 # Credits consumed per game
|
||||
CREDITS_REQUIRES_ADDRESS=true # Require payout address for credits mode
|
||||
ALLOW_MIXED_MODE=true # Allow both direct and credits options
|
||||
PREFER_CREDITS_FIRST=true # Auto-use credits if available
|
||||
|
||||
# Credits mode payouts (usually disabled - credits are a bulk discount)
|
||||
# ⚠️ If enabled, requires PRIMARY_WALLET_ENDPOINT!
|
||||
CREDITS_PAYOUTS_ENABLED=false
|
||||
CREDITS_PAYOUT_BASE=50000000000 # 0.5 WOW per credit payout baseline
|
||||
CREDITS_PAYOUT_BASE=50000000000 # Base payout per credit win
|
||||
CREDITS_PAYOUT_ESCAPE=1.5
|
||||
CREDITS_PAYOUT_TREASURE=2.0
|
||||
|
||||
# Credit packages (JSON array)
|
||||
# Each package: { id, credits, price (atomic units string), bonus (extra free credits) }
|
||||
# ⚠️ Must be valid JSON! Server validates at startup.
|
||||
CREDITS_PACKAGES='[{"id":"small","credits":10,"price":"500000000000","bonus":0},{"id":"medium","credits":25,"price":"1000000000000","bonus":2},{"id":"large","credits":100,"price":"3500000000000","bonus":15}]'
|
||||
|
||||
# Global payouts / limits
|
||||
# -----------------------------------------------------------------------------
|
||||
# Global Payout Settings
|
||||
# -----------------------------------------------------------------------------
|
||||
PAYOUTS_ENABLED=true
|
||||
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
|
||||
PAYOUT_MAX_PER_GAME=10000000000000
|
||||
PAYOUT_BATCH_INTERVAL=300 # seconds (5 minutes)
|
||||
PAYOUT_MAX_RETRIES=3
|
||||
MAX_PAYOUT_BATCH_SIZE=50
|
||||
PAYOUT_MIN_AMOUNT=10000000000 # 0.1 WOW minimum payout
|
||||
PAYOUT_MAX_PER_GAME=10000000000000 # Max payout per single game
|
||||
PAYOUT_BATCH_INTERVAL=300 # Seconds between payout batches (5 min)
|
||||
PAYOUT_MAX_RETRIES=3 # Retry failed payouts
|
||||
MAX_PAYOUT_BATCH_SIZE=50 # Max payouts per batch
|
||||
|
||||
# Anti-abuse limits
|
||||
# -----------------------------------------------------------------------------
|
||||
# Anti-Abuse Limits
|
||||
# -----------------------------------------------------------------------------
|
||||
MAX_GAMES_PER_HOUR=60
|
||||
MAX_PAYOUTS_PER_DAY=100
|
||||
MAX_CREDIT_PURCHASE_PER_DAY=100000000000000
|
||||
GAME_COOLDOWN_SECONDS=5
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy overrides (optional). These mirror the old configuration style and
|
||||
# are primarily kept for backwards compatibility or quick testing.
|
||||
# Wallet RPC (required for paid modes)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GAME_MODE accepts FREE, PAID_SINGLE, PAID_CREDITS. When set it overrides
|
||||
# PAYMENT_MODES. Leave unset to rely on unified config above.
|
||||
GAME_MODE=
|
||||
|
||||
# Legacy per-mode prices (still read as fallbacks if PAYMENT_MODES unset)
|
||||
CRYPTO_TYPE=WOW
|
||||
SINGLE_GAME_PRICE=1e11 # 1 WOW (atomic units)
|
||||
CREDITS_PACKAGE_PRICE=5e11 # 5 WOW (atomic units)
|
||||
# SINGLE_GAME_PRICE=5e9 # 0.005 XMR (5000000000)
|
||||
# CREDITS_PACKAGE_PRICE=5e10 # 0.05 XMR (50000000000)
|
||||
|
||||
# Wallet RPC Configuration (required for paid modes)
|
||||
# Run wownero-wallet-rpc or monero-wallet-rpc with --rpc-bind-port
|
||||
# ⚠️ Without this, payments and payouts will fail!
|
||||
PRIMARY_WALLET_ENDPOINT=http://127.0.0.1:34570
|
||||
# WALLET_RPC_USER=user
|
||||
# WALLET_RPC_PASSWORD=password
|
||||
|
||||
# Blockchain RPC Configuration (daemon URLs)
|
||||
# -----------------------------------------------------------------------------
|
||||
# Blockchain RPC (daemon)
|
||||
# -----------------------------------------------------------------------------
|
||||
PRIMARY_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
FALLBACK_RPC_ENDPOINT=http://127.0.0.1:34568
|
||||
RPC_POLL_INTERVAL=2000
|
||||
RPC_POLL_INTERVAL=2000 # Milliseconds between block polls
|
||||
|
||||
# Database Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Database (PostgreSQL)
|
||||
# -----------------------------------------------------------------------------
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=wownerogue
|
||||
DB_USER=jw
|
||||
DB_PASSWORD=jw
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
|
||||
# Server Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
# Server
|
||||
# -----------------------------------------------------------------------------
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# Block Height Source & Debug Controls
|
||||
# BLOCK_SOURCE=daemon -> always use real blockchain height (default for paid modes)
|
||||
# BLOCK_SOURCE=simulated -> force simulated blocks (development)
|
||||
# SIMULATED_BLOCKS=true/false overrides automatic detection (if set)
|
||||
# FORCE_SIMULATED_BLOCKS=true will force simulation even in paid modes (for local testing)
|
||||
# Examples:
|
||||
# BLOCK_SOURCE=daemon
|
||||
# BLOCK_SOURCE=simulated
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=true
|
||||
# -----------------------------------------------------------------------------
|
||||
# Admin API
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required for admin endpoints: /api/admin/refund/payment, /api/admin/credits/adjust, etc.
|
||||
# Generate a secure key: openssl rand -hex 32
|
||||
# Pass as X-Admin-Key header when calling admin endpoints.
|
||||
# ADMIN_API_KEY=your-secure-random-key-here
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Block Simulation (Development Only)
|
||||
# -----------------------------------------------------------------------------
|
||||
# BLOCK_SOURCE: daemon (real blockchain) | simulated (fake blocks for testing)
|
||||
# In paid modes, defaults to daemon. In FREE mode, defaults to simulated.
|
||||
BLOCK_SOURCE=daemon
|
||||
# SIMULATED_BLOCKS=false
|
||||
# FORCE_SIMULATED_BLOCKS=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy Configuration (deprecated, kept for compatibility)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GAME_MODE: FREE | PAID_SINGLE | PAID_CREDITS
|
||||
# If set, overrides PAYMENT_MODES. Prefer using PAYMENT_MODES instead.
|
||||
# GAME_MODE=
|
||||
|
||||
# Legacy prices (used if PAYMENT_MODES not set)
|
||||
CRYPTO_TYPE=WOW
|
||||
# SINGLE_GAME_PRICE=1e11
|
||||
# CREDITS_PACKAGE_PRICE=5e11
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Environment Validator
|
||||
* Emits friendly warnings when payment related environment variables look suspicious.
|
||||
* Validates payment configuration and emits warnings for suspicious settings.
|
||||
*/
|
||||
|
||||
class EnvironmentValidator {
|
||||
@@ -12,34 +12,70 @@ class EnvironmentValidator {
|
||||
const warnings = [];
|
||||
const errors = [];
|
||||
|
||||
// Check for conflicting legacy/new config
|
||||
if (process.env.GAME_MODE && process.env.PAYMENT_MODES) {
|
||||
warnings.push('Both GAME_MODE and PAYMENT_MODES are set. PAYMENT_MODES takes precedence.');
|
||||
}
|
||||
|
||||
// Wallet RPC required for payments
|
||||
if (config.paymentsEnabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
|
||||
warnings.push('paymentsEnabled is true but PRIMARY_WALLET_ENDPOINT is not set. Wallet RPC may fail.');
|
||||
warnings.push('Payments enabled but PRIMARY_WALLET_ENDPOINT not set. Wallet RPC will fail.');
|
||||
}
|
||||
|
||||
// Blockchain RPC required for payments
|
||||
if (config.paymentsEnabled && !process.env.PRIMARY_RPC_ENDPOINT) {
|
||||
warnings.push('paymentsEnabled is true but PRIMARY_RPC_ENDPOINT is missing. Blockchain RPC is required.');
|
||||
warnings.push('Payments enabled but PRIMARY_RPC_ENDPOINT missing. Block sync will fail.');
|
||||
}
|
||||
|
||||
// Direct mode price validation
|
||||
if (config.modes.direct.enabled && config.modes.direct.price <= 0n) {
|
||||
errors.push('Direct payment mode is enabled but direct price is not positive.');
|
||||
errors.push('Direct payment mode enabled but price is not positive.');
|
||||
}
|
||||
|
||||
// Credits mode package validation
|
||||
if (config.modes.credits.enabled && !config.modes.credits.packages.length) {
|
||||
errors.push('Credits mode is enabled but no credit packages are defined.');
|
||||
errors.push('Credits mode enabled but no credit packages defined.');
|
||||
}
|
||||
|
||||
if (config.modes.credits.enabled && !process.env.CREDITS_PACKAGES) {
|
||||
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Consider defining CREDITS_PACKAGES for production.');
|
||||
// Validate CREDITS_PACKAGES JSON format
|
||||
if (process.env.CREDITS_PACKAGES) {
|
||||
try {
|
||||
const parsed = JSON.parse(process.env.CREDITS_PACKAGES);
|
||||
if (!Array.isArray(parsed)) {
|
||||
errors.push('CREDITS_PACKAGES must be a JSON array.');
|
||||
} else if (parsed.length === 0) {
|
||||
warnings.push('CREDITS_PACKAGES is an empty array. Using defaults.');
|
||||
} else {
|
||||
// Validate each package has required fields
|
||||
parsed.forEach((pkg, i) => {
|
||||
if (!pkg.id) warnings.push(`CREDITS_PACKAGES[${i}] missing "id" field.`);
|
||||
if (!pkg.credits && pkg.credits !== 0) warnings.push(`CREDITS_PACKAGES[${i}] missing "credits" field.`);
|
||||
if (!pkg.price) warnings.push(`CREDITS_PACKAGES[${i}] missing "price" field.`);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
errors.push(`CREDITS_PACKAGES is not valid JSON: ${e.message}`);
|
||||
}
|
||||
} else if (config.modes.credits.enabled) {
|
||||
this.logger.info?.('ℹ️ Credits mode enabled with default packages. Set CREDITS_PACKAGES for production.');
|
||||
}
|
||||
|
||||
// Warn if direct payouts enabled but no wallet RPC
|
||||
if (config.payouts.rules.direct.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
|
||||
warnings.push('DIRECT_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
|
||||
}
|
||||
|
||||
// Warn if credits payouts enabled but no wallet RPC
|
||||
if (config.payouts.rules.credits.enabled && !process.env.PRIMARY_WALLET_ENDPOINT) {
|
||||
warnings.push('CREDITS_PAYOUTS_ENABLED=true but no PRIMARY_WALLET_ENDPOINT. Payouts will fail.');
|
||||
}
|
||||
|
||||
// Info about FREE mode
|
||||
if (!config.paymentsEnabled) {
|
||||
this.logger.info?.('ℹ️ Payments are currently disabled. Server will run in FREE mode.');
|
||||
this.logger.info?.('ℹ️ Payments disabled. Server running in FREE mode.');
|
||||
}
|
||||
|
||||
// Emit warnings and errors
|
||||
if (warnings.length) {
|
||||
warnings.forEach(msg => this.logger.warn?.(`⚠️ ${msg}`));
|
||||
}
|
||||
|
||||
+391
@@ -36,6 +36,7 @@ const debugManager = new DebugManager(broadcastManager);
|
||||
|
||||
// Initialize payment system components (debugManager is now available)
|
||||
const databaseManager = new DatabaseManager();
|
||||
const db = databaseManager; // Alias for convenience in API endpoints
|
||||
const rpcService = new RpcService();
|
||||
const walletRPCService = new WalletRPCService(debugManager);
|
||||
const gameModeManager = new GameModeManager(databaseManager, walletRPCService, debugManager, paymentConfigManager);
|
||||
@@ -276,6 +277,396 @@ app.get('/api/user/:socketId/payment-options', asyncHandler(async (req, res) =>
|
||||
}
|
||||
}));
|
||||
|
||||
// Get payment history for a user
|
||||
app.get('/api/user/:socketId/payments', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId parameter is required.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
id,
|
||||
payment_type,
|
||||
expected_amount,
|
||||
status,
|
||||
credits_purchased,
|
||||
created_at,
|
||||
confirmed_at,
|
||||
description
|
||||
FROM payments
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [user.id, limit, offset]);
|
||||
|
||||
const countResult = await db.query(
|
||||
`SELECT COUNT(*) as total FROM payments WHERE user_id = $1`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
payments: result.rows.map(row => ({
|
||||
id: row.id,
|
||||
type: row.payment_type,
|
||||
amount: row.expected_amount,
|
||||
amountFormatted: gameModeManager.formatAtomicHuman(row.expected_amount, 4),
|
||||
status: row.status,
|
||||
creditsReceived: row.credits_purchased || 0,
|
||||
createdAt: row.created_at,
|
||||
confirmedAt: row.confirmed_at,
|
||||
description: row.description
|
||||
})),
|
||||
total: parseInt(countResult.rows[0].total, 10),
|
||||
limit,
|
||||
offset,
|
||||
currency: gameModeManager.cryptoType
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError('Failed to retrieve payment history', {
|
||||
statusCode: 500,
|
||||
safeMessage: 'Unable to retrieve payment history.',
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// Get payout history for a user
|
||||
app.get('/api/user/:socketId/payouts', asyncHandler(async (req, res) => {
|
||||
const { socketId } = req.params;
|
||||
const limit = Math.min(parseInt(req.query.limit, 10) || 20, 100);
|
||||
const offset = parseInt(req.query.offset, 10) || 0;
|
||||
|
||||
if (!socketId) {
|
||||
throw new ValidationError('Missing socketId', {
|
||||
safeMessage: 'socketId parameter is required.'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
const result = await db.query(`
|
||||
SELECT
|
||||
p.id,
|
||||
p.amount,
|
||||
p.multiplier,
|
||||
p.reason,
|
||||
p.status,
|
||||
p.tx_hash,
|
||||
p.created_at,
|
||||
p.processed_at,
|
||||
g.outcome as game_outcome
|
||||
FROM payouts p
|
||||
LEFT JOIN games g ON p.game_id = g.id
|
||||
WHERE p.user_id = $1
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT $2 OFFSET $3
|
||||
`, [user.id, limit, offset]);
|
||||
|
||||
const countResult = await db.query(
|
||||
`SELECT COUNT(*) as total FROM payouts WHERE user_id = $1`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
// Calculate total received (confirmed payouts)
|
||||
const totalResult = await db.query(
|
||||
`SELECT COALESCE(SUM(amount), 0) as total_received FROM payouts WHERE user_id = $1 AND status = 'confirmed'`,
|
||||
[user.id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
payouts: result.rows.map(row => ({
|
||||
id: row.id,
|
||||
amount: row.amount,
|
||||
amountFormatted: gameModeManager.formatAtomicHuman(row.amount, 4),
|
||||
multiplier: parseFloat(row.multiplier) || 0,
|
||||
reason: row.reason,
|
||||
status: row.status,
|
||||
txHash: row.tx_hash,
|
||||
createdAt: row.created_at,
|
||||
processedAt: row.processed_at,
|
||||
gameOutcome: row.game_outcome
|
||||
})),
|
||||
total: parseInt(countResult.rows[0].total, 10),
|
||||
totalReceived: parseInt(totalResult.rows[0].total_received, 10),
|
||||
totalReceivedFormatted: gameModeManager.formatAtomicHuman(totalResult.rows[0].total_received, 4),
|
||||
limit,
|
||||
offset,
|
||||
currency: gameModeManager.cryptoType
|
||||
});
|
||||
} catch (error) {
|
||||
throw new AppError('Failed to retrieve payout history', {
|
||||
statusCode: 500,
|
||||
safeMessage: 'Unable to retrieve payout history.',
|
||||
cause: error
|
||||
});
|
||||
}
|
||||
}));
|
||||
|
||||
// =============================================================================
|
||||
// Admin API Endpoints (requires ADMIN_API_KEY)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Admin authentication middleware
|
||||
* Requires X-Admin-Key header to match ADMIN_API_KEY env variable
|
||||
*/
|
||||
const adminAuth = (req, res, next) => {
|
||||
const adminKey = process.env.ADMIN_API_KEY;
|
||||
if (!adminKey) {
|
||||
return res.status(503).json({
|
||||
error: 'Admin API not configured',
|
||||
message: 'Set ADMIN_API_KEY environment variable to enable admin endpoints.'
|
||||
});
|
||||
}
|
||||
|
||||
const providedKey = req.headers['x-admin-key'];
|
||||
if (!providedKey || providedKey !== adminKey) {
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or missing X-Admin-Key header.'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/refund/payment
|
||||
* Refund a payment - marks it as refunded and optionally sends funds back
|
||||
* Body: { paymentId: number, reason?: string, sendFunds?: boolean }
|
||||
*/
|
||||
app.post('/api/admin/refund/payment', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { paymentId, reason, sendFunds } = req.body || {};
|
||||
|
||||
if (!paymentId || typeof paymentId !== 'number') {
|
||||
throw new ValidationError('Invalid paymentId', {
|
||||
safeMessage: 'paymentId (number) is required.'
|
||||
});
|
||||
}
|
||||
|
||||
// Get payment record
|
||||
const paymentResult = await db.query(`
|
||||
SELECT p.*, u.payout_address, u.socket_id
|
||||
FROM payments p
|
||||
LEFT JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = $1
|
||||
`, [paymentId]);
|
||||
|
||||
if (paymentResult.rows.length === 0) {
|
||||
throw new NotFoundError('Payment not found', {
|
||||
safeMessage: `Payment ${paymentId} not found.`
|
||||
});
|
||||
}
|
||||
|
||||
const payment = paymentResult.rows[0];
|
||||
|
||||
if (payment.status === 'refunded') {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'Payment already refunded.',
|
||||
payment: { id: payment.id, status: payment.status }
|
||||
});
|
||||
}
|
||||
|
||||
// Begin transaction
|
||||
const client = await db.getClient();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Mark payment as refunded
|
||||
await client.query(`
|
||||
UPDATE payments
|
||||
SET status = 'refunded',
|
||||
description = COALESCE(description, '') || ' | Refunded: ' || $2
|
||||
WHERE id = $1
|
||||
`, [paymentId, reason || 'Admin refund']);
|
||||
|
||||
// If credits were purchased, deduct them
|
||||
let creditsDeducted = 0;
|
||||
if (payment.credits_purchased > 0 && payment.user_id) {
|
||||
const userResult = await client.query(`
|
||||
SELECT credits FROM users WHERE id = $1
|
||||
`, [payment.user_id]);
|
||||
|
||||
const currentCredits = userResult.rows[0]?.credits || 0;
|
||||
creditsDeducted = Math.min(payment.credits_purchased, currentCredits);
|
||||
|
||||
if (creditsDeducted > 0) {
|
||||
const newBalance = currentCredits - creditsDeducted;
|
||||
await client.query(`
|
||||
UPDATE users SET credits = $1 WHERE id = $2
|
||||
`, [newBalance, payment.user_id]);
|
||||
|
||||
// Record credit transaction
|
||||
await client.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, $3, $4, 'refund')
|
||||
`, [payment.user_id, -creditsDeducted, `Refund for payment ${paymentId}`, newBalance]);
|
||||
}
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
// Optionally send funds back via wallet RPC
|
||||
let fundsSent = false;
|
||||
let txHash = null;
|
||||
if (sendFunds && payment.payout_address && payment.expected_amount > 0) {
|
||||
try {
|
||||
if (walletRPCService && typeof walletRPCService.sendPayment === 'function') {
|
||||
const result = await walletRPCService.sendPayment(
|
||||
payment.payout_address,
|
||||
payment.expected_amount
|
||||
);
|
||||
fundsSent = true;
|
||||
txHash = result?.tx_hash || null;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send refund funds:', err.message);
|
||||
// Don't fail the request - refund is recorded, funds can be sent manually
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Payment refunded successfully.',
|
||||
refund: {
|
||||
paymentId,
|
||||
originalAmount: payment.expected_amount,
|
||||
originalAmountFormatted: gameModeManager.formatAtomicHuman(payment.expected_amount, 4),
|
||||
creditsDeducted,
|
||||
fundsSent,
|
||||
txHash,
|
||||
reason: reason || 'Admin refund'
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* POST /api/admin/credits/adjust
|
||||
* Adjust a user's credit balance (add or subtract)
|
||||
* Body: { socketId: string, amount: number, reason?: string }
|
||||
* amount can be positive (add) or negative (subtract)
|
||||
*/
|
||||
app.post('/api/admin/credits/adjust', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { socketId, amount, reason } = req.body || {};
|
||||
|
||||
if (!socketId || typeof socketId !== 'string') {
|
||||
throw new ValidationError('Invalid socketId', {
|
||||
safeMessage: 'socketId (string) is required.'
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof amount !== 'number' || amount === 0) {
|
||||
throw new ValidationError('Invalid amount', {
|
||||
safeMessage: 'amount (non-zero number) is required. Positive to add, negative to subtract.'
|
||||
});
|
||||
}
|
||||
|
||||
// Get user
|
||||
const user = await gameModeManager.getOrCreateUser(socketId);
|
||||
if (!user) {
|
||||
throw new NotFoundError('User not found', {
|
||||
safeMessage: `User with socket ${socketId} not found.`
|
||||
});
|
||||
}
|
||||
|
||||
const currentCredits = user.credits || 0;
|
||||
const newBalance = Math.max(0, currentCredits + amount); // Prevent negative balance
|
||||
const actualAdjustment = newBalance - currentCredits;
|
||||
|
||||
if (actualAdjustment === 0) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No adjustment made (would result in same balance).',
|
||||
user: { credits: currentCredits }
|
||||
});
|
||||
}
|
||||
|
||||
// Update credits
|
||||
await db.query(`
|
||||
UPDATE users SET credits = $1, updated_at = NOW() WHERE id = $2
|
||||
`, [newBalance, user.id]);
|
||||
|
||||
// Record transaction
|
||||
const transactionType = actualAdjustment > 0 ? 'admin_credit' : 'admin_debit';
|
||||
await db.query(`
|
||||
INSERT INTO credit_transactions (user_id, amount, reason, balance_after, transaction_type)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`, [user.id, actualAdjustment, reason || 'Admin adjustment', newBalance, transactionType]);
|
||||
|
||||
// Notify user if connected
|
||||
try {
|
||||
io.to(socketId).emit('credits_update', {
|
||||
balance: newBalance,
|
||||
creditsPerGame: gameModeManager.creditsPerGameCost
|
||||
});
|
||||
} catch (_) {}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Credits ${actualAdjustment > 0 ? 'added' : 'deducted'} successfully.`,
|
||||
adjustment: {
|
||||
userId: user.id,
|
||||
previousBalance: currentCredits,
|
||||
adjustment: actualAdjustment,
|
||||
newBalance,
|
||||
reason: reason || 'Admin adjustment'
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
/**
|
||||
* GET /api/admin/users/search
|
||||
* Search for users by socket ID prefix or payout address
|
||||
* Query: ?q=searchterm&limit=20
|
||||
*/
|
||||
app.get('/api/admin/users/search', adminAuth, asyncHandler(async (req, res) => {
|
||||
const { q, limit = 20 } = req.query;
|
||||
|
||||
if (!q || q.length < 3) {
|
||||
throw new ValidationError('Invalid search query', {
|
||||
safeMessage: 'Search query must be at least 3 characters.'
|
||||
});
|
||||
}
|
||||
|
||||
const searchLimit = Math.min(parseInt(limit, 10) || 20, 100);
|
||||
|
||||
const result = await db.query(`
|
||||
SELECT id, socket_id, payout_address, credits, total_games_played, total_credits_purchased, created_at
|
||||
FROM users
|
||||
WHERE socket_id ILIKE $1 OR payout_address ILIKE $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
`, [`%${q}%`, searchLimit]);
|
||||
|
||||
res.json({
|
||||
users: result.rows.map(u => ({
|
||||
id: u.id,
|
||||
socketId: u.socket_id,
|
||||
payoutAddress: u.payout_address ? `${u.payout_address.substring(0, 10)}...` : null,
|
||||
credits: u.credits || 0,
|
||||
gamesPlayed: u.total_games_played || 0,
|
||||
createdAt: u.created_at
|
||||
})),
|
||||
total: result.rows.length
|
||||
});
|
||||
}));
|
||||
|
||||
app.get('/api/game-modes', (req, res) => {
|
||||
const config = paymentConfigManager.getConfig();
|
||||
const decimals = Number(config.currency?.decimals ?? 12);
|
||||
|
||||
@@ -222,9 +222,10 @@ class ConnectionHandler {
|
||||
});
|
||||
}
|
||||
|
||||
// Credits convenience push
|
||||
// Credits convenience push (include creditsPerGame for games remaining calculation)
|
||||
this.io.to(socket.id).emit('credits_update', {
|
||||
balance: sessionInfo.user.credits || 0
|
||||
balance: sessionInfo.user.credits || 0,
|
||||
creditsPerGame: this.gameModeManager?.creditsPerGameCost || 1
|
||||
});
|
||||
} else {
|
||||
// fallback legacy welcome
|
||||
|
||||
Reference in New Issue
Block a user