- Se corrigió el número de versión estático mostrado en el footer del portal (public.php), el cual mostraba incorrectamente la versión 4.2.1.

- Ahora la versión visual coincide correctamente con la versión actual definida en el `manifest.json` (4.6.0).
This commit is contained in:
DANYDHSV 2026-04-28 10:15:09 -06:00
parent 7942f6b50c
commit 45a0d84caa
12 changed files with 7059 additions and 230 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@
*.jpeg
.vscode/
*.zip
*.csv
*.apib
*.jrxml
*.jasper

View File

@ -1,5 +1,16 @@
# CHANGELOG - SIIP WhatsApp Notifications Plugin
## VERSIÓN 4.6.0 - 11-04-2026
### 🚀 Nuevas Características (Features)
1**Auditoría de Pagos Incompletos**: Agregados nuevos scripts (`scripts-uisp/audit_incomplete_pi.php` y `clean_incomplete_pi.php`) para auditar y limpiar intenciones de pago en el limbo que ocurrieron debido a falsas duplicidades de Stripe, protegiendo las referencias OXXO vigentes.
2**Mejora del Historial SPEI**: Ajuste de vistas (`views/stripe.php` y `public.php`) para desplegar correctamente un "Historial de Intenciones de Pago por transferencia".
### 🐛 Correcciones (Bug Fixes)
1**Fix Duplicidad Intenciones de Pago**: Removida la lógica basada en metadatos y fechas debido al retraso del Webhook de Stripe. Se implementó una verificación instantánea a nivel cliente (`customer_cash_balance_transaction.created`) de su saldo (`CashBalance`) en Stripe, evitando intenciones de pago duplicadas tras la financiación mediante transferencia SPEI.
2**Fix Fichas OXXO y Microservicio**: Restaurado el microservicio Docker (`puppeteer-server`) mediante actualización de dependencias (`p-limit`).
3**Fix Diseño de Ficha OXXO**: Evita que el panel izquierdo de llenado se estire junto a la imagen generada de la ficha, reubicando funcionalmente los accesos "Ver en CRM".
## VERSIÓN 4.5.0 - 13-03-2026
### ⚠️ Requisito Técnico (Update)

View File

@ -1,12 +1,18 @@
# SIIP - WhatsApp Notifications & Integrated Payment Portal
![Version](https://img.shields.io/badge/version-4.4.0-blue.svg?style=for-the-badge)
![Version](https://img.shields.io/badge/version-4.6.0-blue.svg?style=for-the-badge)
![UCRM Compatibility](https://img.shields.io/badge/UCRM-v2.1.0%2B-green.svg?style=for-the-badge)
![Status](https://img.shields.io/badge/status-PRODUCTION-success.svg?style=for-the-badge)
![Author](https://img.shields.io/badge/author-SIIP_INTERNET-orange.svg?style=for-the-badge)
Este plugin es una solución integral que transforma tu UCRM en un **Portal Administrativo de Última Generación**. No solo automatiza la comunicación por WhatsApp, sino que integra un Dashboard completo para la gestión de pagos online (Stripe/OXXO), visualización de comprobantes y coordinación de equipos técnicos.
## ✨ Novedades v4.6.0 (Stripe & OXXO Stability)
- **🛡️ Estabilidad en Pagos (Stripe CashBalance)**: Nuevo sistema para comprobar fondos y validación contra intención de pagos para evitar las intenciones huérfanas o duplicadas tras recibir transferencias SPEI.
- **🧹 Limpieza y Auditoría Local**: Integración local de scripts `audit_incomplete_pi` y `clean_incomplete_pi` para mantenimiento seguro de intenciones de pago incompletas sin afectar comprobantes de OXXO vigentes.
- **🖼️ Interfaz OXXO Optimizada**: Rediseño interno y de contenedores CSS + validación del microservicio `puppeteer-server` para devolver fichas OXXO perfectamente legibles.
## ✨ Novedades v4.4.0 (Resend Job Notifications)
- **📋 Tabla de Tareas Activas por Instalador**: Nuevo módulo dentro de "Gestión de Instaladores" que muestra los jobs "En curso" de cada técnico con datos de cliente, fecha y descripción.

File diff suppressed because one or more lines are too long

View File

@ -5,13 +5,18 @@
"displayName": "SIIP - Procesador de Pagos en línea con Stripe, Oxxo y Transferencia, Sincronizador de CallBell y Envío de Notificaciones y comprobantes vía WhatsApp",
"description": "Este plugin sincroniza los clientes del sistema UISP CRM con los contactos de WhatsApp en CallBell, además procesa pagos de Stripe como las trasferencias bancarias y genera referencias de pago vía OXXO, además envía comprobantes de pago en formato imagen PNG o texto vía Whatsapp a los clientes",
"url": "https://siip.mx/",
"version": "4.5.0",
"version": "4.6.0",
"unmsVersionCompliancy": {
"min": "2.1.0",
"max": null
},
"author": "SIIP INTERNET",
"changelog": [
{
"version": "4.6.0",
"date": "2026-04-11",
"changes": "Actualización: Resolución de duplicidad de intenciones de pago Stripe mediante validación CashBalance. Reparación del renderizado de fichas de OXXO, rediseño de OXXO UI y scripts de auditoría de PI."
},
{
"version": "4.5.0",
"date": "2026-03-13",

View File

@ -337,11 +337,14 @@ if (isset($_GET['action'])) {
}
if ($_GET['action'] === 'get_stripe_history') {
$stripeCustomerId = $_GET['stripeCustomerId'] ?? null;
$stripeCustomerId = $_GET['stripeCustomerId'] ?? $_GET['customerId'] ?? null;
if ($stripeCustomerId) {
echo json_encode(['history' => $paymentIntentService->getLastPayments($stripeCustomerId, 10)]);
echo json_encode([
'success' => true,
'payments' => $paymentIntentService->getLastPayments($stripeCustomerId, 10)
]);
} else {
echo json_encode(['error' => 'Missing customer id']);
echo json_encode(['success' => false, 'error' => 'Missing customer id']);
}
exit;
}
@ -1995,7 +1998,7 @@ $installersData = json_decode($config['installersDataWhatsApp'] ?? '{"instalador
Plugin Desarrollado por <strong>SIIP INTERNET</strong> - Todos los derechos reservados.
</p>
<p style="margin: 5px 0 0 0; font-size: 0.85rem; color: var(--text-muted);">
© <?php echo date('Y'); ?> SIIP Internet. Versión 4.2.1
© <?php echo date('Y'); ?> SIIP Internet. Versión 4.6.0
</p>
</footer>

View File

@ -0,0 +1,128 @@
<?php
$initialDir = getcwd();
chdir(__DIR__ . '/../');
require_once __DIR__ . '/../vendor/autoload.php';
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
use Stripe\StripeClient;
use Stripe\Exception\ApiErrorException;
function logMessage($message)
{
$logFile = __DIR__ . '/audit_incomplete_pi.log';
$timestamp = date('Y-m-d H:i:s');
file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND);
if (php_sapi_name() === 'cli') {
fwrite(STDERR, "[$timestamp] $message\n");
}
}
// -- Initialization --
$config = PluginConfigManager::create()->loadConfig();
$stripeApiKey = $config['tokenstripe'] ?? '';
if (empty($stripeApiKey)) {
logMessage("Error: Stripe Secret Key is missing in plugin configuration.");
exit(1);
}
$stripeClient = new StripeClient($stripeApiKey);
logMessage("Iniciando auditoría de Intenciones de Pago (SPEI/Transferencia) incompletas...");
$hasMore = true;
$nextPage = null;
$totalIncomplete = 0;
$customersWithIncomplete = [];
$pisToReview = [];
while ($hasMore) {
try {
$queryParams = [
'query' => 'status:"requires_payment_method" OR status:"requires_action"',
'limit' => 100
];
if ($nextPage) {
$queryParams['page'] = $nextPage;
}
$results = $stripeClient->paymentIntents->search($queryParams);
foreach ($results->data as $pi) {
// Filtrar y proteger explícitamente los pagos OXXO
$paymentMethodTypes = $pi->payment_method_types ?? [];
$isOxxo = in_array('oxxo', $paymentMethodTypes);
// Si es un pago de OXXO vigente (o en general para estar seguros), lo saltamos
if ($isOxxo) {
continue;
}
// Filtrar solo las que son de Transferencia Bancaria real (SPEI)
$isBankTransfer = in_array('customer_balance', $paymentMethodTypes);
$tipoPago = $pi->metadata['tipoPago'] ?? '';
// Garantizar que solo tocamos las transferencias bancarias
if ($isBankTransfer || ($tipoPago === 'Transferencia Bancaria' && !$isOxxo)) {
$totalIncomplete++;
$customerId = $pi->customer;
if (!isset($customersWithIncomplete[$customerId])) {
$customersWithIncomplete[$customerId] = 0;
}
$customersWithIncomplete[$customerId]++;
$pisToReview[] = [
'id' => $pi->id,
'customer' => $customerId,
'amount' => $pi->amount / 100,
'currency' => $pi->currency,
'status' => $pi->status,
'created' => date('Y-m-d H:i:s', $pi->created)
];
}
}
$hasMore = $results->has_more;
$nextPage = $results->next_page;
// Pausa breve para evitar Rate Limits de Stripe
usleep(500000); // 0.5s
} catch (ApiErrorException $e) {
logMessage("Error en la API de Stripe: " . $e->getMessage());
break;
} catch (\Exception $e) {
logMessage("Error inesperado: " . $e->getMessage());
break;
}
}
logMessage("=== RESULTADOS DE LA AUDITORÍA ===");
logMessage("Total de Intenciones de Pago Incompletas (SPEI): " . $totalIncomplete);
logMessage("Total de Clientes afectados: " . count($customersWithIncomplete));
$csvFile = __DIR__ . '/audit_incomplete_pi_results.csv';
$fp = fopen($csvFile, 'w');
if ($fp) {
fputcsv($fp, ['PaymentIntent_ID', 'Stripe_Customer', 'Amount', 'Currency', 'Status', 'Created_At']);
foreach ($pisToReview as $pi) {
fputcsv($fp, [
$pi['id'],
$pi['customer'],
$pi['amount'],
$pi['currency'],
$pi['status'],
$pi['created']
]);
}
fclose($fp);
logMessage("Se ha generado un archivo CSV con el detalle completo en: $csvFile");
}
echo "\nResumen:\n";
echo "1. Intenciones huérfanas encontradas: $totalIncomplete\n";
echo "2. Clientes afectados: " . count($customersWithIncomplete) . "\n";
echo "-> Verifica el log y el archivo CSV para los detalles.\n";

View File

@ -0,0 +1,122 @@
<?php
$initialDir = getcwd();
chdir(__DIR__ . '/../');
require_once __DIR__ . '/../vendor/autoload.php';
use Ubnt\UcrmPluginSdk\Service\PluginConfigManager;
use Stripe\StripeClient;
use Stripe\Exception\ApiErrorException;
function logMessage($message)
{
$logFile = __DIR__ . '/clean_incomplete_pi.log';
$timestamp = date('Y-m-d H:i:s');
file_put_contents($logFile, "[$timestamp] $message\n", FILE_APPEND);
if (php_sapi_name() === 'cli') {
fwrite(STDERR, "[$timestamp] $message\n");
}
}
// -- Argument Parsing --
$isDryRun = true;
foreach ($argv as $arg) {
if ($arg === '--confirm') {
$isDryRun = false;
}
}
// -- Initialization --
$config = PluginConfigManager::create()->loadConfig();
$stripeApiKey = $config['tokenstripe'] ?? '';
if (empty($stripeApiKey)) {
logMessage("Error: Stripe Secret Key is missing in plugin configuration.");
exit(1);
}
$stripeClient = new StripeClient($stripeApiKey);
logMessage("Iniciando LIMPIEZA de Intenciones de Pago (SPEI/Transferencia) incompletas...");
if ($isDryRun) {
logMessage("ATENCIÓN: Ejecutando en MODO PRUEBA (Dry Run). No se cancelará nada en Stripe.");
logMessage("Para ejecutar la limpieza real, pasa el argumento: --confirm");
} else {
logMessage("ATENCIÓN: Ejecutando en MODO DESTRUCTIVO. Se procederá a cancelar las intenciones encontradas.");
}
$hasMore = true;
$nextPage = null;
$totalCanceled = 0;
$totalFound = 0;
while ($hasMore) {
try {
$queryParams = [
'query' => 'status:"requires_payment_method" OR status:"requires_action"',
'limit' => 100
];
if ($nextPage) {
$queryParams['page'] = $nextPage;
}
$results = $stripeClient->paymentIntents->search($queryParams);
foreach ($results->data as $pi) {
// Proteger y omitir por completo cualquier intención de OXXO
$paymentMethodTypes = $pi->payment_method_types ?? [];
$isOxxo = in_array('oxxo', $paymentMethodTypes);
if ($isOxxo) {
continue;
}
// Filtrar solo las que son de Transferencia Bancaria
$isBankTransfer = in_array('customer_balance', $paymentMethodTypes);
$tipoPago = $pi->metadata['tipoPago'] ?? '';
if ($isBankTransfer || ($tipoPago === 'Transferencia Bancaria' && !$isOxxo)) {
$totalFound++;
$logStr = "PI: {$pi->id} | Cliente: {$pi->customer} | Monto: " . ($pi->amount / 100) . " {$pi->currency}";
if (!$isDryRun) {
try {
$stripeClient->paymentIntents->cancel($pi->id, [
'cancellation_reason' => 'abandoned'
]);
logMessage("[CANCELADA] $logStr");
$totalCanceled++;
} catch (ApiErrorException $e) {
logMessage("[ERROR al cancelar] $logStr - Detalle: " . $e->getMessage());
}
} else {
logMessage("[SIMULACIÓN - Sería Cancelada] $logStr");
}
}
}
$hasMore = $results->has_more;
$nextPage = $results->next_page;
// Pausa breve para evitar Rate Limits
usleep(500000); // 0.5s
} catch (ApiErrorException $e) {
logMessage("Error en la API de Stripe: " . $e->getMessage());
break;
} catch (\Exception $e) {
logMessage("Error inesperado: " . $e->getMessage());
break;
}
}
logMessage("=== RESUMEN DE LA LIMPIEZA ===");
logMessage("Total encontradas (SPEI): $totalFound");
if (!$isDryRun) {
logMessage("Total CANCELADAS exitosamente: $totalCanceled");
} else {
logMessage("Modo PRUEBA finalizado. Ninguna fue cancelada.");
}
echo "\nVerifica el log 'clean_incomplete_pi.log' para los detalles.\n";

View File

@ -55,40 +55,21 @@ abstract class AbstractStripeOperationsFacade
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;
// [NEW] Check for existing PENDING PaymentIntent to avoid duplicates
// Especially useful if webhook is retried or if one was already created.
$existingPIs = $stripe->paymentIntents->search([
'query' => "customer:'$customer' AND status:'requires_payment_method' AND amount>=" . ((int)$amount - 100) . " AND amount<=" . ((int)$amount + 100) . " AND metadata['tipoPago']:'Transferencia Bancaria'",
'limit' => 1
]);
// Note: Range check just in case, or exact check. Using exact check is safer if amount is precise.
// Let's use exact check for now, but sometimes small variations happen? No, Bank Transfer is exact.
// Actually, 'requires_payment_method' or 'requires_action' or 'processing'?
// If it's bank transfer funded, it might be in 'requires_confirmation' if not auto-confirmed.
// But if we are CREATING it, we want to know if we already created one that is waiting.
// If we created it with confirm=true, it should transition to succeeded immediately if funds are available (which they are, cause this event says so).
// However, IF the previous attempt failed or timed out but created the PI...
// BETTER: Check if we have processed this Event ID before?
// The method doesn't receive Event ID in the args easily (it's in $eventJson['id']).
// But we don't store Event IDs in DB.
// Let's stick to checking if there is a PI created recently (last 5 mins?) with same amount?
// Stripe Search API is powerful.
// query: "customer:'$customer' AND amount=$amount AND created>" . (time() - 300)
$fiveMinsAgo = time() - 300;
$existingPIs = $stripe->paymentIntents->search([
'query' => "customer:'$customer' AND amount=" . (int)$amount . " AND created>$fiveMinsAgo AND metadata['createdBy']:'UCRM'",
'limit' => 1
]);
if (count($existingPIs->data) > 0) {
$this->logger->info("PaymentIntent duplicado evitado. Ya existe uno reciente tras el evento de fondos. ID: " . $existingPIs->data[0]->id);
// 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',

View File

@ -144,27 +144,52 @@ class PaymentIntentService
public function getLastPayments($stripeCustomerId, $limit = 10)
{
try {
// Buscamos más registros para filtrar por tipo de pago (transferencias)
$collection = $this->stripeClient->paymentIntents->all([
'customer' => $stripeCustomerId,
'limit' => $limit,
'limit' => 50,
'expand' => ['data.charges']
]);
$result = [];
foreach ($collection->data as $payment) {
$description = $payment->description ?? $payment->metadata['description'] ?? 'Pago Stripe';
$result[] = [
'id' => $payment->id,
'amount' => $payment->amount / 100,
'currency' => strtoupper($payment->currency),
'status' => $payment->status,
'created' => $payment->created,
'description' => $description
];
// Filtramos por transferencias bancarias (balance de cliente)
$isBankTransfer = in_array('customer_balance', $payment->payment_method_types);
if ($isBankTransfer) {
$description = $payment->description ?? $payment->metadata['description'] ?? 'Transferencia Bancaria';
// Extraer referencia si existe en los cargos
$reference = '-';
if (!empty($payment->charges->data)) {
foreach ($payment->charges->data as $charge) {
if ($charge->payment_method_details->type === 'customer_balance') {
$bankTransfer = $charge->payment_method_details->customer_balance->bank_transfer;
if ($bankTransfer && isset($bankTransfer->reference)) {
$reference = $bankTransfer->reference;
}
}
}
}
$result[] = [
'id' => $payment->id,
'amount' => $payment->amount / 100,
'currency' => strtoupper($payment->currency),
'status' => $payment->status,
'created' => $payment->created,
'date' => date('d/m/Y H:i', $payment->created),
'description' => $description,
'reference' => $reference
];
if (count($result) >= $limit) break;
}
}
return $result;
} catch (\Exception $e) {
$this->log("Error fetching last payments: " . $e->getMessage());
return ['error' => $e->getMessage()];
return [];
}
}

View File

@ -1,208 +1,216 @@
<?php if (!isset($isModuleJs)): ?>
<!-- MODULE 4: OXXO -->
<section id="section-pagos-oxxo" class="section-view">
<!-- MODULE 4: OXXO -->
<section id="section-pagos-oxxo" class="section-view">
<div class="card">
<div style="margin-bottom: 2rem;">
<h2 style="margin: 0; display: flex; align-items: center; gap: 10px;">
<img src="?action=get_image&name=oxxo-payments.png" style="width:64px;height:64px;"> Pagos OXXO
</h2>
<p style="color: var(--text-muted); margin: 5px 0 0 0;">🏪 Genera fichas de pago OXXO para que tus clientes paguen en tiendas de conveniencia</p>
</div>
<!-- CONFIG CONTAINER -->
<div class="config-container">
<div class="form-group" style="position: relative;">
<label>Buscar Cliente para OXXO</label>
<div class="search-wrapper">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input type="text" id="oxxoSearch" class="form-control search-input-padded" placeholder="Buscar cliente..." autocomplete="off">
</div>
<div id="oxxoResults" class="search-results"></div>
</div>
</div>
<!-- PLACEHOLDER STATE (Initially Visible) -->
<div id="oxxoModulePlaceholder" class="placeholder-state">
<span class="placeholder-icon">🏪</span>
<p>Selecciona un cliente para generar una ficha OXXO Pay</p>
</div>
</div>
<div id="oxxoDetailContainer" style="display: none; margin-top: 2rem;">
<div class="stripe-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem;">
<!-- Left Column: Form & Client Info -->
<div class="card" style="align-self: start; display: flex; flex-direction: column;">
<!-- Client Header -->
<div style="border-bottom: 1px solid var(--border); padding-bottom: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 0.5rem;">
<img src="?action=image&file=client.webp" class="client-header-icon" style="width: 32px; height: 32px; border-radius: 50%;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-main);"><span id="oxxoClientName">Nombre del Cliente</span></h3>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<p id="oxxoClientIdDisplay" style="color: var(--text-muted); margin: 0; font-size: 0.9rem;">ID: #0</p>
<span class="badge" id="oxxoBalanceBadge" style="background: #fee2e2; color: #ef4444; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px;">Saldo: $0.00</span>
</div>
<div style="margin-top: 1rem;">
<a id="btnOxxoCrm" href="#" target="_blank" class="btn btn-uniform" style="text-align: center; display: flex; justify-content: center; width: 100%; padding: 12px; border-radius: 8px; font-size: 1rem;">
<img src="?action=image&file=crm.webp" class="icon-crm" style="vertical-align: middle; margin-right: 5px; width: 16px;"> Ver en CRM
</a>
</div>
</div>
<!-- OXXO Form -->
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
<div style="text-align: center; margin-bottom: 1.5rem;">
<img src="?action=image&file=oxxo-logo.png" style="max-width: 200px; height: auto;">
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="font-weight: 500; margin-bottom: 0.5rem; display: block; color: var(--text-main);">Monto a Cobrar</label>
<div class="input-group-unified">
<span class="input-prefix">$</span>
<input type="number" id="oxxoAmount" class="form-control" placeholder="0.00">
</div>
</div>
<div class="actions-row" style="display: flex; flex-direction: column; gap: 1rem;">
<button id="btnCreateOxxoIntent" class="btn btn-primary" style="width: 100%; justify-content: center; background-color: #E20613; border-color: #E20613; color: white; display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 8px; font-weight: 600; font-size: 1rem; transition: all 0.2s;">
Generar Ficha OXXO Pay
</button>
</div>
</div>
</div>
<!-- Right Column: Ficha Preview -->
<div class="card" id="oxxoFichaContainer" style="height: 100%; min-height: 450px; display: flex; align-items: center; justify-content: center; background: var(--bg-body); border: 1px dashed var(--border); position: relative;">
<div id="oxxoPlaceholder" style="text-align: center; color: var(--text-muted);">
<img src="?action=image&file=oxxo-payments.png" style="max-width: 80px; opacity: 0.5; margin-bottom: 1rem; filter: grayscale(1);">
<h4 style="margin: 0; font-weight: 500;">Vista Previa</h4>
<p style="margin: 5px 0 0 0; font-size: 0.9rem;">La ficha generada aparecerá aquí</p>
</div>
<div id="oxxoResult" style="display:none; width: 100%; height: 100%;">
<!-- Generated Ficha will be injected here -->
</div>
</div>
</div>
</div>
<!-- OXXO HISTORY CONTAINER -->
<div id="oxxoHistoryContainer" style="display: none; margin-top: 2rem;">
<div class="card">
<div style="margin-bottom: 2rem;">
<h2 style="margin: 0; display: flex; align-items: center; gap: 10px;">
<img src="?action=get_image&name=oxxo-payments.png" style="width:64px;height:64px;"> Pagos OXXO
</h2>
<p style="color: var(--text-muted); margin: 5px 0 0 0;">🏪 Genera fichas de pago OXXO para que tus clientes paguen en tiendas de conveniencia</p>
</div>
<!-- CONFIG CONTAINER -->
<div class="config-container">
<div class="form-group" style="position: relative;">
<label>Buscar Cliente para OXXO</label>
<div class="search-wrapper">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input type="text" id="oxxoSearch" class="form-control search-input-padded" placeholder="Buscar cliente..." autocomplete="off">
</div>
<div id="oxxoResults" class="search-results"></div>
</div>
</div>
<!-- PLACEHOLDER STATE (Initially Visible) -->
<div id="oxxoModulePlaceholder" class="placeholder-state">
<span class="placeholder-icon">🏪</span>
<p>Selecciona un cliente para generar una ficha OXXO Pay</p>
<h3 class="section-title" style="margin-bottom: 1rem;">Historial de Fichas OXXO (Últimas 5)</h3>
<div class="table-responsive">
<table class="table table-hover" id="oxxoHistoryTable">
<thead>
<tr>
<th>ID Pago</th>
<th>Fecha</th>
<th>Monto</th>
<th>Ref. OXXO</th>
<th>Estatus</th>
<th>Ficha</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" style="text-align:center">Seleccione un cliente...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div id="oxxoDetailContainer" style="display: none; margin-top: 2rem;">
<div class="stripe-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; align-items: start;">
<!-- Left Column: Form & Client Info -->
<div class="card" style="height: 100%; display: flex; flex-direction: column;">
<!-- Client Header -->
<div style="border-bottom: 1px solid var(--border); padding-bottom: 1rem; margin-bottom: 1.5rem;">
<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 0.5rem;">
<img src="?action=image&file=client.webp" class="client-header-icon" style="width: 32px; height: 32px; border-radius: 50%;">
<h3 style="margin: 0; font-size: 1.1rem; color: var(--text-main);"><span id="oxxoClientName">Nombre del Cliente</span></h3>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<p id="oxxoClientIdDisplay" style="color: var(--text-muted); margin: 0; font-size: 0.9rem;">ID: #0</p>
<span class="badge" id="oxxoBalanceBadge" style="background: #fee2e2; color: #ef4444; font-size: 0.8rem; padding: 2px 8px; border-radius: 4px;">Saldo: $0.00</span>
</div>
</div>
<!-- OXXO Form -->
<div style="flex: 1; display: flex; flex-direction: column; justify-content: center;">
<div style="text-align: center; margin-bottom: 1.5rem;">
<img src="?action=image&file=oxxo-logo.png" style="max-width: 100px; height: auto;">
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label style="font-weight: 500; margin-bottom: 0.5rem; display: block; color: var(--text-main);">Monto a Cobrar</label>
<div class="input-group-unified">
<span class="input-prefix">$</span>
<input type="number" id="oxxoAmount" class="form-control" placeholder="0.00">
</div>
</div>
<div class="actions-row" style="display: flex; flex-direction: column; gap: 1rem;">
<button id="btnCreateOxxoIntent" class="btn btn-primary" style="width: 100%; justify-content: center; background-color: #E20613; border-color: #E20613; color: white; display: flex; align-items: center; gap: 10px; padding: 12px; border-radius: 8px; font-weight: 600; font-size: 1rem; transition: all 0.2s;">
Generar Ficha OXXO Pay
</button>
<a id="btnOxxoCrm" href="#" target="_blank" class="btn btn-uniform" style="text-align: center; display: flex; justify-content: center; width: 100%; padding: 12px; border-radius: 8px; font-size: 1rem;">
<img src="?action=image&file=crm.webp" class="icon-crm" style="vertical-align: middle; margin-right: 5px; width: 16px;"> Ver en CRM
</a>
</div>
</div>
</div>
<!-- Right Column: Ficha Preview -->
<div class="card" id="oxxoFichaContainer" style="height: 100%; min-height: 450px; display: flex; align-items: center; justify-content: center; background: var(--bg-body); border: 1px dashed var(--border); position: relative;">
<div id="oxxoPlaceholder" style="text-align: center; color: var(--text-muted);">
<img src="?action=image&file=oxxo-payments.png" style="max-width: 80px; opacity: 0.5; margin-bottom: 1rem; filter: grayscale(1);">
<h4 style="margin: 0; font-weight: 500;">Vista Previa</h4>
<p style="margin: 5px 0 0 0; font-size: 0.9rem;">La ficha generada aparecerá aquí</p>
</div>
<div id="oxxoResult" style="display:none; width: 100%; height: 100%;">
<!-- Generated Ficha will be injected here -->
</div>
</div>
</div>
</div>
<!-- OXXO HISTORY CONTAINER -->
<div id="oxxoHistoryContainer" style="display: none; margin-top: 2rem;">
<div class="card">
<h3 class="section-title" style="margin-bottom: 1rem;">Historial de Fichas OXXO (Últimas 5)</h3>
<div class="table-responsive">
<table class="table table-hover" id="oxxoHistoryTable">
<thead>
<tr>
<th>ID Pago</th>
<th>Fecha</th>
<th>Monto</th>
<th>Ref. OXXO</th>
<th>Estatus</th>
<th>Ficha</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="6" style="text-align:center">Seleccione un cliente...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<?php else: ?>
document.addEventListener('DOMContentLoaded', () => {
</div>
<?php else: ?>
document.addEventListener('DOMContentLoaded', () => {
// 3. OXXO SEARCH
let selectedOxxoClient = null;
setupSearch('oxxoSearch', 'oxxoResults', 'search_stripe', async (partialClient) => {
const res = await fetch(`${window.SIIP_OXXO_PATH || ''}?action=get_stripe_details&id=${partialClient.id}`);
const data = await res.json();
selectedOxxoClient = data;
const res = await fetch(`${window.SIIP_OXXO_PATH || ''}?action=get_stripe_details&id=${partialClient.id}`);
const data = await res.json();
selectedOxxoClient = data;
document.getElementById('oxxoClientName').textContent = data.fullName;
document.getElementById('oxxoClientIdDisplay').textContent = `ID: #${data.id}`;
document.getElementById('oxxoBalanceBadge').textContent = `Saldo: $${parseFloat(data.accountOutstanding||0).toFixed(2)}`;
document.getElementById('oxxoAmount').value = data.accountOutstanding > 0 ? data.accountOutstanding : '';
document.getElementById('btnOxxoCrm').href = `${store.publicUrl}/client/${data.id}`;
document.getElementById('oxxoClientName').textContent = data.fullName;
document.getElementById('oxxoClientIdDisplay').textContent = `ID: #${data.id}`;
document.getElementById('oxxoBalanceBadge').textContent = `Saldo: $${parseFloat(data.accountOutstanding||0).toFixed(2)}`;
document.getElementById('oxxoAmount').value = data.accountOutstanding > 0 ? data.accountOutstanding : '';
document.getElementById('btnOxxoCrm').href = `${store.publicUrl}/client/${data.id}`;
// Reset View
document.getElementById('oxxoPlaceholder').style.display = 'block'; // This is the PREVIEW placeholder (keep as is)
document.getElementById('oxxoModulePlaceholder').style.display = 'none'; // Hide MODULE placeholder
document.getElementById('oxxoResult').style.display = 'none';
document.getElementById('oxxoResult').innerHTML = '';
// Reset View
document.getElementById('oxxoPlaceholder').style.display = 'block'; // This is the PREVIEW placeholder (keep as is)
document.getElementById('oxxoModulePlaceholder').style.display = 'none'; // Hide MODULE placeholder
document.getElementById('oxxoResult').style.display = 'none';
document.getElementById('oxxoResult').innerHTML = '';
document.getElementById('oxxoDetailContainer').style.display = 'block';
document.getElementById('oxxoDetailContainer').style.display = 'block';
// Load History
if (data.stripeCustomerId) {
document.getElementById('oxxoHistoryContainer').style.display = 'block';
loadOxxoHistory(data.stripeCustomerId);
} else {
document.getElementById('oxxoHistoryContainer').style.display = 'none';
}
// Load History
if (data.stripeCustomerId) {
document.getElementById('oxxoHistoryContainer').style.display = 'block';
loadOxxoHistory(data.stripeCustomerId);
} else {
document.getElementById('oxxoHistoryContainer').style.display = 'none';
}
});
document.getElementById('btnCreateOxxoIntent').onclick = async () => {
if (!selectedOxxoClient) return;
const amt = parseFloat(document.getElementById('oxxoAmount').value);
if (!amt || amt < 10) return showToast('Mínimo 10 MXN', true);
if (!selectedOxxoClient) return;
const amt = parseFloat(document.getElementById('oxxoAmount').value);
if (!amt || amt < 10) return showToast('Mínimo 10 MXN', true);
const btn = document.getElementById('btnCreateOxxoIntent');
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner" style="border: 2px solid #fff; border-top: 2px solid transparent; width: 16px; height: 16px; border-radius: 50%; display: inline-block; animation: spin 1s linear infinite; margin-right: 8px;"></span> Procesando...';
const btn=document.getElementById('btnCreateOxxoIntent');
const original=btn.innerHTML;
btn.disabled=true;
btn.innerHTML='<span class="spinner" style="border: 2px solid #fff; border-top: 2px solid transparent; width: 16px; height: 16px; border-radius: 50%; display: inline-block; animation: spin 1s linear infinite; margin-right: 8px;"></span> Procesando...' ;
const fd = new FormData();
fd.append('action', 'create_oxxo_intent');
const fd=new FormData();
fd.append('action', 'create_oxxo_intent' );
fd.append('clientId', selectedOxxoClient.id);
fd.append('amount', amt);
try {
const res = await fetch(`${window.SIIP_OXXO_PATH || ''}?`, {
method: 'POST',
body: fd
});
const d = await res.json();
if (d.success) {
const url = d.data.voucher_image_url || `?action=image&file=${d.data.voucher_filename}`;
const res=await fetch(`${window.SIIP_OXXO_PATH || '' }?`, {
method: 'POST' ,
body: fd
});
const d=await res.json();
if (d.success) {
const url=d.data.voucher_image_url || `?action=image&file=${d.data.voucher_filename}`;
// Hide Placeholder
document.getElementById('oxxoPlaceholder').style.display = 'none';
// Hide Placeholder
document.getElementById('oxxoPlaceholder').style.display='none' ;
// Show Result
const resDiv = document.getElementById('oxxoResult');
resDiv.style.display = 'flex';
resDiv.innerHTML = `
<div style="text-align:center; width: 100%; animation: fadeIn 0.5s ease-out;">
<div style="background: #dcfce7; color: #15803d; width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
<h3 style="color:var(--text-main); margin-bottom: 0.5rem;">¡Ficha Generada!</h3>
<p style="color:var(--text-muted); margin-bottom: 1.5rem;">Referencia OXXO Pay</p>
// Show Result
const resDiv=document.getElementById('oxxoResult');
resDiv.style.display='flex' ;
resDiv.innerHTML=`
<div style="text-align:center; width: 100%; animation: fadeIn 0.5s ease-out;">
<div style="background: #dcfce7; color: #15803d; width: 60px; height: 60px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 1rem;">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
</div>
<h3 style="color:var(--text-main); margin-bottom: 0.5rem;">¡Ficha Generada!</h3>
<p style="color:var(--text-muted); margin-bottom: 1.5rem;">Referencia OXXO Pay</p>
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); display: inline-block; margin-bottom: 1.5rem;">
<p style="font-size:1.4rem; font-weight:bold; letter-spacing: 2px; margin: 0; color: #000;">${d.data.oxxo_reference}</p>
</div>
<div style="background: white; padding: 10px; border-radius: 8px; border: 1px solid var(--border); display: inline-block; margin-bottom: 1.5rem;">
<p style="font-size:1.4rem; font-weight:bold; letter-spacing: 2px; margin: 0; color: #000;">${d.data.oxxo_reference}</p>
</div>
<img src="${url}" style="max-width:100%; height:auto; display: block; margin: 0 auto 1.5rem; border:1px solid var(--border); border-radius:8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<img src="${url}" style="max-width:100%; height:auto; display: block; margin: 0 auto 1.5rem; border:1px solid var(--border); border-radius:8px; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);">
<div style="display: flex; gap: 10px; justify-content: center;">
<a href="${url}" target="_blank" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>
Descargar Ficha
</a>
</div>
</div>`;
} else showToast(d.error, true);
<div style="display: flex; gap: 10px; justify-content: center;">
<a href="${url}" target="_blank" class="btn btn-primary" style="display: inline-flex; align-items: center; gap: 8px;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
Descargar Ficha
</a>
</div>
</div>`;
} else showToast(d.error, true);
} catch (e) {
console.error(e);
showToast('Error de conexión', true);
console.error(e);
showToast('Error de conexión', true);
}
btn.disabled = false;
btn.innerHTML = original;
};
});
<?php endif; ?>
};
});
<?php endif; ?>

View File

@ -101,7 +101,7 @@
<div id="stripeHistoryContainer" style="display:none; margin-top: 2rem; border-top: 1px solid var(--border); padding-top: 1.5rem;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h4 class="section-title" style="margin-bottom: 0;">Historial de Pagos (Últimos 10)</h4>
<h4 class="section-title" style="margin-bottom: 0;">Historial de Intenciones de Pago por transferencia</h4>
<div id="stripeCashBalanceBadge" class="balance-badge" style="display:none;">
<img src="?action=image&file=account-balance.webp" style="width: 32px; height: 32px; margin-right: 8px;">
<span id="stripeCashBalanceText">Saldo Stripe: $0.00 MXN</span>
@ -195,7 +195,7 @@
}
if (!d.payments || d.payments.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:var(--text-muted)">No hay pagos recientes</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;padding:2rem;color:var(--text-muted)">No hay intenciones de pago recientes</td></tr>';
return;
}