logger = $logger; $this->messageTextFactory = $messageTextFactory; $this->clientPhoneNumber = $clientPhoneNumber; $config = PluginConfigManager::create()->loadConfig(); $ipServer = $config['ipserver'] ?? 'localhost'; $apiUrl = "https://$ipServer/crm/api/v1.0/"; $client = new Client([ 'base_uri' => $apiUrl, 'verify' => false, ]); $this->ucrmApi = new UcrmApi($client, $config['apitoken'] ?? ''); } public function createPaymentIntent(array $eventJson): void { $config = PluginConfigManager::create()->loadConfig(); $stripe = new StripeClient($config['tokenstripe']); $customer = $eventJson['data']['object']['customer'] ?? null; $amount = $eventJson['data']['object']['net_amount'] ?? 0; if (!$customer || $amount <= 0) { $this->logger->warning("Datos inválidos para PaymentIntent: Customer=$customer, Amount=$amount"); return; } try { $stripeCustomer = $stripe->customers->retrieve($customer); $ucrmClientId = $stripeCustomer->metadata->ucrm_client_id ?? null; // [FIX] Validar el Saldo Disponible en la Billetera (Cash Balance) // Si la cuenta tiene reconciliación automática, Stripe absorbe fondos instantáneamente // para pagar intenciones preexistentes. Si ya se gastaron los fondos, evitamos duplicar. $cashBalance = $stripe->customers->retrieveCashBalance($customer, []); $availableMxn = $cashBalance->available->mxn ?? 0; $amountCents = (int)$amount; // Si el saldo disponible es menor al monto fondeado, significa que una PI // preexistente ya "se tragó" este saldo automáticamente. if ($availableMxn < $amountCents) { $this->logger->info("Fondos ya reconciliados por Stripe. Saldo disponible ({$availableMxn}) es menor al evento ({$amountCents}). Se descarta la creación de un nuevo PaymentIntent duplicado."); return; } $pi = $stripe->paymentIntents->create([ 'amount' => (int)$amount, 'currency' => 'mxn', 'customer' => $customer, 'payment_method_types' => ['customer_balance'], 'payment_method_data' => ['type' => 'customer_balance'], 'confirm' => true, 'payment_method_options' => [ 'customer_balance' => [ 'funding_type' => 'bank_transfer', 'bank_transfer' => ['type' => 'mx_bank_transfer'] ], ], 'metadata' => [ 'clientId' => $ucrmClientId, 'createdBy' => 'UCRM', 'signedInAdminId' => $config['idPaymentAdminCRM'], 'tipoPago' => 'Transferencia Bancaria' ], ], ['idempotency_key' => $eventJson['id'] ?? null]); $this->logger->info("PaymentIntent creado: " . $pi->id); } catch (\Exception $e) { $this->logger->error("Error creando PaymentIntent: " . $e->getMessage()); } } public function registerPaymentFromWebhook(array $eventJson): void { $config = PluginConfigManager::create()->loadConfig(); $stripe = new StripeClient($config['tokenstripe']); $data = $eventJson['data']['object']; $piId = $data['applied_to_payment']['payment_intent'] ?? null; if (!$piId) return; try { $pi = $stripe->paymentIntents->retrieve($piId); $clientId = $pi->metadata->clientId ?? null; if (!$clientId) return; $methodId = $this->findPaymentMethodId('Transferencia bancaria'); if ($methodId) { $this->ucrmApi->post('payments', [ 'clientId' => (int)$clientId, 'amount' => abs($data['net_amount']) / 100, 'currencyCode' => strtoupper($pi->currency), 'methodId' => $methodId, 'note' => "Transferencia Bancaria (Stripe Saldo) - PI: $piId", 'createdDate' => date('c'), ]); } } catch (\Exception $e) { $this->logger->error("Error al registrar pago en UCRM: " . $e->getMessage()); } } public function registerPaymentFromIntent(array $data): void { $piId = $data['id'] ?? null; if (!$piId) return; if (($data['status'] ?? '') !== 'succeeded') { $this->logger->warning("PaymentIntent $piId no está succeeded (status: " . ($data['status'] ?? 'unknown') . ")"); return; } // Buscar clientId en metadata (prioridad: clientId > ucrm_client_id) $clientId = $data['metadata']['clientId'] ?? $data['metadata']['ucrm_client_id'] ?? null; // Fallback: Buscar por Stripe Customer ID si no hay metadata if (!$clientId && !empty($data['customer'])) { $clientId = $this->findClientIdByStripeCustomer($data['customer']); if ($clientId) { $this->logger->info("Cliente encontrado por Stripe ID ({$data['customer']}): $clientId"); } } if (!$clientId) { $this->logger->warning("PaymentIntent $piId no tiene clientId en metadata y no se encontró por Customer ID ({$data['customer']})."); return; } // [NEW] Check for duplicate payment (same PI ID) try { $existingPayments = $this->ucrmApi->get('payments', [ 'clientId' => $clientId, 'limit' => 20, 'order' => 'createdDate', 'direction' => 'DESC' ]); foreach ($existingPayments as $p) { // Check note for PI ID if (isset($p['note']) && strpos($p['note'], $piId) !== false) { $this->logger->info("Pago duplicado detectado para PI $piId (ID existente: {$p['id']}). Omitiendo creación."); return; } // Also check duplicate by transaction ID if applicable if (isset($p['transactionId']) && $p['transactionId'] === $piId) { $this->logger->info("Pago duplicado detectado (Transaction ID) para PI $piId (ID existente: {$p['id']}). Omitiendo creación."); return; } } } catch (\Exception $e) { $this->logger->warning("Falló la verificación de duplicados para PI $piId: " . $e->getMessage()); } try { // Intentar detectar Payment Method name basado en tipo $type = $data['payment_method_types'][0] ?? 'card'; $methodSearchName = ($type === 'oxxo') ? 'OXXO' : 'Stripe'; // Buscar ID de metodo en UCRM $methodId = $this->findPaymentMethodId($methodSearchName); if (!$methodId && $methodSearchName === 'OXXO') { $methodId = $this->findPaymentMethodId('Stripe'); // Fallback } if ($methodId) { $checkAmount = ($data['amount_received'] ?? $data['amount']) / 100; $this->ucrmApi->post('payments', [ 'clientId' => (int)$clientId, 'amount' => $checkAmount, 'currencyCode' => strtoupper($data['currency'] ?? 'MXN'), 'methodId' => $methodId, 'note' => "Stripe ($methodSearchName) - PI: $piId", 'createdDate' => date('c'), ]); $this->logger->info("Pago registrado en UCRM vía PI Succeeded: $piId, Cliente: $clientId, Monto: $checkAmount"); } else { $this->logger->warning("No se encontró método de pago para '$methodSearchName' en UCRM."); } } catch (\Exception $e) { $this->logger->error("Error al registrar pago desde PaymentIntent: " . $e->getMessage()); } } private function findClientIdByStripeCustomer(string $stripeCustomerId): ?int { try { // Nota: Esto puede ser lento si hay muchos clientes, pero es un fallback. // Idealmente usaríamos $this->ucrmApi->get('clients', ['customAttributeKey' => 'stripeCustomerId', ...]) si existiera ese filtro. // Como fallback, buscamos en los clientes que tenemos cacheados o hacemos un search. // UCRM API permite filtrar por userIdent? No directamente por atributo custom en GET /clients sin plugin extentions. // PERO podemos usar la API de 'clients' y filtrar en memoria si no son muchos, o confiar en que el usuario ya usó metadata en el futuro. // MEJOR OPCION: Usar el endpoint de attributes si es posible, o iterar. // Dado que no queremos matar el server, limitaremos la búsqueda o asumiremos que el FIX de metadata es el principal. // Sin embargo, para este caso específico, vamos a intentar buscar en los clientes recientes o usar una búsqueda básica. // UCRM API v1.0 GET /clients soporta ?customAttributeId&customAttributeValue $attrId = $this->resolveAttributeId('stripeCustomerId'); if (!$attrId) return null; $clients = $this->ucrmApi->get('clients', [ 'customAttributeId' => $attrId, 'customAttributeValue' => $stripeCustomerId ]); if (!empty($clients) && isset($clients[0]['id'])) { return (int)$clients[0]['id']; } } catch (\Exception $e) { $this->logger->error("Error buscando cliente por Stripe ID: " . $e->getMessage()); } return null; } private function findPaymentMethodId(string $name): ?int { try { $methods = $this->ucrmApi->get('payment-methods'); foreach ($methods as $m) { if (stripos($m['name'], $name) !== false) { return $m['id']; } } } catch (\Exception $e) { } return null; } public function createStripeClient(NotificationData $notificationData, string $tagName, bool $generateSpei = true): void { $clientId = $notificationData->clientId; if (!$clientId) return; $config = PluginConfigManager::create()->loadConfig(); $stripe = new StripeClient($config['tokenstripe']); try { $clientCRM = $this->ucrmApi->get("clients/$clientId", []); // Automaticamente creará el el Stripe Customer si no existe $customer = $this->createCustomerStripe($stripe, $clientCRM, $generateSpei); if ($customer) { $this->logger->info("Cliente Stripe procesado para ID: $clientId (Tag: $tagName, SPEI: " . ($generateSpei ? 'SI' : 'NO') . ")"); } } catch (\Exception $e) { $this->logger->error("Error en createStripeClient para cliente $clientId: " . $e->getMessage()); } finally { // Garantizamos quitar la etiqueta siempre para evitar re-ejecuciones accidentales $this->removeTagFromClient($clientId, $tagName); } } protected function createCustomerStripe(StripeClient $stripe, array $clientCRM, bool $generateSpei): ?\Stripe\Customer { $clientId = $clientCRM['id']; // Extraer email de contactos (prioridad) o username $email = $clientCRM['username'] ?? null; foreach ($clientCRM['contacts'] ?? [] as $contact) { if ($contact['isBilling'] || $contact['isContact']) { $email = $contact['email'] ?? $email; if ($email) break; } } $name = trim(($clientCRM['firstName'] ?? '') . ' ' . ($clientCRM['lastName'] ?? '')); if (empty($name) && !empty($clientCRM['companyName'])) { $name = $clientCRM['companyName']; } // Obtener IDs de atributos dinámicamente desde el sistema $cidAttrId = $this->resolveAttributeId('stripeCustomerId'); $clabeAttrId = $this->resolveAttributeId('clabeInterbancaria'); $this->logger->debug("IDs de atributos resueltos: Stripe=$cidAttrId, CLABE=$clabeAttrId"); // Buscar cliente existente por metadata $customers = $stripe->customers->search([ 'query' => "metadata['ucrm_client_id']:'$clientId'", ]); if ($customers->count() > 0) { $customer = $customers->data[0]; // Sincronizar datos básicos $stripe->customers->update($customer->id, [ 'email' => $email, 'name' => $name, ]); $this->logger->info("Datos básicos de Cliente Stripe sincronizados para ID: $clientId"); } else { $params = [ 'email' => $email, 'name' => $name, 'metadata' => ['ucrm_client_id' => $clientId] ]; $customer = $stripe->customers->create($params); $this->logger->info("Nuevo Cliente Stripe creado para ID: $clientId. CID: {$customer->id}"); } // Guardar CID en UCRM siempre, por si no estaba sincronizado $this->patchClientCustomAttribute($clientId, (int)$cidAttrId, $customer->id); // Si se requiere SPEI, generamos las instrucciones de fondeo para obtener la CLABE if ($generateSpei) { try { $this->logger->info("Solicitando instrucciones de fondeo (CLABE) para cliente: {$customer->id}"); $fundingInstructions = $stripe->customers->createFundingInstructions( $customer->id, [ 'currency' => 'mxn', 'funding_type' => 'bank_transfer', 'bank_transfer' => ['type' => 'mx_bank_transfer'], ] ); $clabe = $fundingInstructions['bank_transfer']['financial_addresses'][0]['spei']['clabe'] ?? null; if ($clabe) { $this->logger->info("CLABE obtenida via Funding Instructions para cliente $clientId: $clabe"); $this->patchClientCustomAttribute($clientId, (int)$clabeAttrId, $clabe); } else { $this->logger->warning("Stripe no devolvió una CLABE en las instrucciones de fondeo para el cliente $clientId."); } } catch (\Exception $e) { $this->logger->error("Error al crear instrucciones de fondeo para cliente $clientId: " . $e->getMessage()); } } return $customer; } protected function getVaultCredentialsByClientId($clientId): string { $config = PluginConfigManager::create()->loadConfig(); $ipServer = $config['ipserver'] ?? ''; try { // OPT: Lazy Check - Si ya tiene pass válido en CRM, no hace falta procesar nada $clientData = $this->ucrmApi->get("clients/$clientId"); $passCRM = ''; if (isset($clientData['attributes'])) { foreach ($clientData['attributes'] as $attr) { if ($attr['key'] === 'passwordAntenaCliente') { $passCRM = $attr['value'] ?? ''; break; } } } // Si el campo no está vacío y no tiene advertencias, usamos el actual para ahorrar recursos if (!empty($passCRM) && strpos($passCRM, '⚠️') === false) { return $passCRM; } // 1. Obtener los servicios del cliente $svcs = $this->ucrmApi->get('clients/services', ['clientId' => $clientId]); if (empty($svcs)) { $msg = '⚠️ Cliente sin servicios/antenas'; $this->syncPasswordWithCrm((int)$clientId, $msg); return $msg; } $unms = new Client(['base_uri' => "https://{$ipServer}/nms/api/v2.1/", 'verify' => false, 'headers' => ['X-Auth-Token' => $config['apitoken']]]); $allServicePasswords = []; $isTestEnv = ($ipServer === '172.16.5.134' || $ipServer === 'pruebas.internet.mx' || $ipServer === 'venus.siip.mx'); $numServices = count($svcs); foreach ($svcs as $index => $svc) { $label = ($numServices > 1) ? "Servicio " . ($index + 1) . ":" : ""; $siteId = $svc['unmsClientSiteId'] ?? null; $passwordValue = ""; if (!$siteId) { $passwordValue = "⚠️ Sin sitio"; } else { if ($isTestEnv) { // Lógica de bypass: intentar recuperar de la cadena existente $foundInCRM = false; if (!empty($passCRM)) { if ($numServices > 1) { if (preg_match('/Servicio ' . ($index + 1) . ':\s*([^⚠️\s]+)/', $passCRM, $matches)) { $passwordValue = $matches[1]; $foundInCRM = true; } } else { // Caso de un solo servicio: si no tiene advertencias ni etiquetas, asumimos que es el pass if (strpos($passCRM, '⚠️') === false && strpos($passCRM, 'Servicio') === false) { $passwordValue = trim($passCRM); $foundInCRM = true; } } } if (!$foundInCRM) { $passwordValue = $this->generateStrongPassword(16); // [NEW] Static Test Data for Site/Antenna $this->syncNetworkDataWithCrm((int)$clientId, 'VENUS', 'Sectorial de pruebas 172.16.5.134'); } else { // Even if found, ensure static data in test env $this->syncNetworkDataWithCrm((int)$clientId, 'VENUS', 'Sectorial de pruebas 172.16.5.134'); } } else { // Lógica de producción try { $respDev = $unms->get("devices?siteId=$siteId", [ 'headers' => ['X-Auth-Token' => $config['unmsApiToken']] ]); $devs = json_decode($respDev->getBody()->getContents(), true); if (empty($devs)) { $passwordValue = "⚠️ Sin antena"; } else { $passVault = null; $firstDeviceId = null; foreach ($devs as $dev) { $deviceId = $dev['identification']['id'] ?? null; if (!$deviceId) continue; if (!$firstDeviceId) $firstDeviceId = $deviceId; try { $respVault = $unms->get("vault/$deviceId/credentials", [ 'headers' => ['X-Auth-Token' => $config['unmsApiToken']] ]); $vault = json_decode($respVault->getBody()->getContents(), true); if (isset($vault['credentials'][0]['password'])) { $passVault = $vault['credentials'][0]['password']; break; } } catch (\Exception $e) { continue; } } if ($passVault) { $passwordValue = $passVault; } else if ($firstDeviceId) { // Regenerar $newPass = $this->generateStrongPassword(16); try { $unms->post("vault/$firstDeviceId/credentials/regenerate", [ 'headers' => ['X-Auth-Token' => $config['unmsApiToken']], 'json' => [['username' => 'ubnt', 'password' => $newPass, 'readOnly' => true]] ]); $passwordValue = $newPass; } catch (\Exception $e) { $passwordValue = $newPass; } } else { $passwordValue = "⚠️ Sin antena"; } } } catch (\Exception $e) { $passwordValue = "⚠️ Error API"; } } } $allServicePasswords[] = trim("$label $passwordValue"); } $finalValue = implode(' ', $allServicePasswords); // Evitar sincronización redundante si el valor es idéntico al actual (anti-bucle) if ($finalValue === $passCRM) { return $finalValue; } $this->syncPasswordWithCrm((int)$clientId, $finalValue); return $finalValue; } catch (\Exception $e) { $this->logger->error("Excepción en getVaultCredentialsByClientId (Cliente: $clientId): " . $e->getMessage()); return 'Error: ' . $e->getMessage(); } } private function syncNetworkDataWithCrm(int $clientId, string $siteName, string $deviceInfo): void { try { $siteAttrId = $this->resolveAttributeId('site'); $antennaAttrId = $this->resolveAttributeId('antenaSectorial'); if ($siteAttrId) { $this->patchClientCustomAttribute($clientId, $siteAttrId, $siteName); } if ($antennaAttrId) { $this->patchClientCustomAttribute($clientId, $antennaAttrId, $deviceInfo); } } catch (\Exception $e) { $this->logger->warning("Fallo al sincronizar datos de red estáticos en test env para cliente $clientId: " . $e->getMessage()); } } private function syncPasswordWithCrm(int $clientId, string $passVault): void { try { $clientData = $this->ucrmApi->get("clients/$clientId"); $passCRM = ''; $attributeId = $this->resolveAttributeId('passwordAntenaCliente'); if (isset($clientData['attributes'])) { foreach ($clientData['attributes'] as $attr) { if ($attr['key'] === 'passwordAntenaCliente') { $passCRM = $attr['value'] ?? ''; $attributeId = $attr['customAttributeId'] ?? $attributeId; break; } } } if ($attributeId <= 0) { $this->logger->error("No se pudo resolver el ID de atributo para 'passwordAntenaCliente'. Omitiendo sincronización para cliente $clientId."); return; } if (empty($passCRM) || $passCRM !== $passVault) { $this->logger->info("Sincronizando contraseña en CRM para cliente $clientId. [" . ($passCRM ?: 'VACIO') . "] -> [$passVault] (Atributo ID: $attributeId)"); $this->patchClientCustomAttribute($clientId, (int)$attributeId, $passVault); } } catch (\Exception $e) { $this->logger->warning("Fallo al sincronizar contraseña con CRM para cliente $clientId: " . $e->getMessage()); } } protected function generateStrongPassword(int $length = 16): string { $lower = 'abcdefghijkmnopqrstuvwxyz'; // Eliminamos 'l' $upper = 'ABCDEFGHJKLMNPQRSTUVWXYZ'; // Eliminamos 'I', 'O' $digits = '23456789'; // Eliminamos '1', '0' $symbols = '@#'; // Solo símbolos amigables para impresoras térmicas $all = $lower . $upper . $digits . $symbols; $pwChars = []; // Asegurar que tenga al menos uno de cada tipo si es posible $pwChars[] = $lower[random_int(0, strlen($lower) - 1)]; $pwChars[] = $upper[random_int(0, strlen($upper) - 1)]; $pwChars[] = $digits[random_int(0, strlen($digits) - 1)]; $pwChars[] = $symbols[random_int(0, strlen($symbols) - 1)]; for ($i = count($pwChars); $i < $length; $i++) { $pwChars[] = $all[random_int(0, strlen($all) - 1)]; } // Mezclar Fisher-Yates $n = count($pwChars); for ($i = $n - 1; $i > 0; $i--) { $j = random_int(0, $i); $tmp = $pwChars[$i]; $pwChars[$i] = $pwChars[$j]; $pwChars[$j] = $tmp; } return implode('', $pwChars); } protected function patchClientCustomAttribute(int $clientId, int $attributeId, string $value): bool { if ($attributeId <= 0) { $this->logger->error("Intento de patchAttribute con ID inválido ($attributeId) para cliente $clientId"); return false; } try { $this->logger->debug("Intentando PATCH en clients/$clientId para atributo ID: $attributeId con valor: $value"); $this->ucrmApi->patch("clients/$clientId", [ 'attributes' => [ [ 'customAttributeId' => $attributeId, 'value' => $value, ], ], ]); return true; } catch (\Exception $e) { $this->logger->error("Error patching custom attribute for client $clientId: " . $e->getMessage()); return false; } } private function resolveAttributeId(string $key): int { if ($this->systemAttributesCache === null) { try { $this->systemAttributesCache = $this->ucrmApi->get('custom-attributes', ['attributeType' => 'client']); } catch (\Exception $e) { $this->logger->error("No se pudieron cargar los atributos del sistema: " . $e->getMessage()); return 0; } } foreach ($this->systemAttributesCache as $attr) { if ($attr['key'] === $key) { return (int)$attr['id']; } } return 0; } private function resolvePaymentAttributeId(string $key): int { try { $attrs = $this->ucrmApi->get('custom-attributes', ['attributeType' => 'payment']); foreach ($attrs as $attr) { if ($attr['key'] === $key) { return (int)$attr['id']; } } } catch (\Exception $e) { $this->logger->error("Error resolving payment custom attribute '$key': " . $e->getMessage()); } return 0; } protected function comparePasswords(?string $crm, ?string $vault): string { if ($crm && strpos($crm, 'Error') !== 0) return $crm; if ($vault && strpos($vault, 'Error') !== 0) return $vault; return '⚠️ Probar pass conocida.'; } public function syncStripeCustomerData(int $clientId, string $name, ?string $email): void { $config = PluginConfigManager::create()->loadConfig(); $stripe = new StripeClient($config['tokenstripe']); try { $customers = $stripe->customers->search([ 'query' => "metadata['ucrm_client_id']:'$clientId'", ]); if ($customers->count() > 0) { $customer = $customers->data[0]; $stripe->customers->update($customer->id, [ 'name' => $name, 'email' => $email, ]); $this->logger->info("Sincronización automática a Stripe exitosa para cliente $clientId."); } } catch (\Exception $e) { $this->logger->error("Error sincronizando cliente $clientId a Stripe: " . $e->getMessage()); } } protected function removeTagFromClient(int $clientId, string $tagName): void { try { $client = $this->ucrmApi->get("clients/$clientId"); $targetTagId = null; $remainingTags = []; foreach ($client['tags'] as $tag) { if ($tag['name'] === $tagName) { $targetTagId = $tag['id']; break; } } if ($targetTagId) { // The proper UCRM endpoint to remove a tag from a client is PATCH /clients/{id}/remove-tag/{tagId} $this->ucrmApi->patch("clients/$clientId/remove-tag/$targetTagId"); $this->logger->info("Etiqueta '$tagName' (ID: $targetTagId) removida del cliente $clientId."); } else { $this->logger->debug("Etiqueta '$tagName' no encontrada en el cliente $clientId, nada que remover."); } } catch (\Exception $e) { $this->logger->error("Error al remover etiqueta '$tagName' del cliente $clientId: " . $e->getMessage()); } } protected function validarNumeroTelefono($n): string { if (!$n) return ''; $n = preg_replace('/\D/', '', (string)$n); if (strlen($n) === 10) { return '521' . $n; } if (strlen($n) === 12 && strpos($n, '52') === 0) { return '521' . substr($n, 2); } return $n; } public function ensureStripePaymentAttribute($notificationObject): void { // 1. Get Payment ID $paymentId = is_object($notificationObject) ? ($notificationObject->entityId ?? null) : ($notificationObject['entityId'] ?? null); if (!$paymentId) { $this->logger->warning("ensureStripePaymentAttribute: No entityId found in notification."); return; } $this->logger->info("Verificando existencia de atributo 'tipoPagoStripe' para Payment ID: $paymentId"); try { // Load Config $config = PluginConfigManager::create()->loadConfig(); $ipPuppeteer = $config['ipPuppeteer'] ?? 'localhost'; $portPuppeteer = $config['portPuppeteer'] ?? '3000'; $stripeUserId = $config['idPaymentAdminCRM'] ?? null; $microserviceBaseUrl = "http://$ipPuppeteer:$portPuppeteer"; $httpClient = new Client(); // 2. Fetch Metadata from Microservice (DB Access) $metadataTipoPago = null; try { $response = $httpClient->get("$microserviceBaseUrl/stripe-metadata/$paymentId", ['timeout' => 5]); $data = json_decode($response->getBody()->getContents(), true); if (isset($data['metadata']['tipoPago'])) { $metadataTipoPago = $data['metadata']['tipoPago']; $this->logger->info("Microservice found metadata: tipoPago = '$metadataTipoPago'"); } // Capture Stripe ID for prefix-based heuristic detection below $metadataStripeId = $data['stripeId'] ?? null; } catch (\Throwable $e) { $this->logger->warning("Microservice metadata fetch failed: " . $e->getMessage()); } // --- LOCAL FALLBACK LOGIC --- if (!$metadataTipoPago && isset($config['tokenstripe'])) { try { $this->logger->info("Attempting direct Stripe API fetch for Payment $paymentId"); $paymentInfo = $this->ucrmApi->get('payments/' . $paymentId); $clientId = $paymentInfo['clientId'] ?? null; $amount = isset($paymentInfo['amount']) ? round($paymentInfo['amount'] * 100) : null; if ($clientId && $amount) { $clientInfo = $this->ucrmApi->get('clients/' . $clientId); $stripeCustomerId = null; if (isset($clientInfo['attributes'])) { foreach ($clientInfo['attributes'] as $attr) { if (stripos($attr['name'] ?? '', 'Stripe') !== false && stripos($attr['name'] ?? '', 'Customer') !== false) { $stripeCustomerId = $attr['value']; break; } if (($attr['key'] ?? '') === 'stripeCustomerId' || ($attr['key'] ?? '') === 'customerIdStripe') { $stripeCustomerId = $attr['value']; break; } } } if ($stripeCustomerId) { $stripeClient = new Client([ 'base_uri' => 'https://api.stripe.com/v1/', 'auth' => [$config['tokenstripe'], ''], 'timeout' => 5 ]); $res = $stripeClient->get("payment_intents?customer=$stripeCustomerId&limit=5"); $intents = json_decode($res->getBody()->getContents(), true); if (isset($intents['data']) && is_array($intents['data'])) { foreach ($intents['data'] as $intent) { if (isset($intent['amount']) && $intent['amount'] == $amount) { if (isset($intent['metadata']['tipoPago'])) { $metadataTipoPago = $intent['metadata']['tipoPago']; $this->logger->info("Direct Stripe API found metadata: tipoPago = '$metadataTipoPago'"); break; } if (isset($intent['payment_method_types'])) { if (in_array('oxxo', $intent['payment_method_types'])) { $metadataTipoPago = 'OXXO'; $this->logger->info("Direct Stripe API guessed 'OXXO' from payment_method_types"); break; } elseif (in_array('customer_balance', $intent['payment_method_types']) || in_array('spei', $intent['payment_method_types'])) { $metadataTipoPago = 'Transferencia Bancaria'; $this->logger->info("Direct Stripe API guessed 'Transferencia Bancaria' from payment_method_types"); break; } } } } } } } } catch (\Throwable $e) { $this->logger->warning("Direct Stripe API fallback failed: " . $e->getMessage()); } } // 2.5. Last-resort heuristic: use Stripe ID prefix when metadata and API both fail // In Mexico ISP context: py_ prefix = OXXO (cash) payment, ch_ prefix = card charge if (!$metadataTipoPago && !empty($metadataStripeId)) { if (str_starts_with($metadataStripeId, 'py_')) { $metadataTipoPago = 'OXXO'; $this->logger->info("Heuristic detection: stripe_id '$metadataStripeId' starts with 'py_' → assumed OXXO Pay"); } elseif (str_starts_with($metadataStripeId, 'ch_')) { $this->logger->debug("Heuristic detection: stripe_id '$metadataStripeId' starts with 'ch_' → confirmed card"); // No change: will default to Tarjeta de crédito/débito } } // 3. Update User ID if missing (Direct DB Patch via Microservice) // UCRM API doesn't support PATCH userId, so we use microservice if ($stripeUserId) { try { $payment = $this->ucrmApi->get('payments/' . $paymentId); if (empty($payment['userId'])) { $this->logger->info("Payment $paymentId has no User ID. Assigning Stripe User ID: $stripeUserId"); $httpClient->patch("$microserviceBaseUrl/payments/$paymentId/user", [ 'json' => ['userId' => $stripeUserId], 'timeout' => 5 ]); } else { $this->logger->debug("Payment $paymentId already has User ID: " . $payment['userId']); } } catch (\Throwable $e) { $this->logger->error("Failed to patch User ID via microservice: " . $e->getMessage()); } } // [NEW] 3.5. Patch Payment Method ID via Microservice $targetMethodId = null; if ($metadataTipoPago === 'OXXO') { $targetMethodId = 'b01c0b35-b42c-48d9-9ad9-ea6591adfbbb'; // OXXO Pay } elseif ($metadataTipoPago === 'Transferencia Bancaria') { $targetMethodId = '4145b5f5-3bbc-45e3-8fc5-9cda970c62fb'; // Transferencia Bancaria } else { // [NEW] Default to "Credit/Debit Card" if no specific metadata type found $targetMethodId = '93814765-66a1-4c7d-a777-05c18fd6aab3'; // Tarjeta de crédito/débito } if ($targetMethodId) { // [NEW] Update Notification Object in Memory so the calling code knows the change if (is_object($notificationObject) && isset($notificationObject->paymentData)) { // Fix for "Indirect modification of overloaded property" error // We must read the array, modify it, and write it back. $pData = $notificationObject->paymentData; if (is_array($pData)) { $pData['methodId'] = $targetMethodId; $notificationObject->paymentData = $pData; } } try { // Check current methodId (reuse 'payment' if available, otherwise fetch) // Note: We fetched 'payment' in Step 3 ONLY if stripeUserId was valid. // Safe to fetch again or reuse specific check. $paymentCheck = $this->ucrmApi->get('payments/' . $paymentId); if ($paymentCheck['methodId'] !== $targetMethodId) { $this->logger->info("Payment $paymentId has wrong Method ID ({$paymentCheck['methodId']}). Patching to $targetMethodId via Microservice."); $httpClient->patch("$microserviceBaseUrl/payments/$paymentId/method", [ 'json' => ['methodId' => $targetMethodId], 'timeout' => 5 ]); $this->logger->info("Payment Method ID patched successfully."); } } catch (\Throwable $e) { $this->logger->error("Failed to patch Payment Method ID via microservice: " . $e->getMessage()); } } // 4. Determine Target Attribute Value // Truth Source Priority: 1. Metadata (DB), 2. Existing Attribute, 3. Method Name (Guess) // A. Check Existing Attribute (Don't overwrite valid values unless Metadata says otherwise?) // Actually, Metadata keys are stronger than manual edits if the flow is automatic. // But let's respect existing valid attributes if metadata is missing. $paymentAttributeId = $this->resolvePaymentAttributeId('tipoPagoStripe'); $payment = $this->ucrmApi->get('payments/' . $paymentId); // Re-fetch in case changed? Or Use previous result. $currentValue = null; $hasAttribute = false; foreach ($payment['attributes'] as $attr) { if ($attr['key'] === 'tipoPagoStripe' || ($paymentAttributeId > 0 && $attr['customAttributeId'] == $paymentAttributeId)) { $hasAttribute = true; $currentValue = $attr['value']; if ($paymentAttributeId <= 0) { $paymentAttributeId = $attr['customAttributeId']; } break; } } $targetValue = null; if ($metadataTipoPago) { // Normalize Metadata Values to Attribute Choice Values if ($metadataTipoPago === 'OXXO') { $targetValue = 'OXXO Pay'; } else { $targetValue = $metadataTipoPago; } } else { // Fallback to Method Name Guessing if Metadata missing if ($hasAttribute && in_array($currentValue, ['OXXO Pay', 'Transferencia Bancaria', 'Tarjeta de Crédito'])) { $this->logger->debug("Payment $paymentId ya tiene atributo '$currentValue' y no hay metadata. Respetando."); return; } $methodId = $payment['methodId']; $method = $this->ucrmApi->get('payment-methods/' . $methodId); $methodName = $method['name'] ?? ''; if (stripos($methodName, 'OXXO') !== false) { $targetValue = 'OXXO Pay'; } elseif ( stripos($methodName, 'Transferencia') !== false || stripos($methodName, 'Bank') !== false || stripos($methodName, 'transfer') !== false || stripos($methodName, 'ACH') !== false ) { $targetValue = 'Transferencia Bancaria'; } elseif ( stripos($methodName, 'Tarjeta') !== false || stripos($methodName, 'card') !== false || stripos($methodName, 'Crédito') !== false || stripos($methodName, 'débito') !== false ) { $targetValue = 'Tarjeta de Crédito'; } $this->logger->debug("Fallback Method Guessing '$methodName' -> '$targetValue'"); } // 5. Apply Update if ($targetValue && $paymentAttributeId > 0) { // Check redundancy if ($hasAttribute && $currentValue === $targetValue) { $this->logger->debug("Attribute already matches target '$targetValue'. Skipping patch."); return; } $this->logger->info("PATCHING Payment $paymentId: Setting tipoPagoStripe = '$targetValue' (Atributo ID: $paymentAttributeId)"); $this->ucrmApi->patch('payments/' . $paymentId, [ 'attributes' => [ [ 'customAttributeId' => $paymentAttributeId, 'value' => $targetValue ] ] ]); } else { $this->logger->debug("No se pudo determinar el tipoPagoStripe o no se resolvió el ID del atributo."); } } catch (\Throwable $e) { $this->logger->error("Error in ensureStripePaymentAttribute: " . $e->getMessage()); } } abstract protected function sendWhatsApp(NotificationData $notificationData, string $clientPhoneNumber): void; }