Compare commits
14 Commits
7f2db075a8
...
3ea1496861
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ea1496861 | |||
| f13eeb940c | |||
| 62396eb7a7 | |||
| c11c34011e | |||
| a4443763d8 | |||
| ed32f773a4 | |||
| dc64e40e38 | |||
| 7516e9e91e | |||
| f528809c07 | |||
| 725cff9b51 | |||
| 88650fc5e8 | |||
| dc8b67b937 | |||
| 4d451cc85e | |||
| 51d22515e8 |
@ -3,8 +3,10 @@ package com.imprimelibros.erp;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
||||
public class ErpApplication {
|
||||
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package com.imprimelibros.erp.cart;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class CartCleanupService {
|
||||
|
||||
private final CartRepository cartRepository;
|
||||
|
||||
public CartCleanupService(CartRepository cartRepository) {
|
||||
this.cartRepository = cartRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta cada noche a las 2:00 AM
|
||||
*/
|
||||
@Transactional
|
||||
@Scheduled(cron = "0 0 2 * * *") // cada día a las 02:00
|
||||
public void markAbandonedCarts() {
|
||||
LocalDateTime limite = LocalDateTime.now().minusDays(7);
|
||||
int updated = cartRepository.markOldCartsAsAbandoned(limite);
|
||||
System.out.println("Carritos abandonados marcados: " + updated);
|
||||
}
|
||||
}
|
||||
@ -1,9 +1,12 @@
|
||||
package com.imprimelibros.erp.cart;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Modifying;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import java.util.List;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||
@ -18,5 +21,15 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||
where c.id = :id
|
||||
""")
|
||||
Optional<Cart> findByIdFetchAll(@Param("id") Long id);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("""
|
||||
UPDATE Cart c
|
||||
SET c.status = 'ABANDONED'
|
||||
WHERE c.status = 'ACTIVE'
|
||||
AND c.updatedAt < :limite
|
||||
""")
|
||||
int markOldCartsAsAbandoned(LocalDateTime limite);
|
||||
|
||||
}
|
||||
|
||||
@ -53,6 +53,14 @@ public class CartService {
|
||||
this.pedidoService = pedidoService;
|
||||
}
|
||||
|
||||
|
||||
public Cart findById(Long cartId) {
|
||||
return cartRepo.findById(cartId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Devuelve el carrito activo o lo crea si no existe. */
|
||||
@Transactional
|
||||
public Cart getOrCreateActiveCart(Long userId) {
|
||||
@ -136,6 +144,14 @@ public class CartService {
|
||||
cartRepo.save(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void lockCartById(Long cartId) {
|
||||
Cart cart = cartRepo.findById(cartId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
|
||||
cart.setStatus(Cart.Status.LOCKED);
|
||||
cartRepo.save(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long countItems(Long userId) {
|
||||
Cart cart = getOrCreateActiveCart(userId);
|
||||
@ -291,7 +307,9 @@ public class CartService {
|
||||
summary.put("fidelizacion", fidelizacion + "%");
|
||||
summary.put("descuento", Utils.formatCurrency(-descuento, locale));
|
||||
summary.put("total", Utils.formatCurrency(total, locale));
|
||||
summary.put("amountCents", Math.round(total * 100));
|
||||
summary.put("errorShipmentCost", errorShipementCost);
|
||||
summary.put("cartId", cart.getId());
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@ -6,17 +6,20 @@ import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import com.imprimelibros.erp.common.Utils;
|
||||
import com.imprimelibros.erp.i18n.TranslationService;
|
||||
import com.imprimelibros.erp.paises.PaisesService;
|
||||
|
||||
import com.imprimelibros.erp.direcciones.Direccion;
|
||||
import com.imprimelibros.erp.direcciones.DireccionService;
|
||||
|
||||
import com.imprimelibros.erp.cart.Cart;
|
||||
import com.imprimelibros.erp.cart.CartService;
|
||||
|
||||
@Controller
|
||||
@ -44,23 +47,29 @@ public class CheckoutController {
|
||||
List<String> keys = List.of(
|
||||
"app.cancelar",
|
||||
"app.seleccionar",
|
||||
"checkout.shipping.add.title",
|
||||
"checkout.shipping.select-placeholder",
|
||||
"checkout.shipping.new-address",
|
||||
"app.yes",
|
||||
"app.cancelar");
|
||||
"checkout.billing-address.title",
|
||||
"checkout.billing-address.new-address",
|
||||
"checkout.billing-address.select-placeholder",
|
||||
"checkout.billing-address.errors.noAddressSelected");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
|
||||
var items = this.cartService.listItems(Utils.currentUserId(principal), locale);
|
||||
for (var item : items) {
|
||||
if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) {
|
||||
model.addAttribute("hasSample", true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
model.addAttribute("items", items);
|
||||
Long userId = Utils.currentUserId(principal);
|
||||
Cart cart = cartService.getOrCreateActiveCart(userId);
|
||||
model.addAttribute("summary", cartService.getCartSummary(cart, locale));
|
||||
return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple)
|
||||
}
|
||||
|
||||
@GetMapping("/get-address/{id}")
|
||||
public String getDireccionCard(@PathVariable Long id, Model model, Locale locale) {
|
||||
Direccion dir = direccionService.findById(id)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
|
||||
model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null,
|
||||
dir.getPais().getKeyword(), locale));
|
||||
model.addAttribute("direccion", dir);
|
||||
|
||||
return "imprimelibros/direcciones/direccionBillingCard :: direccionBillingCard(direccion=${direccion}, pais=${pais})";
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.security.Principal;
|
||||
import java.text.NumberFormat;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@ -320,4 +322,12 @@ public class Utils {
|
||||
resumen.put("servicios", serviciosExtras);
|
||||
return resumen;
|
||||
}
|
||||
|
||||
public static String formatDateTime(LocalDateTime dateTime, Locale locale) {
|
||||
if (dateTime == null) {
|
||||
return "";
|
||||
}
|
||||
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", locale);
|
||||
return dateTime.format(formatter);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,143 +30,151 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final DataSource dataSource;
|
||||
|
||||
public SecurityConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
public SecurityConfig(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
// ========== Beans base ==========
|
||||
// ========== Beans base ==========
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
// Remember-me (tabla persistent_logins)
|
||||
@Bean
|
||||
public PersistentTokenRepository persistentTokenRepository() {
|
||||
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
|
||||
repo.setDataSource(dataSource);
|
||||
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
|
||||
// tabla
|
||||
return repo;
|
||||
}
|
||||
// Remember-me (tabla persistent_logins)
|
||||
@Bean
|
||||
public PersistentTokenRepository persistentTokenRepository() {
|
||||
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
|
||||
repo.setDataSource(dataSource);
|
||||
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
|
||||
// tabla
|
||||
return repo;
|
||||
}
|
||||
|
||||
// Provider que soporta UsernamePasswordAuthenticationToken
|
||||
private static RequestMatcher pathStartsWith(String... prefixes) {
|
||||
return new RequestMatcher() {
|
||||
@Override
|
||||
public boolean matches(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null)
|
||||
return false;
|
||||
for (String p : prefixes) {
|
||||
if (uri.startsWith(p))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
// Provider que soporta UsernamePasswordAuthenticationToken
|
||||
private static RequestMatcher pathStartsWith(String... prefixes) {
|
||||
return new RequestMatcher() {
|
||||
@Override
|
||||
public boolean matches(HttpServletRequest request) {
|
||||
String uri = request.getRequestURI();
|
||||
if (uri == null)
|
||||
return false;
|
||||
for (String p : prefixes) {
|
||||
if (uri.startsWith(p))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
@Value("${security.rememberme.key}") String keyRememberMe,
|
||||
UserDetailsService userDetailsService,
|
||||
PersistentTokenRepository tokenRepo,
|
||||
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
|
||||
@Bean
|
||||
public SecurityFilterChain securityFilterChain(
|
||||
HttpSecurity http,
|
||||
@Value("${security.rememberme.key}") String keyRememberMe,
|
||||
UserDetailsService userDetailsService,
|
||||
PersistentTokenRepository tokenRepo,
|
||||
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
|
||||
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
http.authenticationProvider(provider);
|
||||
http
|
||||
.authenticationProvider(provider)
|
||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
|
||||
provider.setPasswordEncoder(passwordEncoder);
|
||||
http.authenticationProvider(provider);
|
||||
http
|
||||
.authenticationProvider(provider)
|
||||
|
||||
.sessionManagement(session -> session
|
||||
//.invalidSessionUrl("/login?expired")
|
||||
.maximumSessions(1))
|
||||
.sessionManagement(session -> session
|
||||
// .invalidSessionUrl("/login?expired")
|
||||
.maximumSessions(1))
|
||||
|
||||
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/")))
|
||||
// ====== RequestCache: sólo navegaciones HTML reales ======
|
||||
.requestCache(rc -> {
|
||||
HttpSessionRequestCache cache = new HttpSessionRequestCache();
|
||||
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
|
||||
.csrf(csrf -> csrf
|
||||
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"),
|
||||
pathStartsWith("/pagos/redsys/")))
|
||||
// ====== RequestCache: sólo navegaciones HTML reales ======
|
||||
.requestCache(rc -> {
|
||||
HttpSessionRequestCache cache = new HttpSessionRequestCache();
|
||||
|
||||
// Navegación HTML (por tipo o por cabecera Accept)
|
||||
RequestMatcher htmlPage = new OrRequestMatcher(
|
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
|
||||
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
|
||||
new RequestHeaderRequestMatcher("Accept", "text/html"));
|
||||
// Navegación HTML (por tipo o por cabecera Accept)
|
||||
RequestMatcher htmlPage = new OrRequestMatcher(
|
||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
|
||||
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
|
||||
new RequestHeaderRequestMatcher("Accept", "text/html"));
|
||||
|
||||
// No AJAX
|
||||
RequestMatcher nonAjax = new NegatedRequestMatcher(
|
||||
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
|
||||
// No AJAX
|
||||
RequestMatcher nonAjax = new NegatedRequestMatcher(
|
||||
new RequestHeaderRequestMatcher("X-Requested-With",
|
||||
"XMLHttpRequest"));
|
||||
|
||||
// Excluir sondas .well-known
|
||||
RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/"));
|
||||
// Excluir sondas .well-known
|
||||
RequestMatcher notWellKnown = new NegatedRequestMatcher(
|
||||
pathStartsWith("/.well-known/"));
|
||||
|
||||
// Excluir estáticos: comunes + tu /assets/**
|
||||
RequestMatcher notStatic = new AndRequestMatcher(
|
||||
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
|
||||
new NegatedRequestMatcher(pathStartsWith("/assets/")));
|
||||
|
||||
RequestMatcher cartCount = new AndRequestMatcher(
|
||||
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
|
||||
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
|
||||
// Excluir estáticos: comunes + tu /assets/**
|
||||
RequestMatcher notStatic = new AndRequestMatcher(
|
||||
new NegatedRequestMatcher(PathRequest.toStaticResources()
|
||||
.atCommonLocations()),
|
||||
new NegatedRequestMatcher(pathStartsWith("/assets/")));
|
||||
|
||||
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount));
|
||||
rc.requestCache(cache);
|
||||
})
|
||||
// ========================================================
|
||||
RequestMatcher cartCount = new AndRequestMatcher(
|
||||
new NegatedRequestMatcher(PathRequest.toStaticResources()
|
||||
.atCommonLocations()),
|
||||
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
|
||||
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Aquí usa patrones String (no deprecados)
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/login",
|
||||
"/signup",
|
||||
"/verify",
|
||||
"/auth/password/**",
|
||||
"/assets/**",
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/public/**",
|
||||
"/presupuesto/public/**",
|
||||
"/error",
|
||||
"/favicon.ico",
|
||||
"/.well-known/**", // opcional
|
||||
"/api/pdf/presupuesto/**"
|
||||
).permitAll()
|
||||
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic,
|
||||
notWellKnown, cartCount));
|
||||
rc.requestCache(cache);
|
||||
})
|
||||
// ========================================================
|
||||
|
||||
.formLogin(login -> login
|
||||
.loginPage("/login").permitAll()
|
||||
.loginProcessingUrl("/login")
|
||||
.usernameParameter("username")
|
||||
.passwordParameter("password")
|
||||
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
|
||||
.failureUrl("/login?error"))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
// Aquí usa patrones String (no deprecados)
|
||||
.requestMatchers(
|
||||
"/",
|
||||
"/login",
|
||||
"/signup",
|
||||
"/verify",
|
||||
"/auth/password/**",
|
||||
"/assets/**",
|
||||
"/css/**",
|
||||
"/js/**",
|
||||
"/images/**",
|
||||
"/public/**",
|
||||
"/presupuesto/public/**",
|
||||
"/error",
|
||||
"/favicon.ico",
|
||||
"/.well-known/**", // opcional
|
||||
"/api/pdf/presupuesto/**",
|
||||
"/pagos/redsys/**"
|
||||
)
|
||||
.permitAll()
|
||||
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
|
||||
.anyRequest().authenticated())
|
||||
|
||||
.rememberMe(rm -> rm
|
||||
.key(keyRememberMe)
|
||||
.rememberMeParameter("remember-me")
|
||||
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
|
||||
.tokenValiditySeconds(60 * 60 * 24 * 2)
|
||||
.userDetailsService(userDetailsService)
|
||||
.tokenRepository(tokenRepo))
|
||||
.formLogin(login -> login
|
||||
.loginPage("/login").permitAll()
|
||||
.loginProcessingUrl("/login")
|
||||
.usernameParameter("username")
|
||||
.passwordParameter("password")
|
||||
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
|
||||
.failureUrl("/login?error"))
|
||||
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/")
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
|
||||
.permitAll());
|
||||
.rememberMe(rm -> rm
|
||||
.key(keyRememberMe)
|
||||
.rememberMeParameter("remember-me")
|
||||
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
|
||||
.tokenValiditySeconds(60 * 60 * 24 * 2)
|
||||
.userDetailsService(userDetailsService)
|
||||
.tokenRepository(tokenRepo))
|
||||
|
||||
return http.build();
|
||||
}
|
||||
.logout(logout -> logout
|
||||
.logoutUrl("/logout")
|
||||
.logoutSuccessUrl("/")
|
||||
.invalidateHttpSession(true)
|
||||
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
|
||||
.permitAll());
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
|
||||
@ -506,6 +506,29 @@ public class DireccionController {
|
||||
|
||||
}
|
||||
|
||||
@GetMapping(value = "/facturacion/select2", produces = "application/json")
|
||||
@ResponseBody
|
||||
public Map<String, Object> getSelect2Facturacion(
|
||||
@RequestParam(value = "q", required = false) String q1,
|
||||
@RequestParam(value = "term", required = false) String q2,
|
||||
Authentication auth) {
|
||||
|
||||
boolean isAdmin = auth.getAuthorities().stream()
|
||||
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
|
||||
|
||||
Long currentUserId = 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.getForSelectFacturacion(q1, q2, isAdmin ? null : currentUserId);
|
||||
|
||||
}
|
||||
|
||||
private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) {
|
||||
if (auth == null) {
|
||||
return false;
|
||||
|
||||
@ -38,6 +38,10 @@ public interface DireccionRepository
|
||||
// find by user_id
|
||||
List<Direccion> findByUserId(Long userId);
|
||||
|
||||
// find by user_id and direccion_facturacion = true
|
||||
@Query("SELECT d FROM Direccion d WHERE (:userId IS NULL OR d.user.id = :userId) AND d.direccionFacturacion = true")
|
||||
List<Direccion> findByUserIdAndDireccionFacturacion(@Param("userId") Long userId);
|
||||
|
||||
// find by user_id with deleted
|
||||
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
|
||||
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);
|
||||
|
||||
@ -77,6 +77,65 @@ public class DireccionService {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public Map<String, Object> getForSelectFacturacion(String q1, String q2, Long userId) {
|
||||
try {
|
||||
|
||||
// Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config)
|
||||
String search = Optional.ofNullable(q1).orElse(q2);
|
||||
if (search != null) {
|
||||
search = search.trim();
|
||||
}
|
||||
final String q = (search == null || search.isEmpty())
|
||||
? null
|
||||
: search.toLowerCase();
|
||||
|
||||
List<Direccion> all = repo.findByUserIdAndDireccionFacturacion(userId);
|
||||
|
||||
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
|
||||
List<Map<String, String>> options = all.stream()
|
||||
.map(cc -> {
|
||||
String id = cc.getId().toString();
|
||||
String alias = cc.getAlias();
|
||||
String direccion = cc.getDireccion();
|
||||
String cp = String.valueOf(cc.getCp());
|
||||
String ciudad = cc.getCiudad();
|
||||
String att = cc.getAtt();
|
||||
Map<String, String> m = new HashMap<>();
|
||||
m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3)
|
||||
m.put("text", alias); // texto mostrado, i18n con fallback a keyword
|
||||
m.put("cp", cp);
|
||||
m.put("ciudad", ciudad);
|
||||
m.put("att", att);
|
||||
m.put("alias", alias);
|
||||
m.put("direccion", direccion);
|
||||
return m;
|
||||
})
|
||||
.filter(opt -> {
|
||||
if (q == null || q.isEmpty())
|
||||
return true;
|
||||
String cp = opt.get("cp");
|
||||
String ciudad = opt.get("ciudad").toLowerCase();
|
||||
String att = opt.get("att").toLowerCase();
|
||||
String alias = opt.get("alias").toLowerCase();
|
||||
String text = opt.get("text").toLowerCase();
|
||||
String direccion = opt.get("direccion").toLowerCase();
|
||||
return text.contains(q) || cp.contains(q) || ciudad.contains(q) || att.contains(q)
|
||||
|| alias.contains(q) || direccion.contains(q);
|
||||
})
|
||||
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance()))
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// Estructura Select2
|
||||
Map<String, Object> resp = new HashMap<>();
|
||||
resp.put("results", options);
|
||||
return resp;
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
return Map.of("results", List.of());
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<Direccion> findById(Long id) {
|
||||
return repo.findById(id);
|
||||
}
|
||||
|
||||
@ -0,0 +1,331 @@
|
||||
package com.imprimelibros.erp.payments;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import org.springframework.context.MessageSource;
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.ui.Model;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import com.imprimelibros.erp.common.Utils;
|
||||
import com.imprimelibros.erp.datatables.DataTable;
|
||||
import com.imprimelibros.erp.datatables.DataTablesParser;
|
||||
import com.imprimelibros.erp.datatables.DataTablesRequest;
|
||||
import com.imprimelibros.erp.datatables.DataTablesResponse;
|
||||
import com.imprimelibros.erp.i18n.TranslationService;
|
||||
import com.imprimelibros.erp.payments.model.Payment;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransaction;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionType;
|
||||
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
|
||||
import com.imprimelibros.erp.users.User;
|
||||
import com.imprimelibros.erp.users.UserDao;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/pagos")
|
||||
@PreAuthorize("hasRole('SUPERADMIN')")
|
||||
public class PaymentController {
|
||||
|
||||
protected final PaymentService paymentService;
|
||||
protected final MessageSource messageSource;
|
||||
protected final TranslationService translationService;
|
||||
protected final PaymentTransactionRepository repoPaymentTransaction;
|
||||
protected final UserDao repoUser;
|
||||
|
||||
public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser,
|
||||
MessageSource messageSource, TranslationService translationService, PaymentService paymentService) {
|
||||
this.repoPaymentTransaction = repoPaymentTransaction;
|
||||
this.repoUser = repoUser;
|
||||
this.messageSource = messageSource;
|
||||
this.translationService = translationService;
|
||||
this.paymentService = paymentService;
|
||||
|
||||
}
|
||||
|
||||
@GetMapping()
|
||||
public String index(Model model, Locale locale) {
|
||||
|
||||
List<String> keys = List.of(
|
||||
"app.cancelar",
|
||||
"app.aceptar",
|
||||
"pagos.refund.title",
|
||||
"pagos.refund.text",
|
||||
"pagos.refund.success",
|
||||
"pagos.refund.error.general",
|
||||
"pagos.refund.error.invalid-number",
|
||||
"pagos.transferencia.finalizar.title",
|
||||
"pagos.transferencia.finalizar.text",
|
||||
"pagos.transferencia.finalizar.success");
|
||||
|
||||
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||
model.addAttribute("languageBundle", translations);
|
||||
|
||||
return "imprimelibros/pagos/gestion-pagos";
|
||||
}
|
||||
|
||||
@GetMapping(value = "datatable/redsys", produces = "application/json")
|
||||
@ResponseBody
|
||||
public DataTablesResponse<Map<String, Object>> getDatatableRedsys(HttpServletRequest request, Locale locale) {
|
||||
|
||||
DataTablesRequest dt = DataTablesParser.from(request);
|
||||
|
||||
List<String> searchable = List.of(
|
||||
"payment.gatewayOrderId",
|
||||
"payment.orderId"
|
||||
// "client" no, porque lo calculas a posteriori
|
||||
);
|
||||
|
||||
// Campos ordenables
|
||||
List<String> orderable = List.of(
|
||||
"payment.gatewayOrderId",
|
||||
"payment.orderId",
|
||||
"amountCents",
|
||||
"payment.amountRefundedCents",
|
||||
"createdAt");
|
||||
|
||||
Specification<PaymentTransaction> base = Specification.allOf(
|
||||
(root, query, cb) -> cb.equal(root.get("status"), PaymentTransactionStatus.succeeded));
|
||||
base = base.and((root, query, cb) -> cb.equal(root.get("type"), PaymentTransactionType.CAPTURE));
|
||||
|
||||
String clientSearch = dt.getColumnSearch("client");
|
||||
|
||||
// 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification
|
||||
if (clientSearch != null) {
|
||||
List<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
|
||||
|
||||
if (userIds.isEmpty()) {
|
||||
// Ningún usuario coincide → forzamos 0 resultados
|
||||
base = base.and((root, query, cb) -> cb.disjunction());
|
||||
} else {
|
||||
base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds));
|
||||
}
|
||||
}
|
||||
Long total = repoPaymentTransaction.count(base);
|
||||
|
||||
return DataTable
|
||||
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
|
||||
.orderable(orderable)
|
||||
.add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
|
||||
.add("client", pago -> {
|
||||
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
|
||||
Payment payment = pago.getPayment();
|
||||
if (payment.getUserId() != null) {
|
||||
Optional<User> user = repoUser.findById(payment.getUserId().longValue());
|
||||
return user.map(User::getFullName).orElse("");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.add("gateway_order_id", pago -> {
|
||||
if (pago.getPayment() != null) {
|
||||
return pago.getPayment().getGatewayOrderId();
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.add("orderId", pago -> {
|
||||
if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) {
|
||||
return pago.getPayment().getOrderId().toString();
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale))
|
||||
.add("amount_cents_refund", pago -> {
|
||||
Payment payment = pago.getPayment();
|
||||
if (payment != null) {
|
||||
return Utils.formatCurrency(payment.getAmountRefundedCents() / 100.0, locale);
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.add("actions", pago -> {
|
||||
Payment p = pago.getPayment();
|
||||
if (p != null) {
|
||||
if (pago.getAmountCents() - p.getAmountRefundedCents() > 0) {
|
||||
return "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'"
|
||||
+ p.getGatewayOrderId()
|
||||
+ "\' data-transactionId=\'" + pago.getPayment().getId()
|
||||
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
|
||||
+ "\' style=\'cursor: pointer;\'>"
|
||||
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
|
||||
}
|
||||
return "";
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.where(base)
|
||||
.toJson(total);
|
||||
|
||||
}
|
||||
|
||||
@GetMapping(value = "datatable/transferencias", produces = "application/json")
|
||||
@ResponseBody
|
||||
public DataTablesResponse<Map<String, Object>> getDatatableTransferencias(HttpServletRequest request,
|
||||
Locale locale) {
|
||||
|
||||
DataTablesRequest dt = DataTablesParser.from(request);
|
||||
|
||||
List<String> searchable = List.of(
|
||||
// "client" no, porque lo calculas a posteriori
|
||||
);
|
||||
|
||||
// Campos ordenables
|
||||
List<String> orderable = List.of(
|
||||
"transferId",
|
||||
"status",
|
||||
"amountCents",
|
||||
"payment.amountRefundedCents",
|
||||
"createdAt", "updatedAt");
|
||||
|
||||
Specification<PaymentTransaction> base = (root, query, cb) -> cb.or(
|
||||
cb.equal(root.get("status"), PaymentTransactionStatus.pending),
|
||||
cb.equal(root.get("status"), PaymentTransactionStatus.succeeded));
|
||||
|
||||
base = base.and((root, query, cb) -> cb.equal(root.get("type"), PaymentTransactionType.CAPTURE));
|
||||
base = base.and((root, query, cb) -> cb.equal(root.get("payment").get("gateway"), "bank_transfer"));
|
||||
|
||||
String clientSearch = dt.getColumnSearch("client");
|
||||
|
||||
// 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification
|
||||
if (clientSearch != null) {
|
||||
List<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
|
||||
|
||||
if (userIds.isEmpty()) {
|
||||
// Ningún usuario coincide → forzamos 0 resultados
|
||||
base = base.and((root, query, cb) -> cb.disjunction());
|
||||
} else {
|
||||
base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds));
|
||||
}
|
||||
}
|
||||
Long total = repoPaymentTransaction.count(base);
|
||||
|
||||
return DataTable
|
||||
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
|
||||
.orderable(orderable)
|
||||
.add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
|
||||
.add("processed_at", pago -> Utils.formatDateTime(pago.getProcessedAt(), locale))
|
||||
.add("client", pago -> {
|
||||
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
|
||||
Payment payment = pago.getPayment();
|
||||
if (payment.getUserId() != null) {
|
||||
Optional<User> user = repoUser.findById(payment.getUserId().longValue());
|
||||
return user.map(User::getFullName).orElse("");
|
||||
}
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.add("transfer_id", pago -> {
|
||||
if (pago.getPayment() != null) {
|
||||
return "TRANSF-" + pago.getPayment().getOrderId();
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
})
|
||||
.add("order_id", pago -> {
|
||||
if (pago.getStatus() != PaymentTransactionStatus.pending) {
|
||||
if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) {
|
||||
return pago.getPayment().getOrderId().toString();
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return messageSource.getMessage("pagos.transferencia.no-pedido", null, "Pendiente", locale);
|
||||
|
||||
}).add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale))
|
||||
.add("amount_cents_refund", pago ->
|
||||
|
||||
{
|
||||
Payment payment = pago.getPayment();
|
||||
if (payment != null) {
|
||||
return Utils.formatCurrency(payment.getAmountRefundedCents() / 100.0, locale);
|
||||
}
|
||||
return "";
|
||||
}).add("status", pago -> {
|
||||
switch (pago.getStatus()) {
|
||||
case PaymentTransactionStatus.pending:
|
||||
return messageSource.getMessage("pagos.table.estado.pending", null, "Pendiente", locale);
|
||||
case PaymentTransactionStatus.succeeded:
|
||||
return messageSource.getMessage("pagos.table.estado.succeeded", null, "Completada", locale);
|
||||
case PaymentTransactionStatus.failed:
|
||||
return messageSource.getMessage("pagos.table.estado.failed", null, "Fallido", locale);
|
||||
default:
|
||||
return pago.getStatus().name();
|
||||
}
|
||||
}).add("actions", pago -> {
|
||||
Payment p = pago.getPayment();
|
||||
if (p != null) {
|
||||
String actions = "";
|
||||
if (pago.getStatus() != PaymentTransactionStatus.succeeded) {
|
||||
actions += "<span class=\'badge bg-success btn-mark-as-completed \' data-paymentId=\'"
|
||||
+ p.getId()
|
||||
+ "\' data-transactionId=\'" + pago.getPayment().getId()
|
||||
+ "\' style=\'cursor: pointer;\'>"
|
||||
+ messageSource.getMessage("pagos.table.finalizar", null, locale) + "</span> ";
|
||||
|
||||
}
|
||||
if ((pago.getAmountCents() - p.getAmountRefundedCents() > 0)
|
||||
&& pago.getStatus() == PaymentTransactionStatus.succeeded) {
|
||||
actions += "<span class=\'badge bg-secondary btn-transfer-refund \' data-dsOrderId=\'"
|
||||
+ p.getGatewayOrderId()
|
||||
+ "\' data-transactionId=\'" + pago.getPayment().getId()
|
||||
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
|
||||
+ "\' style=\'cursor: pointer;\'>"
|
||||
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
|
||||
}
|
||||
return actions;
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}).where(base).toJson(total);
|
||||
|
||||
}
|
||||
|
||||
@PostMapping(value = "/transfer/completed/{id}", produces = "application/json")
|
||||
public ResponseEntity<Map<String, Object>> markTransferAsCaptured(@PathVariable Long id) {
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
paymentService.markBankTransferAsCaptured(id);
|
||||
response = Map.of("success", true);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
response = Map.of("success", false);
|
||||
response.put("error", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/transfer/refund/{id}", produces = "application/json")
|
||||
public ResponseEntity<Map<String, Object>> refundTransfer(@PathVariable Long id,
|
||||
@RequestParam("amountCents") Long amountCents) {
|
||||
|
||||
Map<String, Object> response;
|
||||
try {
|
||||
paymentService.refundBankTransfer(id, amountCents);
|
||||
response = Map.of("success", true);
|
||||
return ResponseEntity.ok(response);
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
response = Map.of("success", false);
|
||||
response.put("error", e.getMessage());
|
||||
return ResponseEntity.badRequest().body(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
469
src/main/java/com/imprimelibros/erp/payments/PaymentService.java
Normal file
@ -0,0 +1,469 @@
|
||||
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;
|
||||
|
||||
@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());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Devuelve (total o parcialmente) un pago hecho por transferencia bancaria.
|
||||
* - Solo permite gateway = "bank_transfer".
|
||||
* - Crea un Refund + PaymentTransaction de tipo REFUND.
|
||||
* - Actualiza amountRefundedCents y el estado del Payment.
|
||||
*/
|
||||
@Transactional
|
||||
public Refund refundBankTransfer(Long paymentId, long amountCents) {
|
||||
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");
|
||||
}
|
||||
|
||||
if (amountCents <= 0) {
|
||||
throw new IllegalArgumentException("El importe de devolución debe ser > 0");
|
||||
}
|
||||
|
||||
// Solo tiene sentido devolver si está capturado o ya parcialmente devuelto
|
||||
if (p.getStatus() != PaymentStatus.captured
|
||||
&& p.getStatus() != PaymentStatus.partially_refunded) {
|
||||
throw new IllegalStateException(
|
||||
"El Payment " + paymentId + " no está capturado; estado actual: " + p.getStatus());
|
||||
}
|
||||
|
||||
long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents();
|
||||
if (amountCents > maxRefundable) {
|
||||
throw new IllegalStateException(
|
||||
"Importe de devolución supera lo todavía reembolsable. " +
|
||||
"maxRefundable=" + maxRefundable + " requested=" + amountCents);
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
// 1) Crear Refund (para transferencias lo marcamos como SUCCEEDED directamente)
|
||||
Refund refund = new Refund();
|
||||
refund.setPayment(p);
|
||||
refund.setAmountCents(amountCents);
|
||||
// reason usa el valor por defecto (customer_request); si quieres otro, cámbialo
|
||||
// aquí
|
||||
refund.setStatus(RefundStatus.succeeded);
|
||||
refund.setRequestedAt(now);
|
||||
refund.setProcessedAt(now);
|
||||
// requestedByUserId, notes, metadata -> opcionales, déjalos en null si no los
|
||||
// usas
|
||||
refund = refundRepo.save(refund);
|
||||
|
||||
// 2) Crear transacción de tipo REFUND
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.REFUND);
|
||||
tx.setStatus(PaymentTransactionStatus.succeeded);
|
||||
tx.setAmountCents(amountCents);
|
||||
tx.setCurrency(p.getCurrency());
|
||||
tx.setProcessedAt(now);
|
||||
// gatewayTransactionId lo dejamos null → el índice UNIQUE permite múltiples
|
||||
// NULL
|
||||
tx = txRepo.save(tx);
|
||||
|
||||
// Vincular el Refund con la transacción
|
||||
refund.setTransaction(tx);
|
||||
refundRepo.save(refund);
|
||||
|
||||
// 3) Actualizar Payment: total devuelto y estado
|
||||
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
|
||||
|
||||
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
|
||||
p.setStatus(PaymentStatus.refunded);
|
||||
} else {
|
||||
p.setStatus(PaymentStatus.partially_refunded);
|
||||
}
|
||||
|
||||
payRepo.save(p);
|
||||
|
||||
return refund;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
|
||||
public enum CaptureMethod { automatic, manual }
|
||||
|
||||
173
src/main/java/com/imprimelibros/erp/payments/model/Payment.java
Normal file
@ -0,0 +1,173 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "payments")
|
||||
public class Payment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "order_id")
|
||||
private Long orderId;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Column(nullable = false, length = 3)
|
||||
private String currency;
|
||||
|
||||
@Column(name = "amount_total_cents", nullable = false)
|
||||
private Long amountTotalCents;
|
||||
|
||||
@Column(name = "amount_captured_cents", nullable = false)
|
||||
private Long amountCapturedCents = 0L;
|
||||
|
||||
@Column(name = "amount_refunded_cents", nullable = false)
|
||||
private Long amountRefundedCents = 0L;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private PaymentStatus status = PaymentStatus.requires_payment_method;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "capture_method", nullable = false, length = 16)
|
||||
private CaptureMethod captureMethod = CaptureMethod.automatic;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String gateway;
|
||||
|
||||
@Column(name = "gateway_payment_id", length = 128)
|
||||
private String gatewayPaymentId;
|
||||
|
||||
@Column(name = "gateway_order_id", length = 12)
|
||||
private String gatewayOrderId;
|
||||
|
||||
@Column(name = "authorization_code", length = 32)
|
||||
private String authorizationCode;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "three_ds_status", nullable = false, length = 32)
|
||||
private ThreeDSStatus threeDsStatus = ThreeDSStatus.not_applicable;
|
||||
|
||||
@Column(length = 22)
|
||||
private String descriptor;
|
||||
|
||||
@Lob
|
||||
@Column(name = "client_ip", columnDefinition = "varbinary(16)")
|
||||
private byte[] clientIp;
|
||||
|
||||
@Column(name = "authorized_at")
|
||||
private LocalDateTime authorizedAt;
|
||||
|
||||
@Column(name = "captured_at")
|
||||
private LocalDateTime capturedAt;
|
||||
|
||||
@Column(name = "canceled_at")
|
||||
private LocalDateTime canceledAt;
|
||||
|
||||
@Column(name = "failed_at")
|
||||
private LocalDateTime failedAt;
|
||||
|
||||
@Column(columnDefinition = "json")
|
||||
private String metadata;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "updated_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp on update current_timestamp")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
public Payment() {}
|
||||
|
||||
// Getters y setters ↓ (los típicos)
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getOrderId() { return orderId; }
|
||||
public void setOrderId(Long orderId) { this.orderId = orderId; }
|
||||
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public Long getAmountTotalCents() { return amountTotalCents; }
|
||||
public void setAmountTotalCents(Long amountTotalCents) { this.amountTotalCents = amountTotalCents; }
|
||||
|
||||
public Long getAmountCapturedCents() { return amountCapturedCents; }
|
||||
public void setAmountCapturedCents(Long amountCapturedCents) { this.amountCapturedCents = amountCapturedCents; }
|
||||
|
||||
public Long getAmountRefundedCents() { return amountRefundedCents; }
|
||||
public void setAmountRefundedCents(Long amountRefundedCents) { this.amountRefundedCents = amountRefundedCents; }
|
||||
|
||||
public PaymentStatus getStatus() { return status; }
|
||||
public void setStatus(PaymentStatus status) { this.status = status; }
|
||||
|
||||
public CaptureMethod getCaptureMethod() { return captureMethod; }
|
||||
public void setCaptureMethod(CaptureMethod captureMethod) { this.captureMethod = captureMethod; }
|
||||
|
||||
public String getGateway() { return gateway; }
|
||||
public void setGateway(String gateway) { this.gateway = gateway; }
|
||||
|
||||
public String getGatewayPaymentId() { return gatewayPaymentId; }
|
||||
public void setGatewayPaymentId(String gatewayPaymentId) { this.gatewayPaymentId = gatewayPaymentId; }
|
||||
|
||||
public String getGatewayOrderId() { return gatewayOrderId; }
|
||||
public void setGatewayOrderId(String gatewayOrderId) { this.gatewayOrderId = gatewayOrderId; }
|
||||
|
||||
public String getAuthorizationCode() { return authorizationCode; }
|
||||
public void setAuthorizationCode(String authorizationCode) { this.authorizationCode = authorizationCode; }
|
||||
|
||||
public ThreeDSStatus getThreeDsStatus() { return threeDsStatus; }
|
||||
public void setThreeDsStatus(ThreeDSStatus threeDsStatus) { this.threeDsStatus = threeDsStatus; }
|
||||
|
||||
public String getDescriptor() { return descriptor; }
|
||||
public void setDescriptor(String descriptor) { this.descriptor = descriptor; }
|
||||
|
||||
public byte[] getClientIp() { return clientIp; }
|
||||
public void setClientIp(byte[] clientIp) { this.clientIp = clientIp; }
|
||||
|
||||
public LocalDateTime getAuthorizedAt() { return authorizedAt; }
|
||||
public void setAuthorizedAt(LocalDateTime authorizedAt) { this.authorizedAt = authorizedAt; }
|
||||
|
||||
public LocalDateTime getCapturedAt() { return capturedAt; }
|
||||
public void setCapturedAt(LocalDateTime capturedAt) { this.capturedAt = capturedAt; }
|
||||
|
||||
public LocalDateTime getCanceledAt() { return canceledAt; }
|
||||
public void setCanceledAt(LocalDateTime canceledAt) { this.canceledAt = canceledAt; }
|
||||
|
||||
public LocalDateTime getFailedAt() { return failedAt; }
|
||||
public void setFailedAt(LocalDateTime failedAt) { this.failedAt = failedAt; }
|
||||
|
||||
public String getMetadata() { return metadata; }
|
||||
public void setMetadata(String metadata) { this.metadata = metadata; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentStatus {
|
||||
requires_payment_method, requires_action, authorized,
|
||||
captured, partially_refunded, refunded, canceled, failed
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "payment_transactions",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_tx_gateway_txid", columnNames = {"gateway_transaction_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_tx_pay", columnList = "payment_id"),
|
||||
@Index(name = "idx_tx_type_status", columnList = "type,status"),
|
||||
@Index(name = "idx_tx_idem", columnList = "idempotency_key")
|
||||
}
|
||||
)
|
||||
public class PaymentTransaction {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "payment_id", nullable = false)
|
||||
private Payment payment;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type", nullable = false, length = 16)
|
||||
private PaymentTransactionType type;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private PaymentTransactionStatus status;
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Long amountCents;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 3)
|
||||
private String currency;
|
||||
|
||||
@Column(name = "gateway_transaction_id", length = 128)
|
||||
private String gatewayTransactionId;
|
||||
|
||||
@Column(name = "gateway_response_code", length = 64)
|
||||
private String gatewayResponseCode;
|
||||
|
||||
@Column(name = "avs_result", length = 8)
|
||||
private String avsResult;
|
||||
|
||||
@Column(name = "cvv_result", length = 8)
|
||||
private String cvvResult;
|
||||
|
||||
@Column(name = "three_ds_version", length = 16)
|
||||
private String threeDsVersion;
|
||||
|
||||
@Column(name = "idempotency_key", length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@Column(name = "request_payload", columnDefinition = "json")
|
||||
private String requestPayload;
|
||||
|
||||
@Column(name = "response_payload", columnDefinition = "json")
|
||||
private String responsePayload;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public PaymentTransaction() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Payment getPayment() { return payment; }
|
||||
public void setPayment(Payment payment) { this.payment = payment; }
|
||||
|
||||
public PaymentTransactionType getType() { return type; }
|
||||
public void setType(PaymentTransactionType type) { this.type = type; }
|
||||
|
||||
public PaymentTransactionStatus getStatus() { return status; }
|
||||
public void setStatus(PaymentTransactionStatus status) { this.status = status; }
|
||||
|
||||
public Long getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public String getGatewayTransactionId() { return gatewayTransactionId; }
|
||||
public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; }
|
||||
|
||||
public String getGatewayResponseCode() { return gatewayResponseCode; }
|
||||
public void setGatewayResponseCode(String gatewayResponseCode) { this.gatewayResponseCode = gatewayResponseCode; }
|
||||
|
||||
public String getAvsResult() { return avsResult; }
|
||||
public void setAvsResult(String avsResult) { this.avsResult = avsResult; }
|
||||
|
||||
public String getCvvResult() { return cvvResult; }
|
||||
public void setCvvResult(String cvvResult) { this.cvvResult = cvvResult; }
|
||||
|
||||
public String getThreeDsVersion() { return threeDsVersion; }
|
||||
public void setThreeDsVersion(String threeDsVersion) { this.threeDsVersion = threeDsVersion; }
|
||||
|
||||
public String getIdempotencyKey() { return idempotencyKey; }
|
||||
public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
|
||||
|
||||
public String getRequestPayload() { return requestPayload; }
|
||||
public void setRequestPayload(String requestPayload) { this.requestPayload = requestPayload; }
|
||||
|
||||
public String getResponsePayload() { return responsePayload; }
|
||||
public void setResponsePayload(String responsePayload) { this.responsePayload = responsePayload; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (createdAt == null) {
|
||||
createdAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentTransactionStatus { pending, succeeded, failed }
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID }
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "refunds",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_refund_gateway_id", columnNames = {"gateway_refund_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_ref_pay", columnList = "payment_id"),
|
||||
@Index(name = "idx_ref_status", columnList = "status")
|
||||
}
|
||||
)
|
||||
public class Refund {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "payment_id", nullable = false)
|
||||
private Payment payment;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "transaction_id")
|
||||
private PaymentTransaction transaction; // el REFUND en payment_transactions
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Long amountCents;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reason", nullable = false, length = 32)
|
||||
private RefundReason reason = RefundReason.customer_request;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private RefundStatus status = RefundStatus.pending;
|
||||
|
||||
@Column(name = "requested_by_user_id")
|
||||
private Long requestedByUserId;
|
||||
|
||||
@Column(name = "requested_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "gateway_refund_id", length = 128)
|
||||
private String gatewayRefundId;
|
||||
|
||||
@Column(name = "notes", length = 500)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "metadata", columnDefinition = "json")
|
||||
private String metadata;
|
||||
|
||||
public Refund() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Payment getPayment() { return payment; }
|
||||
public void setPayment(Payment payment) { this.payment = payment; }
|
||||
|
||||
public PaymentTransaction getTransaction() { return transaction; }
|
||||
public void setTransaction(PaymentTransaction transaction) { this.transaction = transaction; }
|
||||
|
||||
public Long getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public RefundReason getReason() { return reason; }
|
||||
public void setReason(RefundReason reason) { this.reason = reason; }
|
||||
|
||||
public RefundStatus getStatus() { return status; }
|
||||
public void setStatus(RefundStatus status) { this.status = status; }
|
||||
|
||||
public Long getRequestedByUserId() { return requestedByUserId; }
|
||||
public void setRequestedByUserId(Long requestedByUserId) { this.requestedByUserId = requestedByUserId; }
|
||||
|
||||
public LocalDateTime getRequestedAt() { return requestedAt; }
|
||||
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public String getGatewayRefundId() { return gatewayRefundId; }
|
||||
public void setGatewayRefundId(String gatewayRefundId) { this.gatewayRefundId = gatewayRefundId; }
|
||||
|
||||
public String getNotes() { return notes; }
|
||||
public void setNotes(String notes) { this.notes = notes; }
|
||||
|
||||
public String getMetadata() { return metadata; }
|
||||
public void setMetadata(String metadata) { this.metadata = metadata; }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundReason {
|
||||
customer_request, partial_return, pricing_adjustment, duplicate, fraud, other
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundStatus { pending, succeeded, failed, canceled }
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed }
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "webhook_events",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_webhook_provider_event", columnNames = {"provider","event_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_webhook_processed", columnList = "processed")
|
||||
}
|
||||
)
|
||||
public class WebhookEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "provider", nullable = false, length = 32)
|
||||
private String provider; // "redsys", etc.
|
||||
|
||||
@Column(name = "event_type", nullable = false, length = 64)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "event_id", length = 128)
|
||||
private String eventId;
|
||||
|
||||
@Column(name = "signature", length = 512)
|
||||
private String signature;
|
||||
|
||||
@Column(name = "payload", nullable = false, columnDefinition = "json")
|
||||
private String payload;
|
||||
|
||||
@Column(name = "processed", nullable = false)
|
||||
private Boolean processed = false;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "attempts", nullable = false)
|
||||
private Integer attempts = 0;
|
||||
|
||||
@Column(name = "last_error", length = 500)
|
||||
private String lastError;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public WebhookEvent() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
|
||||
public String getEventType() { return eventType; }
|
||||
public void setEventType(String eventType) { this.eventType = eventType; }
|
||||
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String eventId) { this.eventId = eventId; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
|
||||
public String getPayload() { return payload; }
|
||||
public void setPayload(String payload) { this.payload = payload; }
|
||||
|
||||
public Boolean getProcessed() { return processed; }
|
||||
public void setProcessed(Boolean processed) { this.processed = processed; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public Integer getAttempts() { return attempts; }
|
||||
public void setAttempts(Integer attempts) { this.attempts = attempts; }
|
||||
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (createdAt == null) {
|
||||
createdAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
// PaymentRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.Payment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
Optional<Payment> findByGatewayAndGatewayOrderId(String gateway, String gatewayOrderId);
|
||||
}
|
||||
@ -0,0 +1,21 @@
|
||||
// PaymentTransactionRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransaction;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionType;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long>, JpaSpecificationExecutor<PaymentTransaction> {
|
||||
Optional<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId);
|
||||
Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey);
|
||||
Optional<PaymentTransaction> findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc(
|
||||
Long paymentId,
|
||||
PaymentTransactionType type,
|
||||
PaymentTransactionStatus status
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// RefundRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.Refund;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
public interface RefundRepository extends JpaRepository<Refund, Long> {
|
||||
@Query("select coalesce(sum(r.amountCents),0) from Refund r where r.payment.id = :paymentId and r.status = com.imprimelibros.erp.payments.model.RefundStatus.succeeded")
|
||||
long sumSucceededByPaymentId(@Param("paymentId") Long paymentId);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// WebhookEventRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.WebhookEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {
|
||||
|
||||
Optional<WebhookEvent> findByProviderAndEventId(String provider, String eventId);
|
||||
}
|
||||
@ -1,83 +1,171 @@
|
||||
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.context.MessageSource;
|
||||
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 org.springframework.web.servlet.mvc.support.RedirectAttributes;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/pagos/redsys")
|
||||
public class RedsysController {
|
||||
|
||||
private final RedsysService service;
|
||||
private final PaymentService paymentService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public RedsysController(RedsysService service) {
|
||||
this.service = service;
|
||||
public RedsysController(PaymentService paymentService, MessageSource messageSource) {
|
||||
this.paymentService = paymentService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@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, @RequestParam("cartId") Long cartId) throws Exception {
|
||||
|
||||
if ("bank-transfer".equalsIgnoreCase(method)) {
|
||||
// 1) Creamos el Payment interno SIN orderId (null)
|
||||
Payment p = paymentService.createBankTransferPayment(cartId, 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(cartId, 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"/>
|
||||
<input type="hidden" name="cartId" value="%d"/>
|
||||
<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(), cartId);
|
||||
|
||||
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")
|
||||
public String okGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
|
||||
String msg = messageSource.getMessage("checkout.success.payment", null, "Pago realizado con éxito. Gracias por su compra.", locale);
|
||||
model.addAttribute("successPago", msg);
|
||||
redirectAttrs.addFlashAttribute("successPago", msg);
|
||||
return "redirect:/cart";
|
||||
}
|
||||
|
||||
// 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
|
||||
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")
|
||||
public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
|
||||
|
||||
String msg = messageSource.getMessage("checkout.error.payment", null, "Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.", locale);
|
||||
model.addAttribute("errorPago", msg);
|
||||
redirectAttrs.addFlashAttribute("errorPago", msg);
|
||||
return "redirect:/cart";
|
||||
}
|
||||
|
||||
@PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@ResponseBody
|
||||
public ResponseEntity<String> koPost(
|
||||
@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";
|
||||
// Procesamos la notificación IGUAL que en /ok y /notify
|
||||
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||
|
||||
// Mensaje para el usuario (pago cancelado/rechazado)
|
||||
String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
|
||||
return ResponseEntity.ok(html);
|
||||
} catch (Exception e) {
|
||||
model.addAttribute("error", "No se pudo validar la respuesta de Redsys.");
|
||||
return "imprimelibros/payments/redsys-ko";
|
||||
// Si algo falla al validar/procesar, lo mostramos (útil en entorno de pruebas)
|
||||
String html = "<h2>Error procesando notificación KO</h2><pre>" + e.getMessage() + "</pre>";
|
||||
return ResponseEntity.badRequest().body(html);
|
||||
}
|
||||
}
|
||||
|
||||
@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 = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@ResponseBody
|
||||
public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
|
||||
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||
try {
|
||||
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||
return "OK";
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // 👈 para ver el motivo del 500 en logs
|
||||
return "ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
@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("{success:true}");
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.badRequest().body("{success:false, error: '" + e.getMessage() + "'}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
package com.imprimelibros.erp.redsys;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import sis.redsys.api.ApiMacSha256;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
@ -18,6 +22,10 @@ import java.util.Objects;
|
||||
public class RedsysService {
|
||||
|
||||
// ---------- CONFIG ----------
|
||||
@Value("${redsys.url}")
|
||||
private String url;
|
||||
@Value("${redsys.refund.url}")
|
||||
private String urlRefund;
|
||||
@Value("${redsys.merchant-code}")
|
||||
private String merchantCode;
|
||||
@Value("${redsys.terminal}")
|
||||
@ -37,19 +45,33 @@ public class RedsysService {
|
||||
@Value("${redsys.environment}")
|
||||
private String env;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||
|
||||
// ---------- RECORDS ----------
|
||||
public record PaymentRequest(String order, long amountCents, String description) {
|
||||
// Pedido a Redsys
|
||||
public record PaymentRequest(String order, long amountCents, String description, Long cartId) {
|
||||
}
|
||||
|
||||
// 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,12 +80,30 @@ public class RedsysService {
|
||||
api.setParameter("DS_MERCHANT_URLOK", urlOk);
|
||||
api.setParameter("DS_MERCHANT_URLKO", urlKo);
|
||||
|
||||
// ✅ Añadir contexto adicional (por ejemplo, cartId)
|
||||
// Si tu PaymentRequest no lo lleva todavía, puedes pasarlo en description o
|
||||
// crear otro campo.
|
||||
JSONObject ctx = new JSONObject();
|
||||
ctx.put("cartId", req.cartId()); // o req.cartId() si decides añadirlo al record
|
||||
api.setParameter("DS_MERCHANT_MERCHANTDATA", ctx.toString());
|
||||
|
||||
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);
|
||||
|
||||
String action = "test".equalsIgnoreCase(env)
|
||||
? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
||||
: "https://sis.redsys.es/sis/realizarPago";
|
||||
String action = url;
|
||||
/*
|
||||
* ? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
||||
* : "https://sis.redsys.es/sis/realizarPago";
|
||||
*/
|
||||
|
||||
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
|
||||
}
|
||||
@ -84,27 +124,40 @@ 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 {
|
||||
|
||||
if (notif.order == null || notif.order.isBlank()) {
|
||||
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
|
||||
ApiMacSha256 api = new ApiMacSha256();
|
||||
|
||||
// 1) Decodificar Ds_MerchantParameters usando la librería oficial
|
||||
String json = api.decodeMerchantParameters(dsMerchantParametersB64);
|
||||
|
||||
// 2) Convertir a Map para tu modelo
|
||||
Map<String, Object> mp = MAPPER.readValue(json, new TypeReference<>() {
|
||||
});
|
||||
RedsysNotification notif = new RedsysNotification(mp);
|
||||
|
||||
if (notif.order == null || notif.order.isBlank()) {
|
||||
System.out.println("### ATENCIÓN: Ds_Order no viene en MerchantParameters");
|
||||
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
|
||||
}
|
||||
|
||||
// 3) Calcular firma esperada: clave comercio + MerchantParameters en B64
|
||||
String expected = api.createMerchantSignatureNotif(
|
||||
secretKeyBase64, // 👈 La misma que usas para crear la firma del pago
|
||||
dsMerchantParametersB64 // 👈 SIEMPRE el B64 tal cual llega de Redsys, sin tocar
|
||||
);
|
||||
|
||||
// 4) Comparar firma Redsys vs firma calculada
|
||||
if (!safeEqualsB64(dsSignature, expected)) {
|
||||
System.out.println("### Firma Redsys no válida");
|
||||
System.out.println("Ds_Signature (Redsys) = " + dsSignature);
|
||||
System.out.println("Expected (local) = " + 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))
|
||||
@ -141,6 +194,7 @@ public class RedsysService {
|
||||
public final String response;
|
||||
public final long amountCents;
|
||||
public final String currency;
|
||||
public final Long cartId;
|
||||
|
||||
public RedsysNotification(Map<String, Object> raw) {
|
||||
this.raw = raw;
|
||||
@ -148,6 +202,24 @@ public class RedsysService {
|
||||
this.response = str(raw.get("Ds_Response"));
|
||||
this.currency = str(raw.get("Ds_Currency"));
|
||||
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
|
||||
this.cartId = extractCartId(raw.get("Ds_MerchantData"));
|
||||
}
|
||||
|
||||
private static Long extractCartId(Object merchantDataObj) {
|
||||
if (merchantDataObj == null)
|
||||
return null;
|
||||
try {
|
||||
String json = String.valueOf(merchantDataObj);
|
||||
|
||||
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
|
||||
json = json.replace(""", "\"");
|
||||
|
||||
org.json.JSONObject ctx = new org.json.JSONObject(json);
|
||||
return ctx.optLong("cartId", 0L);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // te ayudará si vuelve a fallar
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean authorized() {
|
||||
@ -171,4 +243,79 @@ public class RedsysService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Solicita a Redsys una devolución (TransactionType = 3)
|
||||
*
|
||||
* @param order El mismo Ds_Merchant_Order que se usó en el cobro.
|
||||
* @param amountCents Importe en céntimos a devolver.
|
||||
* @return gatewayRefundId (p.ej. Ds_AuthorisationCode o Ds_Order)
|
||||
*/
|
||||
public String requestRefund(String order, long amountCents) throws Exception {
|
||||
ApiMacSha256 api = new ApiMacSha256();
|
||||
|
||||
// Montar parámetros para el refund
|
||||
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
|
||||
api.setParameter("DS_MERCHANT_TERMINAL", terminal);
|
||||
api.setParameter("DS_MERCHANT_ORDER", order);
|
||||
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(amountCents));
|
||||
api.setParameter("DS_MERCHANT_CURRENCY", currency);
|
||||
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", "3"); // 3 = devolución
|
||||
api.setParameter("DS_MERCHANT_MERCHANTURL", "");
|
||||
api.setParameter("DS_MERCHANT_URLOK", "");
|
||||
api.setParameter("DS_MERCHANT_URLKO", "");
|
||||
|
||||
// Crear parámetros y firma (como en tu PHP)
|
||||
String merchantParameters = api.createMerchantParameters();
|
||||
String signature = api.createMerchantSignature(secretKeyBase64);
|
||||
|
||||
// Montar el JSON para Redsys REST
|
||||
String json = """
|
||||
{
|
||||
"Ds_MerchantParameters": "%s",
|
||||
"Ds_Signature": "%s",
|
||||
"Ds_SignatureVersion": "HMAC_SHA256_V1"
|
||||
}
|
||||
""".formatted(merchantParameters, signature);
|
||||
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(urlRefund))
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(json))
|
||||
.build();
|
||||
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
|
||||
if (response.statusCode() / 100 != 2)
|
||||
throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode());
|
||||
|
||||
if (response.body() == null || response.body().isBlank())
|
||||
throw new IllegalStateException("Respuesta vacía de Redsys refund REST");
|
||||
|
||||
// Parsear la respuesta JSON
|
||||
Map<String, Object> respMap = MAPPER.readValue(response.body(), new TypeReference<>() {
|
||||
});
|
||||
|
||||
// Redsys puede devolver "Ds_MerchantParameters" o "errorCode"
|
||||
if (respMap.containsKey("errorCode")) {
|
||||
throw new IllegalStateException("Error Redsys refund: " + respMap.get("errorCode"));
|
||||
}
|
||||
|
||||
String dsMerchantParametersResp = (String) respMap.get("Ds_MerchantParameters");
|
||||
if (dsMerchantParametersResp == null) {
|
||||
throw new IllegalStateException("Respuesta Redsys refund sin Ds_MerchantParameters");
|
||||
}
|
||||
|
||||
// Decodificar MerchantParameters de la respuesta
|
||||
Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp);
|
||||
|
||||
String dsResponse = String.valueOf(decoded.get("Ds_Response"));
|
||||
if (!"0900".equals(dsResponse)) {
|
||||
throw new IllegalStateException("Devolución rechazada, Ds_Response=" + dsResponse);
|
||||
}
|
||||
|
||||
return String.valueOf(decoded.getOrDefault("Ds_AuthorisationCode", order));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@ -19,60 +19,63 @@ import org.springframework.lang.Nullable;
|
||||
@Repository
|
||||
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||
|
||||
// Aplicamos EntityGraph a la versión con Specification+Pageable
|
||||
@Override
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
@NonNull
|
||||
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
|
||||
// Aplicamos EntityGraph a la versión con Specification+Pageable
|
||||
@Override
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
@NonNull
|
||||
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
|
||||
|
||||
Optional<User> findByUserNameIgnoreCase(String userName);
|
||||
Optional<User> findByUserNameIgnoreCase(String userName);
|
||||
|
||||
boolean existsByUserNameIgnoreCase(String userName);
|
||||
boolean existsByUserNameIgnoreCase(String userName);
|
||||
|
||||
// Para comprobar si existe al hacer signup
|
||||
@Query(value = """
|
||||
SELECT id, deleted, enabled
|
||||
FROM users
|
||||
WHERE LOWER(username) = LOWER(:userName)
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
// Para comprobar si existe al hacer signup
|
||||
@Query(value = """
|
||||
SELECT id, deleted, enabled
|
||||
FROM users
|
||||
WHERE LOWER(username) = LOWER(:userName)
|
||||
LIMIT 1
|
||||
""", nativeQuery = true)
|
||||
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
|
||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||
|
||||
// Nuevo: para login/negocio "activo"
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
|
||||
// Nuevo: para login/negocio "activo"
|
||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
|
||||
|
||||
// Para poder restaurar, necesitas leer ignorando @Where (native):
|
||||
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
|
||||
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
|
||||
// Para poder restaurar, necesitas leer ignorando @Where (native):
|
||||
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
|
||||
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
|
||||
|
||||
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
|
||||
List<User> findAllDeleted();
|
||||
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
|
||||
List<User> findAllDeleted();
|
||||
|
||||
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
|
||||
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
|
||||
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
|
||||
|
||||
@Query(value = """
|
||||
SELECT DISTINCT u
|
||||
FROM User u
|
||||
JOIN u.rolesLink rl
|
||||
JOIN rl.role r
|
||||
WHERE (:role IS NULL OR r.name = :role)
|
||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||
""", countQuery = """
|
||||
SELECT COUNT(DISTINCT u.id)
|
||||
FROM User u
|
||||
JOIN u.rolesLink rl
|
||||
JOIN rl.role r
|
||||
WHERE (:role IS NULL OR r.name = :role)
|
||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||
""")
|
||||
Page<User> searchUsers(@Param("role") String role,
|
||||
@Param("q") String q,
|
||||
Pageable pageable);
|
||||
@Query(value = """
|
||||
SELECT DISTINCT u
|
||||
FROM User u
|
||||
JOIN u.rolesLink rl
|
||||
JOIN rl.role r
|
||||
WHERE (:role IS NULL OR r.name = :role)
|
||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||
""", countQuery = """
|
||||
SELECT COUNT(DISTINCT u.id)
|
||||
FROM User u
|
||||
JOIN u.rolesLink rl
|
||||
JOIN rl.role r
|
||||
WHERE (:role IS NULL OR r.name = :role)
|
||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||
""")
|
||||
Page<User> searchUsers(@Param("role") String role,
|
||||
@Param("q") String q,
|
||||
Pageable pageable);
|
||||
|
||||
@Query("select u.id from User u where lower(u.fullName) like lower(concat('%', :name, '%'))")
|
||||
List<Long> findIdsByFullNameLike(@Param("name") String name);
|
||||
|
||||
}
|
||||
|
||||
@ -20,6 +20,8 @@ safekat.api.password=Safekat2024
|
||||
|
||||
# Configuración Redsys
|
||||
redsys.environment=test
|
||||
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
|
||||
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
|
||||
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
|
||||
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
|
||||
redsys.urls.notify=http://localhost:8080/pagos/redsys/notify
|
||||
redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify
|
||||
@ -20,6 +20,8 @@ safekat.api.password=Safekat2024
|
||||
|
||||
# Configuración Redsys
|
||||
redsys.environment=test
|
||||
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
|
||||
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
|
||||
redsys.urls.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok
|
||||
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
|
||||
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify
|
||||
@ -0,0 +1,418 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 0007-payments-core
|
||||
author: jjo
|
||||
changes:
|
||||
# 2) payments
|
||||
- createTable:
|
||||
tableName: payments
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: order_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: user_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: currency
|
||||
type: CHAR(3)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: amount_total_cents
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: amount_captured_cents
|
||||
type: BIGINT
|
||||
defaultValueNumeric: 0
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: amount_refunded_cents
|
||||
type: BIGINT
|
||||
defaultValueNumeric: 0
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: status
|
||||
type: "ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed')"
|
||||
defaultValue: "requires_payment_method"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: capture_method
|
||||
type: "ENUM('automatic','manual')"
|
||||
defaultValue: "automatic"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: gateway
|
||||
type: VARCHAR(32)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: gateway_payment_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: gateway_order_id
|
||||
type: VARCHAR(12)
|
||||
- column:
|
||||
name: authorization_code
|
||||
type: VARCHAR(32)
|
||||
- column:
|
||||
name: three_ds_status
|
||||
type: "ENUM('not_applicable','attempted','challenge','succeeded','failed')"
|
||||
defaultValue: "not_applicable"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: descriptor
|
||||
type: VARCHAR(22)
|
||||
- column:
|
||||
name: client_ip
|
||||
type: VARBINARY(16)
|
||||
- column:
|
||||
name: authorized_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: captured_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: canceled_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: failed_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: metadata
|
||||
type: JSON
|
||||
- column:
|
||||
name: created_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: CURRENT_TIMESTAMP
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: updated_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- createIndex:
|
||||
tableName: payments
|
||||
indexName: idx_payments_order
|
||||
columns:
|
||||
- column:
|
||||
name: order_id
|
||||
|
||||
- createIndex:
|
||||
tableName: payments
|
||||
indexName: idx_payments_gateway
|
||||
columns:
|
||||
- column:
|
||||
name: gateway
|
||||
- column:
|
||||
name: gateway_payment_id
|
||||
|
||||
- createIndex:
|
||||
tableName: payments
|
||||
indexName: idx_payments_status
|
||||
columns:
|
||||
- column:
|
||||
name: status
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: payments
|
||||
columnNames: gateway, gateway_order_id
|
||||
constraintName: uq_payments_gateway_order
|
||||
|
||||
# 3) payment_transactions
|
||||
- createTable:
|
||||
tableName: payment_transactions
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: payment_id
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: type
|
||||
type: "ENUM('AUTH','CAPTURE','REFUND','VOID')"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: status
|
||||
type: "ENUM('pending','succeeded','failed')"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: amount_cents
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: currency
|
||||
type: CHAR(3)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: gateway_transaction_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: gateway_response_code
|
||||
type: VARCHAR(64)
|
||||
- column:
|
||||
name: avs_result
|
||||
type: VARCHAR(8)
|
||||
- column:
|
||||
name: cvv_result
|
||||
type: VARCHAR(8)
|
||||
- column:
|
||||
name: three_ds_version
|
||||
type: VARCHAR(16)
|
||||
- column:
|
||||
name: idempotency_key
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: request_payload
|
||||
type: JSON
|
||||
- column:
|
||||
name: response_payload
|
||||
type: JSON
|
||||
- column:
|
||||
name: processed_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: created_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: CURRENT_TIMESTAMP
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: payment_transactions
|
||||
baseColumnNames: payment_id
|
||||
referencedTableName: payments
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_tx_payment
|
||||
onDelete: CASCADE
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: payment_transactions
|
||||
columnNames: gateway_transaction_id
|
||||
constraintName: uq_tx_gateway_txid
|
||||
|
||||
- createIndex:
|
||||
tableName: payment_transactions
|
||||
indexName: idx_tx_pay
|
||||
columns:
|
||||
- column:
|
||||
name: payment_id
|
||||
|
||||
- createIndex:
|
||||
tableName: payment_transactions
|
||||
indexName: idx_tx_type_status
|
||||
columns:
|
||||
- column:
|
||||
name: type
|
||||
- column:
|
||||
name: status
|
||||
|
||||
- createIndex:
|
||||
tableName: payment_transactions
|
||||
indexName: idx_tx_idem
|
||||
columns:
|
||||
- column:
|
||||
name: idempotency_key
|
||||
|
||||
# 4) refunds
|
||||
- createTable:
|
||||
tableName: refunds
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: payment_id
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: transaction_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: amount_cents
|
||||
type: BIGINT
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: reason
|
||||
type: "ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other')"
|
||||
defaultValue: "customer_request"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: status
|
||||
type: "ENUM('pending','succeeded','failed','canceled')"
|
||||
defaultValue: "pending"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: requested_by_user_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: requested_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: CURRENT_TIMESTAMP
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: processed_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: gateway_refund_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: notes
|
||||
type: VARCHAR(500)
|
||||
- column:
|
||||
name: metadata
|
||||
type: JSON
|
||||
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: refunds
|
||||
baseColumnNames: payment_id
|
||||
referencedTableName: payments
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_ref_payment
|
||||
onDelete: CASCADE
|
||||
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: refunds
|
||||
baseColumnNames: transaction_id
|
||||
referencedTableName: payment_transactions
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_ref_tx
|
||||
onDelete: SET NULL
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: refunds
|
||||
columnNames: gateway_refund_id
|
||||
constraintName: uq_refund_gateway_id
|
||||
|
||||
- createIndex:
|
||||
tableName: refunds
|
||||
indexName: idx_ref_pay
|
||||
columns:
|
||||
- column:
|
||||
name: payment_id
|
||||
|
||||
- createIndex:
|
||||
tableName: refunds
|
||||
indexName: idx_ref_status
|
||||
columns:
|
||||
- column:
|
||||
name: status
|
||||
|
||||
# 5) webhook_events
|
||||
- createTable:
|
||||
tableName: webhook_events
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: provider
|
||||
type: VARCHAR(32)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: event_type
|
||||
type: VARCHAR(64)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: event_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: signature
|
||||
type: VARCHAR(512)
|
||||
- column:
|
||||
name: payload
|
||||
type: JSON
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: processed
|
||||
type: TINYINT(1)
|
||||
defaultValueNumeric: 0
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: processed_at
|
||||
type: DATETIME
|
||||
- column:
|
||||
name: attempts
|
||||
type: INT
|
||||
defaultValueNumeric: 0
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: last_error
|
||||
type: VARCHAR(500)
|
||||
- column:
|
||||
name: created_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: CURRENT_TIMESTAMP
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: webhook_events
|
||||
columnNames: provider, event_id
|
||||
constraintName: uq_webhook_provider_event
|
||||
|
||||
- createIndex:
|
||||
tableName: webhook_events
|
||||
indexName: idx_webhook_processed
|
||||
columns:
|
||||
- column:
|
||||
name: processed
|
||||
|
||||
|
||||
rollback:
|
||||
# Se borran las tablas en orden inverso de dependencias
|
||||
|
||||
- dropTable:
|
||||
tableName: webhook_events
|
||||
|
||||
- dropTable:
|
||||
tableName: refunds
|
||||
|
||||
- dropTable:
|
||||
tableName: payment_transactions
|
||||
|
||||
- dropTable:
|
||||
tableName: payments
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 0008-update-cart-status-constraint
|
||||
author: jjo
|
||||
changes:
|
||||
# 1) Eliminar el índice único antiguo (user_id, status)
|
||||
- sql:
|
||||
sql: |
|
||||
ALTER TABLE carts
|
||||
DROP INDEX uq_carts_user_active;
|
||||
|
||||
# 2) Añadir columna generada 'active_flag'
|
||||
# Será 1 si status = 'ACTIVE', y NULL en cualquier otro caso
|
||||
- sql:
|
||||
sql: |
|
||||
ALTER TABLE carts
|
||||
ADD COLUMN active_flag TINYINT(1)
|
||||
GENERATED ALWAYS AS (
|
||||
CASE WHEN status = 'ACTIVE' THEN 1 ELSE NULL END
|
||||
);
|
||||
|
||||
# 3) Crear el nuevo índice único:
|
||||
# solo limita (user_id, active_flag=1),
|
||||
# se permiten muchos registros con active_flag NULL (LOCKED, COMPLETED, etc.)
|
||||
- sql:
|
||||
sql: |
|
||||
CREATE UNIQUE INDEX uq_carts_user_active
|
||||
ON carts (user_id, active_flag);
|
||||
|
||||
rollback:
|
||||
# 🔙 1) Eliminar el índice nuevo basado en active_flag
|
||||
- sql:
|
||||
sql: |
|
||||
ALTER TABLE carts
|
||||
DROP INDEX uq_carts_user_active;
|
||||
|
||||
# 🔙 2) Eliminar la columna generada active_flag
|
||||
- sql:
|
||||
sql: |
|
||||
ALTER TABLE carts
|
||||
DROP COLUMN active_flag;
|
||||
|
||||
# 🔙 3) Restaurar el índice único original (user_id, status)
|
||||
- sql:
|
||||
sql: |
|
||||
CREATE UNIQUE INDEX uq_carts_user_active
|
||||
ON carts (user_id, status);
|
||||
@ -0,0 +1,29 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 0009-drop-unique-refund-gateway-id
|
||||
author: JJO
|
||||
changes:
|
||||
# 1️⃣ Eliminar la UNIQUE constraint sobre gateway_refund_id
|
||||
- dropUniqueConstraint:
|
||||
constraintName: uq_refund_gateway_id
|
||||
tableName: refunds
|
||||
|
||||
# 2️⃣ Crear un índice normal (no único) para acelerar búsquedas por gateway_refund_id
|
||||
- createIndex:
|
||||
tableName: refunds
|
||||
indexName: idx_refunds_gateway_refund_id
|
||||
columns:
|
||||
- column:
|
||||
name: gateway_refund_id
|
||||
|
||||
rollback:
|
||||
# 🔙 1) Eliminar el índice normal creado en este changeSet
|
||||
- dropIndex:
|
||||
indexName: idx_refunds_gateway_refund_id
|
||||
tableName: refunds
|
||||
|
||||
# 🔙 2) Restaurar la UNIQUE constraint original
|
||||
- addUniqueConstraint:
|
||||
tableName: refunds
|
||||
columnNames: gateway_refund_id
|
||||
constraintName: uq_refund_gateway_id
|
||||
@ -0,0 +1,30 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
id: 0010-drop-unique-tx-gateway
|
||||
author: JJO
|
||||
changes:
|
||||
# 1️⃣ Eliminar la UNIQUE constraint sobre (gateway_transaction_id, type)
|
||||
- dropUniqueConstraint:
|
||||
constraintName: uq_tx_gateway_txid_type
|
||||
tableName: payment_transactions
|
||||
|
||||
# 2️⃣ Crear un índice normal (no único) sobre gateway_transaction_id
|
||||
# para poder seguir buscando rápido por este campo
|
||||
- createIndex:
|
||||
tableName: payment_transactions
|
||||
indexName: idx_payment_tx_gateway_txid
|
||||
columns:
|
||||
- column:
|
||||
name: gateway_transaction_id
|
||||
|
||||
rollback:
|
||||
# 🔙 1) Eliminar el índice normal creado en este changeSet
|
||||
- dropIndex:
|
||||
indexName: idx_payment_tx_gateway_txid
|
||||
tableName: payment_transactions
|
||||
|
||||
# 🔙 2) Restaurar la UNIQUE constraint original
|
||||
- addUniqueConstraint:
|
||||
tableName: payment_transactions
|
||||
columnNames: gateway_transaction_id, type
|
||||
constraintName: uq_tx_gateway_txid_type
|
||||
@ -10,4 +10,12 @@ databaseChangeLog:
|
||||
- include:
|
||||
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0006-add-cart-direcciones.yml
|
||||
file: db/changelog/changesets/0006-add-cart-direcciones.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0007-payments-core.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0008-update-cart-status-constraint.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml
|
||||
@ -23,5 +23,6 @@ app.sidebar.configuracion=Configuración
|
||||
app.sidebar.usuarios=Usuarios
|
||||
app.sidebar.direcciones=Mis Direcciones
|
||||
app.sidebar.direcciones-admin=Administrar Direcciones
|
||||
app.sidebar.gestion-pagos=Gestión de Pagos
|
||||
|
||||
app.errors.403=No tienes permiso para acceder a esta página.
|
||||
0
src/main/resources/i18n/pagos_en.properties
Normal file
35
src/main/resources/i18n/pagos_es.properties
Normal file
@ -0,0 +1,35 @@
|
||||
pagos.module-title=Gestión de Pagos
|
||||
|
||||
pagos.tab.movimientos-redsys=Movimientos Redsys
|
||||
pagos.tab.transferencias-bancarias=Transferencias Bancarias
|
||||
|
||||
pagos.table.cliente.nombre=Nombre Cliente
|
||||
pagos.table.redsys.id=Cod. Redsys
|
||||
pagos.table.pedido.id=Pedido
|
||||
pagos.table.cantidad=Cantidad
|
||||
pagos.table.devuelto=Devolución
|
||||
pagos.table.fecha=Fecha
|
||||
pagos.table.estado=Estado
|
||||
pagos.table.acciones=Acciones
|
||||
|
||||
pagos.table.concepto-transferencia=Concepto
|
||||
pagos.table.estado-transferencia=Estado
|
||||
pagos.table.fecha-created=Fecha creación
|
||||
pagos.table.fecha-procesed=Fecha procesada
|
||||
|
||||
pagos.table.estado.pending=Pendiente
|
||||
pagos.table.estado.succeeded=Completada
|
||||
pagos.table.estado.failed=Fallido
|
||||
pagos.table.finalizar=Finalizar
|
||||
|
||||
pagos.transferencia.no-pedido=No disponible
|
||||
pagos.transferencia.finalizar.title=Finalizar Transferencia Bancaria
|
||||
pagos.transferencia.finalizar.text=¿Estás seguro de que deseas marcar esta transferencia bancaria como completada? Esta acción no se puede deshacer.
|
||||
pagos.transferencia.finalizar.success=Transferencia bancaria marcada como completada con éxito.
|
||||
pagos.transferencia.finalizar.error.general=Error al finalizar la transferencia bancaria
|
||||
|
||||
pagos.refund.title=Devolución
|
||||
pagos.refund.text=Introduce la cantidad a devolver (en euros):
|
||||
pagos.refund.success=Devolución solicitada con éxito. Si no se refleja inmediatamente, espere unos minutos y actualiza la página.
|
||||
pagos.refund.error.general=Error al procesar la devolución
|
||||
pagos.refund.error.invalid-number=Cantidad inválida para la devolución
|
||||
@ -1,17 +1,18 @@
|
||||
checkout.title=Finalizar compra
|
||||
checkout.summay=Resumen de la compra
|
||||
checkout.shipping=Envío
|
||||
checkout.summary=Resumen de la compra
|
||||
checkout.billing-address=Dirección de facturación
|
||||
checkout.payment=Método de pago
|
||||
|
||||
checkout.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido.
|
||||
checkout.shipping.order=Envío del pedido
|
||||
checkout.shipping.samples=Envío de pruebas
|
||||
checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección.
|
||||
checkout.billing-address.title=Seleccione una dirección
|
||||
checkout.billing-address.new-address=Nueva dirección
|
||||
checkout.billing-address.select-placeholder=Buscar en direcciones...
|
||||
checkout.billing-address.errors.noAddressSelected=Debe seleccionar una dirección de facturación para el pedido.
|
||||
|
||||
checkout.payment.card=Tarjeta de crédito / débito
|
||||
checkout.payment.bizum=Bizum
|
||||
checkout.payment.bank-transfer=Transferencia bancaria
|
||||
checkout.error.payment=Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.
|
||||
checkout.success.payment=Pago realizado con éxito. Gracias por su compra.
|
||||
|
||||
checkout.summary.presupuesto=#Presupuesto
|
||||
checkout.summary.titulo=Título
|
||||
checkout.summary.base=Base
|
||||
checkout.summary.iva-4=IVA 4%
|
||||
checkout.summary.iva-21=IVA 21%
|
||||
checkout.summary.envio=Envío
|
||||
checkout.make-payment=Realizar el pago
|
||||
checkout.authorization-required=Certifico que tengo los derechos para imprimir los archivos incluidos en mi pedido y me hago responsable en caso de reclamación de los mismos
|
||||
@ -8240,7 +8240,8 @@ a {
|
||||
display: none;
|
||||
}
|
||||
.card-radio .form-check-input:checked + .form-check-label {
|
||||
border-color: #687cfe !important;
|
||||
border-color: #ff7f5d !important;
|
||||
background-color: rgba(255, 127, 93, 0.05);
|
||||
}
|
||||
.card-radio .form-check-input:checked + .form-check-label:before {
|
||||
content: "\eb80";
|
||||
@ -8249,7 +8250,7 @@ a {
|
||||
top: 2px;
|
||||
right: 6px;
|
||||
font-size: 16px;
|
||||
color: #687cfe;
|
||||
color: #ff7f5d;
|
||||
}
|
||||
.card-radio.dark .form-check-input:checked + .form-check-label:before {
|
||||
color: #fff;
|
||||
|
||||
5
src/main/resources/static/assets/css/checkout.css
Normal file
@ -0,0 +1,5 @@
|
||||
.direccion-card {
|
||||
flex: 1 1 350px; /* ancho mínimo 350px, crece si hay espacio */
|
||||
max-width: 350px; /* opcional, para que no se estiren demasiado */
|
||||
min-width: 340px; /* protege el ancho mínimo */
|
||||
}
|
||||
@ -40,6 +40,14 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.swal2-popup .form-switch-custom .form-check-input:checked{
|
||||
border-color: #92b2a7;
|
||||
background-color: #cbcecd;
|
||||
}
|
||||
.swal2-popup .form-switch-custom .form-check-input:checked::before {
|
||||
color: #92b2a7;
|
||||
}
|
||||
|
||||
.form-switch-presupuesto .form-check-input:checked {
|
||||
border-color: #92b2a7;
|
||||
background-color: #cbcecd;
|
||||
@ -47,4 +55,15 @@ body {
|
||||
|
||||
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
|
||||
color: #92b2a7;
|
||||
}
|
||||
|
||||
.alert-fadeout {
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease;
|
||||
animation: fadeout 4s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 470 KiB |
|
Before Width: | Height: | Size: 490 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 180 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 609 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 316 KiB |
|
Before Width: | Height: | Size: 290 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 183 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 89 KiB |
|
Before Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 72 KiB |