diff --git a/src/main/java/com/imprimelibros/erp/cart/CartRepository.java b/src/main/java/com/imprimelibros/erp/cart/CartRepository.java index c9a8cd4..481ff51 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartRepository.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartRepository.java @@ -3,7 +3,6 @@ package com.imprimelibros.erp.cart; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; import java.util.Optional; public interface CartRepository extends JpaRepository { diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java index 47d9c51..fd60256 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -7,9 +7,7 @@ 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.Notification; -import com.imprimelibros.erp.redsys.RedsysService.PaymentRequest; -import org.springframework.beans.factory.annotation.Autowired; +import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -27,91 +25,126 @@ public class PaymentService { private final ObjectMapper om = new ObjectMapper(); public PaymentService(PaymentRepository payRepo, - PaymentTransactionRepository txRepo, - RefundRepository refundRepo, - RedsysService redsysService) { + PaymentTransactionRepository txRepo, + RefundRepository refundRepo, + RedsysService redsysService) { this.payRepo = payRepo; this.txRepo = txRepo; this.refundRepo = refundRepo; this.redsysService = redsysService; } - /** Crea Payment y devuelve form auto-submit Redsys. Ds_Order = 12 dígitos con el ID. */ + /** + * 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) throws Exception { + public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method) + throws Exception { Payment p = new Payment(); - p.setOrderId(orderId); + p.setOrderId(orderId); // <- ahora puede ser null p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("redsys"); 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()); p.setGatewayOrderId(dsOrder); payRepo.save(p); - PaymentRequest req = new PaymentRequest(dsOrder, amountCents, "Compra en Imprimelibros", "card"); - return redsysService.buildRedirectForm(req); + RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents, + "Compra en Imprimelibros"); + + if ("bizum".equalsIgnoreCase(method)) { + return redsysService.buildRedirectFormBizum(req); + } else { + return redsysService.buildRedirectForm(req); + } } - /** Procesa notificación Redsys (ok/notify). Idempotente. */ + // si aún tienes la versión antigua sin method, puedes dejar este overload si te + // viene bien: @Transactional - public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { - Notification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); + public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception { + return createRedsysPayment(orderId, amountCents, currency, "card"); + } - Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.getOrder()) - .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.getOrder())); + /** + * Procesa una notificación Redsys (OK/notify) con la API oficial: + * - validateAndParseNotification usa createMerchantSignatureNotif + + * decodeMerchantParameters + */ + @Transactional + public void handleRedsysNotification(String dsSignature, String dsMerchantParametersB64) throws Exception { + RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParametersB64); - if (!Objects.equals(p.getCurrency(), notif.getCurrency())) + Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order) + .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); + + if (!Objects.equals(p.getCurrency(), notif.currency)) { throw new IllegalStateException("Divisa inesperada"); - if (!Objects.equals(p.getAmountTotalCents(), notif.getAmountCents())) + } + if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) { throw new IllegalStateException("Importe inesperado"); + } - // ¿Ya registrado? Si ya capturaste, no repitas. - if (p.getStatus() == PaymentStatus.CAPTURED || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED || p.getStatus() == PaymentStatus.REFUNDED) { - return; // idempotencia simple a nivel Payment + // Idempotencia sencilla: si ya está capturado o reembolsado, no creamos otra + // transacción + if (p.getStatus() == PaymentStatus.CAPTURED + || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED + || p.getStatus() == PaymentStatus.REFUNDED) { + return; } PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.CAPTURE); tx.setCurrency(p.getCurrency()); - tx.setAmountCents(notif.getAmountCents()); - tx.setStatus(notif.isAuthorized() ? PaymentTransactionStatus.SUCCEEDED : PaymentTransactionStatus.FAILED); - // En Redsys el authorization code suele estar en Ds_AuthorisationCode - Object authCode = notif.getRaw().get("Ds_AuthorisationCode"); + tx.setAmountCents(notif.amountCents); + tx.setStatus(notif.authorized() ? PaymentTransactionStatus.SUCCEEDED + : PaymentTransactionStatus.FAILED); + + Object authCode = notif.raw.get("Ds_AuthorisationCode"); tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null); - tx.setGatewayResponseCode(notif.getResponse()); - tx.setResponsePayload(om.writeValueAsString(notif.getRaw())); + tx.setGatewayResponseCode(notif.response); + tx.setResponsePayload(om.writeValueAsString(notif.raw)); tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); - if (notif.isAuthorized()) { + if (notif.authorized()) { p.setAuthorizationCode(tx.getGatewayTransactionId()); p.setStatus(PaymentStatus.CAPTURED); - p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.getAmountCents()); + p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); p.setAuthorizedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now()); } else { p.setStatus(PaymentStatus.FAILED); p.setFailedAt(LocalDateTime.now()); } + payRepo.save(p); } - /** Refund (simulado a nivel pasarela; actualiza BD). Sustituye gatewayRefundId por el real cuando lo tengas. */ + // ---- refundViaRedsys y bank_transfer igual que antes, no tocan RedsysService + // ---- + @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"); + 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"); + if (amountCents > maxRefundable) + throw new IllegalStateException("Importe de devolución supera lo capturado"); txRepo.findByIdempotencyKey(idempotencyKey) - .ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); }); + .ifPresent(t -> { + throw new IllegalStateException("Reembolso ya procesado"); + }); Refund r = new Refund(); r.setPayment(p); @@ -120,7 +153,8 @@ public class PaymentService { r.setRequestedAt(LocalDateTime.now()); r = refundRepo.save(r); - String gatewayRefundId = "REF-" + UUID.randomUUID(); // TODO: sustituir por el ID real de Redsys si usas su canal de devoluciones + String gatewayRefundId = "REF-" + UUID.randomUUID(); // aquí iría el ID real si alguna vez llamas a un API de + // devoluciones PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); @@ -148,23 +182,22 @@ public class PaymentService { payRepo.save(p); } - /** Transferencia bancaria: crea Payment en espera de ingreso. */ @Transactional public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) { Payment p = new Payment(); - p.setOrderId(orderId); + p.setOrderId(orderId); // null en tu caso actual p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("bank_transfer"); - p.setStatus(PaymentStatus.REQUIRES_ACTION); + p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso return payRepo.save(p); } - /** Marca transferencia como conciliada (capturada). */ @Transactional public void markBankTransferAsCaptured(Long paymentId) { Payment p = payRepo.findById(paymentId).orElseThrow(); - if (!"bank_transfer".equals(p.getGateway())) throw new IllegalStateException("No es transferencia"); + if (!"bank_transfer".equals(p.getGateway())) + throw new IllegalStateException("No es transferencia"); p.setAmountCapturedCents(p.getAmountTotalCents()); p.setCapturedAt(LocalDateTime.now()); p.setStatus(PaymentStatus.CAPTURED); diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Payment.java b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java index 501941a..18267f7 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/Payment.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java @@ -11,7 +11,7 @@ public class Payment { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(name = "order_id", nullable = false) + @Column(name = "order_id") private Long orderId; @Column(name = "user_id") @@ -161,4 +161,20 @@ public class Payment { 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(); + } } diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java index 921b224..bd28f98 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java @@ -1,83 +1,154 @@ 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.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Controller; -import org.springframework.ui.Model; import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import org.springframework.transaction.annotation.Transactional; + @Controller @RequestMapping("/pagos/redsys") public class RedsysController { - private final RedsysService service; + private final PaymentService paymentService; - public RedsysController(RedsysService service) { - this.service = service; + public RedsysController(PaymentService paymentService) { + this.paymentService = paymentService; } - @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 notifyRedsys( - @RequestParam("Ds_Signature") String dsSignature, - @RequestParam("Ds_MerchantParameters") String dsMerchantParameters) { + public ResponseEntity crearPago(@RequestParam("amountCents") Long amountCents, + @RequestParam("method") String method) throws Exception { + if ("bank-transfer".equalsIgnoreCase(method)) { + // 1) Creamos el Payment interno SIN orderId (null) + Payment p = paymentService.createBankTransferPayment(null, amountCents, "EUR"); + + // 2) Mostramos instrucciones de transferencia + String html = """ + Pago por transferencia + +

Pago por transferencia bancaria

+

Hemos registrado tu intención de pedido.

+

Importe: %s €

+

IBAN: ES00 1234 5678 9012 3456 7890

+

Concepto: TRANSF-%d

+

En cuanto recibamos la transferencia, procesaremos tu pedido.

+

Volver al resumen

+ + """.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(null, amountCents, "EUR", method); + + String html = """ + Redirigiendo a Redsys… + +
+ + + + +
+ + """.formatted( + form.action(), + form.signatureVersion(), + form.merchantParameters(), + form.signature()); + + 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") + @ResponseBody + public ResponseEntity okGet() { + String html = """ +

Pago procesado

+

Si el pago ha sido autorizado, verás el pedido en tu área de usuario o recibirás un email de confirmación.

+

Volver a la tienda

+ """; + return ResponseEntity.ok(html); + } + + // 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 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("

Pago realizado correctamente

Volver"); } catch (Exception e) { - return ResponseEntity.status(500).body("ERROR"); + return ResponseEntity.badRequest() + .body("

Error validando pago

" + e.getMessage() + "
"); } } - @PostMapping("/ok") - public String okReturn(@RequestParam("Ds_Signature") String dsSignature, - @RequestParam("Ds_MerchantParameters") String dsMerchantParameters, - Model model) { + @GetMapping("/ko") + @ResponseBody + public ResponseEntity koGet() { + return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); + } + + @PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @ResponseBody + public ResponseEntity koPost(@RequestParam Map form) { + // Podrías loguear 'form' si quieres ver qué manda Redsys + return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); + } + + @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @ResponseBody + @Transactional + public String notifyRedsys(@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"; + paymentService.handleRedsysNotification(signature, merchantParameters); + return "OK"; } catch (Exception e) { - model.addAttribute("error", "No se pudo validar la respuesta de Redsys."); - return "imprimelibros/payments/redsys-ko"; + return "ERROR"; } } - @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 = "/refund/{paymentId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + @ResponseBody + public ResponseEntity refund(@PathVariable Long paymentId, + @RequestParam("amountCents") Long amountCents) { + try { + String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID(); + paymentService.refundViaRedsys(paymentId, amountCents, idem); + return ResponseEntity.ok("Refund solicitado"); + } catch (Exception e) { + return ResponseEntity.badRequest().body("Error refund: " + e.getMessage()); + } } - } diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java index df8c484..9ceba5a 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java @@ -2,7 +2,6 @@ package com.imprimelibros.erp.redsys; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; - import sis.redsys.api.ApiMacSha256; import com.fasterxml.jackson.core.type.TypeReference; @@ -38,18 +37,28 @@ public class RedsysService { private String env; // ---------- RECORDS ---------- - public record PaymentRequest(String order, long amountCents, String description) { - } + // Pedido a Redsys + public record PaymentRequest(String order, long amountCents, String description) {} - public record FormPayload(String action, String signatureVersion, String merchantParameters, String signature) { - } + // 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,6 +67,15 @@ public class RedsysService { api.setParameter("DS_MERCHANT_URLOK", urlOk); api.setParameter("DS_MERCHANT_URLKO", urlKo); + 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); @@ -84,27 +102,29 @@ public class RedsysService { // ---------- STEP 4: Validar notificación ---------- public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64) - throws Exception { - Map mp = decodeMerchantParametersToMap(dsMerchantParametersB64); - RedsysNotification notif = new RedsysNotification(mp); + throws Exception { + Map mp = decodeMerchantParametersToMap(dsMerchantParametersB64); + RedsysNotification notif = new RedsysNotification(mp); - if (notif.order == null || notif.order.isBlank()) { - throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters"); + if (notif.order == null || notif.order.isBlank()) { + throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters"); + } + + ApiMacSha256 api = new ApiMacSha256(); + api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64); + + String expected = api.createMerchantSignatureNotif( + secretKeyBase64, + api.decodeMerchantParameters(dsMerchantParametersB64) + ); + + if (!safeEqualsB64(dsSignature, 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)) diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index 5c025ce..ee004cd 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -22,4 +22,4 @@ safekat.api.password=Safekat2024 redsys.environment=test 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 \ No newline at end of file +redsys.urls.notify=https://hns2jx2x-8080.uks1.devtunnels.ms/pagos/redsys/notify \ No newline at end of file diff --git a/src/main/resources/db/changelog/changesets/0007-payments-core.yml b/src/main/resources/db/changelog/changesets/0007-payments-core.yml index b94a27e..2d3cd5c 100644 --- a/src/main/resources/db/changelog/changesets/0007-payments-core.yml +++ b/src/main/resources/db/changelog/changesets/0007-payments-core.yml @@ -7,53 +7,172 @@ databaseChangeLog: - 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) } # alias/token de pasarela (nunca PAN) - - 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 } } + - 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 (una intención de cobro por pedido) + # 2) payments - createTable: tableName: payments columns: - - column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } } - - column: { name: order_id, type: BIGINT, constraints: { nullable: false } } # tu pedido interno - - column: { name: user_id, type: BIGINT } - - column: { name: payment_method_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 } } # 'redsys' - - column: { name: gateway_payment_id, type: VARCHAR(128) } # id en pasarela - - column: { name: gateway_order_id, type: VARCHAR(12) } # Ds_Order - - 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 } } + - 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: payment_method_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 + - addForeignKeyConstraint: baseTableName: payments baseColumnNames: payment_method_id @@ -61,34 +180,104 @@ databaseChangeLog: referencedColumnNames: id constraintName: fk_payments_payment_methods onDelete: SET NULL - - createIndex: { tableName: payments, indexName: idx_payments_order, columns: [ {name: order_id} ] } - - createIndex: { tableName: payments, indexName: idx_payments_gateway, columns: [ {name: gateway}, {name: gateway_payment_id} ] } - - createIndex: { tableName: payments, indexName: idx_payments_status, columns: [ {name: status} ] } + + - 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 (libro mayor: AUTH/CAPTURE/REFUND/VOID) + # 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 } } + - 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 @@ -96,30 +285,92 @@ databaseChangeLog: 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: [ {name: payment_id} ] } - - createIndex: { tableName: payment_transactions, indexName: idx_tx_type_status, columns: [ {name: type}, {name: status} ] } - - createIndex: { tableName: payment_transactions, indexName: idx_tx_idem, columns: [ {name: idempotency_key} ] } - # 4) refunds (orquestador de devoluciones) + - 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 } # REFUND en payment_transactions - - 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 } + - 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 @@ -127,6 +378,7 @@ databaseChangeLog: referencedColumnNames: id constraintName: fk_ref_payment onDelete: CASCADE + - addForeignKeyConstraint: baseTableName: refunds baseColumnNames: transaction_id @@ -134,47 +386,138 @@ databaseChangeLog: 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: [ {name: payment_id} ] } - - createIndex: { tableName: refunds, indexName: idx_ref_status, columns: [ {name: status} ] } - # 5) webhooks (para Redsys: notificaciones asincrónicas) + - 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 } } # 'redsys' - - 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 } } + - 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: [ {name: processed} ] } - # 6) idempotency_keys (evitar doble REFUND o reprocesos) + - createIndex: + tableName: webhook_events + indexName: idx_webhook_processed + columns: + - 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 } + - 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: [ {name: resource_id} ] } + + - createIndex: + tableName: idempotency_keys + indexName: idx_idem_resource + columns: + - column: + name: resource_id diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js b/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js index cb48b09..b5e01bd 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/checkout/checkout.js @@ -133,6 +133,7 @@ $(() => { const html = await response.text(); $('#direccion-div').append(html); $('#addBillingAddressBtn').addClass('d-none'); + $('#btn-checkout').prop('disabled', false); hideLoader(); return true; } @@ -149,6 +150,13 @@ $(() => { const $div = $card.parent(); $card.remove(); $('#addBillingAddressBtn').removeClass('d-none'); - + $('#btn-checkout').prop('disabled', true); + }); + + + $('input[name="paymentMethod"]').on('change', function() { + const method = $(this).val(); + // set the hidden input value in the form + $('input[name="method"]').val(method); }); }); diff --git a/src/main/resources/templates/imprimelibros/checkout/_pago.html b/src/main/resources/templates/imprimelibros/checkout/_pago.html index 269dd9e..31c4c21 100644 --- a/src/main/resources/templates/imprimelibros/checkout/_pago.html +++ b/src/main/resources/templates/imprimelibros/checkout/_pago.html @@ -3,7 +3,7 @@
- +
- +