diff --git a/pom.xml b/pom.xml index 7ad6bf8..bc1ebc8 100644 --- a/pom.xml +++ b/pom.xml @@ -57,6 +57,11 @@ mysql-connector-j runtime + + com.stripe + stripe-java + 26.3.0 + org.springframework.boot spring-boot-starter-data-jpa-test diff --git a/src/main/java/es/tatvil/taiageweb/config/SecurityConfig.java b/src/main/java/es/tatvil/taiageweb/config/SecurityConfig.java index e88a9ab..44c462b 100644 --- a/src/main/java/es/tatvil/taiageweb/config/SecurityConfig.java +++ b/src/main/java/es/tatvil/taiageweb/config/SecurityConfig.java @@ -29,11 +29,13 @@ public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http + .csrf(csrf -> csrf.ignoringRequestMatchers("/webhook/stripe")) .authorizeHttpRequests(auth -> auth // Recursos públicos .requestMatchers( "/", "/inicio", "/login", "/registro", "/leyes", "/noticias", "/acceso-denegado", "/error", + "/webhook/stripe", "/css/**", "/js/**", "/images/**", "/favicon.ico" ).permitAll() // Panel de administración diff --git a/src/main/java/es/tatvil/taiageweb/config/StripeConfig.java b/src/main/java/es/tatvil/taiageweb/config/StripeConfig.java new file mode 100644 index 0000000..76bfcdf --- /dev/null +++ b/src/main/java/es/tatvil/taiageweb/config/StripeConfig.java @@ -0,0 +1,18 @@ +package es.tatvil.taiageweb.config; + +import com.stripe.Stripe; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class StripeConfig { + + @Value("${stripe.secret-key}") + private String secretKey; + + @PostConstruct + public void init() { + Stripe.apiKey = secretKey; + } +} diff --git a/src/main/java/es/tatvil/taiageweb/controlador/PagoController.java b/src/main/java/es/tatvil/taiageweb/controlador/PagoController.java new file mode 100644 index 0000000..42fa961 --- /dev/null +++ b/src/main/java/es/tatvil/taiageweb/controlador/PagoController.java @@ -0,0 +1,83 @@ +package es.tatvil.taiageweb.controlador; + +import com.stripe.exception.StripeException; +import com.stripe.model.checkout.Session; +import com.stripe.param.checkout.SessionCreateParams; +import es.tatvil.taiageweb.repositorio.UsuarioRepository; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; + +@Controller +public class PagoController { + + @Value("${stripe.price-amount}") + private long priceAmount; + + @Value("${stripe.currency}") + private String currency; + + private final UsuarioRepository usuarioRepo; + + public PagoController(UsuarioRepository usuarioRepo) { + this.usuarioRepo = usuarioRepo; + } + + /** Página informativa con precio y botón "Pagar ahora" */ + @GetMapping("/pagar") + public String mostrarPagina(Authentication auth, Model model) { + // Si ya tiene acceso, redirigir directo al curso + if (auth != null && auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_PAGADO"))) { + return "redirect:/curso"; + } + model.addAttribute("precio", String.format("%.2f", priceAmount / 100.0)); + return "pagar"; + } + + /** Crea la sesión de pago en Stripe y redirige a su pasarela */ + @PostMapping("/pagar/iniciar") + public String iniciarPago(Authentication auth, HttpServletRequest request) throws StripeException { + String baseUrl = request.getScheme() + "://" + request.getServerName() + ":" + request.getServerPort(); + + UserDetails userDetails = (UserDetails) auth.getPrincipal(); + var usuario = usuarioRepo.findByEmail(userDetails.getUsername()).orElseThrow(); + + SessionCreateParams params = SessionCreateParams.builder() + .setMode(SessionCreateParams.Mode.PAYMENT) + .setSuccessUrl(baseUrl + "/pagar/exito?session_id={CHECKOUT_SESSION_ID}") + .setCancelUrl(baseUrl + "/pagar/cancelado") + .setClientReferenceId(String.valueOf(usuario.getId())) + .setCustomerEmail(usuario.getEmail()) + .addLineItem(SessionCreateParams.LineItem.builder() + .setQuantity(1L) + .setPriceData(SessionCreateParams.LineItem.PriceData.builder() + .setCurrency(currency) + .setUnitAmount(priceAmount) + .setProductData(SessionCreateParams.LineItem.PriceData.ProductData.builder() + .setName("Acceso completo al curso TAI – AGE") + .setDescription("Acceso permanente a todos los temas, apuntes y actualizaciones") + .build()) + .build()) + .build()) + .build(); + + Session session = Session.create(params); + return "redirect:" + session.getUrl(); + } + + @GetMapping("/pagar/exito") + public String exitoPago() { + return "pago-exito"; + } + + @GetMapping("/pagar/cancelado") + public String canceladoPago() { + return "pago-cancelado"; + } +} diff --git a/src/main/java/es/tatvil/taiageweb/controlador/WebhookController.java b/src/main/java/es/tatvil/taiageweb/controlador/WebhookController.java new file mode 100644 index 0000000..67f1d81 --- /dev/null +++ b/src/main/java/es/tatvil/taiageweb/controlador/WebhookController.java @@ -0,0 +1,52 @@ +package es.tatvil.taiageweb.controlador; + +import com.stripe.exception.SignatureVerificationException; +import com.stripe.model.Event; +import com.stripe.model.checkout.Session; +import com.stripe.net.Webhook; +import es.tatvil.taiageweb.servicio.UsuarioService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +public class WebhookController { + + @Value("${stripe.webhook-secret}") + private String webhookSecret; + + private final UsuarioService usuarioService; + + public WebhookController(UsuarioService usuarioService) { + this.usuarioService = usuarioService; + } + + @PostMapping("/webhook/stripe") + public ResponseEntity stripeWebhook( + @RequestBody String payload, + @RequestHeader("Stripe-Signature") String sigHeader) { + + Event event; + try { + event = Webhook.constructEvent(payload, sigHeader, webhookSecret); + } catch (SignatureVerificationException e) { + return ResponseEntity.badRequest().body("Firma inválida"); + } + + if ("checkout.session.completed".equals(event.getType())) { + event.getDataObjectDeserializer().getObject().ifPresent(obj -> { + Session session = (Session) obj; + String refId = session.getClientReferenceId(); + if (refId != null) { + try { + Long userId = Long.parseLong(refId); + usuarioService.darAccesoPagado(userId); + } catch (NumberFormatException ignored) { + } + } + }); + } + + return ResponseEntity.ok("ok"); + } +} diff --git a/src/main/java/es/tatvil/taiageweb/servicio/UsuarioService.java b/src/main/java/es/tatvil/taiageweb/servicio/UsuarioService.java index 7d3bdbd..354df69 100644 --- a/src/main/java/es/tatvil/taiageweb/servicio/UsuarioService.java +++ b/src/main/java/es/tatvil/taiageweb/servicio/UsuarioService.java @@ -89,6 +89,14 @@ public class UsuarioService implements UserDetailsService { } } + /** Concede acceso al curso tras un pago confirmado por Stripe (nunca quita el rol) */ + @Transactional + public void darAccesoPagado(Long id) { + Usuario u = repo.findById(id).orElseThrow(); + u.getRoles().add("ROLE_PAGADO"); + u.setHabilitado(true); + } + /** El admin crea directamente un usuario ya activo */ @Transactional public void crearPorAdmin(String email, String passwordPlana, boolean pagado) { diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ccea6e4..d856787 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -14,3 +14,10 @@ spring.jpa.open-in-view=false # ── Thymeleaf ─────────────────────────────────────────────────── spring.thymeleaf.cache=false + +# ── Stripe ────────────────────────────────────────────────────── +# Sustituye estos valores por los de tu cuenta en dashboard.stripe.com +stripe.secret-key=pk_live_51PDO7CH8SX3oYZHa9viPsZl5qxTxGQkyFw1uoiOqJCiPemxAWPbzTt0Rd3FFBx4vSJlkK7vNP7GV5XKxooMI6Bkc00cVk3lUOL +stripe.webhook-secret=whsec_MkJcSnVYRXrTXzFR45hwuBaGTs59A3iv +stripe.price-amount=2900 +stripe.currency=eur diff --git a/src/main/resources/static/css/style.css b/src/main/resources/static/css/style.css index 669ec0a..80f2351 100644 --- a/src/main/resources/static/css/style.css +++ b/src/main/resources/static/css/style.css @@ -1217,3 +1217,34 @@ a:hover { text-decoration: underline; } flex-shrink: 0; } .inap-banner-close:hover { color: var(--fg); background: color-mix(in srgb, var(--fg) 10%, transparent); } + +/** LOGIN **/ +.auth-wrap { + display: flex; align-items: center; justify-content: center; + min-height: 100vh; padding: 2rem; +} +.auth-card { + background: var(--bg-alt); border: 1px solid var(--border); + border-radius: 12px; padding: 2.5rem 2rem; width: 100%; max-width: 400px; +} +.auth-card h2 { color: var(--accent-2); margin-bottom: 1.5rem; font-size: 1.4rem; } +.auth-field { display: flex; flex-direction: column; gap: .4rem; margin-bottom: 1rem; } +.auth-field label { font-size: .85rem; color: var(--text-muted); } +.auth-field input { + background: var(--bg); border: 1px solid var(--border); border-radius: 6px; + color: var(--text); padding: .55rem .8rem; font-size: .95rem; outline: none; + transition: border-color .15s; +} +.auth-field input:focus { border-color: var(--accent); } +.auth-btn { + width: 100%; padding: .65rem; border-radius: 8px; border: none; + background: var(--accent); color: #fff; font-size: 1rem; + font-weight: 600; cursor: pointer; margin-top: .5rem; + transition: opacity .15s; +} +.auth-btn:hover { opacity: .88; } +.auth-msg { font-size: .85rem; padding: .6rem .9rem; border-radius: 6px; margin-bottom: 1rem; } +.auth-msg.error { background: rgba(244,71,71,.12); color: var(--error); border: 1px solid var(--error); } +.auth-msg.ok { background: rgba(106,153,85,.12); color: var(--success); border: 1px solid var(--success); } +.auth-footer { margin-top: 1.2rem; text-align: center; font-size: .85rem; color: var(--text-muted); } +.auth-footer a { color: var(--accent); } \ No newline at end of file diff --git a/src/main/resources/templates/acceso-denegado.html b/src/main/resources/templates/acceso-denegado.html index be28cff..61ebdcd 100644 --- a/src/main/resources/templates/acceso-denegado.html +++ b/src/main/resources/templates/acceso-denegado.html @@ -1,5 +1,5 @@ - + @@ -29,10 +29,22 @@

Acceso denegado

- No tienes permiso para acceder a este contenido.
- Si ya estás registrado, espera a que el administrador active tu cuenta y conceda acceso al curso. + Este contenido es exclusivo para alumnos con acceso al curso.
+ Si ya estás registrado, espera a que el administrador active tu cuenta.

- Volver al inicio +
+ + Comprar acceso + + + Iniciar sesión + + + Volver al inicio + +
diff --git a/src/main/resources/templates/login.html b/src/main/resources/templates/login.html index f5f010c..86545f8 100644 --- a/src/main/resources/templates/login.html +++ b/src/main/resources/templates/login.html @@ -5,37 +5,6 @@ TAI – AGE | Iniciar sesión -
diff --git a/src/main/resources/templates/pagar.html b/src/main/resources/templates/pagar.html new file mode 100644 index 0000000..bf35785 --- /dev/null +++ b/src/main/resources/templates/pagar.html @@ -0,0 +1,72 @@ + + + + + + TAI – AGE | Comprar acceso + + + + + +
+
+ Pago único · Sin suscripción +

Acceso completo al curso TAI – AGE

+
+ 29.00 / acceso permanente +
+
    +
  • Todos los bloques y temas del temario oficial
  • +
  • Apuntes en Markdown con formato enriquecido
  • +
  • Actualizaciones incluidas
  • +
  • Acceso inmediato tras el pago
  • +
  • Compatible con móvil y escritorio
  • +
+
+ +
+ +
+
+ + diff --git a/src/main/resources/templates/pago-cancelado.html b/src/main/resources/templates/pago-cancelado.html new file mode 100644 index 0000000..00a892a --- /dev/null +++ b/src/main/resources/templates/pago-cancelado.html @@ -0,0 +1,36 @@ + + + + + + TAI – AGE | Pago cancelado + + + + + +
+
+
+

Pago cancelado

+

No se ha realizado ningún cargo.
+ Puedes intentarlo de nuevo cuando quieras.

+ +
+
+ + diff --git a/src/main/resources/templates/pago-exito.html b/src/main/resources/templates/pago-exito.html new file mode 100644 index 0000000..8d47d22 --- /dev/null +++ b/src/main/resources/templates/pago-exito.html @@ -0,0 +1,32 @@ + + + + + + TAI – AGE | Pago completado + + + + + +
+
+
+

¡Pago completado!

+

Tu acceso al curso TAI – AGE está activo.
+ Ya puedes acceder a todos los temas y apuntes.

+ Ir al curso → +

Si el acceso no se activa en unos segundos, recarga la página o contacta con el administrador.

+
+
+ +