diff --git a/src/main/java/com/imprimelibros/erp/payments/PaymentService.java b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java new file mode 100644 index 0000000..15254fe --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/PaymentService.java @@ -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); + } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java new file mode 100644 index 0000000..3239394 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/CaptureMethod.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +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 new file mode 100644 index 0000000..bcf200e --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyKey.java @@ -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; } +} + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java new file mode 100644 index 0000000..e7088d4 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/IdempotencyScope.java @@ -0,0 +1,3 @@ +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 new file mode 100644 index 0000000..501941a --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/Payment.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java new file mode 100644 index 0000000..ab5833f --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethod.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java new file mode 100644 index 0000000..e0ec386 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentMethodType.java @@ -0,0 +1,4 @@ +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 new file mode 100644 index 0000000..18604be --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentStatus.java @@ -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 +} + + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java new file mode 100644 index 0000000..a7fd404 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransaction.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java new file mode 100644 index 0000000..f495274 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentTransactionStatus { PENDING, SUCCEEDED, FAILED } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java new file mode 100644 index 0000000..880654a --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/PaymentTransactionType.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +public enum PaymentTransactionType { AUTH, CAPTURE, REFUND, VOID } + diff --git a/src/main/java/com/imprimelibros/erp/payments/model/Refund.java b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java new file mode 100644 index 0000000..576e752 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/Refund.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java new file mode 100644 index 0000000..95235a8 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundReason.java @@ -0,0 +1,6 @@ +package com.imprimelibros.erp.payments.model; + +public enum RefundReason { + 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 new file mode 100644 index 0000000..e15fd1d --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/RefundStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +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 new file mode 100644 index 0000000..8982ae1 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/ThreeDSStatus.java @@ -0,0 +1,4 @@ +package com.imprimelibros.erp.payments.model; + +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 new file mode 100644 index 0000000..201dc81 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/model/WebhookEvent.java @@ -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; } +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java new file mode 100644 index 0000000..a04ea60 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/IdempotencyKeyRepository.java @@ -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 { + 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 new file mode 100644 index 0000000..397d1ef --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentMethodRepository.java @@ -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 {} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java new file mode 100644 index 0000000..6af17f7 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentRepository.java @@ -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 { + Optional findByGatewayAndGatewayOrderId(String gateway, String gatewayOrderId); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java new file mode 100644 index 0000000..2965178 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/PaymentTransactionRepository.java @@ -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 { + Optional findByGatewayTransactionId(String gatewayTransactionId); + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java new file mode 100644 index 0000000..6e7228d --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/RefundRepository.java @@ -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 { + @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 new file mode 100644 index 0000000..9ba9488 --- /dev/null +++ b/src/main/java/com/imprimelibros/erp/payments/repo/WebhookEventRepository.java @@ -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 {} diff --git a/src/main/resources/db/changelog/changesets/0007-payments-core.yml b/src/main/resources/db/changelog/changesets/0007-payments-core.yml new file mode 100644 index 0000000..b94a27e --- /dev/null +++ b/src/main/resources/db/changelog/changesets/0007-payments-core.yml @@ -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} ] } diff --git a/src/main/resources/db/changelog/master.yml b/src/main/resources/db/changelog/master.yml index 95eb18c..fcdec19 100644 --- a/src/main/resources/db/changelog/master.yml +++ b/src/main/resources/db/changelog/master.yml @@ -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 \ No newline at end of file + file: db/changelog/changesets/0006-add-cart-direcciones.yml + - include: + file: db/changelog/changesets/0007-payments-core.yml \ No newline at end of file diff --git a/src/main/resources/templates/imprimelibros/checkout/_summary.html b/src/main/resources/templates/imprimelibros/checkout/_summary.html index 47eb645..5a16da1 100644 --- a/src/main/resources/templates/imprimelibros/checkout/_summary.html +++ b/src/main/resources/templates/imprimelibros/checkout/_summary.html @@ -38,8 +38,13 @@ - +
+ + + +
+