getContent(); $sig = $request->header('X-Signature', ''); $secret = config('services.betix.webhook_secret', ''); // If a webhook secret is configured, verify the signature if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) { Log::warning('[BetiX] Webhook signature mismatch', ['ip' => $request->ip()]); return response('Unauthorized', 401); } $payload = json_decode($rawBody, true); if (!$payload || !isset($payload['session_token'])) { return response('Bad Request', 400); } $token = $payload['session_token']; $playerId = $payload['player_id'] ?? null; $endBalance = isset($payload['end_balance']) ? (float) $payload['end_balance'] : null; $startBalance = isset($payload['start_balance']) ? (float) $payload['start_balance'] : null; $game = $payload['game'] ?? null; $rounds = $payload['rounds'] ?? 0; $session = OperatorSession::where('session_token', $token)->first(); if (!$session) { // Unknown session — could be a replay or a stale token, acknowledge anyway Log::info('[BetiX] Webhook for unknown session', ['token' => $token]); return response('OK', 200); } if ($session->status !== 'active') { // Already processed (idempotency) return response('OK', 200); } DB::transaction(function () use ($session, $playerId, $endBalance, $startBalance, $rounds, $game) { // Mark session as expired $session->update([ 'status' => 'expired', 'current_balance' => $endBalance ?? $session->current_balance, ]); if ($endBalance === null || $playerId === null) { return; } // Apply balance delta to user (safe even if other transactions happened mid-session) $user = User::lockForUpdate()->find((int) $playerId); if (!$user) { return; } $sessionStart = $startBalance ?? (float) $session->start_balance; $delta = $endBalance - $sessionStart; // positive = player won, negative = player lost $newBalance = max(0, (float) $user->balance + $delta); $user->balance = $newBalance; $user->save(); Log::info(sprintf( '[BetiX] Session ended | player=%s | game=%s | rounds=%d | delta=%+.4f BTX | new_balance=%.4f', $playerId, $game ?? '?', $rounds, $delta, $newBalance )); }); return response('OK', 200); } /** * POST /api/betix/round * * Called by the BetiX game server after each completed round. * Updates the user's balance incrementally and logs to game_bets. * * Payload: { session_token, player_id, new_balance, currency, server_seed_hash, round } */ public function roundUpdate(Request $request, BetiXClient $client): \Illuminate\Http\Response { $rawBody = $request->getContent(); $sig = $request->header('X-Signature', ''); $secret = config('services.betix.webhook_secret', ''); if ($secret !== '' && !$client->verifyWebhook($rawBody, $sig)) { Log::warning('[BetiX] Round webhook signature mismatch', ['ip' => $request->ip()]); return response('Unauthorized', 401); } $payload = json_decode($rawBody, true); if (!$payload || !isset($payload['session_token'], $payload['player_id'], $payload['new_balance'])) { return response('Bad Request', 400); } $token = $payload['session_token']; $playerId = (int) $payload['player_id']; $newBalance = (float) $payload['new_balance']; $seedHash = $payload['server_seed_hash'] ?? null; $roundNumber = $payload['round'] ?? null; $currency = strtoupper($payload['currency'] ?? 'EUR'); $session = OperatorSession::where('session_token', $token)->first(); if (!$session || $session->status !== 'active') { // Unknown or already-ended session — acknowledge without processing return response('OK', 200); } DB::transaction(function () use ($session, $playerId, $newBalance, $seedHash, $roundNumber, $currency) { $oldSessionBalance = (float) $session->current_balance; $delta = $newBalance - $oldSessionBalance; // positive = win, negative = loss // Update session's running balance + optionally rotate the seed hash $sessionUpdate = ['current_balance' => $newBalance]; if ($seedHash) { $sessionUpdate['server_seed_hash'] = $seedHash; } $session->update($sessionUpdate); // Apply balance change to user (row-locked for safety) $user = User::lockForUpdate()->find($playerId); if (!$user) { return; } $userBalanceBefore = (float) $user->balance; $userBalanceAfter = max(0.0, $userBalanceBefore + $delta); $user->balance = $userBalanceAfter; $user->save(); // Log the round to game_bets (wager = loss side, payout = win side) GameBet::create([ 'user_id' => $playerId, 'game_name' => $session->game_slug, 'wager_amount' => $delta < 0 ? abs($delta) : 0, 'payout_amount' => $delta > 0 ? $delta : 0, 'payout_multiplier'=> 0, 'currency' => $currency, 'session_token' => $session->session_token, 'round_number' => $roundNumber, 'server_seed_hash' => $seedHash, ]); Log::info(sprintf( '[BetiX] Round update | player=%d | game=%s | round=%s | delta=%+.4f | balance=%.4f→%.4f', $playerId, $session->game_slug, $roundNumber ?? '?', $delta, $userBalanceBefore, $userBalanceAfter )); }); return response('OK', 200); } }