trabajando en la tabla de transferencias

This commit is contained in:
2025-11-05 21:46:54 +01:00
parent a4443763d8
commit c11c34011e
14 changed files with 800 additions and 433 deletions

View File

@ -8,6 +8,7 @@ import org.springframework.context.MessageSource;
import org.springframework.data.jpa.domain.Specification; import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller; import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ResponseBody; 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.DataTablesParser;
import com.imprimelibros.erp.datatables.DataTablesRequest; import com.imprimelibros.erp.datatables.DataTablesRequest;
import com.imprimelibros.erp.datatables.DataTablesResponse; 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.Payment;
import com.imprimelibros.erp.payments.model.PaymentStatus;
import com.imprimelibros.erp.payments.model.PaymentTransaction; import com.imprimelibros.erp.payments.model.PaymentTransaction;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus; import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
import com.imprimelibros.erp.payments.model.PaymentTransactionType; import com.imprimelibros.erp.payments.model.PaymentTransactionType;
@ -32,20 +35,34 @@ import jakarta.servlet.http.HttpServletRequest;
@PreAuthorize("hasRole('SUPERADMIN')") @PreAuthorize("hasRole('SUPERADMIN')")
public class PaymentController { public class PaymentController {
private final MessageSource messageSource; protected final MessageSource messageSource;
protected final TranslationService translationService;
protected final PaymentTransactionRepository repoPaymentTransaction; protected final PaymentTransactionRepository repoPaymentTransaction;
protected final UserDao repoUser; protected final UserDao repoUser;
public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser, public PaymentController(PaymentTransactionRepository repoPaymentTransaction, UserDao repoUser,
MessageSource messageSource) { MessageSource messageSource, TranslationService translationService) {
this.repoPaymentTransaction = repoPaymentTransaction; this.repoPaymentTransaction = repoPaymentTransaction;
this.repoUser = repoUser; this.repoUser = repoUser;
this.messageSource = messageSource; this.messageSource = messageSource;
this.translationService = translationService;
} }
@GetMapping() @GetMapping()
public String index() { public String index(Model model, Locale locale) {
List<String> keys = List.of(
"app.cancelar",
"app.aceptar",
"pagos.refund.title",
"pagos.refund.text",
"pagos.refund.success",
"pagos.refund.error.general",
"pagos.refund.error.invalid-number");
Map<String, String> translations = translationService.getTranslations(locale, keys);
model.addAttribute("languageBundle", translations);
return "imprimelibros/pagos/gestion-pagos"; return "imprimelibros/pagos/gestion-pagos";
} }
@ -131,6 +148,7 @@ public class PaymentController {
return "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'" return "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'"
+ p.getGatewayOrderId() + p.getGatewayOrderId()
+ "\' data-transactionId=\'" + pago.getPayment().getId() + "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
+ "\' style=\'cursor: pointer;\'>" + "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>"; + messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
} }
@ -144,4 +162,123 @@ public class PaymentController {
} }
@GetMapping(value = "datatable/transferencias", produces = "application/json")
@ResponseBody
public DataTablesResponse<Map<String, Object>> getDatatableTransferencias(HttpServletRequest request,
Locale locale) {
DataTablesRequest dt = DataTablesParser.from(request);
List<String> searchable = List.of(
// "client" no, porque lo calculas a posteriori
);
// Campos ordenables
List<String> orderable = List.of(
"transferId",
"status",
"amountCents",
"payment.amountRefundedCents",
"createdAt", "updatedAt");
Specification<PaymentTransaction> base = 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<Long> userIds = repoUser.findIdsByFullNameLike(clientSearch.trim());
if (userIds.isEmpty()) {
// Ningún usuario coincide → forzamos 0 resultados
base = base.and((root, query, cb) -> cb.disjunction());
} else {
base = base.and((root, query, cb) -> root.join("payment").get("userId").in(userIds));
}
}
Long total = repoPaymentTransaction.count(base);
return DataTable
.of(repoPaymentTransaction, PaymentTransaction.class, dt, searchable)
.orderable(orderable)
.add("created_at", pago -> Utils.formatDateTime(pago.getCreatedAt(), locale))
.add("processed_at", pago -> Utils.formatDateTime(pago.getProcessedAt(), locale))
.add("client", pago -> {
if (pago.getPayment() != null && pago.getPayment().getUserId() != null) {
Payment payment = pago.getPayment();
if (payment.getUserId() != null) {
Optional<User> user = repoUser.findById(payment.getUserId().longValue());
return user.map(User::getFullName).orElse("");
}
}
return "";
})
.add("transfer_id", pago -> {
if (pago.getPayment() != null) {
return "TRANSF-" + pago.getPayment().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 += "<span class=\'badge bg-secondary btn-mark-as-completed \' data-dsOrderId=\'"
+ p.getGatewayOrderId()
+ "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.finalizar", null, locale) + "</span> ";
}
if (pago.getAmountCents() - p.getAmountRefundedCents() > 0) {
actions += "<span class=\'badge bg-secondary btn-refund-payment \' data-dsOrderId=\'"
+ p.getGatewayOrderId()
+ "\' data-transactionId=\'" + pago.getPayment().getId()
+ "\' data-amount=\'" + (pago.getAmountCents() - p.getAmountRefundedCents())
+ "\' style=\'cursor: pointer;\'>"
+ messageSource.getMessage("pagos.table.devuelto", null, locale) + "</span>";
}
return actions;
} else {
return "";
}
}).where(base).toJson(total);
}
} }

View File

@ -198,18 +198,7 @@ public class PaymentService {
} }
if (authorized) { if (authorized) {
// GENERAR PEDIDO A PARTIR DEL CARRITO processOrder(notif.cartId);
Cart cart = this.cartService.findById(notif.cartId);
if (cart != null) {
// Bloqueamos el carrito
this.cartService.lockCartById(cart.getId());
// order ID es generado dentro de createOrderFromCart donde se marcan los
// presupuestos como no editables
// Long orderId =
// this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId());
// p.setOrderId(orderId);
}
} }
payRepo.save(p); payRepo.save(p);
@ -295,7 +284,6 @@ public class PaymentService {
payRepo.save(p); payRepo.save(p);
} }
@Transactional @Transactional
public Payment createBankTransferPayment(Long cartId, long amountCents, String currency) { public Payment createBankTransferPayment(Long cartId, long amountCents, String currency) {
Payment p = new Payment(); Payment p = new Payment();
@ -304,6 +292,10 @@ public class PaymentService {
Cart cart = this.cartService.findById(cartId); Cart cart = this.cartService.findById(cartId);
if (cart != null && cart.getUserId() != null) { if (cart != null && cart.getUserId() != null) {
p.setUserId(cart.getUserId()); p.setUserId(cart.getUserId());
// En el orderId de la transferencia pendiente guardamos el ID del carrito
p.setOrderId(cartId);
// Se bloquea el carrito para evitar modificaciones mientras se procesa el pago
this.cartService.lockCartById(cartId);
} }
p.setCurrency(currency); p.setCurrency(currency);
@ -360,6 +352,11 @@ public class PaymentService {
p.setCapturedAt(LocalDateTime.now()); p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.captured); p.setStatus(PaymentStatus.captured);
payRepo.save(p); 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) { private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) {
@ -376,4 +373,20 @@ public class PaymentService {
return code >= 0 && code <= 99; return code >= 0 && code <= 99;
} }
private Boolean processOrder(Long cartId) {
// GENERAR PEDIDO A PARTIR DEL CARRITO
Cart cart = this.cartService.findById(cartId);
if (cart != null) {
// Bloqueamos el carrito
this.cartService.lockCartById(cart.getId());
// order ID es generado dentro de createOrderFromCart donde se marcan los
// presupuestos como no editables
// Long orderId =
// this.cartService.pedidoService.createOrderFromCart(cart.getId(), p.getId());
// p.setOrderId(orderId);
}
return true;
}
} }

View File

@ -163,9 +163,9 @@ public class RedsysController {
try { try {
String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID(); String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID();
paymentService.refundViaRedsys(paymentId, amountCents, idem); paymentService.refundViaRedsys(paymentId, amountCents, idem);
return ResponseEntity.ok("Refund solicitado"); return ResponseEntity.ok("{success:true}");
} catch (Exception e) { } catch (Exception e) {
return ResponseEntity.badRequest().body("Error refund: " + e.getMessage()); return ResponseEntity.badRequest().body("{success:false, error: '" + e.getMessage() + "'}");
} }
} }
} }

View File

@ -287,10 +287,6 @@ public class RedsysService {
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> 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) if (response.statusCode() / 100 != 2)
throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode()); throw new IllegalStateException("HTTP error Redsys refund: " + response.statusCode());
@ -313,7 +309,6 @@ public class RedsysService {
// Decodificar MerchantParameters de la respuesta // Decodificar MerchantParameters de la respuesta
Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp); Map<String, Object> decoded = decodeMerchantParametersToMap(dsMerchantParametersResp);
System.out.println("### Redsys refund decoded response:\n" + decoded);
String dsResponse = String.valueOf(decoded.get("Ds_Response")); String dsResponse = String.valueOf(decoded.get("Ds_Response"));
if (!"0900".equals(dsResponse)) { if (!"0900".equals(dsResponse)) {

View File

@ -107,7 +107,6 @@ databaseChangeLog:
constraints: constraints:
nullable: false nullable: false
- createIndex: - createIndex:
tableName: payments tableName: payments
indexName: idx_payments_order indexName: idx_payments_order
@ -401,3 +400,19 @@ databaseChangeLog:
- column: - column:
name: processed name: processed
rollback:
# Se borran las tablas en orden inverso de dependencias
- dropTable:
tableName: webhook_events
- dropTable:
tableName: refunds
- dropTable:
tableName: payment_transactions
- dropTable:
tableName: payments

View File

@ -26,3 +26,22 @@ databaseChangeLog:
sql: | sql: |
CREATE UNIQUE INDEX uq_carts_user_active CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, active_flag); ON carts (user_id, active_flag);
rollback:
# 🔙 1) Eliminar el índice nuevo basado en active_flag
- sql:
sql: |
ALTER TABLE carts
DROP INDEX uq_carts_user_active;
# 🔙 2) Eliminar la columna generada active_flag
- sql:
sql: |
ALTER TABLE carts
DROP COLUMN active_flag;
# 🔙 3) Restaurar el índice único original (user_id, status)
- sql:
sql: |
CREATE UNIQUE INDEX uq_carts_user_active
ON carts (user_id, status);

View File

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

View File

@ -0,0 +1,29 @@
databaseChangeLog:
- changeSet:
id: 0009-drop-unique-refund-gateway-id
author: JJO
changes:
# 1⃣ Eliminar la UNIQUE constraint sobre gateway_refund_id
- dropUniqueConstraint:
constraintName: uq_refund_gateway_id
tableName: refunds
# 2⃣ Crear un índice normal (no único) para acelerar búsquedas por gateway_refund_id
- createIndex:
tableName: refunds
indexName: idx_refunds_gateway_refund_id
columns:
- column:
name: gateway_refund_id
rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex:
indexName: idx_refunds_gateway_refund_id
tableName: refunds
# 🔙 2) Restaurar la UNIQUE constraint original
- addUniqueConstraint:
tableName: refunds
columnNames: gateway_refund_id
constraintName: uq_refund_gateway_id

View File

@ -0,0 +1,30 @@
databaseChangeLog:
- changeSet:
id: 0010-drop-unique-tx-gateway
author: JJO
changes:
# 1⃣ Eliminar la UNIQUE constraint sobre (gateway_transaction_id, type)
- dropUniqueConstraint:
constraintName: uq_tx_gateway_txid_type
tableName: payment_transactions
# 2⃣ Crear un índice normal (no único) sobre gateway_transaction_id
# para poder seguir buscando rápido por este campo
- createIndex:
tableName: payment_transactions
indexName: idx_payment_tx_gateway_txid
columns:
- column:
name: gateway_transaction_id
rollback:
# 🔙 1) Eliminar el índice normal creado en este changeSet
- dropIndex:
indexName: idx_payment_tx_gateway_txid
tableName: payment_transactions
# 🔙 2) Restaurar la UNIQUE constraint original
- addUniqueConstraint:
tableName: payment_transactions
columnNames: gateway_transaction_id, type
constraintName: uq_tx_gateway_txid_type

View File

@ -17,3 +17,5 @@ databaseChangeLog:
file: db/changelog/changesets/0008-update-cart-status-constraint.yml file: db/changelog/changesets/0008-update-cart-status-constraint.yml
- include: - include:
file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml file: db/changelog/changesets/0009-add-composite-unique-txid-type.yml
- include:
file: db/changelog/changesets/0010-drop-unique-tx-gateway.yml

View File

@ -11,3 +11,21 @@ pagos.table.devuelto=Devolución
pagos.table.fecha=Fecha pagos.table.fecha=Fecha
pagos.table.estado=Estado pagos.table.estado=Estado
pagos.table.acciones=Acciones 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

View File

@ -76,11 +76,20 @@ $(() => {
$(document).on('click', '.btn-refund-payment', function () { $(document).on('click', '.btn-refund-payment', function () {
const dsOrderId = $(this).data('dsorderid'); const dsOrderId = $(this).data('dsorderid');
const transactionId = $(this).data('transactionid'); const transactionId = $(this).data('transactionid');
const maxAmountCents = $(this).data('amount');
// show swal confirmation with input for amount to refund // show swal confirmation with input for amount to refund
Swal.fire({ Swal.fire({
title: '¿Estás seguro de que deseas devolver este pago?', showCancelButton: true,
text: 'Introduce la cantidad a devolver (en euros):', buttonsStyling: false,
title: window.languageBundle['pagos.refund.title'],
text: window.languageBundle['pagos.refund.text'],
input: 'number', 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: { inputAttributes: {
min: 0, min: 0,
} }
@ -88,7 +97,15 @@ $(() => {
if (result.isConfirmed) { if (result.isConfirmed) {
const amountToRefund = parseFloat(result.value); const amountToRefund = parseFloat(result.value);
if (isNaN(amountToRefund) || amountToRefund <= 0) { 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; return;
} }
$.ajax({ $.ajax({
@ -98,14 +115,91 @@ $(() => {
amountCents: amountToRefund*100 amountCents: amountToRefund*100
} }
}).then((response) => { }).then((response) => {
response = typeof response === 'string' ? JSON.parse(response) : response;
if (response.success) { if (response.success) {
Swal.fire('Éxito', 'Pago devuelto con éxito.', 'success'); showSwal('Éxito', window.languageBundle['pagos.refund.success'], 'success');
table.draw(); $('#pagos-redsys-datatable').DataTable().draw();
} else { } 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',
}
});
}
}); });

View File

@ -67,11 +67,11 @@
</div> </div>
<div class="tab-pane" id="arrow-transferencias" role="tabpanel"> <div class="tab-pane" id="arrow-transferencias" role="tabpanel">
<div></div> <div></div>
<!---
<div <div
th:insert="~{imprimelibros/presupuestos/presupuesto-list-items/tabla-anonimos :: tabla-anonimos}"> th:insert="~{imprimelibros/pagos/tabla-transferencias :: tabla-transferencias}">
</div> </div>
-->
</div> </div>
</div> </div>

View File

@ -0,0 +1,30 @@
<div th:fragment="tabla-transferencias">
<table id="pagos-transferencias-datatable" class="table table-striped table-nowrap responsive w-100">
<thead>
<tr>
<th scope="col" th:text="#{pagos.table.cliente.nombre}"></th>
<th scope="col" th:text="#{pagos.table.concepto-transferencia}"></th>
<th scope="col" th:text="#{pagos.table.estado-transferencia}"></th>
<th scope="col" th:text="#{pagos.table.pedido.id}"></th>
<th scope="col" th:text="#{pagos.table.cantidad}"></th>
<th scope="col" th:text="#{pagos.table.devuelto}"></th>
<th scope="col" th:text="#{pagos.table.fecha-created}"></th>
<th scope="col" th:text="#{pagos.table.fecha-procesed}"></th>
<th scope="col" th:text="#{pagos.table.acciones}">Acciones</th>
</tr>
<tr>
<th><input type="text" class="form-control form-control-sm redsys-filter" /></th>
<th><input type="text" class="form-control form-control-sm redsys-filter" /></th>
<th><input type="text" class="form-control form-control-sm redsys-filter" /></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th> <!-- Acciones (sin filtro) -->
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>