OdooService.java
package com.wavii.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Servicio de integración con Odoo ERP mediante JSON-RPC.
* Gestiona la sincronización de clientes, facturación y tareas de proyectos.
*
* @author eduglezexp
*/
@Service
@Slf4j
@SuppressWarnings({"rawtypes", "unchecked"})
public class OdooService {
private static final String SUBSCRIPTION_PROJECT = "Suscripciones Wavii";
private static final String VERIFICATION_MODEL = "wavii.teacher.verification";
private static final String MODERATION_MODEL = "wavii.moderation.report";
@Value("${odoo.url:}")
private String odooUrl;
@Value("${odoo.db:}")
private String odooDb;
@Value("${odoo.user:}")
private String odooUser;
@Value("${odoo.password:}")
private String odooPassword;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
private final ObjectMapper mapper = new ObjectMapper();
/**
* Comprueba si el servicio de Odoo está configurado correctamente con los parámetros del entorno.
*
* @return true si la configuración mínima está presente.
*/
public boolean isConfigured() {
return odooUrl != null && !odooUrl.isBlank()
&& odooDb != null && !odooDb.isBlank()
&& odooUser != null && !odooUser.isBlank();
}
// ── Capa de transporte JSON-RPC ──
/** Llamada genérica al endpoint /jsonrpc de Odoo. Usa raw List para datos heterogéneos. */
private Object callRpc(String service, String method, List args) throws Exception {
Map<String, Object> body = Map.of(
"jsonrpc", "2.0",
"method", "call",
"id", 1,
"params", Map.of(
"service", service,
"method", method,
"args", args
)
);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(odooUrl + "/jsonrpc"))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(30))
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
Map<String, Object> json = mapper.readValue(response.body(), Map.class);
if (json.containsKey("error")) {
throw new RuntimeException("Odoo RPC error: " + json.get("error"));
}
return json.get("result");
}
/**
* Llama a execute_kw para operar sobre un modelo de Odoo.
* args es la lista de argumentos posicionales del método del modelo.
*/
private Object executeKw(int uid, String model, String method,
List args, Map kwargs) throws Exception {
List rpcArgs = new ArrayList();
rpcArgs.add(odooDb);
rpcArgs.add(uid);
rpcArgs.add(odooPassword);
rpcArgs.add(model);
rpcArgs.add(method);
rpcArgs.add(args);
rpcArgs.add(kwargs);
return callRpc("object", "execute_kw", rpcArgs);
}
private Object executeKw(int uid, String model, String method, List args) throws Exception {
return executeKw(uid, model, method, args, Map.of());
}
// ── Helpers de modelo Odoo ──
/** Busca registros por dominio y devuelve sus IDs. */
private List<Integer> search(int uid, String model, List domain) throws Exception {
List args = new ArrayList();
args.add(domain);
Object result = executeKw(uid, model, "search", args);
List<?> raw = (List<?>) result;
return raw.stream().map(o -> ((Number) o).intValue()).toList();
}
/** Crea un registro y devuelve su ID. Odoo 17 puede devolver [id] o id. */
private int create(int uid, String model, Map<String, Object> values) throws Exception {
List createArgs = new ArrayList();
List valsList = new ArrayList();
valsList.add(values);
createArgs.add(valsList);
Object result = executeKw(uid, model, "create", createArgs);
if (result instanceof List<?> list) {
return ((Number) list.get(0)).intValue();
}
return ((Number) result).intValue();
}
// ── Autenticación ──
private int authenticate() throws Exception {
List args = new ArrayList();
args.add(odooDb);
args.add(odooUser);
args.add(odooPassword);
args.add(Map.of());
Object result = callRpc("common", "authenticate", args);
if (result == null || Boolean.FALSE.equals(result)) {
throw new RuntimeException("Autenticacion Odoo fallida — verifica credenciales en .env");
}
return ((Number) result).intValue();
}
// ── Verificaciones ──
private static final String VERIFICATION_PROJECT = "Verificaciones Wavii";
/**
* Busca un partner (cliente) en Odoo por su email o lo crea si no existe.
*
* @param email Email del cliente.
* @param name Nombre del cliente.
* @param uid ID de usuario autenticado en Odoo.
* @return ID del partner en Odoo.
* @throws Exception Si falla la comunicación con Odoo.
*/
public int findOrCreatePartner(String email, String name, int uid) throws Exception {
List condition = Arrays.asList("email", "=", email);
List domain = new ArrayList();
domain.add(condition);
List<Integer> existing = search(uid, "res.partner", domain);
if (!existing.isEmpty()) {
return existing.get(0);
}
Map<String, Object> vals = new LinkedHashMap<>();
vals.put("name", (name != null && !name.isBlank()) ? name : email);
vals.put("email", email);
vals.put("customer_rank", 1);
int partnerId = create(uid, "res.partner", vals);
log.debug("Odoo: nuevo partner creado id={} email={}", partnerId, email);
return partnerId;
}
/**
* Crea una factura de cliente en Odoo y la confirma automáticamente.
*
* @param partnerId ID del cliente.
* @param planName Nombre del concepto o plan.
* @param amount Importe de la factura.
* @param currency Moneda (ej. "EUR").
* @param uid ID de usuario de Odoo.
* @return ID de la factura creada.
* @throws Exception Si falla la comunicación con Odoo.
*/
public int createInvoice(int partnerId, String planName, double amount,
String currency, int uid) throws Exception {
return createInvoice(partnerId, planName, amount, currency, uid, null);
}
public int createInvoice(int partnerId, String planName, double amount,
String currency, int uid, String stripeReference) throws Exception {
// Buscar el ID de la moneda
List currencyCondition = Arrays.asList("name", "=", currency.toUpperCase());
List currencyDomain = new ArrayList();
currencyDomain.add(currencyCondition);
List<Integer> currencies = search(uid, "res.currency", currencyDomain);
int currencyId = currencies.isEmpty() ? 1 : currencies.get(0);
// Línea de factura como comando ORM: (0, 0, {vals})
Map<String, Object> invoiceLine = new LinkedHashMap<>();
invoiceLine.put("name", "Suscripcion Wavii — " + planName);
invoiceLine.put("quantity", 1.0);
invoiceLine.put("price_unit", amount);
List lineCmd = Arrays.asList(0, 0, invoiceLine);
List invoiceLines = new ArrayList();
invoiceLines.add(lineCmd);
Map<String, Object> invoiceVals = new LinkedHashMap<>();
invoiceVals.put("move_type", "out_invoice");
invoiceVals.put("partner_id", partnerId);
invoiceVals.put("currency_id", currencyId);
invoiceVals.put("invoice_line_ids", invoiceLines);
if (stripeReference != null && !stripeReference.isBlank()) {
invoiceVals.put("ref", stripeReference);
invoiceVals.put("payment_reference", stripeReference);
}
int invoiceId = create(uid, "account.move", invoiceVals);
// Confirmar la factura: draft → posted
List invoiceIds = new ArrayList();
invoiceIds.add(invoiceId);
List postArgs = new ArrayList();
postArgs.add(invoiceIds);
executeKw(uid, "account.move", "action_post", postArgs);
log.debug("Odoo: factura creada y confirmada id={}", invoiceId);
return invoiceId;
}
private Integer findInvoiceByReference(int uid, String stripeReference) throws Exception {
if (stripeReference == null || stripeReference.isBlank()) {
return null;
}
List domain = new ArrayList();
domain.add(Arrays.asList("ref", "=", stripeReference));
List<Integer> invoices = search(uid, "account.move", domain);
return invoices.isEmpty() ? null : invoices.get(0);
}
/**
* Registra un pago para una factura existente en Odoo mediante el asistente de pago.
*
* @param invoiceId ID de la factura.
* @param partnerId ID del cliente.
* @param amount Importe pagado.
* @param uid ID de usuario de Odoo.
* @throws Exception Si falla la comunicación con Odoo.
*/
public void registerPayment(int invoiceId, int partnerId, double amount, int uid) throws Exception {
List journalCondition = Arrays.asList("type", "in", Arrays.asList("bank", "cash"));
List journalDomain = new ArrayList();
journalDomain.add(journalCondition);
List<Integer> journals = search(uid, "account.journal", journalDomain);
if (journals.isEmpty()) {
log.warn("Odoo: no se encontro un journal de banco/caja - pago no registrado");
return;
}
int journalId = journals.get(0);
Map<String, Object> context = new LinkedHashMap<>();
context.put("active_model", "account.move");
context.put("active_ids", List.of(invoiceId));
context.put("active_id", invoiceId);
Map<String, Object> registerVals = new LinkedHashMap<>();
registerVals.put("amount", amount);
registerVals.put("journal_id", journalId);
registerVals.put("partner_id", partnerId);
List createArgs = new ArrayList();
createArgs.add(registerVals);
Object wizardResult = executeKw(
uid,
"account.payment.register",
"create",
createArgs,
Map.of("context", context)
);
int wizardId;
if (wizardResult instanceof List<?> list) {
wizardId = ((Number) list.get(0)).intValue();
} else {
wizardId = ((Number) wizardResult).intValue();
}
List actionArgs = new ArrayList();
actionArgs.add(List.of(wizardId));
executeKw(
uid,
"account.payment.register",
"action_create_payments",
actionArgs,
Map.of("context", context)
);
log.debug("Odoo: pago registrado y conciliado para factura id={} mediante wizard id={}", invoiceId, wizardId);
}
/**
* Crea un contacto en el CRM de Odoo de forma asíncrona tras la verificación de email.
*
* @param name Nombre del contacto.
* @param email Email del contacto.
* @param role Rol asignado en Wavii.
* @param subscription Plan de suscripción actual.
*/
@Async
public void createCrmContact(String name, String email, String role, String subscription) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado — contacto CRM no creado para {}", email);
return;
}
try {
int uid = authenticate();
List condition = Arrays.asList("email", "=", email);
List domain = new ArrayList();
domain.add(condition);
List<Integer> existing = search(uid, "res.partner", domain);
if (!existing.isEmpty()) {
log.debug("Odoo CRM: partner ya existe id={} email={}", existing.get(0), email);
return;
}
Map<String, Object> vals = new LinkedHashMap<>();
vals.put("name", (name != null && !name.isBlank()) ? name : email);
vals.put("email", email);
vals.put("comment", "Rol: " + role + " | Plan: " + subscription);
vals.put("customer_rank", 1);
int partnerId = create(uid, "res.partner", vals);
log.info("Odoo CRM: contacto creado id={} email={}", partnerId, email);
} catch (Exception e) {
log.warn("Odoo CRM: error creando contacto para {} — {}", email, e.getMessage());
}
}
/**
* Crea una tarea de verificación en el proyecto correspondiente de Odoo.
*
* @param userName Nombre del usuario profesor.
* @param email Email del usuario.
* @param fileName Nombre del archivo subido.
* @param documentUrl URL para visualizar el documento.
*/
@Async
public void createVerificationTask(
String userId,
String userName,
String email,
String fileName,
String mimeType,
byte[] fileBytes,
String documentUrl
) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado — tarea de verificación no creada para {}", email);
return;
}
try {
int uid = authenticate();
if (createVerificationRecord(uid, userId, userName, email, fileName, mimeType, fileBytes)) {
log.info("Odoo: verificacion creada en modulo personalizado para {}", email);
return;
}
List projCondition = Arrays.asList("name", "=", VERIFICATION_PROJECT);
List projDomain = new ArrayList();
projDomain.add(projCondition);
List<Integer> projects = search(uid, "project.project", projDomain);
int projectId;
if (projects.isEmpty()) {
Map<String, Object> projVals = new LinkedHashMap<>();
projVals.put("name", VERIFICATION_PROJECT);
projectId = create(uid, "project.project", projVals);
log.debug("Odoo: proyecto '{}' creado id={}", VERIFICATION_PROJECT, projectId);
} else {
projectId = projects.get(0);
}
String description = "UserId: " + userId
+ "\nEmail: " + email
+ "\nDocumento: " + fileName
+ (documentUrl != null ? "\nURL: " + documentUrl : "");
Map<String, Object> taskVals = new LinkedHashMap<>();
taskVals.put("name", "Verificar profesor: " + userName);
taskVals.put("description", description);
taskVals.put("project_id", projectId);
int taskId = create(uid, "project.task", taskVals);
if (fileBytes != null && fileBytes.length > 0) {
createVerificationAttachment(uid, taskId, "project.task", fileName, mimeType, fileBytes);
}
log.info("Odoo: tarea de verificación creada id={} para {}", taskId, email);
} catch (Exception e) {
log.warn("Odoo: error creando tarea de verificación para {} — {}", email, e.getMessage());
}
}
/**
* Crea una tarea de gestión de suscripción en el proyecto correspondiente de Odoo.
*
* @param userName Nombre del usuario.
* @param email Email del usuario.
* @param title Título de la tarea.
* @param description Descripción detallada.
*/
private boolean createVerificationRecord(
int uid,
String userId,
String userName,
String email,
String fileName,
String mimeType,
byte[] fileBytes
) {
try {
Map<String, Object> vals = new LinkedHashMap<>();
vals.put("user_id", userId);
vals.put("teacher_name", userName);
vals.put("email", email);
vals.put("document_filename", fileName);
vals.put("status", "pending");
int verificationId = create(uid, VERIFICATION_MODEL, vals);
if (fileBytes != null && fileBytes.length > 0) {
int attachmentId = createVerificationAttachment(uid, verificationId, VERIFICATION_MODEL, fileName, mimeType, fileBytes);
List writeArgs = new ArrayList();
writeArgs.add(List.of(verificationId));
writeArgs.add(Map.of("document_attachment_id", attachmentId));
executeKw(uid, VERIFICATION_MODEL, "write", writeArgs);
}
return true;
} catch (Exception e) {
log.info("Odoo: modulo {} no disponible, fallback a project.task: {}", VERIFICATION_MODEL, e.getMessage());
return false;
}
}
private int createVerificationAttachment(int uid, int recordId, String resModel, String fileName, String mimeType, byte[] fileBytes) throws Exception {
Map<String, Object> attachmentVals = new LinkedHashMap<>();
attachmentVals.put("name", fileName);
attachmentVals.put("type", "binary");
attachmentVals.put("res_model", resModel);
attachmentVals.put("res_id", recordId);
attachmentVals.put("mimetype", (mimeType != null && !mimeType.isBlank()) ? mimeType : "application/pdf");
attachmentVals.put("datas", Base64.getEncoder().encodeToString(fileBytes));
return create(uid, "ir.attachment", attachmentVals);
}
@Async
public void createSubscriptionTask(String userName, String email, String title, String description) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado - tarea de suscripción no creada para {}", email);
return;
}
try {
int uid = authenticate();
List projCondition = Arrays.asList("name", "=", SUBSCRIPTION_PROJECT);
List projDomain = new ArrayList();
projDomain.add(projCondition);
List<Integer> projects = search(uid, "project.project", projDomain);
int projectId;
if (projects.isEmpty()) {
Map<String, Object> projVals = new LinkedHashMap<>();
projVals.put("name", SUBSCRIPTION_PROJECT);
projectId = create(uid, "project.project", projVals);
} else {
projectId = projects.get(0);
}
Map<String, Object> taskVals = new LinkedHashMap<>();
taskVals.put("name", title);
taskVals.put("description", "Usuario: " + userName + "\nEmail: " + email + "\n\n" + description);
taskVals.put("project_id", projectId);
create(uid, "project.task", taskVals);
} catch (Exception e) {
log.warn("Odoo: error creando tarea de suscripción para {} - {}", email, e.getMessage());
}
}
/**
* Procesa un pago de Stripe de forma asíncrona: crea partner, factura y registra el pago en Odoo.
*
* @param customerEmail Email del cliente.
* @param customerName Nombre del cliente.
* @param planName Nombre del plan suscrito.
* @param amount Importe pagado.
*/
@Async
public void processStripePayment(String customerEmail, String customerName,
String planName, double amount) {
processStripePayment(customerEmail, customerName, planName, amount, null);
}
@Async
public void processStripePayment(String customerEmail, String customerName,
String planName, double amount, String stripeReference) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado — factura no generada para {}", customerEmail);
return;
}
try {
int uid = authenticate();
Integer existingInvoiceId = findInvoiceByReference(uid, stripeReference);
if (existingInvoiceId != null) {
log.info("Odoo: factura ya existente para referencia Stripe {} id={}", stripeReference, existingInvoiceId);
return;
}
int partnerId = findOrCreatePartner(customerEmail, customerName, uid);
int invoiceId = createInvoice(partnerId, planName, amount, "EUR", uid, stripeReference);
registerPayment(invoiceId, partnerId, amount, uid);
log.info("Odoo: factura generada correctamente para {} — plan={} importe={}EUR",
customerEmail, planName, amount);
} catch (Exception e) {
log.error("Odoo: error procesando pago de {} ({}): {}", customerEmail, planName, e.getMessage());
createSubscriptionTask(
customerName,
customerEmail,
"Revision manual de factura Stripe",
"No se pudo crear la factura automaticamente."
+ "\nConcepto: " + planName
+ "\nImporte: " + amount
+ "\nReferencia Stripe: " + (stripeReference != null ? stripeReference : "no informada")
+ "\nError: " + e.getMessage()
);
}
}
@Async
public void processSubscriptionPlanChangePayment(
String customerEmail,
String customerName,
String previousPlan,
String newPlan,
double amount,
String stripeReference
) {
String concept = "Cambio Wavii " + previousPlan + " -> Wavii " + newPlan + " - Promo primer mes";
processStripePayment(customerEmail, customerName, concept, amount, stripeReference);
}
@Async
public void createModerationReport(
String reportType,
String reporterName,
String reporterEmail,
String targetLabel,
String targetReference,
String reason,
String details
) {
createModerationReport(reportType, reporterName, reporterEmail, targetLabel, targetReference, reason, details, Map.of());
}
@Async
public void createModerationReport(
String reportType,
String reporterName,
String reporterEmail,
String targetLabel,
String targetReference,
String reason,
String details,
Map<String, Object> snapshot
) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado - reporte de moderacion no creado para {}", targetLabel);
return;
}
try {
int uid = authenticate();
Map<String, Object> vals = new LinkedHashMap<>();
vals.put("report_type", reportType);
vals.put("reporter_name", reporterName);
vals.put("reporter_email", reporterEmail);
vals.put("target_label", targetLabel);
vals.put("target_reference", targetReference);
vals.put("reason", reason);
vals.put("details", details != null ? details : "");
if (snapshot != null && !snapshot.isEmpty()) {
vals.put("snapshot_json", mapper.writeValueAsString(snapshot));
putIfPresent(vals, "target_description", snapshot.get("listingDescription"));
putIfPresent(vals, "target_city", snapshot.get("listingCity"));
putIfPresent(vals, "target_genre", snapshot.get("listingGenre"));
Object roles = snapshot.get("listingRoles");
if (roles != null) {
vals.put("target_roles", roles.toString());
}
putIfPresent(vals, "reported_user_name", snapshot.get("creatorName"));
putIfPresent(vals, "reported_user_email", snapshot.get("creatorEmail"));
}
vals.put("status", "pending");
create(uid, MODERATION_MODEL, vals);
log.info("Odoo: reporte de moderacion creado tipo={} target={}", reportType, targetReference);
} catch (Exception e) {
log.warn("Odoo: error creando reporte de moderacion {} - {}", targetReference, e.getMessage());
}
}
private void putIfPresent(Map<String, Object> vals, String key, Object value) {
if (value != null) {
vals.put(key, value.toString());
}
}
/**
* Procesa el pago de una clase particular en Odoo.
*
* @param studentEmail Email del alumno.
* @param studentName Nombre del alumno.
* @param teacherName Nombre del profesor.
* @param instrument Instrumento de la clase.
* @param modality Modalidad de la clase.
* @param city Ciudad de la clase.
* @param amount Importe pagado.
*/
@Async
public void processClassPayment(String studentEmail, String studentName,
String teacherName, String instrument,
String modality, String city, double amount) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado — factura de clase no generada para {}", studentEmail);
return;
}
try {
int uid = authenticate();
String partnerLabel = studentName + " / " + teacherName;
int partnerId = findOrCreatePartner(studentEmail, partnerLabel, uid);
String planName = "Clase con " + teacherName;
int invoiceId = createInvoice(partnerId, planName, amount, "EUR", uid);
registerPayment(invoiceId, partnerId, amount, uid);
log.info("Odoo: factura de clase generada para {} con {} por {}EUR",
studentEmail, teacherName, amount);
} catch (Exception e) {
log.error("Odoo: error procesando pago de clase para {}: {}", studentEmail, e.getMessage());
}
}
/**
* Procesa el reembolso de una clase particular en Odoo.
*
* @param studentEmail Email del alumno.
* @param studentName Nombre del alumno.
* @param teacherName Nombre del profesor.
* @param amount Importe reembolsado.
*/
@Async
public void processClassRefund(String studentEmail, String studentName,
String teacherName, double amount) {
if (!isConfigured()) {
log.info("[DEV] Odoo no configurado — devolución de clase no generada para {}", studentEmail);
return;
}
try {
int uid = authenticate();
String partnerLabel = studentName + " / " + teacherName;
int partnerId = findOrCreatePartner(studentEmail, partnerLabel, uid);
int invoiceId = createInvoice(partnerId, "Reembolso clase con " + teacherName, -Math.abs(amount), "EUR", uid);
registerPayment(invoiceId, partnerId, -Math.abs(amount), uid);
log.info("Odoo: reembolso de clase registrado para {} con {} por {}EUR",
studentEmail, teacherName, amount);
} catch (Exception e) {
log.error("Odoo: error procesando reembolso de clase para {}: {}", studentEmail, e.getMessage());
}
}
}