From c11c34011e2534af5133f80543d4f3a633db2d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Jim=C3=A9nez?= Date: Wed, 5 Nov 2025 21:46:54 +0100 Subject: [PATCH] trabajando en la tabla de transferencias --- .../erp/payments/PaymentController.java | 145 +++- .../erp/payments/PaymentService.java | 43 +- .../erp/redsys/RedsysController.java | 4 +- .../erp/redsys/RedsysService.java | 7 +- .../changesets/0007-payments-core.yml | 775 +++++++++--------- .../0008-update-cart-status-constraint.yml | 19 + .../0009-add-composite-unique-txid-type | 15 - .../0009-add-composite-unique-txid-type.yml | 29 + .../0010-drop-unique-tx-gateway.yml | 30 + src/main/resources/db/changelog/master.yml | 4 +- src/main/resources/i18n/pagos_es.properties | 18 + .../js/pages/imprimelibros/pagos/pagos.js | 108 ++- .../imprimelibros/pagos/gestion-pagos.html | 6 +- .../pagos/tabla-transferencias.html | 30 + 14 files changed, 800 insertions(+), 433 deletions(-) delete mode 100644 src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type create mode 100644 src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type.yml create mode 100644 src/main/resources/db/changelog/changesets/0010-drop-unique-tx-gateway.yml create mode 100644 src/main/resources/templates/imprimelibros/pagos/tabla-transferencias.html diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentController.java b/src/main/java/com/imprimelibros/erp/payments/PaymentController.java index c2907da..a1e7174 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentController.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentController.java @@ -8,6 +8,7 @@ import org.springframework.context.MessageSource; import org.springframework.data.jpa.domain.Specification; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ResponseBody; @@ -17,7 +18,9 @@ import com.imprimelibros.erp.datatables.DataTable; import com.imprimelibros.erp.datatables.DataTablesParser; import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesResponse; +import com.imprimelibros.erp.i18n.TranslationService; import com.imprimelibros.erp.payments.model.Payment; +import com.imprimelibros.erp.payments.model.PaymentStatus; import com.imprimelibros.erp.payments.model.PaymentTransaction; import com.imprimelibros.erp.payments.model.PaymentTransactionStatus; import com.imprimelibros.erp.payments.model.PaymentTransactionType; @@ -32,20 +35,34 @@ import jakarta.servlet.http.HttpServletRequest; @PreAuthorize("hasRole('SUPERADMIN')") public class PaymentController { - private final MessageSource messageSource; - + protected final MessageSource messageSource; + protected final TranslationService translationService; protected final PaymentTransactionRepository repoPaymentTransaction; protected final UserDao repoUser; public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser, - MessageSource messageSource) { + MessageSource messageSource, TranslationService translationService) { this.repoPaymentTransaction = repoPaymentTransaction; this.repoUser = repoUser; this.messageSource = messageSource; + this.translationService = translationService; } @GetMapping() - public String index() { + public String index(Model model, Locale locale) { + + List keys = List.of( + "app.cancelar", + "app.aceptar", + "pagos.refund.title", + "pagos.refund.text", + "pagos.refund.success", + "pagos.refund.error.general", + "pagos.refund.error.invalid-number"); + + Map translations = translationService.getTranslations(locale, keys); + model.addAttribute("languageBundle", translations); + return "imprimelibros/pagos/gestion-pagos"; } @@ -131,6 +148,7 @@ public class PaymentController { return "" + messageSource.getMessage("pagos.table.devuelto", null, locale) + ""; } @@ -144,4 +162,123 @@ public class PaymentController { } + @GetMapping(value = "datatable/transferencias", produces = "application/json") + @ResponseBody + public DataTablesResponse> getDatatableTransferencias(HttpServletRequest request, + Locale locale) { + + DataTablesRequest dt = DataTablesParser.from(request); + + List searchable = List.of( + // "client" no, porque lo calculas a posteriori + ); + + // Campos ordenables + List orderable = List.of( + "transferId", + "status", + "amountCents", + "payment.amountRefundedCents", + "createdAt", "updatedAt"); + + Specification base = Specification.allOf( + (root, query, cb) -> cb.equal(root.get("status"), PaymentTransactionStatus.pending)); + base = base.and((root, query, cb) -> cb.equal(root.get("type"), PaymentTransactionType.CAPTURE)); + base = base.and((root, query, cb) -> cb.equal(root.get("payment").get("gateway"), "bank_transfer")); + + String clientSearch = dt.getColumnSearch("client"); + + // 2) Si hay filtro, traducirlo a userIds y añadirlo al Specification + if (clientSearch != null) { + List userIds = repoUser.findIdsByFullNameLike(clientSearch.trim()); + + if (userIds.isEmpty()) { + // Ningún usuario coincide → forzamos 0 resultados + base = base.and((root, query, cb) -> cb.disjunction()); + } else { + base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds)); + } + } + Long total = repoPaymentTransaction.count(base); + + return DataTable + .of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable) + .orderable(orderable) + .add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale)) + .add("processed_at", pago -> Utils.formatDateTime(pago.getProcessedAt(), locale)) + .add("client", pago -> { + if (pago.getPayment() != null && pago.getPayment().getUserId() != null) { + Payment payment = pago.getPayment(); + if (payment.getUserId() != null) { + Optional user = repoUser.findById(payment.getUserId().longValue()); + return user.map(User::getFullName).orElse(""); + } + } + return ""; + }) + .add("transfer_id", pago -> { + if (pago.getPayment() != null) { + return "TRANSF-" + pago.getPayment().getGatewayOrderId(); + } else { + return ""; + } + }) + .add("order_id", pago -> { + if (pago.getStatus() != PaymentTransactionStatus.pending) { + if (pago.getPayment() != null && pago.getPayment().getOrderId() != null) { + return pago.getPayment().getOrderId().toString(); + } else { + return ""; + } + } + return messageSource.getMessage("pagos.transferencia.no-pedido", null, "Pendiente", locale); + + }).add("amount_cents", pago -> Utils.formatCurrency(pago.getAmountCents() / 100.0, locale)) + .add("amount_cents_refund", pago -> + + { + Payment payment = pago.getPayment(); + if (payment != null) { + return Utils.formatCurrency(payment.getAmountRefundedCents() / 100.0, locale); + } + return ""; + }).add("status", pago -> { + switch (pago.getStatus()) { + case PaymentTransactionStatus.pending: + return messageSource.getMessage("pagos.table.estado.pending", null, "Pendiente", locale); + case PaymentTransactionStatus.succeeded: + return messageSource.getMessage("pagos.table.estado.succeeded", null, "Completada", locale); + case PaymentTransactionStatus.failed: + return messageSource.getMessage("pagos.table.estado.failed", null, "Fallido", locale); + default: + return pago.getStatus().name(); + } + }).add("actions", pago -> { + Payment p = pago.getPayment(); + if (p != null) { + String actions = ""; + if (pago.getStatus() != PaymentTransactionStatus.succeeded) { + actions += "" + + messageSource.getMessage("pagos.table.finalizar", null, locale) + " "; + + } + if (pago.getAmountCents() - p.getAmountRefundedCents() > 0) { + actions += "" + + messageSource.getMessage("pagos.table.devuelto", null, locale) + ""; + } + return actions; + } else { + return ""; + } + }).where(base).toJson(total); + + } + } diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java index b5e6f2a..2f92d78 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -198,18 +198,7 @@ public class PaymentService { } if (authorized) { - // GENERAR PEDIDO A PARTIR DEL CARRITO - Cart cart = this.cartService.findById(notif.cartId); - if (cart != null) { - // Bloqueamos el carrito - this.cartService.lockCartById(cart.getId()); - // order ID es generado dentro de createOrderFromCart donde se marcan los - // presupuestos como no editables - // Long orderId = - // this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId()); - // p.setOrderId(orderId); - - } + processOrder(notif.cartId); } payRepo.save(p); @@ -230,7 +219,7 @@ public class PaymentService { } } - // ---- refundViaRedsys + // ---- refundViaRedsys // ---- @Transactional public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) { @@ -295,7 +284,6 @@ public class PaymentService { payRepo.save(p); } - @Transactional public Payment createBankTransferPayment(Long cartId, long amountCents, String currency) { Payment p = new Payment(); @@ -304,6 +292,10 @@ public class PaymentService { Cart cart = this.cartService.findById(cartId); if (cart != null && cart.getUserId() != null) { p.setUserId(cart.getUserId()); + // En el orderId de la transferencia pendiente guardamos el ID del carrito + p.setOrderId(cartId); + // Se bloquea el carrito para evitar modificaciones mientras se procesa el pago + this.cartService.lockCartById(cartId); } p.setCurrency(currency); @@ -321,7 +313,7 @@ public class PaymentService { tx.setCurrency(currency); // tx.setProcessedAt(null); // la dejas nula hasta que se confirme txRepo.save(tx); - + return p; } @@ -360,6 +352,11 @@ public class PaymentService { p.setCapturedAt(LocalDateTime.now()); p.setStatus(PaymentStatus.captured); payRepo.save(p); + + // 4) Procesar el pedido asociado al carrito (si existe) + if (p.getOrderId() != null) { + processOrder(p.getOrderId()); + } } private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) { @@ -376,4 +373,20 @@ public class PaymentService { return code >= 0 && code <= 99; } + private Boolean processOrder(Long cartId) { + // GENERAR PEDIDO A PARTIR DEL CARRITO + Cart cart = this.cartService.findById(cartId); + if (cart != null) { + // Bloqueamos el carrito + this.cartService.lockCartById(cart.getId()); + // order ID es generado dentro de createOrderFromCart donde se marcan los + // presupuestos como no editables + // Long orderId = + // this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId()); + // p.setOrderId(orderId); + + } + return true; + } + } diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java index 56a9e61..255b366 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java @@ -163,9 +163,9 @@ public class RedsysController { try { String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID(); paymentService.refundViaRedsys(paymentId, amountCents, idem); - return ResponseEntity.ok("Refund solicitado"); + return ResponseEntity.ok("{success:true}"); } catch (Exception e) { - return ResponseEntity.badRequest().body("Error refund: " + e.getMessage()); + return ResponseEntity.badRequest().body("{success:false, error: '" + 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 3638faa..7bb6ba0 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java @@ -287,10 +287,6 @@ public class RedsysService { HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - System.out.println("### Redsys refund REST request:\n" + json); - System.out.println("### HTTP " + response.statusCode()); - System.out.println("### Redsys refund REST response:\n" + response.body()); - if (response.statusCode() / 100 != 2) throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode()); @@ -313,8 +309,7 @@ public class RedsysService { // Decodificar MerchantParameters de la respuesta Map decoded = decodeMerchantParametersToMap(dsMerchantParametersResp); - System.out.println("### Redsys refund decoded response:\n" + decoded); - + String dsResponse = String.valueOf(decoded.get("Ds_Response")); if (!"0900".equals(dsResponse)) { throw new IllegalStateException("Devolución rechazada, Ds_Response=" + dsResponse); 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 4ef635f..a6b9341 100644 --- a/src/main/resources/db/changelog/changesets/0007-payments-core.yml +++ b/src/main/resources/db/changelog/changesets/0007-payments-core.yml @@ -1,403 +1,418 @@ databaseChangeLog: - changeSet: - id: 0007-payments-core - author: jjo - changes: - # 2) payments - - createTable: - tableName: payments - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: order_id - type: BIGINT - - column: - name: user_id - type: BIGINT - - column: - name: currency - type: CHAR(3) - constraints: - nullable: false - - column: - name: amount_total_cents - type: BIGINT - constraints: - nullable: false - - column: - name: amount_captured_cents - type: BIGINT - defaultValueNumeric: 0 - constraints: - nullable: false - - column: - name: amount_refunded_cents - type: BIGINT - defaultValueNumeric: 0 - constraints: - nullable: false - - column: - name: status - type: "ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed')" - defaultValue: "requires_payment_method" - constraints: - nullable: false - - column: - name: capture_method - type: "ENUM('automatic','manual')" - defaultValue: "automatic" - constraints: - nullable: false - - column: - name: gateway - type: VARCHAR(32) - constraints: - nullable: false - - column: - name: gateway_payment_id - type: VARCHAR(128) - - column: - name: gateway_order_id - type: VARCHAR(12) - - column: - name: authorization_code - type: VARCHAR(32) - - column: - name: three_ds_status - type: "ENUM('not_applicable','attempted','challenge','succeeded','failed')" - defaultValue: "not_applicable" - constraints: - nullable: false - - column: - name: descriptor - type: VARCHAR(22) - - column: - name: client_ip - type: VARBINARY(16) - - column: - name: authorized_at - type: DATETIME - - column: - name: captured_at - type: DATETIME - - column: - name: canceled_at - type: DATETIME - - column: - name: failed_at - type: DATETIME - - column: - name: metadata - type: JSON - - column: - name: created_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false - - column: - name: updated_at - type: DATETIME - defaultValueComputed: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" - constraints: - nullable: false + id: 0007-payments-core + author: jjo + changes: + # 2) payments + - createTable: + tableName: payments + columns: + - column: + name: id + type: BIGINT AUTO_INCREMENT + constraints: + primaryKey: true + nullable: false + - column: + name: order_id + type: BIGINT + - column: + name: user_id + type: BIGINT + - column: + name: currency + type: CHAR(3) + constraints: + nullable: false + - column: + name: amount_total_cents + type: BIGINT + constraints: + nullable: false + - column: + name: amount_captured_cents + type: BIGINT + defaultValueNumeric: 0 + constraints: + nullable: false + - column: + name: amount_refunded_cents + type: BIGINT + defaultValueNumeric: 0 + constraints: + nullable: false + - column: + name: status + type: "ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed')" + defaultValue: "requires_payment_method" + constraints: + nullable: false + - column: + name: capture_method + type: "ENUM('automatic','manual')" + defaultValue: "automatic" + constraints: + nullable: false + - column: + name: gateway + type: VARCHAR(32) + constraints: + nullable: false + - column: + name: gateway_payment_id + type: VARCHAR(128) + - column: + name: gateway_order_id + type: VARCHAR(12) + - column: + name: authorization_code + type: VARCHAR(32) + - column: + name: three_ds_status + type: "ENUM('not_applicable','attempted','challenge','succeeded','failed')" + defaultValue: "not_applicable" + constraints: + nullable: false + - column: + name: descriptor + type: VARCHAR(22) + - column: + name: client_ip + type: VARBINARY(16) + - column: + name: authorized_at + type: DATETIME + - column: + name: captured_at + type: DATETIME + - column: + name: canceled_at + type: DATETIME + - column: + name: failed_at + type: DATETIME + - column: + name: metadata + type: JSON + - column: + name: created_at + type: DATETIME + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: updated_at + type: DATETIME + defaultValueComputed: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" + constraints: + nullable: false + - createIndex: + tableName: payments + indexName: idx_payments_order + columns: + - column: + name: order_id - - createIndex: - tableName: payments - indexName: idx_payments_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_gateway - columns: - - column: - name: gateway - - column: - name: gateway_payment_id + - createIndex: + tableName: payments + indexName: idx_payments_status + columns: + - column: + name: status - - createIndex: - tableName: payments - indexName: idx_payments_status - columns: - - column: - name: status + - addUniqueConstraint: + tableName: payments + columnNames: gateway, gateway_order_id + constraintName: uq_payments_gateway_order - - addUniqueConstraint: - tableName: payments - columnNames: gateway, gateway_order_id - constraintName: uq_payments_gateway_order + # 3) payment_transactions + - createTable: + tableName: payment_transactions + columns: + - column: + name: id + type: BIGINT AUTO_INCREMENT + constraints: + primaryKey: true + nullable: false + - column: + name: payment_id + type: BIGINT + constraints: + nullable: false + - column: + name: type + type: "ENUM('AUTH','CAPTURE','REFUND','VOID')" + constraints: + nullable: false + - column: + name: status + type: "ENUM('pending','succeeded','failed')" + constraints: + nullable: false + - column: + name: amount_cents + type: BIGINT + constraints: + nullable: false + - column: + name: currency + type: CHAR(3) + constraints: + nullable: false + - column: + name: gateway_transaction_id + type: VARCHAR(128) + - column: + name: gateway_response_code + type: VARCHAR(64) + - column: + name: avs_result + type: VARCHAR(8) + - column: + name: cvv_result + type: VARCHAR(8) + - column: + name: three_ds_version + type: VARCHAR(16) + - column: + name: idempotency_key + type: VARCHAR(128) + - column: + name: request_payload + type: JSON + - column: + name: response_payload + type: JSON + - column: + name: processed_at + type: DATETIME + - column: + name: created_at + type: DATETIME + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false - # 3) payment_transactions - - createTable: - tableName: payment_transactions - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: payment_id - type: BIGINT - constraints: - nullable: false - - column: - name: type - type: "ENUM('AUTH','CAPTURE','REFUND','VOID')" - constraints: - nullable: false - - column: - name: status - type: "ENUM('pending','succeeded','failed')" - constraints: - nullable: false - - column: - name: amount_cents - type: BIGINT - constraints: - nullable: false - - column: - name: currency - type: CHAR(3) - constraints: - nullable: false - - column: - name: gateway_transaction_id - type: VARCHAR(128) - - column: - name: gateway_response_code - type: VARCHAR(64) - - column: - name: avs_result - type: VARCHAR(8) - - column: - name: cvv_result - type: VARCHAR(8) - - column: - name: three_ds_version - type: VARCHAR(16) - - column: - name: idempotency_key - type: VARCHAR(128) - - column: - name: request_payload - type: JSON - - column: - name: response_payload - type: JSON - - column: - name: processed_at - type: DATETIME - - column: - name: created_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false + - addForeignKeyConstraint: + baseTableName: payment_transactions + baseColumnNames: payment_id + referencedTableName: payments + referencedColumnNames: id + constraintName: fk_tx_payment + onDelete: CASCADE - - 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 - - addUniqueConstraint: - tableName: payment_transactions - columnNames: gateway_transaction_id - constraintName: uq_tx_gateway_txid + - createIndex: + tableName: payment_transactions + indexName: idx_tx_pay + columns: + - column: + name: payment_id - - createIndex: - tableName: payment_transactions - indexName: idx_tx_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_type_status - columns: - - column: - name: type - - column: - name: status + - createIndex: + tableName: payment_transactions + indexName: idx_tx_idem + columns: + - column: + name: idempotency_key - - createIndex: - tableName: payment_transactions - indexName: idx_tx_idem - columns: - - column: - name: idempotency_key + # 4) refunds + - createTable: + tableName: refunds + columns: + - column: + name: id + type: BIGINT AUTO_INCREMENT + constraints: + primaryKey: true + nullable: false + - column: + name: payment_id + type: BIGINT + constraints: + nullable: false + - column: + name: transaction_id + type: BIGINT + - column: + name: amount_cents + type: BIGINT + constraints: + nullable: false + - column: + name: reason + type: "ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other')" + defaultValue: "customer_request" + constraints: + nullable: false + - column: + name: status + type: "ENUM('pending','succeeded','failed','canceled')" + defaultValue: "pending" + constraints: + nullable: false + - column: + name: requested_by_user_id + type: BIGINT + - column: + name: requested_at + type: DATETIME + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false + - column: + name: processed_at + type: DATETIME + - column: + name: gateway_refund_id + type: VARCHAR(128) + - column: + name: notes + type: VARCHAR(500) + - column: + name: metadata + type: JSON - # 4) refunds - - createTable: - tableName: refunds - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: payment_id - type: BIGINT - constraints: - nullable: false - - column: - name: transaction_id - type: BIGINT - - column: - name: amount_cents - type: BIGINT - constraints: - nullable: false - - column: - name: reason - type: "ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other')" - defaultValue: "customer_request" - constraints: - nullable: false - - column: - name: status - type: "ENUM('pending','succeeded','failed','canceled')" - defaultValue: "pending" - constraints: - nullable: false - - column: - name: requested_by_user_id - type: BIGINT - - column: - name: requested_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false - - column: - name: processed_at - type: DATETIME - - column: - name: gateway_refund_id - type: VARCHAR(128) - - column: - name: notes - type: VARCHAR(500) - - column: - name: metadata - type: JSON + - addForeignKeyConstraint: + baseTableName: refunds + baseColumnNames: payment_id + referencedTableName: payments + referencedColumnNames: id + constraintName: fk_ref_payment + onDelete: CASCADE - - addForeignKeyConstraint: - baseTableName: refunds - baseColumnNames: 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 - - 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 - - addUniqueConstraint: - tableName: refunds - columnNames: gateway_refund_id - constraintName: uq_refund_gateway_id + - createIndex: + tableName: refunds + indexName: idx_ref_pay + columns: + - column: + name: payment_id - - createIndex: - tableName: refunds - indexName: idx_ref_pay - columns: - - column: - name: payment_id + - createIndex: + tableName: refunds + indexName: idx_ref_status + columns: + - column: + name: status - - createIndex: - tableName: refunds - indexName: idx_ref_status - columns: - - column: - name: status + # 5) webhook_events + - createTable: + tableName: webhook_events + columns: + - column: + name: id + type: BIGINT AUTO_INCREMENT + constraints: + primaryKey: true + nullable: false + - column: + name: provider + type: VARCHAR(32) + constraints: + nullable: false + - column: + name: event_type + type: VARCHAR(64) + constraints: + nullable: false + - column: + name: event_id + type: VARCHAR(128) + - column: + name: signature + type: VARCHAR(512) + - column: + name: payload + type: JSON + constraints: + nullable: false + - column: + name: processed + type: TINYINT(1) + defaultValueNumeric: 0 + constraints: + nullable: false + - column: + name: processed_at + type: DATETIME + - column: + name: attempts + type: INT + defaultValueNumeric: 0 + constraints: + nullable: false + - column: + name: last_error + type: VARCHAR(500) + - column: + name: created_at + type: DATETIME + defaultValueComputed: CURRENT_TIMESTAMP + constraints: + nullable: false - # 5) webhook_events - - createTable: - tableName: webhook_events - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: provider - type: VARCHAR(32) - constraints: - nullable: false - - column: - name: event_type - type: VARCHAR(64) - constraints: - nullable: false - - column: - name: event_id - type: VARCHAR(128) - - column: - name: signature - type: VARCHAR(512) - - column: - name: payload - type: JSON - constraints: - nullable: false - - column: - name: processed - type: TINYINT(1) - defaultValueNumeric: 0 - constraints: - nullable: false - - column: - name: processed_at - type: DATETIME - - column: - name: attempts - type: INT - defaultValueNumeric: 0 - constraints: - nullable: false - - column: - name: last_error - type: VARCHAR(500) - - column: - name: created_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false + - addUniqueConstraint: + tableName: webhook_events + columnNames: provider, event_id + constraintName: uq_webhook_provider_event - - addUniqueConstraint: - tableName: webhook_events - columnNames: provider, event_id - constraintName: uq_webhook_provider_event + - createIndex: + tableName: webhook_events + indexName: idx_webhook_processed + columns: + - column: + name: processed - - createIndex: - tableName: webhook_events - indexName: idx_webhook_processed - columns: - - column: - name: processed + + rollback: + # Se borran las tablas en orden inverso de dependencias + + - dropTable: + tableName: webhook_events + + - dropTable: + tableName: refunds + + - dropTable: + tableName: payment_transactions + + - dropTable: + tableName: payments diff --git a/src/main/resources/db/changelog/changesets/0008-update-cart-status-constraint.yml b/src/main/resources/db/changelog/changesets/0008-update-cart-status-constraint.yml index 458add4..2ee2be7 100644 --- a/src/main/resources/db/changelog/changesets/0008-update-cart-status-constraint.yml +++ b/src/main/resources/db/changelog/changesets/0008-update-cart-status-constraint.yml @@ -26,3 +26,22 @@ databaseChangeLog: sql: | CREATE UNIQUE INDEX uq_carts_user_active ON carts (user_id, active_flag); + + rollback: + # 🔙 1) Eliminar el índice nuevo basado en active_flag + - sql: + sql: | + ALTER TABLE carts + DROP INDEX uq_carts_user_active; + + # 🔙 2) Eliminar la columna generada active_flag + - sql: + sql: | + ALTER TABLE carts + DROP COLUMN active_flag; + + # 🔙 3) Restaurar el índice único original (user_id, status) + - sql: + sql: | + CREATE UNIQUE INDEX uq_carts_user_active + ON carts (user_id, status); diff --git a/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type b/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type deleted file mode 100644 index 651e126..0000000 --- a/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type +++ /dev/null @@ -1,15 +0,0 @@ -databaseChangeLog: - - changeSet: - id: 0009-add-composite-unique-txid-type - author: JJO - changes: - # 1️⃣ Eliminar el índice único anterior - - dropUniqueConstraint: - constraintName: uq_tx_gateway_txid - tableName: payment_transactions - - # 2️⃣ Crear índice único compuesto por gateway_transaction_id + type - - addUniqueConstraint: - tableName: payment_transactions - columnNames: gateway_transaction_id, type - constraintName: uq_tx_gateway_txid_type diff --git a/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type.yml b/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type.yml new file mode 100644 index 0000000..6217dca --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0009-add-composite-unique-txid-type.yml @@ -0,0 +1,29 @@ +databaseChangeLog: + - changeSet: + id: 0009-drop-unique-refund-gateway-id + author: JJO + changes: + # 1️⃣ Eliminar la UNIQUE constraint sobre gateway_refund_id + - dropUniqueConstraint: + constraintName: uq_refund_gateway_id + tableName: refunds + + # 2️⃣ Crear un índice normal (no único) para acelerar búsquedas por gateway_refund_id + - createIndex: + tableName: refunds + indexName: idx_refunds_gateway_refund_id + columns: + - column: + name: gateway_refund_id + + rollback: + # 🔙 1) Eliminar el índice normal creado en este changeSet + - dropIndex: + indexName: idx_refunds_gateway_refund_id + tableName: refunds + + # 🔙 2) Restaurar la UNIQUE constraint original + - addUniqueConstraint: + tableName: refunds + columnNames: gateway_refund_id + constraintName: uq_refund_gateway_id diff --git a/src/main/resources/db/changelog/changesets/0010-drop-unique-tx-gateway.yml b/src/main/resources/db/changelog/changesets/0010-drop-unique-tx-gateway.yml new file mode 100644 index 0000000..d688737 --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0010-drop-unique-tx-gateway.yml @@ -0,0 +1,30 @@ +databaseChangeLog: + - changeSet: + id: 0010-drop-unique-tx-gateway + author: JJO + changes: + # 1️⃣ Eliminar la UNIQUE constraint sobre (gateway_transaction_id, type) + - dropUniqueConstraint: + constraintName: uq_tx_gateway_txid_type + tableName: payment_transactions + + # 2️⃣ Crear un índice normal (no único) sobre gateway_transaction_id + # para poder seguir buscando rápido por este campo + - createIndex: + tableName: payment_transactions + indexName: idx_payment_tx_gateway_txid + columns: + - column: + name: gateway_transaction_id + + rollback: + # 🔙 1) Eliminar el índice normal creado en este changeSet + - dropIndex: + indexName: idx_payment_tx_gateway_txid + tableName: payment_transactions + + # 🔙 2) Restaurar la UNIQUE constraint original + - addUniqueConstraint: + tableName: payment_transactions + columnNames: gateway_transaction_id, type + constraintName: uq_tx_gateway_txid_type diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index 1d976e5..6512f54 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -16,4 +16,6 @@ databaseChangeLog: - include: file: db/changelog/changesets/0008-update-cart-status-constraint.yml - include: - file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml \ No newline at end of file + file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml + - include: + file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml \ No newline at end of file diff --git a/src/main/resources/i18n/pagos_es.properties b/src/main/resources/i18n/pagos_es.properties index c1bb8ef..e7e8060 100644 --- a/src/main/resources/i18n/pagos_es.properties +++ b/src/main/resources/i18n/pagos_es.properties @@ -11,3 +11,21 @@ pagos.table.devuelto=Devolución pagos.table.fecha=Fecha pagos.table.estado=Estado pagos.table.acciones=Acciones + +pagos.table.concepto-transferencia=Concepto +pagos.table.estado-transferencia=Estado +pagos.table.fecha-created=Fecha creación +pagos.table.fecha-procesed=Fecha procesada + +pagos.table.estado.pending=Pendiente +pagos.table.estado.succeeded=Completada +pagos.table.estado.failed=Fallido +pagos.table.finalizar=Finalizar + +pagos.transferencia.no-pedido=No disponible + +pagos.refund.title=Devolución de Pago Redsys +pagos.refund.text=Introduce la cantidad a devolver (en euros): +pagos.refund.success=Devolución solicitada con éxito. Si no se refleja inmediatamente, espere unos minutos y actualiza la página. +pagos.refund.error.general=Error al procesar la devolución +pagos.refund.error.invalid-number=Cantidad inválida para la devolución diff --git a/src/main/resources/static/assets/js/pages/imprimelibros/pagos/pagos.js b/src/main/resources/static/assets/js/pages/imprimelibros/pagos/pagos.js index 400aab6..680e09f 100644 --- a/src/main/resources/static/assets/js/pages/imprimelibros/pagos/pagos.js +++ b/src/main/resources/static/assets/js/pages/imprimelibros/pagos/pagos.js @@ -76,11 +76,20 @@ $(() => { $(document).on('click', '.btn-refund-payment', function () { const dsOrderId = $(this).data('dsorderid'); const transactionId = $(this).data('transactionid'); + const maxAmountCents = $(this).data('amount'); // show swal confirmation with input for amount to refund Swal.fire({ - title: '¿Estás seguro de que deseas devolver este pago?', - text: 'Introduce la cantidad a devolver (en euros):', + showCancelButton: true, + buttonsStyling: false, + title: window.languageBundle['pagos.refund.title'], + text: window.languageBundle['pagos.refund.text'], input: 'number', + confirmButtonText: window.languageBundle['app.aceptar'] || 'Seleccionar', + cancelButtonText: window.languageBundle['app.cancelar'] || 'Cancelar', + customClass: { + confirmButton: 'btn btn-secondary me-2', + cancelButton: 'btn btn-light', + }, inputAttributes: { min: 0, } @@ -88,7 +97,15 @@ $(() => { if (result.isConfirmed) { const amountToRefund = parseFloat(result.value); if (isNaN(amountToRefund) || amountToRefund <= 0) { - Swal.fire('Error', 'Cantidad inválida para la devolución.', 'error'); + showSwal('Error', window.languageBundle['pagos.refund.error.invalid-number'], 'error'); + return; + } + if (amountToRefund*100 > maxAmountCents) { + showSwal('Error', window.languageBundle['pagos.refund.error.invalid-number'], 'error'); + return; + } + if (amountToRefund*100 > maxAmountCents) { + showSwal('Error', window.languageBundle['pagos.refund.error.invalid-number'], 'error'); return; } $.ajax({ @@ -98,14 +115,91 @@ $(() => { amountCents: amountToRefund*100 } }).then((response) => { + response = typeof response === 'string' ? JSON.parse(response) : response; if (response.success) { - Swal.fire('Éxito', 'Pago devuelto con éxito.', 'success'); - table.draw(); + showSwal('Éxito', window.languageBundle['pagos.refund.success'], 'success'); + $('#pagos-redsys-datatable').DataTable().draw(); } else { - Swal.fire('Error', 'No se pudo procesar la devolución.', 'error'); + showSwal('Error', window.languageBundle['pagos.refund.error.general'], 'error'); + $('#pagos-redsys-datatable').DataTable().draw(); + } - }); + }).fail(() => { + showSwal('Error', window.languageBundle['pagos.refund.error.general'], 'error'); + }); } }); }); + + + const tableT = new DataTable('#pagos-transferencias-datatable', { + processing: true, + serverSide: true, + orderCellsTop: true, + pageLength: 50, + lengthMenu: [10, 25, 50, 100, 500], + language: { url: '/assets/libs/datatables/i18n/' + language + '.json' }, + responsive: true, + dom: 'lBrtip', + buttons: { + dom: { + button: { + className: 'btn btn-sm btn-outline-primary me-1' + }, + buttons: [ + { extend: 'copy' }, + { extend: 'csv' }, + { extend: 'excel' }, + { extend: 'pdf' }, + { extend: 'print' }, + { extend: 'colvis' } + ], + } + }, + ajax: { + url: '/pagos/datatable/transferencias', + method: 'GET', + }, + order: [[7, 'desc']], // Ordena por fecha por defecto + columns: [ + { data: 'client', name: 'client', orderable: true }, + { data: 'transfer_id', name: 'transfer_id', orderable: true }, + { data: 'status', name: 'status', orderable: true }, + { data: 'order_id', name: 'payment.orderId', orderable: true }, + { data: 'amount_cents', name: 'amountCents', orderable: true }, + { data: 'amount_cents_refund', name: 'amountCentsRefund', orderable: true }, + { data: 'created_at', name: 'createdAt', orderable: true }, + { data: 'processed_at', name: 'processedAt', orderable: true }, + { data: 'actions', name: 'actions', orderable: false, searchable: false } + + ], + columnDefs: [{ targets: -1, orderable: false, searchable: false }] + }); + + // Fila de filtros = segunda fila del thead (index 1) + $('#pagos-redsys-datatable thead tr:eq(1) th').each(function (colIdx) { + const input = $(this).find('input'); + if (input.length === 0) return; // columnas sin filtro + + input.on('keyup change', function () { + const value = this.value; + if (table.column(colIdx).search() !== value) { + table.column(colIdx).search(value).draw(); + } + }); + }); + + + function showSwal(title, text, icon) { + Swal.fire({ + title: title, + text: text, + icon: icon, + buttonsStyling: false, + confirmButtonText: window.languageBundle['app.aceptar'] || 'Aceptar', + customClass: { + confirmButton: 'btn btn-secondary', + } + }); + } }); diff --git a/src/main/resources/templates/imprimelibros/pagos/gestion-pagos.html b/src/main/resources/templates/imprimelibros/pagos/gestion-pagos.html index 635c48c..5723eb7 100644 --- a/src/main/resources/templates/imprimelibros/pagos/gestion-pagos.html +++ b/src/main/resources/templates/imprimelibros/pagos/gestion-pagos.html @@ -67,11 +67,11 @@
- +
diff --git a/src/main/resources/templates/imprimelibros/pagos/tabla-transferencias.html b/src/main/resources/templates/imprimelibros/pagos/tabla-transferencias.html new file mode 100644 index 0000000..5ceb92d --- /dev/null +++ b/src/main/resources/templates/imprimelibros/pagos/tabla-transferencias.html @@ -0,0 +1,30 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
Acciones
+
\ No newline at end of file