diff --git a/src/main/java/com/imprimelibros/erp/pagos imprimelibros.zip b/src/main/java/com/imprimelibros/erp/pagos imprimelibros.zip new file mode 100644 index 0000000..a23c4c8 Binary files /dev/null and b/src/main/java/com/imprimelibros/erp/pagos imprimelibros.zip differ diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java index 573f0e2..ec64784 100644 --- a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -10,6 +10,7 @@ import com.imprimelibros.erp.redsys.RedsysService.FormPayload; import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.imprimelibros.erp.payments.repo.WebhookEventRepository; import java.time.LocalDateTime; import java.util.Objects; @@ -22,16 +23,19 @@ public class PaymentService { private final PaymentTransactionRepository txRepo; private final RefundRepository refundRepo; private final RedsysService redsysService; + private final WebhookEventRepository webhookEventRepo; private final ObjectMapper om = new ObjectMapper(); public PaymentService(PaymentRepository payRepo, PaymentTransactionRepository txRepo, RefundRepository refundRepo, - RedsysService redsysService) { + RedsysService redsysService, + WebhookEventRepository webhookEventRepo) { this.payRepo = payRepo; this.txRepo = txRepo; this.refundRepo = refundRepo; this.redsysService = redsysService; + this.webhookEventRepo = webhookEventRepo; } /** @@ -42,15 +46,20 @@ public class PaymentService { public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method) throws Exception { Payment p = new Payment(); - p.setOrderId(orderId); // <- ahora puede ser null + p.setOrderId(orderId); p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("redsys"); - p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD); + p.setStatus(PaymentStatus.requires_payment_method); p = payRepo.saveAndFlush(p); - // Ds_Order = ID del Payment, 12 dígitos - String dsOrder = String.format("%012d", p.getId()); + // ANTES: + // String dsOrder = String.format("%012d", p.getId()); + + // AHORA: timestamp + long now = System.currentTimeMillis(); + String dsOrder = String.format("%012d", now % 1_000_000_000_000L); + p.setGatewayOrderId(dsOrder); payRepo.save(p); @@ -64,81 +73,137 @@ public class PaymentService { } } - // si aún tienes la versión antigua sin method, puedes dejar este overload si te - // viene bien: - @Transactional - public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception { - return createRedsysPayment(orderId, amountCents, currency, "card"); - } - - /** - * Procesa una notificación Redsys (OK/notify) con la API oficial: - * - validateAndParseNotification usa createMerchantSignatureNotif + - * decodeMerchantParameters - */ @Transactional public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { - RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); - // Log útil para depurar - System.out.println(">> Redsys notify: order=" + notif.order + - " amountCents=" + notif.amountCents + - " currency=" + notif.currency + - " response=" + notif.response); + // 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo + // y salimos. + RedsysNotification notif; + try { + notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters); + } catch (Exception ex) { + WebhookEvent e = new WebhookEvent(); + e.setProvider("redsys"); + e.setEventType("payment_notification_parse_error"); + e.setEventId("PARSE_ERROR_" + System.currentTimeMillis()); + e.setSignature(dsSignature); + e.setPayload(dsMerchantParameters); + e.setProcessed(false); + e.setAttempts(1); + e.setLastError("Error parsing/validating Redsys notification: " + ex.getMessage()); + webhookEventRepo.save(e); - Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order) - .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); - - // 🔹 Opción sencilla: sólo comprobar el importe - if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) { - throw new IllegalStateException("Importe inesperado: esperado=" + - p.getAmountTotalCents() + " recibido=" + notif.amountCents); - } - - // Si quieres, puedes hacer un check mínimamente decente de divisa numérica: - // (si usas siempre EUR) - /* - * if (!"978".equals(notif.currency)) { - * throw new IllegalStateException("Divisa Redsys inesperada: " + - * notif.currency); - * } - */ - - // Idempotencia simple: si ya está capturado o reembolsado, no hacemos nada - if (p.getStatus() == PaymentStatus.CAPTURED - || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED - || p.getStatus() == PaymentStatus.REFUNDED) { + // IMPORTANTE: NO re-lanzamos la excepción + // Simplemente salimos. Así se hace commit de este insert. return; } - PaymentTransaction tx = new PaymentTransaction(); - tx.setPayment(p); - tx.setType(PaymentTransactionType.CAPTURE); - tx.setCurrency(p.getCurrency()); // "EUR" - tx.setAmountCents(notif.amountCents); - tx.setStatus(notif.authorized() - ? PaymentTransactionStatus.SUCCEEDED - : PaymentTransactionStatus.FAILED); + // 1) A partir de aquí, el parseo ha ido bien y tenemos notif.order, + // notif.amountCents, etc. + String provider = "redsys"; + String eventType = "payment_notification"; + String eventId = notif.order; - Object authCode = notif.raw.get("Ds_AuthorisationCode"); - tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null); - tx.setGatewayResponseCode(notif.response); - tx.setResponsePayload(om.writeValueAsString(notif.raw)); - tx.setProcessedAt(LocalDateTime.now()); - txRepo.save(tx); + WebhookEvent ev = webhookEventRepo + .findByProviderAndEventId(provider, eventId) + .orElseGet(() -> { + WebhookEvent e = new WebhookEvent(); + e.setProvider(provider); + e.setEventType(eventType); + e.setEventId(eventId); + e.setSignature(dsSignature); + try { + e.setPayload(om.writeValueAsString(notif.raw)); + } catch (Exception ex) { + e.setPayload(dsMerchantParameters); + } + e.setProcessed(false); + e.setAttempts(0); + return webhookEventRepo.save(e); + }); - if (notif.authorized()) { - p.setAuthorizationCode(tx.getGatewayTransactionId()); - p.setStatus(PaymentStatus.CAPTURED); - p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); - p.setAuthorizedAt(LocalDateTime.now()); - p.setCapturedAt(LocalDateTime.now()); - } else { - p.setStatus(PaymentStatus.FAILED); - p.setFailedAt(LocalDateTime.now()); + if (Boolean.TRUE.equals(ev.getProcessed())) { + return; } - payRepo.save(p); + Integer attempts = ev.getAttempts() == null ? 0 : ev.getAttempts(); + ev.setAttempts(attempts + 1); + ev.setLastError(null); + webhookEventRepo.save(ev); + + try { + Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.order) + .orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); + + if (!Objects.equals(p.getAmountTotalCents(), notif.amountCents)) { + throw new IllegalStateException("Importe inesperado: esperado=" + + p.getAmountTotalCents() + " recibido=" + notif.amountCents); + } + + if (p.getStatus() == PaymentStatus.captured + || p.getStatus() == PaymentStatus.partially_refunded + || p.getStatus() == PaymentStatus.refunded) { + ev.setProcessed(true); + ev.setProcessedAt(LocalDateTime.now()); + webhookEventRepo.save(ev); + return; + } + + boolean authorized = isRedsysAuthorized(notif); + + PaymentTransaction tx = new PaymentTransaction(); + tx.setPayment(p); + tx.setType(PaymentTransactionType.CAPTURE); + tx.setCurrency(p.getCurrency()); // "EUR" + tx.setAmountCents(notif.amountCents); + tx.setStatus(authorized + ? PaymentTransactionStatus.succeeded + : PaymentTransactionStatus.failed); + + Object authCode = notif.raw.get("Ds_AuthorisationCode"); + String gatewayTxId = null; + if (authCode != null) { + String trimmed = String.valueOf(authCode).trim(); + // Redsys devuelve " " (espacios) cuando NO hay código de autorización. + // Eso lo consideramos "sin ID" → null, para no chocar con el índice único. + if (!trimmed.isEmpty()) { + gatewayTxId = trimmed; + } + } + // MySQL permite múltiples NULL en un índice UNIQUE, así que es seguro. + tx.setGatewayTransactionId(gatewayTxId); + tx.setGatewayResponseCode(notif.response); + tx.setResponsePayload(om.writeValueAsString(notif.raw)); + tx.setProcessedAt(LocalDateTime.now()); + txRepo.save(tx); + + if (authorized) { + p.setAuthorizationCode(tx.getGatewayTransactionId()); + p.setStatus(PaymentStatus.captured); + p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.amountCents); + p.setAuthorizedAt(LocalDateTime.now()); + p.setCapturedAt(LocalDateTime.now()); + } else { + p.setStatus(PaymentStatus.failed); + p.setFailedAt(LocalDateTime.now()); + } + + payRepo.save(p); + + if (!authorized) { + ev.setLastError("Payment declined (Ds_Response=" + notif.response + ")"); + } + + ev.setProcessed(true); + ev.setProcessedAt(LocalDateTime.now()); + webhookEventRepo.save(ev); + } catch (Exception e) { + ev.setProcessed(false); + ev.setLastError(e.getMessage()); + ev.setProcessedAt(null); + webhookEventRepo.save(ev); + throw e; // aquí sí, porque queremos que si falla lógica de negocio el caller se entere + } } // ---- refundViaRedsys y bank_transfer igual que antes, no tocan RedsysService @@ -163,7 +228,7 @@ public class PaymentService { Refund r = new Refund(); r.setPayment(p); r.setAmountCents(amountCents); - r.setStatus(RefundStatus.PENDING); + r.setStatus(RefundStatus.pending); r.setRequestedAt(LocalDateTime.now()); r = refundRepo.save(r); @@ -173,7 +238,7 @@ public class PaymentService { PaymentTransaction tx = new PaymentTransaction(); tx.setPayment(p); tx.setType(PaymentTransactionType.REFUND); - tx.setStatus(PaymentTransactionStatus.SUCCEEDED); + tx.setStatus(PaymentTransactionStatus.succeeded); tx.setAmountCents(amountCents); tx.setCurrency(p.getCurrency()); tx.setGatewayTransactionId(gatewayRefundId); @@ -181,7 +246,7 @@ public class PaymentService { tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); - r.setStatus(RefundStatus.SUCCEEDED); + r.setStatus(RefundStatus.succeeded); r.setTransaction(tx); r.setGatewayRefundId(gatewayRefundId); r.setProcessedAt(LocalDateTime.now()); @@ -189,9 +254,9 @@ public class PaymentService { p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents); if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) { - p.setStatus(PaymentStatus.REFUNDED); + p.setStatus(PaymentStatus.refunded); } else { - p.setStatus(PaymentStatus.PARTIALLY_REFUNDED); + p.setStatus(PaymentStatus.partially_refunded); } payRepo.save(p); } @@ -199,31 +264,75 @@ public class PaymentService { @Transactional public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) { Payment p = new Payment(); - p.setOrderId(orderId); // null en tu caso actual + p.setOrderId(orderId); p.setCurrency(currency); p.setAmountTotalCents(amountCents); p.setGateway("bank_transfer"); - p.setStatus(PaymentStatus.REQUIRES_ACTION); // pendiente de ingreso - return payRepo.save(p); + p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso + p = payRepo.save(p); + + // Crear transacción pendiente + PaymentTransaction tx = new PaymentTransaction(); + tx.setPayment(p); + tx.setType(PaymentTransactionType.CAPTURE); // o AUTH si prefieres + tx.setStatus(PaymentTransactionStatus.pending); + tx.setAmountCents(amountCents); + tx.setCurrency(currency); + // tx.setProcessedAt(null); // la dejas nula hasta que se confirme + txRepo.save(tx); + + return p; } @Transactional public void markBankTransferAsCaptured(Long paymentId) { - Payment p = payRepo.findById(paymentId).orElseThrow(); - if (!"bank_transfer".equals(p.getGateway())) - throw new IllegalStateException("No es transferencia"); - p.setAmountCapturedCents(p.getAmountTotalCents()); - p.setCapturedAt(LocalDateTime.now()); - p.setStatus(PaymentStatus.CAPTURED); - payRepo.save(p); + Payment p = payRepo.findById(paymentId) + .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId)); - PaymentTransaction tx = new PaymentTransaction(); - tx.setPayment(p); - tx.setType(PaymentTransactionType.CAPTURE); - tx.setStatus(PaymentTransactionStatus.SUCCEEDED); - tx.setAmountCents(p.getAmountTotalCents()); - tx.setCurrency(p.getCurrency()); + if (!"bank_transfer".equals(p.getGateway())) { + throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer"); + } + + // Idempotencia simple: si ya está capturado no hacemos nada + if (p.getStatus() == PaymentStatus.captured + || p.getStatus() == PaymentStatus.partially_refunded + || p.getStatus() == PaymentStatus.refunded) { + return; + } + + // 1) Buscar la transacción pendiente de captura + PaymentTransaction tx = txRepo + .findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc( + paymentId, + PaymentTransactionType.CAPTURE, + PaymentTransactionStatus.pending) + .orElseThrow(() -> new IllegalStateException( + "No se ha encontrado transacción PENDING para la transferencia " + paymentId)); + + // 2) Actualizarla a SUCCEEDED y rellenar processedAt + tx.setStatus(PaymentTransactionStatus.succeeded); tx.setProcessedAt(LocalDateTime.now()); txRepo.save(tx); + + // 3) Actualizar el Payment + p.setAmountCapturedCents(p.getAmountTotalCents()); + p.setCapturedAt(LocalDateTime.now()); + p.setStatus(PaymentStatus.captured); + payRepo.save(p); } + + private boolean isRedsysAuthorized(RedsysService.RedsysNotification notif) { + if (notif.response == null) { + return false; + } + String r = notif.response.trim(); + // Si no es numérico, lo tratamos como no autorizado + if (!r.matches("\\d+")) { + return false; + } + int code = Integer.parseInt(r); + // Redsys: 0–99 → autorizado; >=100 → denegado / error + return code >= 0 && code <= 99; + } + } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java index 3239394..2d9c53e 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java @@ -1,4 +1,5 @@ package com.imprimelibros.erp.payments.model; -public enum CaptureMethod { AUTOMATIC, MANUAL } + +public enum CaptureMethod { automatic, manual } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java deleted file mode 100644 index bcf200e..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java +++ /dev/null @@ -1,66 +0,0 @@ -package com.imprimelibros.erp.payments.model; - -import jakarta.persistence.*; -import java.time.LocalDateTime; - -@Entity -@Table( - name = "idempotency_keys", - uniqueConstraints = { - @UniqueConstraint(name = "uq_idem_scope_key", columnNames = {"scope","idem_key"}) - }, - indexes = { - @Index(name = "idx_idem_resource", columnList = "resource_id") - } -) -public class IdempotencyKey { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Enumerated(EnumType.STRING) - @Column(name = "scope", nullable = false, length = 16) - private IdempotencyScope scope; - - @Column(name = "idem_key", nullable = false, length = 128) - private String idemKey; - - @Column(name = "resource_id") - private Long resourceId; - - @Column(name = "response_cache", columnDefinition = "json") - private String responseCache; - - @Column(name = "created_at", nullable = false, - columnDefinition = "datetime default current_timestamp") - private LocalDateTime createdAt; - - @Column(name = "expires_at") - private LocalDateTime expiresAt; - - public IdempotencyKey() {} - - // Getters & Setters - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public IdempotencyScope getScope() { return scope; } - public void setScope(IdempotencyScope scope) { this.scope = scope; } - - public String getIdemKey() { return idemKey; } - public void setIdemKey(String idemKey) { this.idemKey = idemKey; } - - public Long getResourceId() { return resourceId; } - public void setResourceId(Long resourceId) { this.resourceId = resourceId; } - - public String getResponseCache() { return responseCache; } - public void setResponseCache(String responseCache) { this.responseCache = responseCache; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - - public LocalDateTime getExpiresAt() { return expiresAt; } - public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } -} - diff --git a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java deleted file mode 100644 index e7088d4..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java +++ /dev/null @@ -1,3 +0,0 @@ -package com.imprimelibros.erp.payments.model; - -public enum IdempotencyScope { PAYMENT, REFUND, WEBHOOK } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Payment.java b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java index 18267f7..5ca95b9 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/Payment.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java @@ -17,10 +17,6 @@ public class Payment { @Column(name = "user_id") private Long userId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "payment_method_id") - private PaymentMethod paymentMethod; - @Column(nullable = false, length = 3) private String currency; @@ -35,11 +31,11 @@ public class Payment { @Enumerated(EnumType.STRING) @Column(nullable = false, length = 32) - private PaymentStatus status = PaymentStatus.REQUIRES_PAYMENT_METHOD; + private PaymentStatus status = PaymentStatus.requires_payment_method; @Enumerated(EnumType.STRING) @Column(name = "capture_method", nullable = false, length = 16) - private CaptureMethod captureMethod = CaptureMethod.AUTOMATIC; + private CaptureMethod captureMethod = CaptureMethod.automatic; @Column(nullable = false, length = 32) private String gateway; @@ -55,7 +51,7 @@ public class Payment { @Enumerated(EnumType.STRING) @Column(name = "three_ds_status", nullable = false, length = 32) - private ThreeDSStatus threeDsStatus = ThreeDSStatus.NOT_APPLICABLE; + private ThreeDSStatus threeDsStatus = ThreeDSStatus.not_applicable; @Column(length = 22) private String descriptor; @@ -99,9 +95,6 @@ public class Payment { public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } - public PaymentMethod getPaymentMethod() { return paymentMethod; } - public void setPaymentMethod(PaymentMethod paymentMethod) { this.paymentMethod = paymentMethod; } - public String getCurrency() { return currency; } public void setCurrency(String currency) { this.currency = currency; } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java deleted file mode 100644 index ab5833f..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.imprimelibros.erp.payments.model; - -import jakarta.persistence.*; -import java.time.LocalDateTime; - -@Entity -@Table(name = "payment_methods") -public class PaymentMethod { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @Column(name = "user_id") - private Long userId; - - @Enumerated(EnumType.STRING) - @Column(nullable = false, length = 32) - private PaymentMethodType type; - - @Column(length = 32) - private String brand; - - @Column(length = 4) - private String last4; - - @Column(name = "exp_month") - private Integer expMonth; - - @Column(name = "exp_year") - private Integer expYear; - - @Column(length = 128) - private String fingerprint; - - @Column(length = 128, unique = true) - private String tokenId; - - @Column(length = 128) - private String sepaMandateId; - - @Column(length = 190) - private String payerEmail; - - @Column(columnDefinition = "json") - private String metadata; - - @Column(name = "created_at", nullable = false, - columnDefinition = "datetime default current_timestamp") - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false, - columnDefinition = "datetime default current_timestamp on update current_timestamp") - private LocalDateTime updatedAt; - - // ---- Getters/Setters ---- - public PaymentMethod() {} - - public Long getId() { return id; } - public void setId(Long id) { this.id = id; } - - public Long getUserId() { return userId; } - public void setUserId(Long userId) { this.userId = userId; } - - public PaymentMethodType getType() { return type; } - public void setType(PaymentMethodType type) { this.type = type; } - - public String getBrand() { return brand; } - public void setBrand(String brand) { this.brand = brand; } - - public String getLast4() { return last4; } - public void setLast4(String last4) { this.last4 = last4; } - - public Integer getExpMonth() { return expMonth; } - public void setExpMonth(Integer expMonth) { this.expMonth = expMonth; } - - public Integer getExpYear() { return expYear; } - public void setExpYear(Integer expYear) { this.expYear = expYear; } - - public String getFingerprint() { return fingerprint; } - public void setFingerprint(String fingerprint) { this.fingerprint = fingerprint; } - - public String getTokenId() { return tokenId; } - public void setTokenId(String tokenId) { this.tokenId = tokenId; } - - public String getSepaMandateId() { return sepaMandateId; } - public void setSepaMandateId(String sepaMandateId) { this.sepaMandateId = sepaMandateId; } - - public String getPayerEmail() { return payerEmail; } - public void setPayerEmail(String payerEmail) { this.payerEmail = payerEmail; } - - public String getMetadata() { return metadata; } - public void setMetadata(String metadata) { this.metadata = metadata; } - - public LocalDateTime getCreatedAt() { return createdAt; } - public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } - - public LocalDateTime getUpdatedAt() { return updatedAt; } - public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } -} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java deleted file mode 100644 index e0ec386..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.imprimelibros.erp.payments.model; - -public enum PaymentMethodType { CARD, BIZUM, BANK_TRANSFER } - diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java index 18604be..661660f 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java @@ -1,8 +1,8 @@ package com.imprimelibros.erp.payments.model; public enum PaymentStatus { - REQUIRES_PAYMENT_METHOD, REQUIRES_ACTION, AUTHORIZED, - CAPTURED, PARTIALLY_REFUNDED, REFUNDED, CANCELED, FAILED + requires_payment_method, requires_action, authorized, + captured, partially_refunded, refunded, canceled, failed } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java index a7fd404..f8ec70c 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java @@ -120,4 +120,13 @@ public class PaymentTransaction { public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + @PrePersist + public void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + } + } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java index f495274..5ff279b 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java @@ -1,4 +1,4 @@ package com.imprimelibros.erp.payments.model; -public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED } +public enum PaymentTransactionStatus { pending, succeeded, failed } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Refund.java b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java index 576e752..06a4516 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/Refund.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java @@ -33,11 +33,11 @@ public class Refund { @Enumerated(EnumType.STRING) @Column(name = "reason", nullable = false, length = 32) - private RefundReason reason = RefundReason.CUSTOMER_REQUEST; + private RefundReason reason = RefundReason.customer_request; @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false, length = 16) - private RefundStatus status = RefundStatus.PENDING; + private RefundStatus status = RefundStatus.pending; @Column(name = "requested_by_user_id") private Long requestedByUserId; diff --git a/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java index 95235a8..432e146 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java @@ -1,6 +1,6 @@ package com.imprimelibros.erp.payments.model; public enum RefundReason { - CUSTOMER_REQUEST, PARTIAL_RETURN, PRICING_ADJUSTMENT, DUPLICATE, FRAUD, OTHER + customer_request, partial_return, pricing_adjustment, duplicate, fraud, other } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java index e15fd1d..d7e6f79 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java @@ -1,4 +1,4 @@ package com.imprimelibros.erp.payments.model; -public enum RefundStatus { PENDING, SUCCEEDED, FAILED, CANCELED } +public enum RefundStatus { pending, succeeded, failed, canceled } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java index 8982ae1..1af1879 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java @@ -1,4 +1,4 @@ package com.imprimelibros.erp.payments.model; -public enum ThreeDSStatus { NOT_APPLICABLE, ATTEMPTED, CHALLENGE, SUCCEEDED, FAILED } +public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed } diff --git a/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java b/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java index 201dc81..7dd8f0a 100644 --- a/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java +++ b/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java @@ -85,4 +85,12 @@ public class WebhookEvent { public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + + @PrePersist + public void prePersist() { + LocalDateTime now = LocalDateTime.now(); + if (createdAt == null) { + createdAt = now; + } + } } diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java deleted file mode 100644 index a04ea60..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java +++ /dev/null @@ -1,12 +0,0 @@ -// IdempotencyKeyRepository.java -package com.imprimelibros.erp.payments.repo; - -import com.imprimelibros.erp.payments.model.IdempotencyKey; -import com.imprimelibros.erp.payments.model.IdempotencyScope; -import org.springframework.data.jpa.repository.JpaRepository; - -import java.util.Optional; - -public interface IdempotencyKeyRepository extends JpaRepository { - Optional findByScopeAndIdemKey(IdempotencyScope scope, String idemKey); -} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java deleted file mode 100644 index 397d1ef..0000000 --- a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -// PaymentMethodRepository.java -package com.imprimelibros.erp.payments.repo; - -import com.imprimelibros.erp.payments.model.PaymentMethod; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface PaymentMethodRepository extends JpaRepository {} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java index 2965178..aac12ad 100644 --- a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java @@ -2,6 +2,9 @@ package com.imprimelibros.erp.payments.repo; import com.imprimelibros.erp.payments.model.PaymentTransaction; +import com.imprimelibros.erp.payments.model.PaymentTransactionStatus; +import com.imprimelibros.erp.payments.model.PaymentTransactionType; + import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; @@ -9,4 +12,9 @@ import java.util.Optional; public interface PaymentTransactionRepository extends JpaRepository { Optional findByGatewayTransactionId(String gatewayTransactionId); Optional findByIdempotencyKey(String idempotencyKey); + Optional findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc( + Long paymentId, + PaymentTransactionType type, + PaymentTransactionStatus status + ); } diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java index 6e7228d..57c6d4c 100644 --- a/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java +++ b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java @@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface RefundRepository extends JpaRepository { - @Query("select coalesce(sum(r.amountCents),0) from Refund r where r.payment.id = :paymentId and r.status = com.imprimelibros.erp.payments.model.RefundStatus.SUCCEEDED") + @Query("select coalesce(sum(r.amountCents),0) from Refund r where r.payment.id = :paymentId and r.status = com.imprimelibros.erp.payments.model.RefundStatus.succeeded") long sumSucceededByPaymentId(@Param("paymentId") Long paymentId); } diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java index 9ba9488..c70070b 100644 --- a/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java +++ b/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java @@ -4,4 +4,9 @@ package com.imprimelibros.erp.payments.repo; import com.imprimelibros.erp.payments.model.WebhookEvent; import org.springframework.data.jpa.repository.JpaRepository; -public interface WebhookEventRepository extends JpaRepository {} +import java.util.Optional; + +public interface WebhookEventRepository extends JpaRepository { + + Optional findByProviderAndEventId(String provider, String eventId); +} diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java index a1d4d8c..b0bcf07 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysController.java @@ -10,9 +10,7 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import java.nio.charset.StandardCharsets; -import java.util.Map; import java.util.UUID; -import org.springframework.transaction.annotation.Transactional; @Controller @RequestMapping("/pagos/redsys") @@ -100,7 +98,6 @@ public class RedsysController { // integraciones ni lo usan) @PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody - @jakarta.transaction.Transactional public ResponseEntity okPost(@RequestParam("Ds_Signature") String signature, @RequestParam("Ds_MerchantParameters") String merchantParameters) { try { @@ -121,14 +118,26 @@ public class RedsysController { @PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody - public ResponseEntity koPost(@RequestParam Map form) { - // Podrías loguear 'form' si quieres ver qué manda Redsys - return ResponseEntity.ok("

Pago cancelado o rechazado

Volver"); + public ResponseEntity koPost( + @RequestParam("Ds_Signature") String signature, + @RequestParam("Ds_MerchantParameters") String merchantParameters) { + + try { + // Procesamos la notificación IGUAL que en /ok y /notify + paymentService.handleRedsysNotification(signature, merchantParameters); + + // Mensaje para el usuario (pago cancelado/rechazado) + String html = "

Pago cancelado o rechazado

Volver"; + return ResponseEntity.ok(html); + } catch (Exception e) { + // Si algo falla al validar/procesar, lo mostramos (útil en entorno de pruebas) + String html = "

Error procesando notificación KO

" + e.getMessage() + "
"; + return ResponseEntity.badRequest().body(html); + } } @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @ResponseBody - @jakarta.transaction.Transactional public String notifyRedsys(@RequestParam("Ds_Signature") String signature, @RequestParam("Ds_MerchantParameters") String merchantParameters) { try { diff --git a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java index d6a4067..be317ee 100644 --- a/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java +++ b/src/main/java/com/imprimelibros/erp/redsys/RedsysService.java @@ -105,27 +105,31 @@ public class RedsysService { // ---------- STEP 4: Validar notificación ---------- public RedsysNotification validateAndParseNotification(String dsSignature, String dsMerchantParametersB64) throws Exception { - // 1) Decodificamos a mapa solo para leer campos - Map mp = decodeMerchantParametersToMap(dsMerchantParametersB64); + + ApiMacSha256 api = new ApiMacSha256(); + + // 1) Decodificar Ds_MerchantParameters usando la librería oficial + String json = api.decodeMerchantParameters(dsMerchantParametersB64); + + // 2) Convertir a Map para tu modelo + Map mp = MAPPER.readValue(json, new TypeReference<>() { + }); RedsysNotification notif = new RedsysNotification(mp); if (notif.order == null || notif.order.isBlank()) { + System.out.println("### ATENCIÓN: Ds_Order no viene en MerchantParameters"); throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters"); } - // 2) Calculamos la firma esperada usando el B64 tal cual - ApiMacSha256 api = new ApiMacSha256(); - // Esta línea es opcional para createMerchantSignatureNotif, pero no molesta: - api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64); - + // 3) Calcular firma esperada: clave comercio + MerchantParameters en B64 String expected = api.createMerchantSignatureNotif( - secretKeyBase64, - dsMerchantParametersB64 // 👈 AQUÍ va el B64, NO el JSON + secretKeyBase64, // 👈 La misma que usas para crear la firma del pago + dsMerchantParametersB64 // 👈 SIEMPRE el B64 tal cual llega de Redsys, sin tocar ); - // 3) Comparamos en constante time, normalizando Base64 URL-safe + // 4) Comparar firma Redsys vs firma calculada if (!safeEqualsB64(dsSignature, expected)) { - System.out.println("Firma Redsys no válida"); + System.out.println("### Firma Redsys no válida"); System.out.println("Ds_Signature (Redsys) = " + dsSignature); System.out.println("Expected (local) = " + expected); throw new SecurityException("Firma Redsys no válida"); 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 2d3cd5c..4ef635f 100644 --- a/src/main/resources/db/changelog/changesets/0007-payments-core.yml +++ b/src/main/resources/db/changelog/changesets/0007-payments-core.yml @@ -3,70 +3,7 @@ databaseChangeLog: id: 0007-payments-core author: jjo changes: - # 1) payment_methods - - createTable: - tableName: payment_methods - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: user_id - type: BIGINT - - column: - name: type - type: "ENUM('card','bizum','bank_transfer')" - constraints: - nullable: false - - column: - name: brand - type: VARCHAR(32) - - column: - name: last4 - type: VARCHAR(4) - - column: - name: exp_month - type: TINYINT - - column: - name: exp_year - type: SMALLINT - - column: - name: fingerprint - type: VARCHAR(128) - - column: - name: token_id - type: VARCHAR(128) - - column: - name: sepa_mandate_id - type: VARCHAR(128) - - column: - name: payer_email - type: VARCHAR(190) - - column: - name: metadata - type: JSON - - column: - name: created_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false - - column: - name: updated_at - type: DATETIME - defaultValueComputed: "CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP" - constraints: - nullable: false - - - addUniqueConstraint: - tableName: payment_methods - columnNames: token_id - constraintName: uq_payment_methods_token - - # 2) payments + # 2) payments - createTable: tableName: payments columns: @@ -82,9 +19,6 @@ databaseChangeLog: - column: name: user_id type: BIGINT - - column: - name: payment_method_id - type: BIGINT - column: name: currency type: CHAR(3) @@ -173,13 +107,6 @@ databaseChangeLog: constraints: nullable: false - - addForeignKeyConstraint: - baseTableName: payments - baseColumnNames: payment_method_id - referencedTableName: payment_methods - referencedColumnNames: id - constraintName: fk_payments_payment_methods - onDelete: SET NULL - createIndex: tableName: payments @@ -474,50 +401,3 @@ databaseChangeLog: - column: name: processed - # 6) idempotency_keys - - createTable: - tableName: idempotency_keys - columns: - - column: - name: id - type: BIGINT AUTO_INCREMENT - constraints: - primaryKey: true - nullable: false - - column: - name: scope - type: "ENUM('payment','refund','webhook')" - constraints: - nullable: false - - column: - name: idem_key - type: VARCHAR(128) - constraints: - nullable: false - - column: - name: resource_id - type: BIGINT - - column: - name: response_cache - type: JSON - - column: - name: created_at - type: DATETIME - defaultValueComputed: CURRENT_TIMESTAMP - constraints: - nullable: false - - column: - name: expires_at - type: DATETIME - - - addUniqueConstraint: - tableName: idempotency_keys - columnNames: scope, idem_key - constraintName: uq_idem_scope_key - - - createIndex: - tableName: idempotency_keys - indexName: idx_idem_resource - columns: - - column: - name: resource_id