falta vista de pagos

This commit is contained in:
2025-11-04 14:40:18 +01:00
parent f528809c07
commit 7516e9e91e
24 changed files with 276 additions and 442 deletions

View File

@ -10,6 +10,7 @@ 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;
@ -22,16 +23,19 @@ public class PaymentService {
private final PaymentTransactionRepository txRepo;
private final RefundRepository refundRepo;
private final RedsysService redsysService;
private final WebhookEventRepository webhookEventRepo;
private final ObjectMapper om = new ObjectMapper();
public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo,
RefundRepository refundRepo,
RedsysService redsysService) {
RedsysService redsysService,
WebhookEventRepository webhookEventRepo) {
this.payRepo = payRepo;
this.txRepo = txRepo;
this.refundRepo = refundRepo;
this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo;
}
/**
@ -42,15 +46,20 @@ public class PaymentService {
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method)
throws Exception {
Payment p = new Payment();
p.setOrderId(orderId); // <- ahora puede ser null
p.setOrderId(orderId);
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("redsys");
p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD);
p.setStatus(PaymentStatus.requires_payment_method);
p = payRepo.saveAndFlush(p);
// Ds_Order = ID del Payment, 12 dígitos
String dsOrder = String.format("%012d", p.getId());
// 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);
@ -64,81 +73,137 @@ public class PaymentService {
}
}
// si aún tienes la versión antigua sin method, puedes dejar este overload si te
// viene bien:
@Transactional
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
return createRedsysPayment(orderId, amountCents, currency, "card");
}
/**
* Procesa una notificación Redsys (OK/notify) con la API oficial:
* - validateAndParseNotification usa createMerchantSignatureNotif +
* decodeMerchantParameters
*/
@Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
// Log útil para depurar
System.out.println(">> Redsys notify: order=" + notif.order +
" amountCents=" + notif.amountCents +
" currency=" + notif.currency +
" response=" + notif.response);
// 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);
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order)
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order));
// 🔹 Opción sencilla: sólo comprobar el importe
if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) {
throw new IllegalStateException("Importe inesperado: esperado=" +
p.getAmountTotalCents() + " recibido=" + notif.amountCents);
}
// Si quieres, puedes hacer un check mínimamente decente de divisa numérica:
// (si usas siempre EUR)
/*
* if (!"978".equals(notif.currency)) {
* throw new IllegalStateException("Divisa Redsys inesperada: " +
* notif.currency);
* }
*/
// Idempotencia simple: si ya está capturado o reembolsado, no hacemos nada
if (p.getStatus() == PaymentStatus.CAPTURED
|| p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED
|| p.getStatus() == PaymentStatus.REFUNDED) {
// IMPORTANTE: NO re-lanzamos la excepción
// Simplemente salimos. Así se hace commit de este insert.
return;
}
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.CAPTURE);
tx.setCurrency(p.getCurrency()); // "EUR"
tx.setAmountCents(notif.amountCents);
tx.setStatus(notif.authorized()
? PaymentTransactionStatus.SUCCEEDED
: PaymentTransactionStatus.FAILED);
// 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;
Object authCode = notif.raw.get("Ds_AuthorisationCode");
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
tx.setGatewayResponseCode(notif.response);
tx.setResponsePayload(om.writeValueAsString(notif.raw));
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
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 (notif.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 (Boolean.TRUE.equals(ev.getProcessed())) {
return;
}
payRepo.save(p);
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());
}
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 y bank_transfer igual que antes, no tocan RedsysService
@ -163,7 +228,7 @@ public class PaymentService {
Refund r = new Refund();
r.setPayment(p);
r.setAmountCents(amountCents);
r.setStatus(RefundStatus.PENDING);
r.setStatus(RefundStatus.pending);
r.setRequestedAt(LocalDateTime.now());
r = refundRepo.save(r);
@ -173,7 +238,7 @@ public class PaymentService {
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.REFUND);
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
tx.setStatus(PaymentTransactionStatus.succeeded);
tx.setAmountCents(amountCents);
tx.setCurrency(p.getCurrency());
tx.setGatewayTransactionId(gatewayRefundId);
@ -181,7 +246,7 @@ public class PaymentService {
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
r.setStatus(RefundStatus.SUCCEEDED);
r.setStatus(RefundStatus.succeeded);
r.setTransaction(tx);
r.setGatewayRefundId(gatewayRefundId);
r.setProcessedAt(LocalDateTime.now());
@ -189,9 +254,9 @@ public class PaymentService {
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
p.setStatus(PaymentStatus.REFUNDED);
p.setStatus(PaymentStatus.refunded);
} else {
p.setStatus(PaymentStatus.PARTIALLY_REFUNDED);
p.setStatus(PaymentStatus.partially_refunded);
}
payRepo.save(p);
}
@ -199,31 +264,75 @@ public class PaymentService {
@Transactional
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
Payment p = new Payment();
p.setOrderId(orderId); // null en tu caso actual
p.setOrderId(orderId);
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("bank_transfer");
p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso
return payRepo.save(p);
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();
if (!"bank_transfer".equals(p.getGateway()))
throw new IllegalStateException("No es transferencia");
p.setAmountCapturedCents(p.getAmountTotalCents());
p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.CAPTURED);
payRepo.save(p);
Payment p = payRepo.findById(paymentId)
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.CAPTURE);
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
tx.setAmountCents(p.getAmountTotalCents());
tx.setCurrency(p.getCurrency());
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);
}
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: 099 → autorizado; >=100 → denegado / error
return code >= 0 && code <= 99;
}
}