From 51d22515e835b7b004f04acaf34f98268b651d9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sat, 1 Nov 2025 12:23:36 +0100 Subject: [PATCH 01/13] modificado el custom switch en el swal (en el css) --- src/main/resources/static/assets/css/imprimelibros.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/resources/static/assets/css/imprimelibros.css b/src/main/resources/static/assets/css/imprimelibros.css index 3094832..01274ef 100644 --- a/src/main/resources/static/assets/css/imprimelibros.css +++ b/src/main/resources/static/assets/css/imprimelibros.css @@ -40,6 +40,14 @@ body { margin: 0; } +.swal2-popup .form-switch-custom .form-check-input:checked{ + border-color: #92b2a7; + background-color: #cbcecd; +} +.swal2-popup .form-switch-custom .form-check-input:checked::before { + color: #92b2a7; +} + .form-switch-presupuesto .form-check-input:checked { border-color: #92b2a7; background-color: #cbcecd; From 4d451cc85ee717d074a6e6f6bf8e73dd06e2030c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sun, 2 Nov 2025 11:57:05 +0100 Subject: [PATCH 02/13] a falta del pago --- .../imprimelibros/erp/cart/CartService.java | 1 + .../erp/checkout/CheckoutController.java | 37 ++-- .../erp/direcciones/DireccionController.java | 23 +++ .../erp/direcciones/DireccionRepository.java | 4 + .../erp/direcciones/DireccionService.java | 59 +++++++ src/main/resources/i18n/pedidos_es.properties | 23 ++- src/main/resources/static/assets/css/app.css | 5 +- .../resources/static/assets/css/checkout.css | 5 + .../static/assets/css/imprimelibros.css | 3 +- .../js/pages/imprimelibros/cart/cart.js | 10 +- .../pages/imprimelibros/checkout/checkout.js | 137 ++++++++++++++- .../imprimelibros/cart/_cartSummary.html | 10 +- .../imprimelibros/checkout/_envio.html | 43 ----- .../imprimelibros/checkout/_pago.html | 35 +++- .../imprimelibros/checkout/_summary.html | 51 ++++++ .../imprimelibros/checkout/checkout.html | 160 ++++-------------- .../direcciones/direccionBillingCard.html | 31 ++++ 17 files changed, 429 insertions(+), 208 deletions(-) create mode 100644 src/main/resources/static/assets/css/checkout.css delete mode 100644 src/main/resources/templates/imprimelibros/checkout/_envio.html create mode 100644 src/main/resources/templates/imprimelibros/checkout/_summary.html create mode 100644 src/main/resources/templates/imprimelibros/direcciones/direccionBillingCard.html diff --git a/src/main/java/com/imprimelibros/erp/cart/CartService.java b/src/main/java/com/imprimelibros/erp/cart/CartService.java index 7d07149..f900791 100644 --- a/src/main/java/com/imprimelibros/erp/cart/CartService.java +++ b/src/main/java/com/imprimelibros/erp/cart/CartService.java @@ -291,6 +291,7 @@ public class CartService { summary.put("fidelizacion", fidelizacion + "%"); summary.put("descuento", Utils.formatCurrency(-descuento, locale)); summary.put("total", Utils.formatCurrency(total, locale)); + summary.put("amountCents", Math.round(total * 100)); summary.put("errorShipmentCost", errorShipementCost); return summary; diff --git a/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java b/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java index 00474fc..79a9563 100644 --- a/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java +++ b/src/main/java/com/imprimelibros/erp/checkout/CheckoutController.java @@ -6,17 +6,20 @@ import java.util.Locale; import java.util.Map; import org.springframework.context.MessageSource; +import org.springframework.http.HttpStatus; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.server.ResponseStatusException; import com.imprimelibros.erp.common.Utils; import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.paises.PaisesService; - +import com.imprimelibros.erp.direcciones.Direccion; import com.imprimelibros.erp.direcciones.DireccionService; - +import com.imprimelibros.erp.cart.Cart; import com.imprimelibros.erp.cart.CartService; @Controller @@ -44,23 +47,29 @@ public class CheckoutController { List keys = List.of( "app.cancelar", "app.seleccionar", - "checkout.shipping.add.title", - "checkout.shipping.select-placeholder", - "checkout.shipping.new-address", "app.yes", - "app.cancelar"); + "checkout.billing-address.title", + "checkout.billing-address.new-address", + "checkout.billing-address.select-placeholder", + "checkout.billing-address.errors.noAddressSelected"); Map translations = translationService.getTranslations(locale, keys); model.addAttribute("languageBundle", translations); - var items = this.cartService.listItems(Utils.currentUserId(principal), locale); - for (var item : items) { - if (item.get("hasSample") != null && (Boolean) item.get("hasSample")) { - model.addAttribute("hasSample", true); - break; - } - } - model.addAttribute("items", items); + Long userId = Utils.currentUserId(principal); + Cart cart = cartService.getOrCreateActiveCart(userId); + model.addAttribute("summary", cartService.getCartSummary(cart, locale)); return "imprimelibros/checkout/checkout"; // crea esta vista si quieres (tabla simple) } + + @GetMapping("/get-address/{id}") + public String getDireccionCard(@PathVariable Long id, Model model, Locale locale) { + Direccion dir = direccionService.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + model.addAttribute("pais", messageSource.getMessage("paises." + dir.getPais().getKeyword(), null, + dir.getPais().getKeyword(), locale)); + model.addAttribute("direccion", dir); + + return "imprimelibros/direcciones/direccionBillingCard :: direccionBillingCard(direccion=${direccion}, pais=${pais})"; + } } diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java index 398500c..bcd42a5 100644 --- a/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionController.java @@ -506,6 +506,29 @@ public class DireccionController { } + @GetMapping(value = "/facturacion/select2", produces = "application/json") + @ResponseBody + public Map getSelect2Facturacion( + @RequestParam(value = "q", required = false) String q1, + @RequestParam(value = "term", required = false) String q2, + Authentication auth) { + + boolean isAdmin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_SUPERADMIN")); + + Long currentUserId = null; + if (!isAdmin) { + if (auth != null && auth.getPrincipal() instanceof UserDetailsImpl udi) { + currentUserId = udi.getId(); + } else if (auth != null) { + currentUserId = userRepo.findIdByUserNameIgnoreCase(auth.getName()).orElse(null); + } + } + + return direccionService.getForSelectFacturacion(q1, q2, isAdmin ? null : currentUserId); + + } + private boolean isOwnerOrAdmin(Authentication auth, Long ownerId) { if (auth == null) { return false; diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java index fc45640..7abbf33 100644 --- a/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionRepository.java @@ -38,6 +38,10 @@ public interface DireccionRepository // find by user_id List findByUserId(Long userId); + // find by user_id and direccion_facturacion = true + @Query("SELECT d FROM Direccion d WHERE (:userId IS NULL OR d.user.id = :userId) AND d.direccionFacturacion = true") + List findByUserIdAndDireccionFacturacion(@Param("userId") Long userId); + // find by user_id with deleted @Query(value = "SELECT * FROM direcciones WHERE user_id = :userId", nativeQuery = true) List findByUserIdWithDeleted(@Param("userId") Long userId); diff --git a/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java b/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java index 1f1d4d5..f74561a 100644 --- a/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java +++ b/src/main/java/com/imprimelibros/erp/direcciones/DireccionService.java @@ -77,6 +77,65 @@ public class DireccionService { } } + + public Map getForSelectFacturacion(String q1, String q2, Long userId) { + try { + + // Termino de búsqueda (Select2 usa 'q' o 'term' según versión/config) + String search = Optional.ofNullable(q1).orElse(q2); + if (search != null) { + search = search.trim(); + } + final String q = (search == null || search.isEmpty()) + ? null + : search.toLowerCase(); + + List all = repo.findByUserIdAndDireccionFacturacion(userId); + + // Mapear a opciones id/text con i18n y filtrar por búsqueda si llega + List> options = all.stream() + .map(cc -> { + String id = cc.getId().toString(); + String alias = cc.getAlias(); + String direccion = cc.getDireccion(); + String cp = String.valueOf(cc.getCp()); + String ciudad = cc.getCiudad(); + String att = cc.getAtt(); + Map m = new HashMap<>(); + m.put("id", id); // lo normal en Select2: id = valor que guardarás (code3) + m.put("text", alias); // texto mostrado, i18n con fallback a keyword + m.put("cp", cp); + m.put("ciudad", ciudad); + m.put("att", att); + m.put("alias", alias); + m.put("direccion", direccion); + return m; + }) + .filter(opt -> { + if (q == null || q.isEmpty()) + return true; + String cp = opt.get("cp"); + String ciudad = opt.get("ciudad").toLowerCase(); + String att = opt.get("att").toLowerCase(); + String alias = opt.get("alias").toLowerCase(); + String text = opt.get("text").toLowerCase(); + String direccion = opt.get("direccion").toLowerCase(); + return text.contains(q) || cp.contains(q) || ciudad.contains(q) || att.contains(q) + || alias.contains(q) || direccion.contains(q); + }) + .sorted(Comparator.comparing(m -> m.get("text"), Collator.getInstance())) + .collect(Collectors.toList()); + + // Estructura Select2 + Map resp = new HashMap<>(); + resp.put("results", options); + return resp; + } catch (Exception e) { + e.printStackTrace(); + return Map.of("results", List.of()); + } + } + public Optional findById(Long id) { return repo.findById(id); } diff --git a/src/main/resources/i18n/pedidos_es.properties b/src/main/resources/i18n/pedidos_es.properties index a56dd01..84a21ce 100644 --- a/src/main/resources/i18n/pedidos_es.properties +++ b/src/main/resources/i18n/pedidos_es.properties @@ -1,17 +1,16 @@ checkout.title=Finalizar compra -checkout.summay=Resumen de la compra -checkout.shipping=Envío +checkout.summary=Resumen de la compra +checkout.billing-address=Dirección de facturación checkout.payment=Método de pago -checkout.shipping.info=Todos los pedidos incluyen un envío gratuito a la Península y Baleares por línea de pedido. -checkout.shipping.order=Envío del pedido -checkout.shipping.samples=Envío de pruebas -checkout.shipping.onlyOneShipment=Todo el pedido se envía a una única dirección. +checkout.billing-address.title=Seleccione una dirección +checkout.billing-address.new-address=Nueva dirección +checkout.billing-address.select-placeholder=Buscar en direcciones... +checkout.billing-address.errors.noAddressSelected=Debe seleccionar una dirección de facturación para el pedido. -checkout.summary.presupuesto=#Presupuesto -checkout.summary.titulo=Título -checkout.summary.base=Base -checkout.summary.iva-4=IVA 4% -checkout.summary.iva-21=IVA 21% -checkout.summary.envio=Envío \ No newline at end of file +checkout.payment.card=Tarjeta de crédito / débito +checkout.payment.bizum=Bizum +checkout.payment.bank-transfer=Transferencia bancaria + +checkout.make-payment=Realizar el pago \ No newline at end of file diff --git a/src/main/resources/static/assets/css/app.css b/src/main/resources/static/assets/css/app.css index 525d693..79393de 100644 --- a/src/main/resources/static/assets/css/app.css +++ b/src/main/resources/static/assets/css/app.css @@ -8240,7 +8240,8 @@ a { display: none; } .card-radio .form-check-input:checked + .form-check-label { - border-color: #687cfe !important; + border-color: #ff7f5d !important; + background-color: rgba(255, 127, 93, 0.05); } .card-radio .form-check-input:checked + .form-check-label:before { content: "\eb80"; @@ -8249,7 +8250,7 @@ a { top: 2px; right: 6px; font-size: 16px; - color: #687cfe; + color: #ff7f5d; } .card-radio.dark .form-check-input:checked + .form-check-label:before { color: #fff; diff --git a/src/main/resources/static/assets/css/checkout.css b/src/main/resources/static/assets/css/checkout.css new file mode 100644 index 0000000..e61c7e5 --- /dev/null +++ b/src/main/resources/static/assets/css/checkout.css @@ -0,0 +1,5 @@ +.direccion-card { + flex: 1 1 350px; /* ancho mínimo 350px, crece si hay espacio */ + max-width: 350px; /* opcional, para que no se estiren demasiado */ + min-width: 340px; /* protege el ancho mínimo */ +} \ No newline at end of file diff --git a/src/main/resources/static/assets/css/imprimelibros.css b/src/main/resources/static/assets/css/imprimelibros.css index 01274ef..d3af090 100644 --- a/src/main/resources/static/assets/css/imprimelibros.css +++ b/src/main/resources/static/assets/css/imprimelibros.css @@ -55,4 +55,5 @@ body { .form-switch-custom.form-switch-presupuesto .form-check-input:checked::before { color: #92b2a7; -} \ No newline at end of file +} + diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js b/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js index b2f73d5..192b043 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/cart/cart.js @@ -42,6 +42,7 @@ $(() => { if ($('.product').length === 0) { $("#alert-empty").removeClass("d-none"); $('.cart-content').addClass('d-none'); + $('#btn-checkout').prop('disabled', true); return; } else { @@ -59,7 +60,12 @@ $(() => { return; } $(".alert-shipment").addClass("d-none"); - $('#btn-checkout').prop('disabled', false); + if ($("#errorEnvio").hasClass("d-none")) { + $('#btn-checkout').prop('disabled', false); + } + else { + $('#btn-checkout').prop('disabled', true); + } } else { const items = $(".product"); @@ -92,7 +98,7 @@ $(() => { item.find(".alert-icon-shipment").addClass("d-none"); } } - if (errorFound) { + if (errorFound || $("#errorEnvio").hasClass("d-none") === false) { $(".alert-shipment").removeClass("d-none"); $('#btn-checkout').prop('disabled', true); } 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 3aa8e86..cb48b09 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 @@ -1,3 +1,5 @@ +import { showLoader, hideLoader } from '../loader.js'; + $(() => { const csrfToken = document.querySelector('meta[name="_csrf"]')?.getAttribute('content'); @@ -14,8 +16,139 @@ $(() => { const modalEl = document.getElementById('direccionFormModal'); const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + $('#addBillingAddressBtn').on('click', seleccionarDireccionEnvio); - - + async function seleccionarDireccionEnvio() { + + const { value: direccionId, isDenied } = await Swal.fire({ + title: window.languageBundle['checkout.billing-address.title'] || 'Seleccione una dirección', + html: ` + + `, + showCancelButton: true, + showDenyButton: true, + buttonsStyling: false, + confirmButtonText: window.languageBundle['app.seleccionar'] || 'Seleccionar', + cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar', + denyButtonText: window.languageBundle['checkout.billing-address.new-address'] || 'Nueva dirección', + customClass: { + confirmButton: 'btn btn-secondary me-2', + cancelButton: 'btn btn-light', + denyButton: 'btn btn-secondary me-2' + }, + focusConfirm: false, + + // Inicializa cuando el DOM del modal ya existe + didOpen: () => { + const $select = $('#direccionSelect'); + $select.empty(); // limpia placeholder estático + + $select.select2({ + width: '100%', + dropdownParent: $('.swal2-container'), + ajax: { + url: '/direcciones/facturacion/select2', + dataType: 'json', + delay: 250, + data: params => ({ q: params.term || '' }), + processResults: (data) => { + const items = Array.isArray(data) ? data : (data.results || []); + return { + results: items.map(item => ({ + id: item.id, + text: item.text, // ← Select2 necesita 'id' y 'text' + alias: item.alias || 'Sin alias', + att: item.att || '', + direccion: item.direccion || '', + cp: item.cp || '', + ciudad: item.ciudad || '', + html: ` +
+ ${item.alias || 'Sin alias'}
+ ${item.att ? `${item.att}
` : ''} + ${item.direccion || ''}${item.cp ? ', ' + item.cp : ''}${item.ciudad ? ', ' + item.ciudad : ''} +
+ ` + })), + pagination: { more: false } // opcional, evita que espere más páginas + }; + } + + }, + placeholder: window.languageBundle['checkout.billing-address.select-placeholder'] || 'Buscar en direcciones...', + language: language, + + templateResult: data => { + if (data.loading) return data.text; + return $(data.html || data.text); + }, + // Selección más compacta (solo alias + ciudad) + templateSelection: data => { + if (!data.id) return data.text; + const alias = data.alias || data.text; + const ciudad = data.ciudad ? ` — ${data.ciudad}` : ''; + return $(`${alias}${ciudad}`); + }, + escapeMarkup: m => m + }); + }, + + preConfirm: () => { + const $select = $('#direccionSelect'); + const val = $select.val(); + if (!val) { + Swal.showValidationMessage( + window.languageBundle['checkout.billing-address.errors.noAddressSelected'] || 'Por favor, seleccione una dirección.' + ); + return false; + } + return val; + }, + + didClose: () => { + // Limpieza: destruir select2 para evitar fugas + const $select = $('#direccionSelect'); + if ($select.data('select2')) { + $select.select2('destroy'); + } + } + }); + + if (isDenied) { + $.get('/direcciones/direction-form', function (html) { + $('#direccionFormModalBody').html(html); + const title = $('#direccionFormModalBody #direccionForm').data('add'); + $('#direccionFormModal .modal-title').text(title); + modal.show(); + }); + } + + if (direccionId) { + // Obtén el objeto completo seleccionado + showLoader(); + let uri = `/checkout/get-address/${direccionId}`; + const response = await fetch(uri); + if (response.ok) { + const html = await response.text(); + $('#direccion-div').append(html); + $('#addBillingAddressBtn').addClass('d-none'); + hideLoader(); + return true; + } + hideLoader(); + return false; + } + hideLoader(); + return false; + } + + $(document).on('click', '.btn-delete-direccion', function (e) { + e.preventDefault(); + const $card = $(this).closest('.direccion-card'); + const $div = $card.parent(); + $card.remove(); + $('#addBillingAddressBtn').removeClass('d-none'); + + }); }); diff --git a/src/main/resources/templates/imprimelibros/cart/_cartSummary.html b/src/main/resources/templates/imprimelibros/cart/_cartSummary.html index 516e328..88109cf 100644 --- a/src/main/resources/templates/imprimelibros/cart/_cartSummary.html +++ b/src/main/resources/templates/imprimelibros/cart/_cartSummary.html @@ -24,7 +24,7 @@ : - + : @@ -38,12 +38,8 @@ -
- - - -
+ diff --git a/src/main/resources/templates/imprimelibros/checkout/_envio.html b/src/main/resources/templates/imprimelibros/checkout/_envio.html deleted file mode 100644 index 893721c..0000000 --- a/src/main/resources/templates/imprimelibros/checkout/_envio.html +++ /dev/null @@ -1,43 +0,0 @@ -
-
-
-
-
-
Envio del pedido -
-
-
-
-

-
- - -
- - -
-
- -
-
-
- -
-
-
Envio de pruebas -
-
- -
- -
-
- - -
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/_pago.html b/src/main/resources/templates/imprimelibros/checkout/_pago.html index 42eb8c5..269dd9e 100644 --- a/src/main/resources/templates/imprimelibros/checkout/_pago.html +++ b/src/main/resources/templates/imprimelibros/checkout/_pago.html @@ -1,3 +1,36 @@
- +
Método de pago
+
+
+
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+ + +
+ +
+
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/_summary.html b/src/main/resources/templates/imprimelibros/checkout/_summary.html new file mode 100644 index 0000000..47eb645 --- /dev/null +++ b/src/main/resources/templates/imprimelibros/checkout/_summary.html @@ -0,0 +1,51 @@ +
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
:
:
+ :
: + +
+ +
+ +
+
+ +
+ + +
\ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/checkout.html b/src/main/resources/templates/imprimelibros/checkout/checkout.html index 5763197..34bc29f 100644 --- a/src/main/resources/templates/imprimelibros/checkout/checkout.html +++ b/src/main/resources/templates/imprimelibros/checkout/checkout.html @@ -9,7 +9,7 @@ - + @@ -22,6 +22,10 @@
+
+
+
+
+
+ Cargando… +
+
+
-
+
+
Dirección de envío
- + + +
+
-
-
- -
-
-
- - -
- -
-
-
- +
+
+
+
+
-
-
-
-
-
-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
PresupuestoTítulo - Base
- PRESUPUESTO-001 - - Título del presupuesto - - - 0,00 - - -
:
:
: - - -
- -
- -
-
+
- -
- - -
+
+
- + - - - - - - + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/direcciones/direccionBillingCard.html b/src/main/resources/templates/imprimelibros/direcciones/direccionBillingCard.html new file mode 100644 index 0000000..8e2d70a --- /dev/null +++ b/src/main/resources/templates/imprimelibros/direcciones/direccionBillingCard.html @@ -0,0 +1,31 @@ +
+
+ + + + + +
+
+ + + + + + + +
+ +
+ + +
+
\ No newline at end of file From dc8b67b9374ef0a724e8000069e8511fa77055cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sun, 2 Nov 2025 17:14:29 +0100 Subject: [PATCH 03/13] =?UTF-8?q?a=C3=B1adidos=20ficheros=20a=20falta=20de?= =?UTF-8?q?=20modificar=20el=20servicio=20y=20el=20controlador=20redsys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../erp/payments/PaymentService.java | 183 ++++++++++++++++++ .../erp/payments/model/CaptureMethod.java | 4 + .../erp/payments/model/IdempotencyKey.java | 66 +++++++ .../erp/payments/model/IdempotencyScope.java | 3 + .../erp/payments/model/Payment.java | 164 ++++++++++++++++ .../erp/payments/model/PaymentMethod.java | 100 ++++++++++ .../erp/payments/model/PaymentMethodType.java | 4 + .../erp/payments/model/PaymentStatus.java | 8 + .../payments/model/PaymentTransaction.java | 123 ++++++++++++ .../model/PaymentTransactionStatus.java | 4 + .../model/PaymentTransactionType.java | 4 + .../erp/payments/model/Refund.java | 99 ++++++++++ .../erp/payments/model/RefundReason.java | 6 + .../erp/payments/model/RefundStatus.java | 4 + .../erp/payments/model/ThreeDSStatus.java | 4 + .../erp/payments/model/WebhookEvent.java | 88 +++++++++ .../repo/IdempotencyKeyRepository.java | 12 ++ .../repo/PaymentMethodRepository.java | 7 + .../erp/payments/repo/PaymentRepository.java | 11 ++ .../repo/PaymentTransactionRepository.java | 12 ++ .../erp/payments/repo/RefundRepository.java | 12 ++ .../payments/repo/WebhookEventRepository.java | 7 + .../changesets/0007-payments-core.yml | 180 +++++++++++++++++ src/main/resources/db/changelog/master.yml | 4 +- .../imprimelibros/checkout/_summary.html | 9 +- 25 files changed, 1115 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/imprimelibros/erp/payments/PaymentService.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/Payment.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/Refund.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java create mode 100644 src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java create mode 100644 src/main/resources/db/changelog/changesets/0007-payments-core.yml diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java new file mode 100644 index 0000000..15254fe --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -0,0 +1,183 @@ +package com.imprimelibros.erp.payments; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.imprimelibros.erp.payments.model.*; +import com.imprimelibros.erp.payments.repo.PaymentRepository; +import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository; +import com.imprimelibros.erp.payments.repo.RefundRepository; +import com.imprimelibros.erp.redsys.RedsysService; +import com.imprimelibros.erp.redsys.RedsysService.FormPayload; +import com.imprimelibros.erp.redsys.RedsysService.Notification; +import com.imprimelibros.erp.redsys.RedsysService.PaymentRequest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +@Service +public class PaymentService { + + private final PaymentRepository payRepo; + private final PaymentTransactionRepository txRepo; + private final RefundRepository refundRepo; + private final RedsysService redsysService; + private final ObjectMapper om = new ObjectMapper(); + + @Autowired + public PaymentService(PaymentRepository payRepo, + 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. */ + @Transactional + public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception { + Payment p = new Payment(); + p.setOrderId(orderId); + p.setCurrency(currency); + p.setAmountTotalCents(amountCents); + p.setGateway("redsys"); + p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD); + p = payRepo.saveAndFlush(p); + + 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); + } + + /** Procesa notificación Redsys (ok/notify). Idempotente. */ + @Transactional + public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { + Notification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); + + Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.getOrder()) + .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.getOrder())); + + if (!Objects.equals(p.getCurrency(), notif.getCurrency())) + throw new IllegalStateException("Divisa inesperada"); + if (!Objects.equals(p.getAmountTotalCents(), notif.getAmountCents())) + 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 + } + + 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.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null); + tx.setGatewayResponseCode(notif.getResponse()); + tx.setResponsePayload(om.writeValueAsString(notif.getRaw())); + tx.setProcessedAt(LocalDateTime.now()); + txRepo.save(tx); + + if (notif.isAuthorized()) { + p.setAuthorizationCode(tx.getGatewayTransactionId()); + p.setStatus(PaymentStatus.CAPTURED); + p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.getAmountCents()); + 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. */ + @Transactional + public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) { + Payment p = payRepo.findById(paymentId) + .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado")); + + if (amountCents <= 0) throw new IllegalArgumentException("Importe inválido"); + long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents(); + if (amountCents > maxRefundable) throw new IllegalStateException("Importe de devolución supera lo capturado"); + + txRepo.findByIdempotencyKey(idempotencyKey) + .ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); }); + + Refund r = new Refund(); + r.setPayment(p); + r.setAmountCents(amountCents); + r.setStatus(RefundStatus.PENDING); + r.setRequestedAt(LocalDateTime.now()); + r = refundRepo.save(r); + + String gatewayRefundId = "REF-" + UUID.randomUUID(); // TODO: sustituir por el ID real de Redsys si usas su canal de devoluciones + + PaymentTransaction tx = new PaymentTransaction(); + tx.setPayment(p); + tx.setType(PaymentTransactionType.REFUND); + tx.setStatus(PaymentTransactionStatus.SUCCEEDED); + tx.setAmountCents(amountCents); + tx.setCurrency(p.getCurrency()); + tx.setGatewayTransactionId(gatewayRefundId); + tx.setIdempotencyKey(idempotencyKey); + tx.setProcessedAt(LocalDateTime.now()); + txRepo.save(tx); + + r.setStatus(RefundStatus.SUCCEEDED); + r.setTransaction(tx); + r.setGatewayRefundId(gatewayRefundId); + r.setProcessedAt(LocalDateTime.now()); + refundRepo.save(r); + + p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents); + if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) { + p.setStatus(PaymentStatus.REFUNDED); + } else { + p.setStatus(PaymentStatus.PARTIALLY_REFUNDED); + } + payRepo.save(p); + } + + /** 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.setCurrency(currency); + p.setAmountTotalCents(amountCents); + p.setGateway("bank_transfer"); + p.setStatus(PaymentStatus.REQUIRES_ACTION); + 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"); + p.setAmountCapturedCents(p.getAmountTotalCents()); + p.setCapturedAt(LocalDateTime.now()); + p.setStatus(PaymentStatus.CAPTURED); + payRepo.save(p); + + PaymentTransaction tx = new PaymentTransaction(); + tx.setPayment(p); + tx.setType(PaymentTransactionType.CAPTURE); + tx.setStatus(PaymentTransactionStatus.SUCCEEDED); + tx.setAmountCents(p.getAmountTotalCents()); + tx.setCurrency(p.getCurrency()); + tx.setProcessedAt(LocalDateTime.now()); + txRepo.save(tx); + } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java new file mode 100644 index 0000000..3239394 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum CaptureMethod { AUTOMATIC, MANUAL } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java new file mode 100644 index 0000000..bcf200e --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java @@ -0,0 +1,66 @@ +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; } +} + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java new file mode 100644 index 0000000..e7088d4 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java @@ -0,0 +1,3 @@ +package com.imprimelibros.erp.payments.model; + +public enum IdempotencyScope { PAYMENT, REFUND, WEBHOOK } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Payment.java b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java new file mode 100644 index 0000000..501941a --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java @@ -0,0 +1,164 @@ +package com.imprimelibros.erp.payments.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table(name = "payments") +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @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; + + @Column(name = "amount_total_cents", nullable = false) + private Long amountTotalCents; + + @Column(name = "amount_captured_cents", nullable = false) + private Long amountCapturedCents = 0L; + + @Column(name = "amount_refunded_cents", nullable = false) + private Long amountRefundedCents = 0L; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 32) + private PaymentStatus status = PaymentStatus.REQUIRES_PAYMENT_METHOD; + + @Enumerated(EnumType.STRING) + @Column(name = "capture_method", nullable = false, length = 16) + private CaptureMethod captureMethod = CaptureMethod.AUTOMATIC; + + @Column(nullable = false, length = 32) + private String gateway; + + @Column(name = "gateway_payment_id", length = 128) + private String gatewayPaymentId; + + @Column(name = "gateway_order_id", length = 12) + private String gatewayOrderId; + + @Column(name = "authorization_code", length = 32) + private String authorizationCode; + + @Enumerated(EnumType.STRING) + @Column(name = "three_ds_status", nullable = false, length = 32) + private ThreeDSStatus threeDsStatus = ThreeDSStatus.NOT_APPLICABLE; + + @Column(length = 22) + private String descriptor; + + @Lob + @Column(name = "client_ip", columnDefinition = "varbinary(16)") + private byte[] clientIp; + + @Column(name = "authorized_at") + private LocalDateTime authorizedAt; + + @Column(name = "captured_at") + private LocalDateTime capturedAt; + + @Column(name = "canceled_at") + private LocalDateTime canceledAt; + + @Column(name = "failed_at") + private LocalDateTime failedAt; + + @Column(columnDefinition = "json") + private String metadata; + + @Column(name = "created_at", nullable = false, + columnDefinition = "datetime default current_timestamp") + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false, + columnDefinition = "datetime default current_timestamp on update current_timestamp") + private LocalDateTime updatedAt; + + public Payment() {} + + // Getters y setters ↓ (los típicos) + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Long getOrderId() { return orderId; } + public void setOrderId(Long orderId) { this.orderId = orderId; } + + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + + public 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; } + + public Long getAmountTotalCents() { return amountTotalCents; } + public void setAmountTotalCents(Long amountTotalCents) { this.amountTotalCents = amountTotalCents; } + + public Long getAmountCapturedCents() { return amountCapturedCents; } + public void setAmountCapturedCents(Long amountCapturedCents) { this.amountCapturedCents = amountCapturedCents; } + + public Long getAmountRefundedCents() { return amountRefundedCents; } + public void setAmountRefundedCents(Long amountRefundedCents) { this.amountRefundedCents = amountRefundedCents; } + + public PaymentStatus getStatus() { return status; } + public void setStatus(PaymentStatus status) { this.status = status; } + + public CaptureMethod getCaptureMethod() { return captureMethod; } + public void setCaptureMethod(CaptureMethod captureMethod) { this.captureMethod = captureMethod; } + + public String getGateway() { return gateway; } + public void setGateway(String gateway) { this.gateway = gateway; } + + public String getGatewayPaymentId() { return gatewayPaymentId; } + public void setGatewayPaymentId(String gatewayPaymentId) { this.gatewayPaymentId = gatewayPaymentId; } + + public String getGatewayOrderId() { return gatewayOrderId; } + public void setGatewayOrderId(String gatewayOrderId) { this.gatewayOrderId = gatewayOrderId; } + + public String getAuthorizationCode() { return authorizationCode; } + public void setAuthorizationCode(String authorizationCode) { this.authorizationCode = authorizationCode; } + + public ThreeDSStatus getThreeDsStatus() { return threeDsStatus; } + public void setThreeDsStatus(ThreeDSStatus threeDsStatus) { this.threeDsStatus = threeDsStatus; } + + public String getDescriptor() { return descriptor; } + public void setDescriptor(String descriptor) { this.descriptor = descriptor; } + + public byte[] getClientIp() { return clientIp; } + public void setClientIp(byte[] clientIp) { this.clientIp = clientIp; } + + public LocalDateTime getAuthorizedAt() { return authorizedAt; } + public void setAuthorizedAt(LocalDateTime authorizedAt) { this.authorizedAt = authorizedAt; } + + public LocalDateTime getCapturedAt() { return capturedAt; } + public void setCapturedAt(LocalDateTime capturedAt) { this.capturedAt = capturedAt; } + + public LocalDateTime getCanceledAt() { return canceledAt; } + public void setCanceledAt(LocalDateTime canceledAt) { this.canceledAt = canceledAt; } + + public LocalDateTime getFailedAt() { return failedAt; } + public void setFailedAt(LocalDateTime failedAt) { this.failedAt = failedAt; } + + public String getMetadata() { return metadata; } + public void setMetadata(String metadata) { this.metadata = metadata; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java new file mode 100644 index 0000000..ab5833f --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java @@ -0,0 +1,100 @@ +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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java new file mode 100644 index 0000000..e0ec386 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentMethodType { CARD, BIZUM, BANK_TRANSFER } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java new file mode 100644 index 0000000..18604be --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentStatus { + REQUIRES_PAYMENT_METHOD, REQUIRES_ACTION, AUTHORIZED, + CAPTURED, PARTIALLY_REFUNDED, REFUNDED, CANCELED, FAILED +} + + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java new file mode 100644 index 0000000..a7fd404 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java @@ -0,0 +1,123 @@ +package com.imprimelibros.erp.payments.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "payment_transactions", + uniqueConstraints = { + @UniqueConstraint(name = "uq_tx_gateway_txid", columnNames = {"gateway_transaction_id"}) + }, + indexes = { + @Index(name = "idx_tx_pay", columnList = "payment_id"), + @Index(name = "idx_tx_type_status", columnList = "type,status"), + @Index(name = "idx_tx_idem", columnList = "idempotency_key") + } +) +public class PaymentTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "payment_id", nullable = false) + private Payment payment; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 16) + private PaymentTransactionType type; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private PaymentTransactionStatus status; + + @Column(name = "amount_cents", nullable = false) + private Long amountCents; + + @Column(name = "currency", nullable = false, length = 3) + private String currency; + + @Column(name = "gateway_transaction_id", length = 128) + private String gatewayTransactionId; + + @Column(name = "gateway_response_code", length = 64) + private String gatewayResponseCode; + + @Column(name = "avs_result", length = 8) + private String avsResult; + + @Column(name = "cvv_result", length = 8) + private String cvvResult; + + @Column(name = "three_ds_version", length = 16) + private String threeDsVersion; + + @Column(name = "idempotency_key", length = 128) + private String idempotencyKey; + + @Column(name = "request_payload", columnDefinition = "json") + private String requestPayload; + + @Column(name = "response_payload", columnDefinition = "json") + private String responsePayload; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "created_at", nullable = false, + columnDefinition = "datetime default current_timestamp") + private LocalDateTime createdAt; + + public PaymentTransaction() {} + + // Getters & Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Payment getPayment() { return payment; } + public void setPayment(Payment payment) { this.payment = payment; } + + public PaymentTransactionType getType() { return type; } + public void setType(PaymentTransactionType type) { this.type = type; } + + public PaymentTransactionStatus getStatus() { return status; } + public void setStatus(PaymentTransactionStatus status) { this.status = status; } + + public Long getAmountCents() { return amountCents; } + public void setAmountCents(Long amountCents) { this.amountCents = amountCents; } + + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + + public String getGatewayTransactionId() { return gatewayTransactionId; } + public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; } + + public String getGatewayResponseCode() { return gatewayResponseCode; } + public void setGatewayResponseCode(String gatewayResponseCode) { this.gatewayResponseCode = gatewayResponseCode; } + + public String getAvsResult() { return avsResult; } + public void setAvsResult(String avsResult) { this.avsResult = avsResult; } + + public String getCvvResult() { return cvvResult; } + public void setCvvResult(String cvvResult) { this.cvvResult = cvvResult; } + + public String getThreeDsVersion() { return threeDsVersion; } + public void setThreeDsVersion(String threeDsVersion) { this.threeDsVersion = threeDsVersion; } + + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + + public String getRequestPayload() { return requestPayload; } + public void setRequestPayload(String requestPayload) { this.requestPayload = requestPayload; } + + public String getResponsePayload() { return responsePayload; } + public void setResponsePayload(String responsePayload) { this.responsePayload = responsePayload; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java new file mode 100644 index 0000000..f495274 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java new file mode 100644 index 0000000..880654a --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Refund.java b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java new file mode 100644 index 0000000..576e752 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java @@ -0,0 +1,99 @@ +package com.imprimelibros.erp.payments.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "refunds", + uniqueConstraints = { + @UniqueConstraint(name = "uq_refund_gateway_id", columnNames = {"gateway_refund_id"}) + }, + indexes = { + @Index(name = "idx_ref_pay", columnList = "payment_id"), + @Index(name = "idx_ref_status", columnList = "status") + } +) +public class Refund { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "payment_id", nullable = false) + private Payment payment; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "transaction_id") + private PaymentTransaction transaction; // el REFUND en payment_transactions + + @Column(name = "amount_cents", nullable = false) + private Long amountCents; + + @Enumerated(EnumType.STRING) + @Column(name = "reason", nullable = false, length = 32) + private RefundReason reason = RefundReason.CUSTOMER_REQUEST; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private RefundStatus status = RefundStatus.PENDING; + + @Column(name = "requested_by_user_id") + private Long requestedByUserId; + + @Column(name = "requested_at", nullable = false, + columnDefinition = "datetime default current_timestamp") + private LocalDateTime requestedAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "gateway_refund_id", length = 128) + private String gatewayRefundId; + + @Column(name = "notes", length = 500) + private String notes; + + @Column(name = "metadata", columnDefinition = "json") + private String metadata; + + public Refund() {} + + // Getters & Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public Payment getPayment() { return payment; } + public void setPayment(Payment payment) { this.payment = payment; } + + public PaymentTransaction getTransaction() { return transaction; } + public void setTransaction(PaymentTransaction transaction) { this.transaction = transaction; } + + public Long getAmountCents() { return amountCents; } + public void setAmountCents(Long amountCents) { this.amountCents = amountCents; } + + public RefundReason getReason() { return reason; } + public void setReason(RefundReason reason) { this.reason = reason; } + + public RefundStatus getStatus() { return status; } + public void setStatus(RefundStatus status) { this.status = status; } + + public Long getRequestedByUserId() { return requestedByUserId; } + public void setRequestedByUserId(Long requestedByUserId) { this.requestedByUserId = requestedByUserId; } + + public LocalDateTime getRequestedAt() { return requestedAt; } + public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + + public String getGatewayRefundId() { return gatewayRefundId; } + public void setGatewayRefundId(String gatewayRefundId) { this.gatewayRefundId = gatewayRefundId; } + + public String getNotes() { return notes; } + public void setNotes(String notes) { this.notes = notes; } + + public String getMetadata() { return metadata; } + public void setMetadata(String metadata) { this.metadata = metadata; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java new file mode 100644 index 0000000..95235a8 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java @@ -0,0 +1,6 @@ +package com.imprimelibros.erp.payments.model; + +public enum RefundReason { + CUSTOMER_REQUEST, PARTIAL_RETURN, PRICING_ADJUSTMENT, DUPLICATE, FRAUD, OTHER +} + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java new file mode 100644 index 0000000..e15fd1d --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum RefundStatus { PENDING, SUCCEEDED, FAILED, CANCELED } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java new file mode 100644 index 0000000..8982ae1 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum ThreeDSStatus { NOT_APPLICABLE, ATTEMPTED, CHALLENGE, SUCCEEDED, FAILED } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java b/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java new file mode 100644 index 0000000..201dc81 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java @@ -0,0 +1,88 @@ +package com.imprimelibros.erp.payments.model; + +import jakarta.persistence.*; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "webhook_events", + uniqueConstraints = { + @UniqueConstraint(name = "uq_webhook_provider_event", columnNames = {"provider","event_id"}) + }, + indexes = { + @Index(name = "idx_webhook_processed", columnList = "processed") + } +) +public class WebhookEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "provider", nullable = false, length = 32) + private String provider; // "redsys", etc. + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "event_id", length = 128) + private String eventId; + + @Column(name = "signature", length = 512) + private String signature; + + @Column(name = "payload", nullable = false, columnDefinition = "json") + private String payload; + + @Column(name = "processed", nullable = false) + private Boolean processed = false; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + @Column(name = "attempts", nullable = false) + private Integer attempts = 0; + + @Column(name = "last_error", length = 500) + private String lastError; + + @Column(name = "created_at", nullable = false, + columnDefinition = "datetime default current_timestamp") + private LocalDateTime createdAt; + + public WebhookEvent() {} + + // Getters & Setters + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getProvider() { return provider; } + public void setProvider(String provider) { this.provider = provider; } + + public String getEventType() { return eventType; } + public void setEventType(String eventType) { this.eventType = eventType; } + + public String getEventId() { return eventId; } + public void setEventId(String eventId) { this.eventId = eventId; } + + public String getSignature() { return signature; } + public void setSignature(String signature) { this.signature = signature; } + + public String getPayload() { return payload; } + public void setPayload(String payload) { this.payload = payload; } + + public Boolean getProcessed() { return processed; } + public void setProcessed(Boolean processed) { this.processed = processed; } + + public LocalDateTime getProcessedAt() { return processedAt; } + public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; } + + public Integer getAttempts() { return attempts; } + public void setAttempts(Integer attempts) { this.attempts = attempts; } + + public String getLastError() { return lastError; } + public void setLastError(String lastError) { this.lastError = lastError; } + + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java new file mode 100644 index 0000000..a04ea60 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java @@ -0,0 +1,12 @@ +// 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 { + Optional findByScopeAndIdemKey(IdempotencyScope scope, String idemKey); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java new file mode 100644 index 0000000..397d1ef --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java @@ -0,0 +1,7 @@ +// 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 {} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java new file mode 100644 index 0000000..6af17f7 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java @@ -0,0 +1,11 @@ +// PaymentRepository.java +package com.imprimelibros.erp.payments.repo; + +import com.imprimelibros.erp.payments.model.Payment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentRepository extends JpaRepository { + Optional findByGatewayAndGatewayOrderId(String gateway, String gatewayOrderId); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java new file mode 100644 index 0000000..2965178 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java @@ -0,0 +1,12 @@ +// PaymentTransactionRepository.java +package com.imprimelibros.erp.payments.repo; + +import com.imprimelibros.erp.payments.model.PaymentTransaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface PaymentTransactionRepository extends JpaRepository { + Optional findByGatewayTransactionId(String gatewayTransactionId); + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java new file mode 100644 index 0000000..6e7228d --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java @@ -0,0 +1,12 @@ +// RefundRepository.java +package com.imprimelibros.erp.payments.repo; + +import com.imprimelibros.erp.payments.model.Refund; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RefundRepository extends JpaRepository { + @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); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java new file mode 100644 index 0000000..9ba9488 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java @@ -0,0 +1,7 @@ +// WebhookEventRepository.java +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 {} diff --git a/src/main/resources/db/changelog/changesets/0007-payments-core.yml b/src/main/resources/db/changelog/changesets/0007-payments-core.yml new file mode 100644 index 0000000..b94a27e --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0007-payments-core.yml @@ -0,0 +1,180 @@ +databaseChangeLog: + - changeSet: + 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) } # 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 } } + - addUniqueConstraint: + tableName: payment_methods + columnNames: token_id + constraintName: uq_payment_methods_token + + # 2) payments (una intención de cobro por pedido) + - 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 } } + - addForeignKeyConstraint: + baseTableName: payments + baseColumnNames: payment_method_id + referencedTableName: payment_methods + 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} ] } + - addUniqueConstraint: + tableName: payments + columnNames: gateway, gateway_order_id + constraintName: uq_payments_gateway_order + + # 3) payment_transactions (libro mayor: AUTH/CAPTURE/REFUND/VOID) + - createTable: + tableName: payment_transactions + columns: + - column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } } + - column: { name: payment_id, type: BIGINT, constraints: { nullable: false } } + - column: { name: type, type: ENUM('AUTH','CAPTURE','REFUND','VOID'), constraints: { nullable: false } } + - column: { name: status, type: ENUM('pending','succeeded','failed'), constraints: { nullable: false } } + - column: { name: amount_cents, type: BIGINT, constraints: { nullable: false } } + - column: { name: currency, type: CHAR(3), constraints: { nullable: false } } + - column: { name: gateway_transaction_id, type: VARCHAR(128) } + - column: { name: gateway_response_code, type: VARCHAR(64) } + - column: { name: avs_result, type: VARCHAR(8) } + - column: { name: cvv_result, type: VARCHAR(8) } + - column: { name: three_ds_version, type: VARCHAR(16) } + - column: { name: idempotency_key, type: VARCHAR(128) } + - column: { name: request_payload, type: JSON } + - column: { name: response_payload, type: JSON } + - column: { name: processed_at, type: DATETIME } + - column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } } + - addForeignKeyConstraint: + baseTableName: payment_transactions + baseColumnNames: payment_id + referencedTableName: payments + referencedColumnNames: id + constraintName: fk_tx_payment + onDelete: CASCADE + - addUniqueConstraint: + tableName: payment_transactions + columnNames: gateway_transaction_id + constraintName: uq_tx_gateway_txid + - createIndex: { tableName: payment_transactions, indexName: idx_tx_pay, columns: [ {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) + - 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 } + - addForeignKeyConstraint: + baseTableName: refunds + baseColumnNames: payment_id + referencedTableName: payments + referencedColumnNames: id + constraintName: fk_ref_payment + onDelete: CASCADE + - addForeignKeyConstraint: + baseTableName: refunds + baseColumnNames: transaction_id + referencedTableName: payment_transactions + referencedColumnNames: id + constraintName: fk_ref_tx + onDelete: SET NULL + - addUniqueConstraint: + tableName: refunds + columnNames: gateway_refund_id + constraintName: uq_refund_gateway_id + - createIndex: { tableName: refunds, indexName: idx_ref_pay, columns: [ {name: payment_id} ] } + - createIndex: { tableName: refunds, indexName: idx_ref_status, columns: [ {name: status} ] } + + # 5) webhooks (para Redsys: notificaciones asincrónicas) + - 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 } } + - 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) + - 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: [ {name: resource_id} ] } diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index 95eb18c..fcdec19 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -10,4 +10,6 @@ databaseChangeLog: - include: file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml - include: - file: db/changelog/changesets/0006-add-cart-direcciones.yml \ No newline at end of file + file: db/changelog/changesets/0006-add-cart-direcciones.yml + - include: + file: db/changelog/changesets/0007-payments-core.yml \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/_summary.html b/src/main/resources/templates/imprimelibros/checkout/_summary.html index 47eb645..5a16da1 100644 --- a/src/main/resources/templates/imprimelibros/checkout/_summary.html +++ b/src/main/resources/templates/imprimelibros/checkout/_summary.html @@ -38,8 +38,13 @@ - +
+ + + +
+
From 88650fc5e8746313cba126ebc3586082fd5b36ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Sun, 2 Nov 2025 17:14:44 +0100 Subject: [PATCH 04/13] =?UTF-8?q?a=C3=B1adidos=20ficheros=20a=20falta=20de?= =?UTF-8?q?=20modificar=20el=20servicio=20y=20el=20controlador=20redsys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/imprimelibros/erp/payments/PaymentService.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java index 15254fe..47d9c51 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -26,7 +26,6 @@ public class PaymentService { private final RedsysService redsysService; private final ObjectMapper om = new ObjectMapper(); - @Autowired public PaymentService(PaymentRepository payRepo, PaymentTransactionRepository txRepo, RefundRepository refundRepo, From 725cff9b51c69b4e1fb65714f3b68104df226440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Mon, 3 Nov 2025 19:31:28 +0100 Subject: [PATCH 05/13] testeando el notify --- .../erp/cart/CartRepository.java | 1 - .../erp/payments/PaymentService.java | 111 ++-- .../erp/payments/model/Payment.java | 18 +- .../erp/redsys/RedsysController.java | 181 ++++-- .../erp/redsys/RedsysService.java | 70 ++- src/main/resources/application-dev.properties | 2 +- .../changesets/0007-payments-core.yml | 541 ++++++++++++++---- .../pages/imprimelibros/checkout/checkout.js | 10 +- .../imprimelibros/checkout/_pago.html | 6 +- .../imprimelibros/checkout/_summary.html | 2 +- 10 files changed, 716 insertions(+), 226 deletions(-) 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 @@
- +
- +