testeando el notify

This commit is contained in:
2025-11-03 19:31:28 +01:00
parent 88650fc5e8
commit 725cff9b51
10 changed files with 716 additions and 226 deletions

View File

@ -3,7 +3,6 @@ package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> {

View File

@ -7,9 +7,7 @@ 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.Notification;
import com.imprimelibros.erp.redsys.RedsysService.PaymentRequest;
import org.springframework.beans.factory.annotation.Autowired;
import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@ -27,91 +25,126 @@ public class PaymentService {
private final ObjectMapper om = new ObjectMapper();
public PaymentService(PaymentRepository payRepo,
PaymentTransactionRepository txRepo,
RefundRepository refundRepo,
RedsysService redsysService) {
PaymentTransactionRepository txRepo,
RefundRepository refundRepo,
RedsysService redsysService) {
this.payRepo = payRepo;
this.txRepo = txRepo;
this.refundRepo = refundRepo;
this.redsysService = redsysService;
}
/** Crea Payment y devuelve form auto-submit Redsys. Ds_Order = 12 dígitos con el ID. */
/**
* Crea el Payment en BD y construye el formulario de Redsys usando la API
* oficial (ApiMacSha256).
*/
@Transactional
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method)
throws Exception {
Payment p = new Payment();
p.setOrderId(orderId);
p.setOrderId(orderId); // <- ahora puede ser null
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("redsys");
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());
p.setGatewayOrderId(dsOrder);
payRepo.save(p);
PaymentRequest req = new PaymentRequest(dsOrder, amountCents, "Compra en Imprimelibros", "card");
return redsysService.buildRedirectForm(req);
RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents,
"Compra en Imprimelibros");
if ("bizum".equalsIgnoreCase(method)) {
return redsysService.buildRedirectFormBizum(req);
} else {
return redsysService.buildRedirectForm(req);
}
}
/** Procesa notificación Redsys (ok/notify). Idempotente. */
// si aún tienes la versión antigua sin method, puedes dejar este overload si te
// viene bien:
@Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
Notification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
return createRedsysPayment(orderId, amountCents, currency, "card");
}
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.getOrder())
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.getOrder()));
/**
* Procesa una notificación Redsys (OK/notify) con la API oficial:
* - validateAndParseNotification usa createMerchantSignatureNotif +
* decodeMerchantParameters
*/
@Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParametersB64) throws Exception {
RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParametersB64);
if (!Objects.equals(p.getCurrency(), notif.getCurrency()))
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order)
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order));
if (!Objects.equals(p.getCurrency(), notif.currency)) {
throw new IllegalStateException("Divisa inesperada");
if (!Objects.equals(p.getAmountTotalCents(), notif.getAmountCents()))
}
if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) {
throw new IllegalStateException("Importe inesperado");
}
// ¿Ya registrado? Si ya capturaste, no repitas.
if (p.getStatus() == PaymentStatus.CAPTURED || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED || p.getStatus() == PaymentStatus.REFUNDED) {
return; // idempotencia simple a nivel Payment
// Idempotencia sencilla: si ya está capturado o reembolsado, no creamos otra
// transacción
if (p.getStatus() == PaymentStatus.CAPTURED
|| p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED
|| p.getStatus() == PaymentStatus.REFUNDED) {
return;
}
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
tx.setType(PaymentTransactionType.CAPTURE);
tx.setCurrency(p.getCurrency());
tx.setAmountCents(notif.getAmountCents());
tx.setStatus(notif.isAuthorized() ? PaymentTransactionStatus.SUCCEEDED : PaymentTransactionStatus.FAILED);
// En Redsys el authorization code suele estar en Ds_AuthorisationCode
Object authCode = notif.getRaw().get("Ds_AuthorisationCode");
tx.setAmountCents(notif.amountCents);
tx.setStatus(notif.authorized() ? PaymentTransactionStatus.SUCCEEDED
: PaymentTransactionStatus.FAILED);
Object authCode = notif.raw.get("Ds_AuthorisationCode");
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
tx.setGatewayResponseCode(notif.getResponse());
tx.setResponsePayload(om.writeValueAsString(notif.getRaw()));
tx.setGatewayResponseCode(notif.response);
tx.setResponsePayload(om.writeValueAsString(notif.raw));
tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx);
if (notif.isAuthorized()) {
if (notif.authorized()) {
p.setAuthorizationCode(tx.getGatewayTransactionId());
p.setStatus(PaymentStatus.CAPTURED);
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.getAmountCents());
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);
}
/** Refund (simulado a nivel pasarela; actualiza BD). Sustituye gatewayRefundId por el real cuando lo tengas. */
// ---- refundViaRedsys y bank_transfer igual que antes, no tocan RedsysService
// ----
@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");
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");
if (amountCents > maxRefundable)
throw new IllegalStateException("Importe de devolución supera lo capturado");
txRepo.findByIdempotencyKey(idempotencyKey)
.ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); });
.ifPresent(t -> {
throw new IllegalStateException("Reembolso ya procesado");
});
Refund r = new Refund();
r.setPayment(p);
@ -120,7 +153,8 @@ public class PaymentService {
r.setRequestedAt(LocalDateTime.now());
r = refundRepo.save(r);
String gatewayRefundId = "REF-" + UUID.randomUUID(); // TODO: sustituir por el ID real de Redsys si usas su canal de devoluciones
String gatewayRefundId = "REF-" + UUID.randomUUID(); // aquí iría el ID real si alguna vez llamas a un API de
// devoluciones
PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p);
@ -148,23 +182,22 @@ public class PaymentService {
payRepo.save(p);
}
/** Transferencia bancaria: crea Payment en espera de ingreso. */
@Transactional
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
Payment p = new Payment();
p.setOrderId(orderId);
p.setOrderId(orderId); // null en tu caso actual
p.setCurrency(currency);
p.setAmountTotalCents(amountCents);
p.setGateway("bank_transfer");
p.setStatus(PaymentStatus.REQUIRES_ACTION);
p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso
return payRepo.save(p);
}
/** Marca transferencia como conciliada (capturada). */
@Transactional
public void markBankTransferAsCaptured(Long paymentId) {
Payment p = payRepo.findById(paymentId).orElseThrow();
if (!"bank_transfer".equals(p.getGateway())) throw new IllegalStateException("No es transferencia");
if (!"bank_transfer".equals(p.getGateway()))
throw new IllegalStateException("No es transferencia");
p.setAmountCapturedCents(p.getAmountTotalCents());
p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.CAPTURED);

View File

@ -11,7 +11,7 @@ public class Payment {
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "order_id", nullable = false)
@Column(name = "order_id")
private Long orderId;
@Column(name = "user_id")
@ -161,4 +161,20 @@ public class Payment {
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
if (updatedAt == null) {
updatedAt = now;
}
}
@PreUpdate
public void preUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -1,83 +1,154 @@
package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.payments.PaymentService;
import com.imprimelibros.erp.payments.model.Payment;
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID;
import org.springframework.transaction.annotation.Transactional;
@Controller
@RequestMapping("/pagos/redsys")
public class RedsysController {
private final RedsysService service;
private final PaymentService paymentService;
public RedsysController(RedsysService service) {
this.service = service;
public RedsysController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping("/crear")
public String crearPago(@RequestParam String order,
@RequestParam long amountCents,
Model model) throws Exception {
var req = new RedsysService.PaymentRequest(order, amountCents, "Compra en ImprimeLibros");
var form = service.buildRedirectForm(req);
model.addAttribute("action", form.action());
model.addAttribute("signatureVersion", form.signatureVersion());
model.addAttribute("merchantParameters", form.merchantParameters());
model.addAttribute("signature", form.signature());
return "imprimelibros/payments/redsys-redirect";
}
@PostMapping("/notify")
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> notifyRedsys(
@RequestParam("Ds_Signature") String dsSignature,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) {
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
@RequestParam("method") String method) throws Exception {
if ("bank-transfer".equalsIgnoreCase(method)) {
// 1) Creamos el Payment interno SIN orderId (null)
Payment p = paymentService.createBankTransferPayment(null, amountCents, "EUR");
// 2) Mostramos instrucciones de transferencia
String html = """
<html><head><meta charset="utf-8"><title>Pago por transferencia</title></head>
<body>
<h2>Pago por transferencia bancaria</h2>
<p>Hemos registrado tu intención de pedido.</p>
<p><strong>Importe:</strong> %s €</p>
<p><strong>IBAN:</strong> ES00 1234 5678 9012 3456 7890</p>
<p><strong>Concepto:</strong> TRANSF-%d</p>
<p>En cuanto recibamos la transferencia, procesaremos tu pedido.</p>
<p><a href="/checkout/resumen">Volver al resumen</a></p>
</body></html>
""".formatted(
String.format("%.2f", amountCents / 100.0),
p.getId() // usamos el ID del Payment como referencia
);
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// Tarjeta o Bizum (Redsys)
FormPayload form = paymentService.createRedsysPayment(null, amountCents, "EUR", method);
String html = """
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
<body onload="document.forms[0].submit()">
<form action="%s" method="post">
<input type="hidden" name="Ds_SignatureVersion" value="%s"/>
<input type="hidden" name="Ds_MerchantParameters" value="%s"/>
<input type="hidden" name="Ds_Signature" value="%s"/>
<noscript>
<p>Haz clic en pagar para continuar</p>
<button type="submit">Pagar</button>
</noscript>
</form>
</body></html>
""".formatted(
form.action(),
form.signatureVersion(),
form.merchantParameters(),
form.signature());
byte[] body = html.getBytes(StandardCharsets.UTF_8);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(body);
}
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
@GetMapping("/ok")
@ResponseBody
public ResponseEntity<String> okGet() {
String html = """
<h2>Pago procesado</h2>
<p>Si el pago ha sido autorizado, verás el pedido en tu área de usuario o recibirás un email de confirmación.</p>
<p><a href="/cart">Volver a la tienda</a></p>
""";
return ResponseEntity.ok(html);
}
// POST: si Redsys envía Ds_Signature y Ds_MerchantParameters (muchas
// integraciones ni lo usan)
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
@jakarta.transaction.Transactional
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature,
dsMerchantParameters);
// 1) Idempotencia: comprueba si el pedido ya fue procesado
// 2) Valida que importe/moneda/pedido coincidan con lo que esperabas
// 3) Marca como pagado si notif.authorized() == true
return ResponseEntity.ok("OK"); // Redsys espera "OK"
} catch (SecurityException se) {
// Firma incorrecta: NO procesar
return ResponseEntity.status(400).body("BAD SIGNATURE");
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
paymentService.handleRedsysNotification(signature, merchantParameters);
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
} catch (Exception e) {
return ResponseEntity.status(500).body("ERROR");
return ResponseEntity.badRequest()
.body("<h2>Error validando pago</h2><pre>" + e.getMessage() + "</pre>");
}
}
@PostMapping("/ok")
public String okReturn(@RequestParam("Ds_Signature") String dsSignature,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters,
Model model) {
@GetMapping("/ko")
@ResponseBody
public ResponseEntity<String> koGet() {
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/cart\">Volver</a>");
}
@PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> koPost(@RequestParam Map<String, String> form) {
// Podrías loguear 'form' si quieres ver qué manda Redsys
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/cart\">Volver</a>");
}
@PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
@Transactional
public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters);
// Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como
// pagado
model.addAttribute("authorized", notif.authorized());
//model.addAttribute("order", notif.order());
//model.addAttribute("amountCents", notif.amountCents());
return "imprimelibros/payments/redsys-ok";
paymentService.handleRedsysNotification(signature, merchantParameters);
return "OK";
} catch (Exception e) {
model.addAttribute("error", "No se pudo validar la respuesta de Redsys.");
return "imprimelibros/payments/redsys-ko";
return "ERROR";
}
}
@PostMapping("/ko")
public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature,
@RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters,
Model model) {
// Suele venir cuando el usuario cancela o hay error
model.addAttribute("error", "Operación cancelada o rechazada.");
return "imprimelibros/payments/redsys-ko";
@PostMapping(value = "/refund/{paymentId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody
public ResponseEntity<String> refund(@PathVariable Long paymentId,
@RequestParam("amountCents") Long amountCents) {
try {
String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID();
paymentService.refundViaRedsys(paymentId, amountCents, idem);
return ResponseEntity.ok("Refund solicitado");
} catch (Exception e) {
return ResponseEntity.badRequest().body("Error refund: " + e.getMessage());
}
}
}

View File

@ -2,7 +2,6 @@ package com.imprimelibros.erp.redsys;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sis.redsys.api.ApiMacSha256;
import com.fasterxml.jackson.core.type.TypeReference;
@ -38,18 +37,28 @@ public class RedsysService {
private String env;
// ---------- RECORDS ----------
public record PaymentRequest(String order, long amountCents, String description) {
}
// Pedido a Redsys
public record PaymentRequest(String order, long amountCents, String description) {}
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {
}
// Payload para el formulario
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {}
// ---------- MÉTODO PRINCIPAL ----------
// ---------- MÉTODO PRINCIPAL (TARJETA) ----------
public FormPayload buildRedirectForm(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, false); // false = tarjeta (sin PAYMETHODS)
}
// ---------- NUEVO: MÉTODO PARA BIZUM ----------
public FormPayload buildRedirectFormBizum(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, true); // true = Bizum (PAYMETHODS = z)
}
// ---------- LÓGICA COMÚN ----------
private FormPayload buildRedirectFormInternal(PaymentRequest req, boolean bizum) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros si puedes
api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
@ -58,6 +67,15 @@ public class RedsysService {
api.setParameter("DS_MERCHANT_URLOK", urlOk);
api.setParameter("DS_MERCHANT_URLKO", urlKo);
if (req.description() != null && !req.description().isBlank()) {
api.setParameter("DS_MERCHANT_PRODUCTDESCRIPTION", req.description());
}
// 🔹 Bizum: PAYMETHODS = "z" según Redsys
if (bizum) {
api.setParameter("DS_MERCHANT_PAYMETHODS", "z");
}
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
@ -84,27 +102,29 @@ public class RedsysService {
// ---------- STEP 4: Validar notificación ----------
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
throws Exception {
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
RedsysNotification notif = new RedsysNotification(mp);
throws Exception {
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) {
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
if (notif.order == null || notif.order.isBlank()) {
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
}
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
String expected = api.createMerchantSignatureNotif(
secretKeyBase64,
api.decodeMerchantParameters(dsMerchantParametersB64)
);
if (!safeEqualsB64(dsSignature, expected)) {
throw new SecurityException("Firma Redsys no válida");
}
return notif;
}
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
String expected = api.createMerchantSignatureNotif(secretKeyBase64, api.decodeMerchantParameters(dsMerchantParametersB64)); // ✅ SOLO UN PARÁMETRO
if (!safeEqualsB64(dsSignature, expected)) {
throw new SecurityException("Firma Redsys no válida");
}
return notif;
}
// ---------- HELPERS ----------
private static boolean safeEqualsB64(String a, String b) {
if (Objects.equals(a, b))