token()) ->acceptJson() ->asJson() ->timeout(25) ->retry(2, 350); if (strtoupper($method) === 'POST') { $request = $request->withHeaders(['X-Idempotency-Key' => (string) Str::uuid()]); $response = $request->post('https://api.mercadopago.com' . $path, $payload); } else { $response = $request->get('https://api.mercadopago.com' . $path); } $json = $response->json(); if (!$response->successful() || !is_array($json)) { Log::error('GIGAX BET Mercado Pago error', [ 'path' => $path, 'status' => $response->status(), 'body' => $response->body(), ]); throw new \RuntimeException('Mercado Pago recusou a operação. HTTP ' . $response->status()); } return $json; } public function createPix(int $userId, string $purpose, ?int $betRoomId = null, ?int $lobbyId = null): array { if (!in_array($purpose, ['create', 'join'], true)) { throw new \InvalidArgumentException('Finalidade de pagamento inválida.'); } $user = DB::table('users')->where('id', $userId)->first(); if (!$user) throw new \RuntimeException('Usuário não encontrado.'); $email = trim((string) ($user->email ?? '')); if ($email === '') throw new \RuntimeException('Cadastre um e-mail válido no perfil antes de pagar.'); // Evita múltiplos PIX pendentes idênticos em cliques repetidos. $recent = DB::table('gigax_bet_payments') ->where('user_id', $userId) ->where('purpose', $purpose) ->when($betRoomId, fn($q) => $q->where('bet_room_id', $betRoomId)) ->when($lobbyId, fn($q) => $q->where('lobby_id', $lobbyId)) ->whereIn('status', ['pending', 'in_process']) ->where('created_at', '>=', now()->subMinutes(20)) ->orderByDesc('id') ->first(); if ($recent && !empty($recent->mp_payment_id)) { try { $remote = $this->getPayment((string) $recent->mp_payment_id); $tx = data_get($remote, 'point_of_interaction.transaction_data', []); if (!empty($tx['qr_code'])) { return $this->formatPix($recent, $remote); } } catch (\Throwable $e) { report($e); } } $localId = DB::table('gigax_bet_payments')->insertGetId([ 'bet_room_id' => $betRoomId, 'lobby_id' => $lobbyId, 'user_id' => $userId, 'purpose' => $purpose, 'amount_cents' => self::ENTRY_CENTS, 'status' => 'creating', 'created_at' => now(), 'updated_at' => now(), ]); $external = 'GIGAXBET:' . $localId . ':' . $purpose . ':' . $userId; $baseUrl = rtrim((string) config('app.url'), '/'); $payload = [ 'transaction_amount' => self::ENTRY_CENTS / 100, 'description' => $purpose === 'create' ? 'GIGAX Arena - Criacao de Sala Apostada' : 'GIGAX Arena - Entrada em Sala Apostada', 'payment_method_id' => 'pix', 'external_reference' => $external, 'payer' => ['email' => $email], ]; if ($baseUrl !== '') { $payload['notification_url'] = $baseUrl . '/api/mercadopago/bet-webhook'; } try { $remote = $this->api('POST', '/v1/payments', $payload); $paymentId = (string) ($remote['id'] ?? ''); if ($paymentId === '') throw new \RuntimeException('Mercado Pago não retornou o ID do pagamento.'); DB::table('gigax_bet_payments')->where('id', $localId)->update([ 'mp_payment_id' => $paymentId, 'external_reference' => $external, 'status' => (string) ($remote['status'] ?? 'pending'), 'updated_at' => now(), ]); $local = DB::table('gigax_bet_payments')->where('id', $localId)->first(); return $this->formatPix($local, $remote); } catch (\Throwable $e) { DB::table('gigax_bet_payments')->where('id', $localId)->update([ 'status' => 'error', 'updated_at' => now(), ]); throw $e; } } public function getPayment(string $paymentId): array { return $this->api('GET', '/v1/payments/' . rawurlencode($paymentId)); } public function syncLocalPayment(int $localId): array { $local = DB::table('gigax_bet_payments')->where('id', $localId)->first(); if (!$local) throw new \RuntimeException('Pagamento não encontrado.'); if (empty($local->mp_payment_id)) return ['local' => $local, 'approved' => false]; $remote = $this->getPayment((string) $local->mp_payment_id); return $this->applyRemote($local, $remote); } public function syncByMpPaymentId(string $paymentId): array { $remote = $this->getPayment($paymentId); $external = (string) ($remote['external_reference'] ?? ''); $local = DB::table('gigax_bet_payments') ->where('mp_payment_id', $paymentId) ->when($external !== '', fn($q) => $q->orWhere('external_reference', $external)) ->orderByDesc('id') ->first(); if (!$local) return ['approved' => false, 'ignored' => true, 'remote' => $remote]; return $this->applyRemote($local, $remote); } private function applyRemote(object $local, array $remote): array { $remoteId = (string) ($remote['id'] ?? ''); $external = (string) ($remote['external_reference'] ?? ''); $status = (string) ($remote['status'] ?? 'unknown'); $amountCents = (int) round(((float) ($remote['transaction_amount'] ?? 0)) * 100); $expectedExternal = (string) ($local->external_reference ?? ''); $valid = $remoteId !== '' && $remoteId === (string) $local->mp_payment_id && $expectedExternal !== '' && hash_equals($expectedExternal, $external) && $amountCents === (int) $local->amount_cents; if (!$valid) { Log::warning('GIGAX BET pagamento remoto não confere', [ 'local_id' => $local->id, 'remote_id' => $remoteId, 'external' => $external, 'amount_cents' => $amountCents, ]); throw new \RuntimeException('Pagamento recebido não corresponde à cobrança da sala.'); } DB::table('gigax_bet_payments')->where('id', $local->id)->update([ 'status' => $status, 'approved_at' => $status === 'approved' ? now() : $local->approved_at, 'updated_at' => now(), ]); return [ 'local' => DB::table('gigax_bet_payments')->where('id', $local->id)->first(), 'remote' => $remote, 'approved' => $status === 'approved', ]; } private function formatPix(object $local, array $remote): array { $tx = (array) data_get($remote, 'point_of_interaction.transaction_data', []); return [ 'local_payment_id' => (int) $local->id, 'mp_payment_id' => (string) ($remote['id'] ?? $local->mp_payment_id ?? ''), 'status' => (string) ($remote['status'] ?? $local->status ?? 'pending'), 'qr_code' => (string) ($tx['qr_code'] ?? ''), 'qr_code_base64' => (string) ($tx['qr_code_base64'] ?? ''), 'ticket_url' => (string) ($tx['ticket_url'] ?? ''), 'amount' => self::ENTRY_CENTS / 100, ]; } }