Lot F : portail de facturation Stripe et coût réel d'une question

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>
This commit is contained in:
Thomas Fransolet 2026-08-13 10:10:50 +02:00
parent a137cb582d
commit 2dc0cfceeb
6 changed files with 251 additions and 4 deletions

View File

@ -217,6 +217,83 @@ namespace ManagerService.Tests.Controllers
Assert.Equal(100, dto.aiTokensPerMonth); 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<OkObjectResult>(result);
var dto = Assert.IsType<InstanceQuotaDTO>(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<OkObjectResult>(result);
var dto = Assert.IsType<InstanceQuotaDTO>(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<OkObjectResult>(result);
var dto = Assert.IsType<InstanceQuotaDTO>(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<OkObjectResult>(result);
var dto = Assert.IsType<InstanceQuotaDTO>(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] [Fact]
public void GetQuota_SumsResourceSizeBytes() public void GetQuota_SumsResourceSizeBytes()
{ {

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
public class OnboardingControllerTests
{
private OnboardingController BuildController(MyInfoMateDbContext db, string callerInstanceId = "i1")
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string>
{
["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<ProfileLogic>.Instance),
new Mock<IEmailService>().Object,
configuration,
NullLogger<OnboardingController>.Instance,
new Mock<IHttpClientFactory>().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<NotFoundObjectResult>(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<ConflictObjectResult>(result);
}
}
}

View File

@ -395,7 +395,8 @@ namespace ManagerService.Controllers
storageUsedBytes = storageUsed, storageUsedBytes = storageUsed,
storageQuotaBytes = storageQuota, storageQuotaBytes = storageQuota,
aiTokensUsed = aiUsed, aiTokensUsed = aiUsed,
aiTokensPerMonth = aiQuota aiTokensPerMonth = aiQuota,
aiTokensPerQuestion = ResolveTokensPerQuestion(id)
}); });
} }
catch (Exception ex) catch (Exception ex)
@ -404,6 +405,31 @@ namespace ManagerService.Controllers
} }
} }
private const long DefaultAiTokensPerQuestion = 10_000;
private const int MinQuestionSampleSize = 20;
/// <summary>
/// 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.
/// </summary>
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());
}
/// <summary> /// <summary>
/// Delete an instance /// Delete an instance
/// </summary> /// </summary>

View File

@ -294,6 +294,49 @@ namespace ManagerService.Controllers
} }
} }
/// <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) private static string SanitizeSlug(string raw)
{ {
var slug = (raw ?? "").ToLowerInvariant(); var slug = (raw ?? "").ToLowerInvariant();

View File

@ -6,5 +6,12 @@ namespace ManagerService.DTOs
public long storageQuotaBytes { get; set; } public long storageQuotaBytes { get; set; }
public long aiTokensUsed { get; set; } public long aiTokensUsed { get; set; }
public long aiTokensPerMonth { get; set; } public long aiTokensPerMonth { get; set; }
/// <summary>
/// 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.
/// </summary>
public long aiTokensPerQuestion { get; set; }
} }
} }

View File

@ -9,14 +9,15 @@ namespace ManagerService.Services
{ {
/// <summary> /// <summary>
/// Stripe integration for the Essentiel plan self-service subscription (customer creation, /// Stripe integration for the Essentiel plan self-service subscription (customer creation,
/// Checkout Session, webhook signature verification). Stripe Checkout is hosted by Stripe — /// Checkout Session, Billing Portal Session, webhook signature verification). Both Checkout
/// no card form is built in any of our own apps. /// and the Billing Portal are hosted by Stripe — no card form is built in any of our own apps.
/// </summary> /// </summary>
public class StripeService public class StripeService
{ {
private readonly StripeSettings _settings; private readonly StripeSettings _settings;
private readonly CustomerService _customerService; private readonly CustomerService _customerService;
private readonly SessionService _checkoutSessionService; private readonly SessionService _checkoutSessionService;
private readonly Stripe.BillingPortal.SessionService _billingPortalSessionService;
public StripeService(IOptions<StripeSettings> settings) public StripeService(IOptions<StripeSettings> settings)
{ {
@ -24,6 +25,7 @@ namespace ManagerService.Services
StripeConfiguration.ApiKey = _settings.SecretKey; StripeConfiguration.ApiKey = _settings.SecretKey;
_customerService = new CustomerService(); _customerService = new CustomerService();
_checkoutSessionService = new SessionService(); _checkoutSessionService = new SessionService();
_billingPortalSessionService = new Stripe.BillingPortal.SessionService();
} }
public async Task<string> CreateCustomerAsync(Instance instance) public async Task<string> CreateCustomerAsync(Instance instance)
@ -70,6 +72,21 @@ namespace ManagerService.Services
return session.Url; return session.Url;
} }
/// <summary>
/// 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.
/// </summary>
public async Task<string> 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) public Event ConstructWebhookEvent(string json, string stripeSignatureHeader)
{ {
return EventUtility.ConstructEvent(json, stripeSignatureHeader, _settings.WebhookSecret); return EventUtility.ConstructEvent(json, stripeSignatureHeader, _settings.WebhookSecret);