package com.imprimelibros.erp.payments; import com.fasterxml.jackson.databind.ObjectMapper; import com.imprimelibros.erp.cart.Cart; import com.imprimelibros.erp.cart.CartService; import com.imprimelibros.erp.facturacion.SerieFactura; import com.imprimelibros.erp.facturacion.TipoPago; import com.imprimelibros.erp.facturacion.service.FacturacionService; import com.imprimelibros.erp.payments.model.*; import com.imprimelibros.erp.payments.repo.PaymentRepository; import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository; import com.imprimelibros.erp.payments.repo.RefundRepository; import com.imprimelibros.erp.redsys.RedsysService; import com.imprimelibros.erp.redsys.RedsysService.FormPayload; import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.imprimelibros.erp.payments.repo.WebhookEventRepository; import com.imprimelibros.erp.pedidos.Pedido; import com.imprimelibros.erp.pedidos.PedidoService; import java.time.LocalDateTime; import java.util.Locale; import java.util.Map; import java.util.Objects; @Service public class PaymentService { private final PaymentRepository payRepo; private final PaymentTransactionRepository txRepo; private final RefundRepository refundRepo; private final RedsysService redsysService; private final WebhookEventRepository webhookEventRepo; private final ObjectMapper om = new ObjectMapper(); private final CartService cartService; private final PedidoService pedidoService; private final FacturacionService facturacionService; public PaymentService(PaymentRepository payRepo, PaymentTransactionRepository txRepo, RefundRepository refundRepo, RedsysService redsysService, WebhookEventRepository webhookEventRepo, CartService cartService, PedidoService pedidoService, FacturacionService facturacionService) { this.payRepo = payRepo; this.txRepo = txRepo; this.refundRepo = refundRepo; this.redsysService = redsysService; this.webhookEventRepo = webhookEventRepo; this.cartService = cartService; this.pedidoService = pedidoService; this.facturacionService = facturacionService; } public Payment findFailedPaymentByOrderId(Long orderId) { return payRepo.findFirstByOrderIdAndStatusOrderByIdDesc(orderId, PaymentStatus.failed) .orElse(null); } public Map getPaymentTransactionData(Long paymentId) { PaymentTransaction tx = txRepo.findByPaymentIdAndType( paymentId, PaymentTransactionType.CAPTURE) .orElse(null); if (tx == null) { return null; } String resp_payload = tx.getResponsePayload(); try { ObjectMapper om = new ObjectMapper(); var node = om.readTree(resp_payload); Long cartId = null; Long dirFactId = null; if (node.has("Ds_MerchantData")) { // format: "Ds_MerchantData": "{"dirFactId":3,"cartId":90}" String merchantData = node.get("Ds_MerchantData").asText(); merchantData = merchantData.replace(""", "\""); var mdNode = om.readTree(merchantData); if (mdNode.has("cartId")) { cartId = mdNode.get("cartId").asLong(); } if (mdNode.has("dirFactId")) { dirFactId = mdNode.get("dirFactId").asLong(); } } return Map.of( "cartId", cartId, "dirFactId", dirFactId); } catch (Exception e) { return null; } } /** * Crea el Payment en BD y construye el formulario de Redsys usando la API * oficial (ApiMacSha256). */ @Transactional public FormPayload createRedsysPayment(Long cartId, Long dirFactId, Long amountCents, String currency, String method, Long orderId) throws Exception { Payment p = new Payment(); p.setOrderId(orderId); Cart cart = this.cartService.findById(cartId); if (cart != null && cart.getUserId() != null) { p.setUserId(cart.getUserId()); this.cartService.lockCartById(cartId); } p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("redsys"); p.setStatus(PaymentStatus.requires_payment_method); p = payRepo.saveAndFlush(p); long now = System.currentTimeMillis(); String dsOrder = String.format("%012d", now % 1_000_000_000_000L); p.setGatewayOrderId(dsOrder); payRepo.save(p); RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents, "Compra en Imprimelibros", cartId, dirFactId); if ("bizum".equalsIgnoreCase(method)) { return redsysService.buildRedirectFormBizum(req); } else { return redsysService.buildRedirectForm(req); } } @Transactional public void handleRedsysNotification(String dsSignature, String dsMerchantParameters, Locale locale) throws Exception { // 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo // y salimos. RedsysNotification notif; try { notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); } catch (Exception ex) { WebhookEvent e = new WebhookEvent(); e.setProvider("redsys"); e.setEventType("payment_notification_parse_error"); e.setEventId("PARSE_ERROR_" + System.currentTimeMillis()); e.setSignature(dsSignature); e.setPayload(dsMerchantParameters); e.setProcessed(false); e.setAttempts(1); e.setLastError("Error parsing/validating Redsys notification: " + ex.getMessage()); webhookEventRepo.save(e); // IMPORTANTE: NO re-lanzamos la excepción // Simplemente salimos. Así se hace commit de este insert. return; } // 1) A partir de aquí, el parseo ha ido bien y tenemos notif.order, // notif.amountCents, etc. String provider = "redsys"; String eventType = "payment_notification"; String eventId = notif.order; WebhookEvent ev = webhookEventRepo .findByProviderAndEventId(provider, eventId) .orElseGet(() -> { WebhookEvent e = new WebhookEvent(); e.setProvider(provider); e.setEventType(eventType); e.setEventId(eventId); e.setSignature(dsSignature); try { e.setPayload(om.writeValueAsString(notif.raw)); } catch (Exception ex) { e.setPayload(dsMerchantParameters); } e.setProcessed(false); e.setAttempts(0); return webhookEventRepo.save(e); }); if (Boolean.TRUE.equals(ev.getProcessed())) { return; } Integer attempts = ev.getAttempts() == null ? 0 : ev.getAttempts(); ev.setAttempts(attempts + 1); ev.setLastError(null); webhookEventRepo.save(ev); try { Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order) .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) { throw new IllegalStateException("Importe inesperado: esperado=" + p.getAmountTotalCents() + " recibido=" + notif.amountCents); } if (p.getStatus() == PaymentStatus.captured || p.getStatus() == PaymentStatus.partially_refunded || p.getStatus() == PaymentStatus.refunded) { ev.setProcessed(true); ev.setProcessedAt(LocalDateTime.now()); webhookEventRepo.save(ev); return; } boolean authorized = isRedsysAuthorized(notif); PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.CAPTURE); tx.setCurrency(p.getCurrency()); // "EUR" tx.setAmountCents(notif.amountCents); tx.setStatus(authorized ? PaymentTransactionStatus.succeeded : PaymentTransactionStatus.failed); String gatewayTxId = null; // 1) Si es Bizum y tenemos Ds_Bizum_IdOper, úsalo como ID único if (notif.isBizum() && notif.bizumIdOper != null && !notif.bizumIdOper.isBlank()) { gatewayTxId = notif.bizumIdOper.trim(); // 2) Si no es Bizum, intenta usar Ds_AuthorisationCode } else if (notif.authorisationCode != null) { String trimmed = notif.authorisationCode.trim(); // Redsys suele mandar "000000" para Bizum; por si acaso también lo filtramos if (!trimmed.isEmpty() && !"000000".equals(trimmed)) { gatewayTxId = trimmed; } } // MySQL permite múltiples NULL en un índice UNIQUE, así que es seguro. tx.setGatewayTransactionId(gatewayTxId); tx.setGatewayResponseCode(notif.response); tx.setResponsePayload(om.writeValueAsString(notif.raw)); tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); if (authorized) { if (notif.isBizum()) { p.setAuthorizationCode(null); // o "000000" si te interesa mostrarlo } else if (notif.authorisationCode != null && !"000000".equals(notif.authorisationCode.trim()) && !notif.authorisationCode.isBlank()) { p.setAuthorizationCode(notif.authorisationCode.trim()); } p.setStatus(PaymentStatus.captured); p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); p.setAuthorizedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now()); pedidoService.setOrderAsPaid(p.getOrderId()); Pedido pedido = pedidoService.getPedidoById(p.getOrderId()); SerieFactura serie = facturacionService.getDefaultSerieFactura(); facturacionService.crearNuevaFacturaAuto(pedido, serie, notif.isBizum() ? TipoPago.tpv_bizum : TipoPago.tpv_tarjeta, locale); } else { p.setStatus(PaymentStatus.failed); p.setFailedAt(LocalDateTime.now()); pedidoService.markPedidoAsPaymentDenied(p.getOrderId()); } payRepo.save(p); if (!authorized) { ev.setLastError("Payment declined (Ds_Response=" + notif.response + ")"); } ev.setProcessed(true); ev.setProcessedAt(LocalDateTime.now()); webhookEventRepo.save(ev); } catch (Exception e) { ev.setProcessed(false); ev.setLastError(e.getMessage()); ev.setProcessedAt(null); webhookEventRepo.save(ev); throw e; // aquí sí, porque queremos que si falla lógica de negocio el caller se entere } } // ---- refundViaRedsys // ---- @Transactional public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) { Payment p = payRepo.findById(paymentId) .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado")); if (amountCents <= 0) throw new IllegalArgumentException("Importe inválido"); long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents(); if (amountCents > maxRefundable) throw new IllegalStateException("Importe de devolución supera lo capturado"); txRepo.findByIdempotencyKey(idempotencyKey) .ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); }); Refund r = new Refund(); r.setPayment(p); r.setAmountCents(amountCents); r.setStatus(RefundStatus.pending); r.setRequestedAt(LocalDateTime.now()); r = refundRepo.save(r); String gatewayRefundId; try { // ⚠️ Usa aquí el mismo valor que mandaste en Ds_Merchant_Order al cobrar // por ejemplo, p.getGatewayOrderId() o similar String originalOrder = p.getGatewayOrderId(); // ajusta al nombre real del campo gatewayRefundId = redsysService.requestRefund(originalOrder, amountCents); } catch (Exception e) { r.setStatus(RefundStatus.failed); r.setProcessedAt(LocalDateTime.now()); refundRepo.save(r); throw new IllegalStateException("Error al solicitar la devolución a Redsys", e); } // 🔧 NORMALIZAR ANTES DE GUARDAR if (gatewayRefundId != null) { gatewayRefundId = gatewayRefundId.trim(); if (gatewayRefundId.isEmpty() || "000000".equals(gatewayRefundId)) { gatewayRefundId = null; // → múltiples NULL NO rompen el UNIQUE } } PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.REFUND); tx.setStatus(PaymentTransactionStatus.succeeded); tx.setAmountCents(amountCents); tx.setCurrency(p.getCurrency()); tx.setGatewayTransactionId(gatewayRefundId); tx.setIdempotencyKey(idempotencyKey); tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); r.setStatus(RefundStatus.succeeded); r.setTransaction(tx); r.setGatewayRefundId(gatewayRefundId); r.setProcessedAt(LocalDateTime.now()); refundRepo.save(r); p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents); if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) { p.setStatus(PaymentStatus.refunded); } else { p.setStatus(PaymentStatus.partially_refunded); } payRepo.save(p); } @Transactional public Payment createBankTransferPayment(Long cartId, Long dirFactId, long amountCents, String currency, Locale locale, Long orderId) { Payment p = new Payment(); p.setOrderId(null); Cart cart = this.cartService.findById(cartId); if (cart != null && cart.getUserId() != null) { p.setUserId(cart.getUserId()); // Se bloquea el carrito para evitar modificaciones mientras se procesa el pago this.cartService.lockCartById(cartId); } p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("bank_transfer"); p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso if (orderId != null) { p.setOrderId(orderId); } p = payRepo.save(p); // Crear transacción pendiente PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.CAPTURE); // o AUTH si prefieres tx.setStatus(PaymentTransactionStatus.pending); tx.setAmountCents(amountCents); tx.setCurrency(currency); String payload = ""; if (cartId != null) { payload = "{\"cartId\":" + cartId + "}"; } if (dirFactId != null) { if (!payload.isEmpty()) { payload = payload.substring(0, payload.length() - 1) + ",\"dirFactId\":" + dirFactId + "}"; } else { payload = "{\"dirFactId\":" + dirFactId + "}"; } } tx.setResponsePayload(payload); // tx.setProcessedAt(null); // la dejas nula hasta que se confirme txRepo.save(tx); return p; } @Transactional public void markBankTransferAsCaptured(Long paymentId, Locale locale) { Payment p = payRepo.findById(paymentId) .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId)); if (!"bank_transfer".equals(p.getGateway())) { throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer"); } // Idempotencia simple: si ya está capturado no hacemos nada if (p.getStatus() == PaymentStatus.captured || p.getStatus() == PaymentStatus.partially_refunded || p.getStatus() == PaymentStatus.refunded) { return; } // 1) Buscar la transacción pendiente de captura PaymentTransaction tx = txRepo .findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc( paymentId, PaymentTransactionType.CAPTURE, PaymentTransactionStatus.pending) .orElseThrow(() -> new IllegalStateException( "No se ha encontrado transacción PENDING para la transferencia " + paymentId)); // 2) Actualizarla a SUCCEEDED y rellenar processedAt tx.setStatus(PaymentTransactionStatus.succeeded); tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); // 3) Actualizar el Payment p.setAmountCapturedCents(p.getAmountTotalCents()); p.setCapturedAt(LocalDateTime.now()); p.setStatus(PaymentStatus.captured); Long cartId = null; Long dirFactId = null; try { // Intentar extraer cartId del payload de la transacción if (tx.getResponsePayload() != null && !tx.getResponsePayload().isBlank()) { ObjectMapper om = new ObjectMapper(); var node = om.readTree(tx.getResponsePayload()); if (node.has("cartId")) { cartId = node.get("cartId").asLong(); } if (node.has("dirFactId")) { dirFactId = node.get("dirFactId").asLong(); } } } catch (Exception e) { // ignorar } // 4) Procesar el pedido asociado al carrito (si existe) o marcar el pedido como pagado if(p.getOrderId() != null) { pedidoService.setOrderAsPaid(p.getOrderId()); Pedido pedido = pedidoService.getPedidoById(p.getOrderId()); SerieFactura serie = facturacionService.getDefaultSerieFactura(); facturacionService.crearNuevaFacturaAuto(pedido, serie, TipoPago.transferencia, locale); } /*else if (cartId != null) { // Se procesa el pedido dejando el estado calculado en processOrder Long orderId = processOrder(cartId, dirFactId, locale, null); if (orderId != null) { p.setOrderId(orderId); } }*/ payRepo.save(p); } /** * Devuelve (total o parcialmente) un pago hecho por transferencia bancaria. * - Solo permite gateway = "bank_transfer". * - Crea un Refund + PaymentTransaction de tipo REFUND. * - Actualiza amountRefundedCents y el estado del Payment. */ @Transactional public Refund refundBankTransfer(Long paymentId, long amountCents) { Payment p = payRepo.findById(paymentId) .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId)); if (!"bank_transfer".equals(p.getGateway())) { throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer"); } if (amountCents <= 0) { throw new IllegalArgumentException("El importe de devolución debe ser > 0"); } // Solo tiene sentido devolver si está capturado o ya parcialmente devuelto if (p.getStatus() != PaymentStatus.captured && p.getStatus() != PaymentStatus.partially_refunded) { throw new IllegalStateException( "El Payment " + paymentId + " no está capturado; estado actual: " + p.getStatus()); } long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents(); if (amountCents > maxRefundable) { throw new IllegalStateException( "Importe de devolución supera lo todavía reembolsable. " + "maxRefundable=" + maxRefundable + " requested=" + amountCents); } LocalDateTime now = LocalDateTime.now(); // 1) Crear Refund (para transferencias lo marcamos como SUCCEEDED directamente) Refund refund = new Refund(); refund.setPayment(p); refund.setAmountCents(amountCents); // reason usa el valor por defecto (customer_request); si quieres otro, cámbialo // aquí refund.setStatus(RefundStatus.succeeded); refund.setRequestedAt(now); refund.setProcessedAt(now); // requestedByUserId, notes, metadata -> opcionales, déjalos en null si no los // usas refund = refundRepo.save(refund); // 2) Crear transacción de tipo REFUND PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.REFUND); tx.setStatus(PaymentTransactionStatus.succeeded); tx.setAmountCents(amountCents); tx.setCurrency(p.getCurrency()); tx.setProcessedAt(now); // gatewayTransactionId lo dejamos null → el índice UNIQUE permite múltiples // NULL tx = txRepo.save(tx); // Vincular el Refund con la transacción refund.setTransaction(tx); refundRepo.save(refund); // 3) Actualizar Payment: total devuelto y estado p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents); if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) { p.setStatus(PaymentStatus.refunded); } else { p.setStatus(PaymentStatus.partially_refunded); } payRepo.save(p); return refund; } private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) { if (notif.response == null) { return false; } String r = notif.response.trim(); // Si no es numérico, lo tratamos como no autorizado if (!r.matches("\\d+")) { return false; } int code = Integer.parseInt(r); // Redsys: 0–99 → autorizado; >=100 → denegado / error return code >= 0 && code <= 99; } }