StripeService.java
package com.wavii.service;
import com.google.gson.Gson;
import com.stripe.Stripe;
import com.stripe.exception.SignatureVerificationException;
import com.stripe.exception.StripeException;
import com.stripe.model.Customer;
import com.stripe.model.EphemeralKey;
import com.stripe.model.Event;
import com.stripe.model.InvoicePayment;
import com.stripe.model.InvoicePaymentCollection;
import com.stripe.model.PaymentIntent;
import com.stripe.model.PaymentMethod;
import com.stripe.model.Refund;
import com.stripe.model.SetupIntent;
import com.stripe.model.Subscription;
import com.stripe.net.RequestOptions;
import com.stripe.net.Webhook;
import com.stripe.param.CustomerCreateParams;
import com.stripe.param.CustomerUpdateParams;
import com.stripe.param.EphemeralKeyCreateParams;
import com.stripe.param.InvoicePaymentListParams;
import com.stripe.param.PaymentIntentConfirmParams;
import com.stripe.param.PaymentIntentCreateParams;
import com.stripe.param.PaymentMethodAttachParams;
import com.stripe.param.RefundCreateParams;
import com.stripe.param.SetupIntentCreateParams;
import com.stripe.param.SubscriptionCreateParams;
import com.stripe.param.SubscriptionUpdateParams;
import com.stripe.param.common.EmptyParam;
import com.wavii.model.User;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HashMap;
import java.util.Map;
/**
* Servicio para la integración con Stripe.
* Gestiona pagos, suscripciones, webhooks y la configuración del Payment Sheet.
*
* @author eduglezexp
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class StripeService {
@Value("${stripe.secret-key:}")
private String secretKey;
@Value("${stripe.price-plus:}")
private String pricePlus;
@Value("${stripe.price-scholar:}")
private String priceScholar;
@Value("${stripe.webhook-secret:}")
private String webhookSecret;
@Value("${stripe.coupon-scholar-promo:}")
private String couponScholarPromo;
/**
* Inicializa la configuración de Stripe con la clave secreta.
*/
@PostConstruct
public void init() {
if (secretKey != null && !secretKey.isBlank()) {
Stripe.apiKey = secretKey;
log.info("Stripe configurado correctamente");
} else {
log.warn("[DEV] Stripe no configurado - pagos en modo mock");
}
}
/**
* Comprueba si Stripe está configurado correctamente en el entorno.
*
* @return true si hay una clave secreta configurada.
*/
public boolean isConfigured() {
return secretKey != null && !secretKey.isBlank();
}
/**
* Obtiene el ID de cliente de Stripe para un usuario o crea uno nuevo si no existe.
*
* @param user Usuario del sistema.
* @return ID del cliente en Stripe.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public String createOrGetCustomer(User user) throws StripeException {
if (user.getStripeCustomerId() != null && !user.getStripeCustomerId().isBlank()) {
return user.getStripeCustomerId();
}
Customer customer = Customer.create(
CustomerCreateParams.builder()
.setEmail(user.getEmail())
.setName(user.getName())
.build());
return customer.getId();
}
/**
* Crea una suscripción recurrente en Stripe.
*
* @param customerId ID del cliente en Stripe.
* @param plan Nombre del plan (plus o scholar).
* @param paymentMethodId ID del método de pago de Stripe.
* @param trialUsed Indica si el usuario ya ha consumido el periodo de prueba.
* @return Mapa con los detalles de la suscripción creada.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> createSubscription(String customerId, String plan,
String paymentMethodId, boolean trialUsed)
throws StripeException {
PaymentMethod pm = PaymentMethod.retrieve(paymentMethodId);
pm.attach(PaymentMethodAttachParams.builder().setCustomer(customerId).build());
Customer customer = Customer.retrieve(customerId);
customer.update(
CustomerUpdateParams.builder()
.setInvoiceSettings(
CustomerUpdateParams.InvoiceSettings.builder()
.setDefaultPaymentMethod(paymentMethodId)
.build())
.build());
String priceId = "plus".equals(plan) ? pricePlus : priceScholar;
SubscriptionCreateParams.Builder builder = SubscriptionCreateParams.builder()
.setCustomer(customerId)
.addItem(SubscriptionCreateParams.Item.builder().setPrice(priceId).build())
.setDefaultPaymentMethod(paymentMethodId)
.setPaymentBehavior(SubscriptionCreateParams.PaymentBehavior.DEFAULT_INCOMPLETE)
.addExpand("latest_invoice");
if ("plus".equals(plan) && !trialUsed) {
builder.setTrialPeriodDays(14L);
}
Subscription subscription = Subscription.create(builder.build());
boolean needsImmediateCharge = !"plus".equals(plan) || trialUsed;
if (needsImmediateCharge) {
String invoiceId = subscription.getLatestInvoice();
if (invoiceId != null) {
InvoicePaymentCollection invoicePayments = InvoicePayment.list(
InvoicePaymentListParams.builder()
.setInvoice(invoiceId)
.setLimit(1L)
.build());
if (invoicePayments.getData() != null && !invoicePayments.getData().isEmpty()) {
String piId = invoicePayments.getData().get(0).getPayment().getPaymentIntent();
if (piId != null) {
PaymentIntent pi = PaymentIntent.retrieve(piId);
if (!"succeeded".equals(pi.getStatus())) {
pi.confirm(PaymentIntentConfirmParams.builder()
.setPaymentMethod(paymentMethodId)
.build());
subscription = Subscription.retrieve(subscription.getId());
}
}
}
}
}
Map<String, Object> result = new HashMap<>();
result.put("subscriptionId", subscription.getId());
result.put("status", subscription.getStatus());
result.put("cancelAtPeriodEnd", subscription.getCancelAtPeriodEnd());
Long currentPeriodEnd = getCurrentPeriodEnd(subscription);
if (currentPeriodEnd != null) {
result.put("currentPeriodEnd",
LocalDateTime.ofInstant(Instant.ofEpochSecond(currentPeriodEnd), ZoneOffset.UTC).toString());
}
return result;
}
/** Sobrecarga de createSubscription sin indicar trialUsed (por defecto false). */
public Map<String, Object> createSubscription(String customerId, String plan, String paymentMethodId)
throws StripeException {
return createSubscription(customerId, plan, paymentMethodId, false);
}
/**
* Programa una suscripción para cancelarse al final del periodo de facturación actual.
*
* @param subscriptionId ID de la suscripción en Stripe.
* @return Detalles de la suscripción actualizada.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> cancelAtPeriodEnd(String subscriptionId) throws StripeException {
Subscription sub = Subscription.retrieve(subscriptionId);
Subscription updated = sub.update(
SubscriptionUpdateParams.builder()
.setCancelAtPeriodEnd(true)
.build());
Map<String, Object> result = new HashMap<>();
result.put("subscriptionId", updated.getId());
result.put("status", updated.getStatus());
result.put("cancelAtPeriodEnd", updated.getCancelAtPeriodEnd());
Long currentPeriodEnd = getCurrentPeriodEnd(updated);
if (currentPeriodEnd != null) {
result.put("currentPeriodEnd",
LocalDateTime.ofInstant(Instant.ofEpochSecond(currentPeriodEnd), ZoneOffset.UTC).toString());
}
return result;
}
/**
* Reactiva una suscripción que estaba programada para cancelarse.
*
* @param subscriptionId ID de la suscripción en Stripe.
* @return Detalles de la suscripción actualizada.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> reactivateSubscription(String subscriptionId) throws StripeException {
Subscription sub = Subscription.retrieve(subscriptionId);
Subscription updated = sub.update(
SubscriptionUpdateParams.builder()
.setCancelAtPeriodEnd(false)
.build());
Map<String, Object> result = new HashMap<>();
result.put("subscriptionId", updated.getId());
result.put("status", updated.getStatus());
result.put("cancelAtPeriodEnd", false);
Long currentPeriodEnd = getCurrentPeriodEnd(updated);
if (currentPeriodEnd != null) {
result.put("currentPeriodEnd",
LocalDateTime.ofInstant(Instant.ofEpochSecond(currentPeriodEnd), ZoneOffset.UTC).toString());
}
return result;
}
/**
* Cambia el plan de una suscripción activa.
*
* @param subscriptionId ID de la suscripción en Stripe.
* @param newPlan Nombre del nuevo plan.
* @param applyScholarPromo Indica si se aplica cupón de descuento promocional.
* @return Detalles de la suscripción actualizada.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> changeSubscription(String subscriptionId, String newPlan, boolean applyScholarPromo)
throws StripeException {
Subscription sub = Subscription.retrieve(subscriptionId);
String currentItemId = sub.getItems().getData().get(0).getId();
String newPriceId = "plus".equals(newPlan) ? pricePlus : priceScholar;
SubscriptionUpdateParams.Builder builder = SubscriptionUpdateParams.builder()
.addItem(SubscriptionUpdateParams.Item.builder()
.setId(currentItemId)
.setPrice(newPriceId)
.build())
.setProrationBehavior(SubscriptionUpdateParams.ProrationBehavior.NONE)
.setCancelAtPeriodEnd(false);
if (sub.getTrialEnd() != null && sub.getTrialEnd() > System.currentTimeMillis() / 1000) {
builder.setTrialEnd(SubscriptionUpdateParams.TrialEnd.NOW);
}
if ("scholar".equals(newPlan) && applyScholarPromo
&& couponScholarPromo != null && !couponScholarPromo.isBlank()) {
builder.addDiscount(
SubscriptionUpdateParams.Discount.builder()
.setCoupon(couponScholarPromo)
.build());
} else if ("plus".equals(newPlan)) {
builder.setDiscounts(EmptyParam.EMPTY);
}
Subscription updated = sub.update(builder.build());
Map<String, Object> result = new HashMap<>();
result.put("subscriptionId", updated.getId());
result.put("status", updated.getStatus());
result.put("cancelAtPeriodEnd", updated.getCancelAtPeriodEnd());
Long currentPeriodEnd = getCurrentPeriodEnd(updated);
if (currentPeriodEnd != null) {
result.put("currentPeriodEnd",
LocalDateTime.ofInstant(Instant.ofEpochSecond(currentPeriodEnd), ZoneOffset.UTC).toString());
}
return result;
}
/**
* Crea una clave efímera para el SDK de Stripe en el móvil.
*
* @param customerId ID del cliente.
* @param stripeVersion Versión de la API de Stripe requerida por el SDK.
* @return Secreto de la clave efímera.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> createImmediatePlanChangeCharge(
String customerId,
String subscriptionId,
long amountCents,
String description
) throws StripeException {
Subscription sub = Subscription.retrieve(subscriptionId);
String paymentMethodId = sub.getDefaultPaymentMethod();
if (paymentMethodId == null || paymentMethodId.isBlank()) {
Customer customer = Customer.retrieve(customerId);
if (customer.getInvoiceSettings() != null) {
paymentMethodId = customer.getInvoiceSettings().getDefaultPaymentMethod();
}
}
if (paymentMethodId == null || paymentMethodId.isBlank()) {
throw new IllegalStateException("No se encontro un metodo de pago por defecto para cobrar el cambio de plan");
}
PaymentIntent intent = PaymentIntent.create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("eur")
.setCustomer(customerId)
.setPaymentMethod(paymentMethodId)
.setConfirm(true)
.setOffSession(true)
.setDescription(description)
.putMetadata("kind", "subscription_plan_change")
.putMetadata("subscriptionId", subscriptionId)
.putMetadata("targetPlan", "scholar")
.build());
Map<String, Object> result = new HashMap<>();
result.put("paymentIntentId", intent.getId());
result.put("status", intent.getStatus());
result.put("amount", amountCents / 100.0);
return result;
}
public String createEphemeralKey(String customerId, String stripeVersion) throws StripeException {
EphemeralKey key = EphemeralKey.create(
EphemeralKeyCreateParams.builder()
.setCustomer(customerId)
.setStripeVersion(stripeVersion)
.build()
.toMap(),
(RequestOptions) null);
return key.getSecret();
}
/**
* Crea un SetupIntent para recoger un método de pago sin cobrar inmediatamente.
*
* @param customerId ID del cliente.
* @param plan Nombre del plan asociado.
* @return Mapa con el ID y secreto del SetupIntent.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> createSetupIntent(String customerId, String plan) throws StripeException {
SetupIntent si = SetupIntent.create(
SetupIntentCreateParams.builder()
.setCustomer(customerId)
.addPaymentMethodType("card")
.putMetadata("plan", plan)
.build());
Map<String, Object> result = new HashMap<>();
result.put("setupIntentId", si.getId());
result.put("clientSecret", si.getClientSecret());
return result;
}
/**
* Crea un PaymentIntent para el pago de una hora de clase.
*
* @param customerId ID del cliente.
* @param teacherId ID del profesor.
* @param teacherName Nombre del profesor.
* @param studentName Nombre del alumno.
* @param instrument Instrumento de la clase.
* @param modality Modalidad (presencial/online).
* @param city Ciudad.
* @param amountCents Importe en céntimos de euro.
* @return Mapa con el ID y secreto del PaymentIntent.
* @throws StripeException Si hay un error con la API de Stripe.
*/
public Map<String, Object> createClassPaymentIntent(String customerId, String teacherId, String teacherName,
String studentName, String instrument, String modality,
String city, long amountCents) throws StripeException {
PaymentIntent intent = PaymentIntent.create(
PaymentIntentCreateParams.builder()
.setAmount(amountCents)
.setCurrency("eur")
.setCustomer(customerId)
.setAutomaticPaymentMethods(
PaymentIntentCreateParams.AutomaticPaymentMethods.builder()
.setEnabled(true)
.build())
.putMetadata("teacherId", teacherId)
.putMetadata("teacherName", teacherName)
.putMetadata("studentName", studentName)
.putMetadata("instrument", instrument != null ? instrument : "")
.putMetadata("modality", modality != null ? modality : "")
.putMetadata("city", city != null ? city : "")
.putMetadata("kind", "class_payment")
.build());
Map<String, Object> result = new HashMap<>();
result.put("paymentIntentId", intent.getId());
result.put("clientSecret", intent.getClientSecret());
return result;
}
/** Obtiene los detalles de un PaymentIntent a partir de su ID. */
public PaymentIntent retrievePaymentIntent(String paymentIntentId) throws StripeException {
return PaymentIntent.retrieve(paymentIntentId);
}
/** Realiza el reembolso completo de un PaymentIntent. */
public Refund refundPaymentIntent(String paymentIntentId) throws StripeException {
return Refund.create(RefundCreateParams.builder()
.setPaymentIntent(paymentIntentId)
.build());
}
/** Crea una suscripción utilizando un SetupIntent previo. */
public Map<String, Object> createSubscriptionFromSetupIntent(
String customerId, String setupIntentId, String plan, boolean trialUsed) throws StripeException {
SetupIntent si = SetupIntent.retrieve(setupIntentId);
String paymentMethodId = si.getPaymentMethod();
return createSubscription(customerId, plan, paymentMethodId, trialUsed);
}
/** Sobrecarga de createSubscriptionFromSetupIntent sin indicar trialUsed. */
public Map<String, Object> createSubscriptionFromSetupIntent(
String customerId, String setupIntentId, String plan) throws StripeException {
return createSubscriptionFromSetupIntent(customerId, setupIntentId, plan, false);
}
/**
* Valida la firma de un evento de webhook de Stripe y lo convierte en objeto Event.
*
* @param payload Cuerpo JSON del webhook.
* @param sigHeader Cabecera de firma de Stripe.
* @return El objeto Event validado.
* @throws SignatureVerificationException Si la firma no es válida.
*/
public Event constructWebhookEvent(String payload, String sigHeader) throws SignatureVerificationException {
if (webhookSecret != null && !webhookSecret.isBlank()) {
return Webhook.constructEvent(payload, sigHeader, webhookSecret);
}
log.warn("STRIPE_WEBHOOK_SECRET no configurado - saltando verificacion de firma (modo dev)");
return new Gson().fromJson(payload, Event.class);
}
public String getPricePlus() {
return pricePlus;
}
public String getPriceScholar() {
return priceScholar;
}
private Long getCurrentPeriodEnd(Subscription subscription) {
if (subscription.getItems() == null || subscription.getItems().getData() == null
|| subscription.getItems().getData().isEmpty()) {
return null;
}
return subscription.getItems().getData().get(0).getCurrentPeriodEnd();
}
}