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.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
||||||
public class ErpApplication {
|
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;
|
package com.imprimelibros.erp.cart;
|
||||||
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
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.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
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;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface CartRepository extends JpaRepository<Cart, Long> {
|
public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||||
@ -18,5 +21,15 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
|
|||||||
where c.id = :id
|
where c.id = :id
|
||||||
""")
|
""")
|
||||||
Optional<Cart> findByIdFetchAll(@Param("id") Long 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;
|
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. */
|
/** Devuelve el carrito activo o lo crea si no existe. */
|
||||||
@Transactional
|
@Transactional
|
||||||
public Cart getOrCreateActiveCart(Long userId) {
|
public Cart getOrCreateActiveCart(Long userId) {
|
||||||
@ -136,6 +144,14 @@ public class CartService {
|
|||||||
cartRepo.save(cart);
|
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
|
@Transactional
|
||||||
public long countItems(Long userId) {
|
public long countItems(Long userId) {
|
||||||
Cart cart = getOrCreateActiveCart(userId);
|
Cart cart = getOrCreateActiveCart(userId);
|
||||||
@ -291,7 +307,9 @@ public class CartService {
|
|||||||
summary.put("fidelizacion", fidelizacion + "%");
|
summary.put("fidelizacion", fidelizacion + "%");
|
||||||
summary.put("descuento", Utils.formatCurrency(-descuento, locale));
|
summary.put("descuento", Utils.formatCurrency(-descuento, locale));
|
||||||
summary.put("total", Utils.formatCurrency(total, locale));
|
summary.put("total", Utils.formatCurrency(total, locale));
|
||||||
|
summary.put("amountCents", Math.round(total * 100));
|
||||||
summary.put("errorShipmentCost", errorShipementCost);
|
summary.put("errorShipmentCost", errorShipementCost);
|
||||||
|
summary.put("cartId", cart.getId());
|
||||||
|
|
||||||
return summary;
|
return summary;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,17 +6,20 @@ import java.util.Locale;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
import org.springframework.context.MessageSource;
|
import org.springframework.context.MessageSource;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
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.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import com.imprimelibros.erp.common.Utils;
|
import com.imprimelibros.erp.common.Utils;
|
||||||
import com.imprimelibros.erp.i18n.TranslationService;
|
import com.imprimelibros.erp.i18n.TranslationService;
|
||||||
import com.imprimelibros.erp.paises.PaisesService;
|
import com.imprimelibros.erp.paises.PaisesService;
|
||||||
|
import com.imprimelibros.erp.direcciones.Direccion;
|
||||||
import com.imprimelibros.erp.direcciones.DireccionService;
|
import com.imprimelibros.erp.direcciones.DireccionService;
|
||||||
|
import com.imprimelibros.erp.cart.Cart;
|
||||||
import com.imprimelibros.erp.cart.CartService;
|
import com.imprimelibros.erp.cart.CartService;
|
||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
@ -44,23 +47,29 @@ public class CheckoutController {
|
|||||||
List<String> keys = List.of(
|
List<String> keys = List.of(
|
||||||
"app.cancelar",
|
"app.cancelar",
|
||||||
"app.seleccionar",
|
"app.seleccionar",
|
||||||
"checkout.shipping.add.title",
|
|
||||||
"checkout.shipping.select-placeholder",
|
|
||||||
"checkout.shipping.new-address",
|
|
||||||
"app.yes",
|
"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);
|
Map<String, String> translations = translationService.getTranslations(locale, keys);
|
||||||
model.addAttribute("languageBundle", translations);
|
model.addAttribute("languageBundle", translations);
|
||||||
|
|
||||||
var items = this.cartService.listItems(Utils.currentUserId(principal), locale);
|
Long userId = Utils.currentUserId(principal);
|
||||||
for (var item : items) {
|
Cart cart = cartService.getOrCreateActiveCart(userId);
|
||||||
if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) {
|
model.addAttribute("summary", cartService.getCartSummary(cart, locale));
|
||||||
model.addAttribute("hasSample", true);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
model.addAttribute("items", items);
|
|
||||||
return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple)
|
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.math.RoundingMode;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.text.NumberFormat;
|
import java.text.NumberFormat;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -320,4 +322,12 @@ public class Utils {
|
|||||||
resumen.put("servicios", serviciosExtras);
|
resumen.put("servicios", serviciosExtras);
|
||||||
return resumen;
|
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
|
@Configuration
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
private final DataSource dataSource;
|
private final DataSource dataSource;
|
||||||
|
|
||||||
public SecurityConfig(DataSource dataSource) {
|
public SecurityConfig(DataSource dataSource) {
|
||||||
this.dataSource = dataSource;
|
this.dataSource = dataSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Beans base ==========
|
// ========== Beans base ==========
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember-me (tabla persistent_logins)
|
// Remember-me (tabla persistent_logins)
|
||||||
@Bean
|
@Bean
|
||||||
public PersistentTokenRepository persistentTokenRepository() {
|
public PersistentTokenRepository persistentTokenRepository() {
|
||||||
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
|
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
|
||||||
repo.setDataSource(dataSource);
|
repo.setDataSource(dataSource);
|
||||||
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
|
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
|
||||||
// tabla
|
// tabla
|
||||||
return repo;
|
return repo;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider que soporta UsernamePasswordAuthenticationToken
|
// Provider que soporta UsernamePasswordAuthenticationToken
|
||||||
private static RequestMatcher pathStartsWith(String... prefixes) {
|
private static RequestMatcher pathStartsWith(String... prefixes) {
|
||||||
return new RequestMatcher() {
|
return new RequestMatcher() {
|
||||||
@Override
|
@Override
|
||||||
public boolean matches(HttpServletRequest request) {
|
public boolean matches(HttpServletRequest request) {
|
||||||
String uri = request.getRequestURI();
|
String uri = request.getRequestURI();
|
||||||
if (uri == null)
|
if (uri == null)
|
||||||
return false;
|
return false;
|
||||||
for (String p : prefixes) {
|
for (String p : prefixes) {
|
||||||
if (uri.startsWith(p))
|
if (uri.startsWith(p))
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(
|
public SecurityFilterChain securityFilterChain(
|
||||||
HttpSecurity http,
|
HttpSecurity http,
|
||||||
@Value("${security.rememberme.key}") String keyRememberMe,
|
@Value("${security.rememberme.key}") String keyRememberMe,
|
||||||
UserDetailsService userDetailsService,
|
UserDetailsService userDetailsService,
|
||||||
PersistentTokenRepository tokenRepo,
|
PersistentTokenRepository tokenRepo,
|
||||||
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
|
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
|
||||||
|
|
||||||
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
|
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
|
||||||
provider.setPasswordEncoder(passwordEncoder);
|
provider.setPasswordEncoder(passwordEncoder);
|
||||||
http.authenticationProvider(provider);
|
http.authenticationProvider(provider);
|
||||||
http
|
http
|
||||||
.authenticationProvider(provider)
|
.authenticationProvider(provider)
|
||||||
|
|
||||||
.sessionManagement(session -> session
|
.sessionManagement(session -> session
|
||||||
//.invalidSessionUrl("/login?expired")
|
// .invalidSessionUrl("/login?expired")
|
||||||
.maximumSessions(1))
|
.maximumSessions(1))
|
||||||
|
|
||||||
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
|
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
|
||||||
.csrf(csrf -> csrf
|
.csrf(csrf -> csrf
|
||||||
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/")))
|
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"),
|
||||||
// ====== RequestCache: sólo navegaciones HTML reales ======
|
pathStartsWith("/pagos/redsys/")))
|
||||||
.requestCache(rc -> {
|
// ====== RequestCache: sólo navegaciones HTML reales ======
|
||||||
HttpSessionRequestCache cache = new HttpSessionRequestCache();
|
.requestCache(rc -> {
|
||||||
|
HttpSessionRequestCache cache = new HttpSessionRequestCache();
|
||||||
|
|
||||||
// Navegación HTML (por tipo o por cabecera Accept)
|
// Navegación HTML (por tipo o por cabecera Accept)
|
||||||
RequestMatcher htmlPage = new OrRequestMatcher(
|
RequestMatcher htmlPage = new OrRequestMatcher(
|
||||||
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
|
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
|
||||||
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
|
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
|
||||||
new RequestHeaderRequestMatcher("Accept", "text/html"));
|
new RequestHeaderRequestMatcher("Accept", "text/html"));
|
||||||
|
|
||||||
// No AJAX
|
// No AJAX
|
||||||
RequestMatcher nonAjax = new NegatedRequestMatcher(
|
RequestMatcher nonAjax = new NegatedRequestMatcher(
|
||||||
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
|
new RequestHeaderRequestMatcher("X-Requested-With",
|
||||||
|
"XMLHttpRequest"));
|
||||||
|
|
||||||
// Excluir sondas .well-known
|
// Excluir sondas .well-known
|
||||||
RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/"));
|
RequestMatcher notWellKnown = new NegatedRequestMatcher(
|
||||||
|
pathStartsWith("/.well-known/"));
|
||||||
|
|
||||||
// Excluir estáticos: comunes + tu /assets/**
|
// Excluir estáticos: comunes + tu /assets/**
|
||||||
RequestMatcher notStatic = new AndRequestMatcher(
|
RequestMatcher notStatic = new AndRequestMatcher(
|
||||||
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
|
new NegatedRequestMatcher(PathRequest.toStaticResources()
|
||||||
new NegatedRequestMatcher(pathStartsWith("/assets/")));
|
.atCommonLocations()),
|
||||||
|
new NegatedRequestMatcher(pathStartsWith("/assets/")));
|
||||||
RequestMatcher cartCount = new AndRequestMatcher(
|
|
||||||
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
|
|
||||||
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
|
|
||||||
|
|
||||||
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount));
|
RequestMatcher cartCount = new AndRequestMatcher(
|
||||||
rc.requestCache(cache);
|
new NegatedRequestMatcher(PathRequest.toStaticResources()
|
||||||
})
|
.atCommonLocations()),
|
||||||
// ========================================================
|
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
|
||||||
|
|
||||||
.authorizeHttpRequests(auth -> auth
|
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic,
|
||||||
// Aquí usa patrones String (no deprecados)
|
notWellKnown, cartCount));
|
||||||
.requestMatchers(
|
rc.requestCache(cache);
|
||||||
"/",
|
})
|
||||||
"/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())
|
|
||||||
|
|
||||||
.formLogin(login -> login
|
.authorizeHttpRequests(auth -> auth
|
||||||
.loginPage("/login").permitAll()
|
// Aquí usa patrones String (no deprecados)
|
||||||
.loginProcessingUrl("/login")
|
.requestMatchers(
|
||||||
.usernameParameter("username")
|
"/",
|
||||||
.passwordParameter("password")
|
"/login",
|
||||||
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
|
"/signup",
|
||||||
.failureUrl("/login?error"))
|
"/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
|
.formLogin(login -> login
|
||||||
.key(keyRememberMe)
|
.loginPage("/login").permitAll()
|
||||||
.rememberMeParameter("remember-me")
|
.loginProcessingUrl("/login")
|
||||||
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
|
.usernameParameter("username")
|
||||||
.tokenValiditySeconds(60 * 60 * 24 * 2)
|
.passwordParameter("password")
|
||||||
.userDetailsService(userDetailsService)
|
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
|
||||||
.tokenRepository(tokenRepo))
|
.failureUrl("/login?error"))
|
||||||
|
|
||||||
.logout(logout -> logout
|
.rememberMe(rm -> rm
|
||||||
.logoutUrl("/logout")
|
.key(keyRememberMe)
|
||||||
.logoutSuccessUrl("/")
|
.rememberMeParameter("remember-me")
|
||||||
.invalidateHttpSession(true)
|
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
|
||||||
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
|
.tokenValiditySeconds(60 * 60 * 24 * 2)
|
||||||
.permitAll());
|
.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) {
|
private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) {
|
||||||
if (auth == null) {
|
if (auth == null) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@ -38,6 +38,10 @@ public interface DireccionRepository
|
|||||||
// find by user_id
|
// find by user_id
|
||||||
List<Direccion> findByUserId(Long userId);
|
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
|
// find by user_id with deleted
|
||||||
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
|
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
|
||||||
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);
|
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) {
|
public Optional<Direccion> findById(Long id) {
|
||||||
return repo.findById(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;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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
|
@Controller
|
||||||
@RequestMapping("/pagos/redsys")
|
@RequestMapping("/pagos/redsys")
|
||||||
public class RedsysController {
|
public class RedsysController {
|
||||||
|
|
||||||
private final RedsysService service;
|
private final PaymentService paymentService;
|
||||||
|
private final MessageSource messageSource;
|
||||||
|
|
||||||
public RedsysController(RedsysService service) {
|
public RedsysController(PaymentService paymentService, MessageSource messageSource) {
|
||||||
this.service = service;
|
this.paymentService = paymentService;
|
||||||
|
this.messageSource = messageSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/crear")
|
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
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")
|
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public ResponseEntity<String> notifyRedsys(
|
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
|
||||||
@RequestParam("Ds_Signature") String dsSignature,
|
@RequestParam("method") String method, @RequestParam("cartId") Long cartId) throws Exception {
|
||||||
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) {
|
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature,
|
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
|
||||||
dsMerchantParameters);
|
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||||
|
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
|
||||||
// 1) Idempotencia: comprueba si el pedido ya fue procesado
|
|
||||||
// 2) Valida que importe/moneda/pedido coincidan con lo que esperabas
|
|
||||||
// 3) Marca como pagado si notif.authorized() == true
|
|
||||||
|
|
||||||
return ResponseEntity.ok("OK"); // Redsys espera "OK"
|
|
||||||
} catch (SecurityException se) {
|
|
||||||
// Firma incorrecta: NO procesar
|
|
||||||
return ResponseEntity.status(400).body("BAD SIGNATURE");
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body("ERROR");
|
return ResponseEntity.badRequest()
|
||||||
|
.body("<h2>Error validando pago</h2><pre>" + e.getMessage() + "</pre>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/ok")
|
@GetMapping("/ko")
|
||||||
public String okReturn(@RequestParam("Ds_Signature") String dsSignature,
|
public String koGet(RedirectAttributes redirectAttrs, Model model, Locale locale) {
|
||||||
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters,
|
|
||||||
Model model) {
|
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 {
|
try {
|
||||||
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters);
|
// Procesamos la notificación IGUAL que en /ok y /notify
|
||||||
// Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como
|
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||||
// pagado
|
|
||||||
model.addAttribute("authorized", notif.authorized());
|
// Mensaje para el usuario (pago cancelado/rechazado)
|
||||||
//model.addAttribute("order", notif.order());
|
String html = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
|
||||||
//model.addAttribute("amountCents", notif.amountCents());
|
return ResponseEntity.ok(html);
|
||||||
return "imprimelibros/payments/redsys-ok";
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
model.addAttribute("error", "No se pudo validar la respuesta de Redsys.");
|
// Si algo falla al validar/procesar, lo mostramos (útil en entorno de pruebas)
|
||||||
return "imprimelibros/payments/redsys-ko";
|
String html = "<h2>Error procesando notificación KO</h2><pre>" + e.getMessage() + "</pre>";
|
||||||
|
return ResponseEntity.badRequest().body(html);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/ko")
|
@PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature,
|
@ResponseBody
|
||||||
@RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters,
|
public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
|
||||||
Model model) {
|
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||||
// Suele venir cuando el usuario cancela o hay error
|
try {
|
||||||
model.addAttribute("error", "Operación cancelada o rechazada.");
|
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||||
return "imprimelibros/payments/redsys-ko";
|
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;
|
package com.imprimelibros.erp.redsys;
|
||||||
|
|
||||||
|
import org.json.JSONObject;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import sis.redsys.api.ApiMacSha256;
|
import sis.redsys.api.ApiMacSha256;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
@ -18,6 +22,10 @@ import java.util.Objects;
|
|||||||
public class RedsysService {
|
public class RedsysService {
|
||||||
|
|
||||||
// ---------- CONFIG ----------
|
// ---------- CONFIG ----------
|
||||||
|
@Value("${redsys.url}")
|
||||||
|
private String url;
|
||||||
|
@Value("${redsys.refund.url}")
|
||||||
|
private String urlRefund;
|
||||||
@Value("${redsys.merchant-code}")
|
@Value("${redsys.merchant-code}")
|
||||||
private String merchantCode;
|
private String merchantCode;
|
||||||
@Value("${redsys.terminal}")
|
@Value("${redsys.terminal}")
|
||||||
@ -37,19 +45,33 @@ public class RedsysService {
|
|||||||
@Value("${redsys.environment}")
|
@Value("${redsys.environment}")
|
||||||
private String env;
|
private String env;
|
||||||
|
|
||||||
|
private final HttpClient httpClient = HttpClient.newHttpClient();
|
||||||
|
|
||||||
// ---------- RECORDS ----------
|
// ---------- 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) {
|
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 {
|
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();
|
ApiMacSha256 api = new ApiMacSha256();
|
||||||
|
|
||||||
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
|
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_MERCHANTCODE", merchantCode);
|
||||||
api.setParameter("DS_MERCHANT_CURRENCY", currency);
|
api.setParameter("DS_MERCHANT_CURRENCY", currency);
|
||||||
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
|
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
|
||||||
@ -58,12 +80,30 @@ public class RedsysService {
|
|||||||
api.setParameter("DS_MERCHANT_URLOK", urlOk);
|
api.setParameter("DS_MERCHANT_URLOK", urlOk);
|
||||||
api.setParameter("DS_MERCHANT_URLKO", urlKo);
|
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 merchantParameters = api.createMerchantParameters();
|
||||||
String signature = api.createMerchantSignature(secretKeyBase64);
|
String signature = api.createMerchantSignature(secretKeyBase64);
|
||||||
|
|
||||||
String action = "test".equalsIgnoreCase(env)
|
String action = url;
|
||||||
? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
/*
|
||||||
: "https://sis.redsys.es/sis/realizarPago";
|
* ? "https://sis-t.redsys.es:25443/sis/realizarPago"
|
||||||
|
* : "https://sis.redsys.es/sis/realizarPago";
|
||||||
|
*/
|
||||||
|
|
||||||
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
|
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
|
||||||
}
|
}
|
||||||
@ -84,27 +124,40 @@ public class RedsysService {
|
|||||||
|
|
||||||
// ---------- STEP 4: Validar notificación ----------
|
// ---------- STEP 4: Validar notificación ----------
|
||||||
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
|
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
|
|
||||||
RedsysNotification notif = new RedsysNotification(mp);
|
|
||||||
|
|
||||||
if (notif.order == null || notif.order.isBlank()) {
|
ApiMacSha256 api = new ApiMacSha256();
|
||||||
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
|
|
||||||
|
// 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 ----------
|
// ---------- HELPERS ----------
|
||||||
private static boolean safeEqualsB64(String a, String b) {
|
private static boolean safeEqualsB64(String a, String b) {
|
||||||
if (Objects.equals(a, b))
|
if (Objects.equals(a, b))
|
||||||
@ -141,6 +194,7 @@ public class RedsysService {
|
|||||||
public final String response;
|
public final String response;
|
||||||
public final long amountCents;
|
public final long amountCents;
|
||||||
public final String currency;
|
public final String currency;
|
||||||
|
public final Long cartId;
|
||||||
|
|
||||||
public RedsysNotification(Map<String, Object> raw) {
|
public RedsysNotification(Map<String, Object> raw) {
|
||||||
this.raw = raw;
|
this.raw = raw;
|
||||||
@ -148,6 +202,24 @@ public class RedsysService {
|
|||||||
this.response = str(raw.get("Ds_Response"));
|
this.response = str(raw.get("Ds_Response"));
|
||||||
this.currency = str(raw.get("Ds_Currency"));
|
this.currency = str(raw.get("Ds_Currency"));
|
||||||
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
|
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() {
|
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
|
@Repository
|
||||||
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
|
||||||
|
|
||||||
// Aplicamos EntityGraph a la versión con Specification+Pageable
|
// Aplicamos EntityGraph a la versión con Specification+Pageable
|
||||||
@Override
|
@Override
|
||||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||||
@NonNull
|
@NonNull
|
||||||
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
|
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
|
// Para comprobar si existe al hacer signup
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT id, deleted, enabled
|
SELECT id, deleted, enabled
|
||||||
FROM users
|
FROM users
|
||||||
WHERE LOWER(username) = LOWER(:userName)
|
WHERE LOWER(username) = LOWER(:userName)
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""", nativeQuery = true)
|
""", nativeQuery = true)
|
||||||
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
|
||||||
|
|
||||||
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
|
||||||
|
|
||||||
// Nuevo: para login/negocio "activo"
|
// Nuevo: para login/negocio "activo"
|
||||||
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
|
||||||
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
|
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
|
||||||
|
|
||||||
// Para poder restaurar, necesitas leer ignorando @Where (native):
|
// Para poder restaurar, necesitas leer ignorando @Where (native):
|
||||||
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
|
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
|
||||||
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
|
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
|
||||||
|
|
||||||
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
|
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
|
||||||
List<User> findAllDeleted();
|
List<User> findAllDeleted();
|
||||||
|
|
||||||
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
|
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
|
||||||
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
|
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
|
||||||
|
|
||||||
@Query(value = """
|
@Query(value = """
|
||||||
SELECT DISTINCT u
|
SELECT DISTINCT u
|
||||||
FROM User u
|
FROM User u
|
||||||
JOIN u.rolesLink rl
|
JOIN u.rolesLink rl
|
||||||
JOIN rl.role r
|
JOIN rl.role r
|
||||||
WHERE (:role IS NULL OR r.name = :role)
|
WHERE (:role IS NULL OR r.name = :role)
|
||||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||||
""", countQuery = """
|
""", countQuery = """
|
||||||
SELECT COUNT(DISTINCT u.id)
|
SELECT COUNT(DISTINCT u.id)
|
||||||
FROM User u
|
FROM User u
|
||||||
JOIN u.rolesLink rl
|
JOIN u.rolesLink rl
|
||||||
JOIN rl.role r
|
JOIN rl.role r
|
||||||
WHERE (:role IS NULL OR r.name = :role)
|
WHERE (:role IS NULL OR r.name = :role)
|
||||||
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
|
||||||
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
|
||||||
""")
|
""")
|
||||||
Page<User> searchUsers(@Param("role") String role,
|
Page<User> searchUsers(@Param("role") String role,
|
||||||
@Param("q") String q,
|
@Param("q") String q,
|
||||||
Pageable pageable);
|
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
|
# Configuración Redsys
|
||||||
redsys.environment=test
|
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.ok=http://localhost:8080/pagos/redsys/ok
|
||||||
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
|
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
|
# Configuración Redsys
|
||||||
redsys.environment=test
|
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.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok
|
||||||
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
|
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
|
||||||
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify
|
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:
|
- include:
|
||||||
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
|
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
|
||||||
- include:
|
- 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.usuarios=Usuarios
|
||||||
app.sidebar.direcciones=Mis Direcciones
|
app.sidebar.direcciones=Mis Direcciones
|
||||||
app.sidebar.direcciones-admin=Administrar 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.
|
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.title=Finalizar compra
|
||||||
checkout.summay=Resumen de la compra
|
checkout.summary=Resumen de la compra
|
||||||
checkout.shipping=Envío
|
checkout.billing-address=Dirección de facturación
|
||||||
checkout.payment=Método de pago
|
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.billing-address.title=Seleccione una dirección
|
||||||
checkout.shipping.order=Envío del pedido
|
checkout.billing-address.new-address=Nueva dirección
|
||||||
checkout.shipping.samples=Envío de pruebas
|
checkout.billing-address.select-placeholder=Buscar en direcciones...
|
||||||
checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección.
|
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.make-payment=Realizar el pago
|
||||||
checkout.summary.titulo=Título
|
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
|
||||||
checkout.summary.base=Base
|
|
||||||
checkout.summary.iva-4=IVA 4%
|
|
||||||
checkout.summary.iva-21=IVA 21%
|
|
||||||
checkout.summary.envio=Envío
|
|
||||||
@ -8240,7 +8240,8 @@ a {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
.card-radio .form-check-input:checked + .form-check-label {
|
.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 {
|
.card-radio .form-check-input:checked + .form-check-label:before {
|
||||||
content: "\eb80";
|
content: "\eb80";
|
||||||
@ -8249,7 +8250,7 @@ a {
|
|||||||
top: 2px;
|
top: 2px;
|
||||||
right: 6px;
|
right: 6px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #687cfe;
|
color: #ff7f5d;
|
||||||
}
|
}
|
||||||
.card-radio.dark .form-check-input:checked + .form-check-label:before {
|
.card-radio.dark .form-check-input:checked + .form-check-label:before {
|
||||||
color: #fff;
|
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;
|
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 {
|
.form-switch-presupuesto .form-check-input:checked {
|
||||||
border-color: #92b2a7;
|
border-color: #92b2a7;
|
||||||
background-color: #cbcecd;
|
background-color: #cbcecd;
|
||||||
@ -47,4 +55,15 @@ body {
|
|||||||
|
|
||||||
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
|
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
|
||||||
color: #92b2a7;
|
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 |