Gestion de pagos

This commit is contained in:
Tatiana Villa Ema 2026-05-05 22:29:07 +02:00
parent a272abe183
commit aea3936649
13 changed files with 362 additions and 35 deletions

View File

@ -57,6 +57,11 @@
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.stripe</groupId>
<artifactId>stripe-java</artifactId>
<version>26.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa-test</artifactId>

View File

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

View File

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

View File

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

View File

@ -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<String> 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");
}
}

View File

@ -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) {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
@ -29,10 +29,22 @@
<div class="denied-card">
<h2>Acceso denegado</h2>
<p>
No tienes permiso para acceder a este contenido.<br/>
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.<br/>
Si ya estás registrado, espera a que el administrador active tu cuenta.
</p>
<a th:href="@{/}">Volver al inicio</a>
<div style="display:flex;gap:1rem;justify-content:center;flex-wrap:wrap">
<a sec:authorize="isAuthenticated()" th:href="@{/pagar}"
style="background:var(--accent);color:#fff;padding:.6rem 1.4rem;border-radius:8px;text-decoration:none;font-weight:700">
Comprar acceso
</a>
<a sec:authorize="!isAuthenticated()" th:href="@{/login}"
style="background:var(--accent);color:#fff;padding:.6rem 1.4rem;border-radius:8px;text-decoration:none;font-weight:700">
Iniciar sesión
</a>
<a th:href="@{/}" style="background:var(--bg-hover);color:var(--text);padding:.6rem 1.4rem;border-radius:8px;text-decoration:none;font-weight:700">
Volver al inicio
</a>
</div>
</div>
</div>
</body>

View File

@ -5,37 +5,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TAI AGE | Iniciar sesión</title>
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<style>
.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); }
</style>
</head>
<body>
<div class="auth-wrap">

View File

@ -0,0 +1,72 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" xmlns:sec="http://www.thymeleaf.org/extras/spring-security" lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TAI AGE | Comprar acceso</title>
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<style>
.pagar-wrap {
display: flex; align-items: center; justify-content: center;
min-height: 100vh; padding: 5rem 1.5rem 2rem;
}
.pagar-card {
background: var(--bg-alt); border: 1px solid var(--border);
border-radius: 14px; padding: 2.5rem 2rem; width: 100%; max-width: 500px;
}
.pagar-card h2 { color: var(--accent-2); font-size: 1.4rem; margin-bottom: .4rem; }
.pagar-precio {
font-size: 2.8rem; font-weight: 700; color: var(--accent);
margin: 1.2rem 0 .3rem;
}
.pagar-precio small { font-size: 1rem; color: var(--text-muted); font-weight: 400; }
.pagar-features { list-style: none; padding: 0; margin: 1.2rem 0 1.8rem; }
.pagar-features li { padding: .4rem 0; color: var(--text-muted); font-size: .9rem; }
.pagar-features li i { color: var(--accent); margin-right: .5rem; width: 1rem; }
.pagar-badge {
display: inline-block; font-size: .75rem; padding: .25rem .7rem;
border-radius: 20px; background: rgba(106,153,85,.15);
color: var(--success, #6a9955); border: 1px solid var(--success, #6a9955);
margin-bottom: 1rem;
}
.btn-stripe {
display: flex; align-items: center; justify-content: center; gap: .6rem;
width: 100%; padding: .8rem; border-radius: 10px; border: none;
background: #635BFF; color: #fff; font-size: 1rem;
font-weight: 700; cursor: pointer; font-family: inherit;
transition: opacity .15s;
}
.btn-stripe:hover { opacity: .88; }
.pagar-footer { margin-top: 1rem; font-size: .78rem; color: var(--text-muted); text-align: center; }
.pagar-footer a { color: var(--accent); }
</style>
</head>
<body>
<div class="pagar-wrap">
<div class="pagar-card">
<span class="pagar-badge"><i class="fas fa-lock"></i> Pago único · Sin suscripción</span>
<h2>Acceso completo al curso TAI AGE</h2>
<div class="pagar-precio">
<span th:text="${precio}">29.00</span><small> / acceso permanente</small>
</div>
<ul class="pagar-features">
<li><i class="fas fa-check"></i> Todos los bloques y temas del temario oficial</li>
<li><i class="fas fa-check"></i> Apuntes en Markdown con formato enriquecido</li>
<li><i class="fas fa-check"></i> Actualizaciones incluidas</li>
<li><i class="fas fa-check"></i> Acceso inmediato tras el pago</li>
<li><i class="fas fa-check"></i> Compatible con móvil y escritorio</li>
</ul>
<form th:action="@{/pagar/iniciar}" method="post">
<button type="submit" class="btn-stripe">
<i class="fab fa-stripe-s"></i> Pagar ahora con Stripe
</button>
</form>
<p class="pagar-footer">
Pago seguro gestionado por Stripe.<br/>
<a th:href="@{/}">← Volver al inicio</a>
</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TAI AGE | Pago cancelado</title>
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<style>
.result-wrap { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:2rem; text-align:center; }
.result-card { background:var(--bg-alt); border:1px solid var(--border); border-radius:14px; padding:3rem 2rem; max-width:460px; width:100%; }
.result-icon { font-size:3rem; color:var(--text-muted); margin-bottom:1rem; }
.result-card h2 { color:var(--accent-2); font-size:1.4rem; margin-bottom:.8rem; }
.result-card p { color:var(--text-muted); line-height:1.6; margin-bottom:1.5rem; }
.btn-row { display:flex; gap:1rem; justify-content:center; flex-wrap:wrap; }
.btn-row a { display:inline-block; padding:.6rem 1.4rem; border-radius:8px; text-decoration:none; font-weight:700; transition:opacity .15s; }
.btn-primary { background:var(--accent); color:#fff; }
.btn-secondary { background:var(--bg-hover); color:var(--text); }
.btn-row a:hover { opacity:.85; }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<div class="result-icon"><i class="fas fa-circle-xmark"></i></div>
<h2>Pago cancelado</h2>
<p>No se ha realizado ningún cargo.<br/>
Puedes intentarlo de nuevo cuando quieras.</p>
<div class="btn-row">
<a th:href="@{/pagar}" class="btn-primary">Intentar de nuevo</a>
<a th:href="@{/}" class="btn-secondary">Volver al inicio</a>
</div>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="es">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>TAI AGE | Pago completado</title>
<link rel="stylesheet" th:href="@{/css/style.css}"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.0/css/all.min.css"/>
<style>
.result-wrap { display:flex; align-items:center; justify-content:center; min-height:100vh; padding:2rem; text-align:center; }
.result-card { background:var(--bg-alt); border:1px solid var(--border); border-radius:14px; padding:3rem 2rem; max-width:460px; width:100%; }
.result-icon { font-size:3rem; color:var(--success,#6a9955); margin-bottom:1rem; }
.result-card h2 { color:var(--accent-2); font-size:1.4rem; margin-bottom:.8rem; }
.result-card p { color:var(--text-muted); line-height:1.6; margin-bottom:1.5rem; }
.result-card a { display:inline-block; padding:.65rem 1.6rem; border-radius:8px; background:var(--accent); color:#fff; text-decoration:none; font-weight:700; transition:opacity .15s; }
.result-card a:hover { opacity:.85; }
.result-note { margin-top:1rem; font-size:.8rem; color:var(--text-muted); }
</style>
</head>
<body>
<div class="result-wrap">
<div class="result-card">
<div class="result-icon"><i class="fas fa-circle-check"></i></div>
<h2>¡Pago completado!</h2>
<p>Tu acceso al curso TAI AGE está activo.<br/>
Ya puedes acceder a todos los temas y apuntes.</p>
<a th:href="@{/curso}">Ir al curso →</a>
<p class="result-note">Si el acceso no se activa en unos segundos, recarga la página o contacta con el administrador.</p>
</div>
</div>
</body>
</html>