Customer Portal : StripeService.CreateBillingPortalSessionAsync + POST /api/onboarding/billing-portal, calqué sur checkout-session. Une garde absente de l'énoncé : StripeCustomerId peut être nul, car CreateCustomerAsync n'est appelée que par l'inscription self-service. Les 4 instances venues de Mongo n'ont jamais vu Stripe — sans la garde, elles recevaient une erreur d'API Stripe illisible côté front. 409, distinct du 404 d'instance inconnue. Ratio jetons -> questions : InstanceQuotaDTO.aiTokensPerQuestion, mesuré sur les VisitorQuestion.TokensUsed de l'instance, seuil de 20 questions avant de faire foi — une seule réponse citant un long article doublerait la moyenne. En dessous, repli sur l'hypothèse de la grille tarifaire (10 000, pas 1 000 : le /1000 de manager-app était faux d'un facteur 10). Trois autres points du lot F étaient déjà faits et n'attendaient qu'une vérification : rate limiting, endpoint ApplicationInstance, quotas seed. 6 tests. dotnet test : 197 passés, 14 sautés, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
349 lines
16 KiB
C#
349 lines
16 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Self-service sign-up for the Essentiel plan: creates the Instance + the first
|
|
/// InstanceAdmin User, starts the 14-day trial, no card required.
|
|
/// </summary>
|
|
[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<OnboardingController> _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<OnboardingController> logger,
|
|
IHttpClientFactory httpClientFactory,
|
|
StripeService stripeService)
|
|
{
|
|
_myInfoMateDbContext = myInfoMateDbContext;
|
|
_profileLogic = profileLogic;
|
|
_emailService = emailService;
|
|
_configuration = configuration;
|
|
_logger = logger;
|
|
_httpClientFactory = httpClientFactory;
|
|
_stripeService = stripeService;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Check whether a web slug is available
|
|
/// </summary>
|
|
/// <param name="slug">Desired slug</param>
|
|
[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 });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Validate a VAT number against VIES and return the applicable VAT rate
|
|
/// </summary>
|
|
/// <param name="country">ISO country code (e.g. BE, FR)</param>
|
|
/// <param name="vatNumber">VAT number, with or without the country prefix</param>
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(ValidateVatResultDTO), 200)]
|
|
[HttpPost("validate-vat")]
|
|
public async Task<ObjectResult> 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."
|
|
});
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create the Instance + first InstanceAdmin User and start the 14-day free trial
|
|
/// </summary>
|
|
/// <param name="dto">Registration form data</param>
|
|
[AllowAnonymous]
|
|
[ProducesResponseType(typeof(OnboardingRegisterResultDTO), 200)]
|
|
[ProducesResponseType(typeof(string), 400)]
|
|
[ProducesResponseType(typeof(string), 409)]
|
|
[ProducesResponseType(typeof(string), 500)]
|
|
[HttpPost("register")]
|
|
public async Task<ObjectResult> 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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ObjectResult> 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 };
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ObjectResult> 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;
|
|
}
|
|
}
|
|
}
|