commit e9c19b4b3b6d42dcffe2212b064cca19b58041c9 Author: ezerpa Date: Fri Jul 3 15:00:31 2026 -0400 v1.0.11 - Plugin BCV Exchange Rate para iDempiere v10 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e9062f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Compiled class files +*.class + +# Package files +*.jar +*.war +*.ear + +# Build directories +build/ +dist/ + +# IDE files +.idea/ +*.iml +.project +.classpath +.settings/ + +# OS files +.DS_Store +Thumbs.db + +# Log files +*.log diff --git a/META-INF/MANIFEST.MF b/META-INF/MANIFEST.MF new file mode 100644 index 0000000..ef25159 --- /dev/null +++ b/META-INF/MANIFEST.MF @@ -0,0 +1,15 @@ +Manifest-Version: 1.0 +Bundle-ManifestVersion: 2 +Bundle-Name: BCV Exchange Rate Plugin +Bundle-SymbolicName: com.venezuela.bcvrate;singleton:=true +Bundle-Version: 1.0.11 +Bundle-Vendor: Ezerpa +Bundle-RequiredExecutionEnvironment: JavaSE-11 +Bundle-ActivationPolicy: lazy +Bundle-Activator: com.venezuela.bcvrate.Activator +Require-Bundle: org.adempiere.base;bundle-version="10.0.0", + json;bundle-version="20190722.0.0" +Import-Package: org.osgi.framework +Export-Package: com.venezuela.bcvrate.process, + com.venezuela.bcvrate.service, + com.venezuela.bcvrate.factory diff --git a/OSGI-INF/component.xml b/OSGI-INF/component.xml new file mode 100644 index 0000000..3941f28 --- /dev/null +++ b/OSGI-INF/component.xml @@ -0,0 +1,11 @@ + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9a5aa4 --- /dev/null +++ b/README.md @@ -0,0 +1,322 @@ +# BCV Exchange Rate Plugin for iDempiere v10 + +## Descripción + +Plugin OSGi que obtiene automáticamente la tasa de cambio oficial del BCV (Banco Central de Venezuela) USD/VES y la registra en la tabla C_Conversion_Rate de iDempiere. + +## Características + +- **Doble fuente de datos**: API bcv.today + scraping directo del sitio web del BCV +- **Fallback automático**: Si la API no tiene datos recientes, scrapea el sitio del BCV +- **Busca hacia atrás** cuando el BCV no publica tasa para un día hábil +- **Rellena automáticamente** días sin publicación (fines de semana, feriados) +- **Ejecución manual** con rango de fechas específico +- **Ejecución automática** que detecta días pendientes y los rellena +- **Valida duplicados** antes de insertar +- **Modo simulación** para pruebas sin guardar +- **Scheduler automático** diario + +## Fuentes de Datos + +### Fuente Principal: API bcv.today +- **Endpoint**: `https://bcv.today/api/v1/rate.json` +- **Endpoint por fecha**: `https://bcv.today/api/v1/history/YYYY-MM-DD.json` +- **Ventaja**: Rápida, estructurada +- **Desventaja**: Puede tener retraso deactualización + +### Fuente de Respaldo: Scraping BCV +- **URL**: `https://www.bcv.org.ve/` +- **Método**: Extracción directa del HTML con regex +- **Ventaja**: Siempre actualizada (datos oficiales) +- **Desventaja**: Requiere SSL bypass y parsing de HTML + +### Flujo de Selección +``` +1. Intentar API bcv.today (búsqueda hacia adelante 1-10 días) + ↓ (si no encuentra) +2. Scraping directo del sitio web del BCV + ↓ (si falla) +3. API bcv.today getCurrentRate() (último recurso) +``` + +## Comportamiento del Proceso + +### Ejecución Manual (con parámetros de fecha) +- Procesa cada día en el rango DateFrom → DateTo +- Para cada día, busca la última tasa BCV hacia atrás (hasta 10 días) +- Usa la tasa encontrada pero asigna ValidFrom = fecha solicitada + +### Ejecución Automática (sin parámetros de fecha) +1. Obtiene la última tasa BCV publicada (API + scraping) +2. Compara con la última tasa en BD +3. Si hay tasa nueva no registrada, procesa desde la última en BD hasta esa fecha +4. Si ya están todas → "No hay días pendientes" + +### Lógica de Fallback por Día +| Situación | Acción | +|-----------|--------| +| BCV publica tasa para la fecha | Usa esa tasa, ValidFrom = fecha solicitada | +| BCV no publica (feriado, fin de semana) | Busca hacia atrás, usa última tasa disponible | +| No se encuentra tasa en 10 días | Registra error | + +### Ejemplo +| Fecha solicitada | BCV publica | Tasa usada | ValidFrom | +|------------------|-------------|------------|-----------| +| 24/06 (miércoles) | No | 617.6388 (del 23/06) | 24/06 | +| 25/06 (jueves) | Sí (621.5299) | 621.5299 | 25/06 | +| 27/06 (sábado) | No | tasa del 26/06 | 27/06 | +| 28/06 (domingo) | No | tasa del 26/06 | 28/06 | + +## Archivos del Plugin + +``` +com.venezuela.bcvrate/ +├ META-INF/ +│ └ MANIFEST.MF # Manifest OSGi (Bundle-Activator) +├ src/com/venezuela/bcvrate/ +│ ├ Activator.java # Registra IProcessFactory via BundleContext +│ ├ factory/ +│ │ └ BCVProcessFactory.java # Factory del proceso +│ ├ process/ +│ │ └ BCVExchangeRateProcess.java # Lógica principal del proceso +│ └ service/ +│ ├ BCVApiService.java # Cliente HTTP + scraping BCV +│ └ BCVRateResponse.java # Modelo de respuesta +├ dist/ +│ └ com.venezuela.bcvrate.jar # JAR compilado listo para instalar +├ lib/ +│ └ org.osgi.framework.jar # Dependencia OSGi +├ migration/ +│ ├ install_v10.sql # SQL para registrar proceso +│ └ BCV_ExchangeRateProcess_2Pack.xml # 2pack para despliegue +└ build.sh # Script de build para Linux +``` + +## Instalación + +### Prerrequisitos +- iDempiere v10 ejecutándose +- Acceso a consola Felix (http://servidor:8080/system/console) +- Acceso a la base de datos PostgreSQL + +### Paso 1: Instalar JAR en Felix +1. Abrir consola Felix → **Bundles** +2. Click **Install/Update Bundle** +3. Seleccionar `com.venezuela.bcvrate.jar` +4. Seleccionar **Start** +5. Verificar estado **Active** + +**Importante**: Si actualizas el plugin, debes **uninstall** el bundle anterior primero, luego **install** el nuevo. Equinox no recarga si la versión es la misma. + +### Paso 2: Registrar Proceso +Opción A: Ejecutar `migration/install_v10.sql` en la BD +Opción B: Crear manualmente via UI: +- **Proceso**: BCV Exchange Rate Update +- **ClassName**: com.venezuela.bcvrate.process.BCVExchangeRateProcess +- **AccessLevel**: 3 (Client + Organization) +- **Parámetros**: Client, Conversion Type, Date From, Date To, Simulation Mode + +Opción C: Importar 2pack `migration/BCV_ExchangeRateProcess_2Pack.xml` + +### Paso 3: Agregar al Menú +Crear registro en AD_TreeBar o importar 2pack con el menú configurado. + +### Paso 4: Configurar Scheduler (Agenda) +1. Ir a **Agenda** (Process Scheduler) +2. Crear nuevo registro +3. Seleccionar proceso: **BCV Exchange Rate Update** +4. Frecuencia: **1 dia** +5. Hora: **17:00-18:00** (cuando BCV publica la tasa) +6. Dejar parámetros de fecha **vacíos** + +**Recomendación**: Configurar dos ejecuciones (17:00 y 21:00) por si la primera no captura la tasa. + +## Parámetros del Proceso + +| Parámetro | Tipo | Requerido | Default | Descripción | +|-----------|------|-----------|---------|-------------| +| Grupo Empresarial | List | Sí | @#AD_Client_ID@ | Cliente de iDempiere | +| Tipo de Conversión | List | Sí | BCV | Tipo de conversión | +| Fecha desde | Date | No | (vacío) | Fecha inicio (vacío = auto) | +| Fecha hasta | Date | No | (vacío) | Fecha fin (vacío = auto) | +| Simulación | Yes/No | No | N | Ejecutar sin guardar cambios | + +## Datos Clave + +| Campo | Valor | Descripción | +|-------|-------|-------------| +| C_Currency_ID | 100 | USD (hardcoded) | +| C_Currency_ID_To | (dinámico) | Moneda funcional del cliente | +| AD_Org_ID | 0 | Todas las organizaciones | +| MultiplyRate | (API/scraping) | Tasa USD → VES (4 decimales) | +| DivideRate | 1/MultiplyRate | Tasa VES → USD (10 decimales) | +| ValidFrom | (fecha solicitada) | Fecha que necesita la empresa | +| ValidTo | (fecha solicitada) | Misma que ValidFrom | + +## Solución de Problemas + +### Error "Failed to create new process instance" +- Verificar que el bundle está **Active** en Felix +- Verificar logs: `grep -i bcvrate /opt/idempiere-server/logs/idempiere.log` + +### Error "Cross tenant PO writing" +- Se resolvió usando `set_ValueNoCheck()` en vez de `setMultiplyRate()` +- El proceso crea el registro con el client ID correcto del contexto + +### Error "No se pudo obtener la moneda funcional" +- Verificar que el cliente tiene un esquema contable configurado +- Verificar que la moneda funcional no sea USD + +### API bcv.today no responde o tiene retraso +- Verificar conectividad: `curl https://bcv.today/api/v1/rate.json` +- El plugin automáticamente intenta scraping del sitio web del BCV como respaldo + +### Error SSL en scraping del BCV +- El plugin incluye SSL bypass para el sitio web del BCV +- Si persiste, verificar que el bundle tiene la última versión + +### Tasa con decimales incorrectos +- Se resolvió usando `set_ValueNoCheck()` en vez de setters de MConversionRate +- MultiplyRate: 4 decimales +- DivideRate: 10 decimales + +### Scraping captura tasa incorrecta (EUR en vez de USD) +- El regex busca específicamente después de `USD` en el HTML +- Verificar que el HTML del BCV no ha cambiado su estructura + +## Compilación + +### En Windows (desarrollo) +```powershell +# Requiere: JDK 11, JARs de iDempiere v10 +$javac = "C:\Program Files\Eclipse Adoptium\jdk-11.0.31.11-hotspot\bin\javac.exe" +$v10Plugins = "path/to/idempiere-v10/plugins" + +$sources = Get-ChildItem -Path src -Recurse -Filter "*.java" +$classpath = "$v10Plugins\org.adempiere.base_*.jar;$v10Plugins\json_*.jar;lib\org.osgi.framework.jar" + +& $javac -cp $classpath -d dist -source 11 -target 11 @($sources) +& jar cfm dist/com.venezuela.bcvrate.jar META-INF/MANIFEST.MF -C dist . +``` + +### En Linux (servidor) +```bash +chmod +x build.sh +./build.sh +``` + +## Dependencias + +- **org.adempiere.base** >= 10.0.0 - Framework iDempiere +- **json** >= 20190722.0.0 - Parsing JSON (solo para API bcv.today) +- **org.osgi.framework** - Framework OSGi (sistema) + +## Notas Técnicas + +### Dual Source: API + Scraping +El plugin implementa dos fuentes de datos para máxima confiabilidad: + +1. **API bcv.today**: Consulta REST rápida, pero puede tener retraso deactualización +2. **Scraping BCV**: Extracción directa del HTML del sitio web oficial + +El flujo automático intenta la API primero y fallback al scraping si no hay datos recientes. + +### SSL Bypass para Scraping +El sitio web del BCV usa certificados SSL que pueden no estar en el cacerts de Java. El plugin implementa SSL bypass por-conexión (no global) para evitar conflictos con iDempiere: + +```java +TrustManager[] trustAllCerts = new TrustManager[]{...}; +SSLContext sc = SSLContext.getInstance("TLS"); +sc.init(null, trustAllCerts, new SecureRandom()); +HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); +conn.setSSLSocketFactory(sc.getSocketFactory()); +conn.setHostnameVerifier((hostname, session) -> true); +``` + +### Extracción de Tasa USD del HTML +El regex busca específicamente la tasa del dólar después del elemento `USD`: + +``` +USD.*?strong-tb">\s*([\d.,]+)\s* +``` + +Esto evita capturar otras tasas (EUR, CNY, etc.) que aparecen en la misma página. + +### Por qué se usa BundleActivator en vez de DS +- `component.xml` (Declarative Services) no funcionó en iDempiere v10 +- `@Component` annotation tampoco funcionó +- `BundleActivator` con `BundleContext.registerService()` es el más confiable +- Requiere `Import-Package: org.osgi.framework` en MANIFEST.MF + +### Por qué se usa regex en vez del JSON library +- El `json_20190722.0.0.jar` bundled con iDempiere v10 tiene bugs de precisión +- `getDouble("USD")` y `get("USD").toString()` corrompen decimales +- Se extrae el valor directamente del string raw con regex: `"USD"\s*:\s*([0-9.]+)` + +### Por qué se usa set_ValueNoCheck en vez de setters +- `MConversionRate.setMultiplyRate()` modifica el valor internamente +- `MConversionRate.setDivideRate()` redondea a 4 decimales +- `set_ValueNoCheck()` bypassa la lógica interna y almacena el valor exacto + +### Cross Tenant +- `MConversionRate.setRate()` intenta guardar con AD_Client_ID=0 +- Se crea el registro directamente con `MConversionRate` y se usa `set_ValueNoCheck("AD_Client_ID", ...)` + +### Por qué el JAR debe cambiar de versión +- Equinox (runtime OSGi) cachea bundles por versión +- Si la versión no cambia, el bundle anterior permanece activo +- Siempre incrementar `Bundle-Version` en MANIFEST.MF al actualizar + +## Historial de Cambios + +### v1.0.11 (2026-07-03) +- Fix regex scraping: captura tasa USD específicamente (no EUR) +- Regex busca después de `USD` en el HTML + +### v1.0.10 (2026-07-03) +- Logging mejorado en proceso para ver fuente de datos +- Scraping status visible en output del proceso + +### v1.0.9 (2026-07-03) +- SSL bypass por-conexión (no global) para scraping BCV +- Logging detallado en getLatestRate() + +### v1.0.8 (2026-07-03) +- Scraping directo del sitio web del BCV como fuente de respaldo +- Dual source: API bcv.today + scraping bcv.org.ve +- Método getRateFromBCVWebsite() con extracción regex del HTML + +### v1.0.7 (2026-06-23) +- Lógica automática: detecta días pendientes entre BD y última tasa BCV +- Ejecución sin parámetros: busca última tasa BCV y rellena huecos + +### v1.0.6 (2026-06-23) +- Fix: ejecución automática ahora detecta si hay tasa para hoy +- Evita procesar si ya existen todas las tasas + +### v1.0.5 (2026-06-23) +- Lógica de fallback: busca hacia atrás cuando BCV no publica tasa +- Rellena días sin publicación con última tasa disponible + +### v1.0.4 (2026-06-23) +- getRateForDateWithFallback(): busca hacia atrás hasta 10 días +- BCVRateResponse: campo requestedDate vs effectiveDate + +### v1.0.3 (2026-06-23) +- DivideRate: 10 decimales en vez de 4 + +### v1.0.2 (2026-06-23) +- Todos los setters usan set_ValueNoCheck() para bypass MConversionRate +- Log de verificación post-save + +### v1.0.1 (2026-06-23) +- Regex para extraer USD directo del string raw (bypass JSON library) + +### v1.0.0 (2026-06-22) +- Versión inicial +- API: bcv.today +- BundleActivator para registro de IProcessFactory + +## Licencia + +Plugin personalizado para uso interno de Ezerpa. diff --git a/build.properties b/build.properties new file mode 100644 index 0000000..931e163 --- /dev/null +++ b/build.properties @@ -0,0 +1,5 @@ +source.. = src/ +output.. = bin/ +bin.includes = META-INF/,\ + OSGI-INF/,\ + . diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..8ad2926 --- /dev/null +++ b/build.sh @@ -0,0 +1,111 @@ +#!/bin/bash +# Build script for BCV Exchange Rate Plugin - iDempiere v10 +# Run this on the server where iDempiere v10 is installed + +set -e + +# Configuration +IDEMPIERE_HOME="${IDEMPIERE_HOME:-/opt/idempiere-server}" +PLUGIN_DIR="$(dirname "$0")" +DIST_DIR="$PLUGIN_DIR/dist" +BUILD_DIR="$PLUGIN_DIR/build" + +echo "=== BCV Exchange Rate Plugin Build ===" +echo "iDempiere Home: $IDEMPIERE_HOME" + +# Find required JARs +BASE_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.adempiere.base_*.jar" | head -1) +JSON_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "json_*.jar" | head -1) +OSGI_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.osgi.framework_*.jar" | head -1) +COMPONENT_ANNOTATIONS_JAR=$(find "$IDEMPIERE_HOME/plugins" -name "org.osgi.service.component.annotations_*.jar" | head -1) + +# Check required JARs +if [ -z "$BASE_JAR" ]; then + echo "ERROR: org.adempiere.base JAR not found" + exit 1 +fi + +if [ -z "$JSON_JAR" ]; then + echo "ERROR: json JAR not found" + exit 1 +fi + +# If OSGi framework JAR not found, try to use the annotations from the base JAR +if [ -z "$OSGI_JAR" ]; then + echo "WARNING: org.osgi.framework JAR not found, checking if it's in base JAR" +fi + +echo "Base JAR: $BASE_JAR" +echo "JSON JAR: $JSON_JAR" + +# Create directories +mkdir -p "$DIST_DIR" +mkdir -p "$BUILD_DIR" + +# Find Java +JAVA_HOME="${JAVA_HOME:-$(dirname $(dirname $(readlink -f $(which java))))}" +JAVAC="$JAVA_HOME/bin/javac" +JAR="$JAVA_HOME/bin/jar" + +if [ ! -f "$JAVAC" ]; then + echo "ERROR: javac not found. Please set JAVA_HOME" + exit 1 +fi + +echo "Using javac: $JAVAC" + +# Build classpath +CLASSPATH="$BASE_JAR:$JSON_JAR" +if [ -n "$OSGI_JAR" ]; then + CLASSPATH="$CLASSPATH:$OSGI_JAR" +fi +if [ -n "$COMPONENT_ANNOTATIONS_JAR" ]; then + CLASSPATH="$CLASSPATH:$COMPONENT_ANNOTATIONS_JAR" +fi + +# Find all Java source files +SOURCES=$(find "$PLUGIN_DIR/src" -name "*.java") +SOURCE_COUNT=$(echo "$SOURCES" | wc -l) +echo "Found $SOURCE_COUNT source files" + +# Compile +echo "Compiling..." +$JAVAC -cp "$CLASSPATH" \ + -d "$BUILD_DIR" \ + -source 11 -target 11 \ + $SOURCES + +if [ $? -ne 0 ]; then + echo "ERROR: Compilation failed" + exit 1 +fi + +echo "Compilation successful" + +# Create JAR +echo "Creating JAR..." +$JAR cfm "$DIST_DIR/com.venezuela.bcvrate.jar" "$PLUGIN_DIR/META-INF/MANIFEST.MF" \ + -C "$BUILD_DIR" . + +if [ $? -ne 0 ]; then + echo "ERROR: JAR creation failed" + exit 1 +fi + +JAR_SIZE=$(du -h "$DIST_DIR/com.venezuela.bcvrate.jar" | cut -f1) +echo "JAR created: $DIST_DIR/com.venezuela.bcvrate.jar ($JAR_SIZE)" + +# Copy to iDempiere plugins directory +echo "Copying to iDempiere plugins..." +cp "$DIST_DIR/com.venezuela.bcvrate.jar" "$IDEMPIERE_HOME/plugins/" + +echo "" +echo "=== Build Complete ===" +echo "JAR installed at: $IDEMPIERE_HOME/plugins/com.venezuela.bcvrate.jar" +echo "" +echo "Next steps:" +echo "1. Restart iDempiere server" +echo "2. Check bundle status in Felix console: felix:ls | grep bcvrate" +echo "3. Create AD_Process via UI (if not done)" +echo "4. Add process to menu" +echo "5. Test the process" diff --git a/migration/BCV_ExchangeRateProcess_2Pack.xml b/migration/BCV_ExchangeRateProcess_2Pack.xml new file mode 100644 index 0000000..7995516 --- /dev/null +++ b/migration/BCV_ExchangeRateProcess_2Pack.xml @@ -0,0 +1,182 @@ + + + + + + + + 5000000 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + BCV_ExchangeRateUpdate + Actualización automática de tasa de cambio oficial BCV (USD/VES) + Obtiene la tasa de cambio oficial del Banco Central de Venezuela y la registra en la tabla C_Conversion_Rate. La moneda funcional se obtiene del esquema contable principal del grupo empresarial. + com.venezuela.bcvrate.process.BCVExchangeRateProcess + N + N + Y + L + 0 + BCV_ExchangeRateUpdate + +
+
+ + + + + 5000000 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + Client + Grupo empresarial destino para la tasa de cambio + 5000000 + 10 + 19 + 157 + + Y + 0 + 0 + AD_Client_ID + +
+
+ + + + + 5000001 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + Currency Conversion Type + Tipo de conversión de moneda (Spot, Corporate, etc.) + 5000000 + 20 + 19 + 232 + @SQL=SELECT C_ConversionType_ID FROM C_ConversionType WHERE IsDefault='Y' AND AD_Client_ID IN (0, @AD_Client_ID@) ORDER BY AD_Client_ID DESC LIMIT 1 + N + 0 + 0 + C_ConversionType_ID + +
+
+ + + + + 5000002 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + Date From + Fecha de inicio para buscar tasas de cambio. Si no se indica, usa la fecha actual. + 5000000 + 30 + 16 + @#Date@ + N + 0 + 0 + DateFrom + +
+
+ + + + + 5000003 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + Date To + Fecha de fin para buscar tasas de cambio. Si no se indica, usa la fecha actual. + 5000000 + 40 + 16 + @#Date@ + N + 0 + 0 + DateTo + +
+
+ + + + + 5000004 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + Simulation + Si está marcado, el proceso solo muestra qué haría sin grabar datos + 5000000 + 50 + 20 + N + N + 0 + 0 + IsSimulation + +
+
+ + + + + 5000000 + 0 + 0 + Y + 2026-06-22 + 100 + 2026-06-22 + 100 + BCV_DailyRateUpdate + Actualización diaria de tasa BCV - Lunes a Viernes 16:00-23:00 cada 15 min + C + 0 0/15 16-23 * * 1-5 + 15 + M + Y + Y + 5000000 + +
+
+
+
+
diff --git a/migration/install_process.sql b/migration/install_process.sql new file mode 100644 index 0000000..2369f85 --- /dev/null +++ b/migration/install_process.sql @@ -0,0 +1,94 @@ +-- ============================================ +-- BCV Exchange Rate Plugin - Registro en iDempiere +-- ============================================ +-- Ejecutar este script en la base de datos de iDempiere +-- ANTES de instalar el plugin OSGi +-- ============================================ + +-- 1. Crear el Proceso +INSERT INTO AD_Process ( + AD_Process_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Value, Description, Help, Classname, + AccessLevel, EntityType, IsDirectPrint, IsReport, IsServerProcess, ShowHelp +) VALUES ( + 5000000, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'BCV_ExchangeRateUpdate', 'BCV_ExchangeRateUpdate', + 'Actualización automática de tasa de cambio oficial BCV (USD/VES)', + 'Obtiene la tasa de cambio oficial del Banco Central de Venezuela y la registra en C_Conversion_Rate.', + 'com.venezuela.bcvrate.process.BCVExchangeRateProcess', + '4', 'U', 'N', 'N', 'Y', 'L' +) +ON CONFLICT (AD_Process_ID) DO NOTHING; + +-- 2. Parámetro: AD_Client_ID (Grupo Empresarial) +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, ColumnName, Description, AD_Process_ID, SeqNo, + AD_Reference_ID, AD_Reference_Value_ID, IsMandatory, DefaultValue, + FieldLength, IsRange +) VALUES ( + 5000000, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'Client', 'AD_Client_ID', 'Grupo empresarial destino', + 5000000, 10, 19, 157, 'Y', '', 10, 'N' +) +ON CONFLICT (AD_Process_Para_ID) DO NOTHING; + +-- 3. Parámetro: C_ConversionType_ID (Tipo de Conversión) +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, ColumnName, Description, AD_Process_ID, SeqNo, + AD_Reference_ID, AD_Reference_Value_ID, IsMandatory, DefaultValue, + FieldLength, IsRange +) VALUES ( + 5000001, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'Currency Conversion Type', 'C_ConversionType_ID', 'Tipo de conversión (Spot, Corporate, etc.)', + 5000000, 20, 19, 232, 'N', '', 10, 'N' +) +ON CONFLICT (AD_Process_Para_ID) DO NOTHING; + +-- 4. Parámetro: DateFrom (Fecha Desde) +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, ColumnName, Description, AD_Process_ID, SeqNo, + AD_Reference_ID, IsMandatory, DefaultValue, + FieldLength, IsRange +) VALUES ( + 5000002, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'Date From', 'DateFrom', 'Fecha inicio (default: hoy)', + 5000000, 30, 16, 'N', '@#Date@', 10, 'N' +) +ON CONFLICT (AD_Process_Para_ID) DO NOTHING; + +-- 5. Parámetro: DateTo (Fecha Hasta) +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, ColumnName, Description, AD_Process_ID, SeqNo, + AD_Reference_ID, IsMandatory, DefaultValue, + FieldLength, IsRange +) VALUES ( + 5000003, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'Date To', 'DateTo', 'Fecha fin (default: hoy)', + 5000000, 40, 16, 'N', '@#Date@', 10, 'N' +) +ON CONFLICT (AD_Process_Para_ID) DO NOTHING; + +-- 6. Parámetro: IsSimulation (Modo Simulación) +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, ColumnName, Description, AD_Process_ID, SeqNo, + AD_Reference_ID, IsMandatory, DefaultValue, + FieldLength, IsRange +) VALUES ( + 5000004, 0, 0, 'Y', CURRENT_TIMESTAMP, 100, CURRENT_TIMESTAMP, 100, + 'Simulation', 'IsSimulation', 'Si está marcado, solo muestra qué haría sin grabar', + 5000000, 50, 20, 'N', 'N', 1, 'N' +) +ON CONFLICT (AD_Process_Para_ID) DO NOTHING; + +-- ============================================ +-- NOTA: El Scheduler se crea manualmente en iDempiere: +-- Ir a: Procesos > Scheduler > Nuevo +-- Nombre: BCV_DailyRateUpdate +-- Patrón Cron: 0 0/15 16-23 * * 1-5 +-- Proceso: BCV_ExchangeRateUpdate +-- ============================================ diff --git a/migration/install_v10.sql b/migration/install_v10.sql new file mode 100644 index 0000000..750f757 --- /dev/null +++ b/migration/install_v10.sql @@ -0,0 +1,90 @@ +-- ============================================================ +-- BCV Exchange Rate Process Registration for iDempiere v10 +-- ============================================================ + +-- 1. Register the Process +INSERT INTO AD_Process ( + AD_Process_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Value, Description, Help, ClassName, IsReport, IsDirectPrint, + IsServerProcess, IsBetaFunctionality, AccessLevel +) VALUES ( + 50001, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'BCV Exchange Rate Update', + 'BCVExchangeRate', + 'Fetches USD/VES exchange rate from BCV official API and registers it', + 'Connects to BCV API to get the latest USD/VES exchange rate.', + 'com.venezuela.bcvrate.process.BCVExchangeRateProcess', + 'N', 'N', 'Y', 'N', '3' +); + +-- 2. Register Process Parameters + +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Description, AD_Process_ID, AD_Reference_ID, AD_Val_Rule_ID, + IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo, + IsEncrypted, IsCentrallyMaintained, EntityType +) VALUES ( + 50001, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'Client', 'Client for this installation', + 50001, 18, 100, + 'Y', 'N', 0, '@#AD_Client_ID@', 10, + 'N', 'Y', 'D' +); + +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Description, AD_Process_ID, AD_Reference_ID, AD_Val_Rule_ID, + IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo, + IsEncrypted, IsCentrallyMaintained, EntityType +) VALUES ( + 50002, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'Conversion Type', 'Currency conversion type', + 50001, 18, 153, + 'Y', 'N', 0, 'S', 20, + 'N', 'Y', 'D' +); + +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Description, AD_Process_ID, AD_Reference_ID, + IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo, + IsEncrypted, IsCentrallyMaintained, EntityType +) VALUES ( + 50003, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'Date From', 'Start date for rate lookup', + 50001, 16, + 'N', 'N', 0, '@#Date@', 30, + 'N', 'Y', 'D' +); + +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Description, AD_Process_ID, AD_Reference_ID, + IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo, + IsEncrypted, IsCentrallyMaintained, EntityType +) VALUES ( + 50004, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'Date To', 'End date for rate lookup', + 50001, 16, + 'N', 'N', 0, '@#Date@', 40, + 'N', 'Y', 'D' +); + +INSERT INTO AD_Process_Para ( + AD_Process_Para_ID, AD_Client_ID, AD_Org_ID, IsActive, Created, CreatedBy, Updated, UpdatedBy, + Name, Description, AD_Process_ID, AD_Reference_ID, + IsMandatory, IsRange, FieldLength, DefaultValue, SeqNo, + IsEncrypted, IsCentrallyMaintained, EntityType +) VALUES ( + 50005, 0, 0, 'Y', CURRENT_TIMESTAMP, 0, CURRENT_TIMESTAMP, 0, + 'Simulation Mode', 'Run in simulation mode - no actual changes saved', + 50001, 28, + 'N', 'N', 0, 'N', 50, + 'N', 'Y', 'D' +); + +-- 3. Verify +SELECT p.Name, p.ClassName, + (SELECT COUNT(*) FROM AD_Process_Para pp WHERE pp.AD_Process_ID = p.AD_Process_ID) as Params +FROM AD_Process p WHERE p.ClassName = 'com.venezuela.bcvrate.process.BCVExchangeRateProcess'; diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..009e096 --- /dev/null +++ b/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.venezuela + com.venezuela.bcvrate + 1.0.0-SNAPSHOT + eclipse-plugin + + BCV Exchange Rate Plugin for iDempiere + + Plugin para iDempiere v10 que obtiene la tasa de cambio oficial del BCV + (Banco Central de Venezuela) y la registra automáticamente en el sistema. + + + + 4.0.4 + UTF-8 + 17 + 17 + + + + + + org.eclipse.tycho + tycho-maven-plugin + ${tycho.version} + true + + + + diff --git a/src/com/venezuela/bcvrate/Activator.java b/src/com/venezuela/bcvrate/Activator.java new file mode 100644 index 0000000..c8a2bec --- /dev/null +++ b/src/com/venezuela/bcvrate/Activator.java @@ -0,0 +1,23 @@ +package com.venezuela.bcvrate; + +import org.adempiere.base.IProcessFactory; +import org.osgi.framework.BundleActivator; +import org.osgi.framework.BundleContext; +import org.osgi.framework.ServiceRegistration; + +import com.venezuela.bcvrate.factory.BCVProcessFactory; + +public class Activator implements BundleActivator { + + private ServiceRegistration reg; + + @Override + public void start(BundleContext ctx) throws Exception { + reg = ctx.registerService(IProcessFactory.class.getName(), new BCVProcessFactory(), null); + } + + @Override + public void stop(BundleContext ctx) throws Exception { + if (reg != null) reg.unregister(); + } +} diff --git a/src/com/venezuela/bcvrate/factory/BCVProcessFactory.java b/src/com/venezuela/bcvrate/factory/BCVProcessFactory.java new file mode 100644 index 0000000..42f5fc6 --- /dev/null +++ b/src/com/venezuela/bcvrate/factory/BCVProcessFactory.java @@ -0,0 +1,17 @@ +package com.venezuela.bcvrate.factory; + +import org.adempiere.base.IProcessFactory; +import org.compiere.process.SvrProcess; + +import com.venezuela.bcvrate.process.BCVExchangeRateProcess; + +public class BCVProcessFactory implements IProcessFactory { + + @Override + public SvrProcess newProcessInstance(String className) { + if ("com.venezuela.bcvrate.process.BCVExchangeRateProcess".equals(className)) { + return new BCVExchangeRateProcess(); + } + return null; + } +} diff --git a/src/com/venezuela/bcvrate/process/BCVExchangeRateProcess.java b/src/com/venezuela/bcvrate/process/BCVExchangeRateProcess.java new file mode 100644 index 0000000..720e8e2 --- /dev/null +++ b/src/com/venezuela/bcvrate/process/BCVExchangeRateProcess.java @@ -0,0 +1,324 @@ +package com.venezuela.bcvrate.process; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.compiere.model.MAcctSchema; +import org.compiere.model.MConversionRate; +import org.compiere.model.MConversionType; +import org.compiere.model.MCurrency; +import org.compiere.model.Query; +import org.compiere.process.ProcessInfoParameter; +import org.compiere.process.SvrProcess; +import org.compiere.util.Env; + +import com.venezuela.bcvrate.service.BCVApiService; +import com.venezuela.bcvrate.service.BCVRateResponse; + +public class BCVExchangeRateProcess extends SvrProcess { + + private static final Logger log = Logger.getLogger(BCVExchangeRateProcess.class.getName()); + + private static final int C_CURRENCY_USD = 100; + + private int p_AD_Client_ID = 0; + private int p_C_ConversionType_ID = 0; + private Timestamp p_DateFrom = null; + private Timestamp p_DateTo = null; + private boolean p_IsSimulation = false; + + private int currencyToId = 0; + private String currencyToIso = null; + private BCVApiService apiService; + + private int countInserted = 0; + private int countSkipped = 0; + private int countErrors = 0; + + @Override + protected void prepare() { + ProcessInfoParameter[] params = getParameter(); + for (ProcessInfoParameter param : params) { + String name = param.getParameterName(); + if (param.getParameter() == null) { + continue; + } + + if ("AD_Client_ID".equals(name)) { + p_AD_Client_ID = param.getParameterAsInt(); + } else if ("C_ConversionType_ID".equals(name)) { + p_C_ConversionType_ID = param.getParameterAsInt(); + } else if ("DateFrom".equals(name)) { + p_DateFrom = (Timestamp) param.getParameter(); + } else if ("DateTo".equals(name)) { + p_DateTo = (Timestamp) param.getParameter(); + } else if ("IsSimulation".equals(name)) { + p_IsSimulation = "Y".equals(param.getParameter()); + } + } + + if (p_DateFrom == null) { + p_DateFrom = truncateToDay(new Timestamp(System.currentTimeMillis())); + } + if (p_DateTo == null) { + p_DateTo = truncateToDay(new Timestamp(System.currentTimeMillis())); + } + if (p_AD_Client_ID <= 0) { + p_AD_Client_ID = Env.getAD_Client_ID(getCtx()); + } + } + + @Override + protected String doIt() throws Exception { + log.info("=== Inicio proceso BCV Exchange Rate Update ==="); + log.info("Cliente: " + p_AD_Client_ID); + log.info("Fecha desde: " + p_DateFrom); + log.info("Fecha hasta: " + p_DateTo); + log.info("Modo simulacion: " + p_IsSimulation); + + apiService = new BCVApiService(); + + if (!obtenerMonedaFuncional()) { + return "@Error@ No se pudo obtener la moneda funcional del esquema contable"; + } + + if (p_C_ConversionType_ID <= 0) { + p_C_ConversionType_ID = MConversionType.getDefault(p_AD_Client_ID); + log.info("Usando tipo de conversion Spot por defecto: " + p_C_ConversionType_ID); + } + + Env.setContext(getCtx(), "#AD_Client_ID", p_AD_Client_ID); + + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Calendar cal = Calendar.getInstance(); + + boolean esEjecucionAutomatica = (p_DateFrom.equals(p_DateTo) + && p_DateFrom.equals(truncateToDay(new Timestamp(System.currentTimeMillis())))); + + Timestamp fechaInicio = p_DateFrom; + Timestamp fechaFin = p_DateTo; + + if (esEjecucionAutomatica) { + Timestamp ultimaFechaConTasa = getUltimaFechaConTasa(); + + BCVRateResponse ultimaTasaBCV = apiService.getLatestRate(); + if (ultimaTasaBCV != null && ultimaTasaBCV.isValid()) { + String fechaTasaBCV = ultimaTasaBCV.getDate(); + SimpleDateFormat sdfParse = new SimpleDateFormat("yyyy-MM-dd"); + Timestamp fechaBCV = truncateToDay(new Timestamp(sdfParse.parse(fechaTasaBCV).getTime())); + Timestamp hoy = truncateToDay(new Timestamp(System.currentTimeMillis())); + + if (!fechaBCV.equals(hoy) && fechaBCV.after(hoy)) { + log.info("Tasa BCV publicada para " + fechaTasaBCV + " (dia futuro)"); + } + + if (ultimaFechaConTasa != null) { + if (!fechaBCV.after(ultimaFechaConTasa)) { + log.info("Ultima tasa BCV (" + sdf.format(fechaBCV) + + ") ya registrada en BD (" + sdf.format(ultimaFechaConTasa) + ")"); + String resumen = "=== Resumen ===\nNo hay dias pendientes"; + log.info(resumen); + addLog(resumen); + return "@OK@ No hay dias pendientes"; + } + cal.setTime(ultimaFechaConTasa); + cal.add(Calendar.DAY_OF_MONTH, 1); + fechaInicio = new Timestamp(cal.getTimeInMillis()); + } else { + fechaInicio = truncateToDay(new Timestamp(System.currentTimeMillis())); + } + fechaFin = fechaBCV; + } else { + log.info("No se encontro tasa BCV futura"); + String resumen = "=== Resumen ===\nNo hay dias pendientes"; + log.info(resumen); + addLog(resumen); + return "@OK@ No hay dias pendientes"; + } + log.info("Ejecucion automatica: procesando desde " + sdf.format(fechaInicio) + + " hasta " + sdf.format(fechaFin)); + } + + cal.setTime(fechaInicio); + Timestamp currentDate = fechaInicio; + + while (!currentDate.after(fechaFin)) { + String dateStr = sdf.format(currentDate); + procesarDia(currentDate, dateStr); + cal.setTime(currentDate); + cal.add(Calendar.DAY_OF_MONTH, 1); + currentDate = new Timestamp(cal.getTimeInMillis()); + } + + String resumen = "=== Resumen ===" + + "\nTasas insertadas: " + countInserted + + "\nYa existian (omitidas): " + countSkipped + + "\nErrores: " + countErrors; + + log.info(resumen); + addLog(resumen); + + return "@OK@ Insertadas: " + countInserted + + ", Omitidas: " + countSkipped + ", Errores: " + countErrors; + } + + private boolean obtenerMonedaFuncional() { + try { + MAcctSchema[] schemas = MAcctSchema.getClientAcctSchema( + getCtx(), p_AD_Client_ID, get_TrxName()); + + if (schemas == null || schemas.length == 0) { + log.severe("No se encontro esquema contable para el cliente: " + p_AD_Client_ID); + return false; + } + + MAcctSchema primarySchema = schemas[0]; + currencyToId = primarySchema.getC_Currency_ID(); + currencyToIso = MCurrency.getISO_Code(getCtx(), currencyToId); + + log.info("Moneda funcional detectada: " + currencyToIso + " (ID: " + currencyToId + ")"); + + if (C_CURRENCY_USD == currencyToId) { + log.severe("La moneda origen (USD) es igual a la moneda destino."); + return false; + } + + return true; + } catch (Exception e) { + log.log(Level.SEVERE, "Error obteniendo moneda funcional", e); + return false; + } + } + + private void procesarDia(Timestamp date, String dateStr) { + log.info("Procesando fecha: " + dateStr); + addLog("Buscando tasa para " + dateStr + "..."); + + if (existeTasaParaFecha(date)) { + log.info("Ya existe tasa para " + dateStr + ", omitiendo..."); + addLog("Fecha " + dateStr + ": Ya existe tasa (omitida)"); + countSkipped++; + return; + } + + BCVRateResponse rateResponse = apiService.getLatestRate(); + + if (rateResponse == null || !rateResponse.isValid()) { + log.warning("No se pudo obtener tasa del BCV para " + dateStr); + addLog("Fecha " + dateStr + ": Error - No se obtuvo tasa del BCV (API + scraping fallaron)"); + countErrors++; + return; + } + + String apiEffectiveDate = rateResponse.getEffectiveDate(); + log.info("Tasa BCV: " + rateResponse.getDollar() + + " fecha_solicitada=" + dateStr + + " fecha_bcv=" + apiEffectiveDate); + addLog("Fecha " + dateStr + ": Tasa encontrada = " + rateResponse.getDollar() + + " (BCV fecha: " + apiEffectiveDate + ")"); + + if (!p_IsSimulation) { + boolean created = crearRegistroTasa(date, rateResponse.getDollar()); + if (created) { + addLog("Fecha " + dateStr + ": Tasa insertada = " + rateResponse.getDollar() + + " (BCV fecha: " + apiEffectiveDate + ")"); + countInserted++; + } else { + addLog("Fecha " + dateStr + ": Error al insertar tasa"); + countErrors++; + } + } else { + addLog("Fecha " + dateStr + ": [SIMULACION] Tasa = " + rateResponse.getDollar() + + " (BCV fecha: " + apiEffectiveDate + ")"); + countInserted++; + } + } + + private boolean existeTasaParaFecha(Timestamp date) { + Timestamp startOfDay = truncateToDay(date); + Timestamp endOfDay = addOneDay(startOfDay); + + String whereClause = "C_Currency_ID=? AND C_Currency_ID_To=? " + + "AND C_ConversionType_ID=? AND AD_Client_ID=? " + + "AND ValidFrom>=? AND ValidFrom 0; + } + + private boolean crearRegistroTasa(Timestamp date, BigDecimal rate) { + try { + Timestamp startOfDay = truncateToDay(date); + BigDecimal multiplyRate = rate.setScale(4, RoundingMode.HALF_UP); + BigDecimal divideRate = BigDecimal.ONE.divide(multiplyRate, 10, RoundingMode.HALF_UP); + log.info("Insertando tasa: USD -> " + currencyToIso + " = " + multiplyRate + " fecha=" + startOfDay); + + MConversionRate cr = new MConversionRate(getCtx(), 0, get_TrxName()); + cr.set_ValueNoCheck("AD_Client_ID", p_AD_Client_ID); + cr.set_ValueNoCheck("AD_Org_ID", 0); + cr.set_ValueNoCheck("C_Currency_ID", C_CURRENCY_USD); + cr.set_ValueNoCheck("C_Currency_ID_To", currencyToId); + cr.set_ValueNoCheck("C_ConversionType_ID", p_C_ConversionType_ID); + cr.set_ValueNoCheck("ValidFrom", startOfDay); + cr.set_ValueNoCheck("ValidTo", startOfDay); + cr.set_ValueNoCheck("MultiplyRate", multiplyRate); + cr.set_ValueNoCheck("DivideRate", divideRate); + cr.saveEx(); + + log.info("Registro creado: ID=" + cr.get_ID() + + " MultiplyRate=" + cr.get_Value("MultiplyRate") + + " DivideRate=" + cr.get_Value("DivideRate")); + return true; + } catch (Exception e) { + String msg = "Error creando registro: " + e.getMessage(); + log.log(Level.SEVERE, msg, e); + addLog(" Detalle: " + e.getClass().getSimpleName() + " - " + e.getMessage()); + return false; + } + } + + private Timestamp getUltimaFechaConTasa() { + String whereClause = "C_Currency_ID=? AND C_Currency_ID_To=? " + + "AND C_ConversionType_ID=? AND AD_Client_ID=?"; + + MConversionRate lastRate = new Query(getCtx(), "C_Conversion_Rate", whereClause, get_TrxName()) + .setParameters(C_CURRENCY_USD, currencyToId, p_C_ConversionType_ID, p_AD_Client_ID) + .setOrderBy("ValidFrom DESC") + .first(); + + if (lastRate != null) { + Timestamp lastDate = lastRate.getValidFrom(); + log.info("Ultima fecha con tasa en BD: " + new SimpleDateFormat("yyyy-MM-dd").format(lastDate)); + return lastDate; + } + + log.info("No hay tasas previas en BD"); + return null; + } + + private Timestamp truncateToDay(Timestamp ts) { + Calendar cal = Calendar.getInstance(); + cal.setTime(ts); + cal.set(Calendar.HOUR_OF_DAY, 0); + cal.set(Calendar.MINUTE, 0); + cal.set(Calendar.SECOND, 0); + cal.set(Calendar.MILLISECOND, 0); + return new Timestamp(cal.getTimeInMillis()); + } + + private Timestamp addOneDay(Timestamp ts) { + Calendar cal = Calendar.getInstance(); + cal.setTime(ts); + cal.add(Calendar.DAY_OF_MONTH, 1); + return new Timestamp(cal.getTimeInMillis()); + } +} diff --git a/src/com/venezuela/bcvrate/service/BCVApiService.java b/src/com/venezuela/bcvrate/service/BCVApiService.java new file mode 100644 index 0000000..4470a45 --- /dev/null +++ b/src/com/venezuela/bcvrate/service/BCVApiService.java @@ -0,0 +1,276 @@ +package com.venezuela.bcvrate.service; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +public class BCVApiService { + + private static final Logger log = Logger.getLogger(BCVApiService.class.getName()); + + private static final String BASE_URL = "https://bcv.today/api/v1"; + private static final String BCV_WEBSITE = "https://www.bcv.org.ve/"; + private static final int CONNECTION_TIMEOUT = 15000; + private static final int READ_TIMEOUT = 30000; + private static final int MAX_DAYS_BACK = 10; + + public BCVRateResponse getRateForDateWithFallback(String requestedDate) { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + Calendar cal = Calendar.getInstance(); + + try { + cal.setTime(sdf.parse(requestedDate)); + } catch (Exception e) { + log.log(Level.WARNING, "Error parseando fecha: " + requestedDate, e); + return null; + } + + for (int i = 0; i <= MAX_DAYS_BACK; i++) { + String dateToQuery = sdf.format(cal.getTime()); + BCVRateResponse rate = getRateFromApi(dateToQuery); + + if (rate != null && rate.isValid()) { + log.info("Tasa encontrada (API) consultando " + dateToQuery + + " para fecha solicitada " + requestedDate + + ": USD=" + rate.getDollar() + + " effective=" + rate.getDate()); + return new BCVRateResponse(rate.getDollar(), requestedDate, rate.getDate()); + } + + cal.add(Calendar.DAY_OF_MONTH, -1); + } + + log.info("API no tiene tasa para " + requestedDate + ", intentando scraping BCV..."); + BCVRateResponse websiteRate = getRateFromBCVWebsite(); + if (websiteRate != null && websiteRate.isValid()) { + log.info("Tasa obtenida del sitio web BCV: USD=" + websiteRate.getDollar() + + " effective=" + websiteRate.getDate()); + return new BCVRateResponse(websiteRate.getDollar(), requestedDate, websiteRate.getDate()); + } + + log.info("No se encontro tasa en los ultimos " + MAX_DAYS_BACK + " dias para: " + requestedDate); + return null; + } + + public BCVRateResponse getRateForDate(String date) { + return getRateFromApi(date); + } + + public BCVRateResponse getLatestRate() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd"); + + for (int i = 1; i <= 10; i++) { + Calendar cal = Calendar.getInstance(); + cal.add(Calendar.DAY_OF_MONTH, i); + String futureDate = sdf.format(cal.getTime()); + + BCVRateResponse rate = getRateFromApi(futureDate); + if (rate != null && rate.isValid()) { + log.info("Tasa encontrada (API futura) para " + futureDate + + ": USD=" + rate.getDollar() + " effective=" + rate.getDate()); + return rate; + } + } + + log.info("API no tiene tasa futura, intentando scraping BCV..."); + try { + BCVRateResponse websiteRate = getRateFromBCVWebsite(); + if (websiteRate != null && websiteRate.isValid()) { + log.info("Tasa obtenida del sitio web BCV: USD=" + websiteRate.getDollar() + + " effective=" + websiteRate.getDate()); + return websiteRate; + } + log.info("Scraping BCV no devolvio tasa valida"); + } catch (Exception e) { + log.log(Level.WARNING, "Scraping BCV fallo", e); + } + + log.info("Fallback a API actual..."); + BCVRateResponse apiRate = getCurrentRate(); + return apiRate; + } + + private BCVRateResponse getRateFromApi(String date) { + String urlStr = BASE_URL + "/history/" + date + ".json"; + + try { + String response = httpGet(urlStr); + if (response == null) return null; + + BigDecimal dollar = extractUSD(response); + String effectiveDate = extractField(response, "effective_date"); + if (effectiveDate == null) effectiveDate = extractField(response, "date"); + + if (dollar != null && effectiveDate != null) { + return new BCVRateResponse(dollar, effectiveDate); + } + return null; + } catch (Exception e) { + log.log(Level.WARNING, "Error consultando fecha: " + date, e); + return null; + } + } + + public BCVRateResponse getCurrentRate() { + String urlStr = BASE_URL + "/rate.json"; + + try { + String response = httpGet(urlStr); + if (response == null) return null; + + BigDecimal dollar = extractUSD(response); + String effectiveDate = extractField(response, "effective_date"); + if (effectiveDate == null) effectiveDate = extractField(response, "date"); + + if (dollar != null && effectiveDate != null) { + log.info("Tasa actual (API): USD=" + dollar + " effective=" + effectiveDate); + return new BCVRateResponse(dollar, effectiveDate); + } + return null; + } catch (Exception e) { + log.log(Level.WARNING, "Error consultando tasa actual", e); + return null; + } + } + + private BCVRateResponse getRateFromBCVWebsite() { + try { + log.info("Iniciando scraping de BCV website..."); + String html = httpGetHtml(BCV_WEBSITE); + if (html == null) { + log.warning("No se pudo obtener pagina BCV (httpGetHtml returned null)"); + return null; + } + log.info("Pagina BCV obtenida, longitud: " + html.length()); + + Matcher rateMatcher = Pattern.compile( + "USD.*?strong-tb\">\\s*([\\d.,]+)\\s*", Pattern.DOTALL).matcher(html); + if (!rateMatcher.find()) { + log.warning("No se encontro tasa USD en pagina BCV (regex no match)"); + return null; + } + + String rateStr = rateMatcher.group(1).replace(",", "."); + log.info("Tasa encontrada en HTML: " + rateStr); + BigDecimal dollar = new BigDecimal(rateStr).setScale(4, RoundingMode.HALF_UP); + + Matcher dateMatcher = Pattern.compile( + "content=\"(\\d{4}-\\d{2}-\\d{2})T").matcher(html); + String effectiveDate = null; + if (dateMatcher.find()) { + effectiveDate = dateMatcher.group(1); + } + + if (dollar.compareTo(BigDecimal.ZERO) > 0) { + if (effectiveDate == null) { + effectiveDate = new SimpleDateFormat("yyyy-MM-dd").format(new java.util.Date()); + } + log.info("Scraping BCV exitoso: USD=" + dollar + " effective=" + effectiveDate); + return new BCVRateResponse(dollar, effectiveDate); + } + return null; + } catch (Exception e) { + log.log(Level.WARNING, "Error scraping BCV website: " + e.getMessage(), e); + return null; + } + } + + private BigDecimal extractUSD(String json) { + Matcher m = Pattern.compile("\"USD\"\\s*:\\s*([0-9.]+)").matcher(json); + if (m.find()) { + return new BigDecimal(m.group(1)).setScale(4, RoundingMode.HALF_UP); + } + return null; + } + + private String extractField(String json, String field) { + Matcher m = Pattern.compile("\"" + field + "\"\\s*:\\s*\"([^\"]+)\"").matcher(json); + if (m.find()) { + return m.group(1); + } + return null; + } + + private String httpGet(String urlStr) throws Exception { + URL url = new URL(urlStr); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.setConnectTimeout(CONNECTION_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + conn.disconnect(); + return null; + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream()))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } finally { + conn.disconnect(); + } + return sb.toString(); + } + + private String httpGetHtml(String urlStr) throws Exception { + URL url = new URL(urlStr); + + TrustManager[] trustAllCerts = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return null; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + SSLContext sc = SSLContext.getInstance("TLS"); + sc.init(null, trustAllCerts, new SecureRandom()); + + HttpsURLConnection conn = (HttpsURLConnection) url.openConnection(); + conn.setSSLSocketFactory(sc.getSocketFactory()); + conn.setHostnameVerifier((hostname, session) -> true); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "text/html"); + conn.setConnectTimeout(CONNECTION_TIMEOUT); + conn.setReadTimeout(READ_TIMEOUT); + + int responseCode = conn.getResponseCode(); + if (responseCode != 200) { + conn.disconnect(); + return null; + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader reader = new BufferedReader( + new InputStreamReader(conn.getInputStream(), "UTF-8"))) { + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + } finally { + conn.disconnect(); + } + return sb.toString(); + } +} diff --git a/src/com/venezuela/bcvrate/service/BCVHistoryResponse.java b/src/com/venezuela/bcvrate/service/BCVHistoryResponse.java new file mode 100644 index 0000000..3cf708c --- /dev/null +++ b/src/com/venezuela/bcvrate/service/BCVHistoryResponse.java @@ -0,0 +1,107 @@ +package com.venezuela.bcvrate.service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * Modelo para la respuesta del historial de tasas de la API BCV. + * Endpoint: GET /rates/history?start_date={start}&end_date={end} + * Respuesta: {"start_date": "...", "end_date": "...", "rates": [...]} + */ +public class BCVHistoryResponse { + + private String start_date; + private String end_date; + private List rates; + + public BCVHistoryResponse() { + this.rates = new ArrayList<>(); + } + + public String getStart_date() { + return start_date; + } + + public void setStart_date(String start_date) { + this.start_date = start_date; + } + + public String getEnd_date() { + return end_date; + } + + public void setEnd_date(String end_date) { + this.end_date = end_date; + } + + public List getRates() { + return rates; + } + + public void setRates(List rates) { + this.rates = rates; + } + + /** + * Obtiene la tasa más reciente del historial (primer elemento de la lista). + * La API retorna las tasas ordenadas de más reciente a más antigua. + * @return la tasa más reciente, o null si no hay tasas + */ + public RateEntry getMostRecentRate() { + if (rates != null && !rates.isEmpty()) { + return rates.get(0); + } + return null; + } + + /** + * Modelo interno para cada entrada de tasa en el historial. + */ + public static class RateEntry { + private BigDecimal dollar; + private String date; + + public RateEntry() { + } + + public RateEntry(BigDecimal dollar, String date) { + this.dollar = dollar; + this.date = date; + } + + public BigDecimal getDollar() { + return dollar; + } + + public void setDollar(BigDecimal dollar) { + this.dollar = dollar; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public boolean isValid() { + return dollar != null + && dollar.compareTo(BigDecimal.ZERO) > 0 + && date != null + && !date.isEmpty(); + } + + @Override + public String toString() { + return "RateEntry{dollar=" + dollar + ", date='" + date + "'}"; + } + } + + @Override + public String toString() { + return "BCVHistoryResponse{start_date='" + start_date + "', end_date='" + end_date + + "', rates_count=" + (rates != null ? rates.size() : 0) + "}"; + } +} diff --git a/src/com/venezuela/bcvrate/service/BCVRateResponse.java b/src/com/venezuela/bcvrate/service/BCVRateResponse.java new file mode 100644 index 0000000..23a111f --- /dev/null +++ b/src/com/venezuela/bcvrate/service/BCVRateResponse.java @@ -0,0 +1,62 @@ +package com.venezuela.bcvrate.service; + +import java.math.BigDecimal; + +public class BCVRateResponse { + + private BigDecimal dollar; + private String date; + private String effectiveDate; + + public BCVRateResponse() { + } + + public BCVRateResponse(BigDecimal dollar, String date) { + this.dollar = dollar; + this.date = date; + this.effectiveDate = date; + } + + public BCVRateResponse(BigDecimal dollar, String requestedDate, String effectiveDate) { + this.dollar = dollar; + this.date = requestedDate; + this.effectiveDate = effectiveDate; + } + + public BigDecimal getDollar() { + return dollar; + } + + public void setDollar(BigDecimal dollar) { + this.dollar = dollar; + } + + public String getDate() { + return date; + } + + public void setDate(String date) { + this.date = date; + } + + public String getEffectiveDate() { + return effectiveDate; + } + + public void setEffectiveDate(String effectiveDate) { + this.effectiveDate = effectiveDate; + } + + public boolean isValid() { + return dollar != null + && dollar.compareTo(BigDecimal.ZERO) > 0 + && date != null + && !date.isEmpty(); + } + + @Override + public String toString() { + return "BCVRateResponse{dollar=" + dollar + ", date='" + date + + "', effectiveDate='" + effectiveDate + "'}"; + } +}