cargando carrito desde backend

This commit is contained in:
2025-10-29 23:30:33 +01:00
parent ae2904aa71
commit feff9ee94a
23 changed files with 848 additions and 183 deletions

View File

@ -6,13 +6,16 @@ import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "carts",
uniqueConstraints = @UniqueConstraint(name="uq_carts_user_active", columnNames={"user_id","status"}))
@Table(name = "carts", uniqueConstraints = @UniqueConstraint(name = "uq_carts_user_active", columnNames = { "user_id",
"status" }))
public class Cart {
public enum Status { ACTIVE, LOCKED, ABANDONED }
public enum Status {
ACTIVE, LOCKED, ABANDONED
}
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", nullable = false)
@ -25,6 +28,9 @@ public class Cart {
@Column(nullable = false, length = 3)
private String currency = "EUR";
@Column(name = "only_one_shipment", nullable = false)
private Boolean onlyOneShipment = true;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt = LocalDateTime.now();
@ -34,23 +40,72 @@ public class Cart {
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartItem> items = new ArrayList<>();
@OneToMany(mappedBy = "cart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<CartDireccion> direcciones = new ArrayList<>();
@PreUpdate
public void preUpdate() { this.updatedAt = LocalDateTime.now(); }
public void preUpdate() {
this.updatedAt = LocalDateTime.now();
}
// Getters & Setters
public Long getId() { return id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getId() {
return id;
}
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public Long getUserId() {
return userId;
}
public String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; }
public void setUserId(Long userId) {
this.userId = userId;
}
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public Status getStatus() {
return status;
}
public List<CartItem> getItems() { return items; }
public void setItems(List<CartItem> items) { this.items = items; }
public void setStatus(Status status) {
this.status = status;
}
public String getCurrency() {
return currency;
}
public void setCurrency(String currency) {
this.currency = currency;
}
public Boolean getOnlyOneShipment() {
return onlyOneShipment;
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public LocalDateTime getUpdatedAt() {
return updatedAt;
}
public List<CartItem> getItems() {
return items;
}
public void setItems(List<CartItem> items) {
this.items = items;
}
public List<CartDireccion> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<CartDireccion> direcciones) {
this.direcciones = direcciones;
}
}

View File

@ -9,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.Direccion;
@ -19,9 +20,8 @@ import java.security.Principal;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
@Controller
@RequestMapping("/cart")
@ -56,6 +56,9 @@ public class CartController {
"cart.shipping.errors.units-error",
"cart.shipping.ud",
"cart.shipping.uds",
"cart.shipping.send-in-palets",
"cart.shipping.send-in-palets.info",
"cart.shipping.tipo-envio",
"app.yes",
"app.aceptar",
"app.cancelar");
@ -63,12 +66,16 @@ public class CartController {
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
var items = service.listItems(Utils.currentUserId(principal), locale);
Long userId = Utils.currentUserId(principal);
Cart cart = service.getOrCreateActiveCart(userId);
var items = service.listItems(userId, locale);
model.addAttribute("items", items);
var summary = service.getCartSummary(items, locale);
var summary = service.getCartSummary(cart, locale);
model.addAttribute("cartSummary", summary);
model.addAttribute("cartId", service.getOrCreateActiveCart(Utils.currentUserId(principal)));
model.addAttribute("cart", cart);
return "imprimelibros/cart/cart"; // crea esta vista si quieres (tabla simple)
}
@ -126,6 +133,7 @@ public class CartController {
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, @RequestParam(required = false) Long presupuestoId,
@RequestParam(required = false) Integer unidades,
@RequestParam(required = false) Integer isPalets,
Model model, Locale locale) {
Direccion dir = direccionService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
@ -133,18 +141,28 @@ public class CartController {
dir.getPais().getKeyword(), locale));
model.addAttribute("presupuestoId", presupuestoId);
model.addAttribute("unidades", unidades);
model.addAttribute("isPalets", isPalets);
model.addAttribute("direccion", dir);
return "imprimelibros/direcciones/direccionCard :: direccionCard(direccion=${direccion})";
}
@PostMapping("/update/{id}")
public String postMethodName(@PathVariable Long id, @RequestBody String entity) {
@PostMapping(value = "/update/{id}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
public String updateCart(@PathVariable Long id, UpdateCartRequest updateRequest, Model model, Locale locale) {
try {
service.updateCart(id, updateRequest);
var cartSummary = service.getCartSummary(service.getCartById(id), locale);
model.addAttribute("cartSummary", cartSummary);
return "imprimelibros/cart/_cartSummary :: cartSummary(summary=${cartSummary})";
} catch (Exception e) {
model.addAttribute("errorMessage", messageSource.getMessage("cart.errors.update-cart", new Object[]{e.getMessage()}, locale));
return "/cart"; // templates/error/500.html
}
return entity;
}
}

View File

@ -0,0 +1,60 @@
package com.imprimelibros.erp.cart;
import java.math.BigDecimal;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import jakarta.persistence.*;
@Entity
@Table(name = "cart_direcciones")
public class CartDireccion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "cart_id", nullable = false)
private Cart cart;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "direccion_id", nullable = false)
private Direccion direccion;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "presupuesto_id")
private Presupuesto presupuesto;
@Column(name = "unidades")
private Integer unidades;
@Column(name = "isPalets", nullable = false)
private Boolean isPalets;
@Column(name = "base", precision = 12, scale = 2)
private BigDecimal base;
// --- Getters & Setters ---
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Cart getCart() { return cart; }
public void setCart(Cart cart) { this.cart = cart; }
public Direccion getDireccion() { return direccion; }
public void setDireccion(Direccion direccion) { this.direccion = direccion; }
public Presupuesto getPresupuesto() { return presupuesto; }
public void setPresupuesto(Presupuesto presupuesto) { this.presupuesto = presupuesto; }
public Integer getUnidades() { return unidades; }
public void setUnidades(Integer unidades) { this.unidades = unidades; }
public BigDecimal getBase() { return base; }
public void setBase(BigDecimal base) { this.base = base; }
public Boolean getIsPalets() { return isPalets; }
public void setIsPalets(Boolean isPalets) { this.isPalets = isPalets; }
}

View File

@ -13,10 +13,12 @@ import java.util.Map;
import com.imprimelibros.erp.presupuesto.classes.PresupuestoFormatter;
import com.imprimelibros.erp.presupuesto.dto.Presupuesto;
import com.imprimelibros.erp.cart.dto.UpdateCartRequest;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.externalApi.skApiClient;
import com.imprimelibros.erp.presupuesto.PresupuestoRepository;
@Service
public class CartService {
@ -25,15 +27,20 @@ public class CartService {
private final MessageSource messageSource;
private final PresupuestoRepository presupuestoRepo;
private final Utils utils;
private final DireccionService direccionService;
private final skApiClient skApiClient;
public CartService(CartRepository cartRepo, CartItemRepository itemRepo,
MessageSource messageSource, PresupuestoFormatter presupuestoFormatter,
PresupuestoRepository presupuestoRepo, Utils utils) {
PresupuestoRepository presupuestoRepo, Utils utils, DireccionService direccionService,
skApiClient skApiClient) {
this.cartRepo = cartRepo;
this.itemRepo = itemRepo;
this.messageSource = messageSource;
this.presupuestoRepo = presupuestoRepo;
this.utils = utils;
this.direccionService = direccionService;
this.skApiClient = skApiClient;
}
/** Devuelve el carrito activo o lo crea si no existe. */
@ -48,6 +55,11 @@ public class CartService {
});
}
public Cart getCartById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
}
/** Lista items (presupuestos) del carrito activo del usuario. */
@Transactional
public List<Map<String, Object>> listItems(Long userId, Locale locale) {
@ -57,13 +69,14 @@ public class CartService {
for (CartItem item : items) {
Presupuesto p = presupuestoRepo.findById(item.getPresupuestoId())
.orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + item.getPresupuestoId()));
.orElseThrow(
() -> new IllegalStateException("Presupuesto no encontrado: " + item.getPresupuestoId()));
Map<String, Object> elemento = getElementoCart(p, locale);
elemento.put("cartItemId", item.getId());
resultados.add(elemento);
}
//System.out.println("Cart items: " + resultados);
// System.out.println("Cart items: " + resultados);
return resultados;
}
@ -120,9 +133,9 @@ public class CartService {
}
private Map<String, Object> getElementoCart(Presupuesto presupuesto, Locale locale) {
Map<String, Object> resumen = new HashMap<>();
resumen.put("titulo", presupuesto.getTitulo());
resumen.put("imagen",
@ -132,7 +145,7 @@ public class CartService {
resumen.put("presupuestoId", presupuesto.getId());
if(presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
if (presupuesto.getServiciosJson() != null && presupuesto.getServiciosJson().contains("ejemplar-prueba")) {
resumen.put("hasSample", true);
} else {
resumen.put("hasSample", false);
@ -151,18 +164,40 @@ public class CartService {
return resumen;
}
public Map<String, Object> getCartSummary(List<Map<String, Object>> cartItems, Locale locale) {
public Map<String, Object> getCartSummary(Cart cart, Locale locale) {
double base = 0.0;
double iva4 = 0.0;
double iva21 = 0.0;
for (Map<String, Object> item : cartItems) {
Presupuesto p = presupuestoRepo.findById((Long) item.get("presupuestoId"))
.orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + item.get("presupuestoId")));
List<CartItem> items = cart.getItems();
List<CartDireccion> direcciones = cart.getDirecciones();
for (CartItem item : items) {
Presupuesto p = presupuestoRepo.findById(item.getPresupuestoId())
.orElseThrow(() -> new IllegalStateException("Presupuesto no encontrado: " + item.getPresupuestoId()));
base += p.getBaseImponible().doubleValue();
iva4 += p.getIvaImporte4().doubleValue();
iva21 += p.getIvaImporte21().doubleValue();
if(cart.getOnlyOneShipment() != null && cart.getOnlyOneShipment()) {
// Si es envío único, que es a españa y no ha canarias
if(direcciones != null && direcciones.size() > 0) {
CartDireccion cd = direcciones.get(0);
Boolean freeShipment = direccionService.checkFreeShipment(cd.getDireccion().getCp(), cd.getDireccion().getPaisCode3()) && !cd.getIsPalets();
if(!freeShipment) {
Map<String, Object> data =
Map.of(
"cp", cd.getDireccion().getCp(),
"pais_code3", cd.getDireccion().getPaisCode3(),
"peso", p.getPeso() != null ? p.getPeso() : 0,
"unidades", cd.getUnidades(),
"palets", cd.getIsPalets() ? 1 : 0
);
var shipmentCost = skApiClient.getCosteEnvio(data, locale);
}
}
}
}
double total = base + iva4 + iva21;
@ -175,4 +210,18 @@ public class CartService {
return summary;
}
@Transactional
public Boolean updateCart(Long cartId, UpdateCartRequest request) {
try{
Cart cart = cartRepo.findById(cartId).orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setOnlyOneShipment(request.isOnlyOneShipment());
cartRepo.save(cart);
return true;
} catch (Exception e) {
// Manejo de excepciones
return false;
}
}
}

View File

@ -0,0 +1,15 @@
package com.imprimelibros.erp.cart.dto;
import com.imprimelibros.erp.cart.CartDireccion;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CartDireccionRepository extends JpaRepository<CartDireccion, Long> {
// Borrado masivo por cart_id
void deleteByCartId(Long cartId);
// Lectura por cart_id (útil para componer respuestas)
List<CartDireccion> findByCartId(Long cartId);
}

View File

@ -0,0 +1,60 @@
package com.imprimelibros.erp.cart.dto;
public class DireccionShipment {
private Long id; // puede no venir → null
private String cp; // puede no venir → null
private String paisCode3; // puede no venir → null
private Long presupuestoId; // puede no venir → null
private Integer unidades; // puede no venir → null
private Boolean isPalets; // puede no venir → null
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getCp() {
return cp;
}
public void setCp(String cp) {
this.cp = cp;
}
public String getPaisCode3() {
return paisCode3;
}
public void setPaisCode3(String paisCode3) {
this.paisCode3 = paisCode3;
}
public Long getPresupuestoId() {
return presupuestoId;
}
public void setPresupuestoId(Long presupuestoId) {
this.presupuestoId = presupuestoId;
}
public Integer getUnidades() {
return unidades;
}
public void setUnidades(Integer unidades) {
this.unidades = unidades;
}
public Boolean getIsPalets() {
return isPalets;
}
public void setIsPalets(Boolean isPalets) {
this.isPalets = isPalets;
}
}

View File

@ -0,0 +1,26 @@
package com.imprimelibros.erp.cart.dto;
import java.util.ArrayList;
import java.util.List;
public class UpdateCartRequest {
private Boolean onlyOneShipment = Boolean.FALSE; // default: false
private List<DireccionShipment> direcciones = new ArrayList<>();
public boolean isOnlyOneShipment() { // boolean-style getter
return Boolean.TRUE.equals(onlyOneShipment);
}
public void setOnlyOneShipment(Boolean onlyOneShipment) {
this.onlyOneShipment = onlyOneShipment;
}
public List<DireccionShipment> getDirecciones() {
return direcciones;
}
public void setDirecciones(List<DireccionShipment> direcciones) {
this.direcciones = (direcciones != null) ? direcciones : new ArrayList<>();
}
}

View File

@ -369,7 +369,7 @@ public class DireccionController {
Locale locale) {
User current = userRepo.findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(auth.getName()).orElse(null);
direccion.setUser(current);
direccion.setUser(current);
if (binding.hasErrors()) {
response.setStatus(422);
@ -480,16 +480,19 @@ public class DireccionController {
public Map<String, Object> getSelect2(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
@RequestParam(value = "presupuestoId", required = false) Long presupuestoId,
Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
Long currentUserId = null;
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
if (!isAdmin) {
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
}
return direccionService.getForSelect(q1, q2, isAdmin ? null : currentUserId);

View File

@ -83,4 +83,14 @@ public class DireccionService {
return repo.findById(id);
}
public Boolean checkFreeShipment(Integer cp, String paisCode3) {
if(paisCode3.equals("ESP")) {
// España peninsular y baleares
if(cp != null && cp < 35000 && cp >= 35999) {
return true;
}
}
return false;
}
}

View File

@ -920,4 +920,28 @@ public class Presupuesto extends AbstractAuditedEntity implements Cloneable {
public void setId(Long id){
this.id = id;
}
public Double getPeso(){
// get peso from first element of pricingSnapshotJson (need to parse JSON)
// pricingSnapshotJson = {"xxx":{"peso":0.5,...}} is a String
if (this.pricingSnapshotJson != null && !this.pricingSnapshotJson.isEmpty()) {
try {
String json = this.pricingSnapshotJson.trim();
int pesoIndex = json.indexOf("\"peso\":");
if (pesoIndex != -1) {
int startIndex = pesoIndex + 7;
int endIndex = json.indexOf(",", startIndex);
if (endIndex == -1) {
endIndex = json.indexOf("}", startIndex);
}
String pesoStr = json.substring(startIndex, endIndex).trim();
return Double.parseDouble(pesoStr);
}
} catch (Exception e) {
// log error
e.printStackTrace();
}
}
return null;
}
}

View File

@ -1,53 +1,57 @@
package com.imprimelibros.erp.redsys;
import com.imprimelibros.erp.redsys.RedsysService;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.*;
@Controller
@RequestMapping("/pagos/redsys")
public class RedsysController {
private final RedsysService service;
public RedsysController(RedsysService service) { this.service = service; }
public RedsysController(RedsysService service) {
this.service = service;
}
@PostMapping("/crear")
public String crearPago(@RequestParam String order,
@RequestParam long amountCents,
Model model) {
var payReq = new RedsysService.PaymentRequest(order, amountCents, "Compra en ImprimeLibros");
var form = service.buildRedirectForm(payReq);
@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 "payments/redsys-redirect"; // Thymeleaf
return "payments/redsys-redirect";
}
@PostMapping("/notify")
@ResponseBody
public ResponseEntity<String> notifyRedsys(@RequestParam("Ds_Signature") String dsSig,
@RequestParam("Ds_SignatureVersion") String dsSigVer,
@RequestParam("Ds_MerchantParameters") String dsParams) throws Exception {
var notif = service.validateAndParse(dsSig, dsSigVer, dsParams);
public ResponseEntity<String> notifyRedsys(
@RequestParam("Ds_Signature") String dsSignature,
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) {
// 1) Idempotencia: marca el pedido si aún no procesado.
// 2) Verifica importe/moneda/pedido contra tu base de datos.
// 3) Autoriza en tu sistema si notif.authorized() == true.
try {
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature,
dsMerchantParameters);
return ResponseEntity.ok("OK");
// 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");
} catch (Exception e) {
return ResponseEntity.status(500).body("ERROR");
}
}
@GetMapping("/ok")
public String ok() { return "payments/success"; }
@GetMapping("/ko")
public String ko() { return "payments/failure"; }
}

View File

@ -1,89 +1,155 @@
package com.imprimelibros.erp.redsys;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sis.redsys.api.Signature;
import sis.redsys.api.Utils;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@Service
public class RedsysService {
// ---------- CONFIG ----------
@Value("${redsys.merchant-code}") private String merchantCode;
@Value("${redsys.terminal}") private String terminal;
@Value("${redsys.currency}") private String currency;
@Value("${redsys.transaction-type}") private String txType;
@Value("${redsys.secret-key}") private String secretKey;
@Value("${redsys.secret-key}") private String secretKeyBase64;
@Value("${redsys.urls.ok}") private String urlOk;
@Value("${redsys.urls.ko}") private String urlKo;
@Value("${redsys.urls.notify}") private String urlNotify;
@Value("${redsys.environment}") private String env;
// ---------- RECORDS ----------
public record PaymentRequest(String order, long amountCents, String description) {}
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {}
public FormPayload buildRedirectForm(PaymentRequest req) {
// RedsysAPI proviene del JAR oficial
com.redsys.api.RedsysAPI api = new com.redsys.api.RedsysAPI();
// ---------- MÉTODO PRINCIPAL ----------
public FormPayload buildRedirectForm(PaymentRequest req) throws Exception {
Map<String, Object> params = new HashMap<>();
params.put("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
params.put("DS_MERCHANT_ORDER", req.order());
params.put("DS_MERCHANT_MERCHANTCODE", merchantCode);
params.put("DS_MERCHANT_CURRENCY", currency);
params.put("DS_MERCHANT_TRANSACTIONTYPE", txType);
params.put("DS_MERCHANT_TERMINAL", terminal);
params.put("DS_MERCHANT_MERCHANTNAME", "ImprimeLibros");
params.put("DS_MERCHANT_PRODUCTDESCRIPTION", req.description());
params.put("DS_MERCHANT_URLOK", urlOk);
params.put("DS_MERCHANT_URLKO", urlKo);
params.put("DS_MERCHANT_MERCHANTURL", urlNotify);
Map<String, String> mp = new HashMap<>();
mp.put("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
mp.put("DS_MERCHANT_ORDER", req.order());
mp.put("DS_MERCHANT_MERCHANTCODE", merchantCode);
mp.put("DS_MERCHANT_CURRENCY", currency);
mp.put("DS_MERCHANT_TRANSACTIONTYPE", txType);
mp.put("DS_MERCHANT_TERMINAL", terminal);
mp.put("DS_MERCHANT_MERCHANTNAME", "Tu Comercio");
mp.put("DS_MERCHANT_PRODUCTDESCRIPTION", req.description());
mp.put("DS_MERCHANT_URLOK", urlOk);
mp.put("DS_MERCHANT_URLKO", urlKo);
mp.put("DS_MERCHANT_MERCHANTURL", urlNotify);
// JSON -> Base64
String json = new ObjectMapper().writeValueAsString(params);
String merchantParametersB64 = Base64.getEncoder()
.encodeToString(json.getBytes(StandardCharsets.UTF_8));
String merchantParameters = api.createMerchantParameters(mp);
String signature = api.createMerchantSignature(secretKey);
// Firma SHA-512 (tu JAR)
String signature = Signature.createMerchantSignature(secretKeyBase64, req.order(), merchantParametersB64);
String action = "test".equalsIgnoreCase(env)
? "https://sis-t.redsys.es:25443/sis/realizarPago"
: "https://sis.redsys.es/sis/realizarPago";
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
return new FormPayload(action, "HMAC_SHA512_V1", merchantParametersB64, signature);
}
// Validación de la notificación on-line (webhook).
public RedsysNotification validateAndParse(String dsSignature, String dsSignatureVersion, String dsMerchantParametersB64) {
com.redsys.api.RedsysAPI api = new com.redsys.api.RedsysAPI();
// ---------- STEP 3: Decodificar Ds_MerchantParameters ----------
private static final ObjectMapper MAPPER = new ObjectMapper();
// 1) Validar firma
String calc = api.createMerchantSignatureNotif(secretKey, dsMerchantParametersB64);
if (!Objects.equals(calc, dsSignature)) {
throw new IllegalArgumentException("Firma Redsys no válida");
public Map<String, Object> decodeMerchantParametersToMap(String dsMerchantParametersB64) throws Exception {
try {
String json = Utils.decodeB64UrlSafeString(
dsMerchantParametersB64.getBytes(StandardCharsets.UTF_8)
);
return MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
} catch (Exception ignore) {
byte[] decoded = Base64.getDecoder().decode(dsMerchantParametersB64);
String json = new String(decoded, StandardCharsets.UTF_8);
return MAPPER.readValue(json, new TypeReference<Map<String, Object>>() {});
}
// 2) Decodificar parámetros
String json = api.decodeMerchantParameters(dsMerchantParametersB64);
Map<String, Object> params = new com.fasterxml.jackson.databind.ObjectMapper()
.readValue(json, new com.fasterxml.jackson.core.type.TypeReference<>() {});
// Campos típicos: Ds_Order, Ds_Amount, Ds_Currency, Ds_Response, etc.
return RedsysNotification.from(params);
}
public static record RedsysNotification(String order, String dsResponse, long amountCents, String currency) {
static RedsysNotification from(Map<String, Object> p) {
String order = (String) p.get("Ds_Order");
String resp = String.valueOf(p.get("Ds_Response"));
long amount = Long.parseLong((String) p.get("Ds_Amount"));
String curr = String.valueOf(p.get("Ds_Currency"));
return new RedsysNotification(order, resp, amount, curr);
// ---------- 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);
if (notif.order == null || notif.order.isBlank()) {
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
}
// Éxito si 099.
String expected = Signature.createMerchantSignature(
secretKeyBase64, notif.order, dsMerchantParametersB64
);
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)) return true;
try {
String na = normalizeB64(a);
String nb = normalizeB64(b);
byte[] da = Base64.getDecoder().decode(na);
byte[] db = Base64.getDecoder().decode(nb);
return MessageDigest.isEqual(da, db);
} catch (Exception e) {
return false;
}
}
private static String normalizeB64(String s) {
if (s == null) return "";
String n = s.replace('-', '+').replace('_', '/');
int mod = n.length() % 4;
if (mod == 2) n += "==";
else if (mod == 3) n += "=";
else if (mod == 1) n += "===";
return n;
}
// ---------- MODELO DE NOTIFICACIÓN ----------
public static final class RedsysNotification {
public final Map<String, Object> raw;
public final String order;
public final String response;
public final long amountCents;
public final String currency;
public RedsysNotification(Map<String, Object> raw) {
this.raw = raw;
this.order = str(raw.get("Ds_Order"));
this.response = str(raw.get("Ds_Response"));
this.currency = str(raw.get("Ds_Currency"));
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
}
public boolean authorized() {
try {
int r = Integer.parseInt(dsResponse);
int r = Integer.parseInt(response);
return r >= 0 && r <= 99;
} catch (Exception e) { return false; }
}
private static String str(Object o) { return o == null ? null : String.valueOf(o); }
private static long parseLongSafe(Object o) {
try { return Long.parseLong(String.valueOf(o)); } catch (Exception e) { return 0L; }
}
}
}