siip-whatsapp-notifications.../src/Facade/AbstractStripeOperationsFacade.php

976 lines
44 KiB
PHP
Executable File

<?php
declare(strict_types=1);
namespace SmsNotifier\Facade;
use SmsNotifier\Data\NotificationData;
use SmsNotifier\Factory\MessageTextFactory;
use SmsNotifier\Service\Logger;
use SmsNotifier\Service\SmsNumberProvider;
use GuzzleHttp\Client;
use Ubnt\UcrmPluginSdk\Service\UcrmApi;
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
use Stripe\StripeClient;
abstract class AbstractStripeOperationsFacade
{
protected $logger;
protected $messageTextFactory;
protected $clientPhoneNumber;
protected $ucrmApi;
private $systemAttributesCache = null;
public function __construct(Logger $logger, MessageTextFactory $messageTextFactory, SmsNumberProvider $clientPhoneNumber)
{
$this->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;
}