mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-13 00:48:49 +00:00
testeando el notify
This commit is contained in:
@ -3,7 +3,6 @@ package com.imprimelibros.erp.cart;
|
|||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
import java.util.List;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
public interface CartRepository extends JpaRepository<Cart, Long> {
|
public interface CartRepository extends JpaRepository<Cart, Long> {
|
||||||
|
|||||||
@ -7,9 +7,7 @@ import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
|
|||||||
import com.imprimelibros.erp.payments.repo.RefundRepository;
|
import com.imprimelibros.erp.payments.repo.RefundRepository;
|
||||||
import com.imprimelibros.erp.redsys.RedsysService;
|
import com.imprimelibros.erp.redsys.RedsysService;
|
||||||
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
|
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
|
||||||
import com.imprimelibros.erp.redsys.RedsysService.Notification;
|
import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification;
|
||||||
import com.imprimelibros.erp.redsys.RedsysService.PaymentRequest;
|
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
@ -27,91 +25,126 @@ public class PaymentService {
|
|||||||
private final ObjectMapper om = new ObjectMapper();
|
private final ObjectMapper om = new ObjectMapper();
|
||||||
|
|
||||||
public PaymentService(PaymentRepository payRepo,
|
public PaymentService(PaymentRepository payRepo,
|
||||||
PaymentTransactionRepository txRepo,
|
PaymentTransactionRepository txRepo,
|
||||||
RefundRepository refundRepo,
|
RefundRepository refundRepo,
|
||||||
RedsysService redsysService) {
|
RedsysService redsysService) {
|
||||||
this.payRepo = payRepo;
|
this.payRepo = payRepo;
|
||||||
this.txRepo = txRepo;
|
this.txRepo = txRepo;
|
||||||
this.refundRepo = refundRepo;
|
this.refundRepo = refundRepo;
|
||||||
this.redsysService = redsysService;
|
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
|
@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();
|
Payment p = new Payment();
|
||||||
p.setOrderId(orderId);
|
p.setOrderId(orderId); // <- ahora puede ser null
|
||||||
p.setCurrency(currency);
|
p.setCurrency(currency);
|
||||||
p.setAmountTotalCents(amountCents);
|
p.setAmountTotalCents(amountCents);
|
||||||
p.setGateway("redsys");
|
p.setGateway("redsys");
|
||||||
p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD);
|
p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD);
|
||||||
p = payRepo.saveAndFlush(p);
|
p = payRepo.saveAndFlush(p);
|
||||||
|
|
||||||
|
// Ds_Order = ID del Payment, 12 dígitos
|
||||||
String dsOrder = String.format("%012d", p.getId());
|
String dsOrder = String.format("%012d", p.getId());
|
||||||
p.setGatewayOrderId(dsOrder);
|
p.setGatewayOrderId(dsOrder);
|
||||||
payRepo.save(p);
|
payRepo.save(p);
|
||||||
|
|
||||||
PaymentRequest req = new PaymentRequest(dsOrder, amountCents, "Compra en Imprimelibros", "card");
|
RedsysService.PaymentRequest req = new RedsysService.PaymentRequest(dsOrder, amountCents,
|
||||||
return redsysService.buildRedirectForm(req);
|
"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
|
@Transactional
|
||||||
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
|
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
|
||||||
Notification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
|
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");
|
throw new IllegalStateException("Divisa inesperada");
|
||||||
if (!Objects.equals(p.getAmountTotalCents(), notif.getAmountCents()))
|
}
|
||||||
|
if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) {
|
||||||
throw new IllegalStateException("Importe inesperado");
|
throw new IllegalStateException("Importe inesperado");
|
||||||
|
}
|
||||||
|
|
||||||
// ¿Ya registrado? Si ya capturaste, no repitas.
|
// Idempotencia sencilla: si ya está capturado o reembolsado, no creamos otra
|
||||||
if (p.getStatus() == PaymentStatus.CAPTURED || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED || p.getStatus() == PaymentStatus.REFUNDED) {
|
// transacción
|
||||||
return; // idempotencia simple a nivel Payment
|
if (p.getStatus() == PaymentStatus.CAPTURED
|
||||||
|
|| p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED
|
||||||
|
|| p.getStatus() == PaymentStatus.REFUNDED) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PaymentTransaction tx = new PaymentTransaction();
|
PaymentTransaction tx = new PaymentTransaction();
|
||||||
tx.setPayment(p);
|
tx.setPayment(p);
|
||||||
tx.setType(PaymentTransactionType.CAPTURE);
|
tx.setType(PaymentTransactionType.CAPTURE);
|
||||||
tx.setCurrency(p.getCurrency());
|
tx.setCurrency(p.getCurrency());
|
||||||
tx.setAmountCents(notif.getAmountCents());
|
tx.setAmountCents(notif.amountCents);
|
||||||
tx.setStatus(notif.isAuthorized() ? PaymentTransactionStatus.SUCCEEDED : PaymentTransactionStatus.FAILED);
|
tx.setStatus(notif.authorized() ? PaymentTransactionStatus.SUCCEEDED
|
||||||
// En Redsys el authorization code suele estar en Ds_AuthorisationCode
|
: PaymentTransactionStatus.FAILED);
|
||||||
Object authCode = notif.getRaw().get("Ds_AuthorisationCode");
|
|
||||||
|
Object authCode = notif.raw.get("Ds_AuthorisationCode");
|
||||||
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
|
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
|
||||||
tx.setGatewayResponseCode(notif.getResponse());
|
tx.setGatewayResponseCode(notif.response);
|
||||||
tx.setResponsePayload(om.writeValueAsString(notif.getRaw()));
|
tx.setResponsePayload(om.writeValueAsString(notif.raw));
|
||||||
tx.setProcessedAt(LocalDateTime.now());
|
tx.setProcessedAt(LocalDateTime.now());
|
||||||
txRepo.save(tx);
|
txRepo.save(tx);
|
||||||
|
|
||||||
if (notif.isAuthorized()) {
|
if (notif.authorized()) {
|
||||||
p.setAuthorizationCode(tx.getGatewayTransactionId());
|
p.setAuthorizationCode(tx.getGatewayTransactionId());
|
||||||
p.setStatus(PaymentStatus.CAPTURED);
|
p.setStatus(PaymentStatus.CAPTURED);
|
||||||
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.getAmountCents());
|
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents);
|
||||||
p.setAuthorizedAt(LocalDateTime.now());
|
p.setAuthorizedAt(LocalDateTime.now());
|
||||||
p.setCapturedAt(LocalDateTime.now());
|
p.setCapturedAt(LocalDateTime.now());
|
||||||
} else {
|
} else {
|
||||||
p.setStatus(PaymentStatus.FAILED);
|
p.setStatus(PaymentStatus.FAILED);
|
||||||
p.setFailedAt(LocalDateTime.now());
|
p.setFailedAt(LocalDateTime.now());
|
||||||
}
|
}
|
||||||
|
|
||||||
payRepo.save(p);
|
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
|
@Transactional
|
||||||
public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) {
|
public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) {
|
||||||
Payment p = payRepo.findById(paymentId)
|
Payment p = payRepo.findById(paymentId)
|
||||||
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado"));
|
.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();
|
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)
|
txRepo.findByIdempotencyKey(idempotencyKey)
|
||||||
.ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); });
|
.ifPresent(t -> {
|
||||||
|
throw new IllegalStateException("Reembolso ya procesado");
|
||||||
|
});
|
||||||
|
|
||||||
Refund r = new Refund();
|
Refund r = new Refund();
|
||||||
r.setPayment(p);
|
r.setPayment(p);
|
||||||
@ -120,7 +153,8 @@ public class PaymentService {
|
|||||||
r.setRequestedAt(LocalDateTime.now());
|
r.setRequestedAt(LocalDateTime.now());
|
||||||
r = refundRepo.save(r);
|
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();
|
PaymentTransaction tx = new PaymentTransaction();
|
||||||
tx.setPayment(p);
|
tx.setPayment(p);
|
||||||
@ -148,23 +182,22 @@ public class PaymentService {
|
|||||||
payRepo.save(p);
|
payRepo.save(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transferencia bancaria: crea Payment en espera de ingreso. */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
|
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
|
||||||
Payment p = new Payment();
|
Payment p = new Payment();
|
||||||
p.setOrderId(orderId);
|
p.setOrderId(orderId); // null en tu caso actual
|
||||||
p.setCurrency(currency);
|
p.setCurrency(currency);
|
||||||
p.setAmountTotalCents(amountCents);
|
p.setAmountTotalCents(amountCents);
|
||||||
p.setGateway("bank_transfer");
|
p.setGateway("bank_transfer");
|
||||||
p.setStatus(PaymentStatus.REQUIRES_ACTION);
|
p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso
|
||||||
return payRepo.save(p);
|
return payRepo.save(p);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marca transferencia como conciliada (capturada). */
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public void markBankTransferAsCaptured(Long paymentId) {
|
public void markBankTransferAsCaptured(Long paymentId) {
|
||||||
Payment p = payRepo.findById(paymentId).orElseThrow();
|
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.setAmountCapturedCents(p.getAmountTotalCents());
|
||||||
p.setCapturedAt(LocalDateTime.now());
|
p.setCapturedAt(LocalDateTime.now());
|
||||||
p.setStatus(PaymentStatus.CAPTURED);
|
p.setStatus(PaymentStatus.CAPTURED);
|
||||||
|
|||||||
@ -11,7 +11,7 @@ public class Payment {
|
|||||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Column(name = "order_id", nullable = false)
|
@Column(name = "order_id")
|
||||||
private Long orderId;
|
private Long orderId;
|
||||||
|
|
||||||
@Column(name = "user_id")
|
@Column(name = "user_id")
|
||||||
@ -161,4 +161,20 @@ public class Payment {
|
|||||||
|
|
||||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = 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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,83 +1,154 @@
|
|||||||
package com.imprimelibros.erp.redsys;
|
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.http.ResponseEntity;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
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
|
@Controller
|
||||||
@RequestMapping("/pagos/redsys")
|
@RequestMapping("/pagos/redsys")
|
||||||
public class RedsysController {
|
public class RedsysController {
|
||||||
|
|
||||||
private final RedsysService service;
|
private final PaymentService paymentService;
|
||||||
|
|
||||||
public RedsysController(RedsysService service) {
|
public RedsysController(PaymentService paymentService) {
|
||||||
this.service = service;
|
this.paymentService = paymentService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/crear")
|
@PostMapping(value = "/crear", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
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")
|
|
||||||
@ResponseBody
|
@ResponseBody
|
||||||
public ResponseEntity<String> notifyRedsys(
|
public ResponseEntity<byte[]> crearPago(@RequestParam("amountCents") Long amountCents,
|
||||||
@RequestParam("Ds_Signature") String dsSignature,
|
@RequestParam("method") String method) throws Exception {
|
||||||
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters) {
|
|
||||||
|
|
||||||
|
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 = """
|
||||||
|
<html><head><meta charset="utf-8"><title>Pago por transferencia</title></head>
|
||||||
|
<body>
|
||||||
|
<h2>Pago por transferencia bancaria</h2>
|
||||||
|
<p>Hemos registrado tu intención de pedido.</p>
|
||||||
|
<p><strong>Importe:</strong> %s €</p>
|
||||||
|
<p><strong>IBAN:</strong> ES00 1234 5678 9012 3456 7890</p>
|
||||||
|
<p><strong>Concepto:</strong> TRANSF-%d</p>
|
||||||
|
<p>En cuanto recibamos la transferencia, procesaremos tu pedido.</p>
|
||||||
|
<p><a href="/checkout/resumen">Volver al resumen</a></p>
|
||||||
|
</body></html>
|
||||||
|
""".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 = """
|
||||||
|
<html><head><meta charset="utf-8"><title>Redirigiendo a Redsys…</title></head>
|
||||||
|
<body onload="document.forms[0].submit()">
|
||||||
|
<form action="%s" method="post">
|
||||||
|
<input type="hidden" name="Ds_SignatureVersion" value="%s"/>
|
||||||
|
<input type="hidden" name="Ds_MerchantParameters" value="%s"/>
|
||||||
|
<input type="hidden" name="Ds_Signature" value="%s"/>
|
||||||
|
<noscript>
|
||||||
|
<p>Haz clic en pagar para continuar</p>
|
||||||
|
<button type="submit">Pagar</button>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
|
</body></html>
|
||||||
|
""".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<String> okGet() {
|
||||||
|
String html = """
|
||||||
|
<h2>Pago procesado</h2>
|
||||||
|
<p>Si el pago ha sido autorizado, verás el pedido en tu área de usuario o recibirás un email de confirmación.</p>
|
||||||
|
<p><a href="/cart">Volver a la tienda</a></p>
|
||||||
|
""";
|
||||||
|
return ResponseEntity.ok(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: si Redsys envía Ds_Signature y Ds_MerchantParameters (muchas
|
||||||
|
// integraciones ni lo usan)
|
||||||
|
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
|
@ResponseBody
|
||||||
|
@jakarta.transaction.Transactional
|
||||||
|
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
|
||||||
|
@RequestParam("Ds_MerchantParameters") String merchantParameters) {
|
||||||
try {
|
try {
|
||||||
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature,
|
// opcional: idempotente, si /notify ya ha hecho el trabajo no pasa nada
|
||||||
dsMerchantParameters);
|
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||||
|
return ResponseEntity.ok("<h2>Pago realizado correctamente</h2><a href=\"/cart\">Volver</a>");
|
||||||
// 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");
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(500).body("ERROR");
|
return ResponseEntity.badRequest()
|
||||||
|
.body("<h2>Error validando pago</h2><pre>" + e.getMessage() + "</pre>");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/ok")
|
@GetMapping("/ko")
|
||||||
public String okReturn(@RequestParam("Ds_Signature") String dsSignature,
|
@ResponseBody
|
||||||
@RequestParam("Ds_MerchantParameters") String dsMerchantParameters,
|
public ResponseEntity<String> koGet() {
|
||||||
Model model) {
|
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/cart\">Volver</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
|
@ResponseBody
|
||||||
|
public ResponseEntity<String> koPost(@RequestParam Map<String, String> form) {
|
||||||
|
// Podrías loguear 'form' si quieres ver qué manda Redsys
|
||||||
|
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/cart\">Volver</a>");
|
||||||
|
}
|
||||||
|
|
||||||
|
@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 {
|
try {
|
||||||
RedsysService.RedsysNotification notif = service.validateAndParseNotification(dsSignature, dsMerchantParameters);
|
paymentService.handleRedsysNotification(signature, merchantParameters);
|
||||||
// Aquí puedes validar importe/pedido/moneda con tu base de datos y marcar como
|
return "OK";
|
||||||
// pagado
|
|
||||||
model.addAttribute("authorized", notif.authorized());
|
|
||||||
//model.addAttribute("order", notif.order());
|
|
||||||
//model.addAttribute("amountCents", notif.amountCents());
|
|
||||||
return "imprimelibros/payments/redsys-ok";
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
model.addAttribute("error", "No se pudo validar la respuesta de Redsys.");
|
return "ERROR";
|
||||||
return "imprimelibros/payments/redsys-ko";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/ko")
|
@PostMapping(value = "/refund/{paymentId}", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
|
||||||
public String koReturn(@RequestParam(value = "Ds_Signature", required = false) String dsSignature,
|
@ResponseBody
|
||||||
@RequestParam(value = "Ds_MerchantParameters", required = false) String dsMerchantParameters,
|
public ResponseEntity<String> refund(@PathVariable Long paymentId,
|
||||||
Model model) {
|
@RequestParam("amountCents") Long amountCents) {
|
||||||
// Suele venir cuando el usuario cancela o hay error
|
try {
|
||||||
model.addAttribute("error", "Operación cancelada o rechazada.");
|
String idem = "refund-" + paymentId + "-" + amountCents + "-" + UUID.randomUUID();
|
||||||
return "imprimelibros/payments/redsys-ko";
|
paymentService.refundViaRedsys(paymentId, amountCents, idem);
|
||||||
|
return ResponseEntity.ok("Refund solicitado");
|
||||||
|
} catch (Exception e) {
|
||||||
|
return ResponseEntity.badRequest().body("Error refund: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package com.imprimelibros.erp.redsys;
|
|||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import sis.redsys.api.ApiMacSha256;
|
import sis.redsys.api.ApiMacSha256;
|
||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
@ -38,18 +37,28 @@ public class RedsysService {
|
|||||||
private String env;
|
private String env;
|
||||||
|
|
||||||
// ---------- RECORDS ----------
|
// ---------- 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 {
|
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();
|
ApiMacSha256 api = new ApiMacSha256();
|
||||||
|
|
||||||
api.setParameter("DS_MERCHANT_AMOUNT", String.valueOf(req.amountCents()));
|
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_MERCHANTCODE", merchantCode);
|
||||||
api.setParameter("DS_MERCHANT_CURRENCY", currency);
|
api.setParameter("DS_MERCHANT_CURRENCY", currency);
|
||||||
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
|
api.setParameter("DS_MERCHANT_TRANSACTIONTYPE", txType);
|
||||||
@ -58,6 +67,15 @@ public class RedsysService {
|
|||||||
api.setParameter("DS_MERCHANT_URLOK", urlOk);
|
api.setParameter("DS_MERCHANT_URLOK", urlOk);
|
||||||
api.setParameter("DS_MERCHANT_URLKO", urlKo);
|
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 merchantParameters = api.createMerchantParameters();
|
||||||
String signature = api.createMerchantSignature(secretKeyBase64);
|
String signature = api.createMerchantSignature(secretKeyBase64);
|
||||||
|
|
||||||
@ -84,27 +102,29 @@ public class RedsysService {
|
|||||||
|
|
||||||
// ---------- STEP 4: Validar notificación ----------
|
// ---------- STEP 4: Validar notificación ----------
|
||||||
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
|
public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
|
Map<String, Object> mp = decodeMerchantParametersToMap(dsMerchantParametersB64);
|
||||||
RedsysNotification notif = new RedsysNotification(mp);
|
RedsysNotification notif = new RedsysNotification(mp);
|
||||||
|
|
||||||
if (notif.order == null || notif.order.isBlank()) {
|
if (notif.order == null || notif.order.isBlank()) {
|
||||||
throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
|
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 ----------
|
// ---------- HELPERS ----------
|
||||||
private static boolean safeEqualsB64(String a, String b) {
|
private static boolean safeEqualsB64(String a, String b) {
|
||||||
if (Objects.equals(a, b))
|
if (Objects.equals(a, b))
|
||||||
|
|||||||
@ -22,4 +22,4 @@ safekat.api.password=Safekat2024
|
|||||||
redsys.environment=test
|
redsys.environment=test
|
||||||
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
|
redsys.urls.ok=http://localhost:8080/pagos/redsys/ok
|
||||||
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
|
redsys.urls.ko=http://localhost:8080/pagos/redsys/ko
|
||||||
redsys.urls.notify=http://localhost:8080/pagos/redsys/notify
|
redsys.urls.notify=https://hns2jx2x-8080.uks1.devtunnels.ms/pagos/redsys/notify
|
||||||
@ -7,53 +7,172 @@ databaseChangeLog:
|
|||||||
- createTable:
|
- createTable:
|
||||||
tableName: payment_methods
|
tableName: payment_methods
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: user_id, type: BIGINT }
|
name: id
|
||||||
- column: { name: type, type: ENUM('card','bizum','bank_transfer'), constraints: { nullable: false } }
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: brand, type: VARCHAR(32) }
|
constraints:
|
||||||
- column: { name: last4, type: VARCHAR(4) }
|
primaryKey: true
|
||||||
- column: { name: exp_month, type: TINYINT }
|
nullable: false
|
||||||
- column: { name: exp_year, type: SMALLINT }
|
- column:
|
||||||
- column: { name: fingerprint, type: VARCHAR(128) }
|
name: user_id
|
||||||
- column: { name: token_id, type: VARCHAR(128) } # alias/token de pasarela (nunca PAN)
|
type: BIGINT
|
||||||
- column: { name: sepa_mandate_id, type: VARCHAR(128) }
|
- column:
|
||||||
- column: { name: payer_email, type: VARCHAR(190) }
|
name: type
|
||||||
- column: { name: metadata, type: JSON }
|
type: "ENUM('card','bizum','bank_transfer')"
|
||||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
constraints:
|
||||||
- column: { name: updated_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
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:
|
- addUniqueConstraint:
|
||||||
tableName: payment_methods
|
tableName: payment_methods
|
||||||
columnNames: token_id
|
columnNames: token_id
|
||||||
constraintName: uq_payment_methods_token
|
constraintName: uq_payment_methods_token
|
||||||
|
|
||||||
# 2) payments (una intención de cobro por pedido)
|
# 2) payments
|
||||||
- createTable:
|
- createTable:
|
||||||
tableName: payments
|
tableName: payments
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: order_id, type: BIGINT, constraints: { nullable: false } } # tu pedido interno
|
name: id
|
||||||
- column: { name: user_id, type: BIGINT }
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: payment_method_id, type: BIGINT }
|
constraints:
|
||||||
- column: { name: currency, type: CHAR(3), constraints: { nullable: false } }
|
primaryKey: true
|
||||||
- column: { name: amount_total_cents, type: BIGINT, constraints: { nullable: false } }
|
nullable: false
|
||||||
- column: { name: amount_captured_cents, type: BIGINT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
- column:
|
||||||
- column: { name: amount_refunded_cents, type: BIGINT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
name: order_id
|
||||||
- column: { name: status, type: ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed'), defaultValue: requires_payment_method, constraints: { nullable: false } }
|
type: BIGINT
|
||||||
- column: { name: capture_method, type: ENUM('automatic','manual'), defaultValue: automatic, constraints: { nullable: false } }
|
- column:
|
||||||
- column: { name: gateway, type: VARCHAR(32), constraints: { nullable: false } } # 'redsys'
|
name: user_id
|
||||||
- column: { name: gateway_payment_id, type: VARCHAR(128) } # id en pasarela
|
type: BIGINT
|
||||||
- column: { name: gateway_order_id, type: VARCHAR(12) } # Ds_Order
|
- column:
|
||||||
- column: { name: authorization_code, type: VARCHAR(32) }
|
name: payment_method_id
|
||||||
- column: { name: three_ds_status, type: ENUM('not_applicable','attempted','challenge','succeeded','failed'), defaultValue: not_applicable, constraints: { nullable: false } }
|
type: BIGINT
|
||||||
- column: { name: descriptor, type: VARCHAR(22) }
|
- column:
|
||||||
- column: { name: client_ip, type: VARBINARY(16) }
|
name: currency
|
||||||
- column: { name: authorized_at, type: DATETIME }
|
type: CHAR(3)
|
||||||
- column: { name: captured_at, type: DATETIME }
|
constraints:
|
||||||
- column: { name: canceled_at, type: DATETIME }
|
nullable: false
|
||||||
- column: { name: failed_at, type: DATETIME }
|
- column:
|
||||||
- column: { name: metadata, type: JSON }
|
name: amount_total_cents
|
||||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
type: BIGINT
|
||||||
- column: { name: updated_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
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:
|
- addForeignKeyConstraint:
|
||||||
baseTableName: payments
|
baseTableName: payments
|
||||||
baseColumnNames: payment_method_id
|
baseColumnNames: payment_method_id
|
||||||
@ -61,34 +180,104 @@ databaseChangeLog:
|
|||||||
referencedColumnNames: id
|
referencedColumnNames: id
|
||||||
constraintName: fk_payments_payment_methods
|
constraintName: fk_payments_payment_methods
|
||||||
onDelete: SET NULL
|
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:
|
||||||
- createIndex: { tableName: payments, indexName: idx_payments_status, columns: [ {name: status} ] }
|
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:
|
- addUniqueConstraint:
|
||||||
tableName: payments
|
tableName: payments
|
||||||
columnNames: gateway, gateway_order_id
|
columnNames: gateway, gateway_order_id
|
||||||
constraintName: uq_payments_gateway_order
|
constraintName: uq_payments_gateway_order
|
||||||
|
|
||||||
# 3) payment_transactions (libro mayor: AUTH/CAPTURE/REFUND/VOID)
|
# 3) payment_transactions
|
||||||
- createTable:
|
- createTable:
|
||||||
tableName: payment_transactions
|
tableName: payment_transactions
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: payment_id, type: BIGINT, constraints: { nullable: false } }
|
name: id
|
||||||
- column: { name: type, type: ENUM('AUTH','CAPTURE','REFUND','VOID'), constraints: { nullable: false } }
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: status, type: ENUM('pending','succeeded','failed'), constraints: { nullable: false } }
|
constraints:
|
||||||
- column: { name: amount_cents, type: BIGINT, constraints: { nullable: false } }
|
primaryKey: true
|
||||||
- column: { name: currency, type: CHAR(3), constraints: { nullable: false } }
|
nullable: false
|
||||||
- column: { name: gateway_transaction_id, type: VARCHAR(128) }
|
- column:
|
||||||
- column: { name: gateway_response_code, type: VARCHAR(64) }
|
name: payment_id
|
||||||
- column: { name: avs_result, type: VARCHAR(8) }
|
type: BIGINT
|
||||||
- column: { name: cvv_result, type: VARCHAR(8) }
|
constraints:
|
||||||
- column: { name: three_ds_version, type: VARCHAR(16) }
|
nullable: false
|
||||||
- column: { name: idempotency_key, type: VARCHAR(128) }
|
- column:
|
||||||
- column: { name: request_payload, type: JSON }
|
name: type
|
||||||
- column: { name: response_payload, type: JSON }
|
type: "ENUM('AUTH','CAPTURE','REFUND','VOID')"
|
||||||
- column: { name: processed_at, type: DATETIME }
|
constraints:
|
||||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
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:
|
- addForeignKeyConstraint:
|
||||||
baseTableName: payment_transactions
|
baseTableName: payment_transactions
|
||||||
baseColumnNames: payment_id
|
baseColumnNames: payment_id
|
||||||
@ -96,30 +285,92 @@ databaseChangeLog:
|
|||||||
referencedColumnNames: id
|
referencedColumnNames: id
|
||||||
constraintName: fk_tx_payment
|
constraintName: fk_tx_payment
|
||||||
onDelete: CASCADE
|
onDelete: CASCADE
|
||||||
|
|
||||||
- addUniqueConstraint:
|
- addUniqueConstraint:
|
||||||
tableName: payment_transactions
|
tableName: payment_transactions
|
||||||
columnNames: gateway_transaction_id
|
columnNames: gateway_transaction_id
|
||||||
constraintName: uq_tx_gateway_txid
|
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:
|
- createTable:
|
||||||
tableName: refunds
|
tableName: refunds
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: payment_id, type: BIGINT, constraints: { nullable: false } }
|
name: id
|
||||||
- column: { name: transaction_id, type: BIGINT } # REFUND en payment_transactions
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: amount_cents, type: BIGINT, constraints: { nullable: false } }
|
constraints:
|
||||||
- column: { name: reason, type: ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other'), defaultValue: customer_request, constraints: { nullable: false } }
|
primaryKey: true
|
||||||
- column: { name: status, type: ENUM('pending','succeeded','failed','canceled'), defaultValue: pending, constraints: { nullable: false } }
|
nullable: false
|
||||||
- column: { name: requested_by_user_id, type: BIGINT }
|
- column:
|
||||||
- column: { name: requested_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
name: payment_id
|
||||||
- column: { name: processed_at, type: DATETIME }
|
type: BIGINT
|
||||||
- column: { name: gateway_refund_id, type: VARCHAR(128) }
|
constraints:
|
||||||
- column: { name: notes, type: VARCHAR(500) }
|
nullable: false
|
||||||
- column: { name: metadata, type: JSON }
|
- 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:
|
- addForeignKeyConstraint:
|
||||||
baseTableName: refunds
|
baseTableName: refunds
|
||||||
baseColumnNames: payment_id
|
baseColumnNames: payment_id
|
||||||
@ -127,6 +378,7 @@ databaseChangeLog:
|
|||||||
referencedColumnNames: id
|
referencedColumnNames: id
|
||||||
constraintName: fk_ref_payment
|
constraintName: fk_ref_payment
|
||||||
onDelete: CASCADE
|
onDelete: CASCADE
|
||||||
|
|
||||||
- addForeignKeyConstraint:
|
- addForeignKeyConstraint:
|
||||||
baseTableName: refunds
|
baseTableName: refunds
|
||||||
baseColumnNames: transaction_id
|
baseColumnNames: transaction_id
|
||||||
@ -134,47 +386,138 @@ databaseChangeLog:
|
|||||||
referencedColumnNames: id
|
referencedColumnNames: id
|
||||||
constraintName: fk_ref_tx
|
constraintName: fk_ref_tx
|
||||||
onDelete: SET NULL
|
onDelete: SET NULL
|
||||||
|
|
||||||
- addUniqueConstraint:
|
- addUniqueConstraint:
|
||||||
tableName: refunds
|
tableName: refunds
|
||||||
columnNames: gateway_refund_id
|
columnNames: gateway_refund_id
|
||||||
constraintName: uq_refund_gateway_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:
|
- createTable:
|
||||||
tableName: webhook_events
|
tableName: webhook_events
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: provider, type: VARCHAR(32), constraints: { nullable: false } } # 'redsys'
|
name: id
|
||||||
- column: { name: event_type, type: VARCHAR(64), constraints: { nullable: false } }
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: event_id, type: VARCHAR(128) }
|
constraints:
|
||||||
- column: { name: signature, type: VARCHAR(512) }
|
primaryKey: true
|
||||||
- column: { name: payload, type: JSON, constraints: { nullable: false } }
|
nullable: false
|
||||||
- column: { name: processed, type: TINYINT(1), defaultValueNumeric: 0, constraints: { nullable: false } }
|
- column:
|
||||||
- column: { name: processed_at, type: DATETIME }
|
name: provider
|
||||||
- column: { name: attempts, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
type: VARCHAR(32)
|
||||||
- column: { name: last_error, type: VARCHAR(500) }
|
constraints:
|
||||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
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:
|
- addUniqueConstraint:
|
||||||
tableName: webhook_events
|
tableName: webhook_events
|
||||||
columnNames: provider, event_id
|
columnNames: provider, event_id
|
||||||
constraintName: uq_webhook_provider_event
|
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:
|
- createTable:
|
||||||
tableName: idempotency_keys
|
tableName: idempotency_keys
|
||||||
columns:
|
columns:
|
||||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
- column:
|
||||||
- column: { name: scope, type: ENUM('payment','refund','webhook'), constraints: { nullable: false } }
|
name: id
|
||||||
- column: { name: idem_key, type: VARCHAR(128), constraints: { nullable: false } }
|
type: BIGINT AUTO_INCREMENT
|
||||||
- column: { name: resource_id, type: BIGINT }
|
constraints:
|
||||||
- column: { name: response_cache, type: JSON }
|
primaryKey: true
|
||||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
nullable: false
|
||||||
- column: { name: expires_at, type: DATETIME }
|
- 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:
|
- addUniqueConstraint:
|
||||||
tableName: idempotency_keys
|
tableName: idempotency_keys
|
||||||
columnNames: scope, idem_key
|
columnNames: scope, idem_key
|
||||||
constraintName: uq_idem_scope_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
|
||||||
|
|||||||
@ -133,6 +133,7 @@ $(() => {
|
|||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
$('#direccion-div').append(html);
|
$('#direccion-div').append(html);
|
||||||
$('#addBillingAddressBtn').addClass('d-none');
|
$('#addBillingAddressBtn').addClass('d-none');
|
||||||
|
$('#btn-checkout').prop('disabled', false);
|
||||||
hideLoader();
|
hideLoader();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -149,6 +150,13 @@ $(() => {
|
|||||||
const $div = $card.parent();
|
const $div = $card.parent();
|
||||||
$card.remove();
|
$card.remove();
|
||||||
$('#addBillingAddressBtn').removeClass('d-none');
|
$('#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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-lg-4 col-sm-6">
|
<div class="col-lg-4 col-sm-6">
|
||||||
<div class="form-check card-radio">
|
<div class="form-check card-radio">
|
||||||
<input id="paymentMethod01" name="paymentMethod" type="radio" class="form-check-input" checked>
|
<input id="paymentMethod01" name="paymentMethod" type="radio" class="form-check-input" checked value="card">
|
||||||
<label class="form-check-label" for="paymentMethod01">
|
<label class="form-check-label" for="paymentMethod01">
|
||||||
<span class="fs-16 text-muted me-2"><i class="mdi mdi-credit-card-outline align-bottom"></i></span>
|
<span class="fs-16 text-muted me-2"><i class="mdi mdi-credit-card-outline align-bottom"></i></span>
|
||||||
<span class="fs-14 text-wrap" th:text="#{checkout.payment.card}"></span>
|
<span class="fs-14 text-wrap" th:text="#{checkout.payment.card}"></span>
|
||||||
@ -13,7 +13,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-lg-4 col-sm-6">
|
<div class="col-lg-4 col-sm-6">
|
||||||
<div class="form-check card-radio">
|
<div class="form-check card-radio">
|
||||||
<input id="paymentMethod02" name="paymentMethod" type="radio" class="form-check-input">
|
<input id="paymentMethod02" name="paymentMethod" type="radio" class="form-check-input" value="bizum">
|
||||||
<label class="form-check-label" for="paymentMethod02">
|
<label class="form-check-label" for="paymentMethod02">
|
||||||
<span class="fs-16 text-muted me-2"><i class="mdi mdi-wallet-outline align-bottom"></i></span>
|
<span class="fs-16 text-muted me-2"><i class="mdi mdi-wallet-outline align-bottom"></i></span>
|
||||||
<span class="fs-14 text-wrap" th:text="#{checkout.payment.bizum}"></span>
|
<span class="fs-14 text-wrap" th:text="#{checkout.payment.bizum}"></span>
|
||||||
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
<div class="col-lg-4 col-sm-6">
|
<div class="col-lg-4 col-sm-6">
|
||||||
<div class="form-check card-radio">
|
<div class="form-check card-radio">
|
||||||
<input id="paymentMethod03" name="paymentMethod" type="radio" class="form-check-input">
|
<input id="paymentMethod03" name="paymentMethod" type="radio" class="form-check-input" value="bank-transfer">
|
||||||
<label class="form-check-label" for="paymentMethod03">
|
<label class="form-check-label" for="paymentMethod03">
|
||||||
<span class="fs-16 text-muted me-2"><i class="mdi mdi-bank-transfer align-bottom"></i></span>
|
<span class="fs-16 text-muted me-2"><i class="mdi mdi-bank-transfer align-bottom"></i></span>
|
||||||
<span class="fs-14 text-wrap" th:text="#{checkout.payment.bank-transfer}"></span>
|
<span class="fs-14 text-wrap" th:text="#{checkout.payment.bank-transfer}"></span>
|
||||||
|
|||||||
@ -39,8 +39,8 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<form th:action="@{/pagos/redsys/crear}" method="post">
|
<form th:action="@{/pagos/redsys/crear}" method="post">
|
||||||
<input type="hidden" name="order" value="123456789012" />
|
|
||||||
<input type="hidden" name="amountCents" th:value="${summary.amountCents}" />
|
<input type="hidden" name="amountCents" th:value="${summary.amountCents}" />
|
||||||
|
<input type="hidden" name="method" value="card"/>
|
||||||
<button id="btn-checkout" type="submit" class="btn btn-secondary w-100 mt-2"
|
<button id="btn-checkout" type="submit" class="btn btn-secondary w-100 mt-2"
|
||||||
th:text="#{checkout.make-payment}" disabled>Checkout</button>
|
th:text="#{checkout.make-payment}" disabled>Checkout</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user