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.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 java.time.LocalDateTime; import java.util.Objects; import java.util.UUID; @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; public PaymentService(PaymentRepository payRepo, PaymentTransactionRepository txRepo, RefundRepository refundRepo, RedsysService redsysService, WebhookEventRepository webhookEventRepo, CartService cartService) { this.payRepo = payRepo; this.txRepo = txRepo; this.refundRepo = refundRepo; this.redsysService = redsysService; this.webhookEventRepo = webhookEventRepo; this.cartService = cartService; } /** * Crea el Payment en BD y construye el formulario de Redsys usando la API * oficial (ApiMacSha256). */ @Transactional public FormPayload createRedsysPayment(Long cartId, long amountCents, String currency, String method) throws Exception { Payment p = new Payment(); p.setOrderId(null); Cart cart = this.cartService.findById(cartId); if (cart != null && cart.getUserId() != null) { p.setUserId(cart.getUserId()); } p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("redsys"); p.setStatus(PaymentStatus.requires_payment_method); p = payRepo.saveAndFlush(p); // ANTES: // String dsOrder = String.format("%012d", p.getId()); // AHORA: timestamp 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); if ("bizum".equalsIgnoreCase(method)) { return redsysService.buildRedirectFormBizum(req); } else { return redsysService.buildRedirectForm(req); } } @Transactional public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) 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); Object authCode = notif.raw.get("Ds_AuthorisationCode"); String gatewayTxId = null; if (authCode != null) { String trimmed = String.valueOf(authCode).trim(); // Redsys devuelve " " (espacios) cuando NO hay código de autorización. // Eso lo consideramos "sin ID" → null, para no chocar con el índice único. if (!trimmed.isEmpty()) { 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) { p.setAuthorizationCode(tx.getGatewayTransactionId()); p.setStatus(PaymentStatus.captured); p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); p.setAuthorizedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now()); } else { p.setStatus(PaymentStatus.failed); p.setFailedAt(LocalDateTime.now()); } if (authorized) { processOrder(notif.cartId); } 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); } 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 amountCents, String currency) { Payment p = new Payment(); p.setOrderId(null); Cart cart = this.cartService.findById(cartId); if (cart != null && cart.getUserId() != null) { p.setUserId(cart.getUserId()); // En el orderId de la transferencia pendiente guardamos el ID del carrito p.setOrderId(cartId); // 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 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); // tx.setProcessedAt(null); // la dejas nula hasta que se confirme txRepo.save(tx); return p; } @Transactional public void markBankTransferAsCaptured(Long paymentId) { 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); payRepo.save(p); // 4) Procesar el pedido asociado al carrito (si existe) if (p.getOrderId() != null) { processOrder(p.getOrderId()); } } 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; } private Boolean processOrder(Long cartId) { // GENERAR PEDIDO A PARTIR DEL CARRITO Cart cart = this.cartService.findById(cartId); if (cart != null) { // Bloqueamos el carrito this.cartService.lockCartById(cart.getId()); // order ID es generado dentro de createOrderFromCart donde se marcan los // presupuestos como no editables // Long orderId = // this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId()); // p.setOrderId(orderId); } return true; } }