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 && !_myInfoMateDbContext.Instances.Any(i => i.WebSlug == 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 (_myInfoMateDbContext.Instances.Any(i => i.WebSlug == 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;
}
}
}