mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-29 15:18:50 +00:00
Compare commits
3 Commits
f528809c07
...
ed32f773a4
| Author | SHA1 | Date | |
|---|---|---|---|
| ed32f773a4 | |||
| dc64e40e38 | |||
| 7516e9e91e |
@ -3,8 +3,10 @@ package com.imprimelibros.erp;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan(basePackages = "com.imprimelibros.erp")
|
||||
public class ErpApplication {
|
||||
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
package com.imprimelibros.erp.cart;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Service
|
||||
public class CartCleanupService {
|
||||
|
||||
private final CartRepository cartRepository;
|
||||
|
||||
public CartCleanupService(CartRepository cartRepository) {
|
||||
this.cartRepository = cartRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ejecuta cada noche a las 2:00 AM
|
||||
*/
|
||||
@Transactional
|
||||
@Scheduled(cron = "0 0 2 * * *") // cada día a las 02:00
|
||||
public void markAbandonedCarts() {
|
||||
LocalDateTime limite = LocalDateTime.now().minusDays(7);
|
||||
int updated = cartRepository.markOldCartsAsAbandoned(limite);
|
||||
System.out.println("Carritos abandonados marcados: " + updated);
|
||||
}
|
||||
}
|
||||
@ -1,8 +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 org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||
@ -17,5 +21,15 @@ public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||
where c.id = :id
|
||||
""")
|
||||
Optional<Cart> findByIdFetchAll(@Param("id") Long id);
|
||||
|
||||
@Modifying
|
||||
@Transactional
|
||||
@Query("""
|
||||
UPDATE Cart c
|
||||
SET c.status = 'ABANDONED'
|
||||
WHERE c.status = 'ACTIVE'
|
||||
AND c.updatedAt < :limite
|
||||
""")
|
||||
int markOldCartsAsAbandoned(LocalDateTime limite);
|
||||
|
||||
}
|
||||
|
||||
@ -53,6 +53,14 @@ public class CartService {
|
||||
this.pedidoService = pedidoService;
|
||||
}
|
||||
|
||||
|
||||
public Cart findById(Long cartId) {
|
||||
return cartRepo.findById(cartId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Devuelve el carrito activo o lo crea si no existe. */
|
||||
@Transactional
|
||||
public Cart getOrCreateActiveCart(Long userId) {
|
||||
@ -136,6 +144,14 @@ public class CartService {
|
||||
cartRepo.save(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void lockCartById(Long cartId) {
|
||||
Cart cart = cartRepo.findById(cartId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Carrito no encontrado"));
|
||||
cart.setStatus(Cart.Status.LOCKED);
|
||||
cartRepo.save(cart);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public long countItems(Long userId) {
|
||||
Cart cart = getOrCreateActiveCart(userId);
|
||||
@ -293,6 +309,7 @@ public class CartService {
|
||||
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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,124 @@
|
||||
package com.imprimelibros.erp.payments;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.data.jpa.domain.Specification;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus.*;
|
||||
|
||||
import com.imprimelibros.erp.common.Utils;
|
||||
import com.imprimelibros.erp.configuracion.margenes_presupuestos.MargenPresupuesto;
|
||||
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.payments.model.Payment;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransaction;
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
|
||||
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
|
||||
import com.imprimelibros.erp.users.User;
|
||||
import com.imprimelibros.erp.users.UserDao;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/pagos")
|
||||
@PreAuthorize("hasRole('SUPERADMIN')")
|
||||
public class PaymentController {
|
||||
|
||||
protected final PaymentTransactionRepository repoPaymentTransaction;
|
||||
protected final UserDao repoUser;
|
||||
|
||||
public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser) {
|
||||
this.repoPaymentTransaction = repoPaymentTransaction;
|
||||
this.repoUser = repoUser;
|
||||
}
|
||||
|
||||
@GetMapping()
|
||||
public String index() {
|
||||
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(
|
||||
);
|
||||
|
||||
List<String> orderable = List.of(
|
||||
|
||||
);
|
||||
|
||||
Specification<PaymentTransaction> base = Specification.allOf(
|
||||
(root, query, cb) -> cb.equal(root.get("status"), PaymentTransactionStatus.succeeded));
|
||||
|
||||
Long total = repoPaymentTransaction.count(base);
|
||||
|
||||
return DataTable
|
||||
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable) // 'searchable' en DataTable.java
|
||||
// edita columnas "reales":
|
||||
.orderable(orderable)
|
||||
.add("created_at", (pago) -> {
|
||||
return 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());
|
||||
return user.map(User::getFullName).orElse("");
|
||||
}
|
||||
return "";
|
||||
} else {
|
||||
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) -> {
|
||||
return Utils.formatCurrency(pago.getAmountCents() / 100.0, locale);
|
||||
})
|
||||
.add("actions", (pago) -> {
|
||||
return "<div class=\"hstack gap-3 flex-wrap\">\n" +
|
||||
" <a href=\"javascript:void(0);\" data-id=\"" + pago.getId()
|
||||
+ "\" class=\"link-success btn-edit-pago fs-15\"><i class=\"ri-edit-2-line\"></i></a>\n"
|
||||
+ " <a href=\"javascript:void(0);\" data-id=\"" + pago.getId()
|
||||
+ "\" class=\"link-danger btn-delete-pago fs-15\"><i class=\"ri-delete-bin-5-line\"></i></a>\n"
|
||||
+ " </div>";
|
||||
})
|
||||
.where(base)
|
||||
// Filtros custom:
|
||||
.toJson(total);
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -1,6 +1,8 @@
|
||||
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;
|
||||
@ -10,6 +12,7 @@ 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;
|
||||
@ -22,40 +25,56 @@ public class PaymentService {
|
||||
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) {
|
||||
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 orderId, long amountCents, String currency, String method)
|
||||
public FormPayload createRedsysPayment(Long cartId, long amountCents, String currency, String method)
|
||||
throws Exception {
|
||||
Payment p = new Payment();
|
||||
p.setOrderId(orderId); // <- ahora puede ser null
|
||||
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.setStatus(PaymentStatus.requires_payment_method);
|
||||
p = payRepo.saveAndFlush(p);
|
||||
|
||||
// Ds_Order = ID del Payment, 12 dígitos
|
||||
String dsOrder = String.format("%012d", p.getId());
|
||||
// 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");
|
||||
"Compra en Imprimelibros", cartId);
|
||||
|
||||
if ("bizum".equalsIgnoreCase(method)) {
|
||||
return redsysService.buildRedirectFormBizum(req);
|
||||
@ -64,81 +83,151 @@ public class PaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
// si aún tienes la versión antigua sin method, puedes dejar este overload si te
|
||||
// viene bien:
|
||||
@Transactional
|
||||
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
|
||||
return createRedsysPayment(orderId, amountCents, currency, "card");
|
||||
}
|
||||
|
||||
/**
|
||||
* Procesa una notificación Redsys (OK/notify) con la API oficial:
|
||||
* - validateAndParseNotification usa createMerchantSignatureNotif +
|
||||
* decodeMerchantParameters
|
||||
*/
|
||||
@Transactional
|
||||
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
|
||||
RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
|
||||
|
||||
// Log útil para depurar
|
||||
System.out.println(">> Redsys notify: order=" + notif.order +
|
||||
" amountCents=" + notif.amountCents +
|
||||
" currency=" + notif.currency +
|
||||
" response=" + notif.response);
|
||||
// 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);
|
||||
|
||||
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order)
|
||||
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order));
|
||||
|
||||
// 🔹 Opción sencilla: sólo comprobar el importe
|
||||
if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) {
|
||||
throw new IllegalStateException("Importe inesperado: esperado=" +
|
||||
p.getAmountTotalCents() + " recibido=" + notif.amountCents);
|
||||
}
|
||||
|
||||
// Si quieres, puedes hacer un check mínimamente decente de divisa numérica:
|
||||
// (si usas siempre EUR)
|
||||
/*
|
||||
* if (!"978".equals(notif.currency)) {
|
||||
* throw new IllegalStateException("Divisa Redsys inesperada: " +
|
||||
* notif.currency);
|
||||
* }
|
||||
*/
|
||||
|
||||
// Idempotencia simple: si ya está capturado o reembolsado, no hacemos nada
|
||||
if (p.getStatus() == PaymentStatus.CAPTURED
|
||||
|| p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED
|
||||
|| p.getStatus() == PaymentStatus.REFUNDED) {
|
||||
// IMPORTANTE: NO re-lanzamos la excepción
|
||||
// Simplemente salimos. Así se hace commit de este insert.
|
||||
return;
|
||||
}
|
||||
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.CAPTURE);
|
||||
tx.setCurrency(p.getCurrency()); // "EUR"
|
||||
tx.setAmountCents(notif.amountCents);
|
||||
tx.setStatus(notif.authorized()
|
||||
? PaymentTransactionStatus.SUCCEEDED
|
||||
: PaymentTransactionStatus.FAILED);
|
||||
// 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;
|
||||
|
||||
Object authCode = notif.raw.get("Ds_AuthorisationCode");
|
||||
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
|
||||
tx.setGatewayResponseCode(notif.response);
|
||||
tx.setResponsePayload(om.writeValueAsString(notif.raw));
|
||||
tx.setProcessedAt(LocalDateTime.now());
|
||||
txRepo.save(tx);
|
||||
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 (notif.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 (Boolean.TRUE.equals(ev.getProcessed())) {
|
||||
return;
|
||||
}
|
||||
|
||||
payRepo.save(p);
|
||||
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) {
|
||||
// GENERAR PEDIDO A PARTIR DEL CARRITO
|
||||
Cart cart = this.cartService.findById(notif.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);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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 y bank_transfer igual que antes, no tocan RedsysService
|
||||
@ -163,7 +252,7 @@ public class PaymentService {
|
||||
Refund r = new Refund();
|
||||
r.setPayment(p);
|
||||
r.setAmountCents(amountCents);
|
||||
r.setStatus(RefundStatus.PENDING);
|
||||
r.setStatus(RefundStatus.pending);
|
||||
r.setRequestedAt(LocalDateTime.now());
|
||||
r = refundRepo.save(r);
|
||||
|
||||
@ -173,7 +262,7 @@ public class PaymentService {
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.REFUND);
|
||||
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
|
||||
tx.setStatus(PaymentTransactionStatus.succeeded);
|
||||
tx.setAmountCents(amountCents);
|
||||
tx.setCurrency(p.getCurrency());
|
||||
tx.setGatewayTransactionId(gatewayRefundId);
|
||||
@ -181,7 +270,7 @@ public class PaymentService {
|
||||
tx.setProcessedAt(LocalDateTime.now());
|
||||
txRepo.save(tx);
|
||||
|
||||
r.setStatus(RefundStatus.SUCCEEDED);
|
||||
r.setStatus(RefundStatus.succeeded);
|
||||
r.setTransaction(tx);
|
||||
r.setGatewayRefundId(gatewayRefundId);
|
||||
r.setProcessedAt(LocalDateTime.now());
|
||||
@ -189,41 +278,91 @@ public class PaymentService {
|
||||
|
||||
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
|
||||
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
|
||||
p.setStatus(PaymentStatus.REFUNDED);
|
||||
p.setStatus(PaymentStatus.refunded);
|
||||
} else {
|
||||
p.setStatus(PaymentStatus.PARTIALLY_REFUNDED);
|
||||
p.setStatus(PaymentStatus.partially_refunded);
|
||||
}
|
||||
payRepo.save(p);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
|
||||
public Payment createBankTransferPayment(Long cartId, long amountCents, String currency) {
|
||||
Payment p = new Payment();
|
||||
p.setOrderId(orderId); // null en tu caso actual
|
||||
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("bank_transfer");
|
||||
p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso
|
||||
return payRepo.save(p);
|
||||
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();
|
||||
if (!"bank_transfer".equals(p.getGateway()))
|
||||
throw new IllegalStateException("No es transferencia");
|
||||
p.setAmountCapturedCents(p.getAmountTotalCents());
|
||||
p.setCapturedAt(LocalDateTime.now());
|
||||
p.setStatus(PaymentStatus.CAPTURED);
|
||||
payRepo.save(p);
|
||||
Payment p = payRepo.findById(paymentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
|
||||
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.CAPTURE);
|
||||
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
|
||||
tx.setAmountCents(p.getAmountTotalCents());
|
||||
tx.setCurrency(p.getCurrency());
|
||||
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);
|
||||
}
|
||||
|
||||
private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) {
|
||||
if (notif.response == null) {
|
||||
return false;
|
||||
}
|
||||
String r = notif.response.trim();
|
||||
// Si no es numérico, lo tratamos como no autorizado
|
||||
if (!r.matches("\\d+")) {
|
||||
return false;
|
||||
}
|
||||
int code = Integer.parseInt(r);
|
||||
// Redsys: 0–99 → autorizado; >=100 → denegado / error
|
||||
return code >= 0 && code <= 99;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum CaptureMethod { AUTOMATIC, MANUAL }
|
||||
|
||||
public enum CaptureMethod { automatic, manual }
|
||||
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "idempotency_keys",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_idem_scope_key", columnNames = {"scope","idem_key"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_idem_resource", columnList = "resource_id")
|
||||
}
|
||||
)
|
||||
public class IdempotencyKey {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "scope", nullable = false, length = 16)
|
||||
private IdempotencyScope scope;
|
||||
|
||||
@Column(name = "idem_key", nullable = false, length = 128)
|
||||
private String idemKey;
|
||||
|
||||
@Column(name = "resource_id")
|
||||
private Long resourceId;
|
||||
|
||||
@Column(name = "response_cache", columnDefinition = "json")
|
||||
private String responseCache;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "expires_at")
|
||||
private LocalDateTime expiresAt;
|
||||
|
||||
public IdempotencyKey() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public IdempotencyScope getScope() { return scope; }
|
||||
public void setScope(IdempotencyScope scope) { this.scope = scope; }
|
||||
|
||||
public String getIdemKey() { return idemKey; }
|
||||
public void setIdemKey(String idemKey) { this.idemKey = idemKey; }
|
||||
|
||||
public Long getResourceId() { return resourceId; }
|
||||
public void setResourceId(Long resourceId) { this.resourceId = resourceId; }
|
||||
|
||||
public String getResponseCache() { return responseCache; }
|
||||
public void setResponseCache(String responseCache) { this.responseCache = responseCache; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getExpiresAt() { return expiresAt; }
|
||||
public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; }
|
||||
}
|
||||
|
||||
@ -1,3 +0,0 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum IdempotencyScope { PAYMENT, REFUND, WEBHOOK }
|
||||
@ -17,10 +17,6 @@ public class Payment {
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "payment_method_id")
|
||||
private PaymentMethod paymentMethod;
|
||||
|
||||
@Column(nullable = false, length = 3)
|
||||
private String currency;
|
||||
|
||||
@ -35,11 +31,11 @@ public class Payment {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private PaymentStatus status = PaymentStatus.REQUIRES_PAYMENT_METHOD;
|
||||
private PaymentStatus status = PaymentStatus.requires_payment_method;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "capture_method", nullable = false, length = 16)
|
||||
private CaptureMethod captureMethod = CaptureMethod.AUTOMATIC;
|
||||
private CaptureMethod captureMethod = CaptureMethod.automatic;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String gateway;
|
||||
@ -55,7 +51,7 @@ public class Payment {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "three_ds_status", nullable = false, length = 32)
|
||||
private ThreeDSStatus threeDsStatus = ThreeDSStatus.NOT_APPLICABLE;
|
||||
private ThreeDSStatus threeDsStatus = ThreeDSStatus.not_applicable;
|
||||
|
||||
@Column(length = 22)
|
||||
private String descriptor;
|
||||
@ -99,9 +95,6 @@ public class Payment {
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public PaymentMethod getPaymentMethod() { return paymentMethod; }
|
||||
public void setPaymentMethod(PaymentMethod paymentMethod) { this.paymentMethod = paymentMethod; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "payment_methods")
|
||||
public class PaymentMethod {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private PaymentMethodType type;
|
||||
|
||||
@Column(length = 32)
|
||||
private String brand;
|
||||
|
||||
@Column(length = 4)
|
||||
private String last4;
|
||||
|
||||
@Column(name = "exp_month")
|
||||
private Integer expMonth;
|
||||
|
||||
@Column(name = "exp_year")
|
||||
private Integer expYear;
|
||||
|
||||
@Column(length = 128)
|
||||
private String fingerprint;
|
||||
|
||||
@Column(length = 128, unique = true)
|
||||
private String tokenId;
|
||||
|
||||
@Column(length = 128)
|
||||
private String sepaMandateId;
|
||||
|
||||
@Column(length = 190)
|
||||
private String payerEmail;
|
||||
|
||||
@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;
|
||||
|
||||
// ---- Getters/Setters ----
|
||||
public PaymentMethod() {}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getUserId() { return userId; }
|
||||
public void setUserId(Long userId) { this.userId = userId; }
|
||||
|
||||
public PaymentMethodType getType() { return type; }
|
||||
public void setType(PaymentMethodType type) { this.type = type; }
|
||||
|
||||
public String getBrand() { return brand; }
|
||||
public void setBrand(String brand) { this.brand = brand; }
|
||||
|
||||
public String getLast4() { return last4; }
|
||||
public void setLast4(String last4) { this.last4 = last4; }
|
||||
|
||||
public Integer getExpMonth() { return expMonth; }
|
||||
public void setExpMonth(Integer expMonth) { this.expMonth = expMonth; }
|
||||
|
||||
public Integer getExpYear() { return expYear; }
|
||||
public void setExpYear(Integer expYear) { this.expYear = expYear; }
|
||||
|
||||
public String getFingerprint() { return fingerprint; }
|
||||
public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; }
|
||||
|
||||
public String getTokenId() { return tokenId; }
|
||||
public void setTokenId(String tokenId) { this.tokenId = tokenId; }
|
||||
|
||||
public String getSepaMandateId() { return sepaMandateId; }
|
||||
public void setSepaMandateId(String sepaMandateId) { this.sepaMandateId = sepaMandateId; }
|
||||
|
||||
public String getPayerEmail() { return payerEmail; }
|
||||
public void setPayerEmail(String payerEmail) { this.payerEmail = payerEmail; }
|
||||
|
||||
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; }
|
||||
}
|
||||
@ -1,4 +0,0 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentMethodType { CARD, BIZUM, BANK_TRANSFER }
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentStatus {
|
||||
REQUIRES_PAYMENT_METHOD, REQUIRES_ACTION, AUTHORIZED,
|
||||
CAPTURED, PARTIALLY_REFUNDED, REFUNDED, CANCELED, FAILED
|
||||
requires_payment_method, requires_action, authorized,
|
||||
captured, partially_refunded, refunded, canceled, failed
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -120,4 +120,13 @@ public class PaymentTransaction {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED }
|
||||
public enum PaymentTransactionStatus { pending, succeeded, failed }
|
||||
|
||||
|
||||
@ -33,11 +33,11 @@ public class Refund {
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reason", nullable = false, length = 32)
|
||||
private RefundReason reason = RefundReason.CUSTOMER_REQUEST;
|
||||
private RefundReason reason = RefundReason.customer_request;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private RefundStatus status = RefundStatus.PENDING;
|
||||
private RefundStatus status = RefundStatus.pending;
|
||||
|
||||
@Column(name = "requested_by_user_id")
|
||||
private Long requestedByUserId;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundReason {
|
||||
CUSTOMER_REQUEST, PARTIAL_RETURN, PRICING_ADJUSTMENT, DUPLICATE, FRAUD, OTHER
|
||||
customer_request, partial_return, pricing_adjustment, duplicate, fraud, other
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundStatus { PENDING, SUCCEEDED, FAILED, CANCELED }
|
||||
public enum RefundStatus { pending, succeeded, failed, canceled }
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum ThreeDSStatus { NOT_APPLICABLE, ATTEMPTED, CHALLENGE, SUCCEEDED, FAILED }
|
||||
public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed }
|
||||
|
||||
|
||||
@ -85,4 +85,12 @@ public class WebhookEvent {
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
// IdempotencyKeyRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.IdempotencyKey;
|
||||
import com.imprimelibros.erp.payments.model.IdempotencyScope;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface IdempotencyKeyRepository extends JpaRepository<IdempotencyKey, Long> {
|
||||
Optional<IdempotencyKey> findByScopeAndIdemKey(IdempotencyScope scope, String idemKey);
|
||||
}
|
||||
@ -1,7 +0,0 @@
|
||||
// PaymentMethodRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.PaymentMethod;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PaymentMethodRepository extends JpaRepository<PaymentMethod, Long> {}
|
||||
@ -2,11 +2,20 @@
|
||||
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> {
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
@ -7,6 +7,6 @@ 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")
|
||||
@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);
|
||||
}
|
||||
|
||||
@ -4,4 +4,9 @@ package com.imprimelibros.erp.payments.repo;
|
||||
import com.imprimelibros.erp.payments.model.WebhookEvent;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {}
|
||||
import java.util.Optional;
|
||||
|
||||
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {
|
||||
|
||||
Optional<WebhookEvent> findByProviderAndEventId(String provider, String eventId);
|
||||
}
|
||||
|
||||
@ -4,34 +4,38 @@ 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.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.UUID;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@Controller
|
||||
@RequestMapping("/pagos/redsys")
|
||||
public class RedsysController {
|
||||
|
||||
private final PaymentService paymentService;
|
||||
private final MessageSource messageSource;
|
||||
|
||||
public RedsysController(PaymentService paymentService) {
|
||||
public RedsysController(PaymentService paymentService, MessageSource messageSource) {
|
||||
this.paymentService = paymentService;
|
||||
this.messageSource = messageSource;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@ResponseBody
|
||||
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
|
||||
@RequestParam("method") String method) throws Exception {
|
||||
@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(null, amountCents, "EUR");
|
||||
Payment p = paymentService.createBankTransferPayment(cartId, amountCents, "EUR");
|
||||
|
||||
// 2) Mostramos instrucciones de transferencia
|
||||
String html = """
|
||||
@ -57,7 +61,7 @@ public class RedsysController {
|
||||
}
|
||||
|
||||
// Tarjeta o Bizum (Redsys)
|
||||
FormPayload form = paymentService.createRedsysPayment(null, amountCents, "EUR", method);
|
||||
FormPayload form = paymentService.createRedsysPayment(cartId, amountCents, "EUR", method);
|
||||
|
||||
String html = """
|
||||
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
|
||||
@ -66,6 +70,7 @@ public class RedsysController {
|
||||
<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>
|
||||
@ -76,7 +81,7 @@ public class RedsysController {
|
||||
form.action(),
|
||||
form.signatureVersion(),
|
||||
form.merchantParameters(),
|
||||
form.signature());
|
||||
form.signature(), cartId);
|
||||
|
||||
byte[] body = html.getBytes(StandardCharsets.UTF_8);
|
||||
return ResponseEntity.ok()
|
||||
@ -86,21 +91,17 @@ public class RedsysController {
|
||||
|
||||
// GET: cuando el usuario cae aquí sin parámetros, o Redsys redirige por GET
|
||||
@GetMapping("/ok")
|
||||
@ResponseBody
|
||||
public ResponseEntity<String> okGet() {
|
||||
String html = """
|
||||
<h2>Pago procesado</h2>
|
||||
<p>Si el pago ha sido autorizado, verás el pedido en tu área de usuario o recibirás un email de confirmación.</p>
|
||||
<p><a href="/cart">Volver a la tienda</a></p>
|
||||
""";
|
||||
return ResponseEntity.ok(html);
|
||||
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
|
||||
@jakarta.transaction.Transactional
|
||||
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
|
||||
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||
try {
|
||||
@ -114,21 +115,36 @@ public class RedsysController {
|
||||
}
|
||||
|
||||
@GetMapping("/ko")
|
||||
@ResponseBody
|
||||
public ResponseEntity<String> koGet() {
|
||||
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>");
|
||||
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 Map<String, String> form) {
|
||||
// Podrías loguear 'form' si quieres ver qué manda Redsys
|
||||
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>");
|
||||
public ResponseEntity<String> koPost(
|
||||
@RequestParam("Ds_Signature") String signature,
|
||||
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||
|
||||
try {
|
||||
// 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) {
|
||||
// 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(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||
@ResponseBody
|
||||
@jakarta.transaction.Transactional
|
||||
public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
|
||||
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||
try {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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;
|
||||
@ -38,7 +39,7 @@ public class RedsysService {
|
||||
|
||||
// ---------- RECORDS ----------
|
||||
// Pedido a Redsys
|
||||
public record PaymentRequest(String order, long amountCents, String description) {
|
||||
public record PaymentRequest(String order, long amountCents, String description, Long cartId) {
|
||||
}
|
||||
|
||||
// Payload para el formulario
|
||||
@ -69,6 +70,13 @@ 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());
|
||||
}
|
||||
@ -105,27 +113,31 @@ public class RedsysService {
|
||||
// ---------- STEP 4: Validar notificación ----------
|
||||
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
|
||||
throws Exception {
|
||||
// 1) Decodificamos a mapa solo para leer campos
|
||||
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
// 2) Calculamos la firma esperada usando el B64 tal cual
|
||||
ApiMacSha256 api = new ApiMacSha256();
|
||||
// Esta línea es opcional para createMerchantSignatureNotif, pero no molesta:
|
||||
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
|
||||
|
||||
// 3) Calcular firma esperada: clave comercio + MerchantParameters en B64
|
||||
String expected = api.createMerchantSignatureNotif(
|
||||
secretKeyBase64,
|
||||
dsMerchantParametersB64 // 👈 AQUÍ va el B64, NO el JSON
|
||||
secretKeyBase64, // 👈 La misma que usas para crear la firma del pago
|
||||
dsMerchantParametersB64 // 👈 SIEMPRE el B64 tal cual llega de Redsys, sin tocar
|
||||
);
|
||||
|
||||
// 3) Comparamos en constante time, normalizando Base64 URL-safe
|
||||
// 4) Comparar firma Redsys vs firma calculada
|
||||
if (!safeEqualsB64(dsSignature, expected)) {
|
||||
System.out.println("Firma Redsys no válida");
|
||||
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");
|
||||
@ -170,6 +182,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;
|
||||
@ -177,6 +190,24 @@ public class RedsysService {
|
||||
this.response = str(raw.get("Ds_Response"));
|
||||
this.currency = str(raw.get("Ds_Currency"));
|
||||
this.amountCents = parseLongSafe(raw.get("Ds_Amount"));
|
||||
this.cartId = extractCartId(raw.get("Ds_MerchantData"));
|
||||
}
|
||||
|
||||
private static Long extractCartId(Object merchantDataObj) {
|
||||
if (merchantDataObj == null)
|
||||
return null;
|
||||
try {
|
||||
String json = String.valueOf(merchantDataObj);
|
||||
|
||||
// 👇 DES-ESCAPAR las comillas HTML que vienen de Redsys
|
||||
json = json.replace(""", "\"");
|
||||
|
||||
org.json.JSONObject ctx = new org.json.JSONObject(json);
|
||||
return ctx.optLong("cartId", 0L);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace(); // te ayudará si vuelve a fallar
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean authorized() {
|
||||
|
||||
@ -3,70 +3,7 @@ databaseChangeLog:
|
||||
id: 0007-payments-core
|
||||
author: jjo
|
||||
changes:
|
||||
# 1) payment_methods
|
||||
- createTable:
|
||||
tableName: payment_methods
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: user_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: type
|
||||
type: "ENUM('card','bizum','bank_transfer')"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: brand
|
||||
type: VARCHAR(32)
|
||||
- column:
|
||||
name: last4
|
||||
type: VARCHAR(4)
|
||||
- column:
|
||||
name: exp_month
|
||||
type: TINYINT
|
||||
- column:
|
||||
name: exp_year
|
||||
type: SMALLINT
|
||||
- column:
|
||||
name: fingerprint
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: token_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: sepa_mandate_id
|
||||
type: VARCHAR(128)
|
||||
- column:
|
||||
name: payer_email
|
||||
type: VARCHAR(190)
|
||||
- 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
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: payment_methods
|
||||
columnNames: token_id
|
||||
constraintName: uq_payment_methods_token
|
||||
|
||||
# 2) payments
|
||||
# 2) payments
|
||||
- createTable:
|
||||
tableName: payments
|
||||
columns:
|
||||
@ -82,9 +19,6 @@ databaseChangeLog:
|
||||
- column:
|
||||
name: user_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: payment_method_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: currency
|
||||
type: CHAR(3)
|
||||
@ -173,13 +107,6 @@ databaseChangeLog:
|
||||
constraints:
|
||||
nullable: false
|
||||
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: payments
|
||||
baseColumnNames: payment_method_id
|
||||
referencedTableName: payment_methods
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_payments_payment_methods
|
||||
onDelete: SET NULL
|
||||
|
||||
- createIndex:
|
||||
tableName: payments
|
||||
@ -474,50 +401,3 @@ databaseChangeLog:
|
||||
- column:
|
||||
name: processed
|
||||
|
||||
# 6) idempotency_keys
|
||||
- createTable:
|
||||
tableName: idempotency_keys
|
||||
columns:
|
||||
- column:
|
||||
name: id
|
||||
type: BIGINT AUTO_INCREMENT
|
||||
constraints:
|
||||
primaryKey: true
|
||||
nullable: false
|
||||
- column:
|
||||
name: scope
|
||||
type: "ENUM('payment','refund','webhook')"
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: idem_key
|
||||
type: VARCHAR(128)
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: resource_id
|
||||
type: BIGINT
|
||||
- column:
|
||||
name: response_cache
|
||||
type: JSON
|
||||
- column:
|
||||
name: created_at
|
||||
type: DATETIME
|
||||
defaultValueComputed: CURRENT_TIMESTAMP
|
||||
constraints:
|
||||
nullable: false
|
||||
- column:
|
||||
name: expires_at
|
||||
type: DATETIME
|
||||
|
||||
- addUniqueConstraint:
|
||||
tableName: idempotency_keys
|
||||
columnNames: scope, idem_key
|
||||
constraintName: uq_idem_scope_key
|
||||
|
||||
- createIndex:
|
||||
tableName: idempotency_keys
|
||||
indexName: idx_idem_resource
|
||||
columns:
|
||||
- column:
|
||||
name: resource_id
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
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);
|
||||
@ -12,4 +12,6 @@ databaseChangeLog:
|
||||
- include:
|
||||
file: db/changelog/changesets/0006-add-cart-direcciones.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0007-payments-core.yml
|
||||
file: db/changelog/changesets/0007-payments-core.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0008-update-cart-status-constraint.yml
|
||||
@ -23,5 +23,6 @@ app.sidebar.configuracion=Configuración
|
||||
app.sidebar.usuarios=Usuarios
|
||||
app.sidebar.direcciones=Mis Direcciones
|
||||
app.sidebar.direcciones-admin=Administrar Direcciones
|
||||
app.sidebar.gestion-pagos=Gestión de Pagos
|
||||
|
||||
app.errors.403=No tienes permiso para acceder a esta página.
|
||||
0
src/main/resources/i18n/pagos_en.properties
Normal file
0
src/main/resources/i18n/pagos_en.properties
Normal file
12
src/main/resources/i18n/pagos_es.properties
Normal file
12
src/main/resources/i18n/pagos_es.properties
Normal file
@ -0,0 +1,12 @@
|
||||
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.fecha=Fecha
|
||||
pagos.table.estado=Estado
|
||||
pagos.table.acciones=Acciones
|
||||
@ -12,5 +12,7 @@ checkout.billing-address.errors.noAddressSelected=Debe seleccionar una direcció
|
||||
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.make-payment=Realizar el pago
|
||||
@ -57,3 +57,13 @@ body {
|
||||
color: #92b2a7;
|
||||
}
|
||||
|
||||
.alert-fadeout {
|
||||
opacity: 1;
|
||||
transition: opacity 1s ease;
|
||||
animation: fadeout 4s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeout {
|
||||
0%, 70% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@ -256,4 +256,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Oculta los alerts cuando se termina la animacion:
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll('.alert-fadeout').forEach(alert => {
|
||||
alert.addEventListener('animationend', () => {
|
||||
alert.classList.add('d-none');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
})();
|
||||
|
||||
@ -422,7 +422,7 @@ $(() => {
|
||||
return;
|
||||
}
|
||||
// Éxito real: cerrar y recargar tabla
|
||||
modal.addClass('d-none');
|
||||
$('#direccionFormModal').modal('hide');
|
||||
seleccionarDireccionEnvio();
|
||||
},
|
||||
error: function (xhr) {
|
||||
@ -432,7 +432,6 @@ $(() => {
|
||||
const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0;
|
||||
const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add');
|
||||
$('#direccionModal .modal-title').text(title);
|
||||
initSelect2Cliente(true);
|
||||
return;
|
||||
}
|
||||
// Fallback
|
||||
|
||||
@ -153,10 +153,59 @@ $(() => {
|
||||
$('#btn-checkout').prop('disabled', true);
|
||||
});
|
||||
|
||||
|
||||
$('input[name="paymentMethod"]').on('change', function() {
|
||||
|
||||
$('input[name="paymentMethod"]').on('change', function () {
|
||||
const method = $(this).val();
|
||||
// set the hidden input value in the form
|
||||
$('input[name="method"]').val(method);
|
||||
});
|
||||
|
||||
$(document).on("change", ".direccionFacturacion", function () {
|
||||
const isChecked = $(this).is(':checked');
|
||||
if (isChecked) {
|
||||
$('.direccionFacturacionItems').removeClass('d-none');
|
||||
} else {
|
||||
$('.direccionFacturacionItems').addClass('d-none');
|
||||
$('#razonSocial').val('');
|
||||
$('#tipoIdentificacionFiscal').val('DNI');
|
||||
$('#identificacionFiscal').val('');
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on('submit', '#direccionForm', function (e) {
|
||||
e.preventDefault();
|
||||
const $form = $(this);
|
||||
|
||||
$.ajax({
|
||||
url: $form.attr('action'),
|
||||
type: 'POST', // PUT simulado via _method
|
||||
data: $form.serialize(),
|
||||
dataType: 'html',
|
||||
success: function (html) {
|
||||
// Si por cualquier motivo llega 200 con fragmento, lo insertamos igual
|
||||
if (typeof html === 'string' && html.indexOf('id="direccionForm"') !== -1 && html.indexOf('<html') === -1) {
|
||||
$('#direccionFormModalBody').html(html);
|
||||
const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0;
|
||||
const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add');
|
||||
$('#direccionModal .modal-title').text(title);
|
||||
return;
|
||||
}
|
||||
// Éxito real: cerrar y recargar tabla
|
||||
$('#direccionFormModal').modal('hide');
|
||||
seleccionarDireccionEnvio();
|
||||
},
|
||||
error: function (xhr) {
|
||||
// Con 422 devolvemos el fragmento con errores aquí
|
||||
if (xhr.status === 422 && xhr.responseText) {
|
||||
$('#direccionFormModalBody').html(xhr.responseText);
|
||||
const isEdit = $('#direccionFormModalBody #direccionForm input[name="_method"][value="PUT"]').length > 0;
|
||||
const title = $('#direccionFormModalBody #direccionForm').data(isEdit ? 'edit' : 'add');
|
||||
$('#direccionModal .modal-title').text(title);
|
||||
return;
|
||||
}
|
||||
// Fallback
|
||||
$('#direccionFormModalBody').html('<div class="p-3 text-danger">Error inesperado.</div>');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -181,7 +181,7 @@
|
||||
});
|
||||
|
||||
// Submit del form en el modal
|
||||
$(document).on('submit', '#direccionForm', function (e) {
|
||||
$(document).on('submit', '#direccionForm', function (e) {
|
||||
e.preventDefault();
|
||||
const $form = $(this);
|
||||
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
$(() => {
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content');
|
||||
const csrfHeader = document.querySelector('meta[name="_csrf_header"]')?.getAttribute('content');
|
||||
if (window.$ && csrfToken && csrfHeader) {
|
||||
$.ajaxSetup({
|
||||
beforeSend: function (xhr) {
|
||||
xhr.setRequestHeader(csrfHeader, csrfToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const language = document.documentElement.lang || 'es-ES';
|
||||
|
||||
// Comprueba dependencias antes de iniciar
|
||||
if (!window.DataTable) {
|
||||
console.error('DataTables no está cargado aún');
|
||||
return;
|
||||
}
|
||||
|
||||
const table = new DataTable('#pagos-redsys-datatable', {
|
||||
processing: true,
|
||||
serverSide: true,
|
||||
orderCellsTop: true,
|
||||
pageLength: 50,
|
||||
language: { url: '/assets/libs/datatables/i18n/' + language + '.json' },
|
||||
responsive: true,
|
||||
dom: 'lrBtip',
|
||||
buttons: {
|
||||
dom: {
|
||||
button: {
|
||||
className: 'btn btn-sm btn-outline-primary me-1'
|
||||
},
|
||||
buttons: [
|
||||
{ extend: 'copy' },
|
||||
{ extend: 'csv' },
|
||||
{ extend: 'excel' },
|
||||
{ extend: 'pdf' },
|
||||
{ extend: 'print' },
|
||||
{ extend: 'colvis' }
|
||||
],
|
||||
}
|
||||
},
|
||||
ajax: {
|
||||
url: '/pagos/datatable/redsys',
|
||||
method: 'GET',
|
||||
},
|
||||
order: [[4, 'asc']], // Ordena por fecha por defecto
|
||||
columns: [
|
||||
{ data: 'client', name: 'user.fullName', orderable: true },
|
||||
{ data: 'gateway_order_id', name: 'payments.gateway_order_id', orderable: true },
|
||||
{ data: 'orderId', name: 'order.id', orderable: true },
|
||||
{ data: 'amount_cents', name: 'amount_cents', orderable: true },
|
||||
{ data: 'created_at', name: 'created_at', orderable: true },
|
||||
{ data: 'actions', name: 'actions' }
|
||||
],
|
||||
columnDefs: [{ targets: -1, orderable: false, searchable: false }]
|
||||
});
|
||||
|
||||
})
|
||||
@ -7,9 +7,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="errorEnvio" th:class="${'alert alert-danger' + (errorEnvio ? '' : ' d-none')}" role="alert"
|
||||
<div th:if="${errorPago}" class="alert alert-danger alert-fadeout my-1" role="alert" th:text="${errorPago}"></div>
|
||||
|
||||
<div id="errorEnvio" th:class="${'alert alert-danger my-1' + (errorEnvio ? '' : ' d-none')}" role="alert"
|
||||
th:text="#{cart.errors.shipping}"></div>
|
||||
<div th:if="${!#strings.isEmpty(errorMessage) and items != null and !items.isEmpty()}" class="alert alert-danger "
|
||||
<div th:if="${!#strings.isEmpty(errorMessage) and items != null and !items.isEmpty()}" class="alert alert-danger my-1 "
|
||||
role="alert" th:text="${errorMessage}"></div>
|
||||
|
||||
<div class="alert alert-danger alert-shipment d-none" role="alert"
|
||||
|
||||
@ -35,7 +35,9 @@
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
<div th:if="${successPago}" class="alert alert-success alert-fadeout my-1" role="alert" th:text="${successPago}"></div>
|
||||
|
||||
<div th:if="${items.isEmpty()}">
|
||||
<div id="alert-empty"class="alert alert-info" role="alert" th:text="#{cart.empty}"></div>
|
||||
</div>
|
||||
|
||||
@ -41,6 +41,7 @@
|
||||
<form th:action="@{/pagos/redsys/crear}" method="post">
|
||||
<input type="hidden" name="amountCents" th:value="${summary.amountCents}" />
|
||||
<input type="hidden" name="method" value="card"/>
|
||||
<input type="hidden" name="cartId" th:value="${summary.cartId}" />
|
||||
<button id="btn-checkout" type="submit" class="btn btn-secondary w-100 mt-2"
|
||||
th:text="#{checkout.make-payment}" disabled>Checkout</button>
|
||||
</form>
|
||||
|
||||
@ -0,0 +1,108 @@
|
||||
<!doctype html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{imprimelibros/layout}">
|
||||
|
||||
<head>
|
||||
<th:block layout:fragment="pagetitle" />
|
||||
<th:block th:replace="~{imprimelibros/partials/head-css :: head-css}" />
|
||||
<th:block layout:fragment="pagecss">
|
||||
<link th:href="@{/assets/libs/datatables/dataTables.bootstrap5.min.css}" rel="stylesheet" />
|
||||
</th:block>
|
||||
<th:block layout:fragment="pagecss">
|
||||
</th:block>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<div th:replace="~{imprimelibros/partials/topbar :: topbar}" />
|
||||
<div th:replace="~{imprimelibros/partials/sidebar :: sidebar}"
|
||||
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||
|
||||
<th:block layout:fragment="content">
|
||||
<div th:if="${#authorization.expression('isAuthenticated()')}">
|
||||
|
||||
<div class="container-fluid">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/"><i class="ri-home-5-fill"></i></a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page" th:text="#{pagos.module-title}">
|
||||
Gestión de Pagos</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="container-fluid">
|
||||
|
||||
<div th:if="${errorMessage}" class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<span th:text="${errorMessage}"></span>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
|
||||
|
||||
<ul class="nav nav-pills arrow-navtabs nav-secondary-outline bg-light mb-3" role="tablist"
|
||||
sec:authorize="isAuthenticated() and hasAnyRole('SUPERADMIN','ADMIN')">
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link active" data-bs-toggle="tab" href="#arrow-redsys" role="tab"
|
||||
aria-selected="true">
|
||||
<span class="d-block d-sm-none"><i class="mdi mdi-home-variant"></i></span>
|
||||
<span class="d-none d-sm-block" th:text="#{pagos.tab.movimientos-redsys}">Movimientos
|
||||
Redsys</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link" data-bs-toggle="tab" href="#arrow-transferencias" role="tab"
|
||||
aria-selected="false" tabindex="-1">
|
||||
<span class="d-block d-sm-none"><i class="mdi mdi-account"></i></span>
|
||||
<span class="d-none d-sm-block"
|
||||
th:text="#{pagos.tab.transferencias-bancarias}">Transferencias bancarias</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Tab panes -->
|
||||
<div class="tab-content text-muted">
|
||||
<div class="tab-pane active show" id="arrow-redsys" role="tabpanel">
|
||||
|
||||
<div th:insert="~{imprimelibros/pagos/tabla-redsys :: tabla-redsys}">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="tab-pane" id="arrow-transferencias" role="tabpanel">
|
||||
<!---
|
||||
<div
|
||||
th:insert="~{imprimelibros/presupuestos/presupuesto-list-items/tabla-anonimos :: tabla-anonimos}">
|
||||
</div>
|
||||
-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</th:block>
|
||||
|
||||
<th:block th:replace="~{theme/partials/vendor-scripts :: scripts}" />
|
||||
<th:block layout:fragment="pagejs">
|
||||
<script th:inline="javascript">
|
||||
window.languageBundle = /*[[${languageBundle}]]*/ {};
|
||||
</script>
|
||||
|
||||
<script th:src="@{/assets/libs/datatables/datatables.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/datatables/dataTables.bootstrap5.min.js}"></script>
|
||||
|
||||
<!-- JS de Buttons y dependencias -->
|
||||
<script th:src="@{/assets/libs/datatables/dataTables.buttons.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/jszip/jszip.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/pdfmake/pdfmake.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/pdfmake/vfs_fonts.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/datatables/buttons.html5.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/datatables/buttons.print.min.js}"></script>
|
||||
<script th:src="@{/assets/libs/datatables/buttons.colVis.min.js}"></script>
|
||||
|
||||
|
||||
<script type="module" th:src="@{/assets/js/pages/imprimelibros/pagos/pagos.js}"></script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -0,0 +1,24 @@
|
||||
<div th:fragment="tabla-redsys">
|
||||
<table id="pagos-redsys-datatable" class="table table-striped table-nowrap responsive w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" th:text="#{pagos.table.cliente.nombre}">ID</th>
|
||||
<th scope="col" th:text="#{pagos.table.redsys.id}">Cliente</th>
|
||||
<th scope="col" th:text="#{pagos.table.pedido.id}">Pedido</th>
|
||||
<th scope="col" th:text="#{pagos.table.cantidad}">Cantidad</th>
|
||||
<th scope="col" th:text="#{pagos.table.fecha}">Fecha</th>
|
||||
<th scope="col" th:text="#{pagos.table.acciones}">Acciones</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="user.fullName" /></th>
|
||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="user.fullName" /></th>
|
||||
<th><input type="text" class="form-control form-control-sm presupuesto-filter" data-col="payments.gateway_order_id" /></th>
|
||||
<th></th>
|
||||
<th></th>
|
||||
<th></th> <!-- Acciones (sin filtro) -->
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@ -53,6 +53,14 @@
|
||||
th:text="#{app.sidebar.direcciones}">Mis Direcciones</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link menu-link" href="/pagos" th:if="${#authentication.principal.role == 'SUPERADMIN'}">
|
||||
<i class="ri-money-euro-box-line"></i>
|
||||
<span
|
||||
th:text="#{app.sidebar.gestion-pagos}">Administrar Pagos</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
<li th:if="${#authentication.principal.role == 'SUPERADMIN' or #authentication.principal.role == 'ADMIN'}" class="nav-item">
|
||||
<a class="nav-link menu-link collapsed" href="#sidebarConfig" data-bs-toggle="collapse"
|
||||
role="button" aria-expanded="false" aria-controls="sidebarConfig">
|
||||
|
||||
Reference in New Issue
Block a user