mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-13 00:48:49 +00:00
393 lines
15 KiB
Java
393 lines
15 KiB
Java
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;
|
||
}
|
||
|
||
}
|