mirror of
https://git.imnavajas.es/jjimenez/erp-imprimelibros.git
synced 2026-01-12 16:38:48 +00:00
añadidos ficheros a falta de modificar el servicio y el controlador redsys
This commit is contained in:
183
src/main/java/com/imprimelibros/erp/payments/PaymentService.java
Normal file
183
src/main/java/com/imprimelibros/erp/payments/PaymentService.java
Normal file
@ -0,0 +1,183 @@
|
||||
package com.imprimelibros.erp.payments;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.imprimelibros.erp.payments.model.*;
|
||||
import com.imprimelibros.erp.payments.repo.PaymentRepository;
|
||||
import com.imprimelibros.erp.payments.repo.PaymentTransactionRepository;
|
||||
import com.imprimelibros.erp.payments.repo.RefundRepository;
|
||||
import com.imprimelibros.erp.redsys.RedsysService;
|
||||
import com.imprimelibros.erp.redsys.RedsysService.FormPayload;
|
||||
import com.imprimelibros.erp.redsys.RedsysService.Notification;
|
||||
import com.imprimelibros.erp.redsys.RedsysService.PaymentRequest;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Service
|
||||
public class PaymentService {
|
||||
|
||||
private final PaymentRepository payRepo;
|
||||
private final PaymentTransactionRepository txRepo;
|
||||
private final RefundRepository refundRepo;
|
||||
private final RedsysService redsysService;
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
@Autowired
|
||||
public PaymentService(PaymentRepository payRepo,
|
||||
PaymentTransactionRepository txRepo,
|
||||
RefundRepository refundRepo,
|
||||
RedsysService redsysService) {
|
||||
this.payRepo = payRepo;
|
||||
this.txRepo = txRepo;
|
||||
this.refundRepo = refundRepo;
|
||||
this.redsysService = redsysService;
|
||||
}
|
||||
|
||||
/** Crea Payment y devuelve form auto-submit Redsys. Ds_Order = 12 dígitos con el ID. */
|
||||
@Transactional
|
||||
public FormPayload createRedsysPayment(Long orderId, long amountCents, String currency) throws Exception {
|
||||
Payment p = new Payment();
|
||||
p.setOrderId(orderId);
|
||||
p.setCurrency(currency);
|
||||
p.setAmountTotalCents(amountCents);
|
||||
p.setGateway("redsys");
|
||||
p.setStatus(PaymentStatus.REQUIRES_PAYMENT_METHOD);
|
||||
p = payRepo.saveAndFlush(p);
|
||||
|
||||
String dsOrder = String.format("%012d", p.getId());
|
||||
p.setGatewayOrderId(dsOrder);
|
||||
payRepo.save(p);
|
||||
|
||||
PaymentRequest req = new PaymentRequest(dsOrder, amountCents, "Compra en Imprimelibros", "card");
|
||||
return redsysService.buildRedirectForm(req);
|
||||
}
|
||||
|
||||
/** Procesa notificación Redsys (ok/notify). Idempotente. */
|
||||
@Transactional
|
||||
public void handleRedsysNotification(String dsSignature, String dsMerchantParameters) throws Exception {
|
||||
Notification notif = redsysService.validateAndParseNotification(dsSignature, dsMerchantParameters);
|
||||
|
||||
Payment p = payRepo.findByGatewayAndGatewayOrderId("redsys", notif.getOrder())
|
||||
.orElseThrow(() -> new IllegalStateException("Payment no encontrado para Ds_Order " + notif.getOrder()));
|
||||
|
||||
if (!Objects.equals(p.getCurrency(), notif.getCurrency()))
|
||||
throw new IllegalStateException("Divisa inesperada");
|
||||
if (!Objects.equals(p.getAmountTotalCents(), notif.getAmountCents()))
|
||||
throw new IllegalStateException("Importe inesperado");
|
||||
|
||||
// ¿Ya registrado? Si ya capturaste, no repitas.
|
||||
if (p.getStatus() == PaymentStatus.CAPTURED || p.getStatus() == PaymentStatus.PARTIALLY_REFUNDED || p.getStatus() == PaymentStatus.REFUNDED) {
|
||||
return; // idempotencia simple a nivel Payment
|
||||
}
|
||||
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.CAPTURE);
|
||||
tx.setCurrency(p.getCurrency());
|
||||
tx.setAmountCents(notif.getAmountCents());
|
||||
tx.setStatus(notif.isAuthorized() ? PaymentTransactionStatus.SUCCEEDED : PaymentTransactionStatus.FAILED);
|
||||
// En Redsys el authorization code suele estar en Ds_AuthorisationCode
|
||||
Object authCode = notif.getRaw().get("Ds_AuthorisationCode");
|
||||
tx.setGatewayTransactionId(authCode != null ? String.valueOf(authCode) : null);
|
||||
tx.setGatewayResponseCode(notif.getResponse());
|
||||
tx.setResponsePayload(om.writeValueAsString(notif.getRaw()));
|
||||
tx.setProcessedAt(LocalDateTime.now());
|
||||
txRepo.save(tx);
|
||||
|
||||
if (notif.isAuthorized()) {
|
||||
p.setAuthorizationCode(tx.getGatewayTransactionId());
|
||||
p.setStatus(PaymentStatus.CAPTURED);
|
||||
p.setAmountCapturedCents(p.getAmountCapturedCents() + notif.getAmountCents());
|
||||
p.setAuthorizedAt(LocalDateTime.now());
|
||||
p.setCapturedAt(LocalDateTime.now());
|
||||
} else {
|
||||
p.setStatus(PaymentStatus.FAILED);
|
||||
p.setFailedAt(LocalDateTime.now());
|
||||
}
|
||||
payRepo.save(p);
|
||||
}
|
||||
|
||||
/** Refund (simulado a nivel pasarela; actualiza BD). Sustituye gatewayRefundId por el real cuando lo tengas. */
|
||||
@Transactional
|
||||
public void refundViaRedsys(Long paymentId, long amountCents, String idempotencyKey) {
|
||||
Payment p = payRepo.findById(paymentId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Payment no encontrado"));
|
||||
|
||||
if (amountCents <= 0) throw new IllegalArgumentException("Importe inválido");
|
||||
long maxRefundable = p.getAmountCapturedCents() - p.getAmountRefundedCents();
|
||||
if (amountCents > maxRefundable) throw new IllegalStateException("Importe de devolución supera lo capturado");
|
||||
|
||||
txRepo.findByIdempotencyKey(idempotencyKey)
|
||||
.ifPresent(t -> { throw new IllegalStateException("Reembolso ya procesado"); });
|
||||
|
||||
Refund r = new Refund();
|
||||
r.setPayment(p);
|
||||
r.setAmountCents(amountCents);
|
||||
r.setStatus(RefundStatus.PENDING);
|
||||
r.setRequestedAt(LocalDateTime.now());
|
||||
r = refundRepo.save(r);
|
||||
|
||||
String gatewayRefundId = "REF-" + UUID.randomUUID(); // TODO: sustituir por el ID real de Redsys si usas su canal de devoluciones
|
||||
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.REFUND);
|
||||
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
|
||||
tx.setAmountCents(amountCents);
|
||||
tx.setCurrency(p.getCurrency());
|
||||
tx.setGatewayTransactionId(gatewayRefundId);
|
||||
tx.setIdempotencyKey(idempotencyKey);
|
||||
tx.setProcessedAt(LocalDateTime.now());
|
||||
txRepo.save(tx);
|
||||
|
||||
r.setStatus(RefundStatus.SUCCEEDED);
|
||||
r.setTransaction(tx);
|
||||
r.setGatewayRefundId(gatewayRefundId);
|
||||
r.setProcessedAt(LocalDateTime.now());
|
||||
refundRepo.save(r);
|
||||
|
||||
p.setAmountRefundedCents(p.getAmountRefundedCents() + amountCents);
|
||||
if (p.getAmountRefundedCents().equals(p.getAmountCapturedCents())) {
|
||||
p.setStatus(PaymentStatus.REFUNDED);
|
||||
} else {
|
||||
p.setStatus(PaymentStatus.PARTIALLY_REFUNDED);
|
||||
}
|
||||
payRepo.save(p);
|
||||
}
|
||||
|
||||
/** Transferencia bancaria: crea Payment en espera de ingreso. */
|
||||
@Transactional
|
||||
public Payment createBankTransferPayment(Long orderId, long amountCents, String currency) {
|
||||
Payment p = new Payment();
|
||||
p.setOrderId(orderId);
|
||||
p.setCurrency(currency);
|
||||
p.setAmountTotalCents(amountCents);
|
||||
p.setGateway("bank_transfer");
|
||||
p.setStatus(PaymentStatus.REQUIRES_ACTION);
|
||||
return payRepo.save(p);
|
||||
}
|
||||
|
||||
/** Marca transferencia como conciliada (capturada). */
|
||||
@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);
|
||||
|
||||
PaymentTransaction tx = new PaymentTransaction();
|
||||
tx.setPayment(p);
|
||||
tx.setType(PaymentTransactionType.CAPTURE);
|
||||
tx.setStatus(PaymentTransactionStatus.SUCCEEDED);
|
||||
tx.setAmountCents(p.getAmountTotalCents());
|
||||
tx.setCurrency(p.getCurrency());
|
||||
tx.setProcessedAt(LocalDateTime.now());
|
||||
txRepo.save(tx);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum CaptureMethod { AUTOMATIC, MANUAL }
|
||||
|
||||
@ -0,0 +1,66 @@
|
||||
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; }
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum IdempotencyScope { PAYMENT, REFUND, WEBHOOK }
|
||||
164
src/main/java/com/imprimelibros/erp/payments/model/Payment.java
Normal file
164
src/main/java/com/imprimelibros/erp/payments/model/Payment.java
Normal file
@ -0,0 +1,164 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "payments")
|
||||
public class Payment {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "order_id", nullable = false)
|
||||
private Long orderId;
|
||||
|
||||
@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;
|
||||
|
||||
@Column(name = "amount_total_cents", nullable = false)
|
||||
private Long amountTotalCents;
|
||||
|
||||
@Column(name = "amount_captured_cents", nullable = false)
|
||||
private Long amountCapturedCents = 0L;
|
||||
|
||||
@Column(name = "amount_refunded_cents", nullable = false)
|
||||
private Long amountRefundedCents = 0L;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(nullable = false, length = 32)
|
||||
private PaymentStatus status = PaymentStatus.REQUIRES_PAYMENT_METHOD;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "capture_method", nullable = false, length = 16)
|
||||
private CaptureMethod captureMethod = CaptureMethod.AUTOMATIC;
|
||||
|
||||
@Column(nullable = false, length = 32)
|
||||
private String gateway;
|
||||
|
||||
@Column(name = "gateway_payment_id", length = 128)
|
||||
private String gatewayPaymentId;
|
||||
|
||||
@Column(name = "gateway_order_id", length = 12)
|
||||
private String gatewayOrderId;
|
||||
|
||||
@Column(name = "authorization_code", length = 32)
|
||||
private String authorizationCode;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "three_ds_status", nullable = false, length = 32)
|
||||
private ThreeDSStatus threeDsStatus = ThreeDSStatus.NOT_APPLICABLE;
|
||||
|
||||
@Column(length = 22)
|
||||
private String descriptor;
|
||||
|
||||
@Lob
|
||||
@Column(name = "client_ip", columnDefinition = "varbinary(16)")
|
||||
private byte[] clientIp;
|
||||
|
||||
@Column(name = "authorized_at")
|
||||
private LocalDateTime authorizedAt;
|
||||
|
||||
@Column(name = "captured_at")
|
||||
private LocalDateTime capturedAt;
|
||||
|
||||
@Column(name = "canceled_at")
|
||||
private LocalDateTime canceledAt;
|
||||
|
||||
@Column(name = "failed_at")
|
||||
private LocalDateTime failedAt;
|
||||
|
||||
@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;
|
||||
|
||||
public Payment() {}
|
||||
|
||||
// Getters y setters ↓ (los típicos)
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Long getOrderId() { return orderId; }
|
||||
public void setOrderId(Long orderId) { this.orderId = orderId; }
|
||||
|
||||
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; }
|
||||
|
||||
public Long getAmountTotalCents() { return amountTotalCents; }
|
||||
public void setAmountTotalCents(Long amountTotalCents) { this.amountTotalCents = amountTotalCents; }
|
||||
|
||||
public Long getAmountCapturedCents() { return amountCapturedCents; }
|
||||
public void setAmountCapturedCents(Long amountCapturedCents) { this.amountCapturedCents = amountCapturedCents; }
|
||||
|
||||
public Long getAmountRefundedCents() { return amountRefundedCents; }
|
||||
public void setAmountRefundedCents(Long amountRefundedCents) { this.amountRefundedCents = amountRefundedCents; }
|
||||
|
||||
public PaymentStatus getStatus() { return status; }
|
||||
public void setStatus(PaymentStatus status) { this.status = status; }
|
||||
|
||||
public CaptureMethod getCaptureMethod() { return captureMethod; }
|
||||
public void setCaptureMethod(CaptureMethod captureMethod) { this.captureMethod = captureMethod; }
|
||||
|
||||
public String getGateway() { return gateway; }
|
||||
public void setGateway(String gateway) { this.gateway = gateway; }
|
||||
|
||||
public String getGatewayPaymentId() { return gatewayPaymentId; }
|
||||
public void setGatewayPaymentId(String gatewayPaymentId) { this.gatewayPaymentId = gatewayPaymentId; }
|
||||
|
||||
public String getGatewayOrderId() { return gatewayOrderId; }
|
||||
public void setGatewayOrderId(String gatewayOrderId) { this.gatewayOrderId = gatewayOrderId; }
|
||||
|
||||
public String getAuthorizationCode() { return authorizationCode; }
|
||||
public void setAuthorizationCode(String authorizationCode) { this.authorizationCode = authorizationCode; }
|
||||
|
||||
public ThreeDSStatus getThreeDsStatus() { return threeDsStatus; }
|
||||
public void setThreeDsStatus(ThreeDSStatus threeDsStatus) { this.threeDsStatus = threeDsStatus; }
|
||||
|
||||
public String getDescriptor() { return descriptor; }
|
||||
public void setDescriptor(String descriptor) { this.descriptor = descriptor; }
|
||||
|
||||
public byte[] getClientIp() { return clientIp; }
|
||||
public void setClientIp(byte[] clientIp) { this.clientIp = clientIp; }
|
||||
|
||||
public LocalDateTime getAuthorizedAt() { return authorizedAt; }
|
||||
public void setAuthorizedAt(LocalDateTime authorizedAt) { this.authorizedAt = authorizedAt; }
|
||||
|
||||
public LocalDateTime getCapturedAt() { return capturedAt; }
|
||||
public void setCapturedAt(LocalDateTime capturedAt) { this.capturedAt = capturedAt; }
|
||||
|
||||
public LocalDateTime getCanceledAt() { return canceledAt; }
|
||||
public void setCanceledAt(LocalDateTime canceledAt) { this.canceledAt = canceledAt; }
|
||||
|
||||
public LocalDateTime getFailedAt() { return failedAt; }
|
||||
public void setFailedAt(LocalDateTime failedAt) { this.failedAt = failedAt; }
|
||||
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,100 @@
|
||||
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; }
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentMethodType { CARD, BIZUM, BANK_TRANSFER }
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentStatus {
|
||||
REQUIRES_PAYMENT_METHOD, REQUIRES_ACTION, AUTHORIZED,
|
||||
CAPTURED, PARTIALLY_REFUNDED, REFUNDED, CANCELED, FAILED
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "payment_transactions",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_tx_gateway_txid", columnNames = {"gateway_transaction_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_tx_pay", columnList = "payment_id"),
|
||||
@Index(name = "idx_tx_type_status", columnList = "type,status"),
|
||||
@Index(name = "idx_tx_idem", columnList = "idempotency_key")
|
||||
}
|
||||
)
|
||||
public class PaymentTransaction {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "payment_id", nullable = false)
|
||||
private Payment payment;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "type", nullable = false, length = 16)
|
||||
private PaymentTransactionType type;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private PaymentTransactionStatus status;
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Long amountCents;
|
||||
|
||||
@Column(name = "currency", nullable = false, length = 3)
|
||||
private String currency;
|
||||
|
||||
@Column(name = "gateway_transaction_id", length = 128)
|
||||
private String gatewayTransactionId;
|
||||
|
||||
@Column(name = "gateway_response_code", length = 64)
|
||||
private String gatewayResponseCode;
|
||||
|
||||
@Column(name = "avs_result", length = 8)
|
||||
private String avsResult;
|
||||
|
||||
@Column(name = "cvv_result", length = 8)
|
||||
private String cvvResult;
|
||||
|
||||
@Column(name = "three_ds_version", length = 16)
|
||||
private String threeDsVersion;
|
||||
|
||||
@Column(name = "idempotency_key", length = 128)
|
||||
private String idempotencyKey;
|
||||
|
||||
@Column(name = "request_payload", columnDefinition = "json")
|
||||
private String requestPayload;
|
||||
|
||||
@Column(name = "response_payload", columnDefinition = "json")
|
||||
private String responsePayload;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public PaymentTransaction() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Payment getPayment() { return payment; }
|
||||
public void setPayment(Payment payment) { this.payment = payment; }
|
||||
|
||||
public PaymentTransactionType getType() { return type; }
|
||||
public void setType(PaymentTransactionType type) { this.type = type; }
|
||||
|
||||
public PaymentTransactionStatus getStatus() { return status; }
|
||||
public void setStatus(PaymentTransactionStatus status) { this.status = status; }
|
||||
|
||||
public Long getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public String getCurrency() { return currency; }
|
||||
public void setCurrency(String currency) { this.currency = currency; }
|
||||
|
||||
public String getGatewayTransactionId() { return gatewayTransactionId; }
|
||||
public void setGatewayTransactionId(String gatewayTransactionId) { this.gatewayTransactionId = gatewayTransactionId; }
|
||||
|
||||
public String getGatewayResponseCode() { return gatewayResponseCode; }
|
||||
public void setGatewayResponseCode(String gatewayResponseCode) { this.gatewayResponseCode = gatewayResponseCode; }
|
||||
|
||||
public String getAvsResult() { return avsResult; }
|
||||
public void setAvsResult(String avsResult) { this.avsResult = avsResult; }
|
||||
|
||||
public String getCvvResult() { return cvvResult; }
|
||||
public void setCvvResult(String cvvResult) { this.cvvResult = cvvResult; }
|
||||
|
||||
public String getThreeDsVersion() { return threeDsVersion; }
|
||||
public void setThreeDsVersion(String threeDsVersion) { this.threeDsVersion = threeDsVersion; }
|
||||
|
||||
public String getIdempotencyKey() { return idempotencyKey; }
|
||||
public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; }
|
||||
|
||||
public String getRequestPayload() { return requestPayload; }
|
||||
public void setRequestPayload(String requestPayload) { this.requestPayload = requestPayload; }
|
||||
|
||||
public String getResponsePayload() { return responsePayload; }
|
||||
public void setResponsePayload(String responsePayload) { this.responsePayload = responsePayload; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED }
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID }
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "refunds",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_refund_gateway_id", columnNames = {"gateway_refund_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_ref_pay", columnList = "payment_id"),
|
||||
@Index(name = "idx_ref_status", columnList = "status")
|
||||
}
|
||||
)
|
||||
public class Refund {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||
@JoinColumn(name = "payment_id", nullable = false)
|
||||
private Payment payment;
|
||||
|
||||
@OneToOne(fetch = FetchType.LAZY)
|
||||
@JoinColumn(name = "transaction_id")
|
||||
private PaymentTransaction transaction; // el REFUND en payment_transactions
|
||||
|
||||
@Column(name = "amount_cents", nullable = false)
|
||||
private Long amountCents;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "reason", nullable = false, length = 32)
|
||||
private RefundReason reason = RefundReason.CUSTOMER_REQUEST;
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "status", nullable = false, length = 16)
|
||||
private RefundStatus status = RefundStatus.PENDING;
|
||||
|
||||
@Column(name = "requested_by_user_id")
|
||||
private Long requestedByUserId;
|
||||
|
||||
@Column(name = "requested_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime requestedAt;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "gateway_refund_id", length = 128)
|
||||
private String gatewayRefundId;
|
||||
|
||||
@Column(name = "notes", length = 500)
|
||||
private String notes;
|
||||
|
||||
@Column(name = "metadata", columnDefinition = "json")
|
||||
private String metadata;
|
||||
|
||||
public Refund() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Payment getPayment() { return payment; }
|
||||
public void setPayment(Payment payment) { this.payment = payment; }
|
||||
|
||||
public PaymentTransaction getTransaction() { return transaction; }
|
||||
public void setTransaction(PaymentTransaction transaction) { this.transaction = transaction; }
|
||||
|
||||
public Long getAmountCents() { return amountCents; }
|
||||
public void setAmountCents(Long amountCents) { this.amountCents = amountCents; }
|
||||
|
||||
public RefundReason getReason() { return reason; }
|
||||
public void setReason(RefundReason reason) { this.reason = reason; }
|
||||
|
||||
public RefundStatus getStatus() { return status; }
|
||||
public void setStatus(RefundStatus status) { this.status = status; }
|
||||
|
||||
public Long getRequestedByUserId() { return requestedByUserId; }
|
||||
public void setRequestedByUserId(Long requestedByUserId) { this.requestedByUserId = requestedByUserId; }
|
||||
|
||||
public LocalDateTime getRequestedAt() { return requestedAt; }
|
||||
public void setRequestedAt(LocalDateTime requestedAt) { this.requestedAt = requestedAt; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public String getGatewayRefundId() { return gatewayRefundId; }
|
||||
public void setGatewayRefundId(String gatewayRefundId) { this.gatewayRefundId = gatewayRefundId; }
|
||||
|
||||
public String getNotes() { return notes; }
|
||||
public void setNotes(String notes) { this.notes = notes; }
|
||||
|
||||
public String getMetadata() { return metadata; }
|
||||
public void setMetadata(String metadata) { this.metadata = metadata; }
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundReason {
|
||||
CUSTOMER_REQUEST, PARTIAL_RETURN, PRICING_ADJUSTMENT, DUPLICATE, FRAUD, OTHER
|
||||
}
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum RefundStatus { PENDING, SUCCEEDED, FAILED, CANCELED }
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
public enum ThreeDSStatus { NOT_APPLICABLE, ATTEMPTED, CHALLENGE, SUCCEEDED, FAILED }
|
||||
|
||||
@ -0,0 +1,88 @@
|
||||
package com.imprimelibros.erp.payments.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "webhook_events",
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uq_webhook_provider_event", columnNames = {"provider","event_id"})
|
||||
},
|
||||
indexes = {
|
||||
@Index(name = "idx_webhook_processed", columnList = "processed")
|
||||
}
|
||||
)
|
||||
public class WebhookEvent {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "provider", nullable = false, length = 32)
|
||||
private String provider; // "redsys", etc.
|
||||
|
||||
@Column(name = "event_type", nullable = false, length = 64)
|
||||
private String eventType;
|
||||
|
||||
@Column(name = "event_id", length = 128)
|
||||
private String eventId;
|
||||
|
||||
@Column(name = "signature", length = 512)
|
||||
private String signature;
|
||||
|
||||
@Column(name = "payload", nullable = false, columnDefinition = "json")
|
||||
private String payload;
|
||||
|
||||
@Column(name = "processed", nullable = false)
|
||||
private Boolean processed = false;
|
||||
|
||||
@Column(name = "processed_at")
|
||||
private LocalDateTime processedAt;
|
||||
|
||||
@Column(name = "attempts", nullable = false)
|
||||
private Integer attempts = 0;
|
||||
|
||||
@Column(name = "last_error", length = 500)
|
||||
private String lastError;
|
||||
|
||||
@Column(name = "created_at", nullable = false,
|
||||
columnDefinition = "datetime default current_timestamp")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
public WebhookEvent() {}
|
||||
|
||||
// Getters & Setters
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getProvider() { return provider; }
|
||||
public void setProvider(String provider) { this.provider = provider; }
|
||||
|
||||
public String getEventType() { return eventType; }
|
||||
public void setEventType(String eventType) { this.eventType = eventType; }
|
||||
|
||||
public String getEventId() { return eventId; }
|
||||
public void setEventId(String eventId) { this.eventId = eventId; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
|
||||
public String getPayload() { return payload; }
|
||||
public void setPayload(String payload) { this.payload = payload; }
|
||||
|
||||
public Boolean getProcessed() { return processed; }
|
||||
public void setProcessed(Boolean processed) { this.processed = processed; }
|
||||
|
||||
public LocalDateTime getProcessedAt() { return processedAt; }
|
||||
public void setProcessedAt(LocalDateTime processedAt) { this.processedAt = processedAt; }
|
||||
|
||||
public Integer getAttempts() { return attempts; }
|
||||
public void setAttempts(Integer attempts) { this.attempts = attempts; }
|
||||
|
||||
public String getLastError() { return lastError; }
|
||||
public void setLastError(String lastError) { this.lastError = lastError; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// 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);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
// 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> {}
|
||||
@ -0,0 +1,11 @@
|
||||
// PaymentRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.Payment;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PaymentRepository extends JpaRepository<Payment, Long> {
|
||||
Optional<Payment> findByGatewayAndGatewayOrderId(String gateway, String gatewayOrderId);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// PaymentTransactionRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.PaymentTransaction;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public interface PaymentTransactionRepository extends JpaRepository<PaymentTransaction, Long> {
|
||||
Optional<PaymentTransaction> findByGatewayTransactionId(String gatewayTransactionId);
|
||||
Optional<PaymentTransaction> findByIdempotencyKey(String idempotencyKey);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
// RefundRepository.java
|
||||
package com.imprimelibros.erp.payments.repo;
|
||||
|
||||
import com.imprimelibros.erp.payments.model.Refund;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
|
||||
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")
|
||||
long sumSucceededByPaymentId(@Param("paymentId") Long paymentId);
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
// WebhookEventRepository.java
|
||||
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<WebhookEvent, Long> {}
|
||||
@ -0,0 +1,180 @@
|
||||
databaseChangeLog:
|
||||
- changeSet:
|
||||
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) } # alias/token de pasarela (nunca PAN)
|
||||
- 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 (una intención de cobro por pedido)
|
||||
- createTable:
|
||||
tableName: payments
|
||||
columns:
|
||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
||||
- column: { name: order_id, type: BIGINT, constraints: { nullable: false } } # tu pedido interno
|
||||
- column: { name: user_id, type: BIGINT }
|
||||
- column: { name: payment_method_id, type: BIGINT }
|
||||
- column: { name: currency, type: CHAR(3), constraints: { nullable: false } }
|
||||
- column: { name: amount_total_cents, type: BIGINT, constraints: { nullable: false } }
|
||||
- column: { name: amount_captured_cents, type: BIGINT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
||||
- column: { name: amount_refunded_cents, type: BIGINT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
||||
- column: { name: status, type: ENUM('requires_payment_method','requires_action','authorized','captured','partially_refunded','refunded','canceled','failed'), defaultValue: requires_payment_method, constraints: { nullable: false } }
|
||||
- column: { name: capture_method, type: ENUM('automatic','manual'), defaultValue: automatic, constraints: { nullable: false } }
|
||||
- column: { name: gateway, type: VARCHAR(32), constraints: { nullable: false } } # 'redsys'
|
||||
- column: { name: gateway_payment_id, type: VARCHAR(128) } # id en pasarela
|
||||
- column: { name: gateway_order_id, type: VARCHAR(12) } # Ds_Order
|
||||
- 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:
|
||||
baseTableName: payments
|
||||
baseColumnNames: payment_method_id
|
||||
referencedTableName: payment_methods
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_payments_payment_methods
|
||||
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: { tableName: payments, indexName: idx_payments_status, columns: [ {name: status} ] }
|
||||
- addUniqueConstraint:
|
||||
tableName: payments
|
||||
columnNames: gateway, gateway_order_id
|
||||
constraintName: uq_payments_gateway_order
|
||||
|
||||
# 3) payment_transactions (libro mayor: AUTH/CAPTURE/REFUND/VOID)
|
||||
- createTable:
|
||||
tableName: payment_transactions
|
||||
columns:
|
||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
||||
- column: { name: payment_id, type: BIGINT, constraints: { nullable: false } }
|
||||
- column: { name: type, type: ENUM('AUTH','CAPTURE','REFUND','VOID'), constraints: { nullable: false } }
|
||||
- column: { name: status, type: ENUM('pending','succeeded','failed'), constraints: { nullable: false } }
|
||||
- column: { name: amount_cents, type: BIGINT, constraints: { nullable: false } }
|
||||
- column: { name: currency, type: CHAR(3), constraints: { nullable: false } }
|
||||
- column: { name: gateway_transaction_id, type: VARCHAR(128) }
|
||||
- column: { name: gateway_response_code, type: VARCHAR(64) }
|
||||
- column: { name: avs_result, type: VARCHAR(8) }
|
||||
- column: { name: cvv_result, type: VARCHAR(8) }
|
||||
- column: { name: three_ds_version, type: VARCHAR(16) }
|
||||
- column: { name: idempotency_key, type: VARCHAR(128) }
|
||||
- column: { name: request_payload, type: JSON }
|
||||
- column: { name: response_payload, type: JSON }
|
||||
- column: { name: processed_at, type: DATETIME }
|
||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: payment_transactions
|
||||
baseColumnNames: payment_id
|
||||
referencedTableName: payments
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_tx_payment
|
||||
onDelete: CASCADE
|
||||
- addUniqueConstraint:
|
||||
tableName: payment_transactions
|
||||
columnNames: gateway_transaction_id
|
||||
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)
|
||||
- createTable:
|
||||
tableName: refunds
|
||||
columns:
|
||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
||||
- column: { name: payment_id, type: BIGINT, constraints: { nullable: false } }
|
||||
- column: { name: transaction_id, type: BIGINT } # REFUND en payment_transactions
|
||||
- column: { name: amount_cents, type: BIGINT, constraints: { nullable: false } }
|
||||
- column: { name: reason, type: ENUM('customer_request','partial_return','pricing_adjustment','duplicate','fraud','other'), defaultValue: customer_request, constraints: { nullable: false } }
|
||||
- column: { name: status, type: ENUM('pending','succeeded','failed','canceled'), defaultValue: pending, constraints: { nullable: false } }
|
||||
- column: { name: requested_by_user_id, type: BIGINT }
|
||||
- column: { name: requested_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
||||
- column: { name: processed_at, type: DATETIME }
|
||||
- column: { name: gateway_refund_id, type: VARCHAR(128) }
|
||||
- column: { name: notes, type: VARCHAR(500) }
|
||||
- column: { name: metadata, type: JSON }
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: refunds
|
||||
baseColumnNames: payment_id
|
||||
referencedTableName: payments
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_ref_payment
|
||||
onDelete: CASCADE
|
||||
- addForeignKeyConstraint:
|
||||
baseTableName: refunds
|
||||
baseColumnNames: transaction_id
|
||||
referencedTableName: payment_transactions
|
||||
referencedColumnNames: id
|
||||
constraintName: fk_ref_tx
|
||||
onDelete: SET NULL
|
||||
- addUniqueConstraint:
|
||||
tableName: refunds
|
||||
columnNames: gateway_refund_id
|
||||
constraintName: uq_refund_gateway_id
|
||||
- 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)
|
||||
- createTable:
|
||||
tableName: webhook_events
|
||||
columns:
|
||||
- column: { name: id, type: BIGINT AUTO_INCREMENT, constraints: { primaryKey: true, nullable: false } }
|
||||
- column: { name: provider, type: VARCHAR(32), constraints: { nullable: false } } # 'redsys'
|
||||
- column: { name: event_type, type: VARCHAR(64), constraints: { nullable: false } }
|
||||
- column: { name: event_id, type: VARCHAR(128) }
|
||||
- column: { name: signature, type: VARCHAR(512) }
|
||||
- column: { name: payload, type: JSON, constraints: { nullable: false } }
|
||||
- column: { name: processed, type: TINYINT(1), defaultValueNumeric: 0, constraints: { nullable: false } }
|
||||
- column: { name: processed_at, type: DATETIME }
|
||||
- column: { name: attempts, type: INT, defaultValueNumeric: 0, constraints: { nullable: false } }
|
||||
- column: { name: last_error, type: VARCHAR(500) }
|
||||
- column: { name: created_at, type: DATETIME, defaultValueComputed: CURRENT_TIMESTAMP, constraints: { nullable: false } }
|
||||
- addUniqueConstraint:
|
||||
tableName: webhook_events
|
||||
columnNames: provider, event_id
|
||||
constraintName: uq_webhook_provider_event
|
||||
- createIndex: { tableName: webhook_events, indexName: idx_webhook_processed, columns: [ {name: processed} ] }
|
||||
|
||||
# 6) idempotency_keys (evitar doble REFUND o reprocesos)
|
||||
- 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: [ {name: resource_id} ] }
|
||||
@ -10,4 +10,6 @@ databaseChangeLog:
|
||||
- include:
|
||||
file: db/changelog/changesets/0005-add-carts-onlyoneshipment.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0006-add-cart-direcciones.yml
|
||||
file: db/changelog/changesets/0006-add-cart-direcciones.yml
|
||||
- include:
|
||||
file: db/changelog/changesets/0007-payments-core.yml
|
||||
@ -38,8 +38,13 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button id="btn-checkout" onclick="location.href='/checkout'" class="btn btn-secondary w-100 mt-2"
|
||||
th:text="#{checkout.make-payment}" disabled>Checkout</button>
|
||||
<form th:action="@{/pagos/redsys/crear}" method="post">
|
||||
<input type="hidden" name="order" value="123456789012" />
|
||||
<input type="hidden" name="amountCents" th:value="${summary.amountCents}" />
|
||||
<button id="btn-checkout" type="submit" class="btn btn-secondary w-100 mt-2"
|
||||
th:text="#{checkout.make-payment}" disabled>Checkout</button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
<!-- end table-responsive -->
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user