Compare commits

...

14 Commits

458 changed files with 3545 additions and 161228 deletions

View File

@ -3,8 +3,10 @@ package com.imprimelibros.erp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
public class ErpApplication {

View File

@ -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);
}
}

View File

@ -1,9 +1,12 @@
package com.imprimelibros.erp.cart;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
public interface CartRepository extends JpaRepository<Cart, Long> {
@ -18,5 +21,15 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
where c.id = :id
""")
Optional<Cart> findByIdFetchAll(@Param("id") Long id);
@Modifying
@Transactional
@Query("""
UPDATE Cart c
SET c.status = 'ABANDONED'
WHERE c.status = 'ACTIVE'
AND c.updatedAt < :limite
""")
int markOldCartsAsAbandoned(LocalDateTime limite);
}

View File

@ -53,6 +53,14 @@ public class CartService {
this.pedidoService = pedidoService;
}
public Cart findById(Long cartId) {
return cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
}
/** Devuelve el carrito activo o lo crea si no existe. */
@Transactional
public Cart getOrCreateActiveCart(Long userId) {
@ -136,6 +144,14 @@ public class CartService {
cartRepo.save(cart);
}
@Transactional
public void lockCartById(Long cartId) {
Cart cart = cartRepo.findById(cartId)
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
cart.setStatus(Cart.Status.LOCKED);
cartRepo.save(cart);
}
@Transactional
public long countItems(Long userId) {
Cart cart = getOrCreateActiveCart(userId);
@ -291,7 +307,9 @@ public class CartService {
summary.put("fidelizacion", fidelizacion + "%");
summary.put("descuento", Utils.formatCurrency(-descuento, locale));
summary.put("total", Utils.formatCurrency(total, locale));
summary.put("amountCents", Math.round(total * 100));
summary.put("errorShipmentCost", errorShipementCost);
summary.put("cartId", cart.getId());
return summary;
}

View File

@ -6,17 +6,20 @@ import java.util.Locale;
import java.util.Map;
import org.springframework.context.MessageSource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.server.ResponseStatusException;
import com.imprimelibros.erp.common.Utils;
import com.imprimelibros.erp.i18n.TranslationService;
import com.imprimelibros.erp.paises.PaisesService;
import com.imprimelibros.erp.direcciones.Direccion;
import com.imprimelibros.erp.direcciones.DireccionService;
import com.imprimelibros.erp.cart.Cart;
import com.imprimelibros.erp.cart.CartService;
@Controller
@ -44,23 +47,29 @@ public class CheckoutController {
List<String> keys = List.of(
"app.cancelar",
"app.seleccionar",
"checkout.shipping.add.title",
"checkout.shipping.select-placeholder",
"checkout.shipping.new-address",
"app.yes",
"app.cancelar");
"checkout.billing-address.title",
"checkout.billing-address.new-address",
"checkout.billing-address.select-placeholder",
"checkout.billing-address.errors.noAddressSelected");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
var items = this.cartService.listItems(Utils.currentUserId(principal), locale);
for (var item : items) {
if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) {
model.addAttribute("hasSample", true);
break;
}
}
model.addAttribute("items", items);
Long userId = Utils.currentUserId(principal);
Cart cart = cartService.getOrCreateActiveCart(userId);
model.addAttribute("summary", cartService.getCartSummary(cart, locale));
return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple)
}
@GetMapping("/get-address/{id}")
public String getDireccionCard(@PathVariable Long id, Model model, Locale locale) {
Direccion dir = direccionService.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null,
dir.getPais().getKeyword(), locale));
model.addAttribute("direccion", dir);
return "imprimelibros/direcciones/direccionBillingCard :: direccionBillingCard(direccion=${direccion}, pais=${pais})";
}
}

View File

@ -4,6 +4,8 @@ import java.math.BigDecimal;
import java.math.RoundingMode;
import java.security.Principal;
import java.text.NumberFormat;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@ -320,4 +322,12 @@ public class Utils {
resumen.put("servicios", serviciosExtras);
return resumen;
}
public static String formatDateTime(LocalDateTime dateTime, Locale locale) {
if (dateTime == null) {
return "";
}
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy HH:mm", locale);
return dateTime.format(formatter);
}
}

View File

@ -30,143 +30,151 @@ import jakarta.servlet.http.HttpServletRequest;
@Configuration
public class SecurityConfig {
private final DataSource dataSource;
private final DataSource dataSource;
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
public SecurityConfig(DataSource dataSource) {
this.dataSource = dataSource;
}
// ========== Beans base ==========
// ========== Beans base ==========
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
// Remember-me (tabla persistent_logins)
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
// tabla
return repo;
}
// Remember-me (tabla persistent_logins)
@Bean
public PersistentTokenRepository persistentTokenRepository() {
JdbcTokenRepositoryImpl repo = new JdbcTokenRepositoryImpl();
repo.setDataSource(dataSource);
// repo.setCreateTableOnStartup(true); // solo 1ª vez si necesitas crear la
// tabla
return repo;
}
// Provider que soporta UsernamePasswordAuthenticationToken
private static RequestMatcher pathStartsWith(String... prefixes) {
return new RequestMatcher() {
@Override
public boolean matches(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null)
return false;
for (String p : prefixes) {
if (uri.startsWith(p))
return true;
}
return false;
}
};
}
// Provider que soporta UsernamePasswordAuthenticationToken
private static RequestMatcher pathStartsWith(String... prefixes) {
return new RequestMatcher() {
@Override
public boolean matches(HttpServletRequest request) {
String uri = request.getRequestURI();
if (uri == null)
return false;
for (String p : prefixes) {
if (uri.startsWith(p))
return true;
}
return false;
}
};
}
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
@Value("${security.rememberme.key}") String keyRememberMe,
UserDetailsService userDetailsService,
PersistentTokenRepository tokenRepo,
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
@Value("${security.rememberme.key}") String keyRememberMe,
UserDetailsService userDetailsService,
PersistentTokenRepository tokenRepo,
PasswordEncoder passwordEncoder, UserServiceImpl userServiceImpl) throws Exception {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
provider.setPasswordEncoder(passwordEncoder);
http.authenticationProvider(provider);
http
.authenticationProvider(provider)
DaoAuthenticationProvider provider = new DaoAuthenticationProvider(userServiceImpl);
provider.setPasswordEncoder(passwordEncoder);
http.authenticationProvider(provider);
http
.authenticationProvider(provider)
.sessionManagement(session -> session
//.invalidSessionUrl("/login?expired")
.maximumSessions(1))
.sessionManagement(session -> session
// .invalidSessionUrl("/login?expired")
.maximumSessions(1))
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
.csrf(csrf -> csrf
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/")))
// ====== RequestCache: sólo navegaciones HTML reales ======
.requestCache(rc -> {
HttpSessionRequestCache cache = new HttpSessionRequestCache();
// Ignora CSRF para tu recurso público (sin Ant/Mvc matchers)
.csrf(csrf -> csrf
.ignoringRequestMatchers(pathStartsWith("/presupuesto/public/"),
pathStartsWith("/pagos/redsys/")))
// ====== RequestCache: sólo navegaciones HTML reales ======
.requestCache(rc -> {
HttpSessionRequestCache cache = new HttpSessionRequestCache();
// Navegación HTML (por tipo o por cabecera Accept)
RequestMatcher htmlPage = new OrRequestMatcher(
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
new RequestHeaderRequestMatcher("Accept", "text/html"));
// Navegación HTML (por tipo o por cabecera Accept)
RequestMatcher htmlPage = new OrRequestMatcher(
new MediaTypeRequestMatcher(MediaType.TEXT_HTML),
new MediaTypeRequestMatcher(MediaType.APPLICATION_XHTML_XML),
new RequestHeaderRequestMatcher("Accept", "text/html"));
// No AJAX
RequestMatcher nonAjax = new NegatedRequestMatcher(
new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
// No AJAX
RequestMatcher nonAjax = new NegatedRequestMatcher(
new RequestHeaderRequestMatcher("X-Requested-With",
"XMLHttpRequest"));
// Excluir sondas .well-known
RequestMatcher notWellKnown = new NegatedRequestMatcher(pathStartsWith("/.well-known/"));
// Excluir sondas .well-known
RequestMatcher notWellKnown = new NegatedRequestMatcher(
pathStartsWith("/.well-known/"));
// Excluir estáticos: comunes + tu /assets/**
RequestMatcher notStatic = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/assets/")));
RequestMatcher cartCount = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources().atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
// Excluir estáticos: comunes + tu /assets/**
RequestMatcher notStatic = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources()
.atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/assets/")));
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic, notWellKnown, cartCount));
rc.requestCache(cache);
})
// ========================================================
RequestMatcher cartCount = new AndRequestMatcher(
new NegatedRequestMatcher(PathRequest.toStaticResources()
.atCommonLocations()),
new NegatedRequestMatcher(pathStartsWith("/cart/count")));
.authorizeHttpRequests(auth -> auth
// Aquí usa patrones String (no deprecados)
.requestMatchers(
"/",
"/login",
"/signup",
"/verify",
"/auth/password/**",
"/assets/**",
"/css/**",
"/js/**",
"/images/**",
"/public/**",
"/presupuesto/public/**",
"/error",
"/favicon.ico",
"/.well-known/**", // opcional
"/api/pdf/presupuesto/**"
).permitAll()
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())
cache.setRequestMatcher(new AndRequestMatcher(htmlPage, nonAjax, notStatic,
notWellKnown, cartCount));
rc.requestCache(cache);
})
// ========================================================
.formLogin(login -> login
.loginPage("/login").permitAll()
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
.failureUrl("/login?error"))
.authorizeHttpRequests(auth -> auth
// Aquí usa patrones String (no deprecados)
.requestMatchers(
"/",
"/login",
"/signup",
"/verify",
"/auth/password/**",
"/assets/**",
"/css/**",
"/js/**",
"/images/**",
"/public/**",
"/presupuesto/public/**",
"/error",
"/favicon.ico",
"/.well-known/**", // opcional
"/api/pdf/presupuesto/**",
"/pagos/redsys/**"
)
.permitAll()
.requestMatchers("/users/**").hasAnyRole("SUPERADMIN", "ADMIN")
.anyRequest().authenticated())
.rememberMe(rm -> rm
.key(keyRememberMe)
.rememberMeParameter("remember-me")
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
.tokenValiditySeconds(60 * 60 * 24 * 2)
.userDetailsService(userDetailsService)
.tokenRepository(tokenRepo))
.formLogin(login -> login
.loginPage("/login").permitAll()
.loginProcessingUrl("/login")
.usernameParameter("username")
.passwordParameter("password")
.defaultSuccessUrl("/", false) // respeta SavedRequest (ya filtrada)
.failureUrl("/login?error"))
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
.permitAll());
.rememberMe(rm -> rm
.key(keyRememberMe)
.rememberMeParameter("remember-me")
.rememberMeCookieName("IMPRIMELIBROS_REMEMBER")
.tokenValiditySeconds(60 * 60 * 24 * 2)
.userDetailsService(userDetailsService)
.tokenRepository(tokenRepo))
return http.build();
}
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.deleteCookies("JSESSIONID", "IMPRIMELIBROS_REMEMBER")
.permitAll());
return http.build();
}
}

View File

@ -506,6 +506,29 @@ public class DireccionController {
}
@GetMapping(value = "/facturacion/select2", produces = "application/json")
@ResponseBody
public Map<String, Object> getSelect2Facturacion(
@RequestParam(value = "q", required = false) String q1,
@RequestParam(value = "term", required = false) String q2,
Authentication auth) {
boolean isAdmin = auth.getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN"));
Long currentUserId = null;
if (!isAdmin) {
if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) {
currentUserId = udi.getId();
} else if (auth != null) {
currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null);
}
}
return direccionService.getForSelectFacturacion(q1, q2, isAdmin ? null : currentUserId);
}
private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) {
if (auth == null) {
return false;

View File

@ -38,6 +38,10 @@ public interface DireccionRepository
// find by user_id
List<Direccion> findByUserId(Long userId);
// find by user_id and direccion_facturacion = true
@Query("SELECT d FROM Direccion d WHERE (:userId IS NULL OR d.user.id = :userId) AND d.direccionFacturacion = true")
List<Direccion> findByUserIdAndDireccionFacturacion(@Param("userId") Long userId);
// find by user_id with deleted
@Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true)
List<Direccion> findByUserIdWithDeleted(@Param("userId") Long userId);

View File

@ -77,6 +77,65 @@ public class DireccionService {
}
}
public Map<String, Object> getForSelectFacturacion(String q1, String q2, Long userId) {
try {
// Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config)
String search = Optional.ofNullable(q1).orElse(q2);
if (search != null) {
search = search.trim();
}
final String q = (search == null || search.isEmpty())
? null
: search.toLowerCase();
List<Direccion> all = repo.findByUserIdAndDireccionFacturacion(userId);
// Mapear a opciones id/text con i18n y filtrar por búsqueda si llega
List<Map<String, String>> options = all.stream()
.map(cc -> {
String id = cc.getId().toString();
String alias = cc.getAlias();
String direccion = cc.getDireccion();
String cp = String.valueOf(cc.getCp());
String ciudad = cc.getCiudad();
String att = cc.getAtt();
Map<String, String> m = new HashMap<>();
m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3)
m.put("text", alias); // texto mostrado, i18n con fallback a keyword
m.put("cp", cp);
m.put("ciudad", ciudad);
m.put("att", att);
m.put("alias", alias);
m.put("direccion", direccion);
return m;
})
.filter(opt -> {
if (q == null || q.isEmpty())
return true;
String cp = opt.get("cp");
String ciudad = opt.get("ciudad").toLowerCase();
String att = opt.get("att").toLowerCase();
String alias = opt.get("alias").toLowerCase();
String text = opt.get("text").toLowerCase();
String direccion = opt.get("direccion").toLowerCase();
return text.contains(q) || cp.contains(q) || ciudad.contains(q) || att.contains(q)
|| alias.contains(q) || direccion.contains(q);
})
.sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance()))
.collect(Collectors.toList());
// Estructura Select2
Map<String, Object> resp = new HashMap<>();
resp.put("results", options);
return resp;
} catch (Exception e) {
e.printStackTrace();
return Map.of("results", List.of());
}
}
public Optional<Direccion> findById(Long id) {
return repo.findById(id);
}

View File

@ -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);
}
}
}

View 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: 099 → 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;
}
}

View File

@ -0,0 +1,5 @@
package com.imprimelibros.erp.payments.model;
public enum CaptureMethod { automatic, manual }

View 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();
}
}

View File

@ -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
}

View File

@ -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;
}
}
}

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentTransactionStatus { pending, succeeded, failed }

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID }

View File

@ -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; }
}

View File

@ -0,0 +1,6 @@
package com.imprimelibros.erp.payments.model;
public enum RefundReason {
customer_request, partial_return, pricing_adjustment, duplicate, fraud, other
}

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum RefundStatus { pending, succeeded, failed, canceled }

View File

@ -0,0 +1,4 @@
package com.imprimelibros.erp.payments.model;
public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed }

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -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
);
}

View File

@ -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);
}

View File

@ -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);
}

View File

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

View File

@ -1,13 +1,17 @@
package com.imprimelibros.erp.redsys;
import org.json.JSONObject;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import sis.redsys.api.ApiMacSha256;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
@ -18,6 +22,10 @@ import java.util.Objects;
public class RedsysService {
// ---------- CONFIG ----------
@Value("${redsys.url}")
private String url;
@Value("${redsys.refund.url}")
private String urlRefund;
@Value("${redsys.merchant-code}")
private String merchantCode;
@Value("${redsys.terminal}")
@ -37,19 +45,33 @@ public class RedsysService {
@Value("${redsys.environment}")
private String env;
private final HttpClient httpClient = HttpClient.newHttpClient();
// ---------- RECORDS ----------
public record PaymentRequest(String order, long amountCents, String description) {
// Pedido a Redsys
public record PaymentRequest(String order, long amountCents, String description, Long cartId) {
}
// Payload para el formulario
public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) {
}
// ---------- MÉTODO PRINCIPAL ----------
// ---------- MÉTODO PRINCIPAL (TARJETA) ----------
public FormPayload buildRedirectForm(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, false); // false = tarjeta (sin PAYMETHODS)
}
// ---------- NUEVO: MÉTODO PARA BIZUM ----------
public FormPayload buildRedirectFormBizum(PaymentRequest req) throws Exception {
return buildRedirectFormInternal(req, true); // true = Bizum (PAYMETHODS = z)
}
// ---------- LÓGICA COMÚN ----------
private FormPayload buildRedirectFormInternal(PaymentRequest req, boolean bizum) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros si puedes
api.setParameter("DS_MERCHANT_ORDER", req.order()); // Usa 12 dígitos con ceros
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
@ -58,12 +80,30 @@ public class RedsysService {
api.setParameter("DS_MERCHANT_URLOK", urlOk);
api.setParameter("DS_MERCHANT_URLKO", urlKo);
// ✅ Añadir contexto adicional (por ejemplo, cartId)
// Si tu PaymentRequest no lo lleva todavía, puedes pasarlo en description o
// crear otro campo.
JSONObject ctx = new JSONObject();
ctx.put("cartId", req.cartId()); // o req.cartId() si decides añadirlo al record
api.setParameter("DS_MERCHANT_MERCHANTDATA", ctx.toString());
if (req.description() != null && !req.description().isBlank()) {
api.setParameter("DS_MERCHANT_PRODUCTDESCRIPTION", req.description());
}
// 🔹 Bizum: PAYMETHODS = "z" según Redsys
if (bizum) {
api.setParameter("DS_MERCHANT_PAYMETHODS", "z");
}
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
String action = "test".equalsIgnoreCase(env)
? "https://sis-t.redsys.es:25443/sis/realizarPago"
: "https://sis.redsys.es/sis/realizarPago";
String action = url;
/*
* ? "https://sis-t.redsys.es:25443/sis/realizarPago"
* : "https://sis.redsys.es/sis/realizarPago";
*/
return new FormPayload(action, "HMAC_SHA256_V1", merchantParameters, signature);
}
@ -84,27 +124,40 @@ public class RedsysService {
// ---------- STEP 4: Validar notificación ----------
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
throws Exception {
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
RedsysNotification notif = new RedsysNotification(mp);
throws Exception {
if (notif.order == null || notif.order.isBlank()) {
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
ApiMacSha256 api = new ApiMacSha256();
// 1) Decodificar Ds_MerchantParameters usando la librería oficial
String json = api.decodeMerchantParameters(dsMerchantParametersB64);
// 2) Convertir a Map para tu modelo
Map<String, Object> mp = MAPPER.readValue(json, new TypeReference<>() {
});
RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) {
System.out.println("### ATENCIÓN: Ds_Order no viene en MerchantParameters");
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
}
// 3) Calcular firma esperada: clave comercio + MerchantParameters en B64
String expected = api.createMerchantSignatureNotif(
secretKeyBase64, // 👈 La misma que usas para crear la firma del pago
dsMerchantParametersB64 // 👈 SIEMPRE el B64 tal cual llega de Redsys, sin tocar
);
// 4) Comparar firma Redsys vs firma calculada
if (!safeEqualsB64(dsSignature, expected)) {
System.out.println("### Firma Redsys no válida");
System.out.println("Ds_Signature (Redsys) = " + dsSignature);
System.out.println("Expected (local) = " + expected);
throw new SecurityException("Firma Redsys no válida");
}
return notif;
}
ApiMacSha256 api = new ApiMacSha256();
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
String expected = api.createMerchantSignatureNotif(secretKeyBase64, api.decodeMerchantParameters(dsMerchantParametersB64)); // ✅ SOLO UN PARÁMETRO
if (!safeEqualsB64(dsSignature, expected)) {
throw new SecurityException("Firma Redsys no válida");
}
return notif;
}
// ---------- HELPERS ----------
private static boolean safeEqualsB64(String a, String b) {
if (Objects.equals(a, b))
@ -141,6 +194,7 @@ public class RedsysService {
public final String response;
public final long amountCents;
public final String currency;
public final Long cartId;
public RedsysNotification(Map<String, Object> raw) {
this.raw = raw;
@ -148,6 +202,24 @@ public class RedsysService {
this.response = str(raw.get("Ds_Response"));
this.currency = str(raw.get("Ds_Currency"));
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
this.cartId = extractCartId(raw.get("Ds_MerchantData"));
}
private static Long extractCartId(Object merchantDataObj) {
if (merchantDataObj == null)
return null;
try {
String json = String.valueOf(merchantDataObj);
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
json = json.replace("&#34;", "\"");
org.json.JSONObject ctx = new org.json.JSONObject(json);
return ctx.optLong("cartId", 0L);
} catch (Exception e) {
e.printStackTrace(); // te ayudará si vuelve a fallar
return null;
}
}
public boolean authorized() {
@ -171,4 +243,79 @@ public class RedsysService {
}
}
}
/**
* Solicita a Redsys una devolución (TransactionType = 3)
*
* @param order El mismo Ds_Merchant_Order que se usó en el cobro.
* @param amountCents Importe en céntimos a devolver.
* @return gatewayRefundId (p.ej. Ds_AuthorisationCode o Ds_Order)
*/
public String requestRefund(String order, long amountCents) throws Exception {
ApiMacSha256 api = new ApiMacSha256();
// Montar parámetros para el refund
api.setParameter("DS_MERCHANT_MERCHANTCODE", merchantCode);
api.setParameter("DS_MERCHANT_TERMINAL", terminal);
api.setParameter("DS_MERCHANT_ORDER", order);
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(amountCents));
api.setParameter("DS_MERCHANT_CURRENCY", currency);
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", "3"); // 3 = devolución
api.setParameter("DS_MERCHANT_MERCHANTURL", "");
api.setParameter("DS_MERCHANT_URLOK", "");
api.setParameter("DS_MERCHANT_URLKO", "");
// Crear parámetros y firma (como en tu PHP)
String merchantParameters = api.createMerchantParameters();
String signature = api.createMerchantSignature(secretKeyBase64);
// Montar el JSON para Redsys REST
String json = """
{
"Ds_MerchantParameters": "%s",
"Ds_Signature": "%s",
"Ds_SignatureVersion": "HMAC_SHA256_V1"
}
""".formatted(merchantParameters, signature);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(urlRefund))
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() / 100 != 2)
throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode());
if (response.body() == null || response.body().isBlank())
throw new IllegalStateException("Respuesta vacía de Redsys refund REST");
// Parsear la respuesta JSON
Map<String, Object> respMap = MAPPER.readValue(response.body(), new TypeReference<>() {
});
// Redsys puede devolver "Ds_MerchantParameters" o "errorCode"
if (respMap.containsKey("errorCode")) {
throw new IllegalStateException("Error Redsys refund: " + respMap.get("errorCode"));
}
String dsMerchantParametersResp = (String) respMap.get("Ds_MerchantParameters");
if (dsMerchantParametersResp == null) {
throw new IllegalStateException("Respuesta Redsys refund sin Ds_MerchantParameters");
}
// Decodificar MerchantParameters de la respuesta
Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp);
String dsResponse = String.valueOf(decoded.get("Ds_Response"));
if (!"0900".equals(dsResponse)) {
throw new IllegalStateException("Devolución rechazada, Ds_Response=" + dsResponse);
}
return String.valueOf(decoded.getOrDefault("Ds_AuthorisationCode", order));
}
}

View File

@ -19,60 +19,63 @@ import org.springframework.lang.Nullable;
@Repository
public interface UserDao extends JpaRepository<User, Long>, JpaSpecificationExecutor<User> {
// Aplicamos EntityGraph a la versión con Specification+Pageable
@Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
// Aplicamos EntityGraph a la versión con Specification+Pageable
@Override
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
@NonNull
Page<User> findAll(@Nullable Specification<User> spec, @NonNull Pageable pageable);
Optional<User> findByUserNameIgnoreCase(String userName);
Optional<User> findByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
boolean existsByUserNameIgnoreCase(String userName);
// Para comprobar si existe al hacer signup
@Query(value = """
SELECT id, deleted, enabled
FROM users
WHERE LOWER(username) = LOWER(:userName)
LIMIT 1
""", nativeQuery = true)
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
// Para comprobar si existe al hacer signup
@Query(value = """
SELECT id, deleted, enabled
FROM users
WHERE LOWER(username) = LOWER(:userName)
LIMIT 1
""", nativeQuery = true)
Optional<UserLite> findLiteByUserNameIgnoreCase(@Param("userName") String userName);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id);
// Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Nuevo: para login/negocio "activo"
@EntityGraph(attributePaths = { "rolesLink", "rolesLink.role" })
Optional<User> findByUserNameIgnoreCaseAndEnabledTrueAndDeletedFalse(String userName);
// Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
// Para poder restaurar, necesitas leer ignorando @Where (native):
@Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true)
Optional<User> findByIdIncludingDeleted(@Param("id") Long id);
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted();
@Query(value = "SELECT * FROM users WHERE deleted = TRUE", nativeQuery = true)
List<User> findAllDeleted();
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
@Query("select u.id from User u where lower(u.userName) = lower(:userName)")
Optional<Long> findIdByUserNameIgnoreCase(@Param("userName") String userName);
@Query(value = """
SELECT DISTINCT u
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""", countQuery = """
SELECT COUNT(DISTINCT u.id)
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""")
Page<User> searchUsers(@Param("role") String role,
@Param("q") String q,
Pageable pageable);
@Query(value = """
SELECT DISTINCT u
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""", countQuery = """
SELECT COUNT(DISTINCT u.id)
FROM User u
JOIN u.rolesLink rl
JOIN rl.role r
WHERE (:role IS NULL OR r.name = :role)
AND (:q IS NULL OR LOWER(u.fullName) LIKE LOWER(CONCAT('%', :q, '%'))
OR LOWER(u.userName) LIKE LOWER(CONCAT('%', :q, '%')))
""")
Page<User> searchUsers(@Param("role") String role,
@Param("q") String q,
Pageable pageable);
@Query("select u.id from User u where lower(u.fullName) like lower(concat('%', :name, '%'))")
List<Long> findIdsByFullNameLike(@Param("name") String name);
}

View File

@ -20,6 +20,8 @@ safekat.api.password=Safekat2024
# Configuración Redsys
redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
redsys.urls.notify=http://localhost:8080/pagos/redsys/notify
redsys.urls.notify=https://orological-sacrilegiously-lucille.ngrok-free.dev/pagos/redsys/notify

View File

@ -20,6 +20,8 @@ safekat.api.password=Safekat2024
# Configuración Redsys
redsys.environment=test
redsys.url=https://sis-t.redsys.es:25443/sis/realizarPago
redsys.refund.url=https://sis-t.redsys.es:25443/sis/rest/trataPeticionREST
redsys.urls.ok=https://imprimelibros.jjimenez.eu/pagos/redsys/ok
redsys.urls.ko=https://imprimelibros.jjimenez.eu/pagos/redsys/ko
redsys.urls.notify=https://imprimelibros.jjimenez.eu/pagos/redsys/notify

View File

@ -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

View File

@ -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);

View File

@ -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

View File

@ -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

View File

@ -10,4 +10,12 @@ databaseChangeLog:
- include:
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
- include:
file: db/changelog/changesets/0006-add-cart-direcciones.yml
file: db/changelog/changesets/0006-add-cart-direcciones.yml
- include:
file: db/changelog/changesets/0007-payments-core.yml
- include:
file: db/changelog/changesets/0008-update-cart-status-constraint.yml
- include:
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
- include:
file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml

View File

@ -23,5 +23,6 @@ app.sidebar.configuracion=Configuración
app.sidebar.usuarios=Usuarios
app.sidebar.direcciones=Mis Direcciones
app.sidebar.direcciones-admin=Administrar Direcciones
app.sidebar.gestion-pagos=Gestión de Pagos
app.errors.403=No tienes permiso para acceder a esta página.

View 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

View File

@ -1,17 +1,18 @@
checkout.title=Finalizar compra
checkout.summay=Resumen de la compra
checkout.shipping=Envío
checkout.summary=Resumen de la compra
checkout.billing-address=Dirección de facturación
checkout.payment=Método de pago
checkout.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido.
checkout.shipping.order=Envío del pedido
checkout.shipping.samples=Envío de pruebas
checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección.
checkout.billing-address.title=Seleccione una dirección
checkout.billing-address.new-address=Nueva dirección
checkout.billing-address.select-placeholder=Buscar en direcciones...
checkout.billing-address.errors.noAddressSelected=Debe seleccionar una dirección de facturación para el pedido.
checkout.payment.card=Tarjeta de crédito / débito
checkout.payment.bizum=Bizum
checkout.payment.bank-transfer=Transferencia bancaria
checkout.error.payment=Error al procesar el pago: el pago ha sido cancelado o rechazado Por favor, inténtelo de nuevo.
checkout.success.payment=Pago realizado con éxito. Gracias por su compra.
checkout.summary.presupuesto=#Presupuesto
checkout.summary.titulo=Título
checkout.summary.base=Base
checkout.summary.iva-4=IVA 4%
checkout.summary.iva-21=IVA 21%
checkout.summary.envio=Envío
checkout.make-payment=Realizar el pago
checkout.authorization-required=Certifico que tengo los derechos para imprimir los archivos incluidos en mi pedido y me hago responsable en caso de reclamación de los mismos

View File

@ -8240,7 +8240,8 @@ a {
display: none;
}
.card-radio .form-check-input:checked + .form-check-label {
border-color: #687cfe !important;
border-color: #ff7f5d !important;
background-color: rgba(255, 127, 93, 0.05);
}
.card-radio .form-check-input:checked + .form-check-label:before {
content: "\eb80";
@ -8249,7 +8250,7 @@ a {
top: 2px;
right: 6px;
font-size: 16px;
color: #687cfe;
color: #ff7f5d;
}
.card-radio.dark .form-check-input:checked + .form-check-label:before {
color: #fff;

View 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 */
}

View File

@ -40,6 +40,14 @@ body {
margin: 0;
}
.swal2-popup .form-switch-custom .form-check-input:checked{
border-color: #92b2a7;
background-color: #cbcecd;
}
.swal2-popup .form-switch-custom .form-check-input:checked::before {
color: #92b2a7;
}
.form-switch-presupuesto .form-check-input:checked {
border-color: #92b2a7;
background-color: #cbcecd;
@ -47,4 +55,15 @@ body {
.form-switch-custom.form-switch-presupuesto .form-check-input:checked::before {
color: #92b2a7;
}
.alert-fadeout {
opacity: 1;
transition: opacity 1s ease;
animation: fadeout 4s forwards;
}
@keyframes fadeout {
0%, 70% { opacity: 1; }
100% { opacity: 0; }
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 470 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 316 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Some files were not shown because too many files have changed in this diff Show More