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:
parent
a137cb582d
commit
2dc0cfceeb
@ -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<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]
|
||||
public void GetQuota_SumsResourceSizeBytes()
|
||||
{
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -395,7 +395,8 @@ namespace ManagerService.Controllers
|
||||
storageUsedBytes = storageUsed,
|
||||
storageQuotaBytes = storageQuota,
|
||||
aiTokensUsed = aiUsed,
|
||||
aiTokensPerMonth = aiQuota
|
||||
aiTokensPerMonth = aiQuota,
|
||||
aiTokensPerQuestion = ResolveTokensPerQuestion(id)
|
||||
});
|
||||
}
|
||||
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>
|
||||
/// Delete an instance
|
||||
/// </summary>
|
||||
|
||||
@ -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)
|
||||
{
|
||||
var slug = (raw ?? "").ToLowerInvariant();
|
||||
|
||||
@ -6,5 +6,12 @@ namespace ManagerService.DTOs
|
||||
public long storageQuotaBytes { get; set; }
|
||||
public long aiTokensUsed { 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; }
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,14 +9,15 @@ namespace ManagerService.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class StripeService
|
||||
{
|
||||
private readonly StripeSettings _settings;
|
||||
private readonly CustomerService _customerService;
|
||||
private readonly SessionService _checkoutSessionService;
|
||||
private readonly Stripe.BillingPortal.SessionService _billingPortalSessionService;
|
||||
|
||||
public StripeService(IOptions<StripeSettings> 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<string> CreateCustomerAsync(Instance instance)
|
||||
@ -70,6 +72,21 @@ namespace ManagerService.Services
|
||||
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)
|
||||
{
|
||||
return EventUtility.ConstructEvent(json, stripeSignatureHeader, _settings.WebhookSecret);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user