falta vista de pagos

This commit is contained in:
2025-11-04 14:40:18 +01:00
parent f528809c07
commit 7516e9e91e
24 changed files with 276 additions and 442 deletions

View File

@ -10,6 +10,7 @@ import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification; import com.imprimelibros.erp.redsys.RedsysService.RedsysNotification;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import com.imprimelibros.erp.payments.repo.WebhookEventRepository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Objects; import java.util.Objects;
@ -22,16 +23,19 @@ public class PaymentService {
private final PaymentTransactionRepository txRepo; private final PaymentTransactionRepository txRepo;
private final RefundRepository refundRepo; private final RefundRepository refundRepo;
private final RedsysService redsysService; private final RedsysService redsysService;
private final WebhookEventRepository webhookEventRepo;
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,
WebhookEventRepository webhookEventRepo) {
this.payRepo = payRepo; this.payRepo = payRepo;
this.txRepo = txRepo; this.txRepo = txRepo;
this.refundRepo = refundRepo; this.refundRepo = refundRepo;
this.redsysService = redsysService; this.redsysService = redsysService;
this.webhookEventRepo = webhookEventRepo;
} }
/** /**
@ -42,15 +46,20 @@ public class PaymentService {
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method) public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency, String method)
throws Exception { throws Exception {
Payment p = new Payment(); Payment p = new Payment();
p.setOrderId(orderId); // <- ahora puede ser null p.setOrderId(orderId);
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 // ANTES:
String dsOrder = String.format("%012d", p.getId()); // 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); p.setGatewayOrderId(dsOrder);
payRepo.save(p); 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 @Transactional
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception { public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
RedsysNotification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
// Log útil para depurar // 0) Intentamos parsear la notificación. Si falla, registramos el webhook crudo
System.out.println(">> Redsys notify: order=" + notif.order + // y salimos.
" amountCents=" + notif.amountCents + RedsysNotification notif;
" currency=" + notif.currency + try {
" response=" + notif.response); 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) // IMPORTANTE: NO re-lanzamos la excepción
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.order)); // Simplemente salimos. Así se hace commit de este insert.
// 🔹 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) {
return; return;
} }
PaymentTransaction tx = new PaymentTransaction(); // 1) A partir de aquí, el parseo ha ido bien y tenemos notif.order,
tx.setPayment(p); // notif.amountCents, etc.
tx.setType(PaymentTransactionType.CAPTURE); String provider = "redsys";
tx.setCurrency(p.getCurrency()); // "EUR" String eventType = "payment_notification";
tx.setAmountCents(notif.amountCents); String eventId = notif.order;
tx.setStatus(notif.authorized()
? PaymentTransactionStatus.SUCCEEDED
: PaymentTransactionStatus.FAILED);
Object authCode = notif.raw.get("Ds_AuthorisationCode"); WebhookEvent ev = webhookEventRepo
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null); .findByProviderAndEventId(provider, eventId)
tx.setGatewayResponseCode(notif.response); .orElseGet(() -> {
tx.setResponsePayload(om.writeValueAsString(notif.raw)); WebhookEvent e = new WebhookEvent();
tx.setProcessedAt(LocalDateTime.now()); e.setProvider(provider);
txRepo.save(tx); 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()) { if (Boolean.TRUE.equals(ev.getProcessed())) {
p.setAuthorizationCode(tx.getGatewayTransactionId()); return;
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); 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 // ---- refundViaRedsys y bank_transfer igual que antes, no tocan RedsysService
@ -163,7 +228,7 @@ public class PaymentService {
Refund r = new Refund(); Refund r = new Refund();
r.setPayment(p); r.setPayment(p);
r.setAmountCents(amountCents); r.setAmountCents(amountCents);
r.setStatus(RefundStatus.PENDING); r.setStatus(RefundStatus.pending);
r.setRequestedAt(LocalDateTime.now()); r.setRequestedAt(LocalDateTime.now());
r = refundRepo.save(r); r = refundRepo.save(r);
@ -173,7 +238,7 @@ public class PaymentService {
PaymentTransaction tx = new PaymentTransaction(); PaymentTransaction tx = new PaymentTransaction();
tx.setPayment(p); tx.setPayment(p);
tx.setType(PaymentTransactionType.REFUND); tx.setType(PaymentTransactionType.REFUND);
tx.setStatus(PaymentTransactionStatus.SUCCEEDED); tx.setStatus(PaymentTransactionStatus.succeeded);
tx.setAmountCents(amountCents); tx.setAmountCents(amountCents);
tx.setCurrency(p.getCurrency()); tx.setCurrency(p.getCurrency());
tx.setGatewayTransactionId(gatewayRefundId); tx.setGatewayTransactionId(gatewayRefundId);
@ -181,7 +246,7 @@ public class PaymentService {
tx.setProcessedAt(LocalDateTime.now()); tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx); txRepo.save(tx);
r.setStatus(RefundStatus.SUCCEEDED); r.setStatus(RefundStatus.succeeded);
r.setTransaction(tx); r.setTransaction(tx);
r.setGatewayRefundId(gatewayRefundId); r.setGatewayRefundId(gatewayRefundId);
r.setProcessedAt(LocalDateTime.now()); r.setProcessedAt(LocalDateTime.now());
@ -189,9 +254,9 @@ public class PaymentService {
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents); p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) { if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
p.setStatus(PaymentStatus.REFUNDED); p.setStatus(PaymentStatus.refunded);
} else { } else {
p.setStatus(PaymentStatus.PARTIALLY_REFUNDED); p.setStatus(PaymentStatus.partially_refunded);
} }
payRepo.save(p); payRepo.save(p);
} }
@ -199,31 +264,75 @@ public class PaymentService {
@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); // null en tu caso actual p.setOrderId(orderId);
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); // pendiente de ingreso p.setStatus(PaymentStatus.requires_action); // pendiente de ingreso
return payRepo.save(p); 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 @Transactional
public void markBankTransferAsCaptured(Long paymentId) { public void markBankTransferAsCaptured(Long paymentId) {
Payment p = payRepo.findById(paymentId).orElseThrow(); Payment p = payRepo.findById(paymentId)
if (!"bank_transfer".equals(p.getGateway())) .orElseThrow(() -> new IllegalArgumentException("Payment no encontrado: " + paymentId));
throw new IllegalStateException("No es transferencia");
p.setAmountCapturedCents(p.getAmountTotalCents());
p.setCapturedAt(LocalDateTime.now());
p.setStatus(PaymentStatus.CAPTURED);
payRepo.save(p);
PaymentTransaction tx = new PaymentTransaction(); if (!"bank_transfer".equals(p.getGateway())) {
tx.setPayment(p); throw new IllegalStateException("El Payment " + paymentId + " no es de tipo bank_transfer");
tx.setType(PaymentTransactionType.CAPTURE); }
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
tx.setAmountCents(p.getAmountTotalCents()); // Idempotencia simple: si ya está capturado no hacemos nada
tx.setCurrency(p.getCurrency()); 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()); tx.setProcessedAt(LocalDateTime.now());
txRepo.save(tx); 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: 099 → autorizado; >=100 → denegado / error
return code >= 0 && code <= 99;
}
} }

View File

@ -1,4 +1,5 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum CaptureMethod { AUTOMATIC, MANUAL }
public enum CaptureMethod { automatic, manual }

View File

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

View File

@ -1,3 +0,0 @@
package com.imprimelibros.erp.payments.model;
public enum IdempotencyScope { PAYMENT, REFUND, WEBHOOK }

View File

@ -17,10 +17,6 @@ public class Payment {
@Column(name = "user_id") @Column(name = "user_id")
private Long userId; private Long userId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "payment_method_id")
private PaymentMethod paymentMethod;
@Column(nullable = false, length = 3) @Column(nullable = false, length = 3)
private String currency; private String currency;
@ -35,11 +31,11 @@ public class Payment {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(nullable = false, length = 32) @Column(nullable = false, length = 32)
private PaymentStatus status = PaymentStatus.REQUIRES_PAYMENT_METHOD; private PaymentStatus status = PaymentStatus.requires_payment_method;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "capture_method", nullable = false, length = 16) @Column(name = "capture_method", nullable = false, length = 16)
private CaptureMethod captureMethod = CaptureMethod.AUTOMATIC; private CaptureMethod captureMethod = CaptureMethod.automatic;
@Column(nullable = false, length = 32) @Column(nullable = false, length = 32)
private String gateway; private String gateway;
@ -55,7 +51,7 @@ public class Payment {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "three_ds_status", nullable = false, length = 32) @Column(name = "three_ds_status", nullable = false, length = 32)
private ThreeDSStatus threeDsStatus = ThreeDSStatus.NOT_APPLICABLE; private ThreeDSStatus threeDsStatus = ThreeDSStatus.not_applicable;
@Column(length = 22) @Column(length = 22)
private String descriptor; private String descriptor;
@ -99,9 +95,6 @@ public class Payment {
public Long getUserId() { return userId; } public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = 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 String getCurrency() { return currency; }
public void setCurrency(String currency) { this.currency = currency; } public void setCurrency(String currency) { this.currency = currency; }

View File

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

View File

@ -1,4 +0,0 @@
package com.imprimelibros.erp.payments.model;
public enum PaymentMethodType { CARD, BIZUM, BANK_TRANSFER }

View File

@ -1,8 +1,8 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum PaymentStatus { public enum PaymentStatus {
REQUIRES_PAYMENT_METHOD, REQUIRES_ACTION, AUTHORIZED, requires_payment_method, requires_action, authorized,
CAPTURED, PARTIALLY_REFUNDED, REFUNDED, CANCELED, FAILED captured, partially_refunded, refunded, canceled, failed
} }

View File

@ -120,4 +120,13 @@ public class PaymentTransaction {
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
}
} }

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED } public enum PaymentTransactionStatus { pending, succeeded, failed }

View File

@ -33,11 +33,11 @@ public class Refund {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "reason", nullable = false, length = 32) @Column(name = "reason", nullable = false, length = 32)
private RefundReason reason = RefundReason.CUSTOMER_REQUEST; private RefundReason reason = RefundReason.customer_request;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 16) @Column(name = "status", nullable = false, length = 16)
private RefundStatus status = RefundStatus.PENDING; private RefundStatus status = RefundStatus.pending;
@Column(name = "requested_by_user_id") @Column(name = "requested_by_user_id")
private Long requestedByUserId; private Long requestedByUserId;

View File

@ -1,6 +1,6 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum RefundReason { public enum RefundReason {
CUSTOMER_REQUEST, PARTIAL_RETURN, PRICING_ADJUSTMENT, DUPLICATE, FRAUD, OTHER customer_request, partial_return, pricing_adjustment, duplicate, fraud, other
} }

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum RefundStatus { PENDING, SUCCEEDED, FAILED, CANCELED } public enum RefundStatus { pending, succeeded, failed, canceled }

View File

@ -1,4 +1,4 @@
package com.imprimelibros.erp.payments.model; package com.imprimelibros.erp.payments.model;
public enum ThreeDSStatus { NOT_APPLICABLE, ATTEMPTED, CHALLENGE, SUCCEEDED, FAILED } public enum ThreeDSStatus { not_applicable, attempted, challenge, succeeded, failed }

View File

@ -85,4 +85,12 @@ public class WebhookEvent {
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
@PrePersist
public void prePersist() {
LocalDateTime now = LocalDateTime.now();
if (createdAt == null) {
createdAt = now;
}
}
} }

View File

@ -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<IdempotencyKey, Long> {
Optional<IdempotencyKey> findByScopeAndIdemKey(IdempotencyScope scope, String idemKey);
}

View File

@ -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<PaymentMethod, Long> {}

View File

@ -2,6 +2,9 @@
package com.imprimelibros.erp.payments.repo; package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.PaymentTransaction; import com.imprimelibros.erp.payments.model.PaymentTransaction;
import com.imprimelibros.erp.payments.model.PaymentTransactionStatus;
import com.imprimelibros.erp.payments.model.PaymentTransactionType;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
@ -9,4 +12,9 @@ import java.util.Optional;
public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long> { public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long> {
Optional<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId); Optional<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId);
Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey); Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey);
Optional<PaymentTransaction> findFirstByPaymentIdAndTypeAndStatusOrderByIdDesc(
Long paymentId,
PaymentTransactionType type,
PaymentTransactionStatus status
);
} }

View File

@ -7,6 +7,6 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
public interface RefundRepository extends JpaRepository<Refund, Long> { public interface RefundRepository extends JpaRepository<Refund, Long> {
@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); long sumSucceededByPaymentId(@Param("paymentId") Long paymentId);
} }

View File

@ -4,4 +4,9 @@ package com.imprimelibros.erp.payments.repo;
import com.imprimelibros.erp.payments.model.WebhookEvent; import com.imprimelibros.erp.payments.model.WebhookEvent;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {} import java.util.Optional;
public interface WebhookEventRepository extends JpaRepository<WebhookEvent, Long> {
Optional<WebhookEvent> findByProviderAndEventId(String provider, String eventId);
}

View File

@ -10,9 +10,7 @@ import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import org.springframework.transaction.annotation.Transactional;
@Controller @Controller
@RequestMapping("/pagos/redsys") @RequestMapping("/pagos/redsys")
@ -100,7 +98,6 @@ public class RedsysController {
// integraciones ni lo usan) // integraciones ni lo usan)
@PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/ok", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
@jakarta.transaction.Transactional
public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature, public ResponseEntity<String> okPost(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters) {
try { try {
@ -121,14 +118,26 @@ public class RedsysController {
@PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/ko", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
public ResponseEntity<String> koPost(@RequestParam Map<String, String> form) { public ResponseEntity<String> koPost(
// Podrías loguear 'form' si quieres ver qué manda Redsys @RequestParam("Ds_Signature") String signature,
return ResponseEntity.ok("<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>"); @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 = "<h2>Pago cancelado o rechazado</h2><a href=\"/checkout\">Volver</a>";
return ResponseEntity.ok(html);
} catch (Exception e) {
// Si algo falla al validar/procesar, lo mostramos (útil en entorno de pruebas)
String html = "<h2>Error procesando notificación KO</h2><pre>" + e.getMessage() + "</pre>";
return ResponseEntity.badRequest().body(html);
}
} }
@PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @PostMapping(value = "/notify", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE)
@ResponseBody @ResponseBody
@jakarta.transaction.Transactional
public String notifyRedsys(@RequestParam("Ds_Signature") String signature, public String notifyRedsys(@RequestParam("Ds_Signature") String signature,
@RequestParam("Ds_MerchantParameters") String merchantParameters) { @RequestParam("Ds_MerchantParameters") String merchantParameters) {
try { try {

View File

@ -105,27 +105,31 @@ 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 {
// 1) Decodificamos a mapa solo para leer campos
Map<String, Object> 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<String, Object> mp = MAPPER.readValue(json, new TypeReference<>() {
});
RedsysNotification notif = new RedsysNotification(mp); RedsysNotification notif = new RedsysNotification(mp);
if (notif.order == null || notif.order.isBlank()) { 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"); throw new IllegalArgumentException("Falta Ds_Order en Ds_MerchantParameters");
} }
// 2) Calculamos la firma esperada usando el B64 tal cual // 3) Calcular firma esperada: clave comercio + MerchantParameters en B64
ApiMacSha256 api = new ApiMacSha256();
// Esta línea es opcional para createMerchantSignatureNotif, pero no molesta:
api.setParameter("Ds_MerchantParameters", dsMerchantParametersB64);
String expected = api.createMerchantSignatureNotif( String expected = api.createMerchantSignatureNotif(
secretKeyBase64, secretKeyBase64, // 👈 La misma que usas para crear la firma del pago
dsMerchantParametersB64 // 👈 AQUÍ va el B64, NO el JSON 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)) { 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("Ds_Signature (Redsys) = " + dsSignature);
System.out.println("Expected (local) = " + expected); System.out.println("Expected (local) = " + expected);
throw new SecurityException("Firma Redsys no válida"); throw new SecurityException("Firma Redsys no válida");

View File

@ -3,70 +3,7 @@ databaseChangeLog:
id: 0007-payments-core id: 0007-payments-core
author: jjo author: jjo
changes: changes:
# 1) payment_methods # 2) payments
- 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
- createTable: - createTable:
tableName: payments tableName: payments
columns: columns:
@ -82,9 +19,6 @@ databaseChangeLog:
- column: - column:
name: user_id name: user_id
type: BIGINT type: BIGINT
- column:
name: payment_method_id
type: BIGINT
- column: - column:
name: currency name: currency
type: CHAR(3) type: CHAR(3)
@ -173,13 +107,6 @@ databaseChangeLog:
constraints: constraints:
nullable: false nullable: false
- addForeignKeyConstraint:
baseTableName: payments
baseColumnNames: payment_method_id
referencedTableName: payment_methods
referencedColumnNames: id
constraintName: fk_payments_payment_methods
onDelete: SET NULL
- createIndex: - createIndex:
tableName: payments tableName: payments
@ -474,50 +401,3 @@ databaseChangeLog:
- column: - column:
name: processed 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