using System; using System.Linq; using System.Net.Http; using System.Text; using System.Text.Json; using System.Threading.Tasks; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Helpers; using ManagerService.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using NSwag.Annotations; namespace ManagerService.Controllers { /// /// Self-service sign-up for the Essentiel plan: creates the Instance + the first /// InstanceAdmin User, starts the 14-day trial, no card required. /// [ApiController, Route("api/onboarding")] [OpenApiTag("Onboarding", Description = "Self-service sign-up (Essentiel plan)")] public class OnboardingController : ControllerBase { private const string EssentielPlanId = "plan-essentiel"; private const int TrialDurationDays = 14; private readonly MyInfoMateDbContext _myInfoMateDbContext; private readonly ProfileLogic _profileLogic; private readonly IEmailService _emailService; private readonly IConfiguration _configuration; private readonly ILogger _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly StripeService _stripeService; private readonly IHexIdGeneratorService _idService = new HexIdGeneratorService(); public OnboardingController( MyInfoMateDbContext myInfoMateDbContext, ProfileLogic profileLogic, IEmailService emailService, IConfiguration configuration, ILogger logger, IHttpClientFactory httpClientFactory, StripeService stripeService) { _myInfoMateDbContext = myInfoMateDbContext; _profileLogic = profileLogic; _emailService = emailService; _configuration = configuration; _logger = logger; _httpClientFactory = httpClientFactory; _stripeService = stripeService; } /// /// Check whether a web slug is available /// /// Desired slug [AllowAnonymous] [ProducesResponseType(typeof(object), 200)] [HttpGet("check-slug/{slug}")] public ObjectResult CheckSlug(string slug) { var sanitized = SanitizeSlug(slug); var available = sanitized.Length >= 3 && !SlugHelper.IsTaken(_myInfoMateDbContext, sanitized); return new OkObjectResult(new { slug = sanitized, available }); } /// /// Validate a VAT number against VIES and return the applicable VAT rate /// /// ISO country code (e.g. BE, FR) /// VAT number, with or without the country prefix [AllowAnonymous] [ProducesResponseType(typeof(ValidateVatResultDTO), 200)] [HttpPost("validate-vat")] public async Task ValidateVat([FromQuery] string country, [FromQuery] string vatNumber) { const decimal domesticRate = 21m; var euCountries = new[] { "BE", "FR", "LU", "NL", "DE", "IT", "ES", "PL" }; if (string.IsNullOrWhiteSpace(vatNumber)) { return new OkObjectResult(new ValidateVatResultDTO { valid = null, vatRate = domesticRate, message = "Aucun numéro de TVA fourni — TVA belge appliquée par défaut." }); } var cleaned = new string(vatNumber.Where(char.IsLetterOrDigit).ToArray()).ToUpperInvariant(); var vatCountryCode = cleaned.Length >= 2 ? cleaned.Substring(0, 2) : ""; var vatDigits = cleaned.Length > 2 ? cleaned.Substring(2) : ""; if (!euCountries.Contains(vatCountryCode) || vatDigits.Length < 5) { return new OkObjectResult(new ValidateVatResultDTO { valid = false, vatRate = domesticRate, message = "Numéro de TVA invalide." }); } try { var client = _httpClientFactory.CreateClient(); client.Timeout = TimeSpan.FromSeconds(6); var payload = JsonSerializer.Serialize(new { countryCode = vatCountryCode, vatNumber = vatDigits }); var content = new StringContent(payload, Encoding.UTF8, "application/json"); var response = await client.PostAsync("https://ec.europa.eu/taxation_customs/vies/rest-api/check-vat-number", content); response.EnsureSuccessStatusCode(); var body = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(body); var isValid = doc.RootElement.TryGetProperty("valid", out var validProp) && validProp.GetBoolean(); return new OkObjectResult(new ValidateVatResultDTO { valid = isValid, vatRate = isValid ? (vatCountryCode == "BE" ? domesticRate : 0m) : domesticRate, message = isValid ? (vatCountryCode == "BE" ? "TVA belge 21% appliquée." : "TVA intracommunautaire — 0% (autoliquidation).") : "Numéro de TVA invalide selon VIES." }); } catch (Exception ex) { _logger.LogWarning(ex, "VIES lookup failed for {Country} {VatNumber} — falling back to domestic rate", vatCountryCode, vatDigits); return new OkObjectResult(new ValidateVatResultDTO { valid = null, vatRate = domesticRate, message = "Service de validation TVA temporairement indisponible — TVA belge appliquée, à revalider ultérieurement." }); } } /// /// Create the Instance + first InstanceAdmin User and start the 14-day free trial /// /// Registration form data [AllowAnonymous] [ProducesResponseType(typeof(OnboardingRegisterResultDTO), 200)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 409)] [ProducesResponseType(typeof(string), 500)] [HttpPost("register")] public async Task Register([FromBody] OnboardingRegisterDTO dto) { try { if (dto == null) throw new ArgumentNullException(nameof(dto), "Registration data is missing"); if (string.IsNullOrWhiteSpace(dto.organizationName)) throw new ArgumentNullException(nameof(dto.organizationName), "Organization name is required"); if (string.IsNullOrWhiteSpace(dto.email)) throw new ArgumentNullException(nameof(dto.email), "Email is required"); if (string.IsNullOrWhiteSpace(dto.password) || dto.password.Length < 8) throw new ArgumentNullException(nameof(dto.password), "Password must be at least 8 characters"); if (string.IsNullOrWhiteSpace(dto.slug)) throw new ArgumentNullException(nameof(dto.slug), "Slug is required"); var email = dto.email.Trim().ToLowerInvariant(); if (_myInfoMateDbContext.Users.Any(u => u.Email.ToLower() == email)) throw new InvalidOperationException("This email is already used"); var slug = SanitizeSlug(dto.slug); if (slug.Length < 3) throw new ArgumentNullException(nameof(dto.slug), "Slug must be at least 3 characters"); if (SlugHelper.IsTaken(_myInfoMateDbContext, slug)) throw new InvalidOperationException("This address is already taken"); var plan = _myInfoMateDbContext.SubscriptionPlans.FirstOrDefault(p => p.Id == EssentielPlanId); if (plan == null) throw new InvalidOperationException("Essentiel plan is not configured"); var trialEndsAt = DateTime.UtcNow.AddDays(TrialDurationDays); var instance = new Instance { Id = _idService.GenerateHexId(), Name = dto.organizationName.Trim(), DateCreation = DateTime.UtcNow, IsWeb = true, SubscriptionPlanId = plan.Id, StorageQuotaBytes = plan.StorageQuotaBytes, AiTokensPerMonth = plan.AiTokensPerMonth, HasStats = plan.HasStats, StatsHistoryDays = plan.StatsHistoryDays, HasAdvancedStats = plan.HasAdvancedStats, WebSlug = slug, PublicApiKey = "ap_" + Convert.ToBase64String( System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)) .Replace("+", "-").Replace("/", "_").TrimEnd('='), IsTrialActive = true, TrialEndsAt = trialEndsAt, BillingAddress = dto.billingAddress, BillingCountry = dto.billingCountry, VatNumber = dto.vatNumber, }; var user = new User { Id = _idService.GenerateHexId(), Email = email, Password = _profileLogic.HashPassword(dto.password), FirstName = dto.firstName?.Trim(), LastName = dto.lastName?.Trim(), InstanceId = instance.Id, Role = UserRole.InstanceAdmin, Token = Guid.NewGuid().ToString(), DateCreation = DateTime.UtcNow, }; instance.StripeCustomerId = await _stripeService.CreateCustomerAsync(instance); _myInfoMateDbContext.Instances.Add(instance); _myInfoMateDbContext.Users.Add(user); _myInfoMateDbContext.SaveChanges(); var managerAppUrl = _configuration["AppUrls:ManagerApp"]; var landingUrl = _configuration["AppUrls:Landing"]; await _emailService.SendWelcomeEmailAsync(user.Email, user.FirstName, $"{managerAppUrl}/login"); await _emailService.SendTrialStartedEmailAsync(user.Email, user.FirstName, trialEndsAt); await _emailService.NotifyAdminAsync( "Nouveau compte MyInfoMate", $"Organisation: {instance.Name}\nEmail: {user.Email}\nSlug: {instance.WebSlug}"); return new OkObjectResult(new OnboardingRegisterResultDTO { instanceId = instance.Id, slug = instance.WebSlug, webUrl = $"https://app.myinfomate.be/{instance.WebSlug}", managerAppUrl = managerAppUrl, trialEndsAt = trialEndsAt.ToString("O"), }); } catch (ArgumentNullException ex) { return new BadRequestObjectResult(ex.Message) { }; } catch (InvalidOperationException ex) { return new ConflictObjectResult(ex.Message) { }; } catch (Exception ex) { _logger.LogError(ex, "Onboarding registration failed"); return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Create a Stripe Checkout Session to convert the caller's instance from trial to a /// paid Essentiel subscription. Stripe Checkout is hosted by Stripe — this just returns /// the URL to redirect to. /// [Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 500)] [HttpPost("checkout-session")] public async Task CreateCheckoutSession() { try { var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value; var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId); if (instance == null) return new NotFoundObjectResult("Instance not found"); var managerAppUrl = _configuration["AppUrls:ManagerApp"]; var url = await _stripeService.CreateCheckoutSessionAsync( instance, successUrl: $"{managerAppUrl}/main/web", cancelUrl: $"{managerAppUrl}/main/web"); return new OkObjectResult(new { url }); } catch (Exception ex) { _logger.LogError(ex, "Checkout session creation failed"); return new ObjectResult(ex.Message) { StatusCode = 500 }; } } /// /// Create a Stripe Billing Portal Session so the caller can update the card, read past /// invoices or cancel. Stripe hosts the page — this just returns the URL to redirect to. /// /// C'est la sortie de l'impasse décrite par l'e-mail d'échec de paiement : il pointe vers /// `{managerAppUrl}/billing`, où le seul bouton lançait un Checkout de souscription — donc /// proposait au client l'abonnement qu'il a déjà, au lieu de lui laisser corriger sa carte. /// [Authorize(Policy = ManagerService.Service.Security.Policies.AppReadAccess)] [ProducesResponseType(typeof(object), 200)] [ProducesResponseType(typeof(string), 404)] [ProducesResponseType(typeof(string), 409)] [ProducesResponseType(typeof(string), 500)] [HttpPost("billing-portal")] public async Task CreateBillingPortalSession() { try { var instanceId = User.FindFirst(ManagerService.Service.Security.ClaimTypes.InstanceId)?.Value; var instance = _myInfoMateDbContext.Instances.FirstOrDefault(i => i.Id == instanceId); if (instance == null) return new NotFoundObjectResult("Instance not found"); // Une instance née de la migration Mongo n'a jamais vu Stripe : pas de client, // donc pas de portail. Le 409 la distingue d'une instance inexistante — sans lui, // Stripe renverrait une erreur d'API illisible pour le front. if (string.IsNullOrEmpty(instance.StripeCustomerId)) return new ConflictObjectResult("Instance has no Stripe customer"); var managerAppUrl = _configuration["AppUrls:ManagerApp"]; var url = await _stripeService.CreateBillingPortalSessionAsync( instance, returnUrl: $"{managerAppUrl}/main/web"); return new OkObjectResult(new { url }); } catch (Exception ex) { _logger.LogError(ex, "Billing portal session creation failed"); return new ObjectResult(ex.Message) { StatusCode = 500 }; } } private static string SanitizeSlug(string raw) { var slug = (raw ?? "").ToLowerInvariant(); slug = System.Text.RegularExpressions.Regex.Replace(slug, @"[^a-z0-9-]", "-"); slug = System.Text.RegularExpressions.Regex.Replace(slug, @"-+", "-").Trim('-'); return slug; } } }