diff --git a/ManagerService.Tests/Controllers/InstanceControllerTests.cs b/ManagerService.Tests/Controllers/InstanceControllerTests.cs index f666b64..83c8409 100644 --- a/ManagerService.Tests/Controllers/InstanceControllerTests.cs +++ b/ManagerService.Tests/Controllers/InstanceControllerTests.cs @@ -217,6 +217,83 @@ namespace ManagerService.Tests.Controllers Assert.Equal(100, dto.aiTokensPerMonth); } + [Fact] + public void GetQuota_NoQuestionsYet_FallsBackToPricingAssumption() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + db.SaveChanges(); + + var result = BuildController(db).GetQuota("i1"); + + var ok = Assert.IsType(result); + var dto = Assert.IsType(ok.Value); + Assert.Equal(10_000, dto.aiTokensPerQuestion); + } + + [Fact] + public void GetQuota_SmallSample_KeepsFallback() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + // 19 questions : sous le seuil, la moyenne réelle (500) est ignorée + for (var i = 0; i < 19; i++) + db.VisitorQuestions.Add(NewQuestion("i1", 500)); + db.SaveChanges(); + + var result = BuildController(db).GetQuota("i1"); + + var ok = Assert.IsType(result); + var dto = Assert.IsType(ok.Value); + Assert.Equal(10_000, dto.aiTokensPerQuestion); + } + + [Fact] + public void GetQuota_EnoughQuestions_ReturnsMeasuredAverage() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + for (var i = 0; i < 20; i++) + db.VisitorQuestions.Add(NewQuestion("i1", 500)); + db.SaveChanges(); + + var result = BuildController(db).GetQuota("i1"); + + var ok = Assert.IsType(result); + var dto = Assert.IsType(ok.Value); + Assert.Equal(500, dto.aiTokensPerQuestion); + } + + [Fact] + public void GetQuota_TokensPerQuestion_IgnoresOtherInstances() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + for (var i = 0; i < 20; i++) + db.VisitorQuestions.Add(NewQuestion("i1", 500)); + for (var i = 0; i < 20; i++) + db.VisitorQuestions.Add(NewQuestion("other", 90_000)); + db.SaveChanges(); + + var result = BuildController(db).GetQuota("i1"); + + var ok = Assert.IsType(result); + var dto = Assert.IsType(ok.Value); + Assert.Equal(500, dto.aiTokensPerQuestion); + } + + private static VisitorQuestion NewQuestion(string instanceId, long tokensUsed) => new VisitorQuestion + { + ConversationId = Guid.NewGuid().ToString(), + InstanceId = instanceId, + Language = "fr", + Question = "Où sont les toilettes ?", + Reply = "Au fond à gauche.", + TokensUsed = tokensUsed, + HasAnswer = true, + CreatedAt = DateTime.UtcNow + }; + [Fact] public void GetQuota_SumsResourceSizeBytes() { diff --git a/ManagerService.Tests/Controllers/OnboardingControllerTests.cs b/ManagerService.Tests/Controllers/OnboardingControllerTests.cs new file mode 100644 index 0000000..bba1d72 --- /dev/null +++ b/ManagerService.Tests/Controllers/OnboardingControllerTests.cs @@ -0,0 +1,77 @@ +using ManagerService.Controllers; +using ManagerService.Data; +using ManagerService.Helpers; +using ManagerService.Services; +using ManagerService.Tests.Infrastructure; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading.Tasks; +using Xunit; + +namespace ManagerService.Tests.Controllers +{ + /// + /// Les deux gardes du portail de facturation. L'appel à Stripe lui-même n'est pas couvert : + /// il part sur le réseau, et ces tests s'arrêtent avant — c'est justement ce qui compte, + /// un client sans compte Stripe ne doit jamais atteindre l'API. + /// + public class OnboardingControllerTests + { + private OnboardingController BuildController(MyInfoMateDbContext db, string callerInstanceId = "i1") + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["AppUrls:ManagerApp"] = "https://manager.myinfomate.be" + }) + .Build(); + + var stripeService = new StripeService(Options.Create(new StripeSettings + { + SecretKey = "sk_test_dummy", + WebhookSecret = "whsec_dummy", + EssentielPriceId = "price_dummy" + })); + + var controller = new OnboardingController( + db, + new ProfileLogic(NullLogger.Instance), + new Mock().Object, + configuration, + NullLogger.Instance, + new Mock().Object, + stripeService); + + FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, callerInstanceId)); + return controller; + } + + [Fact] + public async Task CreateBillingPortalSession_UnknownInstance_Returns404() + { + using var db = DbContextFactory.Create(); + + var result = await BuildController(db, "unknown").CreateBillingPortalSession(); + + Assert.IsType(result); + } + + [Fact] + public async Task CreateBillingPortalSession_NoStripeCustomer_Returns409() + { + using var db = DbContextFactory.Create(); + db.Instances.Add(new Instance { Id = "i1", Name = "Musée", DateCreation = DateTime.UtcNow }); + db.SaveChanges(); + + var result = await BuildController(db).CreateBillingPortalSession(); + + Assert.IsType(result); + } + } +} diff --git a/ManagerService/Controllers/InstanceController.cs b/ManagerService/Controllers/InstanceController.cs index 56c8a9f..673fee4 100644 --- a/ManagerService/Controllers/InstanceController.cs +++ b/ManagerService/Controllers/InstanceController.cs @@ -395,7 +395,8 @@ namespace ManagerService.Controllers storageUsedBytes = storageUsed, storageQuotaBytes = storageQuota, aiTokensUsed = aiUsed, - aiTokensPerMonth = aiQuota + aiTokensPerMonth = aiQuota, + aiTokensPerQuestion = ResolveTokensPerQuestion(id) }); } catch (Exception ex) @@ -404,10 +405,35 @@ namespace ManagerService.Controllers } } + private const long DefaultAiTokensPerQuestion = 10_000; + private const int MinQuestionSampleSize = 20; + + /// + /// Coût moyen d'une question, en jetons. Mesuré sur les questions réellement posées par + /// l'instance ; à défaut, l'hypothèse de la grille tarifaire (Premium = 20 M de jetons + /// pour ~2 000 questions, cf. le seed de MyInfoMateDbContext). + /// + /// ⚠️ Le seuil d'échantillon n'est pas de la prudence gratuite : une seule question dont + /// la réponse cite un long article suffirait à doubler la moyenne, et le gestionnaire + /// verrait son crédit restant changer de moitié d'un rafraîchissement à l'autre. + /// + private long ResolveTokensPerQuestion(string instanceId) + { + var sample = _myInfoMateDbContext.VisitorQuestions + .Where(q => q.InstanceId == instanceId && q.TokensUsed > 0) + .Select(q => q.TokensUsed) + .ToList(); + + if (sample.Count < MinQuestionSampleSize) + return DefaultAiTokensPerQuestion; + + return (long)Math.Round(sample.Average()); + } + /// /// Delete an instance /// - /// Id of instance to delete + /// Id of instance to delete [ProducesResponseType(typeof(string), 202)] [ProducesResponseType(typeof(string), 400)] [ProducesResponseType(typeof(string), 404)] diff --git a/ManagerService/Controllers/OnboardingController.cs b/ManagerService/Controllers/OnboardingController.cs index b9d6d16..54548b6 100644 --- a/ManagerService/Controllers/OnboardingController.cs +++ b/ManagerService/Controllers/OnboardingController.cs @@ -294,6 +294,49 @@ namespace ManagerService.Controllers } } + /// + /// 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(); diff --git a/ManagerService/DTOs/InstanceQuotaDTO.cs b/ManagerService/DTOs/InstanceQuotaDTO.cs index 7effbba..97dcccb 100644 --- a/ManagerService/DTOs/InstanceQuotaDTO.cs +++ b/ManagerService/DTOs/InstanceQuotaDTO.cs @@ -6,5 +6,12 @@ namespace ManagerService.DTOs public long storageQuotaBytes { get; set; } public long aiTokensUsed { get; set; } public long aiTokensPerMonth { get; set; } + + /// + /// Combien de jetons coûte une question, en moyenne. Le gestionnaire raisonne en + /// questions, pas en jetons : c'est le diviseur que manager-app doit appliquer, et il + /// vient d'ici pour être calé sur l'usage réel de l'instance plutôt que codé en dur. + /// + public long aiTokensPerQuestion { get; set; } } } diff --git a/ManagerService/Services/StripeService.cs b/ManagerService/Services/StripeService.cs index c857193..e32e1b0 100644 --- a/ManagerService/Services/StripeService.cs +++ b/ManagerService/Services/StripeService.cs @@ -9,14 +9,15 @@ namespace ManagerService.Services { /// /// Stripe integration for the Essentiel plan self-service subscription (customer creation, - /// Checkout Session, webhook signature verification). Stripe Checkout is hosted by Stripe — - /// no card form is built in any of our own apps. + /// Checkout Session, Billing Portal Session, webhook signature verification). Both Checkout + /// and the Billing Portal are hosted by Stripe — no card form is built in any of our own apps. /// public class StripeService { private readonly StripeSettings _settings; private readonly CustomerService _customerService; private readonly SessionService _checkoutSessionService; + private readonly Stripe.BillingPortal.SessionService _billingPortalSessionService; public StripeService(IOptions settings) { @@ -24,6 +25,7 @@ namespace ManagerService.Services StripeConfiguration.ApiKey = _settings.SecretKey; _customerService = new CustomerService(); _checkoutSessionService = new SessionService(); + _billingPortalSessionService = new Stripe.BillingPortal.SessionService(); } public async Task CreateCustomerAsync(Instance instance) @@ -70,6 +72,21 @@ namespace ManagerService.Services return session.Url; } + /// + /// Create a Stripe Billing Portal Session: the customer manages card, invoices and + /// cancellation on Stripe's own pages. Nothing to build on our side beyond the redirect. + /// + public async Task CreateBillingPortalSessionAsync(Instance instance, string returnUrl) + { + var session = await _billingPortalSessionService.CreateAsync( + new Stripe.BillingPortal.SessionCreateOptions + { + Customer = instance.StripeCustomerId, + ReturnUrl = returnUrl, + }); + return session.Url; + } + public Event ConstructWebhookEvent(string json, string stripeSignatureHeader) { return EventUtility.ConstructEvent(json, stripeSignatureHeader, _settings.WebhookSecret);