976 lines
44 KiB
PHP
Executable File
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;
|
|
}
|