diff --git a/AGENTS.md b/AGENTS.md index 66af03af11..23850f3a02 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,15 @@ +## Behavioral Guidelines + +1. **Think before coding.** State assumptions explicitly. If uncertain, ask rather than guess. When multiple interpretations exist, present them - don't pick silently. + +2. **Goal-driven execution.** Define success criteria before iterating. Loop until verified. Strong success criteria let you loop independently; "make it work" requires constant clarification. + +3. **Read before you write.** Before adding code in a file, read the file's exports, the immediate caller, and obvious shared utilities. "Looks orthogonal to me" is the most dangerous phrase in this codebase. + +4. **Checkpoint significant steps.** After each step in a multi-step task, summarize what was done, what's verified, and what's left. Don't continue from a state you can't describe back. + +5. **Fail loud.** Surface uncertainty, don't hide it. "Completed" is wrong if anything was skipped silently. "Tests pass" is wrong if any were skipped. "Feature works" is wrong if the edge case wasn't verified. + ## Build, Test, and Format Use the developer CLI skills (`build`, `test`, `format`, `lint`, `e2e`, `aspire-restart`, `team-interrupt`) for all code workflows. They invoke `dotnet run --project developer-cli -- ` directly. Never run `dotnet`, `npm`, or `npx` directly - the pre-tool-use Bash hook blocks them. diff --git a/README.md b/README.md index fb33690a12..806a59edee 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,16 @@ Follow our [up-to-date roadmap](https://github.com/orgs/PlatformPlatform/project Show your support for our project - give us a star on GitHub! It truly means a lot! ⭐ +### Back office + +Operate the platform: manage account signups, users, and logins, and monitor revenue, MRR, churn, invoices, and Stripe drift. + +PlatformPlatform Back Office + +### Product demo + +End-user flows: tenant signup, account settings, Google login, welcome flow, accessibility and localization, and Stripe-powered subscription signup and management. + PlatformPlatform Demo # Getting Started diff --git a/application/AppGateway.Tests/ApiAggregationServiceTests.cs b/application/AppGateway.Tests/ApiAggregationServiceTests.cs new file mode 100644 index 0000000000..106ea09bc9 --- /dev/null +++ b/application/AppGateway.Tests/ApiAggregationServiceTests.cs @@ -0,0 +1,173 @@ +using System.Net; +using System.Text; +using AppGateway.ApiAggregation; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Primitives; +using SharedKernel.Configuration; +using Xunit; +using Yarp.ReverseProxy.Configuration; + +namespace AppGateway.Tests; + +public sealed class ApiAggregationServiceTests +{ + private const string AccountOpenApiJson = """ + { + "openapi": "3.0.0", + "info": { "title": "PlatformPlatform Account API", "version": "v1" }, + "paths": { + "/api/account/users": { "get": { "responses": { "200": { "description": "OK" } } } }, + "/internal-api/account/probe": { "get": { "responses": { "200": { "description": "OK" } } } } + } + } + """; + + private const string MainOpenApiJson = """ + { + "openapi": "3.0.0", + "info": { "title": "PlatformPlatform Main API", "version": "v1" }, + "paths": { + "/api/main/health": { "get": { "responses": { "200": { "description": "OK" } } } } + } + } + """; + + // Simulates a future endpoint that accidentally leaks into the account document with a + // /api/back-office prefix; the aggregator's belt-and-braces filter must drop it. + private const string AccountOpenApiJsonWithLeakedBackOfficePath = """ + { + "openapi": "3.0.0", + "info": { "title": "PlatformPlatform Account API", "version": "v1" }, + "paths": { + "/api/account/users": { "get": { "responses": { "200": { "description": "OK" } } } }, + "/api/back-office/leaked": { "get": { "responses": { "200": { "description": "OK" } } } } + } + } + """; + + [Fact] + public async Task GetAggregatedOpenApiJson_ShouldIncludeAccountApiPaths() + { + // Arrange + var service = CreateService(AccountOpenApiJson, MainOpenApiJson); + + // Act + var aggregated = await service.GetAggregatedOpenApiJson(); + + // Assert + aggregated.Should().Contain("\"/api/account/users\""); + } + + [Fact] + public async Task GetAggregatedOpenApiJson_ShouldIncludeMainApiPaths() + { + // Arrange + var service = CreateService(AccountOpenApiJson, MainOpenApiJson); + + // Act + var aggregated = await service.GetAggregatedOpenApiJson(); + + // Assert + aggregated.Should().Contain("\"/api/main/health\""); + } + + [Fact] + public async Task GetAggregatedOpenApiJson_ShouldExcludeBackOfficePaths() + { + // Arrange + var service = CreateService(AccountOpenApiJsonWithLeakedBackOfficePath, MainOpenApiJson); + + // Act + var aggregated = await service.GetAggregatedOpenApiJson(); + + // Assert + aggregated.Should().NotContain("/api/back-office/"); + aggregated.Should().Contain("\"/api/account/users\""); + } + + [Fact] + public async Task GetAggregatedOpenApiJson_ShouldExcludeInternalApiPaths() + { + // Arrange + var service = CreateService(AccountOpenApiJson, MainOpenApiJson); + + // Act + var aggregated = await service.GetAggregatedOpenApiJson(); + + // Assert + aggregated.Should().NotContain("/internal-api/"); + } + + private static ApiAggregationService CreateService(string accountDocumentJson, string mainDocumentJson) + { + var handler = new StubHttpMessageHandler(request => + { + var requestUrl = request.RequestUri!.ToString(); + if (requestUrl.EndsWith("/openapi/account.json", StringComparison.OrdinalIgnoreCase)) + { + return BuildJsonResponse(accountDocumentJson); + } + + if (requestUrl.EndsWith("/openapi/v1.json", StringComparison.OrdinalIgnoreCase)) + { + return BuildJsonResponse(mainDocumentJson); + } + + return new HttpResponseMessage(HttpStatusCode.NotFound) { ReasonPhrase = $"Unstubbed URL: {requestUrl}" }; + } + ); + + var clusters = new ClusterConfig[] + { + new() { ClusterId = "account-api", Destinations = new Dictionary { ["destination"] = new() { Address = "https://placeholder.invalid" } } }, + new() { ClusterId = "main-api", Destinations = new Dictionary { ["destination"] = new() { Address = "https://placeholder.invalid" } } } + }; + + var proxyConfigProvider = new StubProxyConfigProvider(new StubProxyConfig(clusters)); + var httpClientFactory = new StubHttpClientFactory(handler); + var ports = new PortAllocation(9000); + return new ApiAggregationService(NullLogger.Instance, proxyConfigProvider, httpClientFactory, ports); + } + + private static HttpResponseMessage BuildJsonResponse(string json) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json") + }; + } + + private sealed class StubHttpMessageHandler(Func responder) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(responder(request)); + } + } + + private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) + { + return new HttpClient(handler, false); + } + } + + private sealed class StubProxyConfigProvider(IProxyConfig config) : IProxyConfigProvider + { + public IProxyConfig GetConfig() + { + return config; + } + } + + private sealed class StubProxyConfig(IReadOnlyList clusters) : IProxyConfig + { + public IReadOnlyList Routes => Array.Empty(); + + public IReadOnlyList Clusters => clusters; + + public IChangeToken ChangeToken => new CancellationChangeToken(CancellationToken.None); + } +} diff --git a/application/AppGateway/ApiAggregation/ApiAggregationService.cs b/application/AppGateway/ApiAggregation/ApiAggregationService.cs index 3b9db18632..db8238367a 100644 --- a/application/AppGateway/ApiAggregation/ApiAggregationService.cs +++ b/application/AppGateway/ApiAggregation/ApiAggregationService.cs @@ -5,7 +5,7 @@ namespace AppGateway.ApiAggregation; -public class ApiAggregationService( +public sealed class ApiAggregationService( ILogger logger, IProxyConfigProvider proxyConfigProvider, IHttpClientFactory httpClientFactory, @@ -36,8 +36,8 @@ private async Task GetAggregatedOpenApiDocumentAsync() var proxyConfiguration = proxyConfigProvider.GetConfig(); // account-api emits two OpenAPI documents (account, back-office) post-consolidation; the - // user-facing aggregator only surfaces 'account' since back-office endpoints don't appear - // in the user-facing contract. + // user-facing aggregator only surfaces 'account' since back-office endpoints are admin-only + // and don't appear in the public contract. var accountCluster = proxyConfiguration.Clusters.FirstOrDefault(c => c.ClusterId == "account-api"); if (accountCluster is not null) { @@ -45,7 +45,18 @@ private async Task GetAggregatedOpenApiDocumentAsync() CombineOpenApiDocuments(aggregatedOpenApiDocument, accountDocument); } + // main-api emits a single OpenAPI document at /openapi/v1.json (ApiDocumentLayout.Single). + // It must be aggregated so future routes added to main appear in the unified public contract + // without further wiring here. + var mainCluster = proxyConfiguration.Clusters.FirstOrDefault(c => c.ClusterId == "main-api"); + if (mainCluster is not null) + { + var mainDocument = await FetchOpenApiDocument(mainCluster, "v1"); + CombineOpenApiDocuments(aggregatedOpenApiDocument, mainDocument); + } + FilterInternalEndpoints(aggregatedOpenApiDocument); + FilterBackOfficeEndpoints(aggregatedOpenApiDocument); return aggregatedOpenApiDocument; } @@ -116,4 +127,21 @@ private static void FilterInternalEndpoints(OpenApiDocument openApiDocument) openApiDocument.Paths.Remove(path); } } + + // Belt-and-braces guard: account-api's 'account' document already excludes back-office endpoints + // because they're grouped under a separate ApiExplorer document, but if a future endpoint + // accidentally leaks into the account group with a /api/back-office/ prefix we still drop it + // from the public contract. + private static void FilterBackOfficeEndpoints(OpenApiDocument openApiDocument) + { + var backOfficePaths = openApiDocument.Paths + .Where(p => p.Key.StartsWith("/api/back-office/")) + .Select(p => p.Key) + .ToArray(); + + foreach (var path in backOfficePaths) + { + openApiDocument.Paths.Remove(path); + } + } } diff --git a/application/AppHost/Program.cs b/application/AppHost/Program.cs index d85539ab91..a9c0d1e1f3 100644 --- a/application/AppHost/Program.cs +++ b/application/AppHost/Program.cs @@ -82,6 +82,14 @@ .WithEnvironment("KESTREL_PORT", ports.AccountWorkers.ToString()) .WithReference(accountDatabase) .WithReference(azureStorage) + // The BillingDriftWorker resolves StripeClientFactory which reads these. Without them the worker + // process sees UnconfiguredStripeClient even when Stripe is configured at the API level, and every + // stale subscription logs a warn + fail line through ProcessPendingStripeEvents on every worker start. + .WithEnvironment("Stripe__SubscriptionEnabled", stripeFullyConfigured ? "true" : "false") + .WithEnvironment("Stripe__ApiKey", stripeApiKey) + .WithEnvironment("Stripe__WebhookSecret", stripeWebhookSecret) + .WithEnvironment("Stripe__PublishableKey", stripePublishableKey) + .WithEnvironment("Stripe__AllowMockProvider", "true") .WaitFor(accountDatabase); var accountApi = builder @@ -114,6 +122,10 @@ .WithEnvironment("Stripe__WebhookSecret", stripeWebhookSecret) .WithEnvironment("Stripe__PublishableKey", stripePublishableKey) .WithEnvironment("Stripe__AllowMockProvider", "true") + .WithEnvironment("PUBLIC_GOOGLE_OAUTH_ENABLED", googleOAuthConfigured ? "true" : "false") + // Force-on so newcomers see the back-office billing UI without Stripe configured. Set to "false" (or + // change back to `stripeFullyConfigured ? "true" : "false"`) to hide all billing/revenue/Stripe data. + .WithEnvironment("PUBLIC_SUBSCRIPTION_ENABLED", "true") .WaitFor(accountWorkers); var mainDatabase = postgres diff --git a/application/account/Api/Endpoints/BackOfficeEndpoints.cs b/application/account/Api/BackOffice/BackOfficeEndpoints.cs similarity index 94% rename from application/account/Api/Endpoints/BackOfficeEndpoints.cs rename to application/account/Api/BackOffice/BackOfficeEndpoints.cs index 9ac59aebbe..fb7c6e96b3 100644 --- a/application/account/Api/Endpoints/BackOfficeEndpoints.cs +++ b/application/account/Api/BackOffice/BackOfficeEndpoints.cs @@ -5,7 +5,7 @@ using SharedKernel.Endpoints; using SharedKernel.OpenApi; -namespace Account.Api.Endpoints; +namespace Account.Api.BackOffice; public sealed class BackOfficeEndpoints : IEndpoints { @@ -14,7 +14,7 @@ public sealed class BackOfficeEndpoints : IEndpoints public void MapEndpoints(IEndpointRouteBuilder routes) { // BackOffice:Host is required (validated at startup via ValidateOnStart in - // ApiDependencyConfiguration.AddBackOfficeHostOptions). PP-1149 must keep that validation in place + // ApiDependencyConfiguration.AddBackOfficeHostOptions). The startup validation must stay in place // so a missing/blank value fails loudly rather than silently 404-ing back-office endpoints. var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; diff --git a/application/account/Api/BackOffice/BillingDriftEndpoints.cs b/application/account/Api/BackOffice/BillingDriftEndpoints.cs new file mode 100644 index 0000000000..ab0d17e907 --- /dev/null +++ b/application/account/Api/BackOffice/BillingDriftEndpoints.cs @@ -0,0 +1,37 @@ +using Account.Features.BackOffice.BillingDrift.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class BillingDriftEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/billing-drift"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeBillingDrift") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/summary", async Task> ([AsParameters] GetBillingDriftSummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/unsynced-summary", async Task> ([AsParameters] GetUnsyncedSubscriptionsSummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/mrr-consistency-summary", async Task> ([AsParameters] GetDashboardMrrConsistencySummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/BillingEventsEndpoints.cs b/application/account/Api/BackOffice/BillingEventsEndpoints.cs new file mode 100644 index 0000000000..1270907ad5 --- /dev/null +++ b/application/account/Api/BackOffice/BillingEventsEndpoints.cs @@ -0,0 +1,29 @@ +using Account.Features.BackOffice.BillingEvents.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class BillingEventsEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/billing-events"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeBillingEvents") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetBackOfficeBillingEventsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/DashboardEndpoints.cs b/application/account/Api/BackOffice/DashboardEndpoints.cs new file mode 100644 index 0000000000..fa4d13161b --- /dev/null +++ b/application/account/Api/BackOffice/DashboardEndpoints.cs @@ -0,0 +1,61 @@ +using Account.Features.BackOffice.Dashboard.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class DashboardEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/dashboard"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeDashboard") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/kpis", async Task> ([AsParameters] GetDashboardKpisQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/trends", async Task> ([AsParameters] GetDashboardTrendsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/mrr-trend", async Task> ([AsParameters] GetDashboardMrrTrendQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/revenue-trend", async Task> ([AsParameters] GetDashboardRevenueTrendQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/plan-distribution", async Task> ([AsParameters] GetDashboardPlanDistributionQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-signups", async Task> ([AsParameters] GetDashboardRecentSignupsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-stripe-events", async Task> ([AsParameters] GetDashboardRecentStripeEventsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-payments", async Task> ([AsParameters] GetDashboardRecentPaymentsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-logins", async Task> ([AsParameters] GetDashboardRecentLoginsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/InvoicesEndpoints.cs b/application/account/Api/BackOffice/InvoicesEndpoints.cs new file mode 100644 index 0000000000..3b5b58c357 --- /dev/null +++ b/application/account/Api/BackOffice/InvoicesEndpoints.cs @@ -0,0 +1,29 @@ +using Account.Features.BackOffice.Invoices.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class InvoicesEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/invoices"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeInvoices") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetBackOfficeInvoicesQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs new file mode 100644 index 0000000000..bf59e29376 --- /dev/null +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -0,0 +1,63 @@ +using Account.Features.Tenants.BackOffice.Commands; +using Account.Features.Tenants.BackOffice.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Domain; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class TenantsEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/tenants"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeTenants") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetTenantsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/{id}", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantDetailQuery(id)) + ).Produces(); + + group.MapGet("/{id}/user-counts", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantUserCountsQuery(id)) + ).Produces(); + + group.MapGet("/{id}/users", async Task> (TenantId id, [AsParameters] GetTenantUsersQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapGet("/{id}/activity", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantActivityQuery(id)) + ).Produces(); + + group.MapGet("/{id}/payment-history", async Task> (TenantId id, [AsParameters] GetTenantPaymentHistoryQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapPost("/{id}/reconcile-with-stripe", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new ReconcileTenantWithStripeCommand { TenantId = id }) + ).Produces().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + + group.MapPost("/{id}/replay-archived-stripe-events", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new ReplayArchivedTenantStripeEventsCommand { TenantId = id }) + ).Produces().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + + group.MapPost("/{id}/drift/acknowledge", async Task (TenantId id, IMediator mediator) + => await mediator.Send(new AcknowledgeBillingDriftCommand { TenantId = id }) + ).RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + } +} diff --git a/application/account/Api/BackOffice/UsersEndpoints.cs b/application/account/Api/BackOffice/UsersEndpoints.cs new file mode 100644 index 0000000000..f2f2b69288 --- /dev/null +++ b/application/account/Api/BackOffice/UsersEndpoints.cs @@ -0,0 +1,42 @@ +using Account.Features.Users.BackOffice.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Domain; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class UsersEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/users"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeUsers") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetBackOfficeUsersQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/{id}", async Task> (UserId id, IMediator mediator) + => await mediator.Send(new GetBackOfficeUserDetailQuery(id)) + ).Produces(); + + group.MapGet("/{id}/sessions", async Task> (UserId id, [AsParameters] GetBackOfficeUserSessionsQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapGet("/{id}/login-history", async Task> (UserId id, [AsParameters] GetBackOfficeUserLoginHistoryQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + } +} diff --git a/application/account/Api/BackOfficeBlobProxy.cs b/application/account/Api/BackOfficeBlobProxy.cs new file mode 100644 index 0000000000..9f96518140 --- /dev/null +++ b/application/account/Api/BackOfficeBlobProxy.cs @@ -0,0 +1,35 @@ +using Microsoft.AspNetCore.Mvc; +using SharedKernel.Integrations.BlobStorage; + +namespace Account.Api; + +// Back-office Kestrel listens on its own port (BACK_OFFICE_KESTREL_PORT) and bypasses AppGateway, so +// the avatar/logo routes that AppGateway forwards on the user-facing host are not available here. +// Map equivalent endpoints scoped to the back-office host that stream blobs directly from the +// keyed account-storage IBlobStorageClient. This keeps account list/side-pane logos and owner +// avatars working when the back-office SPA is loaded over the dedicated Kestrel port. +public static class BackOfficeBlobProxy +{ + public static IEndpointRouteBuilder MapBackOfficeBlobProxy(this IEndpointRouteBuilder routes, string backOfficeHostname) + { + routes.MapGet("/avatars/{**path}", async ([FromRoute] string path, [FromKeyedServices("account-storage")] IBlobStorageClient blobStorageClient, HttpContext httpContext, CancellationToken cancellationToken) + => await StreamBlobAsync(blobStorageClient, "avatars", path, httpContext, cancellationToken) + ).RequireHost(backOfficeHostname).AllowAnonymous(); + + routes.MapGet("/logos/{**path}", async ([FromRoute] string path, [FromKeyedServices("account-storage")] IBlobStorageClient blobStorageClient, HttpContext httpContext, CancellationToken cancellationToken) + => await StreamBlobAsync(blobStorageClient, "logos", path, httpContext, cancellationToken) + ).RequireHost(backOfficeHostname).AllowAnonymous(); + + return routes; + } + + private static async Task StreamBlobAsync(IBlobStorageClient blobStorageClient, string containerName, string blobName, HttpContext httpContext, CancellationToken cancellationToken) + { + var blob = await blobStorageClient.DownloadAsync(containerName, blobName, cancellationToken); + if (blob is null) return Results.NotFound(); + + httpContext.Response.Headers.CacheControl = "public, max-age=2592000, immutable"; + httpContext.Response.Headers.XContentTypeOptions = "nosniff"; + return Results.Stream(blob.Value.Stream, blob.Value.ContentType); + } +} diff --git a/application/account/Api/Endpoints/StripeWebhookEndpoints.cs b/application/account/Api/Endpoints/StripeWebhookEndpoints.cs index 13a3cbf9b5..9543aec971 100644 --- a/application/account/Api/Endpoints/StripeWebhookEndpoints.cs +++ b/application/account/Api/Endpoints/StripeWebhookEndpoints.cs @@ -19,14 +19,19 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPost("/", async Task (HttpRequest request, IMediator mediator, ProcessPendingStripeEvents processPendingStripeEvents) => { var payload = await new StreamReader(request.Body).ReadToEndAsync(); - var signatureHeader = request.Headers["Stripe-Signature"].ToString(); + if (!request.Headers.TryGetValue("Stripe-Signature", out var signatureHeaderValues) || signatureHeaderValues.Count != 1) + { + return Result.BadRequest("Stripe-Signature header missing or duplicated."); + } + + var signatureHeader = signatureHeaderValues[0]!; var acknowledgeResult = await mediator.Send(new AcknowledgeStripeWebhookCommand(payload, signatureHeader)); if (!acknowledgeResult.IsSuccess) return Result.From(acknowledgeResult); - var customerId = acknowledgeResult.Value; - if (customerId is not null) + var acknowledgedWebhook = acknowledgeResult.Value; + if (acknowledgedWebhook is not null) { - await processPendingStripeEvents.ExecuteAsync(customerId, request.HttpContext.RequestAborted); + await processPendingStripeEvents.ExecuteAsync(acknowledgedWebhook.StripeCustomerId, acknowledgedWebhook.JustAcknowledgedEvent, request.HttpContext.RequestAborted); } return Result.Success(); diff --git a/application/account/Api/Program.cs b/application/account/Api/Program.cs index 676dff747b..677333590b 100644 --- a/application/account/Api/Program.cs +++ b/application/account/Api/Program.cs @@ -45,6 +45,15 @@ ?? (appPublicUrl is null ? null : ReplaceHost(appPublicUrl, appHostname, backOfficeHostname)); var backOfficeCdnUrl = Environment.GetEnvironmentVariable("BACK_OFFICE_CDN_URL") ?? backOfficePublicUrl; +// Runtime feature flags injected into the SPA HTML so the React shell can branch on capability without +// a separate config endpoint. Both flags must be wired into BOTH host-scoped SPAs because each one renders +// its own HTML from its own StaticRuntimeEnvironment; omitting them on a host renders the gate as "disabled". +var runtimeEnvironment = new Dictionary +{ + ["PUBLIC_GOOGLE_OAUTH_ENABLED"] = Environment.GetEnvironmentVariable("PUBLIC_GOOGLE_OAUTH_ENABLED") ?? "false", + ["PUBLIC_SUBSCRIPTION_ENABLED"] = Environment.GetEnvironmentVariable("PUBLIC_SUBSCRIPTION_ENABLED") ?? "false" +}; + // The /login picker is the dev-only MockEasyAuth identity selector. In Azure-deployed instances the // path must not be reachable: it is removed from the auth-gate exemption list (so the back-office // authorize policy applies) and short-circuited to 401 below to reject even authenticated requests. @@ -62,6 +71,10 @@ app.UseApiServices(); // Add common configuration for all APIs like Swagger, HSTS, and DeveloperExceptionPage. +// Back-office Kestrel listens on its own port and bypasses AppGateway, so the avatar/logo routes +// that AppGateway proxies on the user-facing host must be served here directly from blob storage. +app.MapBackOfficeBlobProxy(backOfficeHostname); + app.UseEmailStaticFiles("WebApp"); if (SharedInfrastructureConfiguration.IsRunningInAzure) @@ -81,7 +94,8 @@ backOfficePublicUrl, backOfficeCdnUrl, BackOfficeIdentityDefaults.PolicyName, - unauthenticatedPaths: backOfficeUnauthenticatedPaths + runtimeEnvironment, + backOfficeUnauthenticatedPaths ) ); } @@ -93,7 +107,8 @@ "WebApp", context => context.RequestServices.GetRequiredService().UserInfo, appPublicUrl, - appCdnUrl + appCdnUrl, + environmentVariables: runtimeEnvironment ) ); } @@ -108,7 +123,8 @@ "WebApp", context => context.RequestServices.GetRequiredService().UserInfo, appPublicUrl, - appCdnUrl + appCdnUrl, + environmentVariables: runtimeEnvironment ), new HostScopedSinglePageApp( backOfficeHostname, @@ -117,7 +133,8 @@ backOfficePublicUrl, backOfficeCdnUrl, BackOfficeIdentityDefaults.PolicyName, - unauthenticatedPaths: backOfficeUnauthenticatedPaths + runtimeEnvironment, + backOfficeUnauthenticatedPaths ) ); } diff --git a/application/account/BackOffice/routes/-components/CurrentPriorTooltip.tsx b/application/account/BackOffice/routes/-components/CurrentPriorTooltip.tsx new file mode 100644 index 0000000000..1d631bfacf --- /dev/null +++ b/application/account/BackOffice/routes/-components/CurrentPriorTooltip.tsx @@ -0,0 +1,56 @@ +import { useLingui } from "@lingui/react"; + +type TooltipPointPayload = { + date?: string; + priorDate?: string; + current?: number; + prior?: number; +}; + +type CurrentPriorTooltipProps = { + active?: boolean; + payload?: ReadonlyArray<{ payload?: TooltipPointPayload }>; + formatValue: (value: number) => string; + accentColor: string; +}; + +export function CurrentPriorTooltip({ active, payload, formatValue, accentColor }: Readonly) { + const { i18n } = useLingui(); + if (!active || !payload || payload.length === 0) return null; + const data = payload[0]?.payload; + if (!data) return null; + + const current = data.current ?? 0; + const prior = data.prior ?? 0; + const deltaPercent = prior === 0 ? null : ((current - prior) / prior) * 100; + + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + const currentLabel = data.date ? dateFormatter.format(new Date(data.date)) : "—"; + const priorLabel = data.priorDate ? dateFormatter.format(new Date(data.priorDate)) : "—"; + + const deltaClassName = + deltaPercent === null + ? "text-muted-foreground" + : deltaPercent > 0 + ? "text-success" + : deltaPercent < 0 + ? "text-destructive" + : "text-muted-foreground"; + const deltaText = deltaPercent === null ? "—" : `${deltaPercent >= 0 ? "+" : ""}${deltaPercent.toFixed(2)}%`; + + return ( +
+
{deltaText}
+
+ + {currentLabel} + {formatValue(current)} +
+
+ + {priorLabel} + {formatValue(prior)} +
+
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardCardShell.tsx b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx new file mode 100644 index 0000000000..544e420ba9 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@repo/ui/components/Card"; + +interface DashboardCardShellProps { + title: ReactNode; + subtitle?: ReactNode; + action?: ReactNode; + children: ReactNode; +} + +export function DashboardCardShell({ title, subtitle, action, children }: Readonly) { + return ( + + + {title} + {subtitle && {subtitle}} + {action && {action}} + + {children} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardHeader.tsx b/application/account/BackOffice/routes/-components/DashboardHeader.tsx new file mode 100644 index 0000000000..9a36da4603 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardHeader.tsx @@ -0,0 +1,87 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { MaximizeIcon, MinimizeIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +interface DashboardHeaderProps { + period: DashboardTrendPeriod; + onPeriodChange: (period: DashboardTrendPeriod) => void; +} + +export function DashboardHeader({ period, onPeriodChange }: Readonly) { + const { i18n } = useLingui(); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { weekday: "long", month: "long", day: "numeric" }); + const today = dateFormatter.format(new Date()); + + // Browser fullscreen for kiosk mode — chrome (sidebar, tabs) hides until the user exits. + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const updateState = () => setIsFullscreen(document.fullscreenElement !== null); + updateState(); + document.addEventListener("fullscreenchange", updateState); + return () => document.removeEventListener("fullscreenchange", updateState); + }, []); + + const toggleFullscreen = () => { + if (document.fullscreenElement === null) { + void document.documentElement.requestFullscreen(); + } else { + void document.exitFullscreen(); + } + }; + + const fullscreenLabel = isFullscreen ? t`Exit kiosk mode` : t`Enter kiosk mode`; + + return ( +
+
+

+ Dashboard +

+

+ Back Office overview · {today} +

+
+
+ { + const next = values[0]; + if (next) { + onPeriodChange(next as DashboardTrendPeriod); + } + }} + > + + 7d + + + 30d + + + 90d + + + + + {isFullscreen ? : } + + } + /> + {fullscreenLabel} + +
+
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx new file mode 100644 index 0000000000..d14ac64cc6 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx @@ -0,0 +1,149 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Card } from "@repo/ui/components/Card"; +import { LinkCard } from "@repo/ui/components/LinkCard"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { ActivityIcon, BuildingIcon, CoinsIcon, TrendingUpIcon, UsersIcon } from "lucide-react"; + +import { api, DashboardTrendPeriod, TenantStatusFilter } from "@/shared/lib/api/client"; + +import { DeltaPercent } from "./DeltaPercent"; + +interface DashboardKpiTilesProps { + period: DashboardTrendPeriod; +} + +const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + +export function DashboardKpiTiles({ period }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/kpis", { + params: { query: { Period: period } } + }); + + const periodDays = periodToDays(period); + + return ( +
+ + +{data.newTenantsInPeriod} new in last {periodDays} days + + ) : undefined + } + to="/accounts" + /> + + {isSubscriptionEnabled && ( + + ) : undefined + } + to="/accounts" + search={{ statuses: [TenantStatusFilter.Active, TenantStatusFilter.Downgrading] }} + /> + )} + + {isSubscriptionEnabled && ( + All-time, excluding VAT} + to="/invoices" + /> + )} + + Last {periodDays} days} + to="/users" + /> + + Last 24 hours} + /> +
+ ); +} + +function periodToDays(period: DashboardTrendPeriod): number { + switch (period) { + case DashboardTrendPeriod.Last7Days: + return 7; + case DashboardTrendPeriod.Last30Days: + return 30; + case DashboardTrendPeriod.Last90Days: + return 90; + } +} + +function DeltaSubtitle({ deltaPercent }: Readonly<{ deltaPercent: number }>) { + return ( + + vs prior period + + ); +} + +interface KpiTileProps { + label: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; + value: React.ReactNode; + loading: boolean; + subtitle?: React.ReactNode; + to?: "/accounts" | "/users" | "/billing-events" | "/invoices"; + search?: { statuses?: TenantStatusFilter[] }; +} + +function KpiTile({ label, icon: Icon, value, loading, subtitle, to, search }: Readonly) { + const content = ( + <> + + + {loading ? ( + <> + + + + ) : ( + <> + {value ?? "-"} + {subtitle && {subtitle}} + + )} + + ); + + if (to) { + return ( + + {content} + + ); + } + + return {content}; +} diff --git a/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx b/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx new file mode 100644 index 0000000000..1e097a41f7 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx @@ -0,0 +1,128 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +import { CurrentPriorTooltip } from "./CurrentPriorTooltip"; +import { DashboardCardShell } from "./DashboardCardShell"; +import { DeltaPercent } from "./DeltaPercent"; + +interface DashboardMrrTrendCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardMrrTrendCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/mrr-trend", { + params: { query: { Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + priorDate: priorPoints[index]?.date, + current: point.monthlyRecurringRevenue, + prior: priorPoints[index]?.monthlyRecurringRevenue ?? 0 + })); + const currency = data?.currency ?? null; + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + const compactNumberFormatter = new Intl.NumberFormat(i18n.locale, { notation: "compact", maximumFractionDigits: 1 }); + + const blended = points.length > 0 ? points[points.length - 1].monthlyRecurringRevenue : 0; + const first = points.length > 0 ? points[0].monthlyRecurringRevenue : 0; + const deltaPercent = first === 0 ? null : ((blended - first) / first) * 100; + + return ( + MRR trend} + subtitle={ + data && currency && deltaPercent !== null ? ( + + {formatCurrency(blended, currency)} blended · {" "} + over period + + ) : data && currency ? ( + {formatCurrency(blended, currency)} blended + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + compactNumberFormatter.format(value)} + /> + (currency ? formatCurrency(value, currency) : String(value))} + accentColor="var(--chart-1)" + /> + } + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx b/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx new file mode 100644 index 0000000000..fca25daf50 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import { api, SubscriptionPlan } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +const PLAN_COLORS: Record = { + Basis: "var(--chart-5)", + Standard: "var(--chart-3)", + Premium: "var(--chart-1)" +}; + +const PLAN_ORDER: SubscriptionPlan[] = [SubscriptionPlan.Premium, SubscriptionPlan.Standard, SubscriptionPlan.Basis]; + +export function DashboardPlanDistributionCard() { + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/plan-distribution"); + + const total = data?.totalTenants ?? 0; + const distribution = (data?.distribution ?? []) + .slice() + .sort((a, b) => PLAN_ORDER.indexOf(a.plan) - PLAN_ORDER.indexOf(b.plan)); + + return ( + Plan distribution} + subtitle={data ? {total} accounts : undefined} + > + {isLoading ? ( + + ) : ( +
+
+ + + + + {distribution.map((entry) => ( + + ))} + + + +
+ {total} + + accounts + +
+
+
    + {distribution.map((entry) => ( +
  • + + + {getSubscriptionPlanLabel(entry.plan)} + + + {entry.count} + {entry.percentage}% + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentLoginsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentLoginsCard.tsx new file mode 100644 index 0000000000..edfd310a4b --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentLoginsCard.tsx @@ -0,0 +1,144 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, KeyRoundIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; +import { getLoginMethodLabel } from "@/shared/lib/api/labels"; + +import { getUserDisplayName, getUserInitials } from "../users/-components/userDisplay"; +import { DashboardCardShell } from "./DashboardCardShell"; + +export function DashboardRecentLoginsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-logins", { + params: { query: { Limit: 6 } } + }); + + const logins = data?.logins ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + const userId = String(key).split("|")[0]; + // Logins not tied to a known user (rare in practice, but possible if the user was deleted) leave the + // synthetic key prefix empty — skip navigation in that case to avoid landing on a 404 detail page. + if (userId !== "") { + navigate({ to: "/users/$userId", params: { userId } }); + } + }, + [navigate] + ); + + return ( + Recent logins} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentPaymentsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentPaymentsCard.tsx new file mode 100644 index 0000000000..2b82dbecc5 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentPaymentsCard.tsx @@ -0,0 +1,148 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, ReceiptIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api, PaymentTransactionStatus } from "@/shared/lib/api/client"; +import { getPaymentStatusLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +export function DashboardRecentPaymentsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-payments", { + params: { query: { Limit: 6 } } + }); + + const payments = data?.payments ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + navigate({ + to: "/accounts/$tenantId", + params: { tenantId: String(key).split("|")[0] }, + search: { tab: "invoices" } + }); + }, + [navigate] + ); + + return ( + Recent payments} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx new file mode 100644 index 0000000000..b6c4aa86b3 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx @@ -0,0 +1,122 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, BuildingIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api, SortableTenantProperties } from "@/shared/lib/api/client"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +function getOwnerDisplayName(owner: { firstName: string | null; lastName: string | null; email: string }): string { + const fullName = [owner.firstName, owner.lastName].filter((part) => part != null && part.trim() !== "").join(" "); + return fullName !== "" ? fullName : owner.email; +} + +export function DashboardRecentSignupsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-signups", { + params: { query: { Limit: 6 } } + }); + + const signups = data?.signups ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId: String(key) } }); + }, + [navigate] + ); + + return ( + Recent signups} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx new file mode 100644 index 0000000000..890c52b191 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -0,0 +1,153 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, ZapIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; +import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +export function DashboardRecentStripeEventsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-stripe-events", { + params: { query: { Limit: 6 } } + }); + + const events = data?.events ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + const tenantId = String(key).split("|")[0]; + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); + }, + [navigate] + ); + + return ( + Recent billing events} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRevenueTrendCard.tsx b/application/account/BackOffice/routes/-components/DashboardRevenueTrendCard.tsx new file mode 100644 index 0000000000..86f54d44cf --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRevenueTrendCard.tsx @@ -0,0 +1,129 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +import { CurrentPriorTooltip } from "./CurrentPriorTooltip"; +import { DashboardCardShell } from "./DashboardCardShell"; +import { DeltaPercent } from "./DeltaPercent"; + +interface DashboardRevenueTrendCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardRevenueTrendCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/revenue-trend", { + params: { query: { Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + priorDate: priorPoints[index]?.date, + current: point.revenue, + prior: priorPoints[index]?.revenue ?? 0 + })); + const currency = data?.currency ?? null; + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + const compactNumberFormatter = new Intl.NumberFormat(i18n.locale, { notation: "compact", maximumFractionDigits: 1 }); + + const currentGain = points.length > 0 ? points[points.length - 1].revenue - points[0].revenue : 0; + const currentEnd = points.length > 0 ? points[points.length - 1].revenue : 0; + const priorEnd = priorPoints.length > 0 ? priorPoints[priorPoints.length - 1].revenue : 0; + const deltaPercent = priorEnd === 0 ? null : ((currentEnd - priorEnd) / priorEnd) * 100; + + return ( + Revenue} + subtitle={ + data && currency && deltaPercent !== null ? ( + + {formatCurrency(currentGain, currency)} this period · {" "} + vs prior period + + ) : data && currency ? ( + {formatCurrency(currentGain, currency)} this period, excluding VAT + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + compactNumberFormatter.format(value)} + /> + (currency ? formatCurrency(value, currency) : String(value))} + accentColor="var(--chart-2)" + /> + } + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardSections.tsx b/application/account/BackOffice/routes/-components/DashboardSections.tsx new file mode 100644 index 0000000000..1652eee20a --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardSections.tsx @@ -0,0 +1,49 @@ +import { useState } from "react"; + +import { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { DashboardHeader } from "./DashboardHeader"; +import { DashboardKpiTiles } from "./DashboardKpiTiles"; +import { DashboardMrrTrendCard } from "./DashboardMrrTrendCard"; +import { DashboardPlanDistributionCard } from "./DashboardPlanDistributionCard"; +import { DashboardRecentLoginsCard } from "./DashboardRecentLoginsCard"; +import { DashboardRecentPaymentsCard } from "./DashboardRecentPaymentsCard"; +import { DashboardRecentSignupsCard } from "./DashboardRecentSignupsCard"; +import { DashboardRecentStripeEventsCard } from "./DashboardRecentStripeEventsCard"; +import { DashboardRevenueTrendCard } from "./DashboardRevenueTrendCard"; +import { DashboardTenantGrowthCard } from "./DashboardTenantGrowthCard"; +import { DashboardUserLoginsCard } from "./DashboardUserLoginsCard"; + +const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + +export function DashboardSections() { + const [period, setPeriod] = useState(DashboardTrendPeriod.Last30Days); + + return ( +
+ + + {isSubscriptionEnabled && ( +
+ + +
+ )} + {isSubscriptionEnabled && } +
+ + +
+
+ + +
+ {isSubscriptionEnabled && ( +
+ + +
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx b/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx new file mode 100644 index 0000000000..e07218b75e --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx @@ -0,0 +1,102 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api, DashboardTrendMetric } from "@/shared/lib/api/client"; + +import { CurrentPriorTooltip } from "./CurrentPriorTooltip"; +import { DashboardCardShell } from "./DashboardCardShell"; + +interface DashboardTenantGrowthCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardTenantGrowthCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/trends", { + params: { query: { Metric: DashboardTrendMetric.NewTenants, Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + priorDate: priorPoints[index]?.date, + current: point.value, + prior: priorPoints[index]?.value ?? 0 + })); + const total = points.reduce((acc, p) => acc + p.value, 0); + const priorTotal = priorPoints.reduce((acc, p) => acc + p.value, 0); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + + return ( + Account growth} + subtitle={ + data ? ( + + {total} new signups · {priorTotal} prior period + + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + + new Intl.NumberFormat(i18n.locale).format(value)} + accentColor="var(--chart-2)" + /> + } + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx b/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx new file mode 100644 index 0000000000..21c010ef91 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx @@ -0,0 +1,119 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api, DashboardTrendMetric } from "@/shared/lib/api/client"; + +import { CurrentPriorTooltip } from "./CurrentPriorTooltip"; +import { DashboardCardShell } from "./DashboardCardShell"; + +interface DashboardUserLoginsCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardUserLoginsCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/trends", { + params: { query: { Metric: DashboardTrendMetric.LoginActivity, Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + priorDate: priorPoints[index]?.date, + current: point.value, + prior: priorPoints[index]?.value ?? 0 + })); + const total = points.reduce((acc, p) => acc + p.value, 0); + const average = points.length === 0 ? 0 : Math.round(total / points.length); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + + return ( + User logins / day} + subtitle={ + data ? ( + + {total} total · avg {average}/day + + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + + new Intl.NumberFormat(i18n.locale).format(value)} + accentColor="var(--chart-3)" + /> + } + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DeltaPercent.tsx b/application/account/BackOffice/routes/-components/DeltaPercent.tsx new file mode 100644 index 0000000000..9b8676f034 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DeltaPercent.tsx @@ -0,0 +1,34 @@ +import { useLingui } from "@lingui/react"; + +type DeltaPercentProps = { + value: number | null; + fractionDigits?: number; +}; + +/** + * Renders a percentage delta with consistent color semantics across the dashboard: + * positive → green (text-success), negative → red (text-destructive), zero or null → muted. + * Locale-aware number formatting via Intl. Caller decides whether to wrap with extra text + * (e.g. "vs prior period") around the rendered span. + */ +export function DeltaPercent({ value, fractionDigits = 1 }: Readonly) { + const { i18n } = useLingui(); + + if (value === null || Number.isNaN(value)) { + return ; + } + + const className = deltaClassName(value); + const formatter = new Intl.NumberFormat(i18n.locale, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + signDisplay: "exceptZero" + }); + + return {formatter.format(value)}%; +} + +export function deltaClassName(value: number | null): string { + if (value === null || Number.isNaN(value) || value === 0) return "text-muted-foreground"; + return value > 0 ? "text-success" : "text-destructive"; +} diff --git a/application/account/BackOffice/routes/__root.tsx b/application/account/BackOffice/routes/__root.tsx index cdddee2776..edcd7cd954 100644 --- a/application/account/BackOffice/routes/__root.tsx +++ b/application/account/BackOffice/routes/__root.tsx @@ -2,10 +2,12 @@ import { PageTracker } from "@repo/infrastructure/applicationInsights/PageTracke import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; import { useErrorTrigger } from "@repo/infrastructure/development/useErrorTrigger"; import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; +import { BannerPortal } from "@repo/ui/components/BannerPortal"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { BackOfficeBanners } from "@/shared/components/BackOfficeBanners"; import { ErrorPage } from "@/shared/components/errorPages/ErrorPage"; import { NotFoundPage } from "@/shared/components/errorPages/NotFoundPage"; import { queryClient } from "@/shared/lib/api/client"; @@ -25,6 +27,9 @@ function Root() { navigate(options)}> + + + diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx new file mode 100644 index 0000000000..5da040f4f6 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -0,0 +1,127 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ActivityIcon, LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; +import { useCallback } from "react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import { AccountBillingTab } from "./-components/AccountBillingTab"; +import { AccountCurrentPlanCard } from "./-components/AccountCurrentPlanCard"; +import { AccountDetailHeader } from "./-components/AccountDetailHeader"; +import { AccountHealthTiles } from "./-components/AccountHealthTiles"; +import { AccountOverviewTab } from "./-components/AccountOverviewTab"; +import { AccountUsersTab } from "./-components/AccountUsersTab"; + +type AccountDetailTab = "overview" | "users" | "invoices" | "billing-events"; + +const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + +const accountDetailSearchSchema = z.object({ + tab: z.enum(["overview", "users", "invoices", "billing-events"]).optional() +}); + +export const Route = createFileRoute("/accounts/$tenantId")({ + staticData: { trackingTitle: "Account detail" }, + validateSearch: accountDetailSearchSchema, + component: AccountDetailPage +}); + +function AccountDetailPage() { + const { tenantId } = Route.useParams(); + const { tab } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const isBillingTab = tab === "invoices" || tab === "billing-events"; + const activeTab = !isSubscriptionEnabled && isBillingTab ? "overview" : (tab ?? "overview"); + + const setActiveTab = useCallback( + (value: string) => { + const next = value as AccountDetailTab; + navigate({ + search: { tab: next === "overview" ? undefined : next }, + replace: true + }); + }, + [navigate] + ); + + const tenantQuery = api.useQuery("get", "/api/back-office/tenants/{id}", { + params: { path: { id: tenantId } } + }); + + const tenant = tenantQuery.data; + + return ( + + + + +
+ + + + + + + Overview + + + + Users + + {isSubscriptionEnabled && ( + + + Invoices + + )} + {isSubscriptionEnabled && ( + + + Billing events + + )} + + + + {isSubscriptionEnabled && ( +
+
+ +
+
+ setActiveTab("invoices")} + onViewAllEvents={() => setActiveTab("billing-events")} + /> +
+
+ )} +
+ + + + {isSubscriptionEnabled && ( + + + + )} + {isSubscriptionEnabled && ( + + + + )} +
+
+
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx new file mode 100644 index 0000000000..48ce75164d --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx @@ -0,0 +1,196 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@repo/ui/components/AlertDialog"; +import { Button } from "@repo/ui/components/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@repo/ui/components/DropdownMenu"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { AlertTriangleIcon, ExternalLinkIcon, MoreVerticalIcon, RefreshCwIcon } from "lucide-react"; +import { useState } from "react"; + +import { useMe } from "@/shared/hooks/useMe"; +import { api } from "@/shared/lib/api/client"; + +import { ReconcileResultDialog, type ReconcileResult } from "./ReconcileResultDialog"; +import { + type ArchivedAwaitingConfirmation, + ReplayArchivedConfirmDialog, + ReplayArchivedResultDialog, + type ReplayArchivedResult +} from "./ReplayArchivedDialogs"; + +interface AccountActionsMenuProps { + tenantId: string; + stripeCustomerUrl: string | null | undefined; +} + +interface ReconcileApiResult extends ReconcileResult { + archivedEventsAwaitingConfirmation: ArchivedAwaitingConfirmation | null; +} + +export function AccountActionsMenu({ tenantId, stripeCustomerUrl }: Readonly) { + const { data: me } = useMe(); + const [result, setResult] = useState(null); + const [replayResult, setReplayResult] = useState(null); + const [isResultOpen, setIsResultOpen] = useState(false); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const [archivedAwaiting, setArchivedAwaiting] = useState(null); + const [isReplayConfirmOpen, setIsReplayConfirmOpen] = useState(false); + const [isReplayResultOpen, setIsReplayResultOpen] = useState(false); + + const reconcileMutation = api.useMutation("post", "/api/back-office/tenants/{id}/reconcile-with-stripe", { + onSuccess: (data) => { + setResult(data); + if (data.archivedEventsAwaitingConfirmation) { + setArchivedAwaiting(data.archivedEventsAwaitingConfirmation); + setIsReplayConfirmOpen(true); + } else { + setIsResultOpen(true); + } + } + }); + + const replayMutation = api.useMutation("post", "/api/back-office/tenants/{id}/replay-archived-stripe-events", { + onSuccess: (data) => { + setReplayResult(data); + setIsReplayResultOpen(true); + } + }); + + // Reconcile with Stripe is admin-only on the server (TenantsEndpoints.cs). Hide the trigger for + // non-admins so the UI matches the policy. + if (!me?.isAdmin) { + return null; + } + + const handleConfirmReconcile = () => { + setIsConfirmOpen(false); + reconcileMutation.mutate({ params: { path: { id: tenantId } } }); + }; + + const handleConfirmReplay = () => { + setIsReplayConfirmOpen(false); + replayMutation.mutate({ params: { path: { id: tenantId } } }); + }; + + const handleSkipReplay = () => { + setIsReplayConfirmOpen(false); + // Operator declined archive replay — surface the reconcile summary they would otherwise have seen, + // so the underlying reconcile is not silently swallowed by the awaiting-confirmation branch. + setIsResultOpen(true); + }; + + const handleRunDisasterRecovery = () => { + // Operator-initiated escalation from the reconcile-result drift branch. There is no captured + // archivedAwaiting snapshot in this flow — the dialog shows the no-snapshot copy that names the + // best-effort caveat without quoting a count. + setArchivedAwaiting(null); + setIsResultOpen(false); + setIsReplayConfirmOpen(true); + }; + + const isWorking = reconcileMutation.isPending || replayMutation.isPending; + + return ( + <> + + + + + + } + /> + } + /> + {t`Account actions`} + + + setIsConfirmOpen(true)} + disabled={isWorking} + > + + {reconcileMutation.isPending ? Reconciling... : Reconcile with Stripe} + + {stripeCustomerUrl && ( + window.open(stripeCustomerUrl, "_blank", "noopener,noreferrer")} + > + + Open in Stripe + + )} + + + + + + + + + + + Reconcile with Stripe? + + + + Reconcile rebuilds this tenant's subscription and billing events from Stripe directly using the + events.list API (the last 30 days). If the drift is not cleared afterwards, disaster recovery from the + locally archived Stripe payloads is available as a last resort. + + + + + + Cancel + + + Reconcile + + + + + + + + + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx new file mode 100644 index 0000000000..d4ac72e287 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { PLAN_TRANSITION_EVENT_TYPES } from "@/shared/lib/billingEventCategories"; +import { getDisplayedPlanTransition } from "@/shared/lib/billingEventPlanTransition"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +export function AccountBillingEventRow({ + event, + renderDate, + isCompact +}: Readonly<{ + event: BillingEventSummary; + renderDate: (value: string | null | undefined) => ReactNode; + isCompact: boolean; +}>) { + const variant = BILLING_EVENT_VARIANT[event.eventType]; + const Icon = variant.icon; + const planTransition = PLAN_TRANSITION_EVENT_TYPES.has(event.eventType) + ? getDisplayedPlanTransition(event.eventType, event.fromPlan, event.toPlan) + : null; + return ( + + +
+ {renderDate(event.occurredAt)} +
+
+ + + + {getBillingEventTypeLabel(event.eventType)} + + + + {planTransition != null ? ( + + {getSubscriptionPlanLabel(planTransition.from)} + + → + + {getSubscriptionPlanLabel(planTransition.to)} + + ) : null} + + {isCompact ? : } +
+ ); +} + +function CompactAmountCell({ event }: Readonly<{ event: BillingEventSummary }>) { + const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; + return ( + + {event.amountDelta != null && event.currency ? ( + formatCurrency(event.amountDelta, event.currency) + ) : ( + + )} + + ); +} + +function MrrImpactAndAfterCells({ event }: Readonly<{ event: BillingEventSummary }>) { + const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; + return ( + <> + + {event.amountDelta != null && event.currency ? formatCurrency(event.amountDelta, event.currency) : "—"} + + + {event.newAmount != null && event.currency ? formatCurrency(event.newAmount, event.currency) : "—"} + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx new file mode 100644 index 0000000000..dfb9ac854e --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { ArrowRightIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { AccountBillingEventRow } from "./AccountBillingEventRow"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; +type RenderDate = (value: string | null | undefined) => ReactNode; + +interface Props { + billingEvents: BillingEventSummary[]; + isLoading: boolean; + isCompact: boolean; + totalEvents: number; + onViewAll?: () => void; + renderDate: RenderDate; +} + +export function AccountBillingEventsSection({ + billingEvents, + isLoading, + isCompact, + totalEvents, + onViewAll, + renderDate +}: Readonly) { + return ( +
+ {isCompact ? ( +
+

+ Billing events +

+ {onViewAll && totalEvents > 0 && ( + + )} +
+ ) : ( +
+ + Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact + over time. + +
+ )} + {isLoading && billingEvents.length === 0 ? ( +
+ {Array.from({ length: isCompact ? 2 : 5 }).map((_, index) => ( + + ))} +
+ ) : billingEvents.length === 0 ? ( + + + + No billing events + + + Subscription, payment, and billing transitions will appear here. + + + + ) : ( + + + + + Occurred + + + Event + + + Plan + + {isCompact ? ( + + MRR impact + + ) : ( + <> + + MRR impact + + + MRR after + + + )} + + + + {billingEvents.map((event) => ( + + ))} + +
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx new file mode 100644 index 0000000000..e16d1c982a --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx @@ -0,0 +1,145 @@ +import type { ReactNode } from "react"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { ArrowRightIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { AccountPaymentRow } from "./AccountPaymentRow"; + +type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; +type RenderDate = (value: string | null | undefined) => ReactNode; + +interface Props { + transactions: PaymentTransaction[]; + isLoading: boolean; + isCompact: boolean; + totalTransactions: number; + totalPages: number; + currentPage: number; + onViewAll?: () => void; + onPageChange: (offset: number) => void; + renderDate: RenderDate; +} + +export function AccountBillingHistorySection({ + transactions, + isLoading, + isCompact, + totalTransactions, + totalPages, + currentPage, + onViewAll, + onPageChange, + renderDate +}: Readonly) { + return ( +
+ {isCompact ? ( +
+

+ Invoices +

+ {onViewAll && totalTransactions > 0 && ( + + )} +
+ ) : ( +
+ Every invoice, refund, and credit note — the money in and out for this subscription. +
+ )} + {isLoading && transactions.length === 0 ? ( +
+ {Array.from({ length: isCompact ? 2 : 5 }).map((_, index) => ( + + ))} +
+ ) : transactions.length === 0 ? ( + + + + No transactions + + + No invoices, refunds, or credit notes yet. + + + + ) : ( + + + + + Date + + {!isCompact && ( + + Plan + + )} + + Amount + + + VAT + + + Total + + + Status + + {!isCompact && ( + + + Actions + + + )} + + + + {transactions.map((transaction) => ( + + ))} + +
+ )} + + {!isCompact && totalPages > 1 && ( +
+ onPageChange(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Billing history" + className="w-full" + /> +
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx new file mode 100644 index 0000000000..3c07c34453 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -0,0 +1,112 @@ +import type { ReactNode } from "react"; + +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useCallback, useState } from "react"; + +import { api } from "@/shared/lib/api/client"; + +import { AccountBillingEventsSection } from "./AccountBillingEventsSection"; +import { AccountBillingHistorySection } from "./AccountBillingHistorySection"; + +type AccountBillingTabVariant = "compact-both" | "history-full" | "events-full"; + +interface AccountBillingTabProps { + tenantId: string; + /** + * `compact-both` — Overview tab: show last 2 events and last 2 invoices (no pagination). + * `history-full` — Billing tab: full pageable list of invoices only. + * `events-full` — Billing events tab: full list of events only, with MRR before/after columns. + */ + variant: AccountBillingTabVariant; + /** Click handler for the "View all # invoices" link rendered in compact mode. */ + onViewAllInvoices?: () => void; + /** Click handler for the "View all # events" link rendered in compact mode. */ + onViewAllEvents?: () => void; +} + +export function AccountBillingTab({ + tenantId, + variant, + onViewAllInvoices, + onViewAllEvents +}: Readonly) { + const formatDate = useFormatDate(); + const [pageOffset, setPageOffset] = useState(0); + + const isCompact = variant === "compact-both"; + const showHistory = variant === "compact-both" || variant === "history-full"; + const showEvents = variant === "compact-both" || variant === "events-full"; + + // Compact (Overview) shows date only. Full views (Invoices, Billing events) include the clock + // time so support can correlate Stripe webhooks with billing-event ordering. The mobile span + // hides the year so the date column stays narrow on phones. + const renderRowDate = useCallback( + (input: string | null | undefined): ReactNode => ( + <> + {formatDate(input, !isCompact, false, true)} + {formatDate(input, !isCompact)} + + ), + [formatDate, isCompact] + ); + + const paymentsPageSize = isCompact ? 2 : 25; + const eventsPageSize = isCompact ? 2 : 50; + + const paymentsQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/payment-history", + { + params: { + path: { id: tenantId }, + query: { PageOffset: pageOffset || undefined, PageSize: paymentsPageSize } + } + }, + { placeholderData: keepPreviousData, enabled: showHistory } + ); + + const eventsQuery = api.useQuery( + "get", + "/api/back-office/billing-events", + { + params: { query: { TenantId: tenantId, PageSize: eventsPageSize } } + }, + { placeholderData: keepPreviousData, enabled: showEvents } + ); + + const transactions = paymentsQuery.data?.transactions ?? []; + const totalPages = paymentsQuery.data?.totalPages ?? 0; + const currentPage = (paymentsQuery.data?.currentPageOffset ?? 0) + 1; + const billingEvents = eventsQuery.data?.billingEvents ?? []; + const totalEvents = eventsQuery.data?.totalCount ?? 0; + const totalTransactions = paymentsQuery.data?.totalCount ?? 0; + + return ( +
+ {showHistory && ( + + )} + {showEvents && ( + + )} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx b/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx new file mode 100644 index 0000000000..5bbcb58720 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx @@ -0,0 +1,73 @@ +import type { ReactNode } from "react"; + +import { Trans } from "@lingui/react/macro"; +import { Card } from "@repo/ui/components/Card"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { components } from "@/shared/lib/api/client"; + +import { CurrentPlanDetails } from "./CurrentPlanDetails"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountCurrentPlanCardProps { + tenant: TenantDetailResponse | undefined; + isLoading: boolean; +} + +export function AccountCurrentPlanCard({ tenant, isLoading }: Readonly) { + if (isLoading || !tenant) { + return {renderSkeleton()}; + } + + const isFree = tenant.subscribedSince === null && !tenant.hasEverSubscribed; + if (isFree) { + return ( + + No plan} description={No paid plan yet.} /> + + ); + } + + return ( + + + + ); +} + +function CurrentPlanShell({ children }: Readonly<{ children: ReactNode }>) { + return ( +
+

+ Current plan +

+ {children} +
+ ); +} + +function CurrentPlanEmpty({ title, description }: Readonly<{ title: ReactNode; description: ReactNode }>) { + return ( + + + {title} + {description} + + + ); +} + +function renderSkeleton() { + return ( + + + + + + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx new file mode 100644 index 0000000000..e21a599e32 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -0,0 +1,107 @@ +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { CalendarIcon, HashIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel, getTenantStateLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { AccountActionsMenu } from "./AccountActionsMenu"; +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountDetailHeaderProps { + tenant: TenantDetailResponse | undefined; + tenantId: string; + isLoading: boolean; +} + +export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly) { + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + return ( +
+ +
+ {isLoading || !tenant ? ( + <> + + + + ) : ( + <> +
+

{tenant.name}

+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + {tenant.state !== TenantState.Active && } + +
+
+
+
+ {tenant.state !== TenantState.Active && } + +
+ {tenant.billingAddress?.country && ( + + {getCountryFlagEmoji(tenant.billingAddress.country)} + {getCountryName(tenant.billingAddress.country, i18n.locale)} + + )} + + + + Signed up {formatDate(tenant.createdAt, false, false, true)} + {formatDate(tenant.createdAt)} + + + + + {tenantId} + +
+ + )} +
+ +
+ ); +} + +function derivePlannedChange(tenant: TenantDetailResponse): PlannedSubscriptionChange | null { + if (tenant.cancelAtPeriodEnd) { + return PlannedSubscriptionChange.Cancellation; + } + if (tenant.scheduledPlan !== null) { + return PlannedSubscriptionChange.ScheduledPlanChange; + } + return null; +} + +function TenantStatePill({ state }: Readonly<{ state: TenantState }>) { + return ( + + {getTenantStateLabel(state)} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx b/application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx new file mode 100644 index 0000000000..390ed1d488 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx @@ -0,0 +1,182 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { LinkCard } from "@repo/ui/components/LinkCard"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { CalendarIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +type AccountDetailTab = "users" | "invoices" | "billing-events"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + +interface AccountHealthTilesProps { + tenant: TenantDetailResponse | undefined; + tenantId: string; + isLoading: boolean; +} + +function formatAmount(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountHealthTiles({ tenant, tenantId, isLoading }: Readonly) { + const formatDate = useFormatDate(); + const userCountsQuery = api.useQuery("get", "/api/back-office/tenants/{id}/user-counts", { + params: { path: { id: tenantId } } + }); + const userCounts = userCountsQuery.data; + const totalUsers = userCounts?.totalUsers ?? 0; + const activeUsers = userCounts?.activeUsers ?? 0; + const pendingUsers = userCounts?.pendingUsers ?? 0; + const inactiveUsers = Math.max(0, totalUsers - activeUsers - pendingUsers); + const activePercent = totalUsers === 0 ? 0 : (activeUsers / totalUsers) * 100; + const inactivePercent = totalUsers === 0 ? 0 : (inactiveUsers / totalUsers) * 100; + const pendingPercent = totalUsers === 0 ? 0 : (pendingUsers / totalUsers) * 100; + + return ( +
+ + {userCounts ? ( +
+ {totalUsers} +
+ {activePercent > 0 && ( +
+ )} + {inactivePercent > 0 && ( +
+ )} + {pendingPercent > 0 && ( +
+ )} +
+ + {activeUsers} active + {" · "} + {inactiveUsers} inactive + {" · "} + {pendingUsers} pending + +
+ ) : ( + - + )} + + + {isSubscriptionEnabled && ( + + + Since {formatDate(tenant.subscribedSince)} + + ) : undefined + } + > + + {tenant ? formatAmount(tenant.lifetimeValue, tenant.currency) : "-"} + + + )} + + {isSubscriptionEnabled && ( + + + Renews {formatDate(tenant.renewalDate)} + + ) : undefined + } + > + + + )} +
+ ); +} + +function MrrAmount({ tenant }: Readonly<{ tenant: TenantDetailResponse | undefined }>) { + if (!tenant) { + return -; + } + + const currentAmount = formatAmount(tenant.monthlyRecurringRevenue, tenant.currency); + const isCanceling = tenant.cancelAtPeriodEnd; + const isDowngrading = !isCanceling && tenant.scheduledPlan !== null; + const newAmount = + isCanceling && tenant.currency !== null + ? formatAmount(0, tenant.currency) + : isDowngrading && tenant.scheduledPriceAmount !== null + ? formatAmount(tenant.scheduledPriceAmount, tenant.currency) + : null; + + if (newAmount === null) { + return {currentAmount}; + } + + return ( +
+ {currentAmount} + {newAmount} +
+ ); +} + +function HealthTile({ + label, + loading, + subtitle, + tenantId, + tab, + children +}: Readonly<{ + label: string; + loading: boolean; + subtitle?: React.ReactNode; + tenantId: string; + tab: AccountDetailTab; + children: React.ReactNode; +}>) { + return ( + + {label} + {loading ? ( + <> + + + + ) : ( + <> + {children} + {subtitle && {subtitle}} + + )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx new file mode 100644 index 0000000000..eadf674db9 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx @@ -0,0 +1,103 @@ +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { Card } from "@repo/ui/components/Card"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { Link } from "@tanstack/react-router"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, UserRole } from "@/shared/lib/api/client"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +interface AccountOverviewTabProps { + tenant: TenantDetailResponse | undefined; + tenantId: string; + isLoading: boolean; +} + +export function AccountOverviewTab({ tenant, tenantId, isLoading }: Readonly) { + const ownersQuery = api.useQuery("get", "/api/back-office/tenants/{id}/users", { + params: { path: { id: tenantId }, query: { Roles: [UserRole.Owner], PageSize: 100 } } + }); + + const owners = ownersQuery.data?.users ?? []; + + return ( +
+

+ Owners +

+ {ownersQuery.isLoading || isLoading || !tenant ? ( + + ) : owners.length === 0 ? ( + + + + No owners + + + No owners on this account. + + + + ) : ( +
+ {owners.map((owner) => ( + + ))} +
+ )} +
+ ); +} + +function OwnerRow({ owner }: Readonly<{ owner: TenantUserSummary }>) { + const displayName = + owner.firstName || owner.lastName ? `${owner.firstName ?? ""} ${owner.lastName ?? ""}`.trim() : owner.email; + + return ( + + + + + + {getInitials(owner.firstName ?? undefined, owner.lastName ?? undefined, owner.email)} + + +
+ {displayName} + {owner.title && {owner.title}} + + + {owner.email} + +
+ {!owner.emailConfirmed && ( + + Pending + + )} + +
+ ); +} + +function OwnersSkeleton() { + return ( +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx new file mode 100644 index 0000000000..469251f394 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx @@ -0,0 +1,164 @@ +import type { ReactNode } from "react"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { DownloadIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { BackOfficeInvoiceRowKind, PaymentTransactionStatus } from "@/shared/lib/api/client"; +import { getPaymentStatusLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; + +export function AccountPaymentRow({ + transaction, + renderDate, + showPlan = true, + showActions = true +}: Readonly<{ + transaction: PaymentTransaction; + renderDate: (value: string | null | undefined) => ReactNode; + showPlan?: boolean; + showActions?: boolean; +}>) { + // Strikethrough only on reversal rows (CreditNote, Refund). The Invoice row always carries the + // original payment outcome and never gets strikethrough — the reversal lives in its own row. + const isCreditNote = transaction.rowKind === BackOfficeInvoiceRowKind.CreditNote; + const isRefundRow = transaction.rowKind === BackOfficeInvoiceRowKind.Refund; + const isReversal = isCreditNote || isRefundRow; + const reverseClass = isReversal ? "text-muted-foreground line-through" : ""; + return ( + + +
{renderDate(transaction.date)}
+
+ {showPlan && ( + + {transaction.plan != null ? ( + {getSubscriptionPlanLabel(transaction.plan)} + ) : ( + + )} + + )} + + {formatCurrency(transaction.amountExcludingTax, transaction.currency)} + + + {formatCurrency(transaction.taxAmount, transaction.currency)} + + + {formatCurrency(transaction.amount, transaction.currency)} + + + + + {showActions && ( + +
+ {isCreditNote && transaction.creditNoteUrl && ( + + )} + {!isCreditNote && !isRefundRow && transaction.invoiceUrl && ( + + )} +
+
+ )} +
+ ); +} + +function RowKindBadge({ + rowKind, + status, + failureReason +}: Readonly<{ rowKind: BackOfficeInvoiceRowKind; status: PaymentTransactionStatus; failureReason: string | null }>) { + if (rowKind === BackOfficeInvoiceRowKind.CreditNote) { + return ( + + Credit note + + ); + } + + if (rowKind === BackOfficeInvoiceRowKind.Refund) { + return ( + + Refunded + + ); + } + + const variant = status === PaymentTransactionStatus.Failed ? "outline" : "secondary"; + const className = + status === PaymentTransactionStatus.Failed + ? "border-destructive/30 text-destructive" + : status === PaymentTransactionStatus.Succeeded + ? "bg-success text-success-foreground" + : undefined; + + const badge = ( + + {getPaymentStatusLabel(status)} + + ); + + if (status === PaymentTransactionStatus.Failed && failureReason) { + return ( +
+ {badge} + {failureReason} +
+ ); + } + + return badge; +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx new file mode 100644 index 0000000000..a860ce9b31 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx @@ -0,0 +1,113 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { SidePane, SidePaneBody, SidePaneFooter, SidePaneHeader } from "@repo/ui/components/SidePane"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { AccountSidePaneSections } from "./AccountSidePaneSections"; +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +interface AccountSidePaneProps { + tenant: TenantSummary | null; + isOpen: boolean; + onClose: () => void; +} + +const DETAIL_DEBOUNCE_MS = 200; + +export function AccountSidePane({ tenant, isOpen, onClose }: Readonly) { + const navigate = useNavigate(); + const { i18n } = useLingui(); + + const tenantId = tenant?.id; + const debouncedTenantId = useDebounce(tenantId, DETAIL_DEBOUNCE_MS); + const detailReady = Boolean(debouncedTenantId) && debouncedTenantId === tenantId; + + const detailQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}", + { params: { path: { id: debouncedTenantId ?? "" } } }, + { enabled: detailReady } + ); + + const detail = detailQuery.data; + + const handleOpen = () => { + if (!tenant) { + return; + } + navigate({ to: "/accounts/$tenantId", params: { tenantId: tenant.id } }); + }; + + return ( + !open && onClose()} + trackingTitle="Account preview" + trackingKey={tenant?.id} + aria-label={t`Account preview`} + > + + {tenant ? ( +
+ +
+ {tenant.name} + + + {getSubscriptionPlanLabel(tenant.plan)} + + + {tenant.country && ( + + {getCountryFlagEmoji(tenant.country)} + {getCountryName(tenant.country, i18n.locale)} + + )} + +
+
+ ) : ( + Account + )} +
+ + + {tenant && ( + + )} + + + + + +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx new file mode 100644 index 0000000000..ce2bc4490d --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx @@ -0,0 +1,195 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, PlannedSubscriptionChange, UserRole } from "@/shared/lib/api/client"; +import { formatRelativeTime } from "@/shared/lib/relativeTime"; + +import { SidePaneDivider, SidePaneSection } from "./SidePaneSection"; +import { SidePaneUserList } from "./SidePaneUserList"; +import { SidePaneUsersRow } from "./SidePaneUsersRow"; +import { SubscriptionStatusIndicator } from "./SubscriptionStatusIndicator"; + +type TenantSummary = components["schemas"]["TenantSummary"]; +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountSidePaneSectionsProps { + tenant: TenantSummary; + detail: TenantDetailResponse | null; + detailLoading: boolean; + debouncedTenantId: string; + detailReady: boolean; +} + +function formatAmount(amount: number | null | undefined, currency: string | null | undefined): string { + if (amount === null || amount === undefined || currency === null || currency === undefined) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountSidePaneSections({ + tenant, + detail, + detailLoading, + debouncedTenantId, + detailReady +}: Readonly) { + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + const userCountsQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/user-counts", + { params: { path: { id: debouncedTenantId } } }, + { enabled: detailReady } + ); + + const ownersQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { params: { path: { id: debouncedTenantId }, query: { Roles: [UserRole.Owner], PageSize: 100 } } }, + { enabled: detailReady } + ); + + const paymentHistoryQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/payment-history", + { params: { path: { id: debouncedTenantId }, query: { PageSize: 1 } } }, + { enabled: detailReady } + ); + + const lastInvoice = paymentHistoryQuery.data?.transactions[0] ?? null; + const paymentHistoryLoading = !detailReady || paymentHistoryQuery.isLoading; + const subscribedSince = detail?.subscribedSince ?? null; + + const isCanceling = tenant.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const isCanceled = tenant.plan === "Basis" && tenant.hasEverSubscribed && !isCanceling && !isDowngrading; + const newMrrAmount = isCanceling ? 0 : isDowngrading ? (detail?.scheduledPriceAmount ?? null) : null; + const showStrikedMrr = (isCanceling || isDowngrading) && newMrrAmount !== null; + + return ( +
+ + + +
+ + + {formatAmount(tenant.monthlyRecurringRevenue, tenant.currency)} + + + → + + {formatAmount(newMrrAmount, tenant.currency)} + + ) : ( + formatAmount(tenant.monthlyRecurringRevenue, tenant.currency) + ) + } + /> + + + + + + +
+
+ + + + + + + + + + + + + + + + + + {formatDate(tenant.createdAt)} + {formatRelativeTime(tenant.createdAt, i18n.locale)} + + +
+ ); +} + +interface KpiRowProps { + leftLabel: string; + leftValue: React.ReactNode; + leftLoading?: boolean; + rightLabel: string; + rightValue: React.ReactNode; + rightLoading?: boolean; +} + +function KpiRow({ leftLabel, leftValue, leftLoading, rightLabel, rightValue, rightLoading }: Readonly) { + return ( +
+
+ {leftLabel} + {leftLoading ? ( + + ) : ( + {leftValue} + )} +
+
+ {rightLabel} + {rightLoading ? ( + + ) : ( + {rightValue} + )} +
+
+ ); +} + +function SubLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx new file mode 100644 index 0000000000..70c14a54f8 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx @@ -0,0 +1,61 @@ +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +export function AccountUserRow({ + user, + formatDate +}: Readonly<{ + user: TenantUserSummary; + formatDate: (value: string | null | undefined) => string; +}>) { + const displayName = + user.firstName || user.lastName ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() : user.email; + + return ( + + +
+ + + + {getInitials(user.firstName ?? undefined, user.lastName ?? undefined, user.email)} + + +
+ {displayName} + {user.title && {user.title}} + + + {user.email} + +
+ {!user.emailConfirmed && ( + + Pending + + )} +
+
+ + + + {user.email} + + + + {getUserRoleLabel(user.role)} + + {user.lastSeenAt ? formatDate(user.lastSeenAt) : "-"} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx new file mode 100644 index 0000000000..f42ffdb2d6 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx @@ -0,0 +1,203 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, UserRole } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +import { AccountUserRow } from "./AccountUserRow"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +interface AccountUsersTabProps { + tenantId: string; +} + +export function AccountUsersTab({ tenantId }: Readonly) { + const [searchInput, setSearchInput] = useState(""); + const [roles, setRoles] = useState([]); + const [pageOffset, setPageOffset] = useState(0); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + setPageOffset(0); + }, [debouncedSearch, roles]); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { + params: { + path: { id: tenantId }, + query: { + Search: debouncedSearch || undefined, + Roles: roles.length === 0 ? undefined : roles, + PageOffset: pageOffset || undefined + } + } + }, + { placeholderData: keepPreviousData } + ); + + const users = data?.users ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + const hasFilters = Boolean(debouncedSearch) || roles.length > 0; + + return ( +
+ + + {totalPages > 1 && ( + setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Account users" + className="w-full" + /> + )} +
+ ); +} + +function UserFilters({ + searchInput, + onSearchChange, + roles, + onRolesChange +}: Readonly<{ + searchInput: string; + onSearchChange: (value: string) => void; + roles: UserRole[]; + onRolesChange: (value: UserRole[]) => void; +}>) { + const handleRolesChange = (values: string[]) => { + onRolesChange(values as UserRole[]); + }; + + return ( +
+
+ + + + + onSearchChange(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && onSearchChange("")} + /> + {searchInput && ( + + onSearchChange("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[UserRole.Owner, UserRole.Admin, UserRole.Member].map((value) => ( + + {getUserRoleLabel(value)} + + ))} + +
+ ); +} + +function UserList({ + users, + isLoading, + hasFilters +}: Readonly<{ users: TenantUserSummary[]; isLoading: boolean; hasFilters: boolean }>) { + const formatDate = useFormatDate(); + const navigate = useNavigate(); + const handleActivate = useCallback( + (key: RowKey) => { + const user = users.find((entry) => entry.id === key); + if (!user) return; + navigate({ to: "/users/$userId", params: { userId: user.id } }); + }, + [navigate, users] + ); + + if (isLoading && users.length === 0) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + if (users.length === 0) { + return ( + + + {hasFilters ? No matching users : No users} + + {hasFilters ? No users match your filters. : This account has no users.} + + + + ); + } + + return ( + + + + + Name + + + Email + + + Role + + + Last seen + + + + + {users.map((user) => ( + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx new file mode 100644 index 0000000000..bcaad13c94 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx @@ -0,0 +1,155 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; + +import type { components, SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +import { AccountsTableColumnHeaders } from "./AccountsTableColumnHeaders"; +import { AccountsTableRow } from "./AccountsTableRow"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +interface AccountsTableProps { + tenants: TenantSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; + selectedTenantId: string | undefined; + onSelectTenant: (tenant: TenantSummary | null) => void; + orderBy: SortableTenantProperties | undefined; + sortOrder: SortOrder | undefined; +} + +export function AccountsTable({ + tenants, + isLoading, + totalPages, + currentPageOffset, + selectedTenantId, + onSelectTenant, + orderBy, + sortOrder +}: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const selectedKeys = useMemo>( + () => (selectedTenantId ? new Set([selectedTenantId]) : new Set()), + [selectedTenantId] + ); + + const handleSelectionChange = useCallback( + (keys: Set) => { + if (keys.size === 0) { + onSelectTenant(null); + return; + } + const [first] = keys; + const tenant = tenants.find((entry) => entry.id === first); + onSelectTenant(tenant ?? null); + }, + [onSelectTenant, tenants] + ); + + const handleActivate = useCallback( + (key: RowKey) => { + const tenant = tenants.find((entry) => entry.id === key); + onSelectTenant(tenant ?? null); + }, + [onSelectTenant, tenants] + ); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableTenantProperties) => { + // Backend default is Descending and the URL stores Descending as undefined; treat both undefined + // and explicit Descending as the descending state when computing the next direction. + const isCurrent = orderBy === column; + const isCurrentlyDescending = (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const nextOrder = isCurrent && isCurrentlyDescending ? SortOrder.Ascending : SortOrder.Descending; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: column, + sortOrder: nextOrder === SortOrder.Descending ? undefined : nextOrder, + pageOffset: undefined + }) + }); + }, + [navigate, orderBy, sortOrder] + ); + + if (isLoading && tenants.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + {tenants.map((tenant) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx new file mode 100644 index 0000000000..b1c308fbaf --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { TableHeader, TableRow } from "@repo/ui/components/Table"; + +import type { SortOrder } from "@/shared/lib/api/client"; + +import { SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SortableTableHead } from "./SortableTableHead"; + +interface AccountsTableColumnHeadersProps { + orderBy: SortableTenantProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableTenantProperties) => void; +} + +export function AccountsTableColumnHeaders({ orderBy, sortOrder, onSort }: Readonly) { + return ( + + + + Name + + + Plan + + + MRR + + + Renewal + + + Status + + + Country + + + Signed up + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx new file mode 100644 index 0000000000..de14a75e9a --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx @@ -0,0 +1,132 @@ +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { getUserDisplayName } from "../../users/-components/userDisplay"; +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +function formatMonthlyRevenue(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountsTableRow({ + tenant, + formatDate +}: Readonly<{ + tenant: TenantSummary; + formatDate: (value: string | null | undefined, includeTime?: boolean, omitCurrentYear?: boolean) => string; +}>) { + return ( + + +
+ +
+ {tenant.name} + {tenant.owner && ( + + {getUserDisplayName(tenant.owner.firstName, tenant.owner.lastName, tenant.owner.email)} + + )} +
+ + {getSubscriptionPlanLabel(tenant.plan)} + +
+
+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + +
+
+ +
+
+
+
+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + + + + + {tenant.renewalDate ? formatDate(tenant.renewalDate) : -} + + +
+ + {tenant.renewalDate && ( + + {formatDate(tenant.renewalDate)} + + )} +
+
+ + {tenant.country ? ( + + {getCountryFlagEmoji(tenant.country)} + {tenant.country} + + ) : ( + "-" + )} + + +
+ + {formatDate(tenant.createdAt, true, true)} +
+
+
+ ); +} + +function MrrCell({ tenant, align = "start" }: Readonly<{ tenant: TenantSummary; align?: "start" | "end" }>) { + const currentAmount = formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency); + const isCanceling = tenant.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const newAmount = + isCanceling && tenant.currency !== null + ? formatCurrency(0, tenant.currency) + : isDowngrading && tenant.scheduledPriceAmount !== null && tenant.currency !== null + ? formatCurrency(tenant.scheduledPriceAmount, tenant.currency) + : null; + + if (newAmount === null) { + return {currentAmount}; + } + + return ( +
+ {currentAmount} + {newAmount} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx new file mode 100644 index 0000000000..c9b6717c67 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx @@ -0,0 +1,180 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { CloudOffIcon, SearchIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SubscriptionPlan, TenantStatusFilter } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +interface AccountsToolbarProps { + search: string | undefined; + plans: SubscriptionPlan[]; + statuses: TenantStatusFilter[]; + unsynced: boolean; + driftDetected: boolean; +} + +export function AccountsToolbar({ search, plans, statuses, unsynced, driftDetected }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + search: debouncedSearch || undefined, + pageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handlePlansChange = (values: string[]) => { + const next = values as SubscriptionPlan[]; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + plans: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + const handleStatusesChange = (values: string[]) => { + const next = values as TenantStatusFilter[]; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + statuses: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[SubscriptionPlan.Premium, SubscriptionPlan.Standard, SubscriptionPlan.Basis].map((value) => ( + + {getSubscriptionPlanLabel(value)} + + ))} + + + + + Active + + + Downgrading + + + Canceling + + + Canceled + + + Free + + + + +
+ ); +} + +function IssueFilterBadges({ unsynced, driftDetected }: Readonly<{ unsynced: boolean; driftDetected: boolean }>) { + const navigate = useNavigate(); + const clear = (key: "unsynced" | "driftDetected") => () => + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + unsynced: key === "unsynced" ? undefined : previous.unsynced, + driftDetected: key === "driftDetected" ? undefined : previous.driftDetected, + pageOffset: undefined + }) + }); + + return ( + <> + {unsynced && ( + + + Not synced yet + + + )} + {driftDetected && ( + + + Drift detected + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/CardBrandLogo.tsx b/application/account/BackOffice/routes/accounts/-components/CardBrandLogo.tsx new file mode 100644 index 0000000000..74cac0f221 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/CardBrandLogo.tsx @@ -0,0 +1,36 @@ +import { CreditCardIcon } from "lucide-react"; + +import amexLogo from "@/shared/images/card-brands/amex.svg"; +import discoverLogo from "@/shared/images/card-brands/discover.svg"; +import mastercardLogo from "@/shared/images/card-brands/mastercard.svg"; +import visaLogo from "@/shared/images/card-brands/visa.svg"; + +// Stripe returns lowercase brand identifiers (visa, mastercard, amex, discover, diners, jcb, unionpay, +// unknown, link). Render known brands with their wordmarks; fall back to a generic icon plus capitalized +// brand for everything else. +const brandLogos: Record = { + visa: { src: visaLogo, alt: "Visa" }, + mastercard: { src: mastercardLogo, alt: "Mastercard" }, + amex: { src: amexLogo, alt: "American Express" }, + discover: { src: discoverLogo, alt: "Discover" } +}; + +type CardBrandLogoSize = "md" | "lg"; + +const sizeClassByVariant: Record = { + md: "h-5 w-8", + lg: "h-9 w-[3.5rem]" +}; + +export function CardBrandLogo({ brand, size = "md" }: Readonly<{ brand: string; size?: CardBrandLogoSize }>) { + const logo = brandLogos[brand.toLowerCase()]; + if (logo) { + return {logo.alt}; + } + return ( + + + {brand} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/CurrentPlanDetails.tsx b/application/account/BackOffice/routes/accounts/-components/CurrentPlanDetails.tsx new file mode 100644 index 0000000000..2ef08fd49f --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/CurrentPlanDetails.tsx @@ -0,0 +1,180 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Card } from "@repo/ui/components/Card"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { CalendarClockIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { CardBrandLogo } from "./CardBrandLogo"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; +type PaymentMethodResponse = NonNullable; + +const sectionLabelClassName = "text-[0.6875rem] font-semibold tracking-wider text-muted-foreground uppercase"; + +export function CurrentPlanDetails({ tenant }: Readonly<{ tenant: TenantDetailResponse }>) { + const formatDate = useFormatDate(); + + const monthlyAmount = + tenant.monthlyRecurringRevenue !== null && tenant.currency !== null + ? formatCurrency(tenant.monthlyRecurringRevenue, tenant.currency) + : "-"; + + const isCanceling = tenant.cancelAtPeriodEnd; + const isDowngrading = !isCanceling && tenant.scheduledPlan !== null; + const isCanceled = tenant.plan === "Basis" && tenant.hasEverSubscribed && !isCanceling && !isDowngrading; + const newMonthlyAmount = + isCanceling && tenant.currency !== null + ? formatCurrency(0, tenant.currency) + : isDowngrading && tenant.scheduledPriceAmount !== null && tenant.currency !== null + ? formatCurrency(tenant.scheduledPriceAmount, tenant.currency) + : null; + const showStrikedAmount = (isCanceling || isDowngrading) && newMonthlyAmount !== null; + + const billingAddressLines = tenant.billingAddress + ? [ + tenant.billingAddress.line1, + tenant.billingAddress.line2, + [tenant.billingAddress.postalCode, tenant.billingAddress.city].filter(Boolean).join(" ").trim() || null, + tenant.billingAddress.state + ].filter((value): value is string => Boolean(value && value.trim().length > 0)) + : []; + + const country = tenant.billingAddress?.country?.trim() ?? ""; + const hasAnyBillingDetails = + Boolean(tenant.billingName) || billingAddressLines.length > 0 || country !== "" || Boolean(tenant.taxId); + + return ( + +
+
+ {showStrikedAmount ? ( +
+ {monthlyAmount} + {newMonthlyAmount} +
+ ) : ( + {monthlyAmount} + )} +
+ + {getSubscriptionPlanLabel(tenant.plan)} + + {isCanceling ? ( + + + Canceling + + ) : isDowngrading ? ( + + + Downgrading + + ) : null} +
+
+ + per month, billed monthly + +
+ +
+ +
+
+ + Subscribed since + + {tenant.subscribedSince ? formatDate(tenant.subscribedSince) : "-"} +
+ +
+ + {isCanceled ? Expired : isCanceling ? Expires : Renewal date} + + + {tenant.renewalDate ? formatDate(tenant.renewalDate) : "-"} + +
+ +
+ +
+ + Billing address + + {hasAnyBillingDetails ? ( +
+ {tenant.billingName &&
{tenant.billingName}
} + {billingAddressLines.map((line) => ( +
{line}
+ ))} + {country !== "" && ( +
+ {getCountryFlagEmoji(country)} + {country} +
+ )} +
+ ) : ( + + No billing address on file. + + )} +
+ +
+
+ + Payment method + + {tenant.paymentMethod ? ( + + ) : ( + - + )} +
+ + {tenant.taxId && ( +
+ + VAT number + + {tenant.taxId} +
+ )} +
+
+
+ ); +} + +// Renders the brand panel for every payment method. Stripe Link is funded by an underlying card that +// the pinned Stripe.NET SDK does not expose, so the backend emits a ("link", "****", 0, 0) sentinel; +// rendering the bullet line and expiry for "link" would surface placeholder data, so we suppress them. +function PaymentMethodBlock({ paymentMethod }: Readonly<{ paymentMethod: PaymentMethodResponse }>) { + const isLink = paymentMethod.brand.toLowerCase() === "link"; + const hasExpiry = paymentMethod.expMonth > 0 && paymentMethod.expYear > 0; + + return ( +
+ + {!isLink && ( +
+ •••• {paymentMethod.last4} + {hasExpiry && ( + + {paymentMethod.expMonth.toString().padStart(2, "0")}/{paymentMethod.expYear.toString().slice(-2)} + + )} +
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/ReconcileResultDialog.tsx b/application/account/BackOffice/routes/accounts/-components/ReconcileResultDialog.tsx new file mode 100644 index 0000000000..d060c287b3 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/ReconcileResultDialog.tsx @@ -0,0 +1,83 @@ +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@repo/ui/components/AlertDialog"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { AlertTriangleIcon, CheckCircle2Icon } from "lucide-react"; + +export interface ReconcileResult { + billingEventsAppended: number; + hasDriftDetected: boolean; + driftDiscrepancyCount: number; + reconciledAt: string; +} + +interface Props { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + result: ReconcileResult | null; + onRunDisasterRecovery?: () => void; +} + +export function ReconcileResultDialog({ isOpen, onOpenChange, result, onRunDisasterRecovery }: Readonly) { + const formatDate = useFormatDate(); + const showDisasterRecovery = result?.hasDriftDetected === true && onRunDisasterRecovery !== undefined; + return ( + + + + + {result?.hasDriftDetected ? ( + + ) : ( + + )} + + + {result?.hasDriftDetected ? ( + Reconcile complete with drift detected + ) : ( + Reconcile complete + )} + + + {result === null ? ( + No result available. + ) : result.billingEventsAppended === 0 && !result.hasDriftDetected ? ( + No new billing events were appended. Account state matches Stripe. + ) : result.billingEventsAppended > 0 ? ( + + Appended {result.billingEventsAppended} new billing events. Last reconciled at{" "} + {formatDate(result.reconciledAt)}. + + ) : ( + + Account has {result.driftDiscrepancyCount} drift discrepancies. Last reconciled at{" "} + {formatDate(result.reconciledAt)}. If standard reconcile cannot clear the drift, disaster recovery from + archived Stripe events is available as a last resort. + + )} + + + + + Close + + {showDisasterRecovery && ( + + Run disaster recovery + + )} + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/ReplayArchivedDialogs.tsx b/application/account/BackOffice/routes/accounts/-components/ReplayArchivedDialogs.tsx new file mode 100644 index 0000000000..d729b73249 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/ReplayArchivedDialogs.tsx @@ -0,0 +1,120 @@ +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@repo/ui/components/AlertDialog"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { CheckCircle2Icon, ShieldAlertIcon } from "lucide-react"; + +export interface ArchivedAwaitingConfirmation { + count: number; + oldestOccurredAt: string; + newestOccurredAt: string; +} + +export interface ReplayArchivedResult { + billingEventsAppended: number; + replayedAt: string; +} + +interface ConfirmProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + archivedAwaiting: ArchivedAwaitingConfirmation | null; + onConfirm: () => void; + onSkip: () => void; +} + +export function ReplayArchivedConfirmDialog({ + isOpen, + onOpenChange, + archivedAwaiting, + onConfirm, + onSkip +}: Readonly) { + const formatDate = useFormatDate(); + return ( + + + + + + + + Disaster recovery from archived Stripe events? + + + {archivedAwaiting === null ? ( + + This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort + recovery that may produce incorrect subscription state or billing event rows. Only run it when standard + Reconcile with Stripe has been tried and did not clear the drift. + + ) : ( + + Reconcile found {archivedAwaiting.count} archived events older than Stripe's 30-day window, from{" "} + {formatDate(archivedAwaiting.oldestOccurredAt)} to {formatDate(archivedAwaiting.newestOccurredAt)}. This + is a best-effort recovery from locally stored payloads and may produce incorrect rows. Only run it when + standard Reconcile with Stripe has been tried and did not clear the drift. + + )} + + + + + Cancel + + + Run disaster recovery + + + + + ); +} + +interface ResultProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + result: ReplayArchivedResult | null; +} + +export function ReplayArchivedResultDialog({ isOpen, onOpenChange, result }: Readonly) { + const formatDate = useFormatDate(); + return ( + + + + + + + + Disaster recovery complete + + + {result === null ? ( + No result available. + ) : ( + + Replayed {result.billingEventsAppended} archived events into the billing event ledger at{" "} + {formatDate(result.replayedAt)}. + + )} + + + + onOpenChange(false)}> + Close + + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx new file mode 100644 index 0000000000..261d4e67a9 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx @@ -0,0 +1,20 @@ +export function SidePaneSection({ + label, + trailing, + children, + className +}: Readonly<{ label: string; trailing?: React.ReactNode; children: React.ReactNode; className?: string }>) { + return ( +
+
+ {label} + {trailing} +
+ {children} +
+ ); +} + +export function SidePaneDivider() { + return
; +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx new file mode 100644 index 0000000000..04e961f78c --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx @@ -0,0 +1,81 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { Link } from "@tanstack/react-router"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +export function SidePaneUserList({ + users, + isLoading, + emptyMessage +}: Readonly<{ + users: TenantUserSummary[]; + isLoading: boolean; + emptyMessage: string; +}>) { + if (isLoading) { + return ( +
+ +
+ ); + } + if (users.length === 0) { + return {emptyMessage}; + } + return ( +
+ {users.map((user) => ( + + ))} +
+ ); +} + +function UserRowSkeleton() { + return ( +
+ +
+ + + + + + +
+
+ ); +} + +function UserRow({ user }: Readonly<{ user: TenantUserSummary }>) { + const displayName = + user.firstName || user.lastName ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() : user.email; + + return ( + + + + + {getInitials(user.firstName ?? undefined, user.lastName ?? undefined, user.email)} + + +
+ {displayName} + {user.title && {user.title}} + + + {user.email} + +
+ + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx new file mode 100644 index 0000000000..f09d66f65e --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx @@ -0,0 +1,57 @@ +import { Trans } from "@lingui/react/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { components } from "@/shared/lib/api/client"; + +export function SidePaneUsersRow({ + detailReady, + userCounts, + isLoading +}: Readonly<{ + detailReady: boolean; + userCounts: components["schemas"]["TenantUserCountsResponse"] | undefined; + isLoading: boolean; +}>) { + if (!detailReady || isLoading) { + return ( +
+ + +
+ ); + } + if (!userCounts) { + return -; + } + const { totalUsers, activeUsers, pendingUsers } = userCounts; + const inactiveUsers = Math.max(0, totalUsers - activeUsers - pendingUsers); + + const activePercent = totalUsers === 0 ? 0 : (activeUsers / totalUsers) * 100; + const inactivePercent = totalUsers === 0 ? 0 : (inactiveUsers / totalUsers) * 100; + const pendingPercent = totalUsers === 0 ? 0 : (pendingUsers / totalUsers) * 100; + + return ( +
+ + + {totalUsers} total + + {" · "} + {activeUsers} active + {" · "} + {inactiveUsers} inactive + {" · "} + {pendingUsers} pending + +
+ {activePercent > 0 &&
} + {inactivePercent > 0 && ( +
+ )} + {pendingPercent > 0 && ( +
+ )} +
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx b/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx new file mode 100644 index 0000000000..494545dbcc --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx @@ -0,0 +1,40 @@ +import { TableHead } from "@repo/ui/components/Table"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import type { SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +export function SortableTableHead({ + column, + orderBy, + sortOrder, + onSort, + className, + children +}: Readonly<{ + column: SortableTenantProperties; + orderBy: SortableTenantProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableTenantProperties) => void; + className?: string; + children: React.ReactNode; +}>) { + const isActive = orderBy === column; + // Backend default is Descending and the URL stores Descending as undefined; treat undefined as + // Descending so the chevron renders correctly when the active column is in its default state. + const isDescending = isActive && (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const ariaSort = isActive ? (isDescending ? "descending" : "ascending") : "none"; + + return ( + onSort(column)}> + {children} + {isActive && + (isDescending ? ( + + ) : ( + + ))} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx b/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx new file mode 100644 index 0000000000..6b26f51f81 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx @@ -0,0 +1,21 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { XCircleIcon } from "lucide-react"; + +import { TenantState } from "@/shared/lib/api/client"; + +export function SubscriptionStatusIndicator({ + state +}: Readonly<{ + state: TenantState | undefined; +}>) { + if (state === TenantState.Suspended) { + return ( + + + Suspended + + ); + } + return null; +} diff --git a/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx b/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx new file mode 100644 index 0000000000..bdf89366e0 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx @@ -0,0 +1,51 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { CalendarClockIcon, CheckCircle2Icon, MinusCircleIcon, XCircleIcon } from "lucide-react"; + +import { PlannedSubscriptionChange, SubscriptionPlan } from "@/shared/lib/api/client"; + +interface TenantStatusBadgeProps { + plan: SubscriptionPlan; + plannedChange: PlannedSubscriptionChange | null | undefined; + hasEverSubscribed: boolean; +} + +export function TenantStatusBadge({ plan, plannedChange, hasEverSubscribed }: Readonly) { + if (plannedChange === PlannedSubscriptionChange.Cancellation) { + return ( + + + Canceling + + ); + } + if (plannedChange === PlannedSubscriptionChange.ScheduledPlanChange) { + return ( + + + Downgrading + + ); + } + if (plan !== SubscriptionPlan.Basis) { + return ( + + + Active + + ); + } + if (hasEverSubscribed) { + return ( + + + Canceled + + ); + } + return ( + + Free + + ); +} diff --git a/application/account/BackOffice/routes/accounts/index.tsx b/application/account/BackOffice/routes/accounts/index.tsx new file mode 100644 index 0000000000..a9fe680fb3 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/index.tsx @@ -0,0 +1,163 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Building2Icon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { z } from "zod"; + +import type { components } from "@/shared/lib/api/client"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { + api, + SortableTenantProperties, + SortOrder, + SubscriptionPlan, + TenantStatusFilter +} from "@/shared/lib/api/client"; + +import { AccountSidePane } from "./-components/AccountSidePane"; +import { AccountsTable } from "./-components/AccountsTable"; +import { AccountsToolbar } from "./-components/AccountsToolbar"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +const accountsSearchSchema = z.object({ + search: z.string().optional(), + plans: z.array(z.nativeEnum(SubscriptionPlan)).max(10).optional(), + statuses: z.array(z.nativeEnum(TenantStatusFilter)).max(10).optional(), + unsynced: z.boolean().optional(), + driftDetected: z.boolean().optional(), + orderBy: z.nativeEnum(SortableTenantProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/accounts/")({ + staticData: { trackingTitle: "Accounts" }, + validateSearch: accountsSearchSchema, + component: AccountsListPage +}); + +function AccountsListPage() { + const { search, plans, statuses, unsynced, driftDetected, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const navigate = useNavigate(); + const [previewTenant, setPreviewTenant] = useState(null); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants", + { + params: { + query: { + Search: search, + Plans: plans, + Statuses: statuses, + Unsynced: unsynced, + DriftDetected: driftDetected, + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const handleSelectTenant = useCallback((tenant: TenantSummary | null) => { + setPreviewTenant(tenant); + }, []); + + const handleClosePane = useCallback(() => setPreviewTenant(null), []); + + const tenants = data?.tenants ?? []; + const hasFilters = + Boolean(search) || + (plans?.length ?? 0) > 0 || + (statuses?.length ?? 0) > 0 || + Boolean(unsynced) || + Boolean(driftDetected); + const showEmpty = !isLoading && tenants.length === 0; + + return ( + + + + + ) : undefined + } + > + + + {showEmpty ? ( + + + + + + + {hasFilters ? No accounts match your filters : No accounts yet} + + + {hasFilters ? ( + Try clearing the search or filters to see more results. + ) : ( + Accounts will appear here as they are created. + )} + + + {hasFilters && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx new file mode 100644 index 0000000000..15d8108dc9 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx @@ -0,0 +1,129 @@ +import { t } from "@lingui/core/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import type { components, SortableBillingEventProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +import type { BillingEventsView } from "./BillingEventsToolbar"; + +import { BillingEventsTableColumnHeaders } from "./BillingEventsTableColumnHeaders"; +import { BillingEventsTableRow } from "./BillingEventsTableRow"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +interface BillingEventsTableProps { + billingEvents: BillingEventSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; +} + +export function BillingEventsTable({ + billingEvents, + isLoading, + totalPages, + currentPageOffset, + orderBy, + sortOrder +}: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + view: previous.view as BillingEventsView | undefined, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableBillingEventProperties) => { + // Backend default is Descending, so the URL stores Descending as undefined. + // Treat both undefined and explicit Descending as the descending state when computing the next direction. + const isCurrent = orderBy === column; + const isCurrentlyDescending = (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const nextOrder = isCurrent && isCurrentlyDescending ? SortOrder.Ascending : SortOrder.Descending; + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + view: previous.view as BillingEventsView | undefined, + orderBy: column, + sortOrder: nextOrder === SortOrder.Descending ? undefined : nextOrder, + pageOffset: undefined + }) + }); + }, + [navigate, orderBy, sortOrder] + ); + + const handleRowClick = useCallback( + (tenantId: string) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); + }, + [navigate] + ); + + if (isLoading && billingEvents.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + {billingEvents.map((event) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx new file mode 100644 index 0000000000..6b31a21122 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx @@ -0,0 +1,96 @@ +import { Trans } from "@lingui/react/macro"; +import { TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { SortableBillingEventProperties, SortOrder } from "@/shared/lib/api/client"; + +interface BillingEventsTableColumnHeadersProps { + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBillingEventProperties) => void; +} + +// Local SortableTableHead so the column reuses the same sort-affordance as Accounts without +// coupling /billing-events to /accounts internal components. +function SortableHead({ + column, + orderBy, + sortOrder, + onSort, + className, + children +}: Readonly<{ + column: SortableBillingEventProperties; + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBillingEventProperties) => void; + className?: string; + children: React.ReactNode; +}>) { + const isActive = orderBy === column; + // Backend default is Descending, stored as undefined in the URL — treat undefined as Descending here so the + // chevron renders correctly when the active column is in its default descending state. + const isDescending = isActive && (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const ariaSort = isActive ? (isDescending ? "descending" : "ascending") : "none"; + + return ( + onSort(column)}> + {children} + {isActive && + (isDescending ? ( + + ) : ( + + ))} + + ); +} + +export function BillingEventsTableColumnHeaders({ + orderBy, + sortOrder, + onSort +}: Readonly) { + return ( + + + + Account + + + Event + + + Plan transition + + + Amount + + + Country + + + Occurred + + + + ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx new file mode 100644 index 0000000000..01ebe8892a --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx @@ -0,0 +1,88 @@ +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { PLAN_TRANSITION_EVENT_TYPES } from "@/shared/lib/billingEventCategories"; +import { getDisplayedPlanTransition } from "@/shared/lib/billingEventPlanTransition"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +export function BillingEventsTableRow({ + event, + formatDate, + onRowClick +}: Readonly<{ + event: BillingEventSummary; + formatDate: (value: string | null | undefined, includeTime?: boolean, omitCurrentYear?: boolean) => string; + onRowClick: (tenantId: string) => void; +}>) { + const variant = BILLING_EVENT_VARIANT[event.eventType]; + const Icon = variant.icon; + const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; + + return ( + onRowClick(String(event.tenantId))} className="cursor-pointer"> + +
+ + {event.tenantName} +
+
+ + + + {getBillingEventTypeLabel(event.eventType)} + + + + {PLAN_TRANSITION_EVENT_TYPES.has(event.eventType) ? renderPlanTransition(event) : null} + + + {event.amountDelta != null && event.currency ? ( + formatCurrency(event.amountDelta, event.currency) + ) : ( + + )} + + + {event.country ? ( + + {getCountryFlagEmoji(event.country)} + {event.country} + + ) : ( + + )} + + +
+ + {formatDate(event.occurredAt, true, true)} +
+
+
+ ); +} + +function renderPlanTransition(event: BillingEventSummary) { + const transition = getDisplayedPlanTransition(event.eventType, event.fromPlan, event.toPlan); + if (transition == null) return null; + return ( + + {getSubscriptionPlanLabel(transition.from)} + + → + + {getSubscriptionPlanLabel(transition.to)} + + ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx new file mode 100644 index 0000000000..5abf3fcf82 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { SortableBillingEventProperties } from "@/shared/lib/api/client"; + +// The TanStack Router cross-route inference widens `previous.view` to the union of every route's +// view literals (the /invoices toggle's "all" | "invoices" | "refunds" leaks in here). Cast at +// every write boundary so each handler keeps its own narrowed view set. +export type BillingEventsView = "all" | "mrr" | "state" | "other"; + +interface BillingEventsToolbarProps { + search: string | undefined; + view: BillingEventsView; +} + +export function BillingEventsToolbar({ search, view }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/billing-events", + search: (previous) => ({ + view: previous.view as BillingEventsView | undefined, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + search: debouncedSearch || undefined, + pageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleViewChange = (values: string[]) => { + // ToggleGroup multi-select returns an array; the page treats this as single-select (one pill at a + // time) so we collapse to the first value, falling back to the default "all" view if the user + // somehow deselected everything. + const next = (values[0] as BillingEventsView | undefined) ?? "all"; + if (next === view) { + return; + } + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + // "all" is the default — keep it out of the URL so the most common path stays clean. + view: next === "all" ? undefined : next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + + All + + + MRR impact + + + Subscription state + + + Other + + +
+ ); +} diff --git a/application/account/BackOffice/routes/billing-events/index.tsx b/application/account/BackOffice/routes/billing-events/index.tsx new file mode 100644 index 0000000000..4357e1c842 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/index.tsx @@ -0,0 +1,146 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { requireSubscriptionEnabled } from "@repo/infrastructure/auth/routeGuards"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ZapIcon } from "lucide-react"; +import { z } from "zod"; + +import type { BillingEventType } from "@/shared/lib/api/client"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api, SortableBillingEventProperties, SortOrder } from "@/shared/lib/api/client"; +import { + MRR_IMPACT_EVENT_TYPES, + OTHER_EVENT_TYPES, + SUBSCRIPTION_STATE_EVENT_TYPES +} from "@/shared/lib/billingEventCategories"; + +import { BillingEventsTable } from "./-components/BillingEventsTable"; +import { BillingEventsToolbar, type BillingEventsView } from "./-components/BillingEventsToolbar"; + +// Drives the URL-controlled view pill. The backend filter accepts an open-ended EventTypes[] array; +// the pill maps the chosen view onto a fixed event-type set so operators don't have to think about +// which low-level types make up each business concept. +const EVENT_TYPES_FOR_VIEW: Record = { + all: undefined, + mrr: [...MRR_IMPACT_EVENT_TYPES], + state: [...SUBSCRIPTION_STATE_EVENT_TYPES], + other: [...OTHER_EVENT_TYPES] +}; + +const billingEventsSearchSchema = z.object({ + search: z.string().optional(), + view: z.enum(["all", "mrr", "state", "other"]).optional(), + orderBy: z.nativeEnum(SortableBillingEventProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/billing-events/")({ + staticData: { trackingTitle: "Billing events" }, + validateSearch: billingEventsSearchSchema, + beforeLoad: () => requireSubscriptionEnabled(), + component: BillingEventsListPage +}); + +function BillingEventsListPage() { + const { search, view, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const navigate = useNavigate(); + const activeView: BillingEventsView = view ?? "all"; + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/billing-events", + { + params: { + query: { + Search: search, + EventTypes: EVENT_TYPES_FOR_VIEW[activeView], + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const billingEvents = data?.billingEvents ?? []; + const hasFilters = Boolean(search) || activeView !== "all"; + const showEmpty = !isLoading && billingEvents.length === 0; + + return ( + + + + + + + {showEmpty ? ( + + + + + + + {hasFilters ? ( + No billing events match your filters + ) : ( + No billing events yet + )} + + + {hasFilters ? ( + Try clearing the search or filters to see more results. + ) : ( + + Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed. + + )} + + + {hasFilters && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/index.tsx b/application/account/BackOffice/routes/index.tsx index 22876395bb..2d620d4133 100644 --- a/application/account/BackOffice/routes/index.tsx +++ b/application/account/BackOffice/routes/index.tsx @@ -5,8 +5,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { DashboardSections } from "./-components/DashboardSections"; + export const Route = createFileRoute("/")({ - staticData: { trackingTitle: "Back office dashboard" }, + staticData: { trackingTitle: "Back Office dashboard" }, component: DashboardPage }); @@ -15,12 +17,8 @@ function DashboardPage() { - - {null} + + diff --git a/application/account/BackOffice/routes/invoices/-components/InvoicesTable.tsx b/application/account/BackOffice/routes/invoices/-components/InvoicesTable.tsx new file mode 100644 index 0000000000..bd07ac0aaf --- /dev/null +++ b/application/account/BackOffice/routes/invoices/-components/InvoicesTable.tsx @@ -0,0 +1,131 @@ +import { t } from "@lingui/core/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import type { components, SortableBackOfficeInvoiceProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +import type { InvoicesView } from "./InvoicesToolbar"; + +import { InvoicesTableColumnHeaders } from "./InvoicesTableColumnHeaders"; +import { InvoicesTableRow } from "./InvoicesTableRow"; + +type Invoice = components["schemas"]["BackOfficeInvoiceSummary"]; + +interface InvoicesTableProps { + invoices: Invoice[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; + orderBy: SortableBackOfficeInvoiceProperties | undefined; + sortOrder: SortOrder | undefined; +} + +export function InvoicesTable({ + invoices, + isLoading, + totalPages, + currentPageOffset, + orderBy, + sortOrder +}: Readonly) { + const navigate = useNavigate(); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/invoices", + search: (previous) => ({ + search: previous.search, + view: previous.view as InvoicesView | undefined, + orderBy: previous.orderBy as SortableBackOfficeInvoiceProperties | undefined, + sortOrder: previous.sortOrder, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableBackOfficeInvoiceProperties) => { + // Backend default is Descending, so the URL stores Descending as undefined; treat both undefined and + // explicit Descending as the descending state when computing the next direction. + const isCurrent = orderBy === column; + const isCurrentlyDescending = (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const nextOrder = isCurrent && isCurrentlyDescending ? SortOrder.Ascending : SortOrder.Descending; + navigate({ + to: "/invoices", + search: (previous) => ({ + search: previous.search, + view: previous.view as InvoicesView | undefined, + orderBy: column, + sortOrder: nextOrder === SortOrder.Descending ? undefined : nextOrder, + pageOffset: undefined + }) + }); + }, + [navigate, orderBy, sortOrder] + ); + + const handleRowClick = useCallback( + (tenantId: string) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "invoices" } }); + }, + [navigate] + ); + + if (isLoading && invoices.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + {invoices.map((invoice) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/invoices/-components/InvoicesTableColumnHeaders.tsx b/application/account/BackOffice/routes/invoices/-components/InvoicesTableColumnHeaders.tsx new file mode 100644 index 0000000000..804b7600ed --- /dev/null +++ b/application/account/BackOffice/routes/invoices/-components/InvoicesTableColumnHeaders.tsx @@ -0,0 +1,99 @@ +import { Trans } from "@lingui/react/macro"; +import { TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { SortableBackOfficeInvoiceProperties, SortOrder } from "@/shared/lib/api/client"; + +interface InvoicesTableColumnHeadersProps { + orderBy: SortableBackOfficeInvoiceProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBackOfficeInvoiceProperties) => void; +} + +function SortableHead({ + column, + orderBy, + sortOrder, + onSort, + className, + children +}: Readonly<{ + column: SortableBackOfficeInvoiceProperties; + orderBy: SortableBackOfficeInvoiceProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBackOfficeInvoiceProperties) => void; + className?: string; + children: React.ReactNode; +}>) { + const isActive = orderBy === column; + // Backend default is Descending and the URL stores Descending as undefined; treat undefined as + // Descending here so the chevron renders correctly when the active column is in its default state. + const isDescending = isActive && (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const ariaSort = isActive ? (isDescending ? "descending" : "ascending") : "none"; + + return ( + onSort(column)}> + {children} + {isActive && + (isDescending ? ( + + ) : ( + + ))} + + ); +} + +export function InvoicesTableColumnHeaders({ orderBy, sortOrder, onSort }: Readonly) { + return ( + + + + Account + + + Date + + + Plan + + + Amount + + + VAT + + + Total + + + Status + + + + ); +} diff --git a/application/account/BackOffice/routes/invoices/-components/InvoicesTableRow.tsx b/application/account/BackOffice/routes/invoices/-components/InvoicesTableRow.tsx new file mode 100644 index 0000000000..90cd289354 --- /dev/null +++ b/application/account/BackOffice/routes/invoices/-components/InvoicesTableRow.tsx @@ -0,0 +1,118 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { BackOfficeInvoiceRowKind, PaymentTransactionStatus } from "@/shared/lib/api/client"; +import { getPaymentStatusLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +type Invoice = components["schemas"]["BackOfficeInvoiceSummary"]; + +export function InvoicesTableRow({ + invoice, + onRowClick +}: Readonly<{ + invoice: Invoice; + onRowClick: (tenantId: string) => void; +}>) { + const formatDate = useFormatDate(); + // Strikethrough only on reversal rows (CreditNote, Refund). The Invoice row always carries the + // original payment outcome and never gets strikethrough — the reversal lives in its own row. + const isReversal = + invoice.rowKind === BackOfficeInvoiceRowKind.CreditNote || invoice.rowKind === BackOfficeInvoiceRowKind.Refund; + const reverseClass = isReversal ? "text-muted-foreground line-through" : ""; + + return ( + onRowClick(String(invoice.tenantId))} + className="cursor-pointer" + > + +
+ + {invoice.tenantName} +
+
+ +
+ + {formatDate(invoice.date, true, true)} +
+
+ + {invoice.plan != null ? ( + {getSubscriptionPlanLabel(invoice.plan)} + ) : ( + + )} + + + {formatCurrency(invoice.amountExcludingTax, invoice.currency)} + + + {formatCurrency(invoice.taxAmount, invoice.currency)} + + + {formatCurrency(invoice.amount, invoice.currency)} + + + + +
+ ); +} + +function RowKindBadge({ + rowKind, + status, + failureReason +}: Readonly<{ rowKind: BackOfficeInvoiceRowKind; status: PaymentTransactionStatus; failureReason: string | null }>) { + if (rowKind === BackOfficeInvoiceRowKind.CreditNote) { + return ( + + Credit note + + ); + } + + if (rowKind === BackOfficeInvoiceRowKind.Refund) { + return ( + + Refunded + + ); + } + + const variant = status === PaymentTransactionStatus.Failed ? "outline" : "secondary"; + const className = + status === PaymentTransactionStatus.Failed + ? "border-destructive/30 text-destructive" + : status === PaymentTransactionStatus.Succeeded + ? "bg-success text-success-foreground" + : undefined; + + const badge = ( + + {getPaymentStatusLabel(status)} + + ); + + if (status === PaymentTransactionStatus.Failed && failureReason) { + return ( +
+ {badge} + {failureReason} +
+ ); + } + + return badge; +} diff --git a/application/account/BackOffice/routes/invoices/-components/InvoicesToolbar.tsx b/application/account/BackOffice/routes/invoices/-components/InvoicesToolbar.tsx new file mode 100644 index 0000000000..886793350e --- /dev/null +++ b/application/account/BackOffice/routes/invoices/-components/InvoicesToolbar.tsx @@ -0,0 +1,104 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { SortableBackOfficeInvoiceProperties } from "@/shared/lib/api/client"; + +export type InvoicesView = "all" | "invoices" | "refunds"; + +interface InvoicesToolbarProps { + search: string | undefined; + view: InvoicesView; +} + +export function InvoicesToolbar({ search, view }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/invoices", + search: (previous) => ({ + view: previous.view as InvoicesView | undefined, + orderBy: previous.orderBy as SortableBackOfficeInvoiceProperties | undefined, + sortOrder: previous.sortOrder, + search: debouncedSearch || undefined, + pageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleViewChange = (values: string[]) => { + // ToggleGroup multi-select returns an array; the page treats this as single-select (one pill at a + // time) so we collapse to the first value, falling back to the default "all" view if the user + // somehow deselected everything. + const next = (values[0] as InvoicesView | undefined) ?? "all"; + if (next === view) { + return; + } + navigate({ + to: "/invoices", + search: (previous) => ({ + search: previous.search, + orderBy: previous.orderBy as SortableBackOfficeInvoiceProperties | undefined, + sortOrder: previous.sortOrder, + // "all" is the default — keep it out of the URL so the most common path stays clean. + view: next === "all" ? undefined : next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + + All + + + Invoices + + + Refunds and credit notes + + +
+ ); +} diff --git a/application/account/BackOffice/routes/invoices/index.tsx b/application/account/BackOffice/routes/invoices/index.tsx new file mode 100644 index 0000000000..50277a7d34 --- /dev/null +++ b/application/account/BackOffice/routes/invoices/index.tsx @@ -0,0 +1,150 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { requireSubscriptionEnabled } from "@repo/infrastructure/auth/routeGuards"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ReceiptIcon } from "lucide-react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { + api, + BackOfficeInvoiceStatusFilter, + SortableBackOfficeInvoiceProperties, + SortOrder +} from "@/shared/lib/api/client"; + +import { InvoicesTable } from "./-components/InvoicesTable"; +import { InvoicesToolbar, type InvoicesView } from "./-components/InvoicesToolbar"; + +// Drives the URL-controlled split between all invoices, paid invoices, and refunds + credit notes. +// The backend filter accepts an open-ended Statuses[] array; the toggle maps the chosen view onto a +// fixed status set so operators don't have to think about which low-level statuses make up each +// business concept. "all" sends no status filter so every row is included. +const STATUSES_FOR_VIEW: Record = { + all: undefined, + invoices: [ + BackOfficeInvoiceStatusFilter.Paid, + BackOfficeInvoiceStatusFilter.Pending, + BackOfficeInvoiceStatusFilter.Failed + ], + refunds: [BackOfficeInvoiceStatusFilter.Refunded, BackOfficeInvoiceStatusFilter.HasCreditNote] +}; + +const invoicesSearchSchema = z.object({ + search: z.string().optional(), + view: z.enum(["all", "invoices", "refunds"]).optional(), + orderBy: z.nativeEnum(SortableBackOfficeInvoiceProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/invoices/")({ + staticData: { trackingTitle: "Invoices" }, + validateSearch: invoicesSearchSchema, + beforeLoad: () => requireSubscriptionEnabled(), + component: InvoicesListPage +}); + +function InvoicesListPage() { + const { search, view, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const navigate = useNavigate(); + const activeView: InvoicesView = view ?? "all"; + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/invoices", + { + params: { + query: { + Search: search, + Statuses: STATUSES_FOR_VIEW[activeView], + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const invoices = data?.invoices ?? []; + const hasSearch = Boolean(search); + const showEmpty = !isLoading && invoices.length === 0; + const subtitle = + activeView === "refunds" + ? t`Refunds and credit notes across all accounts.` + : activeView === "invoices" + ? t`Successful, pending, and failed invoices across all accounts.` + : t`Every invoice, refund, and credit note across all accounts.`; + + return ( + + + + + + + {showEmpty ? ( + + + + + + + {hasSearch ? ( + No results match your search + ) : activeView === "refunds" ? ( + No refunds or credit notes yet + ) : activeView === "invoices" ? ( + No invoices yet + ) : ( + No records yet + )} + + + {hasSearch ? ( + Try clearing the search to see more results. + ) : ( + Records will appear here as accounts subscribe and Stripe webhooks are processed. + )} + + + {hasSearch && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/login.tsx b/application/account/BackOffice/routes/login.tsx index 1d7a082b76..f42a55b39e 100644 --- a/application/account/BackOffice/routes/login.tsx +++ b/application/account/BackOffice/routes/login.tsx @@ -52,7 +52,7 @@ function MockLoginPage() { {t`Logo`}

- BackOffice - Localhost + Back Office - Localhost

diff --git a/application/account/BackOffice/routes/users/$userId.tsx b/application/account/BackOffice/routes/users/$userId.tsx new file mode 100644 index 0000000000..5c5ae99c8e --- /dev/null +++ b/application/account/BackOffice/routes/users/$userId.tsx @@ -0,0 +1,96 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Building2Icon, KeyIcon, MonitorIcon } from "lucide-react"; +import { useCallback } from "react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import { UserActivityTiles } from "./-components/UserActivityTiles"; +import { UserDetailHeader } from "./-components/UserDetailHeader"; +import { getUserDisplayName } from "./-components/userDisplay"; +import { UserLoginHistorySection } from "./-components/UserLoginHistorySection"; +import { UserSessionsSection } from "./-components/UserSessionsSection"; +import { UserTenantsSection } from "./-components/UserTenantsSection"; + +type UserDetailTab = "overview" | "sessions" | "logins"; + +const userDetailSearchSchema = z.object({ + tab: z.enum(["overview", "sessions", "logins"]).optional() +}); + +export const Route = createFileRoute("/users/$userId")({ + staticData: { trackingTitle: "User detail" }, + validateSearch: userDetailSearchSchema, + component: UserDetailPage +}); + +function UserDetailPage() { + const { userId } = Route.useParams(); + const { tab } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const activeTab = tab ?? "overview"; + + const setActiveTab = useCallback( + (value: string) => { + const next = value as UserDetailTab; + navigate({ + search: { tab: next === "overview" ? undefined : next }, + replace: true + }); + }, + [navigate] + ); + + const userQuery = api.useQuery("get", "/api/back-office/users/{id}", { + params: { path: { id: userId } } + }); + + const user = userQuery.data; + + const browserTitle = user ? getUserDisplayName(user.firstName, user.lastName, user.email) : t`User detail`; + + return ( + + + + +
+ + + + + + + Accounts + + + + Logins + + + + Sessions + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx new file mode 100644 index 0000000000..cd43cf99c2 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from "react"; + +import { plural, t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Card } from "@repo/ui/components/Card"; +import { LinkCard } from "@repo/ui/components/LinkCard"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; + +interface UserActivityTilesProps { + user: BackOfficeUserDetailResponse | undefined; + userId: string; + isLoading: boolean; +} + +export function UserActivityTiles({ user, userId, isLoading }: Readonly) { + const sessionsQuery = api.useQuery("get", "/api/back-office/users/{id}/sessions", { + params: { path: { id: userId } } + }); + const sessionsLoading = sessionsQuery.isLoading; + const totalSessions = sessionsQuery.data?.totalCount; + const tenantCount = user?.tenantMemberships.length ?? 0; + + return ( +
+ + {user ? tenantCount : "-"} + + + Most recent activity : Never logged in} + linkTo={user?.lastSeenAt ? "logins" : undefined} + userId={userId} + > + + {user?.lastSeenAt ? : "-"} + + + + All-time : undefined} + linkTo={totalSessions !== undefined && totalSessions > 0 ? "sessions" : undefined} + userId={userId} + > + {totalSessions !== undefined ? totalSessions : "-"} + +
+ ); +} + +function ActivityTile({ + label, + loading, + subtitle, + children, + linkTo, + userId +}: Readonly<{ + label: string; + loading: boolean; + subtitle?: ReactNode; + children: ReactNode; + linkTo?: "overview" | "logins" | "sessions"; + userId?: string; +}>) { + const content = ( + <> + {label} + {loading ? ( + <> + + + + ) : ( + <> + {children} + {subtitle && {subtitle}} + + )} + + ); + + if (linkTo && userId) { + return ( + + {content} + + ); + } + + return {content}; +} diff --git a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx new file mode 100644 index 0000000000..cae0d26de6 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { CalendarIcon, CheckCircle2Icon, HashIcon, MailIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { getUserDisplayName, getUserInitials } from "./userDisplay"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; + +interface UserDetailHeaderProps { + user: BackOfficeUserDetailResponse | undefined; + userId: string; + isLoading: boolean; +} + +export function UserDetailHeader({ user, userId, isLoading }: Readonly) { + const formatDate = useFormatDate(); + + return ( +
+ {isLoading || !user ? ( + <> + +
+ + +
+ + ) : ( + <> + + {user.avatarUrl && ( + + )} + + {getUserInitials(user.firstName, user.lastName, user.email)} + + +
+
+

+ {getUserDisplayName(user.firstName, user.lastName, user.email)} +

+ {user.emailConfirmed ? ( + + + + Email confirmed + + + ) : ( + + + + Email pending + + + )} +
+
+ + + {user.email} + + + + + Created {formatDate(user.createdAt, false, false, true)} + {formatDate(user.createdAt)} + + + + + {userId} + +
+
+ + )} +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx new file mode 100644 index 0000000000..9916bacb51 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx @@ -0,0 +1,105 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api, LoginEventOutcome } from "@/shared/lib/api/client"; +import { getLoginMethodLabel } from "@/shared/lib/api/labels"; + +interface UserLoginHistorySectionProps { + userId: string; +} + +export function UserLoginHistorySection({ userId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/users/{id}/login-history", { + params: { path: { id: userId } } + }); + + return ( +
+
+ + Every sign-in attempt over the last 30 days, successful or failed, across email and external providers. + +
+ {isLoading ? ( + + ) : !data || data.entries.length === 0 ? ( + + + + No login history + + + No sign-in attempts in the last 30 days. + + + + ) : ( + + + + + When + + + Method + + + Outcome + + + + + {data.entries.map((entry, index) => ( + + + + + {getLoginMethodLabel(entry.method)} + + + + + ))} + +
+ )} +
+ ); +} + +function OutcomeBadge({ + outcome, + failureReason +}: Readonly<{ outcome: LoginEventOutcome; failureReason: string | null }>) { + if (outcome === LoginEventOutcome.Succeeded) { + return ( + + Succeeded + + ); + } + if (failureReason) { + return ( + + {failureReason} + + ); + } + if (outcome === LoginEventOutcome.Pending) { + return ( + + Pending + + ); + } + return ( + + Failed + + ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx new file mode 100644 index 0000000000..93f22e1d49 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; +import { getDeviceTypeLabel, getLoginMethodLabel } from "@/shared/lib/api/labels"; +import { parseUserAgent } from "@/shared/lib/userAgent"; + +interface UserSessionsSectionProps { + userId: string; +} + +export function UserSessionsSection({ userId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/users/{id}/sessions", { + params: { path: { id: userId } } + }); + + return ( +
+
+ One row per device or browser the user is signed in from. Revoked sessions cannot sign in again. +
+ {isLoading ? ( + + ) : !data || data.sessions.length === 0 ? ( + + + + No sessions + + + This user has no recorded sessions. + + + + ) : ( + + + + + Last seen + + + Account + + + Browser + + + Device + + + Method + + + IP address + + + Status + + + + + {data.sessions.map((session) => { + const { browser, os } = parseUserAgent(session.userAgent); + return ( + + + + + +
+ + {session.tenantName} +
+
+ +
+ {browser} + {os} +
+
+ {getDeviceTypeLabel(session.deviceType)} + {getLoginMethodLabel(session.loginMethod)} + {session.ipAddress} + + {session.revokedAt ? ( + + Revoked + + ) : ( + + Active + + )} + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx new file mode 100644 index 0000000000..01f53e4055 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx @@ -0,0 +1,161 @@ +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Card } from "@repo/ui/components/Card"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link } from "@tanstack/react-router"; +import { CalendarIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { TenantStatusBadge } from "@/routes/accounts/-components/TenantStatusBadge"; +import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel, getUserRoleLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; +type Membership = BackOfficeUserDetailResponse["tenantMemberships"][number]; + +interface UserTenantsSectionProps { + user: BackOfficeUserDetailResponse | undefined; +} + +export function UserTenantsSection({ user }: Readonly) { + return ( +
+
+ All accounts this user is a member of, with their plan and role. +
+ {!user ? ( + + ) : user.tenantMemberships.length === 0 ? ( + + + + No account memberships + + + This user is not a member of any account. + + + + ) : ( +
+ {user.tenantMemberships.map((membership) => ( + + ))} +
+ )} +
+ ); +} + +function MembershipCard({ membership }: Readonly<{ membership: Membership }>) { + const { i18n } = useLingui(); + const formatDate = useFormatDate(); + const isCanceling = membership.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = membership.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const currentMrr = + membership.monthlyRecurringRevenue !== null && membership.currency !== null + ? formatCurrency(membership.monthlyRecurringRevenue, membership.currency) + : null; + const newMrr = + isCanceling && membership.currency !== null + ? formatCurrency(0, membership.currency) + : isDowngrading && membership.scheduledPriceAmount !== null && membership.currency !== null + ? formatCurrency(membership.scheduledPriceAmount, membership.currency) + : null; + return ( + + +
+ +
+
+ {membership.tenantName} + {membership.country && ( + + + {getCountryName(membership.country, i18n.locale)} + + )} +
+
+ {getUserRoleLabel(membership.role)} + + + {getSubscriptionPlanLabel(membership.plan)} + + {!membership.emailConfirmed && ( + + Email pending + + )} +
+
+
+ {/* Narrow (mobile) layout: stacked with divider, plan + renews on left, prices on right */} +
+
+ + {getSubscriptionPlanLabel(membership.plan)} + + {membership.renewalDate && ( + + + Renews {formatDate(membership.renewalDate)} + + )} +
+
+
+ {newMrr && {currentMrr}} + {currentMrr && {newMrr ?? currentMrr}} +
+ {currentMrr && ( + + / month + + )} +
+
+ {/* Wide layout: prices + /month inline, renews below, all right-aligned next to the badges */} +
+ {currentMrr && ( +
+ {newMrr && {currentMrr}} + {newMrr ?? currentMrr} + + / month + +
+ )} + {membership.renewalDate && ( + + + Renews {formatDate(membership.renewalDate)} + + )} +
+ +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersTable.tsx b/application/account/BackOffice/routes/users/-components/UsersTable.tsx new file mode 100644 index 0000000000..24fd965240 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersTable.tsx @@ -0,0 +1,116 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { UsersTableRow } from "./UsersTableRow"; + +type BackOfficeUserSummary = components["schemas"]["BackOfficeUserSummary"]; + +interface UsersTableProps { + users: BackOfficeUserSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; +} + +export function UsersTable({ users, isLoading, totalPages, currentPageOffset }: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const handleActivate = useCallback( + (key: RowKey) => { + const user = users.find((entry) => entry.id === key); + if (!user) return; + navigate({ to: "/users/$userId", params: { userId: user.id } }); + }, + [navigate, users] + ); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + if (isLoading && users.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + + User + + + Account + + + Role + + + Last seen + + + Created + + + + + {users.map((user) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx new file mode 100644 index 0000000000..fa16115b06 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx @@ -0,0 +1,68 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +import { getUserDisplayName, getUserInitials } from "./userDisplay"; + +type BackOfficeUserSummary = components["schemas"]["BackOfficeUserSummary"]; + +export function UsersTableRow({ + user, + formatDate +}: Readonly<{ + user: BackOfficeUserSummary; + formatDate: (value: string | null | undefined, includeTime?: boolean, omitCurrentYear?: boolean) => string; +}>) { + const displayName = getUserDisplayName(user.firstName, user.lastName, user.email); + const initials = getUserInitials(user.firstName, user.lastName, user.email); + + return ( + + +
+ + {user.avatarUrl && } + {initials} + +
+ {displayName} + + + {user.email} + +
+
+
+ + {user.tenantName} + + + {getUserRoleLabel(user.role)} + + + {user.lastSeenAt ? ( +
+ + + {formatDate(user.lastSeenAt, true, true)} + +
+ ) : ( + - + )} +
+ +
+ + {formatDate(user.createdAt, true, true)} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx b/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx new file mode 100644 index 0000000000..5b93618e45 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx @@ -0,0 +1,125 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { UserActivityFilter, UserRole } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +interface UsersToolbarProps { + search: string | undefined; + roles: UserRole[]; + activity: UserActivityFilter | undefined; +} + +export function UsersToolbar({ search, roles, activity }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/users", + search: (previous) => ({ ...previous, search: debouncedSearch || undefined, pageOffset: undefined }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleRolesChange = (values: string[]) => { + const next = values as UserRole[]; + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + roles: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + // Activity is single-select on the backend; the toggle group exposes a multi-select array, so we keep only the + // newly toggled value (last item in the array) and clear when the user deselects. + const handleActivityChange = (values: string[]) => { + const next = values.length === 0 ? undefined : (values[values.length - 1] as UserActivityFilter); + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + activity: next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[UserRole.Owner, UserRole.Admin, UserRole.Member].map((value) => ( + + {getUserRoleLabel(value)} + + ))} + + + + + 24h + + + 7 days + + + 30 days + + + Inactive + + +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/userDisplay.ts b/application/account/BackOffice/routes/users/-components/userDisplay.ts new file mode 100644 index 0000000000..25b9e497ee --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/userDisplay.ts @@ -0,0 +1,15 @@ +export function getUserDisplayName(firstName: string | null, lastName: string | null, email: string): string { + if (firstName && lastName) return `${firstName} ${lastName}`; + if (firstName) return firstName; + if (lastName) return lastName; + return email; +} + +export function getUserInitials(firstName: string | null, lastName: string | null, email: string): string { + if (firstName && lastName) { + return `${firstName[0]}${lastName[0]}`.toUpperCase(); + } + if (firstName) return firstName.slice(0, 2).toUpperCase(); + if (lastName) return lastName.slice(0, 2).toUpperCase(); + return email.slice(0, 2).toUpperCase(); +} diff --git a/application/account/BackOffice/routes/users/index.tsx b/application/account/BackOffice/routes/users/index.tsx new file mode 100644 index 0000000000..e8875606db --- /dev/null +++ b/application/account/BackOffice/routes/users/index.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { UsersIcon } from "lucide-react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api, UserActivityFilter, UserRole } from "@/shared/lib/api/client"; + +import { UsersTable } from "./-components/UsersTable"; +import { UsersToolbar } from "./-components/UsersToolbar"; + +const usersSearchSchema = z.object({ + search: z.string().optional(), + roles: z.array(z.nativeEnum(UserRole)).max(10).optional(), + activity: z.nativeEnum(UserActivityFilter).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/users/")({ + staticData: { trackingTitle: "Users" }, + validateSearch: usersSearchSchema, + component: UsersSearchPage +}); + +function UsersSearchPage() { + const { search, roles, activity, pageOffset } = Route.useSearch(); + const trimmed = search?.trim() ?? ""; + const hasSearchOrFilter = trimmed.length > 0 || (roles?.length ?? 0) > 0 || activity !== undefined; + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/users", + { + params: { + query: { + Search: trimmed.length > 0 ? trimmed : undefined, + Roles: roles, + Activity: activity, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const users = data?.users ?? []; + const showNoResults = !isLoading && users.length === 0 && hasSearchOrFilter; + const showEmpty = !isLoading && users.length === 0 && !hasSearchOrFilter; + + return ( + + + + + + + {showNoResults ? ( + + + + + + + No users match your search + + + Try a different search term or clear the role and activity filters. + + + + ) : showEmpty ? ( + + + + + + + No users yet + + + Users will appear here as accounts are created. + + + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeBanners.tsx b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx new file mode 100644 index 0000000000..0269e91caf --- /dev/null +++ b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { BillingDriftBanner } from "./BillingDriftBanner"; +import { MrrMismatchBanner } from "./MrrMismatchBanner"; +import { UnsyncedAccountsBanner } from "./UnsyncedAccountsBanner"; + +/** + * Portals all back-office banners into the fixed-top BannerPortal target so they render above the + * sidebar and content rather than being clipped by the layout. The user-facing Banners federated + * module relies on a lazy boundary to defer mount until BannerPortal's DOM is committed; we render + * synchronously, so the target lookup runs in useEffect to avoid the first-render race. + */ +export function BackOfficeBanners() { + const [target, setTarget] = useState(null); + + useEffect(() => { + setTarget(document.getElementById("banner-root")); + }, []); + + if (!target) { + return null; + } + + return createPortal( + <> + + + + , + target + ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx index f615189672..4c7ceddbfc 100644 --- a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx @@ -13,15 +13,21 @@ import { SidebarRail } from "@repo/ui/components/Sidebar"; import { Link as RouterLink, useRouter } from "@tanstack/react-router"; -import { Building2Icon, FlagIcon, HomeIcon, LifeBuoyIcon, ListIcon, UsersIcon } from "lucide-react"; +import { Building2Icon, HomeIcon, ReceiptIcon, UsersIcon, ZapIcon } from "lucide-react"; import { BackOfficeAvatarMenu } from "./BackOfficeAvatarMenu"; const normalizePath = (path: string): string => path.replace(/\/$/, "") || "/"; +const isSubscriptionEnabled = import.meta.runtime_env.PUBLIC_SUBSCRIPTION_ENABLED === "true"; + export function BackOfficeSideMenu() { const router = useRouter(); const currentPath = normalizePath(router.state.location.pathname); + const isAccountsActive = currentPath === "/accounts" || currentPath.startsWith("/accounts/"); + const isUsersActive = currentPath === "/users" || currentPath.startsWith("/users/"); + const isBillingEventsActive = currentPath === "/billing-events" || currentPath.startsWith("/billing-events/"); + const isInvoicesActive = currentPath === "/invoices" || currentPath.startsWith("/invoices/"); return ( @@ -46,58 +52,60 @@ export function BackOfficeSideMenu() { - - - - - - Coming soon - - - - - - - - Accounts - - - - - - - Users - - - - - - - - Feature flags - - - - - - - - Support - + + + + + Accounts + + - - - - Wait list - + + + + + Users + + + {isSubscriptionEnabled && ( + + + Billing + + + + + + + + + Invoices + + + + + + + + + + Billing events + + + + + + + + )} diff --git a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx new file mode 100644 index 0000000000..a1357cb0ac --- /dev/null +++ b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx @@ -0,0 +1,47 @@ +import { plural } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { Button } from "@repo/ui/components/Button"; +import { Link } from "@tanstack/react-router"; +import { AlertTriangleIcon } from "lucide-react"; + +import { api } from "@/shared/lib/api/client"; + +/** + * Global banner that surfaces accounts with detected billing drift. Renders only when at least one + * subscription has unacknowledged drift, so the banner is invisible in a healthy system. Click-through + * navigates to /accounts. + */ +export function BillingDriftBanner() { + const userInfo = useUserInfo(); + const { data } = api.useQuery( + "get", + "/api/back-office/billing-drift/summary", + {}, + { enabled: userInfo?.isAuthenticated === true, refetchInterval: 60_000 } + ); + + const count = data?.subscriptionsWithDriftCount ?? 0; + if (count === 0) { + return null; + } + + return ( +
+ + + {plural(count, { + one: "# account has billing drift detected.", + other: "# accounts have billing drift detected." + })} + + +
+ ); +} diff --git a/application/account/BackOffice/shared/components/MrrMismatchBanner.tsx b/application/account/BackOffice/shared/components/MrrMismatchBanner.tsx new file mode 100644 index 0000000000..f608835df5 --- /dev/null +++ b/application/account/BackOffice/shared/components/MrrMismatchBanner.tsx @@ -0,0 +1,48 @@ +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { Button } from "@repo/ui/components/Button"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link } from "@tanstack/react-router"; +import { ScaleIcon } from "lucide-react"; + +import { api } from "@/shared/lib/api/client"; + +/** + * Global banner that fires when the dashboard's KPI MRR (forward MRR from subscriptions) and the + * trend-latest MRR (sum of latest BillingEvent NewAmount per subscription) disagree. They should + * match in a healthy system; divergence indicates either an event-emission bug, direct DB mutation + * without an event, or a regression in one of the handlers. + */ +export function MrrMismatchBanner() { + const userInfo = useUserInfo(); + const { data } = api.useQuery( + "get", + "/api/back-office/billing-drift/mrr-consistency-summary", + {}, + { enabled: userInfo?.isAuthenticated === true, refetchInterval: 60_000 } + ); + + if (!data || !data.currency || data.kpiMonthlyRecurringRevenue === data.trendLatestMonthlyRecurringRevenue) { + return null; + } + + const currency = data.currency; + return ( +
+ + + + Dashboard MRR mismatch: KPI shows {formatCurrency(data.kpiMonthlyRecurringRevenue, currency)}, trend latest + shows {formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency)}. + + + +
+ ); +} diff --git a/application/account/BackOffice/shared/components/SmartDateTime.tsx b/application/account/BackOffice/shared/components/SmartDateTime.tsx new file mode 100644 index 0000000000..839eb31778 --- /dev/null +++ b/application/account/BackOffice/shared/components/SmartDateTime.tsx @@ -0,0 +1,70 @@ +import { plural, t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { useFormatDate, useSmartDate } from "@repo/ui/hooks/useSmartDate"; + +interface SmartDateTimeProps { + date: string | undefined | null; + className?: string; + /** + * Append the clock time to older entries (e.g. "Yesterday, 14:02"). Default is `false` — + * for billing/business surfaces a relative phrase is enough. Opt in only on security-sensitive + * surfaces (login history, sessions, last-seen) where the exact time matters. + */ + withTime?: boolean; +} + +/** + * Displays a relative timestamp that auto-updates every 10 seconds. + * + * Examples without time (default): "Just now", "12 minutes ago", "1 hour ago", "Yesterday", + * "2 days ago", "Apr 22". + * Examples with time (`withTime`): "Just now", "12 minutes ago", "1 hour ago, 14:02", + * "Yesterday, 14:02", "2 days ago, 09:15", "Apr 22, 02:41". + */ +export function SmartDateTime({ date, className, withTime = false }: Readonly) { + const result = useSmartDate(date); + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + if (!result || !date) { + return null; + } + + const formatTime = () => + new Intl.DateTimeFormat(i18n.locale, { hour: "2-digit", minute: "2-digit" }).format(new Date(date)); + + let text: string; + switch (result.type) { + case "justNow": + text = t`Just now`; + break; + case "minutesAgo": + text = plural(result.value, { one: "# minute ago", other: "# minutes ago" }); + break; + case "hoursAgo": { + const relative = plural(result.value, { one: "# hour ago", other: "# hours ago" }); + text = withTime ? `${relative}, ${formatTime()}` : relative; + break; + } + case "date": { + const target = new Date(date); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const targetStart = new Date(target); + targetStart.setHours(0, 0, 0, 0); + const diffDays = Math.round((todayStart.getTime() - targetStart.getTime()) / 86400000); + let dayPart: string; + if (diffDays === 1) { + dayPart = t`Yesterday`; + } else if (diffDays >= 2 && diffDays <= 5) { + dayPart = plural(diffDays, { one: "# day ago", other: "# days ago" }); + } else { + dayPart = formatDate(date); + } + text = withTime ? `${dayPart}, ${formatTime()}` : dayPart; + break; + } + } + + return {text}; +} diff --git a/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx new file mode 100644 index 0000000000..dde0ccad45 --- /dev/null +++ b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx @@ -0,0 +1,48 @@ +import { plural } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { Button } from "@repo/ui/components/Button"; +import { Link } from "@tanstack/react-router"; +import { CloudOffIcon } from "lucide-react"; + +import { api } from "@/shared/lib/api/client"; + +/** + * Global banner that surfaces paid subscriptions that have never been synced into the BillingEvent log. + * The dashboard's MRR trend is computed from BillingEvents, so unsynced subscriptions silently under-count + * the trend (the KPI tile and the trend would diverge). The banner is invisible when every paid + * subscription has at least one BillingEvent row. + */ +export function UnsyncedAccountsBanner() { + const userInfo = useUserInfo(); + const { data } = api.useQuery( + "get", + "/api/back-office/billing-drift/unsynced-summary", + {}, + { enabled: userInfo?.isAuthenticated === true, refetchInterval: 60_000 } + ); + + const count = data?.unsyncedSubscriptionsCount ?? 0; + if (count === 0) { + return null; + } + + return ( +
+ + + {plural(count, { + one: "# account has not been synced yet — MRR trend is incomplete.", + other: "# accounts have not been synced yet — MRR trend is incomplete." + })} + + +
+ ); +} diff --git a/application/account/BackOffice/shared/images/card-brands/amex.svg b/application/account/BackOffice/shared/images/card-brands/amex.svg new file mode 100644 index 0000000000..f62271471c --- /dev/null +++ b/application/account/BackOffice/shared/images/card-brands/amex.svg @@ -0,0 +1,5 @@ + + + AMERICAN + EXPRESS + diff --git a/application/account/BackOffice/shared/images/card-brands/discover.svg b/application/account/BackOffice/shared/images/card-brands/discover.svg new file mode 100644 index 0000000000..8880bed79d --- /dev/null +++ b/application/account/BackOffice/shared/images/card-brands/discover.svg @@ -0,0 +1,5 @@ + + + + DISCOVER + diff --git a/application/account/BackOffice/shared/images/card-brands/mastercard.svg b/application/account/BackOffice/shared/images/card-brands/mastercard.svg new file mode 100644 index 0000000000..501622bb3e --- /dev/null +++ b/application/account/BackOffice/shared/images/card-brands/mastercard.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/application/account/BackOffice/shared/images/card-brands/visa.svg b/application/account/BackOffice/shared/images/card-brands/visa.svg new file mode 100644 index 0000000000..5f2e756edd --- /dev/null +++ b/application/account/BackOffice/shared/images/card-brands/visa.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/application/account/BackOffice/shared/lib/api/labels.ts b/application/account/BackOffice/shared/lib/api/labels.ts index 95b08ab9e8..46b1a033a4 100644 --- a/application/account/BackOffice/shared/lib/api/labels.ts +++ b/application/account/BackOffice/shared/lib/api/labels.ts @@ -1,6 +1,15 @@ import { t } from "@lingui/core/macro"; -import { SubscriptionPlan, UserRole } from "@/shared/lib/api/client"; +import { + BillingEventType, + DeviceType, + LoginMethod, + PaymentTransactionStatus, + PlannedSubscriptionChange, + SubscriptionPlan, + TenantState, + UserRole +} from "@/shared/lib/api/client"; export function getSubscriptionPlanLabel(plan: SubscriptionPlan): string { switch (plan) { @@ -15,6 +24,45 @@ export function getSubscriptionPlanLabel(plan: SubscriptionPlan): string { } } +export function getPlannedChangeLabel(change: PlannedSubscriptionChange): string { + switch (change) { + case PlannedSubscriptionChange.Cancellation: + return t`Cancellation`; + case PlannedSubscriptionChange.ScheduledPlanChange: + return t`Scheduled plan change`; + default: + return String(change); + } +} + +export function getTenantStateLabel(state: TenantState): string { + switch (state) { + case TenantState.Active: + return t`Active`; + case TenantState.Suspended: + return t`Suspended`; + default: + return String(state); + } +} + +export function getPaymentStatusLabel(status: PaymentTransactionStatus): string { + switch (status) { + case PaymentTransactionStatus.Succeeded: + return t`Paid`; + case PaymentTransactionStatus.Failed: + return t`Failed`; + case PaymentTransactionStatus.Pending: + return t`Pending`; + case PaymentTransactionStatus.Refunded: + return t`Refunded`; + case PaymentTransactionStatus.Cancelled: + return t`Cancelled`; + default: + return String(status); + } +} + export function getUserRoleLabel(role: UserRole): string { switch (role) { case UserRole.Owner: @@ -27,3 +75,76 @@ export function getUserRoleLabel(role: UserRole): string { return String(role); } } + +export function getDeviceTypeLabel(deviceType: DeviceType): string { + switch (deviceType) { + case DeviceType.Desktop: + return t`Desktop`; + case DeviceType.Mobile: + return t`Mobile`; + case DeviceType.Tablet: + return t`Tablet`; + case DeviceType.Unknown: + return t`Unknown`; + default: + return String(deviceType); + } +} + +export function getLoginMethodLabel(method: LoginMethod): string { + switch (method) { + case LoginMethod.OneTimePassword: + return t`One-time password`; + case LoginMethod.Google: + return t`Google`; + default: + return String(method); + } +} + +export function getBillingEventTypeLabel(type: BillingEventType): string { + switch (type) { + case BillingEventType.SubscriptionCreated: + return t`Subscribed`; + case BillingEventType.SubscriptionRenewed: + return t`Renewed`; + case BillingEventType.SubscriptionUpgraded: + return t`Upgraded`; + case BillingEventType.SubscriptionDowngradeScheduled: + return t`Downgrade scheduled`; + case BillingEventType.SubscriptionDowngradeCancelled: + return t`Downgrade cancelled`; + case BillingEventType.SubscriptionDowngraded: + return t`Downgraded`; + case BillingEventType.SubscriptionCancelled: + return t`Cancelled`; + case BillingEventType.SubscriptionReactivated: + return t`Reactivated`; + case BillingEventType.SubscriptionExpired: + return t`Expired`; + case BillingEventType.SubscriptionImmediatelyCancelled: + return t`Cancelled immediately`; + case BillingEventType.SubscriptionSuspended: + return t`Suspended`; + case BillingEventType.SubscriptionPastDue: + return t`Past due`; + case BillingEventType.PaymentFailed: + return t`Payment failed`; + case BillingEventType.PaymentRecovered: + return t`Payment recovered`; + case BillingEventType.PaymentRefunded: + return t`Payment refunded`; + case BillingEventType.BillingInfoAdded: + return t`Billing info added`; + case BillingEventType.BillingInfoUpdated: + return t`Billing info updated`; + case BillingEventType.PaymentMethodUpdated: + return t`Payment method updated`; + case BillingEventType.NoOp: + return t`No change`; + case BillingEventType.Unclassified: + return t`Unclassified`; + default: + return String(type); + } +} diff --git a/application/account/BackOffice/shared/lib/billingEventCategories.ts b/application/account/BackOffice/shared/lib/billingEventCategories.ts new file mode 100644 index 0000000000..d9e29b9ba3 --- /dev/null +++ b/application/account/BackOffice/shared/lib/billingEventCategories.ts @@ -0,0 +1,84 @@ +import { BillingEventType, SubscriptionPlan } from "@/shared/lib/api/client"; + +/** + * Events whose semantics include a plan transition (subscription state changes plus MRR-only events + * that still carry a plan). The Billing Events table renders the `{fromPlan} → {toPlan}` column for + * these types; every other event renders the column empty. Used by both the cross-account billing + * events route and the per-tenant Billing events tab. + */ +export const PLAN_TRANSITION_EVENT_TYPES: ReadonlySet = new Set([ + BillingEventType.SubscriptionCreated, + BillingEventType.SubscriptionRenewed, + BillingEventType.SubscriptionUpgraded, + BillingEventType.SubscriptionDowngradeScheduled, + BillingEventType.SubscriptionDowngradeCancelled, + BillingEventType.SubscriptionDowngraded, + BillingEventType.SubscriptionReactivated, + BillingEventType.SubscriptionExpired, + BillingEventType.SubscriptionImmediatelyCancelled, + BillingEventType.SubscriptionSuspended, + BillingEventType.SubscriptionCancelled, + BillingEventType.PaymentRefunded +]); + +/** Default `fromPlan` when an event's persisted `fromPlan` is null (e.g. SubscriptionCreated). */ +export const DEFAULT_FROM_PLAN = SubscriptionPlan.Basis; + +/** + * Event types that change recurring revenue. Powers the "MRR impact" pill on the Billing Events + * filter toolbar. Includes every subscription transition that moves committed MRR up or down — + * Subscribed/Upgraded/Reactivated raise it, Cancelled/Downgraded/Expired/ImmediatelyCancelled + * lower it. Downgrade schedule/cancel preview a future MRR change so they belong here too. + * Events can appear in more than one category (an Upgrade also transitions state) — the pill + * filters are independent lenses, not a partition. + */ +export const MRR_IMPACT_EVENT_TYPES: readonly BillingEventType[] = [ + BillingEventType.SubscriptionCreated, + BillingEventType.SubscriptionUpgraded, + BillingEventType.SubscriptionDowngradeScheduled, + BillingEventType.SubscriptionDowngradeCancelled, + BillingEventType.SubscriptionDowngraded, + BillingEventType.SubscriptionReactivated, + BillingEventType.SubscriptionCancelled, + BillingEventType.SubscriptionExpired, + BillingEventType.SubscriptionImmediatelyCancelled +]; + +/** + * Event types that change the subscription's effective plan or terminal state. Powers the + * "Subscription state" pill. Strict semantics: a row qualifies only when the plan the customer + * is currently on actually changes (immediately or by the event taking effect). Excludes + * scheduled future changes (DowngradeScheduled), reversals of scheduled changes + * (DowngradeCancelled, Reactivated — the effective plan didn't move), soft cancels that don't + * take effect until period end (Cancelled — the customer is still on the same plan), and + * Renewed (same plan, new period). Overlaps with MRR impact for any state transition that + * also moves committed revenue. + */ +export const SUBSCRIPTION_STATE_EVENT_TYPES: readonly BillingEventType[] = [ + BillingEventType.SubscriptionCreated, + BillingEventType.SubscriptionUpgraded, + BillingEventType.SubscriptionDowngraded, + BillingEventType.SubscriptionExpired, + BillingEventType.SubscriptionImmediatelyCancelled, + BillingEventType.SubscriptionSuspended +]; + +/** + * Payment-flow, billing-metadata, and same-plan-renewal events that neither change committed MRR + * nor mutate the subscription's effective plan. Renewed reuses the same plan into a new period + * (no plan change). Refunds are a backwards money flow on an existing charge. Past-due/failed/ + * recovered are payment hiccups, not state transitions on the Subscription aggregate. Grouped + * under "Other". + */ +export const OTHER_EVENT_TYPES: readonly BillingEventType[] = [ + BillingEventType.SubscriptionRenewed, + BillingEventType.SubscriptionPastDue, + BillingEventType.PaymentFailed, + BillingEventType.PaymentRecovered, + BillingEventType.PaymentRefunded, + BillingEventType.BillingInfoAdded, + BillingEventType.BillingInfoUpdated, + BillingEventType.PaymentMethodUpdated, + BillingEventType.NoOp, + BillingEventType.Unclassified +]; diff --git a/application/account/BackOffice/shared/lib/billingEventPlanTransition.ts b/application/account/BackOffice/shared/lib/billingEventPlanTransition.ts new file mode 100644 index 0000000000..7705de73b1 --- /dev/null +++ b/application/account/BackOffice/shared/lib/billingEventPlanTransition.ts @@ -0,0 +1,20 @@ +import { BillingEventType, SubscriptionPlan } from "@/shared/lib/api/client"; + +import { DEFAULT_FROM_PLAN } from "./billingEventCategories"; + +export type PlanTransition = { from: SubscriptionPlan; to: SubscriptionPlan }; + +// SubscriptionCancelled is persisted with from_plan=null and to_plan=. +// Rendered with the default-when-null logic that becomes "Basis → Standard", which reads as the +// opposite of a cancellation. Flip for display so the cancelled plan is shown on the left. +export function getDisplayedPlanTransition( + eventType: BillingEventType, + fromPlan: SubscriptionPlan | null | undefined, + toPlan: SubscriptionPlan | null | undefined +): PlanTransition | null { + if (toPlan == null) return null; + if (eventType === BillingEventType.SubscriptionCancelled) { + return { from: toPlan, to: SubscriptionPlan.Basis }; + } + return { from: fromPlan ?? DEFAULT_FROM_PLAN, to: toPlan }; +} diff --git a/application/account/BackOffice/shared/lib/billingEventStyle.ts b/application/account/BackOffice/shared/lib/billingEventStyle.ts new file mode 100644 index 0000000000..56216c3613 --- /dev/null +++ b/application/account/BackOffice/shared/lib/billingEventStyle.ts @@ -0,0 +1,111 @@ +import { + ArrowDownRightIcon, + ArrowUpRightIcon, + CalendarClockIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleSlashIcon, + CircleXIcon, + CreditCardIcon, + PauseCircleIcon, + RefreshCwIcon, + ReplyIcon, + RotateCcwIcon, + TriangleAlertIcon, + WalletIcon +} from "lucide-react"; + +import { BillingEventType } from "@/shared/lib/api/client"; + +export interface BillingEventVariant { + className: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; +} + +/** + * Centralised badge styling for the BillingEventType enum. Used by the dashboard "Recent billing events" + * card and the /billing-events table so the colour and icon are consistent everywhere a billing event is + * surfaced. + */ +export const BILLING_EVENT_VARIANT: Record = { + [BillingEventType.SubscriptionCreated]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: CircleCheckIcon + }, + [BillingEventType.SubscriptionRenewed]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: RefreshCwIcon + }, + [BillingEventType.SubscriptionUpgraded]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: ArrowUpRightIcon + }, + [BillingEventType.SubscriptionDowngradeScheduled]: { + className: "bg-amber-500/10 text-amber-700 border-amber-500/20 dark:text-amber-300", + icon: CalendarClockIcon + }, + [BillingEventType.SubscriptionDowngradeCancelled]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: RotateCcwIcon + }, + [BillingEventType.SubscriptionDowngraded]: { + className: "bg-amber-500/10 text-amber-700 border-amber-500/20 dark:text-amber-300", + icon: ArrowDownRightIcon + }, + [BillingEventType.SubscriptionCancelled]: { + className: "bg-rose-500/10 text-rose-700 border-rose-500/20 dark:text-rose-300", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionReactivated]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: ReplyIcon + }, + [BillingEventType.SubscriptionExpired]: { + className: "bg-rose-500/10 text-rose-700 border-rose-500/20 dark:text-rose-300", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionImmediatelyCancelled]: { + className: "bg-rose-500/10 text-rose-700 border-rose-500/20 dark:text-rose-300", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionSuspended]: { + className: "bg-rose-500/10 text-rose-700 border-rose-500/20 dark:text-rose-300", + icon: PauseCircleIcon + }, + [BillingEventType.SubscriptionPastDue]: { + className: "bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300", + icon: CircleAlertIcon + }, + [BillingEventType.PaymentFailed]: { + className: "bg-rose-500/10 text-rose-700 border-rose-500/20 dark:text-rose-300", + icon: CircleAlertIcon + }, + [BillingEventType.PaymentRecovered]: { + className: "bg-emerald-500/10 text-emerald-700 border-emerald-500/20 dark:text-emerald-300", + icon: CircleCheckIcon + }, + [BillingEventType.PaymentRefunded]: { + className: "bg-amber-500/10 text-amber-700 border-amber-500/20 dark:text-amber-300", + icon: ArrowDownRightIcon + }, + [BillingEventType.BillingInfoAdded]: { + className: "bg-sky-500/10 text-sky-700 border-sky-500/20 dark:text-sky-300", + icon: WalletIcon + }, + [BillingEventType.BillingInfoUpdated]: { + className: "bg-sky-500/10 text-sky-700 border-sky-500/20 dark:text-sky-300", + icon: WalletIcon + }, + [BillingEventType.PaymentMethodUpdated]: { + className: "bg-sky-500/10 text-sky-700 border-sky-500/20 dark:text-sky-300", + icon: CreditCardIcon + }, + [BillingEventType.NoOp]: { + className: "bg-muted text-muted-foreground border-border", + icon: CircleSlashIcon + }, + [BillingEventType.Unclassified]: { + className: "bg-amber-500/10 text-amber-700 border-amber-500/30 dark:text-amber-300", + icon: TriangleAlertIcon + } +}; diff --git a/application/account/BackOffice/shared/lib/planBadge.ts b/application/account/BackOffice/shared/lib/planBadge.ts new file mode 100644 index 0000000000..9a744b6678 --- /dev/null +++ b/application/account/BackOffice/shared/lib/planBadge.ts @@ -0,0 +1,14 @@ +import { SubscriptionPlan } from "@/shared/lib/api/client"; + +export function getSubscriptionPlanBadgeClass(plan: SubscriptionPlan): string { + switch (plan) { + case SubscriptionPlan.Basis: + return "border-transparent bg-muted text-foreground"; + case SubscriptionPlan.Standard: + return "border-transparent bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"; + case SubscriptionPlan.Premium: + return "border-transparent bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300"; + default: + return ""; + } +} diff --git a/application/account/BackOffice/shared/lib/relativeTime.ts b/application/account/BackOffice/shared/lib/relativeTime.ts new file mode 100644 index 0000000000..3f0a48e5ca --- /dev/null +++ b/application/account/BackOffice/shared/lib/relativeTime.ts @@ -0,0 +1,24 @@ +const SECOND_MS = 1000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; + +export function formatRelativeTime(input: string | null | undefined, locale: string): string { + if (!input) { + return ""; + } + const date = new Date(input); + const diffMs = date.getTime() - Date.now(); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const absMs = Math.abs(diffMs); + if (absMs < MINUTE_MS) return formatter.format(Math.round(diffMs / SECOND_MS), "second"); + if (absMs < HOUR_MS) return formatter.format(Math.round(diffMs / MINUTE_MS), "minute"); + if (absMs < DAY_MS) return formatter.format(Math.round(diffMs / HOUR_MS), "hour"); + if (absMs < WEEK_MS) return formatter.format(Math.round(diffMs / DAY_MS), "day"); + if (absMs < MONTH_MS) return formatter.format(Math.round(diffMs / WEEK_MS), "week"); + if (absMs < YEAR_MS) return formatter.format(Math.round(diffMs / MONTH_MS), "month"); + return formatter.format(Math.round(diffMs / YEAR_MS), "year"); +} diff --git a/application/account/BackOffice/shared/lib/userAgent.ts b/application/account/BackOffice/shared/lib/userAgent.ts new file mode 100644 index 0000000000..c51bd2ff71 --- /dev/null +++ b/application/account/BackOffice/shared/lib/userAgent.ts @@ -0,0 +1,44 @@ +import { t } from "@lingui/core/macro"; + +// Order matters: more specific patterns first. Modern Chromium-based browsers like Opera and Edge +// also include `Chrome/` in their user-agent string, so the `OPR/` and `Edg/` matches must run +// before `Chrome/`. Firefox occasionally announces a Safari token for compatibility, and every +// Android browser identifies itself as Linux too — keep the platform-specific markers above the +// generic ones so detection lands on the most specific match. +const browserPatterns: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /OPR\/[\d.]+/, name: "Opera" }, + { pattern: /Edg\/[\d.]+/, name: "Edge" }, + { pattern: /Firefox\/[\d.]+/, name: "Firefox" }, + { pattern: /Chrome\/[\d.]+/, name: "Chrome" }, + { pattern: /Safari\/[\d.]+/, name: "Safari" } +]; + +const osPatterns: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /Android/, name: "Android" }, + { pattern: /iPhone|iPad/, name: "iOS" }, + { pattern: /Windows NT/, name: "Windows" }, + { pattern: /Mac OS X/, name: "macOS" }, + { pattern: /Linux/, name: "Linux" } +]; + +export function parseUserAgent(userAgent: string): { browser: string; os: string } { + const unknown = t`Unknown`; + let browser = unknown; + let os = unknown; + + for (const { pattern, name } of browserPatterns) { + if (pattern.test(userAgent)) { + browser = name; + break; + } + } + + for (const { pattern, name } of osPatterns) { + if (pattern.test(userAgent)) { + os = name; + break; + } + } + + return { browser, os }; +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 7389529d12..dbccce70e4 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -13,27 +13,207 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +#. placeholder {0}: result.value +msgid "{0, plural, one {# hour ago} other {# hours ago}}" +msgstr "{0, plural, one {# time siden} other {# timer siden}}" + +#. placeholder {0}: result.value +msgid "{0, plural, one {# minute ago} other {# minutes ago}}" +msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" + +#. placeholder {0}: formatCurrency(blended, currency) +msgid "{0} blended" +msgstr "{0} samlet" + +#. placeholder {0}: formatCurrency(currentGain, currency) +msgid "{0} this period, excluding VAT" +msgstr "{0} i denne periode, eksklusiv moms" + +msgid "{activeUsers} active" +msgstr "{activeUsers} aktive" + +msgid "{count, plural, one {# account has billing drift detected.} other {# accounts have billing drift detected.}}" +msgstr "{count, plural, one {# konto har faktureringsafvigelser.} other {# konti har faktureringsafvigelser.}}" + +msgid "{count, plural, one {# account has not been synced yet — MRR trend is incomplete.} other {# accounts have not been synced yet — MRR trend is incomplete.}}" +msgstr "{count, plural, one {# konto er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.} other {# konti er endnu ikke synkroniseret — MRR-tendensen er ufuldstændig.}}" + +msgid "{diffDays, plural, one {# day ago} other {# days ago}}" +msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" + +msgid "{inactiveUsers} inactive" +msgstr "{inactiveUsers} inaktive" + +msgid "{pendingUsers} pending" +msgstr "{pendingUsers} afventer" + +msgid "{tenantCount, plural, one {# membership} other {# memberships}}" +msgstr "{tenantCount, plural, one {# medlemskab} other {# medlemskaber}}" + +msgid "{total} accounts" +msgstr "{total} konti" + +msgid "{total} new signups · {priorTotal} prior period" +msgstr "{total} nye tilmeldinger · {priorTotal} forrige periode" + +msgid "{total} total · avg {average}/day" +msgstr "{total} i alt · gns. {average}/dag" + +msgid "/ month" +msgstr "/ måned" + +#. placeholder {0}: data.newTenantsInPeriod +msgid "+{0} new in last {periodDays} days" +msgstr "+{0} nye sidste {periodDays} dage" + +msgid "<0>{totalUsers} total" +msgstr "<0>{totalUsers} i alt" + +msgid "24h" +msgstr "24t" + +msgid "30 days" +msgstr "30 dage" + +msgid "30d" +msgstr "30d" + +msgid "7 days" +msgstr "7 dage" + +msgid "7d" +msgstr "7d" + +msgid "90d" +msgstr "90d" + +msgid "Account" +msgstr "Konto" + +msgid "Account actions" +msgstr "Kontohandlinger" + +msgid "Account growth" +msgstr "Kontovækst" + +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.reconciledAt) +msgid "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." +msgstr "Kontoen har {0} afvigelser. Sidst afstemt {1}. Hvis standard-afstemning ikke kan rydde afvigelserne, er katastrofegendannelse fra arkiverede Stripe-events tilgængelig som sidste udvej." + +msgid "Account preview" +msgstr "Kontoforhåndsvisning" + +msgid "Account users" +msgstr "Kontobrugere" + +msgid "accounts" +msgstr "konti" + msgid "Accounts" msgstr "Konti" -msgid "Accounts (coming soon)" -msgstr "Konti (kommer snart)" +msgid "Accounts will appear here as they are created." +msgstr "Konti vises her, når de oprettes." + +msgid "Actions" +msgstr "Handlinger" + +msgid "Active" +msgstr "Aktiv" + +msgid "Active sessions" +msgstr "Aktive sessioner" + +msgid "Activity" +msgstr "Aktivitet" msgid "Admin" msgstr "Admin" +msgid "All" +msgstr "Alle" + +msgid "All accounts this user is a member of, with their plan and role." +msgstr "Alle konti, brugeren er medlem af, med deres plan og rolle." + +msgid "All users across every account, most recently seen first. Search and filter to narrow down." +msgstr "Alle brugere på tværs af konti, senest sete først. Søg og filtrér for at indsnævre." + +msgid "All-time" +msgstr "Samlet" + +msgid "All-time, excluding VAT" +msgstr "Hele perioden, eksklusiv moms" + +msgid "Amount" +msgstr "Beløb" + msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.reconciledAt) +msgid "Appended {0} new billing events. Last reconciled at {1}." +msgstr "Tilføjede {0} nye faktureringshændelser. Sidst afstemt {1}." + +msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." +msgstr "Autoritativ log over abonnements-, betalings- og faktureringsændringer på tværs af alle konti." + msgid "Back Office" msgstr "Back Office" -msgid "BackOffice - Localhost" -msgstr "BackOffice - Localhost" +msgid "Back Office - Localhost" +msgstr "Back Office - Localhost" + +msgid "Back Office overview · {today}" +msgstr "Back Office oversigt · {today}" msgid "Basis" msgstr "Basis" +msgid "Billing" +msgstr "Fakturering" + +msgid "Billing address" +msgstr "Faktureringsadresse" + +msgid "Billing events" +msgstr "Faktureringshændelser" + +msgid "Billing info added" +msgstr "Faktureringsinfo tilføjet" + +msgid "Billing info updated" +msgstr "Faktureringsinfo opdateret" + +msgid "blended" +msgstr "blandet" + +msgid "Blended MRR" +msgstr "Samlet MRR" + +msgid "Browser" +msgstr "Browser" + +msgid "Cancel" +msgstr "Annuller" + +msgid "Canceled" +msgstr "Opsagt" + +msgid "Canceling" +msgstr "Opsiges" + +msgid "Cancellation" +msgstr "Opsigelse" + +msgid "Cancelled" +msgstr "Annulleret" + +msgid "Cancelled immediately" +msgstr "Annulleret med det samme" + msgid "Change language" msgstr "Skift sprog" @@ -43,33 +223,160 @@ msgstr "Skift tema" msgid "Change zoom level" msgstr "Skift zoomniveau" -msgid "Coming soon" -msgstr "Kommer snart" +msgid "Clear filter" +msgstr "Ryd filter" + +msgid "Clear filters" +msgstr "Ryd filtre" + +msgid "Clear search" +msgstr "Ryd søgning" + +msgid "Close" +msgstr "Luk" + +msgid "Close account preview" +msgstr "Luk kontoforhåndsvisning" msgid "Contact your administrator." msgstr "Kontakt din administrator." +msgid "Country" +msgstr "Land" + +msgid "Created" +msgstr "Oprettet" + +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Oprettet <0>{0}<1>{1}" + +msgid "Credit note" +msgstr "Kreditnota" + +msgid "Current period" +msgstr "Aktuel periode" + +msgid "Current plan" +msgstr "Aktuelt abonnement" + msgid "Dark" msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency) +msgid "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." +msgstr "MRR-uoverensstemmelse på dashboard: KPI viser {0}, seneste tendens viser {1}." + +msgid "Date" +msgstr "Dato" + msgid "Default" msgstr "Standard" -msgid "Feature flags" -msgstr "Feature flags" +msgid "Desktop" +msgstr "Computer" + +msgid "Device" +msgstr "Enhed" + +msgid "Disaster recovery complete" +msgstr "Katastrofegendannelse fuldført" + +msgid "Disaster recovery from archived Stripe events?" +msgstr "Katastrofegendannelse fra arkiverede Stripe-events?" + +msgid "Downgrade cancelled" +msgstr "Nedgradering annulleret" + +msgid "Downgrade scheduled" +msgstr "Nedgradering planlagt" + +msgid "Downgraded" +msgstr "Nedgraderet" + +msgid "Downgrading" +msgstr "Nedgraderer" + +msgid "Drift detected" +msgstr "Afvigelser fundet" + +msgid "Email" +msgstr "E-mail" + +msgid "Email confirmed" +msgstr "E-mail bekræftet" + +msgid "Email pending" +msgstr "E-mail afventer" -msgid "Feature flags (coming soon)" -msgstr "Feature flags (kommer snart)" +msgid "Enter kiosk mode" +msgstr "Aktivér kiosktilstand" + +msgid "Event" +msgstr "Hændelse" + +msgid "Event view" +msgstr "Hændelsesvisning" + +msgid "Every invoice, refund, and credit note — the money in and out for this subscription." +msgstr "Alle fakturaer, refusioner og kreditnotaer — pengene ind og ud for dette abonnement." + +msgid "Every invoice, refund, and credit note across all accounts." +msgstr "Alle fakturaer, refusioner og kreditnotaer på tværs af alle konti." + +msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." +msgstr "Alle log-ind-forsøg de seneste 30 dage, lykkedes eller mislykkedes, på tværs af e-mail og eksterne udbydere." + +msgid "Exit kiosk mode" +msgstr "Afslut kiosktilstand" + +msgid "Expired" +msgstr "Udløbet" + +msgid "Expires" +msgstr "Udløber" + +msgid "Failed" +msgstr "Mislykket" + +msgid "Free" +msgstr "Gratis" msgid "Go to home" msgstr "Gå til forsiden" +msgid "Google" +msgstr "Google" + msgid "Hide details" msgstr "Skjul detaljer" +msgid "Inactive" +msgstr "Inaktive" + +msgid "Invoice" +msgstr "Faktura" + +msgid "Invoice view" +msgstr "Fakturavisning" + +msgid "Invoices" +msgstr "Fakturaer" + +msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." +msgstr "Fakturaer vises her, når konti tilmelder sig, og Stripe-webhooks behandles." + +msgid "IP address" +msgstr "IP-adresse" + +msgid "Just now" +msgstr "Lige nu" + msgid "Language" msgstr "Sprog" @@ -79,6 +386,24 @@ msgstr "Stor" msgid "Larger" msgstr "Større" +msgid "Last {periodDays} days" +msgstr "Sidste {periodDays} dage" + +msgid "Last 24 hours" +msgstr "Sidste 24 timer" + +msgid "Last invoice" +msgstr "Sidste faktura" + +msgid "Last log-in" +msgstr "Seneste login" + +msgid "Last seen" +msgstr "Sidst set" + +msgid "Lifetime value" +msgstr "Livstidsværdi" + msgid "Light" msgstr "Lys" @@ -100,30 +425,246 @@ msgstr "Log ud" msgid "Logging in..." msgstr "Logger ind..." +msgid "Login history" +msgstr "Login-historik" + +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" msgid "Main navigation" msgstr "Hovednavigation" -msgid "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." -msgstr "Administrer konti, se systemdata, se undtagelser og udfør forskellige opgaver for drifts- og supportteams." - msgid "Member" msgstr "Medlem" +msgid "Method" +msgstr "Metode" + +msgid "Mobile" +msgstr "Mobil" + +msgid "Most recent activity" +msgstr "Seneste aktivitet" + +msgid "MRR" +msgstr "MRR" + +msgid "MRR after" +msgstr "MRR efter" + +msgid "MRR impact" +msgstr "MRR-effekt" + +msgid "MRR trend" +msgstr "MRR-tendens" + +msgid "Name" +msgstr "Navn" + msgid "Navigation" msgstr "Navigation" +msgid "Never logged in" +msgstr "Aldrig logget ind" + +msgid "New accounts will appear here as they sign up." +msgstr "Nye konti vises her, når de tilmelder sig." + +msgid "Next" +msgstr "Næste" + +msgid "No account memberships" +msgstr "Ingen kontomedlemskaber" + +msgid "No accounts match your filters" +msgstr "Ingen konti matcher dine filtre" + +msgid "No accounts yet" +msgstr "Ingen konti endnu" + msgid "No back-office access" msgstr "Ingen adgang til Back Office" +msgid "No billing address on file." +msgstr "Ingen faktureringsadresse registreret." + +msgid "No billing events" +msgstr "Ingen faktureringshændelser" + +msgid "No billing events match your filters" +msgstr "Ingen faktureringshændelser matcher dine filtre" + +msgid "No billing events yet" +msgstr "Ingen faktureringshændelser endnu" + +msgid "No change" +msgstr "Ingen ændring" + +msgid "No invoices yet" +msgstr "Ingen fakturaer endnu" + +msgid "No invoices, refunds, or credit notes yet." +msgstr "Ingen fakturaer, refusioner eller kreditnotaer endnu." + +msgid "No login history" +msgstr "Ingen login-historik" + +msgid "No matching users" +msgstr "Ingen matchende brugere" + +msgid "No new billing events were appended. Account state matches Stripe." +msgstr "Ingen nye faktureringshændelser blev tilføjet. Kontotilstand matcher Stripe." + +msgid "No owners" +msgstr "Ingen ejere" + +msgid "No owners on this account." +msgstr "Ingen ejere på denne konto." + +msgid "No paid plan yet." +msgstr "Intet betalt abonnement endnu." + +msgid "No plan" +msgstr "Intet abonnement" + +msgid "No recent billing events" +msgstr "Ingen nylige faktureringshændelser" + +msgid "No recent logins" +msgstr "Ingen nylige logins" + +msgid "No recent payments" +msgstr "Ingen nylige betalinger" + +msgid "No recent signups" +msgstr "Ingen nylige tilmeldinger" + +msgid "No records yet" +msgstr "Ingen poster endnu" + +msgid "No refunds or credit notes yet" +msgstr "Ingen refunderinger eller kreditnotaer endnu" + +msgid "No result available." +msgstr "Intet resultat tilgængeligt." + +msgid "No results match your search" +msgstr "Ingen resultater matcher din søgning" + +msgid "No sessions" +msgstr "Ingen sessioner" + +msgid "No sign-in attempts in the last 30 days." +msgstr "Ingen login-forsøg de seneste 30 dage." + +msgid "No transactions" +msgstr "Ingen transaktioner" + +msgid "No users" +msgstr "Ingen brugere" + +msgid "No users match your filters." +msgstr "Ingen brugere matcher dine filtre." + +msgid "No users match your search" +msgstr "Ingen brugere matcher din søgning" + +msgid "No users yet" +msgstr "Ingen brugere endnu" + +msgid "Not synced yet" +msgstr "Ikke synkroniseret endnu" + +msgid "Occurred" +msgstr "Tidspunkt" + +msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." +msgstr "En række pr. enhed eller browser, brugeren er logget ind fra. Tilbagekaldte sessioner kan ikke logge ind igen." + +msgid "One-time password" +msgstr "Engangskode" + +msgid "Open account" +msgstr "Åbn konto" + +msgid "Open credit note" +msgstr "Åbn kreditnota" + +msgid "Open in Stripe" +msgstr "Åbn i Stripe" + +msgid "Open invoice" +msgstr "Åbn faktura" + +msgid "Other" +msgstr "Andet" + +msgid "Outcome" +msgstr "Resultat" + +msgid "over period" +msgstr "over perioden" + +msgid "Overview" +msgstr "Overblik" + msgid "Owner" msgstr "Ejer" +msgid "Owners" +msgstr "Ejere" + msgid "Page not found" msgstr "Siden blev ikke fundet" +msgid "Paid" +msgstr "Betalt" + +msgid "Past due" +msgstr "Forfalden" + +msgid "Payment failed" +msgstr "Betaling mislykkedes" + +msgid "Payment method" +msgstr "Betalingsmetode" + +msgid "Payment method updated" +msgstr "Betalingsmetode opdateret" + +msgid "Payment recovered" +msgstr "Betaling gendannet" + +msgid "Payment refunded" +msgstr "Betaling refunderet" + +msgid "Pending" +msgstr "Afventer" + +msgid "per month, billed monthly" +msgstr "pr. måned, faktureres månedligt" + +msgid "Period" +msgstr "Periode" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Abonnement og omsætning" + +msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." +msgstr "Planændringer, fornyelser, opsigelser og betalingsresultater — abonnementets livscyklus og dets MRR-effekt over tid." + +msgid "Plan distribution" +msgstr "Plan-fordeling" + +msgid "Plan transition" +msgstr "Abonnementsændring" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,12 +677,142 @@ msgstr "Prøv venligst igen eller vend tilbage til forsiden." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Forrige" + +msgid "Prior period" +msgstr "Forrige periode" + +msgid "Reactivated" +msgstr "Genaktiveret" + +msgid "Recent billing events" +msgstr "Nylige faktureringshændelser" + +msgid "Recent logins" +msgstr "Nylige logins" + +msgid "Recent payments" +msgstr "Nylige betalinger" + +msgid "Recent signups" +msgstr "Nylige tilmeldinger" + +msgid "Reconcile" +msgstr "Afstem" + +msgid "Reconcile complete" +msgstr "Afstemning fuldført" + +msgid "Reconcile complete with drift detected" +msgstr "Afstemning fuldført med afvigelser fundet" + +#. placeholder {0}: archivedAwaiting.count +#. placeholder {1}: formatDate(archivedAwaiting.oldestOccurredAt) +#. placeholder {2}: formatDate(archivedAwaiting.newestOccurredAt) +msgid "Reconcile found {0} archived events older than Stripe's 30-day window, from {1} to {2}. This is a best-effort recovery from locally stored payloads and may produce incorrect rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." +msgstr "Afstemning fandt {0} arkiverede events ældre end Stripes 30-dages vindue, fra {1} til {2}. Dette er en bedst muligt-gendannelse fra lokalt gemte payloads og kan producere forkerte rækker. Kør kun denne handling, når standard Afstem med Stripe er forsøgt og ikke ryddede afvigelserne." + +msgid "Reconcile rebuilds this tenant's subscription and billing events from Stripe directly using the events.list API (the last 30 days). If the drift is not cleared afterwards, disaster recovery from the locally archived Stripe payloads is available as a last resort." +msgstr "Afstemning genopbygger denne kontos abonnement og faktureringshændelser direkte fra Stripe via events.list-API'et (de seneste 30 dage). Hvis afvigelserne ikke er ryddet bagefter, er katastrofegendannelse fra de lokalt arkiverede Stripe-payloads tilgængelig som sidste udvej." + +msgid "Reconcile with Stripe" +msgstr "Afstem med Stripe" + +msgid "Reconcile with Stripe?" +msgstr "Afstem med Stripe?" + +msgid "Reconciling..." +msgstr "Afstemmer..." + +msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." +msgstr "Posteringer vises her, når konti abonnerer, og Stripe-webhooks behandles." + +msgid "Refunded" +msgstr "Refunderet" + +msgid "Refunds and credit notes" +msgstr "Refunderinger og kreditnotaer" + +msgid "Refunds and credit notes across all accounts." +msgstr "Refunderinger og kreditnotaer på tværs af alle konti." + +msgid "Renewal" +msgstr "Fornyelse" + +msgid "Renewal date" +msgstr "Fornyelsesdato" + +msgid "Renewed" +msgstr "Fornyet" + +#. placeholder {0}: formatDate(membership.renewalDate) +#. placeholder {0}: formatDate(tenant.renewalDate) +msgid "Renews {0}" +msgstr "Fornyes {0}" + +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.replayedAt) +msgid "Replayed {0} archived events into the billing event ledger at {1}." +msgstr "Afspillede {0} arkiverede hændelser til faktureringshændelseslogen {1}." + +msgid "Revenue" +msgstr "Omsætning" + +msgid "Revoked" +msgstr "Tilbagekaldt" + +msgid "Role" +msgstr "Rolle" + +msgid "Run disaster recovery" +msgstr "Kør katastrofegendannelse" + +msgid "Scheduled plan change" +msgstr "Planlagt planændring" + msgid "Screenshots of the dashboard project with desktop and mobile versions" msgstr "Skærmbilleder af dashboard-projektet i desktop- og mobilversioner" +msgid "Search" +msgstr "Søg" + +msgid "Search by account name" +msgstr "Søg efter kontonavn" + +msgid "Search by email, name, or account" +msgstr "Søg på e-mail, navn eller konto" + +msgid "Search by name" +msgstr "Søg efter navn" + +msgid "Search by name or email" +msgstr "Søg efter navn eller e-mail" + +msgid "Search users" +msgstr "Søg brugere" + +msgid "Search, filter, and review accounts." +msgstr "Søg, filtrér og gennemse konti." + +msgid "Sessions" +msgstr "Sessioner" + msgid "Show details" msgstr "Vis detaljer" +msgid "Signed up" +msgstr "Tilmeldt" + +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +msgid "Signed up <0>{0}<1>{1}" +msgstr "Tilmeldt <0>{0}<1>{1}" + +#. placeholder {0}: formatDate(tenant.subscribedSince) +msgid "Since {0}" +msgstr "Siden {0}" + msgid "Small" msgstr "Lille" @@ -151,44 +822,149 @@ msgstr "Noget gik galt" msgid "Standard" msgstr "Standard" -msgid "Support" -msgstr "Support" +msgid "Status" +msgstr "Status" + +msgid "Subscribed" +msgstr "Tilmeldt" + +msgid "Subscribed since" +msgstr "Abonneret siden" + +msgid "Subscription state" +msgstr "Abonnementstilstand" + +msgid "Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed." +msgstr "Abonnements-, betalings- og faktureringsændringer vises her, når Stripe-webhooks behandles." -msgid "Support (coming soon)" -msgstr "Support (kommer snart)" +msgid "Subscription, payment, and billing transitions will appear here." +msgstr "Abonnements-, betalings- og faktureringsændringer vises her." + +msgid "Subscriptions, upgrades, and cancellations will appear here." +msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." + +msgid "Subtotal" +msgstr "Subtotal" + +msgid "Succeeded" +msgstr "Gennemført" + +msgid "Successful logins will appear here as users sign in." +msgstr "Vellykkede logins vises her, når brugere logger ind." + +msgid "Successful, pending, and failed invoices across all accounts." +msgstr "Gennemførte, afventende og mislykkede fakturaer på tværs af alle konti." + +msgid "Suspended" +msgstr "Suspenderet" msgid "System" msgstr "System" +msgid "Tablet" +msgstr "Tablet" + msgid "The page you are looking for does not exist or was moved." msgstr "Siden du leder efter findes ikke eller er blevet flyttet." msgid "Theme" msgstr "Tema" +msgid "This account has no users." +msgstr "Denne konto har ingen brugere." + +msgid "this period" +msgstr "denne periode" + +msgid "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." +msgstr "Dette genopbygger faktureringshændelsesloggen fra denne kontos arkiverede Stripe-payloads. Det er en bedst muligt-gendannelse, der kan producere forkerte abonnementstilstande eller faktureringshændelser. Kør kun denne handling, når standard Afstem med Stripe er forsøgt og ikke ryddede afvigelserne." + +msgid "This user has no recorded sessions." +msgstr "Denne bruger har ingen registrerede sessioner." + +msgid "This user is not a member of any account." +msgstr "Denne bruger er ikke medlem af nogen konto." + +msgid "Total" +msgstr "I alt" + +msgid "Total accounts" +msgstr "Konti i alt" + +msgid "Total revenue" +msgstr "Omsætning i alt" + +msgid "Try a different search term or clear the role and activity filters." +msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." + msgid "Try again" msgstr "Prøv igen" +msgid "Try clearing the search or filters to see more results." +msgstr "Prøv at rydde søgningen eller filtrene for at se flere resultater." + +msgid "Try clearing the search to see more results." +msgstr "Prøv at rydde søgningen for at se flere resultater." + +msgid "Unclassified" +msgstr "Uklassificeret" + +msgid "Unknown" +msgstr "Ukendt" + +msgid "Upgraded" +msgstr "Opgraderet" + msgid "User" msgstr "Bruger" +msgid "User detail" +msgstr "Brugerdetalje" + +msgid "User logins / day" +msgstr "Brugerlogins / dag" + msgid "User menu" msgstr "Brugermenu" msgid "Users" msgstr "Brugere" -msgid "Users (coming soon)" -msgstr "Brugere (kommer snart)" +msgid "Users active" +msgstr "Aktive brugere" + +msgid "Users will appear here as accounts are created." +msgstr "Brugere vises her, efterhånden som konti oprettes." + +msgid "VAT" +msgstr "Moms" + +msgid "VAT number" +msgstr "Momsnummer" + +msgid "View accounts" +msgstr "Vis konti" + +msgid "View all" +msgstr "Vis alle" + +msgid "View all {totalEvents} events" +msgstr "Vis alle {totalEvents} hændelser" + +msgid "View all {totalTransactions} invoices" +msgstr "Vis alle {totalTransactions} fakturaer" + +msgid "View billing events" +msgstr "Vis faktureringshændelser" -msgid "Wait list" -msgstr "Venteliste" +msgid "vs prior period" +msgstr "mod forrige periode" -msgid "Wait list (coming soon)" -msgstr "Venteliste (kommer snart)" +msgid "When" +msgstr "Hvornår" -msgid "Welcome to the Back Office" -msgstr "Velkommen til Back Office" +msgid "Yesterday" +msgstr "I går" msgid "Your account is not in the required group." msgstr "Din konto er ikke i den krævede gruppe." diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 3bdbf4d88d..190dc5b5d3 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -13,27 +13,207 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#. placeholder {0}: result.value +msgid "{0, plural, one {# hour ago} other {# hours ago}}" +msgstr "{0, plural, one {# hour ago} other {# hours ago}}" + +#. placeholder {0}: result.value +msgid "{0, plural, one {# minute ago} other {# minutes ago}}" +msgstr "{0, plural, one {# minute ago} other {# minutes ago}}" + +#. placeholder {0}: formatCurrency(blended, currency) +msgid "{0} blended" +msgstr "{0} blended" + +#. placeholder {0}: formatCurrency(currentGain, currency) +msgid "{0} this period, excluding VAT" +msgstr "{0} this period, excluding VAT" + +msgid "{activeUsers} active" +msgstr "{activeUsers} active" + +msgid "{count, plural, one {# account has billing drift detected.} other {# accounts have billing drift detected.}}" +msgstr "{count, plural, one {# account has billing drift detected.} other {# accounts have billing drift detected.}}" + +msgid "{count, plural, one {# account has not been synced yet — MRR trend is incomplete.} other {# accounts have not been synced yet — MRR trend is incomplete.}}" +msgstr "{count, plural, one {# account has not been synced yet — MRR trend is incomplete.} other {# accounts have not been synced yet — MRR trend is incomplete.}}" + +msgid "{diffDays, plural, one {# day ago} other {# days ago}}" +msgstr "{diffDays, plural, one {# day ago} other {# days ago}}" + +msgid "{inactiveUsers} inactive" +msgstr "{inactiveUsers} inactive" + +msgid "{pendingUsers} pending" +msgstr "{pendingUsers} pending" + +msgid "{tenantCount, plural, one {# membership} other {# memberships}}" +msgstr "{tenantCount, plural, one {# membership} other {# memberships}}" + +msgid "{total} accounts" +msgstr "{total} accounts" + +msgid "{total} new signups · {priorTotal} prior period" +msgstr "{total} new signups · {priorTotal} prior period" + +msgid "{total} total · avg {average}/day" +msgstr "{total} total · avg {average}/day" + +msgid "/ month" +msgstr "/ month" + +#. placeholder {0}: data.newTenantsInPeriod +msgid "+{0} new in last {periodDays} days" +msgstr "+{0} new in last {periodDays} days" + +msgid "<0>{totalUsers} total" +msgstr "<0>{totalUsers} total" + +msgid "24h" +msgstr "24h" + +msgid "30 days" +msgstr "30 days" + +msgid "30d" +msgstr "30d" + +msgid "7 days" +msgstr "7 days" + +msgid "7d" +msgstr "7d" + +msgid "90d" +msgstr "90d" + +msgid "Account" +msgstr "Account" + +msgid "Account actions" +msgstr "Account actions" + +msgid "Account growth" +msgstr "Account growth" + +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.reconciledAt) +msgid "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." +msgstr "Account has {0} drift discrepancies. Last reconciled at {1}. If standard reconcile cannot clear the drift, disaster recovery from archived Stripe events is available as a last resort." + +msgid "Account preview" +msgstr "Account preview" + +msgid "Account users" +msgstr "Account users" + +msgid "accounts" +msgstr "accounts" + msgid "Accounts" msgstr "Accounts" -msgid "Accounts (coming soon)" -msgstr "Accounts (coming soon)" +msgid "Accounts will appear here as they are created." +msgstr "Accounts will appear here as they are created." + +msgid "Actions" +msgstr "Actions" + +msgid "Active" +msgstr "Active" + +msgid "Active sessions" +msgstr "Active sessions" + +msgid "Activity" +msgstr "Activity" msgid "Admin" msgstr "Admin" +msgid "All" +msgstr "All" + +msgid "All accounts this user is a member of, with their plan and role." +msgstr "All accounts this user is a member of, with their plan and role." + +msgid "All users across every account, most recently seen first. Search and filter to narrow down." +msgstr "All users across every account, most recently seen first. Search and filter to narrow down." + +msgid "All-time" +msgstr "All-time" + +msgid "All-time, excluding VAT" +msgstr "All-time, excluding VAT" + +msgid "Amount" +msgstr "Amount" + msgid "An unexpected error occurred while processing your request." msgstr "An unexpected error occurred while processing your request." +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.reconciledAt) +msgid "Appended {0} new billing events. Last reconciled at {1}." +msgstr "Appended {0} new billing events. Last reconciled at {1}." + +msgid "Authoritative log of subscription, payment, and billing transitions across all accounts." +msgstr "Authoritative log of subscription, payment, and billing transitions across all accounts." + msgid "Back Office" msgstr "Back Office" -msgid "BackOffice - Localhost" -msgstr "BackOffice - Localhost" +msgid "Back Office - Localhost" +msgstr "Back Office - Localhost" + +msgid "Back Office overview · {today}" +msgstr "Back Office overview · {today}" msgid "Basis" msgstr "Basis" +msgid "Billing" +msgstr "Billing" + +msgid "Billing address" +msgstr "Billing address" + +msgid "Billing events" +msgstr "Billing events" + +msgid "Billing info added" +msgstr "Billing info added" + +msgid "Billing info updated" +msgstr "Billing info updated" + +msgid "blended" +msgstr "blended" + +msgid "Blended MRR" +msgstr "Blended MRR" + +msgid "Browser" +msgstr "Browser" + +msgid "Cancel" +msgstr "Cancel" + +msgid "Canceled" +msgstr "Canceled" + +msgid "Canceling" +msgstr "Canceling" + +msgid "Cancellation" +msgstr "Cancellation" + +msgid "Cancelled" +msgstr "Cancelled" + +msgid "Cancelled immediately" +msgstr "Cancelled immediately" + msgid "Change language" msgstr "Change language" @@ -43,33 +223,160 @@ msgstr "Change theme" msgid "Change zoom level" msgstr "Change zoom level" -msgid "Coming soon" -msgstr "Coming soon" +msgid "Clear filter" +msgstr "Clear filter" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "Clear search" +msgstr "Clear search" + +msgid "Close" +msgstr "Close" + +msgid "Close account preview" +msgstr "Close account preview" msgid "Contact your administrator." msgstr "Contact your administrator." +msgid "Country" +msgstr "Country" + +msgid "Created" +msgstr "Created" + +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Created <0>{0}<1>{1}" + +msgid "Credit note" +msgstr "Credit note" + +msgid "Current period" +msgstr "Current period" + +msgid "Current plan" +msgstr "Current plan" + msgid "Dark" msgstr "Dark" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, currency) +msgid "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." +msgstr "Dashboard MRR mismatch: KPI shows {0}, trend latest shows {1}." + +msgid "Date" +msgstr "Date" + msgid "Default" msgstr "Default" -msgid "Feature flags" -msgstr "Feature flags" +msgid "Desktop" +msgstr "Desktop" + +msgid "Device" +msgstr "Device" + +msgid "Disaster recovery complete" +msgstr "Disaster recovery complete" + +msgid "Disaster recovery from archived Stripe events?" +msgstr "Disaster recovery from archived Stripe events?" + +msgid "Downgrade cancelled" +msgstr "Downgrade cancelled" + +msgid "Downgrade scheduled" +msgstr "Downgrade scheduled" + +msgid "Downgraded" +msgstr "Downgraded" + +msgid "Downgrading" +msgstr "Downgrading" + +msgid "Drift detected" +msgstr "Drift detected" + +msgid "Email" +msgstr "Email" + +msgid "Email confirmed" +msgstr "Email confirmed" + +msgid "Email pending" +msgstr "Email pending" -msgid "Feature flags (coming soon)" -msgstr "Feature flags (coming soon)" +msgid "Enter kiosk mode" +msgstr "Enter kiosk mode" + +msgid "Event" +msgstr "Event" + +msgid "Event view" +msgstr "Event view" + +msgid "Every invoice, refund, and credit note — the money in and out for this subscription." +msgstr "Every invoice, refund, and credit note — the money in and out for this subscription." + +msgid "Every invoice, refund, and credit note across all accounts." +msgstr "Every invoice, refund, and credit note across all accounts." + +msgid "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." +msgstr "Every sign-in attempt over the last 30 days, successful or failed, across email and external providers." + +msgid "Exit kiosk mode" +msgstr "Exit kiosk mode" + +msgid "Expired" +msgstr "Expired" + +msgid "Expires" +msgstr "Expires" + +msgid "Failed" +msgstr "Failed" + +msgid "Free" +msgstr "Free" msgid "Go to home" msgstr "Go to home" +msgid "Google" +msgstr "Google" + msgid "Hide details" msgstr "Hide details" +msgid "Inactive" +msgstr "Inactive" + +msgid "Invoice" +msgstr "Invoice" + +msgid "Invoice view" +msgstr "Invoice view" + +msgid "Invoices" +msgstr "Invoices" + +msgid "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." +msgstr "Invoices will appear here as accounts subscribe and Stripe webhooks are processed." + +msgid "IP address" +msgstr "IP address" + +msgid "Just now" +msgstr "Just now" + msgid "Language" msgstr "Language" @@ -79,6 +386,24 @@ msgstr "Large" msgid "Larger" msgstr "Larger" +msgid "Last {periodDays} days" +msgstr "Last {periodDays} days" + +msgid "Last 24 hours" +msgstr "Last 24 hours" + +msgid "Last invoice" +msgstr "Last invoice" + +msgid "Last log-in" +msgstr "Last log-in" + +msgid "Last seen" +msgstr "Last seen" + +msgid "Lifetime value" +msgstr "Lifetime value" + msgid "Light" msgstr "Light" @@ -100,30 +425,246 @@ msgstr "Log out" msgid "Logging in..." msgstr "Logging in..." +msgid "Login history" +msgstr "Login history" + +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" msgid "Main navigation" msgstr "Main navigation" -msgid "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." -msgstr "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." - msgid "Member" msgstr "Member" +msgid "Method" +msgstr "Method" + +msgid "Mobile" +msgstr "Mobile" + +msgid "Most recent activity" +msgstr "Most recent activity" + +msgid "MRR" +msgstr "MRR" + +msgid "MRR after" +msgstr "MRR after" + +msgid "MRR impact" +msgstr "MRR impact" + +msgid "MRR trend" +msgstr "MRR trend" + +msgid "Name" +msgstr "Name" + msgid "Navigation" msgstr "Navigation" +msgid "Never logged in" +msgstr "Never logged in" + +msgid "New accounts will appear here as they sign up." +msgstr "New accounts will appear here as they sign up." + +msgid "Next" +msgstr "Next" + +msgid "No account memberships" +msgstr "No account memberships" + +msgid "No accounts match your filters" +msgstr "No accounts match your filters" + +msgid "No accounts yet" +msgstr "No accounts yet" + msgid "No back-office access" msgstr "No back-office access" +msgid "No billing address on file." +msgstr "No billing address on file." + +msgid "No billing events" +msgstr "No billing events" + +msgid "No billing events match your filters" +msgstr "No billing events match your filters" + +msgid "No billing events yet" +msgstr "No billing events yet" + +msgid "No change" +msgstr "No change" + +msgid "No invoices yet" +msgstr "No invoices yet" + +msgid "No invoices, refunds, or credit notes yet." +msgstr "No invoices, refunds, or credit notes yet." + +msgid "No login history" +msgstr "No login history" + +msgid "No matching users" +msgstr "No matching users" + +msgid "No new billing events were appended. Account state matches Stripe." +msgstr "No new billing events were appended. Account state matches Stripe." + +msgid "No owners" +msgstr "No owners" + +msgid "No owners on this account." +msgstr "No owners on this account." + +msgid "No paid plan yet." +msgstr "No paid plan yet." + +msgid "No plan" +msgstr "No plan" + +msgid "No recent billing events" +msgstr "No recent billing events" + +msgid "No recent logins" +msgstr "No recent logins" + +msgid "No recent payments" +msgstr "No recent payments" + +msgid "No recent signups" +msgstr "No recent signups" + +msgid "No records yet" +msgstr "No records yet" + +msgid "No refunds or credit notes yet" +msgstr "No refunds or credit notes yet" + +msgid "No result available." +msgstr "No result available." + +msgid "No results match your search" +msgstr "No results match your search" + +msgid "No sessions" +msgstr "No sessions" + +msgid "No sign-in attempts in the last 30 days." +msgstr "No sign-in attempts in the last 30 days." + +msgid "No transactions" +msgstr "No transactions" + +msgid "No users" +msgstr "No users" + +msgid "No users match your filters." +msgstr "No users match your filters." + +msgid "No users match your search" +msgstr "No users match your search" + +msgid "No users yet" +msgstr "No users yet" + +msgid "Not synced yet" +msgstr "Not synced yet" + +msgid "Occurred" +msgstr "Occurred" + +msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." +msgstr "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." + +msgid "One-time password" +msgstr "One-time password" + +msgid "Open account" +msgstr "Open account" + +msgid "Open credit note" +msgstr "Open credit note" + +msgid "Open in Stripe" +msgstr "Open in Stripe" + +msgid "Open invoice" +msgstr "Open invoice" + +msgid "Other" +msgstr "Other" + +msgid "Outcome" +msgstr "Outcome" + +msgid "over period" +msgstr "over period" + +msgid "Overview" +msgstr "Overview" + msgid "Owner" msgstr "Owner" +msgid "Owners" +msgstr "Owners" + msgid "Page not found" msgstr "Page not found" +msgid "Paid" +msgstr "Paid" + +msgid "Past due" +msgstr "Past due" + +msgid "Payment failed" +msgstr "Payment failed" + +msgid "Payment method" +msgstr "Payment method" + +msgid "Payment method updated" +msgstr "Payment method updated" + +msgid "Payment recovered" +msgstr "Payment recovered" + +msgid "Payment refunded" +msgstr "Payment refunded" + +msgid "Pending" +msgstr "Pending" + +msgid "per month, billed monthly" +msgstr "per month, billed monthly" + +msgid "Period" +msgstr "Period" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Plan & revenue" + +msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." +msgstr "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." + +msgid "Plan distribution" +msgstr "Plan distribution" + +msgid "Plan transition" +msgstr "Plan transition" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,12 +677,142 @@ msgstr "Please try again or return to the home page." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Previous" + +msgid "Prior period" +msgstr "Prior period" + +msgid "Reactivated" +msgstr "Reactivated" + +msgid "Recent billing events" +msgstr "Recent billing events" + +msgid "Recent logins" +msgstr "Recent logins" + +msgid "Recent payments" +msgstr "Recent payments" + +msgid "Recent signups" +msgstr "Recent signups" + +msgid "Reconcile" +msgstr "Reconcile" + +msgid "Reconcile complete" +msgstr "Reconcile complete" + +msgid "Reconcile complete with drift detected" +msgstr "Reconcile complete with drift detected" + +#. placeholder {0}: archivedAwaiting.count +#. placeholder {1}: formatDate(archivedAwaiting.oldestOccurredAt) +#. placeholder {2}: formatDate(archivedAwaiting.newestOccurredAt) +msgid "Reconcile found {0} archived events older than Stripe's 30-day window, from {1} to {2}. This is a best-effort recovery from locally stored payloads and may produce incorrect rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." +msgstr "Reconcile found {0} archived events older than Stripe's 30-day window, from {1} to {2}. This is a best-effort recovery from locally stored payloads and may produce incorrect rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." + +msgid "Reconcile rebuilds this tenant's subscription and billing events from Stripe directly using the events.list API (the last 30 days). If the drift is not cleared afterwards, disaster recovery from the locally archived Stripe payloads is available as a last resort." +msgstr "Reconcile rebuilds this tenant's subscription and billing events from Stripe directly using the events.list API (the last 30 days). If the drift is not cleared afterwards, disaster recovery from the locally archived Stripe payloads is available as a last resort." + +msgid "Reconcile with Stripe" +msgstr "Reconcile with Stripe" + +msgid "Reconcile with Stripe?" +msgstr "Reconcile with Stripe?" + +msgid "Reconciling..." +msgstr "Reconciling..." + +msgid "Records will appear here as accounts subscribe and Stripe webhooks are processed." +msgstr "Records will appear here as accounts subscribe and Stripe webhooks are processed." + +msgid "Refunded" +msgstr "Refunded" + +msgid "Refunds and credit notes" +msgstr "Refunds and credit notes" + +msgid "Refunds and credit notes across all accounts." +msgstr "Refunds and credit notes across all accounts." + +msgid "Renewal" +msgstr "Renewal" + +msgid "Renewal date" +msgstr "Renewal date" + +msgid "Renewed" +msgstr "Renewed" + +#. placeholder {0}: formatDate(membership.renewalDate) +#. placeholder {0}: formatDate(tenant.renewalDate) +msgid "Renews {0}" +msgstr "Renews {0}" + +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.replayedAt) +msgid "Replayed {0} archived events into the billing event ledger at {1}." +msgstr "Replayed {0} archived events into the billing event ledger at {1}." + +msgid "Revenue" +msgstr "Revenue" + +msgid "Revoked" +msgstr "Revoked" + +msgid "Role" +msgstr "Role" + +msgid "Run disaster recovery" +msgstr "Run disaster recovery" + +msgid "Scheduled plan change" +msgstr "Scheduled plan change" + msgid "Screenshots of the dashboard project with desktop and mobile versions" msgstr "Screenshots of the dashboard project with desktop and mobile versions" +msgid "Search" +msgstr "Search" + +msgid "Search by account name" +msgstr "Search by account name" + +msgid "Search by email, name, or account" +msgstr "Search by email, name, or account" + +msgid "Search by name" +msgstr "Search by name" + +msgid "Search by name or email" +msgstr "Search by name or email" + +msgid "Search users" +msgstr "Search users" + +msgid "Search, filter, and review accounts." +msgstr "Search, filter, and review accounts." + +msgid "Sessions" +msgstr "Sessions" + msgid "Show details" msgstr "Show details" +msgid "Signed up" +msgstr "Signed up" + +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +msgid "Signed up <0>{0}<1>{1}" +msgstr "Signed up <0>{0}<1>{1}" + +#. placeholder {0}: formatDate(tenant.subscribedSince) +msgid "Since {0}" +msgstr "Since {0}" + msgid "Small" msgstr "Small" @@ -151,44 +822,149 @@ msgstr "Something went wrong" msgid "Standard" msgstr "Standard" -msgid "Support" -msgstr "Support" +msgid "Status" +msgstr "Status" + +msgid "Subscribed" +msgstr "Subscribed" + +msgid "Subscribed since" +msgstr "Subscribed since" + +msgid "Subscription state" +msgstr "Subscription state" + +msgid "Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed." +msgstr "Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed." -msgid "Support (coming soon)" -msgstr "Support (coming soon)" +msgid "Subscription, payment, and billing transitions will appear here." +msgstr "Subscription, payment, and billing transitions will appear here." + +msgid "Subscriptions, upgrades, and cancellations will appear here." +msgstr "Subscriptions, upgrades, and cancellations will appear here." + +msgid "Subtotal" +msgstr "Subtotal" + +msgid "Succeeded" +msgstr "Succeeded" + +msgid "Successful logins will appear here as users sign in." +msgstr "Successful logins will appear here as users sign in." + +msgid "Successful, pending, and failed invoices across all accounts." +msgstr "Successful, pending, and failed invoices across all accounts." + +msgid "Suspended" +msgstr "Suspended" msgid "System" msgstr "System" +msgid "Tablet" +msgstr "Tablet" + msgid "The page you are looking for does not exist or was moved." msgstr "The page you are looking for does not exist or was moved." msgid "Theme" msgstr "Theme" +msgid "This account has no users." +msgstr "This account has no users." + +msgid "this period" +msgstr "this period" + +msgid "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." +msgstr "This rebuilds the billing event ledger from this tenant's archived Stripe payloads. It is a best-effort recovery that may produce incorrect subscription state or billing event rows. Only run it when standard Reconcile with Stripe has been tried and did not clear the drift." + +msgid "This user has no recorded sessions." +msgstr "This user has no recorded sessions." + +msgid "This user is not a member of any account." +msgstr "This user is not a member of any account." + +msgid "Total" +msgstr "Total" + +msgid "Total accounts" +msgstr "Total accounts" + +msgid "Total revenue" +msgstr "Total revenue" + +msgid "Try a different search term or clear the role and activity filters." +msgstr "Try a different search term or clear the role and activity filters." + msgid "Try again" msgstr "Try again" +msgid "Try clearing the search or filters to see more results." +msgstr "Try clearing the search or filters to see more results." + +msgid "Try clearing the search to see more results." +msgstr "Try clearing the search to see more results." + +msgid "Unclassified" +msgstr "Unclassified" + +msgid "Unknown" +msgstr "Unknown" + +msgid "Upgraded" +msgstr "Upgraded" + msgid "User" msgstr "User" +msgid "User detail" +msgstr "User detail" + +msgid "User logins / day" +msgstr "User logins / day" + msgid "User menu" msgstr "User menu" msgid "Users" msgstr "Users" -msgid "Users (coming soon)" -msgstr "Users (coming soon)" +msgid "Users active" +msgstr "Users active" + +msgid "Users will appear here as accounts are created." +msgstr "Users will appear here as accounts are created." + +msgid "VAT" +msgstr "VAT" + +msgid "VAT number" +msgstr "VAT number" + +msgid "View accounts" +msgstr "View accounts" + +msgid "View all" +msgstr "View all" + +msgid "View all {totalEvents} events" +msgstr "View all {totalEvents} events" + +msgid "View all {totalTransactions} invoices" +msgstr "View all {totalTransactions} invoices" + +msgid "View billing events" +msgstr "View billing events" -msgid "Wait list" -msgstr "Wait list" +msgid "vs prior period" +msgstr "vs prior period" -msgid "Wait list (coming soon)" -msgstr "Wait list (coming soon)" +msgid "When" +msgstr "When" -msgid "Welcome to the Back Office" -msgstr "Welcome to the Back Office" +msgid "Yesterday" +msgstr "Yesterday" msgid "Your account is not in the required group." msgstr "Your account is not in the required group." diff --git a/application/account/Core/Configuration.cs b/application/account/Core/Configuration.cs index 8cd77ff312..611bbad97c 100644 --- a/application/account/Core/Configuration.cs +++ b/application/account/Core/Configuration.cs @@ -56,7 +56,10 @@ public IServiceCollection AddAccountServices() services.AddEmailRendering("WebApp"); services.AddMemoryCache(); - services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => new MockStripeState { PlatformCurrencyProvider = sp.GetRequiredService() }); + services.AddHostedService(); services.AddKeyedScoped("stripe"); services.AddKeyedScoped("mock-stripe"); services.AddKeyedScoped("unconfigured-stripe"); diff --git a/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs b/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs new file mode 100644 index 0000000000..6f40255830 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260509180000_AddBillingEventsAndDriftDetection")] +public sealed class AddBillingEventsAndDriftDetection : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("subscribed_since", "subscriptions", "timestamptz", nullable: true); + migrationBuilder.AddColumn("last_synced_stripe_event_created_at", "subscriptions", "timestamptz", nullable: true); + migrationBuilder.AddColumn("scheduled_price_amount", "subscriptions", "numeric(18,2)", nullable: true); + migrationBuilder.AddColumn("has_drift_detected", "subscriptions", "boolean", nullable: false, defaultValue: false); + migrationBuilder.AddColumn("drift_checked_at", "subscriptions", "timestamptz", nullable: true); + migrationBuilder.AddColumn("drift_discrepancies", "subscriptions", "jsonb", nullable: false, defaultValue: "[]"); + + migrationBuilder.CreateIndex("ix_subscriptions_has_drift_detected", "subscriptions", "has_drift_detected", filter: "has_drift_detected = true"); + + migrationBuilder.AddCheckConstraint( + "chk_subscriptions_payment_transactions_amounts_non_negative", + "subscriptions", + """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number") || !(@.InvoiceTotal.type() == "number") || !(@.AmountFromCredit.type() == "number") || @.AmountExcludingTax < 0 || @.TaxAmount < 0 || @.InvoiceTotal < 0 || @.AmountFromCredit < 0)')""" + ); + + // The billing_events table is append-only. The unique index on stripe_event_id enforces strict + // 1:1 with Stripe events: every recognized Stripe event yields exactly one row. Stripe's events.list + // API has a 30-day retention window (see https://docs.stripe.com/api/events), so the local + // stripe_events table is the authoritative source for replays beyond that window. + // Hard rule: NO migration ever drops, deletes from, or truncates this table. Schema changes use + // ALTER TABLE ADD/DROP COLUMN. Forensics and audit depend on full history being preserved. + // tenant_id is the soft-scope query filter for ITenantScopedEntity; no FK to tenants because the + // back-office is cross-tenant by design and uses IgnoreQueryFilters([QueryFilterNames.Tenant]). + // modified_at is inherited from the framework's AggregateRoot shape and remains NULL by design — + // billing_events is append-only forever (rows are never updated after insert). + migrationBuilder.CreateTable( + "billing_events", + table => new + { + tenant_id = table.Column("bigint", nullable: false), + id = table.Column("text", nullable: false), + subscription_id = table.Column("text", nullable: false), + created_at = table.Column("timestamptz", nullable: false), + modified_at = table.Column("timestamptz", nullable: true), + stripe_event_id = table.Column("text", nullable: false), + event_type = table.Column("text", nullable: false), + from_plan = table.Column("text", nullable: true), + to_plan = table.Column("text", nullable: true), + previous_amount = table.Column("numeric(18,2)", nullable: true), + new_amount = table.Column("numeric(18,2)", nullable: true), + amount_delta = table.Column("numeric(18,2)", nullable: true), + committed_mrr = table.Column("numeric(18,2)", nullable: false), + currency = table.Column("text", nullable: true), + occurred_at = table.Column("timestamptz", nullable: false), + cancellation_reason = table.Column("text", nullable: true), + suspension_reason = table.Column("text", nullable: true) + }, + constraints: table => { table.PrimaryKey("pk_billing_events", x => x.id); } + ); + + migrationBuilder.CreateIndex("ix_billing_events_stripe_event_id", "billing_events", "stripe_event_id", unique: true); + migrationBuilder.CreateIndex("ix_billing_events_tenant_id_occurred_at", "billing_events", ["tenant_id", "occurred_at"], descending: [false, true]); + migrationBuilder.CreateIndex("ix_billing_events_occurred_at", "billing_events", "occurred_at", descending: [true]); + migrationBuilder.CreateIndex("ix_billing_events_subscription_id", "billing_events", "subscription_id"); + + // stripe_events extensions for the multi-source reconciliation architecture. stripe_events is a + // passive archive of Stripe's webhook payloads — Stripe owns the payload schema, so we cannot + // constrain any column derived from payload content. Every column added here is nullable: Stripe + // is free to send null, omit fields, or change shape, and we never re-hash or backfill stored + // payloads. Rows are INSERT-only; the *state machine columns* (status, processed_at, error, + // tenant_id, stripe_subscription_id) are mutable as the row progresses Pending → Processed, but + // the *payload and payload-derived columns* below are immutable after insert. + // - api_version: pinned at event creation per https://docs.stripe.com/api/events; lets the + // replayer dispatch to the correct payload resolver when Stripe ships a new API version. + // - payload_hash: SHA-256 of the raw payload at first observation; lets AcknowledgeStripeWebhook + // detect StripeEventPayloadDivergence (same id, different payload) without comparing JSON bodies. + // - recovered_at / recovery_source: non-null when the event was added by reconciliation + // (events.list or webhook_endpoint_deliveries) rather than via webhook delivery — forensic + // marker that a webhook delivery was missed. + // - stripe_created_at: Stripe's authoritative Event.Created timestamp; legacy rows from before + // this column existed have NULL here and are never backfilled. + migrationBuilder.AddColumn("api_version", "stripe_events", "text", nullable: true); + migrationBuilder.AddColumn("recovered_at", "stripe_events", "timestamptz", nullable: true); + migrationBuilder.AddColumn("recovery_source", "stripe_events", "text", nullable: true); + migrationBuilder.AddColumn("payload_hash", "stripe_events", "text", nullable: true); + migrationBuilder.AddColumn("stripe_created_at", "stripe_events", "timestamptz", nullable: true); + + migrationBuilder.CreateIndex("ix_stripe_events_recovered_at", "stripe_events", "recovered_at", filter: "recovered_at IS NOT NULL"); + + // The platform's architectural promise is that every active Stripe price uses the same currency, + // derived from Stripe at startup (see PlatformCurrencyStartupResolver). The dashboard MRR handlers + // sum decimal amounts across every subscription / billing event without grouping by currency, so + // any row that does not use the platform currency corrupts the totals. The boundary guards in + // StripeClient and ReconcileTenantWithStripeHandler reject mismatched currencies before they reach + // persistence; these CHECK constraints are the structural format backstop so the invariant holds + // even if a future code path forgets the boundary check. The application uppercases currency on + // read via ToUpperInvariant() before persistence, so the constraint requires the canonical + // uppercase ISO-4217 form. Basis-only tenants have no current_price_currency (NULL), so the + // subscriptions constraint allows NULL. + migrationBuilder.AddCheckConstraint( + "chk_billing_events_currency_format", + "billing_events", + "currency ~ '^[A-Z]{3}$'" + ); + + migrationBuilder.AddCheckConstraint( + "chk_subscriptions_current_price_currency_format", + "subscriptions", + "current_price_currency IS NULL OR current_price_currency ~ '^[A-Z]{3}$'" + ); + + migrationBuilder.AddCheckConstraint( + "chk_subscriptions_payment_transactions_currency_format", + "subscriptions", + """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.Currency.type() == "string") || !(@.Currency like_regex "^[A-Z]{3}$"))')""" + ); + } +} diff --git a/application/account/Core/Features/Authentication/Domain/SessionRepository.cs b/application/account/Core/Features/Authentication/Domain/SessionRepository.cs index 79dc40e21d..eb39bfee22 100644 --- a/application/account/Core/Features/Authentication/Domain/SessionRepository.cs +++ b/application/account/Core/Features/Authentication/Domain/SessionRepository.cs @@ -4,6 +4,7 @@ using Npgsql; using SharedKernel.Authentication.TokenGeneration; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.Persistence; namespace Account.Features.Authentication.Domain; @@ -37,6 +38,27 @@ public interface ISessionRepository : ICrudRepository /// This method should only be used during token refresh where tenant context comes from the token claims. /// Task TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken); + + /// + /// Returns the paged session history for a single user without applying tenant query filters. Used by the + /// back-office User detail page where tenant context is not established. Includes both active and revoked + /// sessions, ordered most-recent first. + /// + Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUserUnfilteredAsync(UserId userId, int pageOffset, int pageSize, CancellationToken cancellationToken); + + /// + /// Returns the paged session history for any of the supplied user ids without applying tenant query filters. + /// Used by the back-office User detail page to surface sessions across every user record sharing the same email + /// across tenants. Includes active and revoked sessions, ordered most-recent first. + /// + Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUsersUnfilteredAsync(UserId[] userIds, int pageOffset, int pageSize, CancellationToken cancellationToken); + + /// + /// Counts active (not revoked) sessions created at or after across all tenants + /// without applying tenant query filters. Used by the back-office dashboard KPI snapshot for active sessions + /// in the last 24 hours. + /// + Task CountActiveSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class SessionRepository(AccountDbContext accountDbContext, IServiceProvider serviceProvider) @@ -117,6 +139,67 @@ public async Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] u return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); } + /// + /// Returns the paged session history for a single user without applying tenant query filters. Used by the + /// back-office User detail page where tenant context is not established. Includes both active and revoked + /// sessions, ordered most-recent first. SQLite cannot translate DateTimeOffset comparisons in ORDER BY, so + /// sessions are materialized and ordered in memory; a single user has very few sessions so scale is not a + /// concern. + /// + public async Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUserUnfilteredAsync(UserId userId, int pageOffset, int pageSize, CancellationToken cancellationToken) + { + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => s.UserId == userId) + .ToArrayAsync(cancellationToken); + + var ordered = sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); + + var totalItems = ordered.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var page = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (page, totalItems, totalPages); + } + + /// + /// Returns the paged session history for any of the supplied user ids without applying tenant query filters. + /// Used by the back-office User detail page to surface sessions across every user record sharing the same email + /// across tenants. SQLite cannot translate DateTimeOffset comparisons in ORDER BY, so sessions are materialized + /// and ordered in memory; the cross-tenant set for one person is small enough that scale is not a concern. + /// + public async Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUsersUnfilteredAsync(UserId[] userIds, int pageOffset, int pageSize, CancellationToken cancellationToken) + { + if (userIds.Length == 0) return ([], 0, 0); + + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => userIds.AsEnumerable().Contains(s.UserId)) + .ToArrayAsync(cancellationToken); + + var ordered = sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); + + var totalItems = ordered.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var page = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (page, totalItems, totalPages); + } + + /// + /// Counts active (not revoked) sessions created at or after across all tenants + /// without applying tenant query filters. Used by the back-office dashboard KPI snapshot for active sessions + /// in the last 24 hours. SQLite cannot translate DateTimeOffset comparisons in WHERE, so sessions are + /// materialized and filtered in memory; the bounded 24-hour window keeps the set small. + /// + public async Task CountActiveSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => s.RevokedAt == null) + .Select(s => new { s.CreatedAt }) + .ToArrayAsync(cancellationToken); + return sessions.LongCount(s => s.CreatedAt >= since); + } + private async Task OpenFallbackConnectionAsync(CancellationToken cancellationToken) { var existingConnection = accountDbContext.Database.GetDbConnection(); diff --git a/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs new file mode 100644 index 0000000000..3ff63cfb70 --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs @@ -0,0 +1,21 @@ +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.BillingDrift.Queries; + +[PublicAPI] +public sealed record GetBillingDriftSummaryQuery : IRequest>; + +[PublicAPI] +public sealed record BillingDriftSummaryResponse(int SubscriptionsWithDriftCount); + +public sealed class GetBillingDriftSummaryHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + public async Task> Handle(GetBillingDriftSummaryQuery query, CancellationToken cancellationToken) + { + var count = await subscriptionRepository.CountWithDriftDetectedUnfilteredAsync(cancellationToken); + return new BillingDriftSummaryResponse(count); + } +} diff --git a/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs new file mode 100644 index 0000000000..d031e0b2cc --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs @@ -0,0 +1,44 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Integrations.Stripe; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.BillingDrift.Queries; + +[PublicAPI] +public sealed record GetDashboardMrrConsistencySummaryQuery : IRequest>; + +[PublicAPI] +public sealed record DashboardMrrConsistencySummaryResponse(decimal KpiMonthlyRecurringRevenue, decimal TrendLatestMonthlyRecurringRevenue, string? Currency); + +public sealed class GetDashboardMrrConsistencySummaryHandler(ISubscriptionRepository subscriptionRepository, IBillingEventRepository billingEventRepository, IPlatformCurrencyProvider platformCurrencyProvider) + : IRequestHandler> +{ + // Soft-delete semantic: both sides of the consistency comparison include every active subscription / MRR-changing + // billing event regardless of tenant soft-delete state. Subscription and BillingEvent rows are immutable historical + // money facts; the comparison only stays meaningful if both sides share the same retention rule. + + // Sub-cent diffs between KPI and trend-latest MRR are accounting noise, not drift. The FE banner + // does strict equality so we snap trend-latest to KPI when the absolute diff is below tolerance. + private const decimal ToleranceAmount = 0.01m; + + public async Task> Handle(GetDashboardMrrConsistencySummaryQuery query, CancellationToken cancellationToken) + { + var paidSubscriptions = await subscriptionRepository.GetAllActiveUnfilteredAsync(cancellationToken); + var kpiMrr = paidSubscriptions.Sum(MrrCalculator.ForwardMrr); + + // Trend-latest MRR — mirrors GetDashboardMrrTrendHandler: per subscription, take the latest event's NewAmount. + var events = await billingEventRepository.GetMrrChangeEventsUnfilteredAsync(cancellationToken); + var trendLatestMrr = events + .GroupBy(e => e.SubscriptionId) + .Sum(g => g.OrderByDescending(e => e.OccurredAt).First().NewAmount ?? 0m); + + if (Math.Abs(kpiMrr - trendLatestMrr) < ToleranceAmount) + { + trendLatestMrr = kpiMrr; + } + + return new DashboardMrrConsistencySummaryResponse(kpiMrr, trendLatestMrr, platformCurrencyProvider.Currency); + } +} diff --git a/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetUnsyncedSubscriptionsSummary.cs b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetUnsyncedSubscriptionsSummary.cs new file mode 100644 index 0000000000..0130a53663 --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetUnsyncedSubscriptionsSummary.cs @@ -0,0 +1,21 @@ +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.BillingDrift.Queries; + +[PublicAPI] +public sealed record GetUnsyncedSubscriptionsSummaryQuery : IRequest>; + +[PublicAPI] +public sealed record UnsyncedSubscriptionsSummaryResponse(int UnsyncedSubscriptionsCount); + +public sealed class GetUnsyncedSubscriptionsSummaryHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + public async Task> Handle(GetUnsyncedSubscriptionsSummaryQuery query, CancellationToken cancellationToken) + { + var count = await subscriptionRepository.CountWithoutBillingEventsUnfilteredAsync(cancellationToken); + return new UnsyncedSubscriptionsSummaryResponse(count); + } +} diff --git a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs new file mode 100644 index 0000000000..31b0f62cc5 --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs @@ -0,0 +1,148 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.BackOffice.BillingEvents.Queries; + +[PublicAPI] +public sealed record GetBackOfficeBillingEventsQuery( + string? Search = null, + BillingEventType[]? EventTypes = null, + DateTimeOffset? OccurredFrom = null, + DateTimeOffset? OccurredTo = null, + TenantId? TenantId = null, + SortableBillingEventProperties OrderBy = SortableBillingEventProperties.OccurredAt, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public BillingEventType[] EventTypes { get; } = EventTypes ?? []; +} + +[PublicAPI] +public sealed record BillingEventsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BillingEventSummary[] BillingEvents); + +[PublicAPI] +public sealed record BillingEventSummary( + BillingEventId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + string? Country, + BillingEventType EventType, + SubscriptionPlan? FromPlan, + SubscriptionPlan? ToPlan, + decimal? AmountDelta, + decimal? PreviousAmount, + decimal? NewAmount, + decimal CommittedMrr, + string? Currency, + DateTimeOffset OccurredAt +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableBillingEventProperties +{ + OccurredAt, + EventType, + TenantName +} + +public sealed class GetBackOfficeBillingEventsQueryValidator : AbstractValidator +{ + public GetBackOfficeBillingEventsQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.EventTypes.Length).LessThanOrEqualTo(25).WithMessage("Event types filter must contain no more than 25 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeBillingEventsHandler( + IBillingEventRepository billingEventRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeBillingEventsQuery query, CancellationToken cancellationToken) + { + var billingEvents = await billingEventRepository.SearchAllUnfilteredAsync(query.EventTypes, query.OccurredFrom, query.OccurredTo, cancellationToken); + + if (query.TenantId is not null) + { + billingEvents = billingEvents.Where(e => e.TenantId == query.TenantId).ToArray(); + } + + var tenantIds = billingEvents.Select(e => e.TenantId).Distinct().ToArray(); + var tenants = tenantIds.Length == 0 + ? [] + : await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var subscriptions = tenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + var summaries = billingEvents + .Where(e => tenantsById.ContainsKey(e.TenantId)) + .Select(e => + { + var tenant = tenantsById[e.TenantId]; + var subscription = subscriptionsByTenantId.GetValueOrDefault(tenant.Id); + return new BillingEventSummary( + e.Id, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + subscription?.BillingInfo?.Address?.Country, + e.EventType, + e.FromPlan, + e.ToPlan, + e.AmountDelta, + e.PreviousAmount, + e.NewAmount, + e.CommittedMrr, + e.Currency, + e.OccurredAt + ); + } + ) + .ToArray(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + summaries = summaries.Where(s => s.TenantName.ToLower().Contains(query.Search)).ToArray(); + } + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (SortableBillingEventProperties.EventType, SortOrder.Ascending) => summaries.OrderBy(s => s.EventType).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.EventType, _) => summaries.OrderByDescending(s => s.EventType).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.TenantName, SortOrder.Ascending) => summaries.OrderBy(s => s.TenantName).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.TenantName, _) => summaries.OrderByDescending(s => s.TenantName).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.OccurredAt, SortOrder.Ascending) => summaries.OrderBy(s => s.OccurredAt), + _ => summaries.OrderByDescending(s => s.OccurredAt) + }; + + var totalCount = summaries.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new BillingEventsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/DashboardMrrCalculator.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/DashboardMrrCalculator.cs new file mode 100644 index 0000000000..87d1686f12 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/DashboardMrrCalculator.cs @@ -0,0 +1,33 @@ +using Account.Features.Subscriptions.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +/// +/// Reconstructs MRR on a given date from the log: for each subscription, +/// the most recent event with NewAmount set (and OccurredAt before end-of-day on the date) is its +/// committed MRR for that day. Shared between (which uses +/// it to build the daily series) and (which uses it to compute +/// the period-over-period delta against the start-of-window value). +/// +internal static class DashboardMrrCalculator +{ + public static decimal ComputeMrrOnDate(Dictionary eventsBySubscription, DateOnly date) + { + var endOfDay = new DateTimeOffset(date.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var total = 0m; + foreach (var subscriptionEvents in eventsBySubscription.Values) + { + var latest = subscriptionEvents.LastOrDefault(e => e.OccurredAt < endOfDay); + if (latest?.NewAmount is { } amount) total += amount; + } + + return total; + } + + public static Dictionary GroupByOccurredAt(BillingEvent[] events) + { + return events + .GroupBy(e => e.SubscriptionId) + .ToDictionary(g => g.Key, g => g.OrderBy(e => e.OccurredAt).ToArray()); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs new file mode 100644 index 0000000000..e71d6ead07 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs @@ -0,0 +1,142 @@ +using Account.Features.Authentication.Domain; +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using Account.Integrations.Stripe; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardKpisQuery(DashboardTrendPeriod Period = DashboardTrendPeriod.Last30Days) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardKpisResponse( + DashboardTrendPeriod Period, + long TotalTenants, + long ActiveTenants, + long TrialTenants, + long CanceledTenants, + long NewTenantsInPeriod, + long? NewTenantsDeltaPercent, + long TotalUsers, + long ActiveUsersInPeriod, + decimal BlendedMonthlyRecurringRevenue, + decimal? BlendedMonthlyRecurringRevenueDeltaPercent, + decimal TotalRevenue, + string? Currency, + long ActiveSessionsLast24Hours +); + +public sealed class GetDashboardKpisQueryValidator : AbstractValidator +{ + public GetDashboardKpisQueryValidator() + { + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +public sealed class GetDashboardKpisHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + ISessionRepository sessionRepository, + ISubscriptionRepository subscriptionRepository, + IBillingEventRepository billingEventRepository, + IPlatformCurrencyProvider platformCurrencyProvider, + TimeProvider timeProvider +) : IRequestHandler> +{ + // Soft-delete semantic: tenant counts (Total/Active/Trial/Canceled, NewTenantsInPeriod) exclude soft-deleted + // tenants — a deleted tenant is no longer a tenant. BLENDED MRR sums every active subscription regardless of + // tenant soft-delete state — subscription/billing rows are immutable historical money facts that outlive the + // tenant lifecycle, so MRR must not silently drop the moment a paying tenant churns. + public async Task> Handle(GetDashboardKpisQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var twentyFourHoursAgo = now.AddHours(-24); + var periodStart = now.AddDays(-days); + var priorPeriodStart = now.AddDays(-days * 2); + + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var paidSubscriptions = await subscriptionRepository.GetAllActiveUnfilteredAsync(cancellationToken); + var allUsers = await userRepository.GetAllUnfilteredAsync(cancellationToken); + var activeSessions = await sessionRepository.CountActiveSinceUnfilteredAsync(twentyFourHoursAgo, cancellationToken); + + // HasEverSubscribed is the same heuristic used by GetTenants/GetTenantsResponse: a successful payment + // exists in the subscription's payment history. We need it to disambiguate Trial (never paid) from Canceled + // (was paying, now on free Basis plan). Subscriptions on the free plan are not loaded by GetAllActiveUnfilteredAsync, + // so look up the full set via tenant ids only when needed. + var freePlanTenantIds = tenants.Where(t => t.Plan == SubscriptionPlan.Basis).Select(t => t.Id).ToArray(); + var freePlanSubscriptions = freePlanTenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(freePlanTenantIds, cancellationToken); + var hasEverSubscribedByTenantId = freePlanSubscriptions.ToDictionary( + s => s.TenantId, + s => s.PaymentTransactions.Any(t => t.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) + ); + + var totalTenants = tenants.LongLength; + var activeTenants = tenants.LongCount(t => t.State == TenantState.Active && t.Plan != SubscriptionPlan.Basis); + var trialTenants = tenants.LongCount(t => + t is { State: TenantState.Active, Plan: SubscriptionPlan.Basis } && + !hasEverSubscribedByTenantId.GetValueOrDefault(t.Id) + ); + var canceledTenants = tenants.LongCount(t => + t.Plan == SubscriptionPlan.Basis && + hasEverSubscribedByTenantId.GetValueOrDefault(t.Id) + ); + + var newTenantsInPeriod = tenants.LongCount(t => t.CreatedAt >= periodStart); + var newTenantsInPriorPeriod = tenants.LongCount(t => t.CreatedAt >= priorPeriodStart && t.CreatedAt < periodStart); + var newTenantsDeltaPercent = newTenantsInPriorPeriod == 0 + ? (long?)null + : (long)Math.Round((double)(newTenantsInPeriod - newTenantsInPriorPeriod) / newTenantsInPriorPeriod * 100d); + + var activeUsersInPeriod = allUsers.LongCount(u => u.LastSeenAt >= periodStart); + + var totalMonthlyRecurringRevenue = paidSubscriptions.Sum(MrrCalculator.ForwardMrr); + + // Total Revenue: ex-VAT lifetime revenue across every subscription (paid + cancelled). VAT is collected + // on behalf of tax authorities and never our revenue, so the sum uses AmountExcludingTax. Credit-noted + // and refunded rows are excluded — money returned to the customer is not revenue. + var totalRevenue = paidSubscriptions.Concat(freePlanSubscriptions) + .SelectMany(s => s.PaymentTransactions) + .Where(t => t is { Status: PaymentTransactionStatus.Succeeded, CreditNoteUrl: null, RefundedAt: null }) + .Sum(t => t.AmountExcludingTax); + + // Period-over-period MRR delta mirrors the MRR trend card's "over period" subtitle: today's + // blended MRR vs the blended MRR at the start of the window. Reconstructed from the BillingEvent + // log using the shared DashboardMrrCalculator so both tile and trend chart show the same number. + var billingEvents = await billingEventRepository.GetMrrChangeEventsUnfilteredAsync(cancellationToken); + var eventsBySubscription = DashboardMrrCalculator.GroupByOccurredAt(billingEvents); + var today = DateOnly.FromDateTime(now.UtcDateTime); + var startOfWindow = today.AddDays(-(days - 1)); + var endMrr = DashboardMrrCalculator.ComputeMrrOnDate(eventsBySubscription, today); + var startMrr = DashboardMrrCalculator.ComputeMrrOnDate(eventsBySubscription, startOfWindow); + var mrrDeltaPercent = startMrr == 0m + ? (decimal?)null + : Math.Round((endMrr - startMrr) / startMrr * 100m, 1); + + return new BackOfficeDashboardKpisResponse( + query.Period, + totalTenants, + activeTenants, + trialTenants, + canceledTenants, + newTenantsInPeriod, + newTenantsDeltaPercent, + allUsers.LongLength, + activeUsersInPeriod, + totalMonthlyRecurringRevenue, + mrrDeltaPercent, + totalRevenue, + platformCurrencyProvider.Currency, + activeSessions + ); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs new file mode 100644 index 0000000000..7c93b49c27 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs @@ -0,0 +1,73 @@ +using Account.Features.Subscriptions.Domain; +using Account.Integrations.Stripe; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardMrrTrendQuery(DashboardTrendPeriod Period = DashboardTrendPeriod.Last30Days) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardMrrTrendResponse( + DashboardTrendPeriod Period, + string? Currency, + BackOfficeDashboardMrrTrendPoint[] Points, + BackOfficeDashboardMrrTrendPoint[] PriorPoints +); + +[PublicAPI] +public sealed record BackOfficeDashboardMrrTrendPoint(DateOnly Date, decimal MonthlyRecurringRevenue); + +public sealed class GetDashboardMrrTrendQueryValidator : AbstractValidator +{ + public GetDashboardMrrTrendQueryValidator() + { + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +/// +/// Reconstructs historical MRR from the log: the trend is the sum of each +/// subscription's latest NewAmount as-of each day in the window. This handler reads a different +/// writer than the dashboard KPI tile: the trend's source is the events.list writer (BillingEvent.NewAmount), +/// while the KPI reads the live Stripe-object writer (Subscription.ScheduledPriceAmount / current price). +/// The two writers run on different code paths and converge only after the BillingEvent is appended for a +/// given subscription change, so the MrrMismatchBanner may fire transiently during catalog edits +/// or while a Stripe event is in-flight. That is expected and self-heals once events are processed. +/// +public sealed class GetDashboardMrrTrendHandler(IBillingEventRepository billingEventRepository, IPlatformCurrencyProvider platformCurrencyProvider, TimeProvider timeProvider) + : IRequestHandler> +{ + // Soft-delete semantic: every historical point sums the MRR from every subscription active at that time, + // regardless of whether the tenant has since been soft-deleted. BillingEvent rows are immutable historical + // money facts that outlive the tenant lifecycle, so a deleted tenant must appear in the historical curve at + // the period it was paying — otherwise the trend silently rewrites the past every time a tenant churns. + public async Task> Handle(GetDashboardMrrTrendQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var today = DateOnly.FromDateTime(now.UtcDateTime); + var startDate = today.AddDays(-(days - 1)); + var priorStartDate = startDate.AddDays(-days); + + // Reconstruct historical MRR from the BillingEvent log: for each subscription, the most recent + // event with NewAmount set (and OccurredAt before end-of-day) is its committed MRR for that day. + var events = await billingEventRepository.GetMrrChangeEventsUnfilteredAsync(cancellationToken); + var eventsBySubscription = DashboardMrrCalculator.GroupByOccurredAt(events); + + var points = new BackOfficeDashboardMrrTrendPoint[days]; + var priorPoints = new BackOfficeDashboardMrrTrendPoint[days]; + for (var index = 0; index < days; index++) + { + var currentDate = startDate.AddDays(index); + var priorDate = priorStartDate.AddDays(index); + points[index] = new BackOfficeDashboardMrrTrendPoint(currentDate, DashboardMrrCalculator.ComputeMrrOnDate(eventsBySubscription, currentDate)); + priorPoints[index] = new BackOfficeDashboardMrrTrendPoint(priorDate, DashboardMrrCalculator.ComputeMrrOnDate(eventsBySubscription, priorDate)); + } + + return new BackOfficeDashboardMrrTrendResponse(query.Period, platformCurrencyProvider.Currency, points, priorPoints); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs new file mode 100644 index 0000000000..8db75d4d37 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs @@ -0,0 +1,45 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardPlanDistributionQuery : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardPlanDistributionResponse( + long TotalTenants, + BackOfficeDashboardPlanDistributionEntry[] Distribution +); + +[PublicAPI] +public sealed record BackOfficeDashboardPlanDistributionEntry(SubscriptionPlan Plan, long Count, double Percentage); + +public sealed class GetDashboardPlanDistributionHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + // Soft-delete semantic: this is a forward-looking current-state snapshot. Soft-deleted tenants are excluded — + // a deleted tenant has no "current plan" by definition. GetAllUnfilteredAsync bypasses the tenant scope filter + // but the SoftDelete query filter still applies, so deleted tenants drop out automatically. + public async Task> Handle(GetDashboardPlanDistributionQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var totalTenants = tenants.LongLength; + + // Distribution always returns one entry per known plan, even when zero, so the donut renders consistent + // legend slots regardless of the current data shape. + var distribution = Enum.GetValues() + .Select(plan => + { + var count = tenants.LongCount(t => t.Plan == plan); + var percentage = totalTenants == 0 ? 0d : Math.Round((double)count / totalTenants * 100d, 1); + return new BackOfficeDashboardPlanDistributionEntry(plan, count, percentage); + } + ) + .ToArray(); + + return new BackOfficeDashboardPlanDistributionResponse(totalTenants, distribution); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentLogins.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentLogins.cs new file mode 100644 index 0000000000..2e9f35eb76 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentLogins.cs @@ -0,0 +1,115 @@ +using Account.Features.Authentication.Domain; +using Account.Features.EmailAuthentication.Domain; +using Account.Features.ExternalAuthentication.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentLoginsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentLoginsResponse(BackOfficeDashboardLogin[] Logins); + +[PublicAPI] +public sealed record BackOfficeDashboardLogin( + UserId? UserId, + string Email, + string? FirstName, + string? LastName, + string? AvatarUrl, + TenantId? TenantId, + string? TenantName, + string? TenantLogoUrl, + LoginMethod Method, + DateTimeOffset OccurredAt +); + +public sealed class GetDashboardRecentLoginsQueryValidator : AbstractValidator +{ + public GetDashboardRecentLoginsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentLoginsHandler( + IEmailLoginRepository emailLoginRepository, + IExternalLoginRepository externalLoginRepository, + IUserRepository userRepository, + ITenantRepository tenantRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + private const int LookbackDays = 30; + + public async Task> Handle(GetDashboardRecentLoginsQuery query, CancellationToken cancellationToken) + { + var since = timeProvider.GetUtcNow().AddDays(-LookbackDays); + var emailLogins = await emailLoginRepository.GetCompletedSinceAsync(since, cancellationToken); + var externalLogins = await externalLoginRepository.GetSucceededSinceAsync(since, cancellationToken); + + var entries = emailLogins.Select(e => new LoginEntry(e.Email, LoginMethod.OneTimePassword, e.CreatedAt)) + .Concat(externalLogins.Where(e => e.Email is not null).Select(e => new LoginEntry(e.Email!, MapExternalMethod(e.ProviderType), e.CreatedAt))) + .OrderByDescending(e => e.OccurredAt) + .Take(query.Limit) + .ToArray(); + + if (entries.Length == 0) return new BackOfficeDashboardRecentLoginsResponse([]); + + // Login aggregates store email rather than user id (an email can map to multiple users across tenants). + // Resolve to the first user per email so the dashboard row can show a name and the tenant context; + // operators can still drill into the user/account detail pages for full disambiguation. + var distinctEmails = entries.Select(e => e.Email).Distinct().ToArray(); + var userByEmail = new Dictionary(); + foreach (var email in distinctEmails) + { + var users = await userRepository.GetUsersByEmailUnfilteredAsync(email, cancellationToken); + if (users.Length > 0) userByEmail[email] = users[0]; + } + + var tenantIds = userByEmail.Values.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = tenantIds.Length == 0 + ? [] + : await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var logins = entries.Select(entry => + { + var user = userByEmail.GetValueOrDefault(entry.Email); + var tenant = user is not null ? tenantsById.GetValueOrDefault(user.TenantId) : null; + return new BackOfficeDashboardLogin( + user?.Id, + entry.Email, + user?.FirstName, + user?.LastName, + user?.Avatar.Url, + user?.TenantId, + tenant?.Name, + tenant?.Logo.Url, + entry.Method, + entry.OccurredAt + ); + } + ).ToArray(); + + return new BackOfficeDashboardRecentLoginsResponse(logins); + } + + private static LoginMethod MapExternalMethod(ExternalProviderType providerType) + { + return providerType switch + { + ExternalProviderType.Google => LoginMethod.Google, + _ => throw new UnreachableException($"Unknown external provider type '{providerType}'.") + }; + } + + private sealed record LoginEntry(string Email, LoginMethod Method, DateTimeOffset OccurredAt); +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentPayments.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentPayments.cs new file mode 100644 index 0000000000..6cf36424ed --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentPayments.cs @@ -0,0 +1,75 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentPaymentsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentPaymentsResponse(BackOfficeDashboardPayment[] Payments); + +[PublicAPI] +public sealed record BackOfficeDashboardPayment( + PaymentTransactionId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + DateTimeOffset Date, + SubscriptionPlan? Plan, + decimal Amount, + string Currency, + PaymentTransactionStatus Status +); + +public sealed class GetDashboardRecentPaymentsQueryValidator : AbstractValidator +{ + public GetDashboardRecentPaymentsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentPaymentsHandler(ISubscriptionRepository subscriptionRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRecentPaymentsQuery query, CancellationToken cancellationToken) + { + var subscriptions = await subscriptionRepository.GetAllWithTransactionsUnfilteredAsync(cancellationToken); + if (subscriptions.Length == 0) return new BackOfficeDashboardRecentPaymentsResponse([]); + + var tenantIds = subscriptions.Select(s => s.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var payments = subscriptions + .Where(s => tenantsById.ContainsKey(s.TenantId)) + .SelectMany(subscription => subscription.PaymentTransactions.Select(transaction => + { + var tenant = tenantsById[subscription.TenantId]; + return new BackOfficeDashboardPayment( + transaction.Id, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + transaction.Date, + transaction.Plan, + transaction.Amount, + transaction.Currency, + transaction.Status + ); + } + ) + ) + .OrderByDescending(p => p.Date) + .Take(query.Limit) + .ToArray(); + + return new BackOfficeDashboardRecentPaymentsResponse(payments); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs new file mode 100644 index 0000000000..ff421877ff --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs @@ -0,0 +1,61 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentSignupsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignupsResponse(BackOfficeDashboardRecentSignup[] Signups); + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignup( + TenantId TenantId, + string Name, + string? TenantLogoUrl, + DateTimeOffset CreatedAt, + BackOfficeDashboardRecentSignupOwner? Owner +); + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignupOwner(UserId UserId, string? FirstName, string? LastName, string Email); + +public sealed class GetDashboardRecentSignupsQueryValidator : AbstractValidator +{ + public GetDashboardRecentSignupsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentSignupsHandler(ITenantRepository tenantRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRecentSignupsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetMostRecentSignupsUnfilteredAsync(query.Limit, cancellationToken); + var tenantIds = tenants.Select(t => t.Id).ToArray(); + var ownerByTenantId = await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + + var signups = tenants.Select(tenant => + { + var owner = ownerByTenantId.GetValueOrDefault(tenant.Id); + return new BackOfficeDashboardRecentSignup( + tenant.Id, + tenant.Name, + tenant.Logo.Url, + tenant.CreatedAt, + owner is null ? null : new BackOfficeDashboardRecentSignupOwner(owner.Id, owner.FirstName, owner.LastName, owner.Email) + ); + } + ).ToArray(); + + return new BackOfficeDashboardRecentSignupsResponse(signups); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs new file mode 100644 index 0000000000..368e10a6ea --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs @@ -0,0 +1,74 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentStripeEventsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentStripeEventsResponse(BackOfficeDashboardStripeEvent[] Events); + +[PublicAPI] +public sealed record BackOfficeDashboardStripeEvent( + BillingEventId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + BillingEventType Type, + SubscriptionPlan? FromPlan, + SubscriptionPlan? ToPlan, + decimal? AmountDelta, + string? Currency, + DateTimeOffset OccurredAt +); + +public sealed class GetDashboardRecentStripeEventsQueryValidator : AbstractValidator +{ + public GetDashboardRecentStripeEventsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentStripeEventsHandler(IBillingEventRepository billingEventRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRecentStripeEventsQuery query, CancellationToken cancellationToken) + { + var billingEvents = await billingEventRepository.GetRecentUnfilteredAsync(query.Limit, cancellationToken); + if (billingEvents.Length == 0) return new BackOfficeDashboardRecentStripeEventsResponse([]); + + var tenantIds = billingEvents.Select(e => e.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var events = billingEvents + .Where(e => tenantsById.ContainsKey(e.TenantId)) + .Select(e => + { + var tenant = tenantsById[e.TenantId]; + return new BackOfficeDashboardStripeEvent( + e.Id, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + e.EventType, + e.FromPlan, + e.ToPlan, + e.AmountDelta, + e.Currency, + e.OccurredAt + ); + } + ) + .ToArray(); + + return new BackOfficeDashboardRecentStripeEventsResponse(events); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRevenueTrend.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRevenueTrend.cs new file mode 100644 index 0000000000..a3cd349399 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRevenueTrend.cs @@ -0,0 +1,113 @@ +using Account.Features.Subscriptions.Domain; +using Account.Integrations.Stripe; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRevenueTrendQuery(DashboardTrendPeriod Period = DashboardTrendPeriod.Last30Days) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRevenueTrendResponse( + DashboardTrendPeriod Period, + string? Currency, + BackOfficeDashboardRevenueTrendPoint[] Points, + BackOfficeDashboardRevenueTrendPoint[] PriorPoints +); + +[PublicAPI] +public sealed record BackOfficeDashboardRevenueTrendPoint(DateOnly Date, decimal Revenue); + +public sealed class GetDashboardRevenueTrendQueryValidator : AbstractValidator +{ + public GetDashboardRevenueTrendQueryValidator() + { + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +/// +/// Cumulative ex-VAT revenue across every subscription's payment transactions, within the selected +/// trend period. Each successful charge adds to its +/// bucket; a later credit note subtracts the same amount from its +/// bucket, falling back to +/// when the credit-note timestamp is missing; a +/// refund-without-credit-note subtracts from its bucket. The chart shows +/// the running balance over +/// time — historic days reflect what was true at that point, and reversals dip the line on the day they +/// happened. Net contribution of any reversed transaction is zero, so the end-of-window cumulative +/// matches the Total Revenue tile (which sums only un-reversed transactions). The prior-period series is +/// the same all-time cumulative sampled across the equivalent window immediately before — when the +/// current line stays above the prior line, the platform is accumulating revenue faster than it did in +/// the prior window. Soft-delete semantic: historical revenue from soft-deleted tenants stays in the +/// curve because payment transactions are immutable historical money facts that outlive the tenant +/// lifecycle. +/// +public sealed class GetDashboardRevenueTrendHandler(ISubscriptionRepository subscriptionRepository, IPlatformCurrencyProvider platformCurrencyProvider, TimeProvider timeProvider) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRevenueTrendQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var today = DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime); + var startDate = today.AddDays(-(days - 1)); + var priorStartDate = startDate.AddDays(-days); + + var subscriptions = await subscriptionRepository.GetAllWithTransactionsUnfilteredAsync(cancellationToken); + var deltasByDay = ComputeDailyDeltas(subscriptions.SelectMany(s => s.PaymentTransactions)); + var cumulativeBeforePrior = deltasByDay.Where(d => d.Key < priorStartDate).Sum(d => d.Value); + var cumulativeBeforeCurrent = deltasByDay.Where(d => d.Key < startDate).Sum(d => d.Value); + + var points = new BackOfficeDashboardRevenueTrendPoint[days]; + var priorPoints = new BackOfficeDashboardRevenueTrendPoint[days]; + var currentCumulative = cumulativeBeforeCurrent; + var priorCumulative = cumulativeBeforePrior; + for (var index = 0; index < days; index++) + { + var currentDate = startDate.AddDays(index); + var priorDate = priorStartDate.AddDays(index); + currentCumulative += deltasByDay.GetValueOrDefault(currentDate, 0m); + priorCumulative += deltasByDay.GetValueOrDefault(priorDate, 0m); + points[index] = new BackOfficeDashboardRevenueTrendPoint(currentDate, currentCumulative); + priorPoints[index] = new BackOfficeDashboardRevenueTrendPoint(priorDate, priorCumulative); + } + + return new BackOfficeDashboardRevenueTrendResponse(query.Period, platformCurrencyProvider.Currency, points, priorPoints); + } + + private static Dictionary ComputeDailyDeltas(IEnumerable transactions) + { + var deltasByDay = new Dictionary(); + foreach (var transaction in transactions) + { + // Add on the payment day for every charge that succeeded — even ones that were later reversed. + // The chart is a running balance; reversals show up as a separate dip on the day they happened. + if (transaction.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) + { + var paidOn = DateOnly.FromDateTime(transaction.Date.UtcDateTime); + deltasByDay[paidOn] = deltasByDay.GetValueOrDefault(paidOn) + transaction.AmountExcludingTax; + } + + // Subtract on the reversal day. A credit note encompasses any associated refund, so we subtract + // once when both are present. Prefer the credit-note timestamp, then the refund timestamp, then + // the payment date as a last-resort fallback for legacy rows where neither reversal timestamp + // was captured; the same-day add+subtract still nets to zero so the end total matches the Total + // Revenue tile. + if (transaction.CreditNoteUrl is not null) + { + var creditNotedOn = DateOnly.FromDateTime((transaction.CreditNotedAt ?? transaction.RefundedAt ?? transaction.Date).UtcDateTime); + deltasByDay[creditNotedOn] = deltasByDay.GetValueOrDefault(creditNotedOn) - transaction.AmountExcludingTax; + } + else if (transaction.RefundedAt is { } refundedAt) + { + var refundedOn = DateOnly.FromDateTime(refundedAt.UtcDateTime); + deltasByDay[refundedOn] = deltasByDay.GetValueOrDefault(refundedOn) - transaction.AmountExcludingTax; + } + } + + return deltasByDay; + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs new file mode 100644 index 0000000000..ca09fe2283 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs @@ -0,0 +1,132 @@ +using Account.Features.EmailAuthentication.Domain; +using Account.Features.ExternalAuthentication.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardTrendsQuery(DashboardTrendMetric Metric, DashboardTrendPeriod Period) + : IRequest>; + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DashboardTrendMetric +{ + NewTenants, + NewUsers, + LoginActivity +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DashboardTrendPeriod +{ + Last7Days, + Last30Days, + Last90Days +} + +[PublicAPI] +public sealed record BackOfficeDashboardTrendsResponse( + DashboardTrendMetric Metric, + DashboardTrendPeriod Period, + BackOfficeDashboardTrendPoint[] Points, + BackOfficeDashboardTrendPoint[] PriorPoints +); + +[PublicAPI] +public sealed record BackOfficeDashboardTrendPoint(DateOnly Date, long Value); + +public sealed class GetDashboardTrendsQueryValidator : AbstractValidator +{ + public GetDashboardTrendsQueryValidator() + { + RuleFor(x => x.Metric).Must(m => Enum.IsDefined(typeof(DashboardTrendMetric), m)).WithMessage("Metric must be one of NewTenants, NewUsers, or LoginActivity."); + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +public sealed class GetDashboardTrendsHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + IEmailLoginRepository emailLoginRepository, + IExternalLoginRepository externalLoginRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + public async Task> Handle(GetDashboardTrendsQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var today = DateOnly.FromDateTime(now.UtcDateTime); + var startDate = today.AddDays(-(days - 1)); + var priorStartDate = startDate.AddDays(-days); + // Pull the prior window in the same query so the chart can render a comparison overlay without a second round-trip. + var since = new DateTimeOffset(priorStartDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + var counts = query.Metric switch + { + DashboardTrendMetric.NewTenants => await CountNewTenantsPerDay(since, cancellationToken), + DashboardTrendMetric.NewUsers => await CountNewUsersPerDay(since, cancellationToken), + DashboardTrendMetric.LoginActivity => await CountLoginActivityPerDay(since, cancellationToken), + _ => throw new UnreachableException($"Unsupported metric '{query.Metric}'.") + }; + + var points = new BackOfficeDashboardTrendPoint[days]; + var priorPoints = new BackOfficeDashboardTrendPoint[days]; + for (var index = 0; index < days; index++) + { + var currentDate = startDate.AddDays(index); + var priorDate = priorStartDate.AddDays(index); + points[index] = new BackOfficeDashboardTrendPoint(currentDate, counts.GetValueOrDefault(currentDate)); + priorPoints[index] = new BackOfficeDashboardTrendPoint(priorDate, counts.GetValueOrDefault(priorDate)); + } + + return new BackOfficeDashboardTrendsResponse(query.Metric, query.Period, points, priorPoints); + } + + private async Task> CountNewTenantsPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetCreatedSinceUnfilteredAsync(since, cancellationToken); + return BucketByDay(tenants.Select(t => t.CreatedAt)); + } + + private async Task> CountNewUsersPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var users = await userRepository.GetCreatedSinceUnfilteredAsync(since, cancellationToken); + return BucketByDay(users.Select(u => u.CreatedAt)); + } + + private async Task> CountLoginActivityPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var emailLogins = await emailLoginRepository.GetCompletedSinceAsync(since, cancellationToken); + var externalLogins = await externalLoginRepository.GetSucceededSinceAsync(since, cancellationToken); + var timestamps = emailLogins.Select(l => l.CreatedAt).Concat(externalLogins.Select(l => l.CreatedAt)); + return BucketByDay(timestamps); + } + + private static Dictionary BucketByDay(IEnumerable timestamps) + { + return timestamps + .GroupBy(timestamp => DateOnly.FromDateTime(timestamp.UtcDateTime)) + .ToDictionary(group => group.Key, group => group.LongCount()); + } +} + +public static class DashboardTrendPeriods +{ + public static int GetDays(DashboardTrendPeriod period) + { + return period switch + { + DashboardTrendPeriod.Last7Days => 7, + DashboardTrendPeriod.Last30Days => 30, + DashboardTrendPeriod.Last90Days => 90, + _ => throw new UnreachableException($"Unsupported period '{period}'.") + }; + } +} diff --git a/application/account/Core/Features/BackOffice/Invoices/Queries/GetBackOfficeInvoices.cs b/application/account/Core/Features/BackOffice/Invoices/Queries/GetBackOfficeInvoices.cs new file mode 100644 index 0000000000..d340ef899b --- /dev/null +++ b/application/account/Core/Features/BackOffice/Invoices/Queries/GetBackOfficeInvoices.cs @@ -0,0 +1,254 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.BackOffice.Invoices.Queries; + +[PublicAPI] +public sealed record GetBackOfficeInvoicesQuery( + string? Search = null, + BackOfficeInvoiceStatusFilter[]? Statuses = null, + SortableBackOfficeInvoiceProperties OrderBy = SortableBackOfficeInvoiceProperties.Date, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public BackOfficeInvoiceStatusFilter[] Statuses { get; } = Statuses ?? []; +} + +[PublicAPI] +public sealed record BackOfficeInvoicesResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BackOfficeInvoiceSummary[] Invoices); + +[PublicAPI] +public sealed record BackOfficeInvoiceSummary( + PaymentTransactionId Id, + BackOfficeInvoiceRowKind RowKind, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + DateTimeOffset Date, + SubscriptionPlan? Plan, + decimal Amount, + decimal AmountExcludingTax, + decimal TaxAmount, + string Currency, + PaymentTransactionStatus Status, + string? FailureReason, + string? InvoiceUrl, + string? CreditNoteUrl, + DateTimeOffset? CreditNotedAt, + DateTimeOffset? RefundedAt +); + +/// +/// Each PaymentTransaction projects to one Invoice row (always) plus an optional reversal row — +/// either CreditNote (when Stripe issued a credit note) or Refund (the edge case where a Stripe +/// pro-rated refund happened without a credit note). The Invoice row always carries the original +/// payment outcome (Paid / Pending / Failed); the reversal row carries the later state change. +/// +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BackOfficeInvoiceRowKind +{ + Invoice, + CreditNote, + Refund +} + +/// +/// Filter values exposed by the back-office invoices toolbar. , , +/// and match Invoice rows by their original payment outcome. +/// matches RowKind=Refund rows (refund-without-credit-note edge case). +/// matches RowKind=CreditNote rows. The "Refunds and credit notes" UI +/// toggle sends both and . +/// +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BackOfficeInvoiceStatusFilter +{ + Paid, + Refunded, + Failed, + Pending, + HasCreditNote +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableBackOfficeInvoiceProperties +{ + Date, + TenantName, + Total, + Status +} + +public sealed class GetBackOfficeInvoicesQueryValidator : AbstractValidator +{ + public GetBackOfficeInvoicesQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.Statuses.Length).LessThanOrEqualTo(10).WithMessage("Status filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeInvoicesHandler(ISubscriptionRepository subscriptionRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeInvoicesQuery query, CancellationToken cancellationToken) + { + var subscriptions = await subscriptionRepository.GetAllWithTransactionsUnfilteredAsync(cancellationToken); + if (subscriptions.Length == 0) + { + return new BackOfficeInvoicesResponse(0, query.PageSize, 0, query.PageOffset, []); + } + + var tenantIds = subscriptions.Select(s => s.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var summaries = subscriptions + .Where(s => tenantsById.ContainsKey(s.TenantId)) + .SelectMany(subscription => subscription.PaymentTransactions.SelectMany(transaction => ProjectRows(transaction, tenantsById[subscription.TenantId]))) + .ToArray(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + summaries = [.. summaries.Where(s => s.TenantName.ToLower().Contains(query.Search))]; + } + + if (query.Statuses.Length > 0) + { + summaries = [.. summaries.Where(s => query.Statuses.Any(filter => MatchesStatusFilter(s, filter)))]; + } + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (SortableBackOfficeInvoiceProperties.TenantName, SortOrder.Ascending) => summaries.OrderBy(s => s.TenantName).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.TenantName, _) => summaries.OrderByDescending(s => s.TenantName).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.Total, SortOrder.Ascending) => summaries.OrderBy(s => s.Amount).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.Total, _) => summaries.OrderByDescending(s => s.Amount).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.Status, SortOrder.Ascending) => summaries.OrderBy(s => s.Status).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.Status, _) => summaries.OrderByDescending(s => s.Status).ThenByDescending(s => s.Date), + (SortableBackOfficeInvoiceProperties.Date, SortOrder.Ascending) => summaries.OrderBy(s => s.Date), + _ => summaries.OrderByDescending(s => s.Date) + }; + + var totalCount = summaries.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new BackOfficeInvoicesResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } + + private static IEnumerable ProjectRows(PaymentTransaction transaction, Tenant tenant) + { + // Invoice row: always emitted. Status reflects the ORIGINAL payment outcome — a later refund + // or credit note doesn't flip the invoice row to "Refunded"; it gets its own row instead. The + // Refunded enum value at the transaction level becomes Succeeded on the invoice row because + // every refunded transaction had a successful original charge. + var invoiceRowStatus = transaction.Status == PaymentTransactionStatus.Refunded + ? PaymentTransactionStatus.Succeeded + : transaction.Status; + + yield return new BackOfficeInvoiceSummary( + transaction.Id, + BackOfficeInvoiceRowKind.Invoice, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + transaction.Date, + transaction.Plan, + transaction.Amount, + transaction.AmountExcludingTax, + transaction.TaxAmount, + transaction.Currency, + invoiceRowStatus, + transaction.FailureReason, + transaction.InvoiceUrl, + transaction.CreditNoteUrl, + transaction.CreditNotedAt, + transaction.RefundedAt + ); + + if (transaction.CreditNoteUrl is not null) + { + // CreditNote row: emitted whenever a Stripe credit note exists. Date falls through + // CreditNotedAt → RefundedAt → original Date so legacy rows whose timestamps were never + // backfilled still surface as their own row at the only timestamp we have. The producer + // populates the precise dates on fresh Reconcile passes. + yield return new BackOfficeInvoiceSummary( + transaction.Id, + BackOfficeInvoiceRowKind.CreditNote, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + transaction.CreditNotedAt ?? transaction.RefundedAt ?? transaction.Date, + transaction.Plan, + transaction.Amount, + transaction.AmountExcludingTax, + transaction.TaxAmount, + transaction.Currency, + PaymentTransactionStatus.Refunded, + transaction.FailureReason, + transaction.InvoiceUrl, + transaction.CreditNoteUrl, + transaction.CreditNotedAt, + transaction.RefundedAt + ); + } + else if (transaction.Status == PaymentTransactionStatus.Refunded || transaction.RefundedAt is not null) + { + // Refund row (edge case): Stripe pro-rated refunds don't always create a credit note — + // when one happens the refund is the standalone reversal. Skip when a CreditNote sibling + // already exists (per the user model: the credit note encompasses the refund). + yield return new BackOfficeInvoiceSummary( + transaction.Id, + BackOfficeInvoiceRowKind.Refund, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + transaction.RefundedAt ?? transaction.Date, + transaction.Plan, + transaction.Amount, + transaction.AmountExcludingTax, + transaction.TaxAmount, + transaction.Currency, + PaymentTransactionStatus.Refunded, + transaction.FailureReason, + transaction.InvoiceUrl, + transaction.CreditNoteUrl, + transaction.CreditNotedAt, + transaction.RefundedAt + ); + } + } + + private static bool MatchesStatusFilter(BackOfficeInvoiceSummary summary, BackOfficeInvoiceStatusFilter filter) + { + return filter switch + { + // Invoice-side status filters only match RowKind=Invoice — reversal rows surface via Refunded / HasCreditNote. + BackOfficeInvoiceStatusFilter.Paid => summary is { RowKind: BackOfficeInvoiceRowKind.Invoice, Status: PaymentTransactionStatus.Succeeded }, + BackOfficeInvoiceStatusFilter.Failed => summary is { RowKind: BackOfficeInvoiceRowKind.Invoice, Status: PaymentTransactionStatus.Failed }, + BackOfficeInvoiceStatusFilter.Pending => summary is { RowKind: BackOfficeInvoiceRowKind.Invoice, Status: PaymentTransactionStatus.Pending }, + BackOfficeInvoiceStatusFilter.Refunded => summary.RowKind == BackOfficeInvoiceRowKind.Refund, + BackOfficeInvoiceStatusFilter.HasCreditNote => summary.RowKind == BackOfficeInvoiceRowKind.CreditNote, + _ => false + }; + } +} diff --git a/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs b/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs index 2761ac15d5..c9a2d9b869 100644 --- a/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs +++ b/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs @@ -1,4 +1,5 @@ using Account.Database; +using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; using SharedKernel.Persistence; @@ -9,6 +10,19 @@ public interface IEmailLoginRepository : IAppendRepository + /// Returns every email login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts), not just the active in-progress logins returned by . + /// + Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every completed email login created at or after . Used by the back-office + /// dashboard to aggregate successful email login activity per day across all tenants. + /// + Task GetCompletedSinceAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class EmailLoginRepository(AccountDbContext accountDbContext) @@ -21,4 +35,31 @@ public EmailLogin[] GetByEmail(string email) .Where(el => el.Email == email.ToLowerInvariant()) .ToArray(); } + + /// + /// Returns every email login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts), not just the active in-progress logins returned by . + /// SQLite cannot translate DateTimeOffset comparisons, so the time filter runs in memory; the email filter + /// keeps the materialized set bounded. + /// + public async Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet + .Where(el => el.Email == email.ToLowerInvariant()) + .ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every completed email login created at or after . Used by the back-office + /// dashboard to aggregate successful email login activity per day across all tenants. SQLite cannot translate + /// DateTimeOffset comparisons, so the time filter runs in memory; the dashboard period is bounded (max 90 days) + /// so the materialized set stays small. + /// + public async Task GetCompletedSinceAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet.Where(el => el.Completed).ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } } diff --git a/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs b/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs index d7d9859bb1..3ef222da1d 100644 --- a/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs +++ b/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs @@ -1,4 +1,5 @@ using Account.Database; +using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; using SharedKernel.Persistence; @@ -7,7 +8,47 @@ namespace Account.Features.ExternalAuthentication.Domain; public interface IExternalLoginRepository : IAppendRepository { void Update(ExternalLogin aggregate); + + /// + /// Returns every external login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts). + /// + Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every successful external login created at or after . Used by the back-office + /// dashboard to aggregate successful external login activity per day across all tenants. + /// + Task GetSucceededSinceAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class ExternalLoginRepository(AccountDbContext accountDbContext) - : RepositoryBase(accountDbContext), IExternalLoginRepository; + : RepositoryBase(accountDbContext), IExternalLoginRepository +{ + /// + /// Returns every external login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts). SQLite cannot translate DateTimeOffset comparisons, so the time filter runs in + /// memory; the email filter keeps the materialized set bounded. + /// + public async Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet + .Where(el => el.Email == email.ToLowerInvariant()) + .ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every successful external login created at or after . Used by the back-office + /// dashboard to aggregate successful external login activity per day across all tenants. SQLite cannot translate + /// DateTimeOffset comparisons, so the time filter runs in memory; the dashboard period is bounded (max 90 days) + /// so the materialized set stays small. + /// + public async Task GetSucceededSinceAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet.Where(el => el.LoginResult == ExternalLoginResult.Success).ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } +} diff --git a/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs b/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs index aebfc5c666..16a2f8eb99 100644 --- a/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs +++ b/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs @@ -1,41 +1,80 @@ using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; using Account.Integrations.Stripe; using JetBrains.Annotations; using SharedKernel.Cqrs; +using SharedKernel.Telemetry; namespace Account.Features.Subscriptions.Commands; /// -/// Phase 1 of two-phase webhook processing. Validates the Stripe signature, stores the event -/// as pending, and returns the customer ID so the API can trigger phase 2 processing. +/// Phase 1 of two-phase webhook processing. Validates the Stripe signature, stores the event as +/// pending, and returns the resolved customer id alongside the just-acked event so the API can +/// trigger phase 2 processing without re-reading the durable stripe_events.payload archive. /// [PublicAPI] -public sealed record AcknowledgeStripeWebhookCommand(string Payload, string SignatureHeader) : ICommand, IRequest>; +public sealed record AcknowledgeStripeWebhookCommand(string Payload, string SignatureHeader) : ICommand, IRequest>; + +/// +/// Carries the resolved customer id and (when the webhook is new and supported) the in-memory +/// payload for phase 2 to process. is null when the webhook is a +/// duplicate (the existing row's payload hash matched), when the customer is unknown (the row was +/// stored but marked Ignored), or when the event type is not subscription-relevant for this tenant. +/// +[PublicAPI] +public sealed record AcknowledgeStripeWebhookResult(StripeCustomerId StripeCustomerId, PendingWebhookEvent? JustAcknowledgedEvent); public sealed class AcknowledgeStripeWebhookHandler( IStripeEventRepository stripeEventRepository, StripeClientFactory stripeClientFactory, - TimeProvider timeProvider -) : IRequestHandler> + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger +) : IRequestHandler> { - public async Task> Handle(AcknowledgeStripeWebhookCommand command, CancellationToken cancellationToken) + public async Task> Handle(AcknowledgeStripeWebhookCommand command, CancellationToken cancellationToken) { var stripeClient = stripeClientFactory.GetClient(); var webhookEvent = stripeClient.VerifyWebhookSignature(command.Payload, command.SignatureHeader); if (webhookEvent is null) { - return Result.BadRequest("Invalid webhook signature."); + return Result.BadRequest("Invalid webhook signature."); } - if (await stripeEventRepository.ExistsAsync(webhookEvent.EventId, cancellationToken)) + var payloadHash = StripeEventPayloadHasher.Hash(command.Payload); + + // Idempotency: Stripe redelivers webhooks on transient errors (network, our 5xx, etc.). Same event + // id arriving twice with the same payload is a no-op. Same id with a *different* payload is a + // forensic anomaly: the existing row is preserved unchanged and a divergence telemetry event is + // emitted so the drift banner can surface it. We never overwrite stripe_events rows. + var existing = await stripeEventRepository.GetByIdAsync(StripeEventId.NewId(webhookEvent.EventId), cancellationToken); + if (existing is not null) { - return Result.Success(webhookEvent.CustomerId); + // Short-circuit when the existing row has no payload hash recorded — legacy rows from before + // the payload_hash column existed have NULL there, and stripe_events is INSERT-only so we never + // re-hash the stored payload to backfill. Treat null as "no prior hash to compare against", + // not as divergence. + if (existing.PayloadHash is not null && existing.PayloadHash != payloadHash) + { + logger.LogWarning( + "Stripe event {EventId} arrived twice with different payloads (existing hash {ExistingHash} vs new {NewHash}); existing row preserved", + webhookEvent.EventId, existing.PayloadHash, payloadHash + ); + events.CollectEvent(new StripeEventPayloadMismatch(webhookEvent.EventId, webhookEvent.EventType, existing.PayloadHash, payloadHash)); + } + + // Redeliveries return the customer id but no JustAcknowledgedEvent: phase 2 already processed the + // original arrival (or is about to via a concurrent request); replaying the same payload + // would just produce a no-op since billing_events is idempotent on stripe_event_id. + return webhookEvent.CustomerId is null + ? Result.Success(null) + : new AcknowledgeStripeWebhookResult(webhookEvent.CustomerId, null); } var now = timeProvider.GetUtcNow(); var customerId = webhookEvent.CustomerId; - var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload); + var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload, webhookEvent.ApiVersion, payloadHash, webhookEvent.Created); if (customerId is null) { @@ -44,6 +83,15 @@ TimeProvider timeProvider await stripeEventRepository.AddAsync(stripeEvent, cancellationToken); - return customerId; + if (customerId is null) return Result.Success(null); + + var justAcknowledgedEvent = new PendingWebhookEvent( + webhookEvent.EventId, + webhookEvent.EventType, + webhookEvent.Created, + command.Payload, + webhookEvent.ApiVersion + ); + return new AcknowledgeStripeWebhookResult(customerId, justAcknowledgedEvent); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs new file mode 100644 index 0000000000..b23d7cc107 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs @@ -0,0 +1,202 @@ +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Domain; +using SharedKernel.StronglyTypedIds; + +namespace Account.Features.Subscriptions.Domain; + +[PublicAPI] +[IdPrefix("bilevt")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record BillingEventId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} + +/// +/// A durable, append-only record of one subscription-relevant Stripe event. +/// The invariant is strict 1:1: every recognized Stripe event for a subscription produces exactly +/// one row. Events that don't move state we care about are written as . +/// Events whose Stripe payload combines multiple changes that don't decompose into one of our domain +/// transitions (e.g. a subscription update that toggles cancel-at-period-end *and* changes price in +/// the same payload) are written as and flip the +/// subscription's drift flag for admin review. +/// Idempotent on (unique index): redelivered webhooks and re-pulls from +/// the Stripe events API are no-ops. +/// Source of truth: the local stripe_events archive, NOT Stripe's events.list API. Stripe only +/// retains events for 30 days (see https://docs.stripe.com/api/events) — anything older must come from +/// our local archive. The events.list API is used only as a reconciliation source for detecting +/// webhooks that never reached us within the retention window. +/// Hard rule: rows in this table are never deleted, never updated. Schema changes use ALTER TABLE +/// ADD/DROP COLUMN, never DROP/TRUNCATE/DELETE FROM. +/// +public sealed class BillingEvent : AggregateRoot, ITenantScopedEntity +{ + private BillingEvent(TenantId tenantId, SubscriptionId subscriptionId, string stripeEventId) + : base(BillingEventId.NewId()) + { + TenantId = tenantId; + SubscriptionId = subscriptionId; + StripeEventId = stripeEventId; + EventType = default; + OccurredAt = default; + } + + public SubscriptionId SubscriptionId { get; private set; } + + public string StripeEventId { get; private set; } + + public BillingEventType EventType { get; private set; } + + public SubscriptionPlan? FromPlan { get; private set; } + + public SubscriptionPlan? ToPlan { get; private set; } + + /// + /// The ex-VAT recurring price before this event applied. ALWAYS ex-VAT: MRR is revenue + /// accounting, VAT is collected on behalf of tax authorities and never our revenue, so every + /// amount column on this aggregate is net-of-tax. Null for event types that don't carry a + /// before/after price (e.g. ). + /// + public decimal? PreviousAmount { get; private set; } + + /// + /// The ex-VAT recurring price after this event applied. ALWAYS ex-VAT: MRR is revenue + /// accounting, VAT is collected on behalf of tax authorities and never our revenue, so every + /// amount column on this aggregate is net-of-tax. Sourced either from the Stripe event payload's + /// unit_amount (normalized to ex-VAT when the underlying price's tax_behavior is + /// inclusive) or from the ex-VAT catalog fallback. Null for event types that don't + /// carry a new price. + /// + public decimal? NewAmount { get; private set; } + + /// + /// The ex-VAT difference , signed. + /// ALWAYS ex-VAT: MRR is revenue accounting, VAT is collected on behalf of tax authorities and + /// never our revenue, so the recurring-revenue delta is net-of-tax. Null for event types where + /// a delta is undefined (NoOp, BillingInfoUpdated, PaymentMethodUpdated, etc.). + /// + public decimal? AmountDelta { get; private set; } + + /// + /// The ex-VAT committed MRR state immediately after this event applied — the running total + /// denormalized so paginated reads don't have to walk history. ALWAYS ex-VAT: MRR is revenue + /// accounting, VAT is collected on behalf of tax authorities and never our revenue, so the + /// KPI sum must be net-of-tax. Zero when the subscription is cancelled-at-period-end (the + /// forward MRR contribution drops at the moment the customer commits to leaving, not at the + /// effective period end). + /// + public decimal CommittedMrr { get; private set; } + + public string? Currency { get; private set; } + + public DateTimeOffset OccurredAt { get; private set; } + + public CancellationReason? CancellationReason { get; private set; } + + public SuspensionReason? SuspensionReason { get; private set; } + + public TenantId TenantId { get; } + + public static BillingEvent Create( + TenantId tenantId, + SubscriptionId subscriptionId, + string stripeEventId, + BillingEventType eventType, + DateTimeOffset occurredAt, + decimal committedMrr, + SubscriptionPlan? fromPlan = null, + SubscriptionPlan? toPlan = null, + decimal? previousAmount = null, + decimal? newAmount = null, + decimal? amountDelta = null, + string? currency = null, + CancellationReason? cancellationReason = null, + SuspensionReason? suspensionReason = null + ) + { + if (committedMrr < 0m) + { + throw new UnreachableException($"BillingEvent.committedMrr must be non-negative; got {committedMrr} for event '{stripeEventId}'."); + } + + if (previousAmount is not null && newAmount is not null && amountDelta is not null + && Math.Abs(newAmount.Value - previousAmount.Value - amountDelta.Value) > 0.005m) + { + throw new UnreachableException($"BillingEvent.amountDelta inconsistency on event '{stripeEventId}': previousAmount={previousAmount}, newAmount={newAmount}, amountDelta={amountDelta}."); + } + + return new BillingEvent(tenantId, subscriptionId, stripeEventId) + { + EventType = eventType, + OccurredAt = occurredAt, + CommittedMrr = committedMrr, + FromPlan = fromPlan, + ToPlan = toPlan, + PreviousAmount = previousAmount, + NewAmount = newAmount, + AmountDelta = amountDelta, + Currency = currency, + CancellationReason = cancellationReason, + SuspensionReason = suspensionReason + }; + } +} + +/// +/// The type of subscription-relevant Stripe event recorded by the BillingEvent log. +/// IMPORTANT: when adding a new value, also add it to the multi-select on /billing-events +/// (see application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx, +/// constant ALL_EVENT_TYPES). The toolbar is hand-maintained and does not enumerate the +/// enum at runtime — operators won't be able to filter by a new type until that list is updated. +/// +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BillingEventType +{ + SubscriptionCreated, + SubscriptionRenewed, + SubscriptionUpgraded, + SubscriptionDowngradeScheduled, + SubscriptionDowngradeCancelled, + SubscriptionDowngraded, + SubscriptionCancelled, + SubscriptionReactivated, + SubscriptionExpired, + SubscriptionImmediatelyCancelled, + SubscriptionSuspended, + + /// + /// Stripe transitioned the subscription's status from active to past_due (a payment failed). + /// Fires alongside from the corresponding invoice.payment_failed event; + /// pairs with when payment recovers and status returns to active. + /// Carries forward CommittedMrr unchanged and AmountDelta=null — the customer is still on the plan, + /// just behind on payment. + /// + SubscriptionPastDue, + PaymentFailed, + PaymentRecovered, + PaymentRefunded, + BillingInfoAdded, + BillingInfoUpdated, + PaymentMethodUpdated, + + /// + /// A recognized subscription-relevant Stripe event that doesn't move state we care about (e.g. + /// a subscription_schedule.updated arriving with status=canceled after a cancellation, where + /// phases haven't changed). Hidden from the timeline UI; carries forward CommittedMrr unchanged + /// and AmountDelta=null so it's invisible to MRR trend computation. + /// + NoOp, + + /// + /// A Stripe event whose payload combines multiple state changes that the writer can't decompose + /// into a single domain transition (e.g. a customer.subscription.updated whose previous_attributes + /// contain both a cancel_at_period_end toggle and a price change). Triggers the drift banner so + /// an admin can investigate in Stripe Dashboard. + /// + Unclassified +} diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs new file mode 100644 index 0000000000..32b02de69a --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharedKernel.Domain; +using SharedKernel.EntityFramework; + +namespace Account.Features.Subscriptions.Domain; + +public sealed class BillingEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.MapStronglyTypedUuid(e => e.Id); + builder.MapStronglyTypedLongId(e => e.TenantId); + builder.MapStronglyTypedUuid(e => e.SubscriptionId); + + builder.Property(e => e.PreviousAmount).HasPrecision(18, 2); + builder.Property(e => e.NewAmount).HasPrecision(18, 2); + builder.Property(e => e.AmountDelta).HasPrecision(18, 2); + builder.Property(e => e.CommittedMrr).HasPrecision(18, 2); + + builder.HasIndex(e => e.StripeEventId).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.OccurredAt }).IsDescending(false, true); + builder.HasIndex(e => e.OccurredAt).IsDescending(); + builder.HasIndex(e => e.SubscriptionId); + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs new file mode 100644 index 0000000000..3e98a01eab --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs @@ -0,0 +1,133 @@ +using Account.Database; +using Microsoft.EntityFrameworkCore; +using SharedKernel.Domain; +using SharedKernel.EntityFramework; +using SharedKernel.Persistence; + +namespace Account.Features.Subscriptions.Domain; + +public interface IBillingEventRepository : IAppendRepository +{ + /// + /// Returns every billing event for a subscription. Used by drift detection and projection logic + /// that walks subscription history. Bypasses the tenant query filter because the drift detector + /// and webhook pipeline both run without an authenticated tenant context. + /// + Task GetBySubscriptionIdUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns the set of Stripe event ids already recorded for a subscription. Used to enforce the + /// 1:1 invariant idempotently — a redelivered webhook or a re-pull from the Stripe events API + /// skips events whose ids are already in this set. Bypasses the tenant query filter because the + /// webhook pipeline runs without an authenticated tenant context. + /// + Task> GetExistingStripeEventIdsUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns the most recent billing events across all tenants. Bypasses the tenant query filter + /// because the back-office is cross-tenant by design. + /// + Task GetRecentUnfilteredAsync(int limit, CancellationToken cancellationToken); + + /// + /// Returns all billing events matching the optional event-type filter, across all tenants. + /// Bypasses the tenant query filter because the back-office is cross-tenant by design. Date-range + /// filtering is applied in memory because SQLite (used in tests) cannot translate DateTimeOffset + /// comparisons to SQL; the materialized set stays small in practice because event-type filtering + /// happens at the database level and dashboard windows are bounded. + /// + Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken); + + /// + /// Returns every billing event with a non-null AmountDelta across all tenants — the events that + /// actually move committed MRR. Used by the dashboard MRR-trend computation. Bypasses the tenant + /// query filter because the back-office is cross-tenant by design. + /// + Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Returns the subset of that have at least one billing event + /// recorded. Used by the back-office accounts list to filter to "unsynced" subscriptions (paid + /// subscriptions with no events). Bypasses the tenant query filter because the back-office is + /// cross-tenant by design. + /// + Task> GetSubscriptionIdsWithEventsUnfilteredAsync(SubscriptionId[] subscriptionIds, CancellationToken cancellationToken); +} + +public sealed class BillingEventRepository(AccountDbContext accountDbContext) + : RepositoryBase(accountDbContext), IBillingEventRepository +{ + public async Task GetBySubscriptionIdUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.SubscriptionId == subscriptionId) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetExistingStripeEventIdsUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken) + { + var ids = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.SubscriptionId == subscriptionId) + .Select(e => e.StripeEventId) + .ToArrayAsync(cancellationToken); + return [.. ids]; + } + + public async Task GetRecentUnfilteredAsync(int limit, CancellationToken cancellationToken) + { + // SQLite (used in tests) cannot translate DateTimeOffset comparisons in ORDER BY, so the sort runs + // in memory. The materialized set is bounded by the dashboard's small request limit (max 50 rows). + // NoOp rows are audit-only and hidden from the timeline display. + var events = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.EventType != BillingEventType.NoOp) + .ToArrayAsync(cancellationToken); + return events.OrderByDescending(e => e.OccurredAt).Take(limit).ToArray(); + } + + public async Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.AmountDelta != null) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetSubscriptionIdsWithEventsUnfilteredAsync(SubscriptionId[] subscriptionIds, CancellationToken cancellationToken) + { + if (subscriptionIds.Length == 0) return []; + + var ids = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => subscriptionIds.AsEnumerable().Contains(e.SubscriptionId)) + .Select(e => e.SubscriptionId) + .Distinct() + .ToArrayAsync(cancellationToken); + return [.. ids]; + } + + public async Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken) + { + // NoOp rows are audit-only — hidden from the timeline display unless an admin explicitly filters + // for them via the eventTypes parameter. + var queryable = eventTypes.Length > 0 + ? DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(e => eventTypes.AsEnumerable().Contains(e.EventType)) + : DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(e => e.EventType != BillingEventType.NoOp); + + var events = await queryable.ToArrayAsync(cancellationToken); + + if (occurredFrom.HasValue) + { + events = events.Where(e => e.OccurredAt >= occurredFrom.Value).ToArray(); + } + + if (occurredTo.HasValue) + { + events = events.Where(e => e.OccurredAt <= occurredTo.Value).ToArray(); + } + + return events; + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs b/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs index 466e652dd5..1dbd1df3c5 100644 --- a/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs +++ b/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs @@ -15,6 +15,27 @@ public override string ToString() } } +/// +/// A durable archive of every Stripe webhook payload we have observed for this account. Two roles: +/// (1) inbox for two-phase webhook processing (Pending → Processed), and +/// (2) authoritative source for replaying BillingEvents beyond Stripe's 30-day events.list retention +/// (see https://docs.stripe.com/api/events). +/// This table is INSERT-only. The state machine columns (, +/// , , , +/// ) are mutable as the row progresses Pending → Processed (or +/// Ignored / Failed). The payload and payload-derived columns (, +/// , , , +/// , ) are immutable after insert — written +/// once at ingestion and never re-hashed, backfilled, or recomputed from the stored payload. Stripe +/// owns the payload schema and is free to send null, omit fields, or change shape, so every +/// payload-derived column is nullable and tolerant of null on read. +/// Subsequent redeliveries of the same event id are deduplicated at insert time and never overwrite +/// an existing row. If the same event id is observed with a different payload, it is logged as a +/// forensic anomaly and the existing row is preserved unchanged. +/// Hard rule: rows in this table are never updated (except for the state machine transitions noted +/// above), deleted, or truncated. Schema changes use ALTER TABLE ADD/DROP COLUMN, never +/// DROP/TRUNCATE/DELETE FROM. +/// public sealed class StripeEvent : AggregateRoot { private StripeEvent(StripeEventId id) : base(id) @@ -39,28 +60,121 @@ private StripeEvent(StripeEventId id) : base(id) public string? Error { get; private set; } + /// + /// The Stripe API version active when Stripe created this event. Pinned at event creation time and + /// never changes (see https://docs.stripe.com/api/events). The replayer uses this to dispatch to + /// the correct IStripeEventPayloadResolver when the JSON shape changes between Stripe API + /// versions. Nullable because Stripe owns the payload schema and may omit the field; readers fall + /// back to the resolver's default behavior for unknown versions. + /// + public string? ApiVersion { get; private set; } + + /// + /// When this event was recovered from a reconciliation source (events.list or + /// webhook_endpoint_deliveries) instead of arriving via webhook delivery. Null for events that came + /// via normal webhook delivery. Forensics: a row with non-null RecoveredAt is a webhook we + /// didn't receive in real-time. + /// + public DateTimeOffset? RecoveredAt { get; private set; } + + /// + /// The reconciliation source that recovered this event. "events_list" means we found it via + /// Stripe's events.list API; "delivery_audit" means Stripe's webhook_endpoint_deliveries API + /// showed us a delivery attempt we never acked. Null when arrived via webhook delivery. + /// + public string? RecoverySource { get; private set; } + + /// + /// SHA-256 hash of the raw payload when this row was first stored. Used by AcknowledgeStripeWebhook + /// to detect StripeEventPayloadDivergence: if the same event id arrives twice with different + /// payloads, the existing row is preserved unchanged and the divergence is surfaced as a drift + /// discrepancy. Nullable because legacy rows from before this column existed have NULL here; the + /// stored payload is never re-hashed to backfill (the hash is computed exactly once at ingestion). + /// + public string? PayloadHash { get; private set; } + + /// + /// Stripe's authoritative Event.Created timestamp (see https://docs.stripe.com/api/events). + /// Captured at ingestion from both webhook deliveries and reconciliation sources so the replayer + /// can order events and stamp BillingEvent.OccurredAt from the time Stripe says the event + /// occurred — never our ingestion time. Nullable because legacy rows from before this column + /// existed have NULL here and Stripe is free to omit the field on a future payload shape change. + /// + public DateTimeOffset? StripeCreatedAt { get; private set; } + /// /// Factory method for phase 1 webhook acknowledgment. Creates a Pending event that will be - /// batch-processed in phase 2. TenantId and StripeSubscriptionId are backfilled by phase 2 - /// via SetTenantId() and SetStripeSubscriptionId(). + /// batch-processed in phase 2. TenantId and StripeSubscriptionId are filled in by phase 2 via + /// . /// - public static StripeEvent Create(string stripeEventId, string eventType, StripeCustomerId? stripeCustomerId, string? payload) + public static StripeEvent Create( + string stripeEventId, + string eventType, + StripeCustomerId? stripeCustomerId, + string? payload, + string apiVersion, + string payloadHash, + DateTimeOffset stripeCreatedAt + ) { return new StripeEvent(StripeEventId.NewId(stripeEventId)) { EventType = eventType, StripeCustomerId = stripeCustomerId, - Payload = payload + Payload = payload, + ApiVersion = apiVersion, + PayloadHash = payloadHash, + StripeCreatedAt = stripeCreatedAt }; } /// - /// Marks the event as successfully processed during phase 2 batch processing. + /// Factory method for events recovered via reconciliation (events.list or webhook_endpoint_deliveries). + /// Lands directly as Processed because reconciliation runs inside the same transaction as the replayer, + /// and there's no signature to verify (events.list and webhook_endpoint_deliveries are authenticated by + /// API key, not webhook signature). The two-phase pending → processed split exists for incoming + /// webhooks; recovered events skip phase 1. /// - public void MarkProcessed(DateTimeOffset processedAt) + public static StripeEvent CreateRecovered( + string stripeEventId, + string eventType, + StripeCustomerId? stripeCustomerId, + string? payload, + string apiVersion, + string payloadHash, + DateTimeOffset recoveredAt, + string recoverySource, + DateTimeOffset stripeCreatedAt + ) { + return new StripeEvent(StripeEventId.NewId(stripeEventId)) + { + EventType = eventType, + Status = StripeEventStatus.Processed, + ProcessedAt = recoveredAt, + StripeCustomerId = stripeCustomerId, + Payload = payload, + ApiVersion = apiVersion, + PayloadHash = payloadHash, + RecoveredAt = recoveredAt, + RecoverySource = recoverySource, + StripeCreatedAt = stripeCreatedAt + }; + } + + /// + /// Marks the event as successfully processed during phase 2 batch processing. Backfills the + /// resolved tenant id and Stripe subscription id at the same moment so the row is fully populated + /// before transitioning out of the Pending state. After this call the row is logically immutable — + /// no method on this aggregate mutates state once Status is Processed. + /// + public void MarkProcessed(DateTimeOffset processedAt, TenantId? tenantId, StripeSubscriptionId? stripeSubscriptionId) + { + EnsurePending(); Status = StripeEventStatus.Processed; ProcessedAt = processedAt; + TenantId = tenantId; + StripeSubscriptionId = stripeSubscriptionId; } /// @@ -68,6 +182,7 @@ public void MarkProcessed(DateTimeOffset processedAt) /// public void MarkIgnored(DateTimeOffset processedAt) { + EnsurePending(); Status = StripeEventStatus.Ignored; ProcessedAt = processedAt; } @@ -77,18 +192,17 @@ public void MarkIgnored(DateTimeOffset processedAt) /// public void MarkFailed(DateTimeOffset failedAt, string error) { + EnsurePending(); Status = StripeEventStatus.Failed; ProcessedAt = failedAt; Error = error; } - public void SetStripeSubscriptionId(StripeSubscriptionId? stripeSubscriptionId) - { - StripeSubscriptionId = stripeSubscriptionId; - } - - public void SetTenantId(TenantId? tenantId) + private void EnsurePending() { - TenantId = tenantId; + if (Status is not StripeEventStatus.Pending) + { + throw new InvalidOperationException($"StripeEvent '{Id.Value}' is no longer Pending (status: {Status}); refusing to mutate."); + } } } diff --git a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs index 6d8a23753e..bcbe90c226 100644 --- a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs @@ -1,6 +1,7 @@ using Account.Database; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.Persistence; namespace Account.Features.Subscriptions.Domain; @@ -11,16 +12,61 @@ public interface IStripeEventRepository : IAppendRepository GetPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); - /// /// Checks if any pending events exist for a Stripe customer without locking. /// Used by the frontend to poll for webhook processing completion. /// Task HasPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Transitions every Pending stripe_events row for a Stripe customer to Processed via a + /// column-only UPDATE, backfilling the resolved tenant_id and stripe_subscription_id + /// at the same moment. Does not materialize any row, so the durable payload column is + /// never read; this is the hot-path replacement for fetch-then-mark patterns that round-tripped + /// the jsonb archive bytes through the application. Covers both the just-acked event and any + /// accumulated Pending orphans from prior partial deliveries so the next sync starts clean. + /// + Task MarkPendingProcessedByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, DateTimeOffset processedAt, TenantId tenantId, StripeSubscriptionId? stripeSubscriptionId, CancellationToken cancellationToken); + + /// + /// Returns the set of Stripe event ids (regardless of Status) already recorded for a customer. + /// Used by the reconciliation passes to detect events that exist in Stripe but not in our + /// archive — those are inserted as recovered events with status=Processed. + /// + Task> GetExistingEventIdsByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns archived stripe_events for a customer whose StripeCreatedAt is strictly older than + /// and that have no matching billing_events row yet. Used by the + /// admin reconcile flow to surface payloads that fell out of Stripe's 30-day events.list retention + /// window so an operator can confirm replay before they are projected into billing_events. Excludes + /// Pending (not yet processed), Ignored (no customer match), and Failed; orders ASC by + /// StripeCreatedAt so the replayer state machine consumes them in the order Stripe produced + /// them. Bypasses the tenant query filter because reconciliation runs outside an authenticated + /// tenant context. StripeCreatedAt is nullable on the aggregate because legacy rows from + /// before the column existed have NULL there; rows with NULL StripeCreatedAt are excluded by + /// SQL semantics of the < cutoff filter (NULL comparisons yield NULL, which is filtered + /// out) so every row returned from this method has a non-null StripeCreatedAt. + /// + Task GetArchivedEventsOlderThanAsync(StripeCustomerId stripeCustomerId, DateTimeOffset cutoff, CancellationToken cancellationToken); + + /// + /// Returns archived stripe_events for a customer whose Id is NOT in + /// and that have no matching billing_events row yet. Used by the disaster-recovery replay + /// command after the caller has asked Stripe's events.list what events Stripe still serves; the + /// resulting id set defines the boundary between "Stripe still has this" (skip) and "archive is the + /// only source" (replay). ID comparison avoids the same-second-sibling-events fragility of any + /// date-based cutoff — an upgrade emits multiple events sharing the same created timestamp, + /// so a date boundary can split or duplicate the sibling group, whereas the id set is exact. + /// Excludes Pending, Ignored, and Failed; orders ASC by StripeCreatedAt so the replayer + /// consumes them in the order Stripe produced them. Bypasses the tenant query filter because + /// reconciliation runs outside an authenticated tenant context. Rows with NULL StripeCreatedAt + /// are excluded so the replayer always sees a usable timestamp. + /// + Task GetArchivedEventsExcludingAsync(StripeCustomerId stripeCustomerId, HashSet excludedEventIds, CancellationToken cancellationToken); } -internal sealed class StripeEventRepository(AccountDbContext accountDbContext) +public sealed class StripeEventRepository(AccountDbContext accountDbContext) : RepositoryBase(accountDbContext), IStripeEventRepository { public async Task ExistsAsync(string stripeEventId, CancellationToken cancellationToken) @@ -29,15 +75,94 @@ public async Task ExistsAsync(string stripeEventId, CancellationToken canc return await DbSet.AnyAsync(e => e.Id == id, cancellationToken); } - public async Task GetPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + public async Task HasPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) { - return await DbSet + return await DbSet.AnyAsync(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Pending, cancellationToken); + } + + public async Task MarkPendingProcessedByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, DateTimeOffset processedAt, TenantId tenantId, StripeSubscriptionId? stripeSubscriptionId, CancellationToken cancellationToken) + { + // ExecuteUpdateAsync rewrites only the state-machine columns; the durable payload jsonb column is + // never read. Filters on stripe_customer_id and status=Pending so the transition is idempotent — + // a concurrent request that already consumed the rows produces a no-op rather than a double-write. + await DbSet .Where(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Pending) + .ExecuteUpdateAsync(e => e + .SetProperty(x => x.Status, StripeEventStatus.Processed) + .SetProperty(x => x.ProcessedAt, processedAt) + .SetProperty(x => x.TenantId, tenantId) + .SetProperty(x => x.StripeSubscriptionId, stripeSubscriptionId), + cancellationToken + ); + } + + public async Task> GetExistingEventIdsByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + var ids = await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId) + .Select(e => e.Id.Value) .ToArrayAsync(cancellationToken); + return [.. ids]; } - public async Task HasPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + public async Task GetArchivedEventsOlderThanAsync(StripeCustomerId stripeCustomerId, DateTimeOffset cutoff, CancellationToken cancellationToken) { - return await DbSet.AnyAsync(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Pending, cancellationToken); + // Materialize first then filter by stripe_event_id presence in billing_events in memory. SQLite (used + // in tests) cannot translate the DateTimeOffset comparison plus the cross-table NotContains pattern + // into a single SQL query, and the per-customer event set is bounded (typically <200 webhooks over a + // subscription's lifetime). Status=Processed covers both webhook-delivered and reconciliation-recovered + // events; Pending/Ignored/Failed are excluded so partial-state rows never reach the replayer. + var candidateEvents = await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Processed) + .ToArrayAsync(cancellationToken); + + // StripeCreatedAt is nullable on the aggregate; the `< cutoff` filter excludes rows with NULL + // (NULL comparisons yield false in LINQ-to-Objects after materialization, mirroring SQL's + // NULL-tri-state semantics). Every row reaching the OrderBy therefore has a non-null value. + var olderThanCutoff = candidateEvents + .Where(e => e.StripeCreatedAt < cutoff) + .OrderBy(e => e.StripeCreatedAt!.Value) + .ToArray(); + + if (olderThanCutoff.Length == 0) return []; + + var candidateEventIds = olderThanCutoff.Select(e => e.Id.Value).ToArray(); + var billingEventIds = await Context.Set() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(b => candidateEventIds.AsEnumerable().Contains(b.StripeEventId)) + .Select(b => b.StripeEventId) + .ToArrayAsync(cancellationToken); + + var billingEventIdSet = new HashSet(billingEventIds); + return olderThanCutoff.Where(e => !billingEventIdSet.Contains(e.Id.Value)).ToArray(); + } + + public async Task GetArchivedEventsExcludingAsync(StripeCustomerId stripeCustomerId, HashSet excludedEventIds, CancellationToken cancellationToken) + { + // Materialize first then filter in memory for the same reason as GetArchivedEventsOlderThanAsync: + // SQLite (used in tests) cannot translate the cross-table NotContains pattern combined with a + // HashSet parameter into a single SQL query, and the per-customer event set is bounded. + var candidateEvents = await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Processed) + .ToArrayAsync(cancellationToken); + + // StripeCreatedAt is nullable on the aggregate; legacy rows from before the column existed have NULL. + // The replayer needs the timestamp to order events, so rows with NULL are excluded. + var archivedNotInStripe = candidateEvents + .Where(e => e.StripeCreatedAt is not null && !excludedEventIds.Contains(e.Id.Value)) + .OrderBy(e => e.StripeCreatedAt!.Value) + .ToArray(); + + if (archivedNotInStripe.Length == 0) return []; + + var candidateEventIds = archivedNotInStripe.Select(e => e.Id.Value).ToArray(); + var billingEventIds = await Context.Set() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(b => candidateEventIds.AsEnumerable().Contains(b.StripeEventId)) + .Select(b => b.StripeEventId) + .ToArrayAsync(cancellationToken); + + var billingEventIdSet = new HashSet(billingEventIds); + return archivedNotInStripe.Where(e => !billingEventIdSet.Contains(e.Id.Value)).ToArray(); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index a91becd2c2..7c23119b3a 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -34,16 +34,38 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) TenantId = tenantId; Plan = SubscriptionPlan.Basis; PaymentTransactions = ImmutableArray.Empty; + DriftDiscrepancies = ImmutableArray.Empty; } public SubscriptionPlan Plan { get; private set; } public SubscriptionPlan? ScheduledPlan { get; private set; } + /// + /// The ex-VAT price the subscription will charge after the scheduled downgrade activates at + /// . ALWAYS ex-VAT: MRR is revenue accounting, VAT is collected + /// on behalf of tax authorities and never our revenue, so every internal recurring-revenue + /// number is net-of-tax. Sourced from the price catalog at sync time; the catalog itself + /// normalizes from Stripe's inc-VAT listed amount when tax_behavior=inclusive. Null + /// when no downgrade is scheduled. The inc-VAT customer-facing amount only appears in + /// for invoice display. + /// + public decimal? ScheduledPriceAmount { get; private set; } + public StripeCustomerId? StripeCustomerId { get; private set; } public StripeSubscriptionId? StripeSubscriptionId { get; private set; } + /// + /// The ex-VAT price the subscription currently charges per . + /// ALWAYS ex-VAT: MRR is revenue accounting, VAT is collected on behalf of tax authorities and + /// never our revenue, so every internal recurring-revenue number is net-of-tax. The real Stripe + /// client normalizes from price.unit_amount based on price.tax_behavior — for + /// inclusive prices it subtracts the VAT component before persisting; for + /// exclusive it stores the listed amount unchanged. Null on Basis plans and brand-new + /// tenants. The inc-VAT customer-facing amount only appears in + /// for invoice display. + /// public decimal? CurrentPriceAmount { get; private set; } public string? CurrentPriceCurrency { get; private set; } @@ -58,12 +80,38 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) public string? CancellationFeedback { get; private set; } + /// + /// Denormalized cache of MIN(occurred_at) across every SubscriptionCreated BillingEvent + /// for this tenant. The BillingEvent log is the source of truth; this column exists so paginated reads + /// don't have to walk history. Mutated only via , + /// which is monotonic-backward — a late-arriving recovered event can rewind it earlier, but lifecycle + /// transitions (cancel, expire, reactivate on a brand-new Stripe subscription) never move it forward. + /// Null when no SubscriptionCreated event has yet been emitted for the tenant. + /// + public DateTimeOffset? SubscribedSince { get; private set; } + + /// + /// The Event.Created of the most recent Stripe event applied to this subscription via the + /// events.list-driven hot path. Used as the created.gte anchor on the next sync so we only + /// fetch events Stripe has produced since we were last in sync. Stripe retains events for 30 days + /// (see https://docs.stripe.com/api/events); the background sweeper re-syncs every active customer + /// well within that window so this anchor never falls out of range. Null on subscriptions that + /// have never been synced (e.g. fresh tenants on Basis). + /// + public DateTimeOffset? LastSyncedStripeEventCreatedAt { get; private set; } + public ImmutableArray PaymentTransactions { get; private set; } public PaymentMethod? PaymentMethod { get; private set; } public BillingInfo? BillingInfo { get; private set; } + public bool HasDriftDetected { get; private set; } + + public DateTimeOffset? DriftCheckedAt { get; private set; } + + public ImmutableArray DriftDiscrepancies { get; private set; } + public TenantId TenantId { get; } public static Subscription Create(TenantId tenantId) @@ -91,6 +139,33 @@ public void SetStripeSubscription(StripeSubscriptionId? stripeSubscriptionId, Su PaymentMethod = paymentMethod; } + /// + /// Denormalized cache of MIN(occurred_at) across every SubscriptionCreated BillingEvent + /// for this tenant. Monotonic backward: only assigns when the incoming event is older than the current + /// value, so a late-arriving recovered event can rewind the date earlier but lifecycle transitions + /// (cancel, expire, reactivate on a brand-new Stripe subscription) never move it forward. Idempotent. + /// + public void AdvanceSubscribedSinceBackwardFromBillingEvent(DateTimeOffset eventOccurredAt) + { + if (SubscribedSince is null || eventOccurredAt < SubscribedSince.Value) + { + SubscribedSince = eventOccurredAt; + } + } + + /// + /// Advances the events.list anchor to the Event.Created of the most recent event applied in + /// this sync. Monotonic: only advances forward so a late-arriving older event recovered via + /// reconcile cannot rewind the anchor below an already-applied event. + /// + public void AdvanceLastSyncedStripeEventCreatedAt(DateTimeOffset eventCreatedAt) + { + if (LastSyncedStripeEventCreatedAt is null || eventCreatedAt > LastSyncedStripeEventCreatedAt.Value) + { + LastSyncedStripeEventCreatedAt = eventCreatedAt; + } + } + public void SetCancellation(bool cancelAtPeriodEnd, CancellationReason? cancellationReason, string? cancellationFeedback) { CancelAtPeriodEnd = cancelAtPeriodEnd; @@ -98,9 +173,10 @@ public void SetCancellation(bool cancelAtPeriodEnd, CancellationReason? cancella CancellationFeedback = cancellationFeedback; } - public void SetScheduledPlan(SubscriptionPlan? scheduledPlan) + public void SetScheduledPlan(SubscriptionPlan? scheduledPlan, decimal? scheduledPriceAmount) { ScheduledPlan = scheduledPlan; + ScheduledPriceAmount = scheduledPriceAmount; } public void SetPaymentTransactions(ImmutableArray paymentTransactions) @@ -127,10 +203,13 @@ public void ResetToFreePlan() { Plan = SubscriptionPlan.Basis; ScheduledPlan = null; + ScheduledPriceAmount = null; StripeSubscriptionId = null; CurrentPriceAmount = null; CurrentPriceCurrency = null; - CurrentPeriodEnd = null; + // CurrentPeriodEnd is intentionally preserved as the "expired on" date for canceled + // subscriptions: it's the last date the subscription was active. The back-office uses it to + // render "Expired {date}" instead of just "—" for tenants that have been fully reset. CancelAtPeriodEnd = false; FirstPaymentFailedAt = null; CancellationReason = null; @@ -141,6 +220,20 @@ public bool HasActiveStripeSubscription() { return StripeSubscriptionId is not null && Plan != SubscriptionPlan.Basis && !CancelAtPeriodEnd; } + + public void SetDriftStatus(ImmutableArray discrepancies, DateTimeOffset checkedAt) + { + DriftDiscrepancies = discrepancies; + HasDriftDetected = !discrepancies.IsDefaultOrEmpty; + DriftCheckedAt = checkedAt; + } + + public void AcknowledgeDrift(DateTimeOffset acknowledgedAt) + { + // Manual override clears the flag but preserves the discrepancy list for audit. + HasDriftDetected = false; + DriftCheckedAt = acknowledgedAt; + } } [PublicAPI] @@ -163,10 +256,107 @@ public sealed record PaymentMethod(string Brand, string Last4, int ExpMonth, int public sealed record PaymentTransaction( PaymentTransactionId Id, decimal Amount, + decimal AmountExcludingTax, + decimal TaxAmount, string Currency, PaymentTransactionStatus Status, DateTimeOffset Date, string? FailureReason, string? InvoiceUrl, - string? CreditNoteUrl + string? CreditNoteUrl, + SubscriptionPlan? Plan = null, + DateTimeOffset? RefundedAt = null, + // InvoiceTotal is the gross amount Stripe billed for this invoice (= AmountExcludingTax + TaxAmount). + // Amount is what the customer actually paid from card; AmountFromCredit is the portion absorbed by + // their Stripe credit balance (e.g. from a prior credit note). The invariant + // `Amount + AmountFromCredit == InvoiceTotal` lets LTV math count credit-absorbed invoices + // without conflating them with cash-paid ones. Defaults to 0 so existing JSONB rows backfilled + // by migration deserialize cleanly. + decimal InvoiceTotal = 0m, + decimal AmountFromCredit = 0m, + // Stripe's timestamp for when a credit note was issued against this invoice. Null when no credit + // note exists, or for legacy rows where the producer didn't yet capture it. Surfaces on the + // back-office invoices UI so refunded rows show the actual credit-note date, not just the + // original invoice date. + DateTimeOffset? CreditNotedAt = null ); + +[PublicAPI] +public sealed record DriftDiscrepancy( + DriftDiscrepancyKind Kind, + string Description, + DriftSeverity Severity, + BillingEventType? ExpectedEventType = null, + string? ExpectedValue = null, + string? ActualValue = null, + DateTimeOffset? OccurredAt = null +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DriftDiscrepancyKind +{ + MissingEvent, + ExtraEvent, + FieldDisagree, + SubscriptionStateMismatch, + + /// + /// A Stripe event arrived whose payload combined multiple state changes that the writer couldn't + /// decompose into a single domain transition (e.g. a customer.subscription.updated whose + /// previous_attributes contain both a cancel_at_period_end toggle and a price change). The + /// event is recorded as BillingEventType.Unclassified; this discrepancy surfaces it on + /// the drift banner so an admin can investigate in Stripe Dashboard. + /// + UnclassifiedStripeEvent, + + /// + /// Stripe sent an event whose api_version doesn't have a matching + /// IStripeEventPayloadResolver. The event is preserved unchanged in + /// stripe_events; the replayer skips it and surfaces this discrepancy so the + /// resolver-per-version mapping can be extended. + /// + UnsupportedStripeApiVersion, + + /// + /// The same Stripe event id was observed twice with different payloads (SHA-256 hash + /// mismatch on the second arrival). The original row is preserved; the divergence is + /// surfaced for forensic review. Either Stripe redelivered an event with mutated content + /// (their bug to investigate) or our hashing is broken (our bug to investigate). + /// + StripeEventPayloadDivergence, + + /// + /// A persisted BillingEvent row's denormalized fields (CommittedMrr, AmountDelta, PreviousAmount, NewAmount) + /// no longer match what a fresh replay produces — typically because an older event was recovered after a + /// newer event was already classified and persisted. The persisted row is left untouched per the + /// append-only invariant; this discrepancy surfaces the wrongness for operator review. + /// + BillingEventDenormalizationStale, + + /// + /// Stripe returned a payment with total_taxes greater than the display amount, which would + /// otherwise produce a negative AmountExcludingTax. The value is clamped at zero so the DB + /// CHECK does not reject the row (which would 500 the webhook and trigger infinite Stripe retries), + /// but the LTV totals silently undercount until the underlying Stripe anomaly is investigated. + /// + AmountExcludingTaxClamped, + + /// + /// The subscription has a ScheduledPlan set but ScheduledPriceAmount is null. The + /// MRR KPI falls back to the current (higher) price in this state, silently distorting BLENDED MRR. + /// Originates from edge cases in SyncStateFromStripe where a cancel-then-reschedule pair + /// landed in the same sync window and the diff-based transition detector did not fire; the + /// unconditional reconciliation in SyncStateFromStripe now prevents this, and this drift + /// check stands as defence-in-depth so any future regression surfaces on the next sync. + /// + ScheduledPriceMissing +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DriftSeverity +{ + Warning, + Critical +} diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs index 9c80e0254d..a7d3fc765b 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs @@ -22,6 +22,7 @@ public void Configure(EntityTypeBuilder builder) builder.MapStronglyTypedNullableId(s => s.StripeSubscriptionId); builder.Property(s => s.CurrentPriceAmount).HasPrecision(18, 2); + builder.Property(s => s.ScheduledPriceAmount).HasPrecision(18, 2); builder.Property(s => s.PaymentTransactions) .HasColumnType("jsonb") @@ -44,5 +45,18 @@ public void Configure(EntityTypeBuilder builder) v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions), v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions) ); + + builder.Property(s => s.DriftDiscrepancies) + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v.ToArray(), JsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions) + ) + .Metadata.SetValueComparer(new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c + ) + ); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs index 27ab0e3a00..5fccaafa97 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs @@ -1,6 +1,8 @@ +using System.Collections.Immutable; using Account.Database; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.ExecutionContext; using SharedKernel.Persistence; @@ -17,14 +19,73 @@ public interface ISubscriptionRepository : ICrudRepository Task GetByStripeCustomerIdWithLockUnfilteredAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + /// + /// Retrieves a subscription by Stripe customer ID without acquiring a row lock and without applying + /// tenant query filters. Used by the detect-only BillingDriftWorker tripwire: a Stripe roundtrip + /// under a FOR UPDATE lock would needlessly serialize the worker with the webhook hot path on + /// the same row for the full duration of the Stripe call. The result is returned untracked because + /// Detect mode never mutates the aggregate via the change tracker — drift status is written through + /// as a column-only update. + /// + Task GetByStripeCustomerIdUnfilteredAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Persists the drift status fields (has_drift_detected, drift_checked_at, + /// drift_discrepancies) for a single subscription via a column-only UPDATE. Bypasses the + /// change tracker so the underlying row is never loaded under a FOR UPDATE lock first, which + /// keeps the detect-only BillingDriftWorker from blocking the webhook hot path on the same row. + /// Bypasses tenant query filters because the worker has no tenant context. + /// + Task UpdateDriftStatusAsync(SubscriptionId subscriptionId, bool hasDriftDetected, DateTimeOffset driftCheckedAt, ImmutableArray driftDiscrepancies, CancellationToken cancellationToken); + /// /// Retrieves a subscription by tenant ID without applying tenant query filters. /// This method is used when tenant context is not available (e.g., during signup token creation). /// Task GetByTenantIdUnfilteredAsync(TenantId tenantId, CancellationToken cancellationToken); + + /// + /// Retrieves all subscriptions for the given tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + Task GetByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken); + + /// + /// Retrieves every subscription on a paid plan (Plan != Basis) without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute total monthly recurring revenue across all tenants. + /// + Task GetAllActiveUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Retrieves every subscription whose drift check is stale, regardless of plan, without applying tenant + /// query filters. A subscription is "stale" when (a) it has a Stripe customer id (we need one to compare + /// against Stripe at all), and (b) DriftCheckedAt is either NULL or older than the supplied cutoff. + /// + Task GetSubscriptionsDueForDriftCheckUnfilteredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken); + + /// + /// Retrieves every subscription that has at least one payment transaction recorded. Used by the + /// back-office invoices listing which expands the JSON transactions array into one row per invoice. + /// Bypasses the tenant query filter because the back-office is cross-tenant by design. + /// + Task GetAllWithTransactionsUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Counts subscriptions where billing drift has been detected and not yet acknowledged. Bypasses the + /// tenant query filter because the back-office is cross-tenant by design. + /// + Task CountWithDriftDetectedUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Counts paid subscriptions that have no rows in billing_events — i.e. subscriptions that have + /// never been synced into the BillingEvent log. The dashboard's MRR trend silently under-counts + /// these, so the back-office surfaces the count as a banner. Bypasses the tenant query filter + /// because the back-office is cross-tenant by design. + /// + Task CountWithoutBillingEventsUnfilteredAsync(CancellationToken cancellationToken); } -internal sealed class SubscriptionRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) +public sealed class SubscriptionRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) : RepositoryBase(accountDbContext), ISubscriptionRepository { public async Task GetCurrentAsync(CancellationToken cancellationToken) @@ -42,18 +103,118 @@ public async Task GetCurrentAsync(CancellationToken cancellationTo { if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") { - return await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(s => s.StripeCustomerId == stripeCustomerId, cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(s => s.StripeCustomerId == stripeCustomerId, cancellationToken); } return await DbSet .FromSqlInterpolated($"SELECT * FROM subscriptions WHERE stripe_customer_id = {stripeCustomerId.Value} FOR UPDATE") - .IgnoreQueryFilters() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) .SingleOrDefaultAsync(cancellationToken); } + /// + /// Retrieves a subscription by Stripe customer ID without acquiring a row lock and without applying + /// tenant query filters. Used by the detect-only BillingDriftWorker tripwire: a Stripe roundtrip + /// under a FOR UPDATE lock would needlessly serialize the worker with the webhook hot path on + /// the same row for the full duration of the Stripe call. The result is returned untracked because + /// Detect mode never mutates the aggregate via the change tracker — drift status is written through + /// as a column-only update. + /// + public async Task GetByStripeCustomerIdUnfilteredAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + return await DbSet + .AsNoTracking() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .SingleOrDefaultAsync(s => s.StripeCustomerId == stripeCustomerId, cancellationToken); + } + + /// + /// Persists the drift status fields (has_drift_detected, drift_checked_at, + /// drift_discrepancies) for a single subscription via a column-only UPDATE. Bypasses the + /// change tracker so the underlying row is never loaded under a FOR UPDATE lock first, which + /// keeps the detect-only BillingDriftWorker from blocking the webhook hot path on the same row. + /// Bypasses tenant query filters because the worker has no tenant context. + /// + public async Task UpdateDriftStatusAsync(SubscriptionId subscriptionId, bool hasDriftDetected, DateTimeOffset driftCheckedAt, ImmutableArray driftDiscrepancies, CancellationToken cancellationToken) + { + await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => s.Id == subscriptionId) + .ExecuteUpdateAsync(s => s + .SetProperty(x => x.HasDriftDetected, hasDriftDetected) + .SetProperty(x => x.DriftCheckedAt, driftCheckedAt) + .SetProperty(x => x.DriftDiscrepancies, driftDiscrepancies), + cancellationToken + ); + } + public async Task GetByTenantIdUnfilteredAsync(TenantId tenantId, CancellationToken cancellationToken) { return DbSet.Local.SingleOrDefault(s => s.TenantId == tenantId) - ?? await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(s => s.TenantId == tenantId, cancellationToken); + ?? await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(s => s.TenantId == tenantId, cancellationToken); + } + + /// + /// Retrieves all subscriptions for the given tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task GetByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(s => tenantIds.AsEnumerable().Contains(s.TenantId)).ToArrayAsync(cancellationToken); + } + + /// + /// Retrieves every subscription on a paid plan (Plan != Basis) without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute total monthly recurring revenue across all tenants. + /// + public async Task GetAllActiveUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(s => s.Plan != SubscriptionPlan.Basis).ToArrayAsync(cancellationToken); + } + + /// + /// Retrieves every subscription whose drift check is stale, regardless of plan, without applying tenant + /// query filters. A subscription is "stale" when (a) it has a Stripe customer id (we need one to compare + /// against Stripe at all), and (b) DriftCheckedAt is either NULL or older than the supplied cutoff. + /// + public async Task GetSubscriptionsDueForDriftCheckUnfilteredAsync(DateTimeOffset cutoff, CancellationToken cancellationToken) + { + // Both predicates are evaluated in memory: StripeCustomerId is a value-object-typed nullable column + // that EF Core does not translate against null directly, and the DateTimeOffset comparison on the + // nullable DriftCheckedAt does not round-trip cleanly across SQLite (tests) and Postgres (production). + // The row count of subscriptions across all tenants is small and the worker runs once per Container + // App scale-up, so the in-memory filter is acceptable here. Matches the established pattern used by + // every other CreatedAt/OccurredAt range query in this SCS (see BillingEventRepository, + // EmailLoginRepository, ExternalLoginRepository, UserRepository). + var allSubscriptions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .OrderBy(s => s.Id) + .ToArrayAsync(cancellationToken); + + return [.. allSubscriptions.Where(s => s.StripeCustomerId is not null && (s.DriftCheckedAt is null || s.DriftCheckedAt < cutoff))]; + } + + public async Task GetAllWithTransactionsUnfilteredAsync(CancellationToken cancellationToken) + { + // PaymentTransactions is a jsonb column whose Length cannot be translated to SQL by EF Core + // uniformly across providers (SQLite is used in tests, Postgres in dev/prod), so the materialized + // set is filtered in memory. This is acceptable because back-office is the only caller and the row + // count of subscriptions is small relative to other cross-tenant queries already done here. + var subscriptions = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return [.. subscriptions.Where(s => s.PaymentTransactions.Length > 0)]; + } + + public async Task CountWithDriftDetectedUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(s => s.HasDriftDetected, cancellationToken); + } + + public async Task CountWithoutBillingEventsUnfilteredAsync(CancellationToken cancellationToken) + { + var tenantFilterName = new[] { QueryFilterNames.Tenant }; + return await DbSet.IgnoreQueryFilters(tenantFilterName) + .Where(s => s.CurrentPriceAmount != null) + .Where(s => !accountDbContext.Set().IgnoreQueryFilters(tenantFilterName).Any(e => e.SubscriptionId == s.Id)) + .CountAsync(cancellationToken); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionTypes.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionTypes.cs index 8f83460ee2..e015968662 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionTypes.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionTypes.cs @@ -52,7 +52,8 @@ public enum PaymentTransactionStatus Succeeded, Failed, Pending, - Refunded + Refunded, + Cancelled } [PublicAPI] diff --git a/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs new file mode 100644 index 0000000000..c5489f2e84 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs @@ -0,0 +1,136 @@ +using System.Collections.Immutable; +using Account.Features.Subscriptions.Domain; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Pure function that detects drift between the local subscription state and Stripe's authoritative state. +/// Runs inline at the end of every Stripe sync (per-customer) so drift is surfaced immediately on the next +/// webhook for that account, with no scheduled job required. +/// The detector covers — comparing +/// `Plan`, `CancelAtPeriodEnd`, `CurrentPriceAmount`, `CurrentPriceCurrency` between the local snapshot +/// captured before sync mutations and the Stripe snapshot captured from Stripe's response. These fields +/// drive customer access and are operationally the most important to keep aligned. It also flags a coarse +/// when there are stored PaymentTransactions but zero +/// BillingEvent rows for the subscription — invoices made it to the local PaymentTransactions array +/// without a corresponding event row, indicating a bug in the event-emission pipeline. Per-event +/// comparison ( / ) +/// requires a deterministic `ComputeExpectedEvents(StripeSyncSnapshot)` helper that consumes full Stripe +/// history; this is a follow-up extension that plugs into the same return type. +/// +public static class BillingDriftDetector +{ + public static ImmutableArray Detect(StripeSyncSnapshot localSnapshot, StripeSyncSnapshot stripeSnapshot, int paymentTransactionCount, int billingEventCount) + { + var discrepancies = ImmutableArray.CreateBuilder(); + + // PaymentTransactions exist on the subscription but the BillingEvent log is empty — the event-emission + // pipeline missed at least one invoice.payment_succeeded. Reconcile is the natural recovery path. + if (paymentTransactionCount > 0 && billingEventCount == 0) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.MissingEvent, + $"Subscription has {paymentTransactionCount} payment transactions but no billing events recorded.", + DriftSeverity.Warning, + ExpectedValue: paymentTransactionCount.ToString(), + ActualValue: "0" + ) + ); + } + + if (localSnapshot.Plan != stripeSnapshot.Plan) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Plan differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: stripeSnapshot.Plan.ToString(), + ActualValue: localSnapshot.Plan.ToString() + ) + ); + } + + if (localSnapshot.CancelAtPeriodEnd != stripeSnapshot.CancelAtPeriodEnd) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Cancel-at-period-end differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: stripeSnapshot.CancelAtPeriodEnd.ToString(), + ActualValue: localSnapshot.CancelAtPeriodEnd.ToString() + ) + ); + } + + if (localSnapshot.CurrentPriceAmount != stripeSnapshot.CurrentPriceAmount) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price amount differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: stripeSnapshot.CurrentPriceAmount?.ToString(), + ActualValue: localSnapshot.CurrentPriceAmount?.ToString() + ) + ); + } + + if (localSnapshot.CurrentPriceCurrency != stripeSnapshot.CurrentPriceCurrency) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price currency differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: stripeSnapshot.CurrentPriceCurrency, + ActualValue: localSnapshot.CurrentPriceCurrency + ) + ); + } + + // ScheduledPlan without ScheduledPriceAmount distorts the BLENDED MRR KPI: MrrCalculator.ForwardMrr + // falls back from the missing scheduled price to the current (higher) price, overstating forward MRR. + // The unconditional reconciliation pass in SyncStateFromStripe prevents this from being written; + // this check stands as defence-in-depth so any future regression surfaces on the next sync. + if (localSnapshot.ScheduledPlan is not null && localSnapshot.ScheduledPriceAmount is null) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.ScheduledPriceMissing, + "Subscription has a scheduled plan but the scheduled price amount is missing. The MRR KPI falls back to the current price instead, distorting BLENDED MRR.", + DriftSeverity.Critical, + ExpectedValue: localSnapshot.ScheduledPlan.ToString() + ) + ); + } + + return discrepancies.ToImmutable(); + } +} + +/// +/// Snapshot of subscription state captured at a point in time. Used twice during drift detection: once for +/// the local subscription state captured before any sync mutations are applied, and once for Stripe's +/// authoritative view captured from the SubscriptionSyncResult returned by the Stripe client. Comparing +/// the two surfaces real drift even though the local subscription is mutated to match Stripe later in the +/// same sync. The shape is also the seam where additional Stripe data (full invoice history, charge +/// history with refunds, scheduled-phase data) plugs in for the BillingEvent-comparison extension. +/// +public sealed record StripeSyncSnapshot( + SubscriptionPlan Plan, + bool CancelAtPeriodEnd, + decimal? CurrentPriceAmount, + string? CurrentPriceCurrency, + SubscriptionPlan? ScheduledPlan = null, + decimal? ScheduledPriceAmount = null +) +{ + public static StripeSyncSnapshot FromSubscription(Subscription subscription) + { + return new StripeSyncSnapshot( + subscription.Plan, + subscription.CancelAtPeriodEnd, + subscription.CurrentPriceAmount, + subscription.CurrentPriceCurrency, + subscription.ScheduledPlan, + subscription.ScheduledPriceAmount + ); + } +} diff --git a/application/account/Core/Features/Subscriptions/Shared/BillingDriftIterationTimeout.cs b/application/account/Core/Features/Subscriptions/Shared/BillingDriftIterationTimeout.cs new file mode 100644 index 0000000000..580579941b --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/BillingDriftIterationTimeout.cs @@ -0,0 +1,24 @@ +namespace Account.Features.Subscriptions.Shared; + +/// +/// Per-iteration timeout budget for BillingDriftWorker. The worker scans subscriptions one at a +/// time and acquires a row-level FOR UPDATE lock for each one; a slow Stripe call must not hold +/// that lock long enough to block the webhook hot path or other reconcile callers. 30s is well under +/// the app-level 45s resilience timeout, so the worker releases the lock before any other caller would +/// also time out waiting on the same row. +/// +public static class BillingDriftIterationTimeout +{ + public static readonly TimeSpan Value = TimeSpan.FromSeconds(30); + + /// + /// Creates a linked to that + /// additionally cancels itself after . Caller must dispose the returned source. + /// + public static CancellationTokenSource CreateLinkedTokenSource(CancellationToken parentToken) + { + var iterationCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(parentToken); + iterationCancellationTokenSource.CancelAfter(Value); + return iterationCancellationTokenSource; + } +} diff --git a/application/account/Core/Features/Subscriptions/Shared/MrrCalculator.cs b/application/account/Core/Features/Subscriptions/Shared/MrrCalculator.cs new file mode 100644 index 0000000000..8edc3af806 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/MrrCalculator.cs @@ -0,0 +1,23 @@ +using Account.Features.Subscriptions.Domain; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Per-subscription forward MRR contribution: 0 if cancelling at period end, the scheduled +/// (downgraded) price if a downgrade is queued, otherwise the current price. Mirrors the +/// per-account MrrAmount tile in the front-end. Used by the dashboard KPI sum and the +/// KPI/trend consistency check — keep them in lockstep by funneling both through this method. +/// ALWAYS returns ex-VAT: MRR is revenue accounting, VAT is collected on behalf of tax authorities +/// and never our revenue, so the KPI sum must be net-of-tax. Both CurrentPriceAmount and +/// ScheduledPriceAmount are stored ex-VAT (normalized at the price-catalog boundary), so +/// this method is a pure read with no tax math. +/// +public static class MrrCalculator +{ + public static decimal ForwardMrr(Subscription subscription) + { + if (!subscription.CurrentPriceAmount.HasValue) return 0m; + if (subscription.CancelAtPeriodEnd) return 0m; + return subscription.ScheduledPriceAmount ?? subscription.CurrentPriceAmount.Value; + } +} diff --git a/application/account/Core/Features/Subscriptions/Shared/PendingWebhookEvent.cs b/application/account/Core/Features/Subscriptions/Shared/PendingWebhookEvent.cs new file mode 100644 index 0000000000..8d50393a48 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/PendingWebhookEvent.cs @@ -0,0 +1,17 @@ +namespace Account.Features.Subscriptions.Shared; + +/// +/// In-memory carrier for the webhook payload that +/// +/// just acknowledged. Phase 2 of two-phase webhook processing receives this record directly from the +/// endpoint so it never has to re-read the just-persisted stripe_events.payload archive column. +/// Same shape as but exists at the +/// Subscriptions feature layer, decoupled from the Stripe integration boundary. +/// +public sealed record PendingWebhookEvent( + string EventId, + string EventType, + DateTimeOffset StripeCreatedAt, + string Payload, + string ApiVersion +); diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 0d9858f8e1..4c849e184f 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -1,4 +1,5 @@ using System.Data; +using System.Globalization; using Account.Database; using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; @@ -10,14 +11,21 @@ namespace Account.Features.Subscriptions.Shared; /// -/// Phase 2 of two-phase webhook processing. Acquires a pessimistic lock on the subscription row -/// to serialize concurrent webhook processing, syncs current state from Stripe, then applies -/// side effects (tenant state changes) based on state diffs between local and synced data. +/// Phase 2 of two-phase webhook processing. Acquires a pessimistic lock on the subscription row to +/// serialize concurrent webhook processing for the same customer, syncs current state from Stripe, +/// then writes new BillingEvent rows from Stripe's events.list view (anchored on the subscription's +/// ) plus the single just-acked webhook +/// event threaded in-memory from StripeWebhookEndpoints. The hot +/// path NEVER reads stripe_events.payload: the durable archive is consulted only by the admin +/// ReplayArchivedTenantStripeEventsCommand disaster-recovery handler. The unique +/// stripe_event_id index on billing_events makes the emission idempotent: redelivered +/// webhooks and re-pulls from events.list are no-ops. /// public sealed class ProcessPendingStripeEvents( AccountDbContext dbContext, ISubscriptionRepository subscriptionRepository, IStripeEventRepository stripeEventRepository, + IBillingEventRepository billingEventRepository, ITenantRepository tenantRepository, StripeClientFactory stripeClientFactory, TimeProvider timeProvider, @@ -26,9 +34,35 @@ public sealed class ProcessPendingStripeEvents( ILogger logger ) { - public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) { - // Pessimistic lock serializes concurrent webhook processing for the same customer + return ExecuteAsync(stripeCustomerId, null, false, SyncMode.Apply, cancellationToken); + } + + public Task ExecuteAsync(StripeCustomerId stripeCustomerId, PendingWebhookEvent? justAcknowledgedEvent, CancellationToken cancellationToken) + { + return ExecuteAsync(stripeCustomerId, justAcknowledgedEvent, false, SyncMode.Apply, cancellationToken); + } + + public Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync, SyncMode syncMode, CancellationToken cancellationToken) + { + return ExecuteAsync(stripeCustomerId, null, forceSync, syncMode, cancellationToken); + } + + public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, PendingWebhookEvent? justAcknowledgedEvent, bool forceSync, SyncMode syncMode, CancellationToken cancellationToken) + { + // Detect mode is the BillingDriftWorker tripwire: it must NOT acquire a FOR UPDATE row lock on the + // subscription, because the worker iterates across every stale row and would otherwise serialize + // each Stripe roundtrip against the webhook hot path on the same row. Read non-locking, do the + // in-memory drift computation, then write drift status via a column-only UPDATE that takes a brief + // lock for the write itself. + if (syncMode == SyncMode.Detect) + { + await ExecuteDetectAsync(stripeCustomerId, cancellationToken); + return; + } + + // Apply mode acquires a pessimistic lock to serialize concurrent webhook processing for the same customer. var isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; await using var transaction = isSqlite ? await dbContext.Database.BeginTransactionAsync(cancellationToken) @@ -43,13 +77,33 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationTo } var tenant = (await tenantRepository.GetByIdUnfilteredAsync(subscription.TenantId, cancellationToken))!; - var pendingEvents = await stripeEventRepository.GetPendingByStripeCustomerIdAsync(stripeCustomerId, cancellationToken); - if (pendingEvents.Length > 0) + // The hot path runs whenever a webhook just landed (justAcknowledgedEvent is not null), an admin + // reconcile click sets forceSync, or accumulated Pending stripe_events rows from a prior partial + // delivery need a self-heal sync (the tenant-side process-pending-events polling endpoint + // depends on this path to drain orphans). HasPendingByStripeCustomerIdAsync is a column-only + // existence check — it never reads the durable payload jsonb column. + var hasOrphanPendingEvents = justAcknowledgedEvent is null && !forceSync + && await stripeEventRepository.HasPendingByStripeCustomerIdAsync(stripeCustomerId, cancellationToken); + if (justAcknowledgedEvent is not null || forceSync || hasOrphanPendingEvents) { - await SyncStateFromStripe(tenant, subscription, cancellationToken); - - MarkAllEventsAsProcessed(pendingEvents, subscription); + var eventsListResult = await PullEventsListAndArchiveRecoveredAsync(subscription, stripeCustomerId, SyncMode.Apply, cancellationToken); + var (driftSnapshots, stripeViewUnavailable) = await SyncStateFromStripe(tenant, subscription, SyncMode.Apply, cancellationToken); + await EmitBillingEventsFromEventsListAsync(subscription, justAcknowledgedEvent, eventsListResult, driftSnapshots, SyncMode.Apply, cancellationToken); + + // Apply mode normally consumes every Pending stripe_events row for this customer by marking + // them Processed — both the just-acked event and any orphans accumulated from prior partial + // deliveries. When the Stripe view is unavailable (the integration client swallowed a + // StripeException or TaskCanceledException and returned null) the SyncStateFromStripe + // mutation branches were skipped, so consuming the rows would silently drop their side + // effects. Leave them Pending so the next sync retries; the BillingEvent ledger emission + // above is idempotent on stripe_event_id and the events.list anchor is correctly advanced + // regardless. The mark is column-only: it never reads the durable payload jsonb column. + if (!stripeViewUnavailable) + { + var now = timeProvider.GetUtcNow(); + await stripeEventRepository.MarkPendingProcessedByStripeCustomerIdAsync(stripeCustomerId, now, subscription.TenantId, subscription.StripeSubscriptionId, cancellationToken); + } } await dbContext.SaveChangesAsync(cancellationToken); @@ -58,12 +112,38 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationTo SendTelemetryEvents(tenant, subscription); } - private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, CancellationToken cancellationToken) + private async Task ExecuteDetectAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + var subscription = await subscriptionRepository.GetByStripeCustomerIdUnfilteredAsync(stripeCustomerId, cancellationToken); + if (subscription is null) + { + logger.LogWarning("Subscription not found for Stripe customer '{StripeCustomerId}', drift detection skipped", stripeCustomerId); + return; + } + + var tenant = (await tenantRepository.GetByIdUnfilteredAsync(subscription.TenantId, cancellationToken))!; + + // Detect mode never consults the stripe_events archive: events.list within Stripe's 30-day window + // covers every customer today (see AUDIT-30-DAY-WINDOW.md), and any Pending row that fails to + // appear there retries on the next webhook arrival, not on the next worker pass. + var eventsListResult = await PullEventsListAndArchiveRecoveredAsync(subscription, stripeCustomerId, SyncMode.Detect, cancellationToken); + var (driftSnapshots, _) = await SyncStateFromStripe(tenant, subscription, SyncMode.Detect, cancellationToken); + await EmitBillingEventsFromEventsListAsync(subscription, null, eventsListResult, driftSnapshots, SyncMode.Detect, cancellationToken); + + // Detect mode normally collects nothing because the mutation branches that drive telemetry are + // skipped. The Stripe-unavailable drift-skipped event in DetectDrift is the one Detect-mode + // signal that must still surface in Application Insights, so flush whatever was collected. + SendTelemetryEvents(tenant, subscription); + } + + private async Task<(DriftSnapshots DriftSnapshots, bool StripeViewUnavailable)> SyncStateFromStripe(Tenant tenant, Subscription subscription, SyncMode syncMode, CancellationToken cancellationToken) { - // Fetch current state from Stripe var stripeClient = stripeClientFactory.GetClient(); var customerResult = await stripeClient.GetCustomerBillingInfoAsync(subscription.StripeCustomerId!, cancellationToken); + // Snapshot captured before any mutation so the drift detector can compare local-pre-sync against Stripe. + var localSnapshot = StripeSyncSnapshot.FromSubscription(subscription); + var previousPlan = subscription.Plan; var previousPriceAmount = subscription.CurrentPriceAmount; var previousPriceCurrency = subscription.CurrentPriceCurrency; @@ -71,23 +151,42 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, if (customerResult is null) { logger.LogError("Failed to fetch billing info for Stripe customer '{StripeCustomerId}'", subscription.StripeCustomerId); - return; + return (new DriftSnapshots(localSnapshot, null), true); } if (customerResult.IsCustomerDeleted) { - subscription.ResetToFreePlan(); - tenant.UpdatePlan(SubscriptionPlan.Basis); - tenant.Suspend(SuspensionReason.CustomerDeleted, timeProvider.GetUtcNow()); - tenantRepository.Update(tenant); - subscriptionRepository.Update(subscription); - events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.CustomerDeleted, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - return; + if (syncMode == SyncMode.Apply) + { + var nowAtCustomerDeleted = timeProvider.GetUtcNow(); + subscription.ResetToFreePlan(); + tenant.UpdatePlan(SubscriptionPlan.Basis); + tenant.Suspend(SuspensionReason.CustomerDeleted, nowAtCustomerDeleted); + tenantRepository.Update(tenant); + subscriptionRepository.Update(subscription); + events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.CustomerDeleted, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); + } + + // Stripe's view: customer is gone, no subscription. Pair with the pre-sync local snapshot above. + return (new DriftSnapshots(localSnapshot, new StripeSyncSnapshot(SubscriptionPlan.Basis, false, null, null)), false); } var stripeState = await stripeClient.SyncSubscriptionStateAsync(subscription.StripeCustomerId!, cancellationToken); - // Detect state transitions in lifecycle order (variables and if-blocks below follow the same order) + if (syncMode == SyncMode.Detect) + { + // Detect mode is a tripwire: read paths run, but no Subscription/Tenant mutation, no telemetry events, + // and no payment-transaction reconciliation. The downstream emitter still computes the discrepancy list + // from the snapshots and calls SetDriftStatus, which is the only sanctioned mutation in this mode. + var detectStripeSnapshot = stripeState is null + ? new StripeSyncSnapshot(SubscriptionPlan.Basis, false, null, null) + : new StripeSyncSnapshot(stripeState.Plan, stripeState.CancelAtPeriodEnd, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency); + return (new DriftSnapshots(localSnapshot, detectStripeSnapshot), false); + } + + // Detect state transitions in lifecycle order (variables and if-blocks below follow the same order). + // The detections drive telemetry collection and Subscription/Tenant state mutations; the BillingEvent + // log is populated separately by EmitBillingEventsFromEventsListAsync running over the events.list view. var billingInfoAdded = subscription.BillingInfo is null && customerResult.BillingInfo is not null; var billingInfoUpdated = subscription.BillingInfo is not null && customerResult.BillingInfo is not null && customerResult.BillingInfo != subscription.BillingInfo; var latestPaymentMethod = stripeState?.PaymentMethod ?? customerResult.PaymentMethod; @@ -109,14 +208,12 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, var now = timeProvider.GetUtcNow(); var daysOnCurrentPlan = (int)(now - (subscription.ModifiedAt ?? subscription.CreatedAt)).TotalDays; - // Apply Stripe state to aggregate (after detection, before side effects) if (stripeState is not null) { subscription.SetStripeSubscription(stripeState.StripeSubscriptionId, stripeState.Plan, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency, stripeState.CurrentPeriodEnd, stripeState.PaymentMethod); tenant.UpdatePlan(stripeState.Plan); } - // Always sync payment transactions from Stripe (via subscription when active, via invoices when cancelled) var syncedTransactions = stripeState?.PaymentTransactions ?? await stripeClient.SyncPaymentTransactionsAsync(subscription.StripeCustomerId!, cancellationToken); if (syncedTransactions is not null) { @@ -161,30 +258,83 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, if (downgradeScheduled) { - subscription.SetScheduledPlan(stripeState!.ScheduledPlan); - var daysUntilDowngrade = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; + var scheduledPlan = stripeState!.ScheduledPlan!.Value; var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); - var scheduledPlanPrice = priceCatalog.Single(p => p.Plan == subscription.ScheduledPlan!.Value).UnitAmount; - events.CollectEvent(new SubscriptionDowngradeScheduled(subscription.Id, subscription.Plan, subscription.ScheduledPlan!.Value, daysUntilDowngrade, subscription.CurrentPriceAmount!.Value, scheduledPlanPrice - subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); + // SingleOrDefault (not FirstOrDefault) so duplicate plan entries still surface as errors; the + // catalog-gap path emits StripePriceCatalogLookupMissed telemetry + a structured warning and + // skips the ScheduledPriceAmount write so the empty-cache scenario does not roll back the + // transaction and poison the webhook hot path. + var scheduledPlanPrice = priceCatalog.SingleOrDefault(p => p.Plan == scheduledPlan)?.UnitAmount; + var daysUntilDowngrade = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; + if (scheduledPlanPrice is null) + { + logger.LogWarning( + "Stripe price catalog missing entry for scheduled plan '{ScheduledPlan}' on subscription '{SubscriptionId}', ScheduledPriceAmount write skipped", + scheduledPlan, subscription.Id + ); + events.CollectEvent(new StripePriceCatalogLookupMissed(subscription.Id, scheduledPlan)); + } + else + { + subscription.SetScheduledPlan(scheduledPlan, scheduledPlanPrice); + events.CollectEvent(new SubscriptionDowngradeScheduled(subscription.Id, subscription.Plan, scheduledPlan, daysUntilDowngrade, subscription.CurrentPriceAmount!.Value, scheduledPlanPrice.Value - subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); + } } if (downgradeCancelled) { - var previousScheduledPlan = subscription.ScheduledPlan; + var previousScheduledPlan = subscription.ScheduledPlan!.Value; var daysSinceDowngradeScheduled = (int)(now - (subscription.ModifiedAt ?? subscription.CreatedAt)).TotalDays; - subscription.SetScheduledPlan(stripeState!.ScheduledPlan); + subscription.SetScheduledPlan(stripeState!.ScheduledPlan, null); var daysUntilDowngrade = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); - var scheduledPlanPrice = priceCatalog.Single(p => p.Plan == previousScheduledPlan!.Value).UnitAmount; - events.CollectEvent(new SubscriptionDowngradeCancelled(subscription.Id, subscription.Plan, previousScheduledPlan!.Value, daysUntilDowngrade, daysSinceDowngradeScheduled, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value - scheduledPlanPrice, subscription.CurrentPriceCurrency!)); + var scheduledPlanPrice = priceCatalog.SingleOrDefault(p => p.Plan == previousScheduledPlan)?.UnitAmount; + if (scheduledPlanPrice is null) + { + logger.LogWarning( + "Stripe price catalog missing entry for previously scheduled plan '{ScheduledPlan}' on subscription '{SubscriptionId}', SubscriptionDowngradeCancelled telemetry skipped", + previousScheduledPlan, subscription.Id + ); + events.CollectEvent(new StripePriceCatalogLookupMissed(subscription.Id, previousScheduledPlan)); + } + else + { + events.CollectEvent(new SubscriptionDowngradeCancelled(subscription.Id, subscription.Plan, previousScheduledPlan, daysUntilDowngrade, daysSinceDowngradeScheduled, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value - scheduledPlanPrice.Value, subscription.CurrentPriceCurrency!)); + } } if (subscriptionDowngraded) { - subscription.SetScheduledPlan(stripeState!.ScheduledPlan); + subscription.SetScheduledPlan(stripeState!.ScheduledPlan, null); events.CollectEvent(new SubscriptionDowngraded(subscription.Id, previousPlan, subscription.Plan, daysOnCurrentPlan, previousPriceAmount!.Value, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value - previousPriceAmount.Value, subscription.CurrentPriceCurrency!)); } + // Unconditional reconciliation of the scheduled-plan price from the catalog. Mirrors how + // SetStripeSubscription above unconditionally reconciles CurrentPriceAmount on every sync. Without + // this, a cancel-then-reschedule pair landing in the same sync window leaves both diff flags + // (downgradeScheduled, downgradeCancelled) false — the local pre-sync ScheduledPlan equals the + // Stripe post-sync ScheduledPlan — and scheduled_price_amount stays NULL from an earlier transition + // (e.g. the downgradeCancelled call to SetScheduledPlan(..., null)). MrrCalculator.ForwardMrr then + // falls back to CurrentPriceAmount, overstating BLENDED MRR. The reconciliation is idempotent: + // when the diff detector already set the correct price, this re-applies the same value. + if (stripeState?.ScheduledPlan is not null) + { + var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); + var scheduledPlanPrice = priceCatalog.SingleOrDefault(p => p.Plan == stripeState.ScheduledPlan.Value)?.UnitAmount; + if (scheduledPlanPrice is null) + { + logger.LogWarning( + "Stripe price catalog missing entry for scheduled plan '{ScheduledPlan}' on subscription '{SubscriptionId}', unconditional ScheduledPriceAmount reconciliation skipped", + stripeState.ScheduledPlan.Value, subscription.Id + ); + events.CollectEvent(new StripePriceCatalogLookupMissed(subscription.Id, stripeState.ScheduledPlan.Value)); + } + else + { + subscription.SetScheduledPlan(stripeState.ScheduledPlan, scheduledPlanPrice); + } + } + if (subscriptionCancelled) { subscription.SetCancellation(stripeState!.CancelAtPeriodEnd, stripeState.CancellationReason, stripeState.CancellationFeedback); @@ -218,7 +368,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, { subscription.ResetToFreePlan(); tenant.UpdatePlan(SubscriptionPlan.Basis); - tenant.Suspend(SuspensionReason.PaymentFailed, timeProvider.GetUtcNow()); + tenant.Suspend(SuspensionReason.PaymentFailed, now); events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.PaymentFailed, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); } @@ -244,7 +394,6 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, events.CollectEvent(new PaymentRefunded(subscription.Id, plan, refundCount, latestRefund.Amount, latestRefund.Currency)); } - // Persist all aggregate mutations and mark pending events as processed var tenantChanged = stripeState is not null || subscriptionCreated || subscriptionExpired || subscriptionImmediatelyCancelled || subscriptionSuspended; if (tenantChanged) { @@ -252,26 +401,370 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, } subscriptionRepository.Update(subscription); + + // Stripe snapshot built from the just-fetched Stripe state, paired with the pre-sync local snapshot + // captured at the start of this method. When stripeState is null Stripe has no active subscription + // for the customer, so the equivalent snapshot is the free plan with no price. + var stripeSnapshot = stripeState is null + ? new StripeSyncSnapshot(SubscriptionPlan.Basis, false, null, null) + : new StripeSyncSnapshot(stripeState.Plan, stripeState.CancelAtPeriodEnd, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency); + return (new DriftSnapshots(localSnapshot, stripeSnapshot), false); + } + + /// + /// Authoritative BillingEvent emission for the hot path. Inputs are strictly in-memory: + /// (1) the events.list response Stripe just returned (with payloads carried inline by + /// ) and (2) the single just-acked webhook event threaded in-memory + /// from StripeWebhookEndpoints (null for the worker, admin + /// reconcile, and tenant-API drain callers). The durable stripe_events archive is NEVER + /// queried in this pass — its Payload column is a cold backup only, read by the admin + /// disaster-recovery ReplayArchivedTenantStripeEventsCommand. Stripe retains events for + /// 30 days (see https://docs.stripe.com/api/events); the background sweeper keeps the anchor + /// inside the window so the events.list view is always complete. + /// Idempotent on billing_events.stripe_event_id: rows whose source Stripe event id is + /// already recorded are skipped, so re-running this for every webhook (or via the back-office + /// reconcile action) is safe. + /// + private async Task EmitBillingEventsFromEventsListAsync( + Subscription subscription, + PendingWebhookEvent? justAcknowledgedEvent, + StripeEventsListResult eventsListResult, + DriftSnapshots driftSnapshots, + SyncMode syncMode, + CancellationToken cancellationToken + ) + { + if (subscription.StripeCustomerId is null) return; + + // Union the events.list view of the world with the single just-acked webhook still in memory from + // the StripeWebhookEndpoints request that triggered this call. Stripe's events.list typically + // reflects a new webhook within a few seconds, but the hot path can't depend on that — so the + // in-memory event is added if events.list has not yet caught up. Dedup by event id; the events.list + // payload wins when both sources describe the same event (Stripe's serialization is the + // authoritative view). + var unioned = new Dictionary(eventsListResult.Events.Length + 1); + foreach (var eventListItem in eventsListResult.Events) + { + unioned[eventListItem.EventId] = eventListItem; + } + + if (justAcknowledgedEvent is not null && !unioned.ContainsKey(justAcknowledgedEvent.EventId)) + { + // justAcknowledgedEvent.Payload is the webhook body threaded in-memory from StripeWebhookEndpoints; it + // is the SAME string received from the HTTP request, not a re-read from the stripe_events + // archive. The archive is consulted only by ReplayArchivedTenantStripeEventsCommand for + // disaster recovery. + unioned[justAcknowledgedEvent.EventId] = new StripeReplayEvent( + justAcknowledgedEvent.EventId, justAcknowledgedEvent.EventType, justAcknowledgedEvent.StripeCreatedAt, + justAcknowledgedEvent.Payload, justAcknowledgedEvent.ApiVersion + ); + } + + if (unioned.Count == 0) + { + // Re-snapshot after the heal pass so any local-state validation that the detector performs + // (e.g. ScheduledPriceMissing) sees the healed values, not the stale pre-sync state. + var emptyUnionedLocalAfterSyncSnapshot = StripeSyncSnapshot.FromSubscription(subscription); + var driftSnapshotsForEmptyUnion = driftSnapshots with { LocalBeforeSync = emptyUnionedLocalAfterSyncSnapshot }; + // The empty-union branch fires when Stripe's events.list returns nothing new since the + // last sync anchor — typical for a sync that runs shortly after a successful one. The drift + // detector still needs the existing billing_events count for its MissingEvent check; + // passing 0 here would falsely flag every healthy customer as "no billing events recorded". + // The count is re-queried here (instead of relying on a stored value) so any stale drift + // emitted by an earlier pass that read 0 before commit gets corrected on the next sync. + var existingBillingEventIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + await DetectDriftAsync(subscription, driftSnapshotsForEmptyUnion, existingBillingEventIds.Count, false, [], [], syncMode, cancellationToken); + return; + } + + var unsupportedVersions = new HashSet(); + var supportedEvents = new List(unioned.Count); + foreach (var stripeEvent in unioned.Values) + { + if (StripeEventPayloadResolverFactory.TryFor(stripeEvent.ApiVersion, out _)) + { + supportedEvents.Add(stripeEvent); + continue; + } + + if (unsupportedVersions.Add(stripeEvent.ApiVersion)) + { + logger.LogWarning( + "Stripe event {EventId} has unsupported api_version '{ApiVersion}'; replay skipped — add an IStripeEventPayloadResolver implementation", + stripeEvent.EventId, stripeEvent.ApiVersion + ); + } + } + + var stripeClient = stripeClientFactory.GetClient(); + var planByPriceId = await stripeClient.GetPlanByPriceIdAsync(cancellationToken); + var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); + var priceByPlan = priceCatalog.ToDictionary(p => p.Plan, p => p.UnitAmount); + + var existingStripeEventIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + // Seed the running state from the latest persisted BillingEvent so an events.list anchor that has aged + // past Stripe's 30-day window doesn't replay against phantom-zero defaults — without this seed a cancel + // toggle arriving alone in a stale-anchor sync would emit SubscriptionCancelled with previousAmount=0 / + // amountDelta=0 / committedMrr=0 and silently rewrite MRR history. + var persistedRowsForSeed = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + var latestPersistedBillingEvent = persistedRowsForSeed + .OrderByDescending(r => r.OccurredAt) + .ThenByDescending(r => r.Id.Value) + .FirstOrDefault(); + var state = StripeEventReplayer.SeedReplayStateFromHistory(latestPersistedBillingEvent, subscription); + + // Several SyncStateFromStripe branches (subscriptionExpired, subscriptionImmediatelyCancelled, + // subscriptionSuspended, IsCustomerDeleted) call Subscription.ResetToFreePlan which nulls + // CurrentPriceCurrency BEFORE this emission runs, so the live subscription is no longer authoritative. + // Prefer the just-fetched Stripe view, otherwise fall back to the pre-sync local snapshot — both + // were captured before any mutation. The replayer still tries the per-event payload first. + var currencyOverride = driftSnapshots.Stripe?.CurrentPriceCurrency ?? driftSnapshots.LocalBeforeSync.CurrentPriceCurrency; + var replayedEvents = StripeEventReplayer.Replay(subscription, [.. supportedEvents], planByPriceId, priceByPlan, state, currencyOverride, logger); + + var appendedCount = 0; + foreach (var billingEvent in replayedEvents) + { + if (existingStripeEventIds.Contains(billingEvent.StripeEventId)) continue; + // Detect mode never persists billing_events, so it must not count would-be-appended rows toward + // the billingEventCount passed to the drift detector. Otherwise the MissingEvent check sees a + // phantom count > 0 and fails to flag cancelled customers whose payment_transactions array is + // populated while the billing_events log is empty (the worker tripwire's load-bearing signal). + if (syncMode == SyncMode.Detect) continue; + + await billingEventRepository.AddAsync(billingEvent, cancellationToken); + + // SubscribedSince is a denormalized cache of MIN(occurred_at) across SubscriptionCreated rows for + // the tenant. AdvanceSubscribedSinceBackwardFromBillingEvent is monotonic-backward: a late-arriving + // recovered event can rewind it earlier, but a new subscription started after a cancel (later + // OccurredAt) cannot move it forward. + if (billingEvent.EventType == BillingEventType.SubscriptionCreated) + { + subscription.AdvanceSubscribedSinceBackwardFromBillingEvent(billingEvent.OccurredAt); + } + + appendedCount++; + } + + // Advance the events.list anchor to the most recent event we just consumed so the next sync only + // pulls events Stripe produced after this point. Pending-source events whose Created is older than + // an already-applied anchor cannot rewind it (AdvanceLastSyncedStripeEventCreatedAt is monotonic). + // Skip the advance entirely when events.list failed mid-pagination — otherwise an unseen event + // older than supportedEvents.Max would be permanently skipped on the next sync. + if (syncMode == SyncMode.Apply && supportedEvents.Count > 0 && eventsListResult.Succeeded) + { + var latestEventCreated = supportedEvents.Max(e => e.CreatedAt); + subscription.AdvanceLastSyncedStripeEventCreatedAt(latestEventCreated); + } + + // Out-of-order recovery (e.g. a customer.subscription.created arriving after a later + // customer.subscription.deleted was already classified and persisted) leaves the persisted row's + // denormalized fields wrong for the now-correct state-machine ordering. The append-only invariant + // forbids mutating the persisted row, so surface the wrongness via drift instead so an operator + // can investigate. + // + // EXCEPT: Stripe's events.list?created.gte=X is inclusive on X, AND Stripe routinely emits multiple + // events at the same `created` timestamp (e.g. a Standard-to-Premium upgrade emits three sibling + // events with identical `created`: a per-plan NoOp from the prior plan, a per-plan NoOp on the new + // plan, and the SubscriptionUpgraded itself). The "latest persisted" pick selects ONE row from such + // a same-second cluster; re-replaying any of its siblings from a state seeded by that row produces + // structurally different denormalized fields by construction (the previousMrr at their original + // persistence time is not the post-state of whichever sibling we happened to pick as the seed). + // Skip the entire same-second cluster by OccurredAt equality. Events strictly older than the seed + // (genuine out-of-order recovery) and events strictly newer than the seed (forward replay) both + // remain comparable; mismatches there are real and worth flagging. + var seedOccurredAt = latestPersistedBillingEvent?.OccurredAt; + var persistedRows = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + var persistedByStripeId = persistedRows.ToDictionary(r => r.StripeEventId); + var staleBillingEvents = new List(); + foreach (var replayed in replayedEvents) + { + if (!persistedByStripeId.TryGetValue(replayed.StripeEventId, out var persisted)) continue; + if (seedOccurredAt is not null && replayed.OccurredAt == seedOccurredAt) continue; + if (persisted.CommittedMrr != replayed.CommittedMrr + || persisted.AmountDelta != replayed.AmountDelta + || persisted.PreviousAmount != replayed.PreviousAmount + || persisted.NewAmount != replayed.NewAmount) + { + staleBillingEvents.Add(replayed); + } + } + + // Re-query the canonical billing_events count fresh from the repository so DetectDriftAsync sees + // the latest committed state. Prevents a stuck MissingEvent: if an earlier worker pass read 0 + // (events not yet committed) and persisted that drift, this fresh count corrects the picture. + var freshExistingIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + var totalBillingEvents = Math.Max(freshExistingIds.Count, existingStripeEventIds.Count + appendedCount); + + // Re-snapshot the local subscription after the Apply-mode heal pass so the drift detector compares + // the healed state — not the stale pre-sync state — against Stripe. Without this, a heal that + // wrote a previously-missing ScheduledPriceAmount in the same sync would still trigger a + // ScheduledPriceMissing Critical discrepancy on the very pass that fixed it. Detect mode never + // mutates the subscription, so the re-snapshot matches the original LocalBeforeSync. + var localAfterSyncSnapshot = StripeSyncSnapshot.FromSubscription(subscription); + var driftSnapshotsForDetection = driftSnapshots with { LocalBeforeSync = localAfterSyncSnapshot }; + + await DetectDriftAsync(subscription, driftSnapshotsForDetection, totalBillingEvents, state.HasUnclassifiedEvent, unsupportedVersions, staleBillingEvents, syncMode, cancellationToken); } - private void MarkAllEventsAsProcessed(StripeEvent[] pendingEvents, Subscription subscription) + private async Task DetectDriftAsync( + Subscription subscription, + DriftSnapshots driftSnapshots, + int billingEventCount, + bool hasUnclassifiedEvent, + HashSet unsupportedApiVersions, + IReadOnlyList staleBillingEvents, + SyncMode syncMode, + CancellationToken cancellationToken + ) { + // Detect mode is the BillingDriftWorker tripwire. When the Stripe view is unavailable (the integration + // client swallowed a StripeException or TaskCanceledException and returned null) the detector would + // otherwise self-compare the pre-sync local snapshot against itself, find no divergence, and still + // advance DriftCheckedAt via SetDriftStatus — which would mask a real Stripe outage from the worker's + // staleness tripwire for 23h. Skip the SetDriftStatus call so the row stays stale and the next pass + // retries. The webhook hot path (Apply mode) keeps the legacy fallback so the remaining drift checks + // still run, and Stripe's webhook retry handles the recovery. + if (syncMode == SyncMode.Detect && driftSnapshots.Stripe is null) + { + logger.LogError("Skipping drift detection for Stripe customer '{StripeCustomerId}' because the Stripe view is unavailable; DriftCheckedAt left unchanged so the next worker pass retries", subscription.StripeCustomerId); + events.CollectEvent(new BillingDriftSkippedDueToStripeUnavailable(subscription.Id)); + return; + } + + var now = timeProvider.GetUtcNow(); + try + { + // The Stripe snapshot is null when the customer fetch failed earlier in the sync, so fall back + // to the local pre-sync view (no SubscriptionStateMismatch can be detected without a Stripe view). + var stripeSnapshot = driftSnapshots.Stripe ?? driftSnapshots.LocalBeforeSync; + var discrepancies = BillingDriftDetector.Detect(driftSnapshots.LocalBeforeSync, stripeSnapshot, subscription.PaymentTransactions.Length, billingEventCount); + if (hasUnclassifiedEvent) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.UnclassifiedStripeEvent, + "Stripe sent a subscription update combining multiple changes that don't decompose into a single domain transition. Investigate in Stripe Dashboard.", + DriftSeverity.Warning + ) + ); + } + + foreach (var version in unsupportedApiVersions) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.UnsupportedStripeApiVersion, + $"Stripe sent an event using api_version '{version}' for which no IStripeEventPayloadResolver is registered. The event is preserved in stripe_events but not replayed into billing_events. Add a resolver and re-sync.", + DriftSeverity.Critical, + ActualValue: version + ) + ); + } + + foreach (var staleRow in staleBillingEvents) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.BillingEventDenormalizationStale, + $"Persisted billing_event '{staleRow.StripeEventId}' has stale denormalized fields. Replay produced different CommittedMrr/AmountDelta/PreviousAmount/NewAmount values; this indicates an out-of-order event recovery. The persisted row is left untouched per the append-only invariant.", + DriftSeverity.Warning, + staleRow.EventType, + OccurredAt: staleRow.OccurredAt + ) + ); + } + + // Surface payment transactions whose AmountExcludingTax was clamped to zero because Stripe + // returned tax > display. The clamp itself is intentional (keeps the DB CHECK happy so the + // webhook does not 500 and trigger infinite Stripe retries), but the underlying anomaly must + // be visible on the drift banner so an operator can investigate the Stripe side. + var clampedTransactions = subscription.PaymentTransactions.Where(t => t.AmountExcludingTax is 0m && t.TaxAmount > 0m && t.Amount < t.TaxAmount).ToArray(); + foreach (var clampedTransaction in clampedTransactions) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.AmountExcludingTaxClamped, + $"Payment transaction '{clampedTransaction.Id}' had AmountExcludingTax clamped to zero because Stripe reported tax ({clampedTransaction.TaxAmount}) greater than display amount ({clampedTransaction.Amount}). LTV totals undercount this row; investigate the Stripe invoice for the underlying anomaly.", + DriftSeverity.Warning, + ExpectedValue: clampedTransaction.Amount.ToString("F2", CultureInfo.InvariantCulture), + ActualValue: clampedTransaction.TaxAmount.ToString("F2", CultureInfo.InvariantCulture), + OccurredAt: clampedTransaction.Date + ) + ); + } + + subscription.SetDriftStatus(discrepancies, now); + if (syncMode == SyncMode.Detect) + { + // Detect mode read the subscription untracked (no FOR UPDATE) so the BillingDriftWorker does + // not block the webhook hot path on the same row for the full Stripe roundtrip. Persist drift + // status via a column-only UPDATE that takes its own brief row lock only for the write itself. + await subscriptionRepository.UpdateDriftStatusAsync(subscription.Id, subscription.HasDriftDetected, now, discrepancies, cancellationToken); + } + else + { + // Apply mode loaded the subscription tracked under FOR UPDATE; SetDriftStatus rides along with + // the other mutations and is flushed by the surrounding SaveChangesAsync. Update is idempotent + // when an earlier branch already marked the aggregate dirty. + subscriptionRepository.Update(subscription); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Drift detection threw while syncing Stripe customer '{StripeCustomerId}', existing drift status preserved", subscription.StripeCustomerId); + } + } + + /// + /// Pulls Stripe's events.list for the customer (anchored on + /// ) and inserts any event ids Stripe + /// knows about but the local archive doesn't into stripe_events as recovered rows. The + /// archive is a cold backup only; the events.list response itself is what drives BillingEvent + /// emission in , so the local payload column + /// is never read in the hot path. Returns the events.list response so the emitter can consume + /// it directly. + /// + private async Task PullEventsListAndArchiveRecoveredAsync(Subscription subscription, StripeCustomerId stripeCustomerId, SyncMode syncMode, CancellationToken cancellationToken) + { + var stripeClient = stripeClientFactory.GetClient(); + var eventsListResult = await stripeClient.GetEventsForCustomerAsync(stripeCustomerId, subscription.LastSyncedStripeEventCreatedAt, cancellationToken); + if (eventsListResult.Events.Length == 0) return eventsListResult; + + // Detect mode is a tripwire: read paths run, but no recovered stripe_events rows are inserted. The + // webhook hot path and the reconcile admin action own remediation; the worker only flags drift. + if (syncMode == SyncMode.Detect) return eventsListResult; + + var existingIds = await stripeEventRepository.GetExistingEventIdsByStripeCustomerIdAsync(stripeCustomerId, cancellationToken); var now = timeProvider.GetUtcNow(); - foreach (var pendingEvent in pendingEvents) + foreach (var stripeEvent in eventsListResult.Events) { - pendingEvent.MarkProcessed(now); - pendingEvent.SetStripeSubscriptionId(subscription.StripeSubscriptionId); - pendingEvent.SetTenantId(subscription.TenantId); - stripeEventRepository.Update(pendingEvent); + if (existingIds.Contains(stripeEvent.EventId)) continue; + + var payloadHash = StripeEventPayloadHasher.Hash(stripeEvent.Payload); + var recoveredEvent = StripeEvent.CreateRecovered( + stripeEvent.EventId, + stripeEvent.EventType, + stripeCustomerId, + stripeEvent.Payload, + stripeEvent.ApiVersion, + payloadHash, + now, + "events_list", + stripeEvent.CreatedAt + ); + await stripeEventRepository.AddAsync(recoveredEvent, cancellationToken); + + events.CollectEvent(new WebhookDeliveryRecovered(stripeEvent.EventId, stripeEvent.EventType, "events_list")); + logger.LogWarning( + "Recovered Stripe event {EventId} ({EventType}) for customer '{StripeCustomerId}' from events.list — webhook delivery was missed", + stripeEvent.EventId, stripeEvent.EventType, stripeCustomerId + ); } + + return eventsListResult; } private void SendTelemetryEvents(Tenant tenant, Subscription subscription) { TenantScopedTelemetryContext.Set(tenant.Id, subscription.Plan.ToString()); - // Publish collected telemetry events after successful commit while (events.HasEvents) { var telemetryEvent = events.Dequeue(); @@ -279,4 +772,18 @@ private void SendTelemetryEvents(Tenant tenant, Subscription subscription) logger.LogInformation("Telemetry: {EventName} {EventProperties}", telemetryEvent.GetType().Name, string.Join(", ", telemetryEvent.Properties.Select(p => $"{p.Key}={p.Value}"))); } } + + private sealed record DriftSnapshots(StripeSyncSnapshot LocalBeforeSync, StripeSyncSnapshot? Stripe); +} + +/// +/// Controls whether applies state changes (Apply) or merely flags +/// drift via (Detect). The webhook hot path and the back-office +/// reconcile admin action run Apply; the BillingDriftWorker tripwire runs Detect so it can never +/// silently mutate persisted state — real remediation stays on the customer-facing paths. +/// +public enum SyncMode +{ + Apply, + Detect } diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs new file mode 100644 index 0000000000..5c19366008 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Stable SHA-256 hash of a raw Stripe webhook payload. Used by AcknowledgeStripeWebhook on insert +/// and by the reconciliation pass on recovered rows so the same value lands in stripe_events.payload_hash +/// regardless of the entry path. Comparing hashes is how StripeEventPayloadDivergence detects the +/// forensic anomaly of a redelivered event with a different body. +/// Hex-lower so the same hash format works for grep/log pivots regardless of Stripe API version. +/// +public static class StripeEventPayloadHasher +{ + public static string Hash(string payload) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs new file mode 100644 index 0000000000..9c9b741614 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs @@ -0,0 +1,74 @@ +namespace Account.Features.Subscriptions.Shared; + +/// +/// Per-Stripe-API-version dispatcher for the JSON-shape navigation that the replayer relies on. +/// Stripe events carry an immutable api_version at creation time +/// (see https://docs.stripe.com/api/events). When Stripe ships a new API version that renames +/// fields or restructures the payload, a new resolver implementation handles the new shape while +/// existing rows keep their original resolver — old fixtures keep passing without modification. +/// throws +/// for unknown versions; the calling code +/// catches it and adds a UnsupportedStripeApiVersion drift discrepancy so an admin knows +/// to add the new resolver. +/// +public interface IStripeEventPayloadResolver; + +/// +/// Default resolver for Stripe API versions whose JSON shape matches what +/// currently parses. Extends as new versions ship — +/// each new resolver implementation handles its own payload navigation. +/// +public sealed class DefaultStripeEventPayloadResolver : IStripeEventPayloadResolver; + +/// +/// Routes a Stripe event's api_version to the matching resolver. +/// +public static class StripeEventPayloadResolverFactory +{ + private static readonly DefaultStripeEventPayloadResolver Default = new(); + + // TODO: When Stripe rolls a new pinned API version, update this list AND add a new + // IStripeEventPayloadResolver implementation. Future: derive from Stripe.NET's + // StripeConfiguration.ApiVersion. Keeping it explicit for now so adding a version is a + // deliberate, reviewable change rather than a silent dependency upgrade. + private static readonly HashSet RecognizedVersions = ["2025-09-30.preview", "2025-10-29.clover"]; + + public static IStripeEventPayloadResolver For(string apiVersion) + { + if (!TryFor(apiVersion, out var resolver)) + { + throw new UnsupportedStripeApiVersionException(apiVersion); + } + + return resolver; + } + + /// + /// Non-throwing variant of . Returns false when the api_version is unknown so + /// callers in hot paths (the replayer) can branch on the result instead of paying for an + /// exception. The throwing remains for genuinely unreachable cases. + /// + public static bool TryFor(string apiVersion, out IStripeEventPayloadResolver resolver) + { + if (RecognizedVersions.Contains(apiVersion)) + { + resolver = Default; + return true; + } + + resolver = null!; + return false; + } +} + +/// +/// Thrown by when Stripe sends an event +/// whose api_version we don't have a resolver for. The replayer catches this, logs the event, +/// and surfaces a UnsupportedStripeApiVersion drift discrepancy so the missing resolver +/// can be added. +/// +public sealed class UnsupportedStripeApiVersionException(string apiVersion) + : InvalidOperationException($"Stripe event api_version '{apiVersion}' is not supported by any registered IStripeEventPayloadResolver.") +{ + public string ApiVersion { get; } = apiVersion; +} diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs new file mode 100644 index 0000000000..3078e6e792 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs @@ -0,0 +1,823 @@ +using System.Text.Json; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using Account.Integrations.Stripe; +using SharedKernel.Domain; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Classifies Stripe events into BillingEvent rows under a strict 1:1 invariant: every recognized +/// subscription-relevant Stripe event yields exactly one row. +/// The hot path drives this classifier from Stripe's events.list response plus the single just-acked +/// webhook payload threaded in-memory via EmitBillingEventsFromEventsListAsync; the durable +/// stripe_events.payload column is read only by the +/// ReplayArchivedTenantStripeEventsCommand disaster-recovery handler, which feeds the archived +/// payloads into this same classifier when events have aged past Stripe's 30-day events.list +/// retention window. +/// Events that don't move state we care about are emitted as . +/// Events whose payload combines multiple state changes that don't decompose into one of our domain +/// transitions are emitted as and flip the +/// flag for the caller to translate into a +/// Subscription.HasDriftDetected change. +/// The replayer is a state machine: it iterates events in chronological order and tracks running +/// subscription state (current plan/price, cancel-at-period-end, scheduled downgrade plan, committed +/// MRR). The committed_mrr column on every row is the state-after, denormalized so paginated reads +/// don't have to walk history. +/// +public static class StripeEventReplayer +{ + public static IReadOnlyList Replay( + Subscription subscription, + StripeReplayEvent[] stripeEvents, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + ReplayState? state = null, + string? currencyOverride = null, + ILogger? logger = null + ) + { + var emitted = new List(); + state ??= new ReplayState(); + + foreach (var stripeEvent in stripeEvents.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)) + { + var payload = ParsePayload(stripeEvent.Payload); + + // Per-event resolution preserves the true currency of every row even when state mutations + // (e.g. Subscription.ResetToFreePlan in the same sync transaction) have nulled the live + // CurrentPriceCurrency before this replayer reads it. Order: payload (authoritative for the + // event itself), then caller-supplied override (typically a pre-mutation snapshot), then the + // live subscription. No "USD" fallback — a missing currency is a real upstream defect. + var currency = ExtractCurrencyFromPayload(payload) + ?? currencyOverride + ?? subscription.CurrentPriceCurrency; + + // Customer-lifecycle events (customer.created/updated and payment_method.attached) carry no + // currency in Stripe's data model — currency belongs to prices and invoices, not the Customer + // or PaymentMethod object. For these events, null currency is the correct semantic; for + // revenue-bearing events, null currency is an upstream defect and the row is skipped. + if (currency is null && RequiresCurrency(stripeEvent.EventType)) + { + logger?.LogWarning( + "Skipping Stripe event {EventId} ({EventType}) for subscription '{SubscriptionId}': no currency could be resolved from payload, override, or subscription", + stripeEvent.EventId, stripeEvent.EventType, subscription.Id.Value + ); + continue; + } + + var billingEvent = MapEvent(stripeEvent, payload, subscription, state, planByPriceId, priceByPlan, currency); + if (billingEvent is not null) + { + emitted.Add(billingEvent); + } + } + + return emitted; + } + + private static bool RequiresCurrency(string eventType) + { + return eventType switch + { + "customer.created" or "customer.updated" or "payment_method.attached" => false, + _ => true + }; + } + + private static BillingEvent? MapEvent( + StripeReplayEvent stripeEvent, + JsonElement payload, + Subscription subscription, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string? currency + ) + { + var subscriptionId = subscription.Id; + var tenantId = subscription.TenantId; + var occurredAt = stripeEvent.CreatedAt; + var stripeEventId = stripeEvent.EventId; + + switch (stripeEvent.EventType) + { + case "customer.created": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.BillingInfoAdded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + + case "customer.updated": + return HasBillingFieldsChanged(payload) + ? BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.BillingInfoUpdated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ) + : NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + + case "payment_method.attached": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentMethodUpdated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + + // From here on, every event type is a revenue-bearing event for which the Replay-loop's + // `RequiresCurrency` gate guarantees a non-null currency before calling MapEvent. `currency!` + // reflects that invariant — it is never null in these branches. + case "customer.subscription.created": + return MapSubscriptionCreated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency!, subscription.Plan); + + case "customer.subscription.updated": + return MapSubscriptionUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency!, subscription.CancellationReason); + + // customer.subscription.pending_update_applied fires alongside customer.subscription.updated for + // the same upgrade transition. The updated event carries previous_attributes and is the higher- + // fidelity source — pending_update_applied is recorded as NoOp to preserve the 1:1 audit row. + case "customer.subscription.pending_update_applied": + case "customer.subscription.pending_update_expired": + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency!); + + case "customer.subscription.deleted": + return MapSubscriptionDeleted(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency!); + + case "customer.deleted": + return MapCustomerDeleted(occurredAt, stripeEventId, tenantId, subscriptionId, state, currency!); + + // subscription_schedule.created carries only the current phase — the future-phase plan that + // defines the downgrade target only shows up in the subsequent subscription_schedule.updated + // event. The created row is preserved as NoOp for the audit trail. + case "subscription_schedule.created": + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency!); + + case "subscription_schedule.updated": + return MapScheduleUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency!); + + case "subscription_schedule.released": + case "subscription_schedule.canceled": + return MapScheduleTerminated(occurredAt, stripeEventId, tenantId, subscriptionId, state, currency!); + + case "invoice.payment_succeeded": + return MapInvoicePaymentSucceeded(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency!); + + case "invoice.payment_failed": + return MapInvoicePaymentFailed(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency!); + + case "charge.refunded": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentRefunded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, newAmount: state.CommittedMrr, currency: currency + ); + + default: + // Stripe event we don't have a case for. The 1:1 invariant only applies to events the + // writer recognizes — unknown events are not subscription-relevant and are skipped. + return null; + } + } + + private static BillingEvent MapSubscriptionCreated( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency, + SubscriptionPlan fallbackPlan + ) + { + var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId) ?? fallbackPlan; + var newPrice = ExtractUnitAmountFromPayload(payload) ?? (priceByPlan.TryGetValue(newPlan, out var catalogPrice) ? catalogPrice : 0m); + var previousMrr = state.CommittedMrr; + state.Plan = newPlan; + state.PlanPrice = newPrice; + state.CancelAtPeriodEnd = false; + state.ScheduledPlan = null; + state.CommittedMrr = newPrice; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionCreated, occurredAt, state.CommittedMrr, + toPlan: newPlan, + previousAmount: previousMrr, newAmount: newPrice, + amountDelta: newPrice - previousMrr, + currency: currency + ); + } + + private static BillingEvent MapSubscriptionUpdated( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency, + CancellationReason? subscriptionCancellationReason + ) + { + if (payload.ValueKind != JsonValueKind.Object) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var previous = payload.TryGetProperty("data", out var data) && data.TryGetProperty("previous_attributes", out var previousAttributes) ? previousAttributes : default; + if (previous.ValueKind != JsonValueKind.Object) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var cancelAtPeriodEndChanged = previous.TryGetProperty("cancel_at_period_end", out var previousCancelAtPeriodEnd) && previousCancelAtPeriodEnd.ValueKind is JsonValueKind.True or JsonValueKind.False; + var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId); + var previousPlan = ResolvePlanFromPreviousAttributes(previous, planByPriceId); + var planChanged = newPlan is not null && previousPlan is not null && newPlan != previousPlan; + + // Combined cancel-toggle and plan-change in the same Stripe event payload. Our domain models these + // as separate transitions, so we can't decompose this into one row without losing information. + // Emit Unclassified, flip the drift flag for admin review, and don't mutate state — the next sync's + // direct subscription-state diff against Stripe will reconcile. + if (cancelAtPeriodEndChanged && planChanged) + { + state.HasUnclassifiedEvent = true; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.Unclassified, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + } + + if (cancelAtPeriodEndChanged && previousCancelAtPeriodEnd.ValueKind == JsonValueKind.False) + { + // false → true: cancellation scheduled. Forward MRR drops at the moment the customer commits to + // leaving, not at the effective period end — committed MRR is the leading indicator we want. + var previousMrr = state.CommittedMrr; + state.CancelAtPeriodEnd = true; + state.CommittedMrr = 0m; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionCancelled, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: 0m, amountDelta: -previousMrr, + currency: currency, + cancellationReason: subscriptionCancellationReason + ); + } + + if (cancelAtPeriodEndChanged && previousCancelAtPeriodEnd.ValueKind == JsonValueKind.True) + { + // true → false: reactivation. Restore committed MRR to the active plan's price. + var previousMrr = state.CommittedMrr; + state.CancelAtPeriodEnd = false; + state.CommittedMrr = state.PlanPrice; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionReactivated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: state.CommittedMrr, + amountDelta: state.CommittedMrr - previousMrr, + currency: currency + ); + } + + if (planChanged) + { + // Plan change (items.data[0].price changed). MRR impact is the real price diff between plans + // looked up from the catalog — so an upgrade Standard→Premium shows +150 and a downgrade + // Premium→Standard shows -150. + var eventType = newPlan!.Value > previousPlan!.Value ? BillingEventType.SubscriptionUpgraded : BillingEventType.SubscriptionDowngraded; + var previousMrr = state.CommittedMrr; + var newPrice = ExtractUnitAmountFromPayload(payload) ?? (priceByPlan.TryGetValue(newPlan.Value, out var catalogPrice) ? catalogPrice : 0m); + state.Plan = newPlan; + state.PlanPrice = newPrice; + state.CommittedMrr = state.CancelAtPeriodEnd ? 0m : newPrice; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, + previousPlan, newPlan, + previousMrr, state.CommittedMrr, + state.CommittedMrr - previousMrr, + currency + ); + } + + // status: active → past_due (or unpaid). Customer's payment failed; subscription remains on plan but is + // delinquent. Both Stripe statuses indicate the same business state from our perspective — the + // dunning escalation path (past_due → unpaid → canceled) is a Stripe-side detail. Pairs with the + // PaymentFailed row that the invoice.payment_failed event produces at the same timestamp; both rows + // describe different facets of the same business event. Committed MRR unchanged. + if (previous.TryGetProperty("status", out var prevStatus) && prevStatus.GetString() == "active") + { + var newStatus = ResolveSubscriptionStatus(payload); + if (newStatus is "past_due" or "unpaid") + { + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionPastDue, occurredAt, state.CommittedMrr, + toPlan: state.Plan, newAmount: state.CommittedMrr, currency: currency + ); + } + } + + // latest_invoice rolled to a new invoice id — Stripe started a new billing cycle. This branch is + // intentionally a NoOp: + // * Happy-path renewal: invoice.payment_succeeded fires next and emits SubscriptionRenewed (or + // PaymentRecovered for retry success). Emitting SubscriptionRenewed here too would duplicate it. + // * Past_due renewal: the active → past_due status change in the same payload is handled by the + // branch above, which emits SubscriptionPastDue. The latest_invoice change is the same business + // event from a different angle and adds no unique signal. + // Audit row preserved so the 1:1 invariant holds. + if (previous.TryGetProperty("latest_invoice", out _)) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + // previous_attributes carried fields we don't track (e.g. metadata, period dates). Audit row. + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + private static string? ResolveSubscriptionStatus(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var subscription = data.TryGetProperty("object", out var subscriptionObject) ? subscriptionObject : default; + if (subscription.ValueKind != JsonValueKind.Object) return null; + return subscription.TryGetProperty("status", out var status) ? status.GetString() : null; + } + + private static BillingEvent MapSubscriptionDeleted( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Stripe clears cancel_at_period_end BEFORE emitting customer.subscription.deleted, so the payload's + // cape field is unreliable for distinguishing a voluntary period-end expiry from an immediate cancel. + // The leading signal is cancellation_details.reason (payment_failed → dunning, cancellation_requested + // → voluntary), with state.CancelAtPeriodEnd (tracked from prior subscription.updated events) as the + // disambiguator for voluntary cases. + var cancellationReason = ExtractCancellationReasonFromPayload(payload); + + BillingEventType eventType; + SuspensionReason? suspensionReason = null; + if (cancellationReason == "payment_failed") + { + eventType = BillingEventType.SubscriptionSuspended; + suspensionReason = SuspensionReason.PaymentFailed; + } + else if (state.CancelAtPeriodEnd) + { + eventType = BillingEventType.SubscriptionExpired; + } + else + { + eventType = BillingEventType.SubscriptionImmediatelyCancelled; + } + + var previousMrr = state.CommittedMrr; + var fromPlan = state.Plan; + state.Plan = null; + state.PlanPrice = 0m; + state.CancelAtPeriodEnd = false; + state.ScheduledPlan = null; + state.CommittedMrr = 0m; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, + fromPlan, SubscriptionPlan.Basis, + previousMrr, 0m, -previousMrr, + currency, + suspensionReason: suspensionReason + ); + } + + private static BillingEvent MapCustomerDeleted( + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Stripe customer deletion zeroes the tenant's MRR — emitted as SubscriptionSuspended with the + // CustomerDeleted reason so the audit log captures why the subscription ended even when the + // corresponding customer.subscription.deleted event never arrived (or arrives separately). + var previousMrr = state.CommittedMrr; + var fromPlan = state.Plan; + state.Plan = null; + state.PlanPrice = 0m; + state.CancelAtPeriodEnd = false; + state.ScheduledPlan = null; + state.CommittedMrr = 0m; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionSuspended, occurredAt, state.CommittedMrr, + fromPlan, SubscriptionPlan.Basis, + previousMrr, 0m, -previousMrr, + currency, + suspensionReason: SuspensionReason.CustomerDeleted + ); + } + + private static BillingEvent MapScheduleUpdated( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency + ) + { + // Stripe emits a trailing schedule.updated event with status=canceled/released/completed right + // after a schedule is dropped; the phases array hasn't changed, so falling through to the + // resolver would re-emit a phantom DowngradeScheduled. Terminal-status updates are NoOp. + var status = ResolveScheduleStatus(payload); + if (status is "canceled" or "released" or "completed") + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var scheduledPlan = ResolveScheduledTargetPlan(payload, planByPriceId, state.Plan); + if (scheduledPlan is null || scheduledPlan == state.ScheduledPlan) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var scheduledPrice = ExtractUnitAmountFromPayload(payload) ?? (priceByPlan.TryGetValue(scheduledPlan.Value, out var catalogPrice) ? catalogPrice : 0m); + var previousMrr = state.CommittedMrr; + state.ScheduledPlan = scheduledPlan; + state.CommittedMrr = scheduledPrice; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionDowngradeScheduled, occurredAt, state.CommittedMrr, + state.Plan, scheduledPlan, + previousMrr, scheduledPrice, + scheduledPrice - previousMrr, + currency + ); + } + + private static BillingEvent MapScheduleTerminated( + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + if (state.ScheduledPlan is null) + { + // Schedule terminated without ever having a scheduled plan tracked locally — possibly because + // we missed the corresponding subscription_schedule.updated. Audit row. + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var previousMrr = state.CommittedMrr; + var newMrr = state.PlanPrice; + state.ScheduledPlan = null; + state.CommittedMrr = newMrr; + var delta = newMrr - previousMrr; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionDowngradeCancelled, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: newMrr, + amountDelta: delta == 0m ? null : delta, + currency: currency + ); + } + + private static BillingEvent MapInvoicePaymentSucceeded( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Only emit a Renewed/Recovered row for genuine recurring renewals (billing_reason == + // subscription_cycle). subscription_create is covered by customer.subscription.created; + // subscription_update is the proration invoice from a plan change and is covered by the + // customer.subscription.updated upgrade/downgrade row — emitting Renewed here would duplicate it. + var billingReason = ExtractInvoiceBillingReason(payload); + if (billingReason != "subscription_cycle") + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var eventType = HasMultiplePaymentAttempts(payload) ? BillingEventType.PaymentRecovered : BillingEventType.SubscriptionRenewed; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + } + + private static BillingEvent MapInvoicePaymentFailed( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Only emit PaymentFailed for genuine recurring billing failures (billing_reason == + // subscription_cycle). The proration invoice from a plan change can also fail and is covered by + // the customer.subscription.updated upgrade/downgrade row instead. Failures don't change committed + // MRR — the customer is still on the plan, just behind on payment. If Stripe later succeeds via a + // retry (3DS or smart-retry), invoice.payment_succeeded fires and emits PaymentRecovered — that's + // accurate history rather than swallowing the initial failure. + var billingReason = ExtractInvoiceBillingReason(payload); + if (billingReason != "subscription_cycle") + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentFailed, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + } + + // NoOp accepts nullable currency because the customer.updated branch reaches NoOp via the + // RequiresCurrency-bypass path (customer-lifecycle events have no currency in Stripe's data model); + // all other NoOp call sites are revenue contexts where currency is non-null. + private static BillingEvent NoOp(TenantId tenantId, SubscriptionId subscriptionId, string stripeEventId, DateTimeOffset occurredAt, ReplayState state, string? currency) + { + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.NoOp, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + } + + private static SubscriptionPlan? ResolvePlanFromSubscriptionPayload(JsonElement payload, IReadOnlyDictionary planByPriceId) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var subscription = data.TryGetProperty("object", out var subscriptionObject) ? subscriptionObject : default; + if (subscription.ValueKind != JsonValueKind.Object) return null; + var items = subscription.TryGetProperty("items", out var itemsElement) ? itemsElement : default; + if (items.ValueKind != JsonValueKind.Object) return null; + var itemsData = items.TryGetProperty("data", out var itemsDataElement) ? itemsDataElement : default; + if (itemsData.ValueKind != JsonValueKind.Array) return null; + foreach (var item in itemsData.EnumerateArray()) + { + var priceId = item.TryGetProperty("price", out var price) && price.TryGetProperty("id", out var priceIdElement) ? priceIdElement.GetString() : null; + if (priceId is not null && planByPriceId.TryGetValue(priceId, out var plan)) return plan; + } + + return null; + } + + private static SubscriptionPlan? ResolvePlanFromPreviousAttributes(JsonElement previousAttributes, IReadOnlyDictionary planByPriceId) + { + if (previousAttributes.ValueKind != JsonValueKind.Object) return null; + if (!previousAttributes.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Object) return null; + if (!items.TryGetProperty("data", out var itemsData) || itemsData.ValueKind != JsonValueKind.Array) return null; + foreach (var item in itemsData.EnumerateArray()) + { + var priceId = item.TryGetProperty("price", out var price) && price.TryGetProperty("id", out var priceIdElement) ? priceIdElement.GetString() : null; + if (priceId is not null && planByPriceId.TryGetValue(priceId, out var plan)) return plan; + } + + return null; + } + + /// + /// Resolves the scheduled target plan from a subscription_schedule.updated payload. The phases + /// array describes consecutive billing windows; the LAST phase carries the future plan after the + /// current period ends. Returns null when the schedule has fewer than two phases (no future + /// target) or when the last phase's plan equals the current plan (no actual change). + /// + private static SubscriptionPlan? ResolveScheduledTargetPlan(JsonElement payload, IReadOnlyDictionary planByPriceId, SubscriptionPlan? currentPlan) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var schedule = data.TryGetProperty("object", out var scheduleObject) ? scheduleObject : default; + if (schedule.ValueKind != JsonValueKind.Object) return null; + var phases = schedule.TryGetProperty("phases", out var phasesElement) ? phasesElement : default; + if (phases.ValueKind != JsonValueKind.Array) return null; + + SubscriptionPlan? lastPhasePlan = null; + var phaseCount = 0; + foreach (var phase in phases.EnumerateArray()) + { + phaseCount++; + var items = phase.TryGetProperty("items", out var itemsElement) ? itemsElement : default; + if (items.ValueKind != JsonValueKind.Array) continue; + foreach (var item in items.EnumerateArray()) + { + var priceId = item.TryGetProperty("price", out var price) ? price.GetString() : null; + if (priceId is not null && planByPriceId.TryGetValue(priceId, out var plan)) lastPhasePlan = plan; + } + } + + if (phaseCount < 2) return null; + if (lastPhasePlan == currentPlan) return null; + return lastPhasePlan; + } + + private static string? ResolveScheduleStatus(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var schedule = data.TryGetProperty("object", out var scheduleObject) ? scheduleObject : default; + if (schedule.ValueKind != JsonValueKind.Object) return null; + return schedule.TryGetProperty("status", out var status) ? status.GetString() : null; + } + + private static bool HasBillingFieldsChanged(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return false; + var previous = payload.TryGetProperty("data", out var data) && data.ValueKind == JsonValueKind.Object && data.TryGetProperty("previous_attributes", out var previousAttributes) ? previousAttributes : default; + if (previous.ValueKind != JsonValueKind.Object) return false; + return previous.TryGetProperty("address", out _) + || previous.TryGetProperty("email", out _) + || previous.TryGetProperty("name", out _) + || previous.TryGetProperty("tax_ids", out _); + } + + private static string? ExtractInvoiceBillingReason(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var invoice = data.TryGetProperty("object", out var invoiceObject) ? invoiceObject : default; + if (invoice.ValueKind != JsonValueKind.Object) return null; + return invoice.TryGetProperty("billing_reason", out var billingReason) ? billingReason.GetString() : null; + } + + private static bool HasMultiplePaymentAttempts(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return false; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return false; + var invoice = data.TryGetProperty("object", out var invoiceObject) ? invoiceObject : default; + if (invoice.ValueKind != JsonValueKind.Object) return false; + var attemptCount = invoice.TryGetProperty("attempt_count", out var attemptCountElement) && attemptCountElement.ValueKind == JsonValueKind.Number ? attemptCountElement.GetInt32() : 0; + return attemptCount > 1; + } + + /// + /// Extracts the ISO 4217 currency code from a Stripe event payload. invoice.* and + /// customer.subscription.* objects expose $.data.object.currency; the subscription object + /// additionally carries the per-item price under $.data.object.items.data[0].price.currency + /// which is the authoritative source on the rare event types where the top-level currency + /// is absent. Stripe normalizes currency codes to lowercase in payloads (per + /// https://docs.stripe.com/currencies); we upper-case to match the rest of the codebase. + /// + private static string? ExtractCurrencyFromPayload(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var stripeObject = data.TryGetProperty("object", out var rawObject) ? rawObject : default; + if (stripeObject.ValueKind != JsonValueKind.Object) return null; + + if (stripeObject.TryGetProperty("currency", out var currency) && currency.ValueKind == JsonValueKind.String) + { + return currency.GetString()?.ToUpperInvariant(); + } + + if (stripeObject.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object + && items.TryGetProperty("data", out var itemsData) && itemsData.ValueKind == JsonValueKind.Array + && itemsData.GetArrayLength() > 0) + { + var firstItem = itemsData[0]; + if (firstItem.ValueKind == JsonValueKind.Object + && firstItem.TryGetProperty("price", out var price) && price.ValueKind == JsonValueKind.Object + && price.TryGetProperty("currency", out var priceCurrency) && priceCurrency.ValueKind == JsonValueKind.String) + { + return priceCurrency.GetString()?.ToUpperInvariant(); + } + } + + return null; + } + + /// + /// Extracts $.data.object.items.data[0].price.unit_amount from a Stripe subscription event + /// payload. This is the locked-in price for this specific subscription at the time the event fired, + /// which is authoritative over the live catalog priceByPlan dictionary — an admin who archives + /// a plan's price and creates a new active one for the same plan would otherwise cause the catalog + /// lookup to return the new price while the subscription remains on the old locked-in price. + /// Stripe encodes unit_amount as a long in minor units (cents); the value is divided by 100 + /// to produce the major-unit decimal the rest of the domain uses. Returns null when the payload + /// doesn't carry a unit_amount (e.g., subscription_schedule events whose price lives elsewhere), in + /// which case the caller falls back to the catalog dictionary. + /// + private static decimal? ExtractUnitAmountFromPayload(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var stripeObject = data.TryGetProperty("object", out var rawObject) ? rawObject : default; + if (stripeObject.ValueKind != JsonValueKind.Object) return null; + if (!stripeObject.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Object) return null; + if (!items.TryGetProperty("data", out var itemsData) || itemsData.ValueKind != JsonValueKind.Array || itemsData.GetArrayLength() == 0) return null; + var firstItem = itemsData[0]; + if (firstItem.ValueKind != JsonValueKind.Object) return null; + if (!firstItem.TryGetProperty("price", out var price) || price.ValueKind != JsonValueKind.Object) return null; + if (!price.TryGetProperty("unit_amount", out var unitAmount) || unitAmount.ValueKind != JsonValueKind.Number) return null; + return unitAmount.GetInt64() / 100m; + } + + /// + /// Extracts $.data.object.cancellation_details.reason from a Stripe subscription event payload. + /// Stripe populates this on customer.subscription.deleted to convey *why* the subscription ended — + /// payment_failed for dunning-driven terminations, cancellation_requested for voluntary + /// cancels. Returns the lowercase string as Stripe emits it, or null when absent or non-string. + /// + private static string? ExtractCancellationReasonFromPayload(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var dataElement) ? dataElement : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var stripeObject = data.TryGetProperty("object", out var rawObject) ? rawObject : default; + if (stripeObject.ValueKind != JsonValueKind.Object) return null; + var cancellationDetails = stripeObject.TryGetProperty("cancellation_details", out var details) ? details : default; + if (cancellationDetails.ValueKind != JsonValueKind.Object) return null; + if (!cancellationDetails.TryGetProperty("reason", out var reason) || reason.ValueKind != JsonValueKind.String) return null; + return reason.GetString()?.ToLowerInvariant(); + } + + private static JsonElement ParsePayload(string? rawPayload) + { + if (string.IsNullOrWhiteSpace(rawPayload)) return default; + try + { + using var doc = JsonDocument.Parse(rawPayload); + return doc.RootElement.Clone(); + } + catch (JsonException) + { + return default; + } + } + + /// + /// Seeds a fresh for the next replay batch. Current-state-of-the-world + /// fields (Plan, PlanPrice, ScheduledPlan, CancelAtPeriodEnd) come from the live + /// aggregate — which was just reconciled from Stripe earlier in the + /// same sync — so the replayer's view of the active plan stays consistent with truth even when a + /// transition (e.g. subscription_schedule.released cancelling a pending downgrade) doesn't emit a + /// paired customer.subscription.updated to refresh history. The running total + /// () comes from so the + /// anchor doesn't reset to zero when events.list re-pulls a partial window. Returns a fresh state + /// with CommittedMrr=0 when is null. + /// + public static ReplayState SeedReplayStateFromHistory(BillingEvent? latestPersisted, Subscription subscription) + { + // When the most recently persisted row is a SubscriptionDowngradeScheduled, history shows a still- + // open schedule that the live aggregate may have already discarded. Stripe's + // subscription_schedule.released cancels the schedule without re-emitting + // customer.subscription.updated, so SyncStateFromStripe nulls ScheduledPlan on the live aggregate + // before this seed runs. Falling back to history here lets MapScheduleTerminated classify the + // cancellation as SubscriptionDowngradeCancelled instead of a NoOp audit row. + var scheduledPlan = latestPersisted?.EventType == BillingEventType.SubscriptionDowngradeScheduled + ? latestPersisted.ToPlan + : subscription.ScheduledPlan; + + return new ReplayState + { + Plan = subscription.Plan, + PlanPrice = subscription.CurrentPriceAmount ?? 0m, + ScheduledPlan = scheduledPlan, + CancelAtPeriodEnd = subscription.CancelAtPeriodEnd, + CommittedMrr = latestPersisted?.CommittedMrr ?? 0m + }; + } + + public sealed class ReplayState + { + public SubscriptionPlan? Plan { get; set; } + + public decimal PlanPrice { get; set; } + + public bool CancelAtPeriodEnd { get; set; } + + public SubscriptionPlan? ScheduledPlan { get; set; } + + public decimal CommittedMrr { get; set; } + + /// + /// Set to true when the replayer encounters a Stripe event whose payload combines multiple + /// state changes that don't decompose into a single domain transition. Callers translate this + /// into a Subscription.SetDriftStatus call so the existing drift banner picks it up. + /// + public bool HasUnclassifiedEvent { get; set; } + } +} diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index eb02f2068f..5edfedd97d 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -16,6 +16,9 @@ namespace Account.Features; /// This particular includes the naming of the telemetry events (which should be in past tense) and the properties that /// are collected with each telemetry event. Since missing or bad data cannot be fixed, it is important to have a good /// data quality from the start. +public sealed class BillingDriftSkippedDueToStripeUnavailable(SubscriptionId subscriptionId) + : TelemetryEvent(("subscription_id", subscriptionId)); + public sealed class BillingInfoAdded(SubscriptionId subscriptionId, string? country, string? postalCode, string? city) : TelemetryEvent(("subscription_id", subscriptionId), ("country", country as object ?? "unknown"), ("postal_code", postalCode as object ?? "unknown"), ("city", city as object ?? "unknown")); @@ -70,6 +73,9 @@ public sealed class Logout public sealed class PaymentFailed(SubscriptionId subscriptionId, SubscriptionPlan plan, decimal priceAmount, string currency) : TelemetryEvent(("subscription_id", subscriptionId), ("plan", plan), ("price_amount", priceAmount), ("currency", currency)); +public sealed class PaymentTransactionAmountExcludingTaxClamped(string paymentReference, decimal displayAmount, decimal taxAmount, string currency) + : TelemetryEvent(("payment_reference", paymentReference), ("display_amount", displayAmount), ("tax_amount", taxAmount), ("currency", currency)); + public sealed class PaymentMethodSetupStarted(SubscriptionId subscriptionId) : TelemetryEvent(("subscription_id", subscriptionId)); @@ -100,6 +106,15 @@ public sealed class SignupCompleted(TenantId tenantId, int signupTimeInSeconds) public sealed class SignupStarted : TelemetryEvent; +public sealed class StripeEventPayloadMismatch(string eventId, string eventType, string existingHash, string newHash) + : TelemetryEvent(("event_id", eventId), ("event_type", eventType), ("existing_hash", existingHash), ("new_hash", newHash)); + +public sealed class StripeSubscriptionCurrencyMismatchRejected(string stripeSubscriptionId, string observedCurrency, string platformCurrency) + : TelemetryEvent(("stripe_subscription_id", stripeSubscriptionId), ("observed_currency", observedCurrency), ("platform_currency", platformCurrency)); + +public sealed class StripePriceCatalogLookupMissed(SubscriptionId subscriptionId, SubscriptionPlan scheduledPlan) + : TelemetryEvent(("subscription_id", subscriptionId), ("scheduled_plan", scheduledPlan)); + public sealed class SubscriptionCancelled( SubscriptionId subscriptionId, SubscriptionPlan plan, @@ -199,6 +214,9 @@ string currency ) : TelemetryEvent(("subscription_id", subscriptionId), ("from_plan", fromPlan), ("to_plan", toPlan), ("days_on_current_plan", daysOnCurrentPlan), ("previous_price_amount", previousPriceAmount), ("new_price_amount", newPriceAmount), ("mrr_impact", mrrImpact), ("currency", currency)); +public sealed class TenantBillingDriftAcknowledged(SubscriptionId subscriptionId) + : TelemetryEvent(("subscription_id", subscriptionId)); + public sealed class TenantCreated(TenantId tenantId, TenantState state) : TelemetryEvent(("tenant_id", tenantId), ("tenant_state", state)); @@ -211,6 +229,12 @@ public sealed class TenantLogoRemoved public sealed class TenantLogoUpdated(string contentType, long size) : TelemetryEvent(("content_type", contentType), ("size", size)); +public sealed class TenantReconciledWithStripe(SubscriptionId subscriptionId, int billingEventsAppended) + : TelemetryEvent(("subscription_id", subscriptionId), ("billing_events_appended", billingEventsAppended)); + +public sealed class TenantStripeArchiveReplayed(int count) + : TelemetryEvent(("count", count)); + public sealed class TenantSwitched(TenantId fromTenantId, TenantId toTenantId, UserId userId) : TelemetryEvent(("from_tenant_id", fromTenantId), ("to_tenant_id", toTenantId), ("user_id", userId)); @@ -261,3 +285,6 @@ public sealed class UserZoomLevelChanged(string fromZoomLevel, string toZoomLeve public sealed class UsersBulkDeleted(int count) : TelemetryEvent(("count", count)); + +public sealed class WebhookDeliveryRecovered(string eventId, string eventType, string recoverySource) + : TelemetryEvent(("event_id", eventId), ("event_type", eventType), ("recovery_source", recoverySource)); diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs new file mode 100644 index 0000000000..b47c1f78bb --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs @@ -0,0 +1,36 @@ +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record AcknowledgeBillingDriftCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +public sealed class AcknowledgeBillingDriftHandler( + ISubscriptionRepository subscriptionRepository, + TimeProvider timeProvider, + ITelemetryEventsCollector events +) : IRequestHandler +{ + public async Task Handle(AcknowledgeBillingDriftCommand command, CancellationToken cancellationToken) + { + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(command.TenantId, cancellationToken); + if (subscription is null) return Result.NotFound($"Subscription for tenant '{command.TenantId}' not found."); + + if (!subscription.HasDriftDetected) return Result.BadRequest("Subscription has no drift to acknowledge."); + + subscription.AcknowledgeDrift(timeProvider.GetUtcNow()); + subscriptionRepository.Update(subscription); + + events.CollectEvent(new TenantBillingDriftAcknowledged(subscription.Id)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/ReconcileTenantWithStripe.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/ReconcileTenantWithStripe.cs new file mode 100644 index 0000000000..dbe6c81043 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/ReconcileTenantWithStripe.cs @@ -0,0 +1,137 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Features.Tenants.Domain; +using Account.Integrations.Stripe; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record ReconcileTenantWithStripeCommand : ICommand, IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +[PublicAPI] +public sealed record ReconcileTenantWithStripeResponse( + int BillingEventsAppended, + bool HasDriftDetected, + int DriftDiscrepancyCount, + DateTimeOffset ReconciledAt, + ArchivedEventsAwaitingConfirmation? ArchivedEventsAwaitingConfirmation +); + +/// +/// Set on when the local stripe_events archive contains +/// events older than Stripe's 30-day events.list retention window that have no matching billing_events +/// row yet. The reconcile flow never auto-replays archive data — surfacing this block tells the +/// back-office admin to confirm before ReplayArchivedTenantStripeEventsCommand projects the +/// cold-backup payloads into the BillingEvent ledger. +/// +[PublicAPI] +public sealed record ArchivedEventsAwaitingConfirmation(int Count, DateTimeOffset OldestOccurredAt, DateTimeOffset NewestOccurredAt); + +/// +/// Reconcile is the admin recovery path for a tenant's BillingEvent ledger. It runs the same +/// events.list-driven sync as the webhook hot path, then additionally falls back to the local +/// stripe_events.payload cold backup for any event older than Stripe's 30-day events.list +/// retention window. The hot path never reads stripe_events.payload; reconcile is the +/// only code path that does, and it is expected to be rare. +/// +public sealed class ReconcileTenantWithStripeHandler( + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + IBillingEventRepository billingEventRepository, + IStripeEventRepository stripeEventRepository, + ProcessPendingStripeEvents processPendingStripeEvents, + StripeClientFactory stripeClientFactory, + IPlatformCurrencyProvider platformCurrencyProvider, + TimeProvider timeProvider, + ITelemetryEventsCollector events +) : IRequestHandler> +{ + /// + /// Stripe retains events for 30 days via its events.list API (see https://docs.stripe.com/api/events). + /// Anything older must be replayed from the local stripe_events archive — but only after the operator + /// confirms, because the cold backup carries the payload Stripe served at ingestion time and replay + /// may write approximate data when the catalog has rolled forward since. + /// + private static readonly TimeSpan StripeEventsListRetentionWindow = TimeSpan.FromDays(30); + + public async Task> Handle(ReconcileTenantWithStripeCommand command, CancellationToken cancellationToken) + { + if (stripeClientFactory.GetClient() is UnconfiguredStripeClient) + { + return Result.BadRequest("Stripe is not configured in this environment, reconcile is unavailable."); + } + + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); + if (tenant is null) return Result.NotFound($"Tenant with id '{command.TenantId}' not found."); + + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(command.TenantId, cancellationToken); + if (subscription is null) return Result.NotFound($"Subscription for tenant '{command.TenantId}' not found."); + + if (subscription.StripeCustomerId is null) + { + return Result.BadRequest("Tenant has no Stripe customer to reconcile with."); + } + + // Single-currency invariant: the dashboard MRR handlers sum across all subscriptions / billing events + // without grouping by currency, so any row that does not use the platform currency corrupts the totals. + // Reject mismatched currencies at the boundary before reconcile mutates persistence. The DB CHECK + // constraint enforces the format invariant as a structural backstop. + var stripeState = await stripeClientFactory.GetClient().SyncSubscriptionStateAsync(subscription.StripeCustomerId, cancellationToken); + var platformCurrency = platformCurrencyProvider.Currency; + if (stripeState?.CurrentPriceCurrency is { } observedCurrency && platformCurrency is not null && observedCurrency != platformCurrency) + { + events.CollectEvent(new StripeSubscriptionCurrencyMismatchRejected(subscription.StripeSubscriptionId?.Value ?? subscription.Id.Value, observedCurrency, platformCurrency)); + return Result.BadRequest($"Subscription currency '{observedCurrency}' does not match the platform currency '{platformCurrency}'.", true); + } + + var beforeEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + + await processPendingStripeEvents.ExecuteAsync(subscription.StripeCustomerId, true, SyncMode.Apply, cancellationToken); + + var afterEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + var billingEventsAppended = afterEvents.Length - beforeEvents.Length; + + // Reload the subscription so drift fields reflect the just-completed reconcile. ExecuteAsync runs in its own + // transaction and the previously-fetched aggregate is detached, so we read the freshly persisted state. + var refreshedSubscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(command.TenantId, cancellationToken); + var hasDriftDetected = refreshedSubscription?.HasDriftDetected ?? false; + var driftDiscrepancyCount = refreshedSubscription?.DriftDiscrepancies.Length ?? 0; + + // Bootstrap and stale-anchor recovery: the events.list anchor only covers the last 30 days, so + // archive payloads older than that window can only land in billing_events via explicit replay. + // Surface the count and date range here; replay is gated behind the admin confirmation dialog and + // ReplayArchivedTenantStripeEventsCommand — never auto-applied from this handler. + var now = timeProvider.GetUtcNow(); + var archiveCutoff = now - StripeEventsListRetentionWindow; + var archivedAwaitingReplay = await stripeEventRepository.GetArchivedEventsOlderThanAsync(subscription.StripeCustomerId, archiveCutoff, cancellationToken); + // GetArchivedEventsOlderThanAsync filters on `StripeCreatedAt < cutoff`, which excludes NULLs by + // SQL/LINQ semantics, so every row returned is guaranteed to have a non-null StripeCreatedAt. + var archivedEventsAwaitingConfirmation = archivedAwaitingReplay.Length > 0 + ? new ArchivedEventsAwaitingConfirmation( + archivedAwaitingReplay.Length, + archivedAwaitingReplay.Min(e => e.StripeCreatedAt!.Value), + archivedAwaitingReplay.Max(e => e.StripeCreatedAt!.Value) + ) + : null; + + var response = new ReconcileTenantWithStripeResponse( + billingEventsAppended, + hasDriftDetected, + driftDiscrepancyCount, + now, + archivedEventsAwaitingConfirmation + ); + + events.CollectEvent(new TenantReconciledWithStripe(subscription.Id, billingEventsAppended)); + + return response; + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/ReplayArchivedTenantStripeEvents.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/ReplayArchivedTenantStripeEvents.cs new file mode 100644 index 0000000000..5066547607 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/ReplayArchivedTenantStripeEvents.cs @@ -0,0 +1,129 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Features.Tenants.Domain; +using Account.Integrations.Stripe; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record ReplayArchivedTenantStripeEventsCommand : ICommand, IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +[PublicAPI] +public sealed record ReplayArchivedTenantStripeEventsResponse(int BillingEventsAppended, DateTimeOffset ReplayedAt); + +/// +/// Replays archived stripe_events rows that Stripe's events.list no longer returns for the customer +/// into the BillingEvent ledger. This is the cold-backup recovery path explicitly opted into by an +/// admin after surfaced +/// . The handler is the only writer that reads +/// stripe_events.payload from outside the same-transaction hot path — every other code path +/// drives BillingEvent emission from Stripe's events.list view of the world. +/// The boundary between "Stripe still has it" and "archive is the only source" comes from asking +/// Stripe directly: the handler calls events.list, collects the returned event ids, and replays +/// every archived row whose id is NOT in that set. ID comparison is exact and avoids the +/// off-by-one fragility of any date-based cutoff (Stripe documents "approximately 30 days" and +/// same-second sibling events would split or duplicate under a date boundary). +/// +public sealed class ReplayArchivedTenantStripeEventsHandler( + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + IBillingEventRepository billingEventRepository, + IStripeEventRepository stripeEventRepository, + StripeClientFactory stripeClientFactory, + TimeProvider timeProvider, + ITelemetryEventsCollector events, + ILogger logger +) : IRequestHandler> +{ + public async Task> Handle(ReplayArchivedTenantStripeEventsCommand command, CancellationToken cancellationToken) + { + if (stripeClientFactory.GetClient() is UnconfiguredStripeClient) + { + return Result.BadRequest("Stripe is not configured in this environment, archive replay is unavailable."); + } + + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); + if (tenant is null) return Result.NotFound($"Tenant with id '{command.TenantId}' not found."); + + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(command.TenantId, cancellationToken); + if (subscription is null) return Result.NotFound($"Subscription for tenant '{command.TenantId}' not found."); + + if (subscription.StripeCustomerId is null) + { + return Result.BadRequest("Tenant has no Stripe customer to replay archived events for."); + } + + // Ask Stripe what events it still serves for this customer; anything in the archive whose id is NOT + // in that set is by definition outside the events.list window and is a candidate for replay. When + // Stripe returns empty (customer dormant long enough that everything aged out), every archived row + // qualifies — the archive is then our only source. Using ids avoids the same-second-sibling-event + // ambiguity that any date cutoff has. + var stripeClient = stripeClientFactory.GetClient(); + var stripeEventsList = await stripeClient.GetEventsForCustomerAsync(subscription.StripeCustomerId, null, cancellationToken); + var knownStripeEventIds = stripeEventsList.Events.Select(e => e.EventId).ToHashSet(); + var archivedEvents = await stripeEventRepository.GetArchivedEventsExcludingAsync(subscription.StripeCustomerId, knownStripeEventIds, cancellationToken); + if (archivedEvents.Length == 0) + { + return Result.BadRequest("No archived events outside Stripe's current events.list window are awaiting replay."); + } + + var now = timeProvider.GetUtcNow(); + var planByPriceId = await stripeClient.GetPlanByPriceIdAsync(cancellationToken); + var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); + var priceByPlan = priceCatalog.ToDictionary(p => p.Plan, p => p.UnitAmount); + + // Seed the running state from the latest persisted BillingEvent so the archive batch carries forward + // history rather than restarting at zero — without this seed the first SubscriptionCancelled in the + // archive would emit committedMrr=0 and silently rewrite MRR. The replayer otherwise has no view of + // the pre-archive state because the events feeding it predate every billing_events row. + var persistedRows = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + var latestPersistedBillingEvent = persistedRows + .OrderByDescending(r => r.OccurredAt) + .ThenByDescending(r => r.Id.Value) + .FirstOrDefault(); + var state = StripeEventReplayer.SeedReplayStateFromHistory(latestPersistedBillingEvent, subscription); + + // GetArchivedEventsExcludingAsync excludes rows with NULL StripeCreatedAt, so every row returned is + // guaranteed to have a non-null timestamp. A null ApiVersion (Stripe omitted the field on a legacy + // row) is passed as empty so the unsupported-version code path surfaces it as drift instead of + // matching a real resolver. + var replayInputs = archivedEvents + .Select(e => new StripeReplayEvent(e.Id.Value, e.EventType, e.StripeCreatedAt!.Value, e.Payload ?? "", e.ApiVersion ?? "")) + .ToArray(); + + var existingStripeEventIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + var currencyOverride = subscription.CurrentPriceCurrency; + var replayedEvents = StripeEventReplayer.Replay(subscription, replayInputs, planByPriceId, priceByPlan, state, currencyOverride, logger); + + var appendedCount = 0; + foreach (var billingEvent in replayedEvents) + { + if (existingStripeEventIds.Contains(billingEvent.StripeEventId)) continue; + await billingEventRepository.AddAsync(billingEvent, cancellationToken); + + if (billingEvent.EventType == BillingEventType.SubscriptionCreated) + { + subscription.AdvanceSubscribedSinceBackwardFromBillingEvent(billingEvent.OccurredAt); + } + + appendedCount++; + } + + if (appendedCount > 0) + { + subscriptionRepository.Update(subscription); + } + + events.CollectEvent(new TenantStripeArchiveReplayed(appendedCount)); + + return new ReplayArchivedTenantStripeEventsResponse(appendedCount, now); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantActivity.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantActivity.cs new file mode 100644 index 0000000000..a306b10782 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantActivity.cs @@ -0,0 +1,33 @@ +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantActivityQuery(TenantId Id) : IRequest>; + +[PublicAPI] +public sealed record TenantActivityResponse(TenantActivityEvent[] Events); + +[PublicAPI] +public sealed record TenantActivityEvent(DateTimeOffset Timestamp, string Name, string? Description); + +public sealed class GetTenantActivityHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantActivityQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + // Activity is sourced from Application Insights telemetry scoped by tenant id. The Application Insights + // wiring is delivered separately; until then this endpoint returns an empty list so the front-end can + // render the activity tab without a hard dependency on the telemetry pipeline. + return new TenantActivityResponse([]); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs new file mode 100644 index 0000000000..e13944403c --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -0,0 +1,115 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using Account.Integrations.Stripe; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantDetailQuery(TenantId Id) : IRequest>; + +[PublicAPI] +public sealed record TenantDetailResponse( + TenantId Id, + string Name, + SubscriptionPlan Plan, + SubscriptionPlan? ScheduledPlan, + decimal? ScheduledPriceAmount, + bool CancelAtPeriodEnd, + decimal? MonthlyRecurringRevenue, + string? Currency, + DateTimeOffset? RenewalDate, + DateTimeOffset? SubscribedSince, + bool HasEverSubscribed, + string? BillingName, + string? TaxId, + BillingAddressResponse? BillingAddress, + PaymentMethodResponse? PaymentMethod, + decimal? LifetimeValue, + TenantState State, + SuspensionReason? SuspensionReason, + DateTimeOffset? SuspendedAt, + string? LogoUrl, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + bool HasDriftDetected, + DateTimeOffset? DriftCheckedAt, + DriftDiscrepancy[] DriftDiscrepancies, + string? StripeCustomerUrl +); + +[PublicAPI] +public sealed record BillingAddressResponse( + string? Line1, + string? Line2, + string? PostalCode, + string? City, + string? State, + string? Country +); + +[PublicAPI] +public sealed record PaymentMethodResponse(string Brand, string Last4, int ExpMonth, int ExpYear); + +public sealed class GetTenantDetailHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, StripeClientFactory stripeClientFactory) + : IRequestHandler> +{ + public async Task> Handle(GetTenantDetailQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(tenant.Id, cancellationToken); + + // Net lifetime value: only count successful payments that were NOT later credit-noted or refunded. + // Money returned to the customer (via credit note or refund) is not revenue, so it doesn't contribute. + var lifetimeValue = subscription?.PaymentTransactions + .Where(t => t is { Status: PaymentTransactionStatus.Succeeded, CreditNoteUrl: null, RefundedAt: null }) + .Sum(t => t.AmountExcludingTax); + + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(t => t.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) == true; + + var billingAddress = subscription?.BillingInfo?.Address is { } address + ? new BillingAddressResponse(address.Line1, address.Line2, address.PostalCode, address.City, address.State, address.Country) + : null; + + var paymentMethod = subscription?.PaymentMethod is { } currentPaymentMethod + ? new PaymentMethodResponse(currentPaymentMethod.Brand, currentPaymentMethod.Last4, currentPaymentMethod.ExpMonth, currentPaymentMethod.ExpYear) + : null; + + return new TenantDetailResponse( + tenant.Id, + tenant.Name, + tenant.Plan, + subscription?.ScheduledPlan, + subscription?.ScheduledPriceAmount, + subscription?.CancelAtPeriodEnd ?? false, + subscription?.CurrentPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + subscription?.SubscribedSince, + hasEverSubscribed, + subscription?.BillingInfo?.Name, + subscription?.BillingInfo?.TaxId, + billingAddress, + paymentMethod, + lifetimeValue, + tenant.State, + tenant.SuspensionReason, + tenant.SuspendedAt, + tenant.Logo.Url, + tenant.CreatedAt, + tenant.ModifiedAt, + subscription?.HasDriftDetected ?? false, + subscription?.DriftCheckedAt, + subscription?.DriftDiscrepancies.ToArray() ?? [], + subscription?.StripeCustomerId is { } stripeCustomerId ? stripeClientFactory.GetClient().BuildCustomerDashboardUrl(stripeCustomerId) : null + ); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs new file mode 100644 index 0000000000..4e048e9df7 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs @@ -0,0 +1,116 @@ +using Account.Features.BackOffice.Invoices.Queries; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantPaymentHistoryQuery(int PageOffset = 0, int PageSize = 25) : IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record TenantPaymentHistoryResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantPaymentTransaction[] Transactions); + +[PublicAPI] +public sealed record TenantPaymentTransaction( + PaymentTransactionId Id, + BackOfficeInvoiceRowKind RowKind, + decimal Amount, + decimal AmountExcludingTax, + decimal TaxAmount, + string Currency, + PaymentTransactionStatus Status, + DateTimeOffset Date, + DateTimeOffset? RefundedAt, + string? FailureReason, + string? InvoiceUrl, + string? CreditNoteUrl, + DateTimeOffset? CreditNotedAt, + SubscriptionPlan? Plan +); + +public sealed class GetTenantPaymentHistoryQueryValidator : AbstractValidator +{ + public GetTenantPaymentHistoryQueryValidator() + { + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantPaymentHistoryHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantPaymentHistoryQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(tenant.Id, cancellationToken); + var rows = (subscription?.PaymentTransactions ?? []) + .SelectMany(ProjectRows) + .OrderByDescending(r => r.Date) + .ToArray(); + + var totalCount = rows.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = rows.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new TenantPaymentHistoryResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } + + private static IEnumerable ProjectRows(PaymentTransaction transaction) + { + // Invoice row: always emitted. Status reflects the ORIGINAL payment outcome — a later refund + // or credit note doesn't flip the invoice row to "Refunded"; it gets its own row instead. + var invoiceRowStatus = transaction.Status == PaymentTransactionStatus.Refunded + ? PaymentTransactionStatus.Succeeded + : transaction.Status; + + yield return new TenantPaymentTransaction( + transaction.Id, BackOfficeInvoiceRowKind.Invoice, transaction.Amount, transaction.AmountExcludingTax, + transaction.TaxAmount, transaction.Currency, invoiceRowStatus, transaction.Date, transaction.RefundedAt, + transaction.FailureReason, transaction.InvoiceUrl, transaction.CreditNoteUrl, transaction.CreditNotedAt, transaction.Plan + ); + + if (transaction.CreditNoteUrl is not null) + { + // CreditNote row: emitted whenever a Stripe credit note exists. Date falls through + // CreditNotedAt → RefundedAt → original Date so legacy rows whose timestamps were never + // backfilled still surface as their own reversal row. + yield return new TenantPaymentTransaction( + transaction.Id, BackOfficeInvoiceRowKind.CreditNote, transaction.Amount, transaction.AmountExcludingTax, + transaction.TaxAmount, transaction.Currency, PaymentTransactionStatus.Refunded, + transaction.CreditNotedAt ?? transaction.RefundedAt ?? transaction.Date, transaction.RefundedAt, + transaction.FailureReason, transaction.InvoiceUrl, transaction.CreditNoteUrl, transaction.CreditNotedAt, transaction.Plan + ); + } + else if (transaction.Status == PaymentTransactionStatus.Refunded || transaction.RefundedAt is not null) + { + // Refund row (edge case): Stripe pro-rated refunds don't always create a credit note — + // when one happens the refund is the standalone reversal. Skip when a CreditNote sibling + // already exists (the credit note encompasses the refund). + yield return new TenantPaymentTransaction( + transaction.Id, BackOfficeInvoiceRowKind.Refund, transaction.Amount, transaction.AmountExcludingTax, + transaction.TaxAmount, transaction.Currency, PaymentTransactionStatus.Refunded, + transaction.RefundedAt ?? transaction.Date, transaction.RefundedAt, + transaction.FailureReason, transaction.InvoiceUrl, transaction.CreditNoteUrl, transaction.CreditNotedAt, transaction.Plan + ); + } + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs new file mode 100644 index 0000000000..b910eae3dc --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs @@ -0,0 +1,32 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantUserCountsQuery(TenantId Id) : IRequest>; + +[PublicAPI] +public sealed record TenantUserCountsResponse(int TotalUsers, int ActiveUsers, int PendingUsers); + +public sealed class GetTenantUserCountsHandler(ITenantRepository tenantRepository, IUserRepository userRepository, TimeProvider timeProvider) + : IRequestHandler> +{ + private const int ActiveWindowDays = 30; + + public async Task> Handle(GetTenantUserCountsQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var activeSince = timeProvider.GetUtcNow().AddDays(-ActiveWindowDays); + var (totalUsers, activeUsers, pendingUsers) = await userRepository.GetUserCountsForTenantUnfilteredAsync(tenant.Id, activeSince, cancellationToken); + return new TenantUserCountsResponse(totalUsers, activeUsers, pendingUsers); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs new file mode 100644 index 0000000000..ef638d553f --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs @@ -0,0 +1,94 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantUsersQuery( + string? Search = null, + UserRole[]? Roles = null, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId Id { get; init; } = null!; + + public string? Search { get; } = Search?.Trim().ToLower(); + + public UserRole[] Roles { get; } = Roles ?? []; +} + +[PublicAPI] +public sealed record TenantUsersResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantUserSummary[] Users); + +[PublicAPI] +public sealed record TenantUserSummary( + UserId Id, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl +); + +public sealed class GetTenantUsersQueryValidator : AbstractValidator +{ + public GetTenantUsersQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantUsersHandler(ITenantRepository tenantRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantUsersQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var (users, totalCount, totalPages) = await userRepository.SearchTenantUsersUnfilteredAsync( + tenant.Id, + query.Search, + query.Roles, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var summaries = users.Select(u => new TenantUserSummary( + u.Id, + u.Email, + u.FirstName, + u.LastName, + u.Title, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt, + u.Avatar.Url + ) + ).ToArray(); + + return new TenantUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs new file mode 100644 index 0000000000..818d0643c7 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -0,0 +1,242 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantsQuery( + string? Search = null, + SubscriptionPlan[]? Plans = null, + TenantStatusFilter[]? Statuses = null, + bool Unsynced = false, + bool DriftDetected = false, + SortableTenantProperties OrderBy = SortableTenantProperties.ModifiedAt, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public SubscriptionPlan[] Plans { get; } = Plans ?? []; + + public TenantStatusFilter[] Statuses { get; } = Statuses ?? []; +} + +[PublicAPI] +public sealed record TenantsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantSummary[] Tenants); + +[PublicAPI] +public sealed record TenantSummary( + TenantId Id, + string Name, + string? LogoUrl, + SubscriptionPlan Plan, + decimal? MonthlyRecurringRevenue, + decimal? ScheduledPriceAmount, + string? Currency, + DateTimeOffset? RenewalDate, + PlannedSubscriptionChange? PlannedChange, + bool HasEverSubscribed, + string? Country, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + TenantOwnerSummary? Owner +); + +[PublicAPI] +public sealed record TenantOwnerSummary(UserId UserId, string? FirstName, string? LastName, string Email); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PlannedSubscriptionChange +{ + Cancellation, + ScheduledPlanChange +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TenantStatusFilter +{ + Active, + Downgrading, + Canceling, + Canceled, + Free +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableTenantProperties +{ + Name, + Plan, + MonthlyRecurringRevenue, + RenewalDate, + Status, + Country, + CreatedAt, + ModifiedAt +} + +public sealed class GetTenantsQueryValidator : AbstractValidator +{ + public GetTenantsQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.Plans.Length).LessThanOrEqualTo(10).WithMessage("Plans filter must contain no more than 10 values."); + RuleFor(x => x.Statuses.Length).LessThanOrEqualTo(10).WithMessage("Statuses filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantsHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, IBillingEventRepository billingEventRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.SearchAllTenantsAsync(query.Search, query.Plans, cancellationToken); + + var tenantIds = tenants.Select(t => t.Id).ToArray(); + var subscriptions = tenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + // Tenant-issue filters from the back-office banners. DriftDetected is a per-subscription flag set + // by the writer when the replayer hits an Unclassified event; Unsynced means a paid subscription + // has no BillingEvent rows yet (the dashboard MRR trend silently under-counts these). + if (query.DriftDetected) + { + tenants = tenants.Where(t => subscriptionsByTenantId.GetValueOrDefault(t.Id)?.HasDriftDetected == true).ToArray(); + } + + if (query.Unsynced) + { + var subscriptionIdsWithEvents = subscriptions.Length == 0 + ? new HashSet() + : await billingEventRepository.GetSubscriptionIdsWithEventsUnfilteredAsync([.. subscriptions.Select(s => s.Id)], cancellationToken); + tenants = tenants.Where(t => + { + var subscription = subscriptionsByTenantId.GetValueOrDefault(t.Id); + return subscription is { CurrentPriceAmount: not null } && !subscriptionIdsWithEvents.Contains(subscription.Id); + } + ).ToArray(); + } + + var ownerByTenantId = tenants.Length == 0 + ? new Dictionary() + : await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenants.Select(t => t.Id).ToArray(), cancellationToken); + + var summaries = tenants.Select(tenant => MapTenantSummary( + tenant, + subscriptionsByTenantId.GetValueOrDefault(tenant.Id), + ownerByTenantId.GetValueOrDefault(tenant.Id) + ) + ).ToArray(); + + if (query.Statuses.Length > 0) + { + summaries = summaries.Where(s => query.Statuses.Contains(GetStatus(s))).ToArray(); + } + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (SortableTenantProperties.Plan, SortOrder.Ascending) => summaries.OrderBy(s => s.Plan).ThenBy(s => s.Name), + (SortableTenantProperties.Plan, _) => summaries.OrderByDescending(s => s.Plan).ThenBy(s => s.Name), + (SortableTenantProperties.MonthlyRecurringRevenue, SortOrder.Ascending) => summaries.OrderBy(s => s.MonthlyRecurringRevenue ?? 0).ThenBy(s => s.Name), + (SortableTenantProperties.MonthlyRecurringRevenue, _) => summaries.OrderByDescending(s => s.MonthlyRecurringRevenue ?? 0).ThenBy(s => s.Name), + (SortableTenantProperties.RenewalDate, SortOrder.Ascending) => summaries.OrderBy(s => s.RenewalDate ?? DateTimeOffset.MaxValue).ThenBy(s => s.Name), + (SortableTenantProperties.RenewalDate, _) => summaries.OrderByDescending(s => s.RenewalDate ?? DateTimeOffset.MinValue).ThenBy(s => s.Name), + (SortableTenantProperties.Status, SortOrder.Ascending) => summaries.OrderBy(StatusSortKey).ThenBy(s => s.Name), + (SortableTenantProperties.Status, _) => summaries.OrderByDescending(StatusSortKey).ThenBy(s => s.Name), + (SortableTenantProperties.Country, SortOrder.Ascending) => summaries.OrderBy(s => s.Country ?? string.Empty).ThenBy(s => s.Name), + (SortableTenantProperties.Country, _) => summaries.OrderByDescending(s => s.Country ?? string.Empty).ThenBy(s => s.Name), + (SortableTenantProperties.CreatedAt, SortOrder.Ascending) => summaries.OrderBy(s => s.CreatedAt), + (SortableTenantProperties.CreatedAt, _) => summaries.OrderByDescending(s => s.CreatedAt), + // ModifiedAt is null until the tenant is touched; treat that as "latest activity = creation" + // so the default view shows recently-changed and brand-new tenants together at the top. + (SortableTenantProperties.ModifiedAt, SortOrder.Ascending) => summaries.OrderBy(s => s.ModifiedAt ?? s.CreatedAt).ThenBy(s => s.Name), + (SortableTenantProperties.ModifiedAt, _) => summaries.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ThenBy(s => s.Name), + (SortableTenantProperties.Name, SortOrder.Descending) => summaries.OrderByDescending(s => s.Name), + _ => summaries.OrderBy(s => s.Name) + }; + + var totalCount = summaries.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new TenantsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } + + private static int StatusSortKey(TenantSummary summary) + { + return GetStatus(summary) switch + { + TenantStatusFilter.Active => 0, + TenantStatusFilter.Downgrading => 1, + TenantStatusFilter.Canceling => 2, + TenantStatusFilter.Canceled => 3, + TenantStatusFilter.Free => 4, + _ => 5 + }; + } + + private static TenantStatusFilter GetStatus(TenantSummary summary) + { + return summary switch + { + { PlannedChange: PlannedSubscriptionChange.Cancellation } => TenantStatusFilter.Canceling, + { PlannedChange: PlannedSubscriptionChange.ScheduledPlanChange } => TenantStatusFilter.Downgrading, + { Plan: not SubscriptionPlan.Basis } => TenantStatusFilter.Active, + { HasEverSubscribed: true } => TenantStatusFilter.Canceled, + _ => TenantStatusFilter.Free + }; + } + + private static TenantSummary MapTenantSummary(Tenant tenant, Subscription? subscription, User? owner) + { + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + + // Refunded counts as "ever subscribed" — money flowed in before being credited back, so the + // tenant did pay at some point. Distinguishes a refunded customer (Canceled) from never having + // paid at all (Free). + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status is PaymentTransactionStatus.Succeeded or PaymentTransactionStatus.Refunded) == true; + + return new TenantSummary( + tenant.Id, + tenant.Name, + tenant.Logo.Url, + tenant.Plan, + subscription?.CurrentPriceAmount, + subscription?.ScheduledPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + plannedChange, + hasEverSubscribed, + subscription?.BillingInfo?.Address?.Country, + tenant.CreatedAt, + tenant.ModifiedAt, + owner is null ? null : new TenantOwnerSummary(owner.Id, owner.FirstName, owner.LastName, owner.Email) + ); + } +} diff --git a/application/account/Core/Features/Tenants/Commands/UpdateTenantLogo.cs b/application/account/Core/Features/Tenants/Commands/UpdateTenantLogo.cs index 48344f7119..0a6e7a3b94 100644 --- a/application/account/Core/Features/Tenants/Commands/UpdateTenantLogo.cs +++ b/application/account/Core/Features/Tenants/Commands/UpdateTenantLogo.cs @@ -20,8 +20,8 @@ public sealed class UpdateTenantLogoValidator : AbstractValidator x.ContentType) - .Must(x => x is "image/jpeg" or "image/png" or "image/gif" or "image/webp" or "image/svg+xml") - .WithMessage(_ => "Image must be of type JPEG, PNG, GIF, WebP, or SVG."); + .Must(x => x is "image/jpeg" or "image/png" or "image/gif" or "image/webp") + .WithMessage(_ => "Image must be of type JPEG, PNG, GIF, or WebP."); RuleFor(x => x.FileStream.Length) .LessThanOrEqualTo(2 * 1024 * 1024) @@ -91,7 +91,6 @@ private static string GetFileExtension(string contentType) "image/png" => "png", "image/gif" => "gif", "image/webp" => "webp", - "image/svg+xml" => "svg", _ => throw new InvalidOperationException($"Unsupported content type: {contentType}") }; } diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 402cd52e51..2aa097c5b5 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -1,6 +1,8 @@ using Account.Database; +using Account.Features.Subscriptions.Domain; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.ExecutionContext; using SharedKernel.Persistence; @@ -19,9 +21,43 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// This method should only be used in webhook processing where tenant context is not established. /// Task GetByIdUnfilteredAsync(TenantId id, CancellationToken cancellationToken); + + Task SearchAllTenantsAsync(string? search, SubscriptionPlan[] plans, CancellationToken cancellationToken); + + /// + /// Looks up tenant names for a set of tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries that need to attach the tenant name + /// to a list of records (users, sessions, ...) where tenant context is not established. + /// + Task> GetNamesByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); + + /// + /// Loads tenants by id without applying tenant query filters. + /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where + /// tenant context is not established. + /// + Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); + + /// + /// Returns every tenant created at or after without applying tenant query filters. + /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. + /// + Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every tenant without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// + Task GetAllUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Returns the most recently created tenants without applying tenant query filters. + /// Used by the back-office dashboard "Recent signups" list. + /// + Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); } -internal sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) +public sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) : SoftDeletableRepositoryBase(accountDbContext), ITenantRepository { public async Task GetCurrentTenantAsync(CancellationToken cancellationToken) @@ -41,6 +77,86 @@ public async Task GetByIdsAsync(TenantId[] ids, CancellationToken canc /// public async Task GetByIdUnfilteredAsync(TenantId id, CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(t => t.Id == id, cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task SearchAllTenantsAsync(string? search, SubscriptionPlan[] plans, CancellationToken cancellationToken) + { + IQueryable tenants = DbSet; + + if (!string.IsNullOrWhiteSpace(search)) + { + // TenantId is a long, so an exact match on a parsable id is the only way to filter by id at the DB level. + // Partial id matches are not supported - operators search by tenant name for fuzzy matches. + var idMatch = long.TryParse(search, out var parsedId) ? new TenantId(parsedId) : null; + tenants = tenants.Where(t => t.Name.ToLower().Contains(search) || (idMatch != null && t.Id == idMatch)); + } + + if (plans.Length > 0) + { + tenants = tenants.Where(t => plans.AsEnumerable().Contains(t.Plan)); + } + + return await tenants.ToArrayAsync(cancellationToken); + } + + /// + /// Looks up tenant names for a set of tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries that need to attach the tenant name + /// to a list of records (users, sessions, ...) where tenant context is not established. + /// + public async Task> GetNamesByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) + { + if (ids.Length == 0) return new Dictionary(); + + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(t => ids.AsEnumerable().Contains(t.Id)) + .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); + } + + /// + /// Loads tenants by id without applying tenant query filters. + /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where + /// tenant context is not established. + /// + public async Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) + { + if (ids.Length == 0) return []; + + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); + } + + /// + /// Returns every tenant created at or after without applying tenant query filters. + /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. + /// SQLite cannot translate DateTimeOffset comparisons in WHERE, so the time filter runs in memory; the + /// dashboard period is bounded (max 90 days) so the materialized set stays small. + /// + public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var tenants = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return tenants.Where(t => t.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every tenant without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// + public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + } + + /// + /// Returns the most recently created tenants without applying tenant query filters. + /// Used by the back-office dashboard "Recent signups" list. + /// SQLite cannot translate DateTimeOffset comparisons, so the order-by runs in memory; the limit keeps the + /// materialized set small. + /// + public async Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken) + { + var tenants = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return tenants.OrderByDescending(t => t.CreatedAt).Take(limit).ToArray(); } } diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs new file mode 100644 index 0000000000..5e5e079a54 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs @@ -0,0 +1,132 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserDetailQuery(UserId Id) : IRequest>; + +[PublicAPI] +public sealed record BackOfficeUserDetailResponse( + UserId Id, + TenantId TenantId, + string TenantName, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + string Locale, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl, + BackOfficeUserTenantMembership[] TenantMemberships +); + +// A "tenant membership" is another user record sharing the same email in a different tenant. Each row in the back-office +// User detail Tenants section corresponds to a single user-record-per-tenant; we expose its UserId so the frontend can +// link the row to that other user's detail page when needed. We also surface the tenant logo, plan, currency, MRR and +// country to render a rich tenant card without requiring a per-membership tenant detail fetch from the SPA. +[PublicAPI] +public sealed record BackOfficeUserTenantMembership( + UserId UserId, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + SubscriptionPlan Plan, + PlannedSubscriptionChange? PlannedChange, + bool HasEverSubscribed, + decimal? MonthlyRecurringRevenue, + decimal? ScheduledPriceAmount, + string? Currency, + DateTimeOffset? RenewalDate, + string? Country, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt +); + +public sealed class GetBackOfficeUserDetailHandler( + IUserRepository userRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUserDetailQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The "Tenants" section on the User detail page lists every tenant this person belongs to. Each tenant has its + // own user record (same email, different TenantId), so we look them up unfiltered by email. The lookup always + // includes the queried user record itself, so its tenant is naturally part of the result. + var membershipUsers = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken); + var tenantIds = membershipUsers.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + var subscriptions = await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + var memberships = membershipUsers.Select(u => + { + var tenant = tenantsById.GetValueOrDefault(u.TenantId); + var subscription = subscriptionsByTenantId.GetValueOrDefault(u.TenantId); + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == true; + return new BackOfficeUserTenantMembership( + u.Id, + u.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Logo.Url, + tenant?.Plan ?? SubscriptionPlan.Basis, + plannedChange, + hasEverSubscribed, + subscription?.CurrentPriceAmount, + subscription?.ScheduledPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + subscription?.BillingInfo?.Address?.Country, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt + ); + } + ).ToArray(); + + return new BackOfficeUserDetailResponse( + user.Id, + user.TenantId, + tenantsById.GetValueOrDefault(user.TenantId)?.Name ?? string.Empty, + user.Email, + user.FirstName, + user.LastName, + user.Title, + user.Role, + user.EmailConfirmed, + user.Locale, + user.CreatedAt, + user.ModifiedAt, + user.LastSeenAt, + user.Avatar.Url, + memberships + ); + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs new file mode 100644 index 0000000000..2f15ac1e32 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs @@ -0,0 +1,131 @@ +using Account.Features.Authentication.Domain; +using Account.Features.EmailAuthentication.Domain; +using Account.Features.ExternalAuthentication.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserLoginHistoryQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public UserId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record BackOfficeUserLoginHistoryResponse(BackOfficeUserLoginEntry[] Entries); + +[PublicAPI] +public sealed record BackOfficeUserLoginEntry( + LoginEventKind Kind, + LoginMethod Method, + LoginEventOutcome Outcome, + DateTimeOffset OccurredAt, + string? FailureReason, + ExternalProviderType? ExternalProvider +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginEventKind +{ + Email, + External +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginEventOutcome +{ + Pending, + Succeeded, + Failed +} + +public sealed class GetBackOfficeUserLoginHistoryHandler( + IUserRepository userRepository, + IEmailLoginRepository emailLoginRepository, + IExternalLoginRepository externalLoginRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + private const int LookbackDays = 30; + + public async Task> Handle(GetBackOfficeUserLoginHistoryQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The PRD calls this section "real sign-in attempts" - so we union both authentication aggregates by email. + // The aggregates don't track IP or country today; back-office shows only what we have until those columns land. + var since = timeProvider.GetUtcNow().AddDays(-LookbackDays); + var emailLogins = await emailLoginRepository.GetByEmailSinceAsync(user.Email, since, cancellationToken); + var externalLogins = await externalLoginRepository.GetByEmailSinceAsync(user.Email, since, cancellationToken); + + var entries = new List(emailLogins.Length + externalLogins.Length); + + entries.AddRange(emailLogins.Select(e => new BackOfficeUserLoginEntry( + LoginEventKind.Email, + LoginMethod.OneTimePassword, + MapEmailOutcome(e), + e.CreatedAt, + MapEmailFailureReason(e), + null + ) + ) + ); + + entries.AddRange(externalLogins.Select(e => new BackOfficeUserLoginEntry( + LoginEventKind.External, + MapExternalMethod(e.ProviderType), + MapExternalOutcome(e.LoginResult), + e.CreatedAt, + e.LoginResult is null or ExternalLoginResult.Success ? null : e.LoginResult.ToString(), + e.ProviderType + ) + ) + ); + + var ordered = entries.OrderByDescending(e => e.OccurredAt).ToArray(); + return new BackOfficeUserLoginHistoryResponse(ordered); + } + + private static LoginEventOutcome MapEmailOutcome(EmailLogin login) + { + if (login.Completed) return LoginEventOutcome.Succeeded; + if (login.RetryCount >= EmailLogin.MaxAttempts) return LoginEventOutcome.Failed; + return LoginEventOutcome.Pending; + } + + private static string? MapEmailFailureReason(EmailLogin login) + { + if (login.Completed) return null; + if (login.RetryCount >= EmailLogin.MaxAttempts) return "TooManyRetries"; + return null; + } + + private static LoginEventOutcome MapExternalOutcome(ExternalLoginResult? result) + { + return result switch + { + null => LoginEventOutcome.Pending, + ExternalLoginResult.Success => LoginEventOutcome.Succeeded, + _ => LoginEventOutcome.Failed + }; + } + + private static LoginMethod MapExternalMethod(ExternalProviderType providerType) + { + return providerType switch + { + ExternalProviderType.Google => LoginMethod.Google, + _ => throw new UnreachableException($"Unknown external provider type '{providerType}'.") + }; + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs new file mode 100644 index 0000000000..a2876437a9 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs @@ -0,0 +1,104 @@ +using Account.Features.Authentication.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Authentication.TokenGeneration; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserSessionsQuery(int PageOffset = 0, int PageSize = 25) : IRequest> +{ + [JsonIgnore] // Removes from API contract + public UserId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record BackOfficeUserSessionsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BackOfficeUserSession[] Sessions); + +[PublicAPI] +public sealed record BackOfficeUserSession( + SessionId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + LoginMethod LoginMethod, + DeviceType DeviceType, + string UserAgent, + string IpAddress, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActiveAt, + DateTimeOffset? RevokedAt, + SessionRevokedReason? RevokedReason, + DateTimeOffset ExpiresAt +); + +public sealed class GetBackOfficeUserSessionsQueryValidator : AbstractValidator +{ + public GetBackOfficeUserSessionsQueryValidator() + { + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeUserSessionsHandler(IUserRepository userRepository, ISessionRepository sessionRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUserSessionsQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The Sessions list aggregates activity across every user record sharing this email (one record per tenant), + // so we look up all sibling user ids and ask for their sessions together. The lookup always includes the + // queried user record itself, so its sessions are naturally part of the result. + var membershipUsers = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken); + var userIds = membershipUsers.Select(u => u.Id).ToArray(); + + var (sessions, totalCount, totalPages) = await sessionRepository.GetSessionsForUsersUnfilteredAsync( + userIds, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var tenantIds = sessions.Select(s => s.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var summaries = sessions.Select(s => + { + var tenant = tenantsById.GetValueOrDefault(s.TenantId); + return new BackOfficeUserSession( + s.Id, + s.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Logo.Url, + s.LoginMethod, + s.DeviceType, + s.UserAgent, + s.IpAddress, + s.CreatedAt, + s.ModifiedAt, + s.RevokedAt, + s.RevokedReason, + s.ExpiresAt + ); + } + ).ToArray(); + + return new BackOfficeUserSessionsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs new file mode 100644 index 0000000000..54d356e1c7 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs @@ -0,0 +1,151 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUsersQuery( + string? Search = null, + UserRole[]? Roles = null, + UserActivityFilter? Activity = null, + SortableBackOfficeUserProperties OrderBy = SortableBackOfficeUserProperties.LastSeenAt, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public UserRole[] Roles { get; } = Roles ?? []; +} + +[PublicAPI] +public sealed record BackOfficeUsersResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BackOfficeUserSummary[] Users); + +[PublicAPI] +public sealed record BackOfficeUserSummary( + UserId Id, + TenantId TenantId, + string TenantName, + SubscriptionPlan TenantPlan, + PlannedSubscriptionChange? TenantPlannedChange, + bool TenantHasEverSubscribed, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserActivityFilter +{ + ActiveLast24Hours, + ActiveLast7Days, + ActiveLast30Days, + InactiveOver30Days +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableBackOfficeUserProperties +{ + Name, + Email, + Role, + LastSeenAt, + CreatedAt +} + +public sealed class GetBackOfficeUsersQueryValidator : AbstractValidator +{ + public GetBackOfficeUsersQueryValidator() + { + // Search is optional. When omitted or empty, the page lists every user newest-first. When provided, the cap of + // 100 characters guards against malicious input — the WebApp normally sends short tokens. + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be at most 100 characters."); + RuleFor(x => x.Roles.Length).LessThanOrEqualTo(10).WithMessage("Roles filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeUsersHandler( + IUserRepository userRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUsersQuery query, CancellationToken cancellationToken) + { + var (users, totalCount, totalPages) = await userRepository.SearchAllUsersUnfilteredAsync( + query.Search ?? "", + query.Roles, + query.Activity, + timeProvider.GetUtcNow(), + query.OrderBy, + query.SortOrder, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + var tenantIds = users.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + var subscriptions = await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var summaries = users.Select(u => + { + var tenant = tenantsById.GetValueOrDefault(u.TenantId); + var subscription = subscriptionsByTenantId.GetValueOrDefault(u.TenantId); + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == true; + return new BackOfficeUserSummary( + u.Id, + u.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Plan ?? SubscriptionPlan.Basis, + plannedChange, + hasEverSubscribed, + u.Email, + u.FirstName, + u.LastName, + u.Title, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt, + u.Avatar.Url + ); + } + ).ToArray(); + + return new BackOfficeUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 0f8b1fd138..12458e37cc 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -1,4 +1,6 @@ using Account.Database; +using Account.Features.Tenants.Domain; +using Account.Features.Users.BackOffice.Queries; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; @@ -44,9 +46,67 @@ CancellationToken cancellationToken Task GetTenantUsers(CancellationToken cancellationToken); Task GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken); + + /// + /// Returns total, 30-day active, and pending (unconfirmed email) user counts for the given tenant without applying + /// tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken); + + /// + /// Searches users belonging to a specific tenant without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + Task<(User[] Users, int TotalItems, int TotalPages)> SearchTenantUsersUnfilteredAsync( + TenantId tenantId, + string? search, + UserRole[] roles, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ); + + /// + /// Searches users across every tenant without applying tenant query filters. Search is required and matches + /// user email, full name, or tenant name. The activity filter compares to + /// a sliding window relative to . This method is used by the back-office cross-tenant + /// Users search page where tenant context is not established. + /// + Task<(User[] Users, int TotalItems, int TotalPages)> SearchAllUsersUnfilteredAsync( + string search, + UserRole[] roles, + UserActivityFilter? activity, + DateTimeOffset now, + SortableBackOfficeUserProperties orderBy, + SortOrder sortOrder, + int pageOffset, + int pageSize, + CancellationToken cancellationToken + ); + + /// + /// Returns every user created at or after across all tenants without applying tenant + /// query filters. Used by the back-office dashboard to compute new-user trend buckets across all tenants. + /// + Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. + /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. + /// + Task> GetFirstOwnerByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken); + + /// + /// Returns every non-deleted user across all tenants without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute period-active users (last_seen_at within + /// the selected period) across all tenants. SQLite cannot translate DateTimeOffset comparisons in WHERE, + /// so the time filter runs in memory; the user count is bounded by the dashboard's audience. + /// + Task GetAllUnfilteredAsync(CancellationToken cancellationToken); } -internal sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) +public sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) : SoftDeletableRepositoryBase(accountDbContext), IUserRepository { /// @@ -202,8 +262,8 @@ CancellationToken cancellationToken ? users.OrderBy(u => u.CreatedAt) : users.OrderByDescending(u => u.CreatedAt), SortableUserProperties.LastSeenAt => sortOrder == SortOrder.Ascending - ? users.OrderBy(u => u.LastSeenAt) - : users.OrderByDescending(u => u.LastSeenAt), + ? users.OrderBy(u => u.LastSeenAt == null ? 1 : 0).ThenBy(u => u.LastSeenAt).ThenByDescending(u => u.CreatedAt) + : users.OrderBy(u => u.LastSeenAt == null ? 1 : 0).ThenByDescending(u => u.LastSeenAt).ThenByDescending(u => u.CreatedAt), SortableUserProperties.Name => sortOrder == SortOrder.Ascending ? users.OrderBy(u => u.FirstName == null ? 1 : 0) .ThenBy(u => u.FirstName) @@ -237,7 +297,7 @@ CancellationToken cancellationToken ? result.Length // If the first page returns fewer items than page size, skip querying the total count : await users.CountAsync(cancellationToken); - var totalPages = (totalItems - 1) / pageSize.Value + 1; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize.Value + 1; return (result, totalItems, totalPages); } @@ -260,6 +320,217 @@ public async Task GetUsersByEmailUnfilteredAsync(string email, Cancellat .ToArrayAsync(cancellationToken); } + /// + /// Returns total, 30-day active, and pending (unconfirmed email) user counts for the given tenant without applying + /// tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken) + { + // SQLite EF cannot translate DateTimeOffset comparisons (text-stored); test path materializes the relevant columns and counts in memory, bounded by tenant size. + if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") + { + var users = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.TenantId == tenantId) + .Select(u => new { u.LastSeenAt, u.EmailConfirmed }) + .ToListAsync(cancellationToken); + return (users.Count, users.Count(u => u.EmailConfirmed && u.LastSeenAt.HasValue && u.LastSeenAt.Value >= activeSince), users.Count(u => !u.EmailConfirmed)); + } + + var counts = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.TenantId == tenantId) + .GroupBy(_ => 1) + .Select(g => new { Total = g.Count(), Active = g.Count(u => u.EmailConfirmed && u.LastSeenAt >= activeSince), Pending = g.Count(u => !u.EmailConfirmed) }) + .SingleOrDefaultAsync(cancellationToken); + + return (counts?.Total ?? 0, counts?.Active ?? 0, counts?.Pending ?? 0); + } + + /// + /// Searches users belonging to a specific tenant without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task<(User[] Users, int TotalItems, int TotalPages)> SearchTenantUsersUnfilteredAsync( + TenantId tenantId, + string? search, + UserRole[] roles, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ) + { + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(u => u.TenantId == tenantId); + + if (roles.Length > 0) + { + users = users.Where(u => roles.AsEnumerable().Contains(u.Role)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + users = users.Where(u => + u.Email.Contains(search) || + (u.FirstName + " " + u.LastName).Contains(search) || + (u.Title ?? "").Contains(search) + ); + } + + users = users + .OrderBy(u => u.FirstName == null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName == null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email); + + var itemOffset = (pageOffset ?? 0) * pageSize; + var result = await users.Skip(itemOffset).Take(pageSize).ToArrayAsync(cancellationToken); + + var totalItems = pageOffset == 0 && result.Length < pageSize + ? result.Length + : await users.CountAsync(cancellationToken); + + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + return (result, totalItems, totalPages); + } + + /// + /// Searches users across every tenant without applying tenant query filters. When + /// is empty, every user is returned (subject to role/activity filters and pagination). When non-empty, + /// matches user email, full name, or tenant name. The activity filter compares + /// to a sliding window relative to . This method is used by the back-office + /// cross-tenant Users page where tenant context is not established. + /// Search and role filters run in the database. Activity filter, sort, and pagination run in memory because + /// SQLite cannot translate DateTimeOffset comparisons in WHERE or ORDER BY clauses (the test database is + /// SQLite). + /// + public async Task<(User[] Users, int TotalItems, int TotalPages)> SearchAllUsersUnfilteredAsync( + string search, + UserRole[] roles, + UserActivityFilter? activity, + DateTimeOffset now, + SortableBackOfficeUserProperties orderBy, + SortOrder sortOrder, + int pageOffset, + int pageSize, + CancellationToken cancellationToken + ) + { + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]); + + if (!string.IsNullOrEmpty(search)) + { + // Tenant name search is implemented as a separate lookup so we don't need an EF join. We then OR the + // resulting ids into the user predicate alongside email and full-name matches. + var matchingTenantIds = await accountDbContext.Set() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(t => t.Name.ToLower().Contains(search)) + .Select(t => t.Id) + .ToArrayAsync(cancellationToken); + + users = users.Where(u => + u.Email.Contains(search) || + ((u.FirstName ?? "") + " " + (u.LastName ?? "")).ToLower().Contains(search) || + matchingTenantIds.AsEnumerable().Contains(u.TenantId) + ); + } + + if (roles.Length > 0) + { + users = users.Where(u => roles.AsEnumerable().Contains(u.Role)); + } + + var candidates = await users.ToArrayAsync(cancellationToken); + + if (activity is not null) + { + var oneDayAgo = now.AddDays(-1); + var sevenDaysAgo = now.AddDays(-7); + var thirtyDaysAgo = now.AddDays(-30); + candidates = activity switch + { + UserActivityFilter.ActiveLast24Hours => candidates.Where(u => u.LastSeenAt >= oneDayAgo).ToArray(), + UserActivityFilter.ActiveLast7Days => candidates.Where(u => u.LastSeenAt >= sevenDaysAgo).ToArray(), + UserActivityFilter.ActiveLast30Days => candidates.Where(u => u.LastSeenAt >= thirtyDaysAgo).ToArray(), + UserActivityFilter.InactiveOver30Days => candidates.Where(u => u.LastSeenAt is null || u.LastSeenAt < thirtyDaysAgo).ToArray(), + _ => candidates + }; + } + + IEnumerable ordered = (orderBy, sortOrder) switch + { + (SortableBackOfficeUserProperties.Email, SortOrder.Ascending) => candidates.OrderBy(u => u.Email), + (SortableBackOfficeUserProperties.Email, _) => candidates.OrderByDescending(u => u.Email), + (SortableBackOfficeUserProperties.Role, SortOrder.Ascending) => candidates.OrderBy(u => u.Role).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.Role, _) => candidates.OrderByDescending(u => u.Role).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.LastSeenAt, SortOrder.Ascending) => candidates.OrderBy(u => u.LastSeenAt ?? DateTimeOffset.MinValue).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.LastSeenAt, _) => candidates.OrderByDescending(u => u.LastSeenAt ?? DateTimeOffset.MinValue).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.CreatedAt, SortOrder.Ascending) => candidates.OrderBy(u => u.CreatedAt), + (SortableBackOfficeUserProperties.CreatedAt, _) => candidates.OrderByDescending(u => u.CreatedAt), + (_, SortOrder.Descending) => candidates + .OrderBy(u => u.FirstName is null ? 0 : 1) + .ThenByDescending(u => u.FirstName) + .ThenBy(u => u.LastName is null ? 0 : 1) + .ThenByDescending(u => u.LastName) + .ThenBy(u => u.Email), + _ => candidates + .OrderBy(u => u.FirstName is null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName is null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email) + }; + + var totalItems = candidates.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var pageUsers = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (pageUsers, totalItems, totalPages); + } + + /// + /// Returns every user created at or after across all tenants without applying tenant + /// query filters. Used by the back-office dashboard to compute new-user trend buckets across all tenants. + /// SQLite cannot translate DateTimeOffset comparisons in WHERE, so the time filter runs in memory; the + /// dashboard period is bounded (max 90 days) so the materialized set stays small. + /// + public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var users = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return users.Where(u => u.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every non-deleted user across all tenants without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute period-active users (last_seen_at within + /// the selected period) across all tenants. SQLite cannot translate DateTimeOffset comparisons in WHERE, + /// so the time filter runs in memory; the user count is bounded by the dashboard's audience. + /// + public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + } + + /// + /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. + /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. + /// + public async Task> GetFirstOwnerByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken) + { + if (tenantIds.Length == 0) return new Dictionary(); + + // SQLite cannot translate DateTimeOffset ORDER BY clauses, so materialize the candidate Owners and pick + // the earliest in memory. Bounded by the number of tenants on the dashboard recent-signups list. + var owners = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.Role == UserRole.Owner && tenantIds.AsEnumerable().Contains(u.TenantId)) + .ToArrayAsync(cancellationToken); + + return owners + .GroupBy(u => u.TenantId) + .ToDictionary(g => g.Key, g => g.OrderBy(u => u.CreatedAt).ThenBy(u => u.Id.Value).First()); + } + [UsedImplicitly] private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers); } diff --git a/application/account/Core/Integrations/Stripe/IStripeClient.cs b/application/account/Core/Integrations/Stripe/IStripeClient.cs index c3f6ce0b74..51351d9522 100644 --- a/application/account/Core/Integrations/Stripe/IStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/IStripeClient.cs @@ -24,6 +24,17 @@ public interface IStripeClient Task GetPriceCatalogAsync(CancellationToken cancellationToken); + Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken); + + /// + /// Returns the single currency observed across active Stripe prices. The application's + /// architectural promise is that every active Stripe price uses the same currency; an + /// implementation must throw when it observes more than one distinct currency. Returns + /// null from so callers can detect the + /// unconfigured environment without exception handling. + /// + Task GetPlatformCurrencyAsync(CancellationToken cancellationToken); + StripeWebhookEventResult? VerifyWebhookSignature(string payload, string signatureHeader); Task GetCustomerBillingInfoAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); @@ -51,12 +62,45 @@ public interface IStripeClient Task CreateSubscriptionWithSavedPaymentMethodAsync(StripeCustomerId stripeCustomerId, SubscriptionPlan plan, CancellationToken cancellationToken); Task SyncPaymentTransactionsAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns Stripe events related to a customer (last 30 days — see + /// https://docs.stripe.com/api/events) via the events.list API. This is the authoritative + /// source for the hot path: every webhook-driven sync calls this with + /// set to the subscription's last-synced anchor so the + /// BillingEvent ledger is rebuilt from Stripe's view of the world, never from + /// stripe_events.payload. The local archive is a cold backup read only by the + /// admin reconcile command for events older than the 30-day window. + /// The returned flag distinguishes a + /// fully-paged success (anchor may advance) from a partial / failed enumeration (anchor + /// must remain unchanged so the next sync re-pulls the unseen events). + /// + Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, DateTimeOffset? sinceCreated, CancellationToken cancellationToken); + + /// + /// Builds the Stripe Dashboard URL for a customer. Returns null when no Stripe API key is + /// configured. The URL points at the test-mode dashboard for `sk_test_*` keys and the live + /// dashboard otherwise — matching how the Stripe Dashboard itself disambiguates modes. + /// + string? BuildCustomerDashboardUrl(StripeCustomerId stripeCustomerId); } +public sealed record StripeReplayEvent(string EventId, string EventType, DateTimeOffset CreatedAt, string Payload, string ApiVersion); + +/// +/// Result of . is +/// true only when the events.list enumeration completed without an exception mid-pagination; +/// callers must keep the events.list anchor unchanged when it is false so the next sync re-pulls +/// the events that were never observed. +/// +public sealed record StripeEventsListResult(StripeReplayEvent[] Events, bool Succeeded); + public sealed record StripeWebhookEventResult( string EventId, string EventType, - StripeCustomerId? CustomerId + StripeCustomerId? CustomerId, + string ApiVersion, + DateTimeOffset Created ); public sealed record CheckoutSessionResult(string SessionId, string ClientSecret); @@ -92,6 +136,17 @@ public sealed record UpgradePreviewLineItem(string Description, decimal Amount, public sealed record CheckoutPreviewResult(decimal TotalAmount, string Currency, decimal TaxAmount); +/// +/// One row in the Stripe price catalog, normalized to the platform's ex-VAT convention. The +/// field is ALWAYS the ex-VAT recurring price — implementations must +/// subtract VAT from Stripe's inc-VAT listed amount when the price has +/// tax_behavior=inclusive. The catalog is the source of truth for MRR, BillingEvent amounts, +/// and ScheduledPriceAmount; all of those are revenue-accounting numbers and VAT is collected on +/// behalf of tax authorities, never our revenue. The only place inc-VAT amounts appear in the +/// domain is , which carries the inc-VAT customer-facing amount +/// alongside and +/// for invoice display. +/// public sealed record PriceCatalogItem( SubscriptionPlan Plan, decimal UnitAmount, diff --git a/application/account/Core/Integrations/Stripe/MockStripeClient.cs b/application/account/Core/Integrations/Stripe/MockStripeClient.cs index ea66d28705..6cfb4193d1 100644 --- a/application/account/Core/Integrations/Stripe/MockStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/MockStripeClient.cs @@ -1,5 +1,6 @@ using Account.Features.Subscriptions.Domain; using Microsoft.Extensions.Configuration; +using SharedKernel.Configuration; namespace Account.Integrations.Stripe; @@ -11,7 +12,52 @@ public sealed class MockStripeState public bool SimulateCustomerDeleted { get; set; } + // Simulates the production behavior of StripeClient.GetCustomerBillingInfoAsync where a + // StripeException or TaskCanceledException is caught at the integration boundary and the + // method returns null (per the "never throw from integration clients" project rule). + public bool SimulateGetCustomerBillingInfoFailure { get; set; } + public bool SimulateOpenInvoice { get; set; } + + // Per-test override for the currency the mock emits from SyncSubscriptionStateAsync (and the rest + // of the methods that surface a currency). Left null so the mock's PlatformCurrency tracks the + // resolver-populated platform currency by default; set to a different ISO 4217 code in a test to + // simulate Stripe returning a currency that does not match the cached platform currency so the + // boundary guard can be exercised. Setting to MockStripeClient.MockStandardCurrency explicitly is + // equivalent to leaving it null when the resolver also resolves the default. + public string? SubscriptionCurrency { get; set; } + + // Optional reference to the singleton populated by PlatformCurrencyStartupResolver. When set, the + // mock emits this currency on every method so the mock and the production resolver agree on the + // platform currency without each test having to wire SubscriptionCurrency explicitly. Left null in + // the UnconfiguredStripeClient path and in unit tests that construct MockStripeState directly. + public IPlatformCurrencyProvider? PlatformCurrencyProvider { get; init; } + + // Resolves the currency the mock emits at request time. The per-test override wins so the mismatch + // guard can be exercised; otherwise the resolver-populated currency wins; otherwise fall back to + // the constant default so unit tests that construct MockStripeState directly don't observe null. + public string PlatformCurrency => SubscriptionCurrency ?? PlatformCurrencyProvider?.Currency ?? MockStripeClient.MockStandardCurrency; + + // Extra Stripe events the test wants the mock's events.list to return on top of the defaults. + // Lets a test simulate the events.list view of the world for scenarios where the new + // events.list-driven emission must see historical events that aren't part of the default mock + // timeline (e.g. drift detection across earlier customer.subscription.created/deleted pairs). + public List EventsListAdditionalEvents { get; } = []; + + // Override the scheduled plan returned by SyncSubscriptionStateAsync. Used to simulate the + // cancel-then-reschedule edge case where local pre-sync ScheduledPlan equals Stripe post-sync + // ScheduledPlan and the diff-based transition detector therefore doesn't fire. + public SubscriptionPlan? ScheduledPlan { get; set; } + + // Plans to omit from the GetPriceCatalogAsync result. Used to simulate the upstream Stripe + // price-list call returning a partial or empty catalog, so the SingleOrDefault catalog-gap + // guard in ProcessPendingStripeEvents can be exercised without rolling back the transaction. + public HashSet PriceCatalogOmittedPlans { get; } = []; + + // When set the mock simulates an events.list enumeration that failed partway through. The mock + // surfaces this through the StripeEventsListResult.Succeeded flag so the anchor-advance guard in + // ProcessPendingStripeEvents can be exercised end-to-end. + public bool SimulateEventsListFailure { get; set; } } public sealed class MockStripeClient(IConfiguration configuration, TimeProvider timeProvider, MockStripeState state) : IStripeClient @@ -21,9 +67,48 @@ public sealed class MockStripeClient(IConfiguration configuration, TimeProvider public const string MockSessionId = "cs_mock_session_12345"; public const string MockClientSecret = "cs_mock_client_secret_12345"; public const string MockInvoiceUrl = "https://mock.stripe.local/invoice/12345"; + public const string MockPaymentMethodId = "pm_mock_12345"; + public const string MockInvoiceId = "in_mock_12345"; public const string MockWebhookEventId = "evt_mock_12345"; - private readonly bool _isEnabled = configuration.GetValue("Stripe:AllowMockProvider"); + public const string MockSubscriptionCreatedEventId = "evt_mock_subscription_created"; + public const string MockPaymentMethodAttachedEventId = "evt_mock_payment_method_attached"; + public const string MockInvoicePaymentSucceededEventId = "evt_mock_invoice_payment_succeeded"; + public const string MockPaymentFailedEventId = "evt_mock_payment_failed"; + public const string MockCustomerDeletedEventId = "evt_mock_customer_deleted"; + + public const string MockApiVersion = "2025-09-30.preview"; + + // Default mock currency used both as MockStripeState.SubscriptionCurrency's seed value and as a + // searchable test constant. Tests reference MockStripeClient.MockStandardCurrency instead of a raw + // "DKK" literal so flipping the seed flips the entire mock and test surface in lock-step. + public const string MockStandardCurrency = "DKK"; + + // Mock plan amounts follow the platform's ex-VAT convention for internal recurring-revenue numbers. + // MRR is revenue accounting; VAT is collected on behalf of tax authorities and is never our revenue, + // so CurrentPriceAmount, ScheduledPriceAmount, and every BillingEvent amount column are ALWAYS ex-VAT. + // PaymentTransaction is the one exception — it exposes the inc-VAT customer-facing display amount as + // Amount, plus AmountExcludingTax and TaxAmount for the invoice breakdown. The mock derives the tax + // and inc-VAT amounts at request time from the active platform currency via VatRatesByCurrency, so + // running the developer Stripe sandbox in any of the supported currencies stays internally consistent. + public const decimal StandardAmountExcludingTax = 149.00m; + public const decimal PremiumAmountExcludingTax = 299.00m; + + // Per-currency VAT rates the mock applies when synthesising inc-VAT and tax amounts. Real-world + // VAT rates depend on merchant country plus customer country (B2B vs B2C), so production must read + // them off the Stripe invoice. For the mock these defaults are sufficient to keep the local-dev + // experience self-consistent across the four currencies developer sandboxes are likely to be in. + // EU rates vary across member states (17%-27%); 21% is the rough EU average and is approximate. + // TODO: configurable VAT rate per merchant/country once the platform onboards multiple jurisdictions. + private static readonly Dictionary VatRatesByCurrency = new(StringComparer.OrdinalIgnoreCase) + { + ["DKK"] = 0.25m, + ["EUR"] = 0.21m, + ["USD"] = 0m, + ["GBP"] = 0.20m + }; + + private readonly bool _isEnabled = ResolveIsEnabled(configuration); public Task CreateCustomerAsync(string tenantName, string email, long tenantId, CancellationToken cancellationToken) { @@ -47,26 +132,33 @@ public sealed class MockStripeClient(IConfiguration configuration, TimeProvider } var now = timeProvider.GetUtcNow(); + var currency = state.PlatformCurrency; + var (standardIncludingTax, standardTaxAmount) = ComputeAmountBreakdown(StandardAmountExcludingTax, currency); var transactions = new[] { new PaymentTransaction( PaymentTransactionId.NewId(), - 29.99m, - "USD", + standardIncludingTax, + StandardAmountExcludingTax, + standardTaxAmount, + currency, PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, - null + null, + SubscriptionPlan.Standard, + null, + standardIncludingTax ) }; var result = new SubscriptionSyncResult( SubscriptionPlan.Standard, - null, + state.ScheduledPlan, StripeSubscriptionId.NewId(MockSubscriptionId), - 29.99m, - "USD", + StandardAmountExcludingTax, + currency, now.AddDays(30), false, null, @@ -118,10 +210,36 @@ public Task ReactivateSubscriptionAsync(StripeSubscriptionId stripeSubscri public Task GetPriceCatalogAsync(CancellationToken cancellationToken) { EnsureEnabled(); - return Task.FromResult([ - new PriceCatalogItem(SubscriptionPlan.Standard, 29.00m, "USD", "month", 1, false), - new PriceCatalogItem(SubscriptionPlan.Premium, 99.00m, "USD", "month", 1, false) - ] + var currency = state.PlatformCurrency; + var catalog = new List + { + new(SubscriptionPlan.Standard, StandardAmountExcludingTax, currency, "month", 1, false), + new(SubscriptionPlan.Premium, PremiumAmountExcludingTax, currency, "month", 1, false) + }; + catalog.RemoveAll(item => state.PriceCatalogOmittedPlans.Contains(item.Plan)); + return Task.FromResult(catalog.ToArray()); + } + + public Task GetPlatformCurrencyAsync(CancellationToken cancellationToken) + { + EnsureEnabled(); + // PlatformCurrencyStartupResolver calls this method exactly once at host startup to seed the + // platform-currency singleton. The per-test SubscriptionCurrency override is not the right + // source here because it represents Stripe drift from the seed; the resolver wants the seed + // itself. Fall through to PlatformCurrency so the same property the rest of the mock reads + // is also what the resolver caches — a non-null override therefore *does* flow through to the + // resolver when wired before host startup (this is what PlatformCurrencyProviderTests exercises). + return Task.FromResult(state.PlatformCurrency); + } + + public Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) + { + EnsureEnabled(); + return Task.FromResult>(new Dictionary + { + ["price_mock_standard"] = SubscriptionPlan.Standard, + ["price_mock_premium"] = SubscriptionPlan.Premium + } ); } @@ -147,13 +265,18 @@ public Task GetPriceCatalogAsync(CancellationToken cancellat var customerIdString = payload.StartsWith("customer:") ? payload.Split(':')[1] : payload == "no_customer" ? null : MockCustomerId; StripeCustomerId.TryParse(customerIdString, out var customerId); - return new StripeWebhookEventResult(eventId, eventType, customerId); + return new StripeWebhookEventResult(eventId, eventType, customerId, MockApiVersion, timeProvider.GetUtcNow()); } public Task GetCustomerBillingInfoAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) { EnsureEnabled(); + if (state.SimulateGetCustomerBillingInfoFailure) + { + return Task.FromResult(null); + } + if (state.SimulateCustomerDeleted) { return Task.FromResult(new CustomerBillingResult(null, true)); @@ -210,7 +333,9 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu EnsureEnabled(); if (state.SimulateOpenInvoice) { - return Task.FromResult(new OpenInvoiceResult(29.99m, "USD")); + var currency = state.PlatformCurrency; + var (standardIncludingTax, _) = ComputeAmountBreakdown(StandardAmountExcludingTax, currency); + return Task.FromResult(new OpenInvoiceResult(standardIncludingTax, currency)); } return Task.FromResult(null); @@ -231,19 +356,20 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu { EnsureEnabled(); var now = timeProvider.GetUtcNow(); + var currency = state.PlatformCurrency; var lineItems = new[] { - new UpgradePreviewLineItem("Unused time on Standard after " + now.ToString("d MMM yyyy"), -14.50m, "USD", true, false), - new UpgradePreviewLineItem("Remaining time on Premium after " + now.ToString("d MMM yyyy"), 30.00m, "USD", true, false), - new UpgradePreviewLineItem("Tax", 1.55m, "USD", false, true) + new UpgradePreviewLineItem("Unused time on Standard after " + now.ToString("d MMM yyyy"), -14.50m, currency, true, false), + new UpgradePreviewLineItem("Remaining time on Premium after " + now.ToString("d MMM yyyy"), 30.00m, currency, true, false), + new UpgradePreviewLineItem("Tax", 1.55m, currency, false, true) }; - return Task.FromResult(new UpgradePreviewResult(17.05m, "USD", lineItems)); + return Task.FromResult(new UpgradePreviewResult(17.05m, currency, lineItems)); } public Task GetCheckoutPreviewAsync(StripeCustomerId stripeCustomerId, SubscriptionPlan plan, CancellationToken cancellationToken) { EnsureEnabled(); - return Task.FromResult(new CheckoutPreviewResult(19.00m, "EUR", 0m)); + return Task.FromResult(new CheckoutPreviewResult(19.00m, state.PlatformCurrency, 0m)); } public Task CreateSubscriptionWithSavedPaymentMethodAsync(StripeCustomerId stripeCustomerId, SubscriptionPlan plan, CancellationToken cancellationToken) @@ -256,13 +382,117 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu { EnsureEnabled(); var now = timeProvider.GetUtcNow(); + var currency = state.PlatformCurrency; + var (standardIncludingTax, standardTaxAmount) = ComputeAmountBreakdown(StandardAmountExcludingTax, currency); return Task.FromResult( [ - new PaymentTransaction(PaymentTransactionId.NewId(), 29.99m, "USD", PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, null) + new PaymentTransaction(PaymentTransactionId.NewId(), standardIncludingTax, StandardAmountExcludingTax, standardTaxAmount, currency, PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, null, SubscriptionPlan.Standard, null, standardIncludingTax) ] ); } + public Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, DateTimeOffset? sinceCreated, CancellationToken cancellationToken) + { + EnsureEnabled(); + var now = timeProvider.GetUtcNow(); + // Stripe encodes invoice amounts in the smallest currency unit (øre for DKK, cents for USD/EUR). + // The amount_paid value is inc-VAT, amount_excluding_tax is ex-VAT, and tax is the difference — + // mirrors the PaymentTransaction triple the mock produces elsewhere so the replayer sees an + // internally consistent timeline. The currency code is the lowercase ISO 4217 platform currency. + // See https://docs.stripe.com/currencies. + var currency = state.PlatformCurrency; + var (standardIncludingTax, standardTaxAmount) = ComputeAmountBreakdown(StandardAmountExcludingTax, currency); + var amountPaidMinorUnits = (long)(standardIncludingTax * 100m); + var amountExcludingTaxMinorUnits = (long)(StandardAmountExcludingTax * 100m); + var taxMinorUnits = (long)(standardTaxAmount * 100m); + var currencyCodeForPayload = currency.ToLowerInvariant(); + var paymentMethodAttachedPayload = "{\"data\":{\"object\":{\"id\":\"" + MockPaymentMethodId + "\",\"type\":\"card\",\"customer\":\"" + MockCustomerId + "\"}}}"; + var invoicePaymentSucceededPayload = "{\"data\":{\"object\":{\"id\":\"" + MockInvoiceId + "\",\"amount_paid\":" + amountPaidMinorUnits + ",\"amount_excluding_tax\":" + amountExcludingTaxMinorUnits + ",\"tax\":" + taxMinorUnits + ",\"currency\":\"" + currencyCodeForPayload + "\",\"subscription\":\"" + MockSubscriptionId + "\",\"status\":\"paid\",\"billing_reason\":\"subscription_create\"}}}"; + var events = new List + { + // The default timeline mirrors the state SyncSubscriptionStateAsync returns: an attached + // payment method, an active Standard subscription on price_mock_standard, and a paid invoice + // for the first billing cycle. The replayer must consume all three so the BillingEvent + // timeline matches the live subscription state without surfacing spurious drift. + new( + MockPaymentMethodAttachedEventId, + "payment_method.attached", + now.AddMinutes(-6), + paymentMethodAttachedPayload, + MockApiVersion + ), + new( + MockSubscriptionCreatedEventId, + "customer.subscription.created", + now.AddMinutes(-5), + """{"data":{"object":{"items":{"data":[{"price":{"id":"price_mock_standard"}}]}}}}""", + MockApiVersion + ), + new( + MockInvoicePaymentSucceededEventId, + "invoice.payment_succeeded", + now.AddMinutes(-4), + invoicePaymentSucceededPayload, + MockApiVersion + ) + }; + + if (state.OverrideSubscriptionStatus == StripeSubscriptionStatus.PastDue) + { + events.Add(new StripeReplayEvent( + MockPaymentFailedEventId, + "invoice.payment_failed", + now.AddMinutes(-1), + """{"data":{"object":{"attempt_count":1,"billing_reason":"subscription_cycle"}}}""", + MockApiVersion + ) + ); + } + + if (state.SimulateCustomerDeleted) + { + events.Add(new StripeReplayEvent(MockCustomerDeletedEventId, "customer.deleted", now, "{}", MockApiVersion)); + } + + events.AddRange(state.EventsListAdditionalEvents); + + var filtered = sinceCreated is { } anchor ? events.Where(e => e.CreatedAt >= anchor) : events; + var ordered = filtered.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId).ToArray(); + return Task.FromResult(new StripeEventsListResult(ordered, !state.SimulateEventsListFailure)); + } + + public string? BuildCustomerDashboardUrl(StripeCustomerId stripeCustomerId) + { + EnsureEnabled(); + // Mock customers don't exist in Stripe's Dashboard, so returning null tells the back-office UI + // to hide the "Open in Stripe" menu item entirely — clicking the link would otherwise land on a + // 404 inside Stripe and confuse operators investigating a mock-mode tenant. + return null; + } + + private static (decimal AmountIncludingTax, decimal TaxAmount) ComputeAmountBreakdown(decimal amountExcludingTax, string currency) + { + // Unknown currencies fall back to 0% so the mock stays self-consistent in a developer sandbox + // configured for a currency outside the registry — the only observable effect is that inc-VAT + // equals ex-VAT and tax is zero, which production reconciliation handles via the same path as + // a B2B reverse-charge invoice with no tax line. + var vatRate = VatRatesByCurrency.GetValueOrDefault(currency, 0m); + var taxAmount = Math.Round(amountExcludingTax * vatRate, 2); + return (amountExcludingTax + taxAmount, taxAmount); + } + + private static bool ResolveIsEnabled(IConfiguration configuration) + { + var allowMockProvider = configuration.GetValue("Stripe:AllowMockProvider"); + + if (allowMockProvider && SharedInfrastructureConfiguration.IsRunningInAzure) + { + throw new InvalidOperationException("Mock Stripe provider cannot be enabled in Azure environments."); + } + + return allowMockProvider; + } + private void EnsureEnabled() { if (!_isEnabled) diff --git a/application/account/Core/Integrations/Stripe/PlatformCurrencyProvider.cs b/application/account/Core/Integrations/Stripe/PlatformCurrencyProvider.cs new file mode 100644 index 0000000000..47dc7a0504 --- /dev/null +++ b/application/account/Core/Integrations/Stripe/PlatformCurrencyProvider.cs @@ -0,0 +1,24 @@ +namespace Account.Integrations.Stripe; + +/// +/// Singleton exposing the platform currency observed on active Stripe prices at startup. +/// Resolved once by via +/// and cached for the process lifetime. +/// Returns null when Stripe is not configured (the dashboard layer renders gracefully when +/// currency is missing). When Stripe is configured but active prices use multiple currencies, the +/// startup resolver fails fast — the application never observes a mixed-currency state at runtime. +/// +public interface IPlatformCurrencyProvider +{ + string? Currency { get; } +} + +public sealed class PlatformCurrencyProvider : IPlatformCurrencyProvider +{ + public string? Currency { get; private set; } + + internal void SetCurrency(string? currency) + { + Currency = currency; + } +} diff --git a/application/account/Core/Integrations/Stripe/PlatformCurrencyStartupResolver.cs b/application/account/Core/Integrations/Stripe/PlatformCurrencyStartupResolver.cs new file mode 100644 index 0000000000..5f1b25357b --- /dev/null +++ b/application/account/Core/Integrations/Stripe/PlatformCurrencyStartupResolver.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Account.Integrations.Stripe; + +/// +/// Resolves the platform currency once at application startup by reading the active Stripe price +/// catalog and validating that every active price uses the same currency. The resolved value is +/// cached on for the process lifetime — the platform +/// currency never changes at runtime. When Stripe is not configured ( +/// is the active implementation) the provider stays +/// null and consumers handle the missing currency gracefully. When Stripe is configured but +/// resolution fails or returns no currency, startup aborts with a clear exception so the +/// application never serves a missing- or mixed-currency state. +/// +public sealed class PlatformCurrencyStartupResolver( + IServiceProvider serviceProvider, + IConfiguration configuration, + PlatformCurrencyProvider platformCurrencyProvider, + ILogger logger +) : IHostedService +{ + public async Task StartAsync(CancellationToken cancellationToken) + { + using var scope = serviceProvider.CreateScope(); + var stripeClient = ResolveActiveStripeClient(scope.ServiceProvider); + + if (stripeClient is UnconfiguredStripeClient) + { + logger.LogInformation("Stripe is not configured; platform currency will be null for the process lifetime"); + return; + } + + var currency = await stripeClient.GetPlatformCurrencyAsync(cancellationToken); + if (currency is null) + { + throw new InvalidOperationException("Stripe is configured but the platform currency could not be resolved from active prices."); + } + + platformCurrencyProvider.SetCurrency(currency); + logger.LogInformation("Resolved platform currency '{Currency}' from active Stripe prices", currency); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private IStripeClient ResolveActiveStripeClient(IServiceProvider scopedServiceProvider) + { + // The mock provider is gated per-request by an HTTP cookie at runtime; at startup there is no + // request, so select directly based on configuration. The mock provider is preferred in test + // and local-dev runs (Stripe:AllowMockProvider=true) so the resolver populates the provider + // with the mock's configured currency. Otherwise pick the real Stripe client when configured, + // or fall back to the unconfigured client. + var allowMockProvider = configuration.GetValue("Stripe:AllowMockProvider"); + if (allowMockProvider) + { + return scopedServiceProvider.GetRequiredKeyedService("mock-stripe"); + } + + var isStripeSubscriptionEnabled = configuration["Stripe:SubscriptionEnabled"] == "true"; + if (isStripeSubscriptionEnabled) + { + return scopedServiceProvider.GetRequiredKeyedService("stripe"); + } + + return scopedServiceProvider.GetRequiredKeyedService("unconfigured-stripe"); + } +} diff --git a/application/account/Core/Integrations/Stripe/StripeClient.cs b/application/account/Core/Integrations/Stripe/StripeClient.cs index 52bcc550da..4f40efab1b 100644 --- a/application/account/Core/Integrations/Stripe/StripeClient.cs +++ b/application/account/Core/Integrations/Stripe/StripeClient.cs @@ -1,24 +1,51 @@ using System.Text.Json; +using Account.Features; using Account.Features.Subscriptions.Domain; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; +using SharedKernel.Telemetry; using Stripe; using Stripe.Checkout; using PaymentMethod = Account.Features.Subscriptions.Domain.PaymentMethod; using SessionCreateOptions = Stripe.Checkout.SessionCreateOptions; using SessionService = Stripe.Checkout.SessionService; +using StripePaymentMethod = Stripe.PaymentMethod; using StripePrice = Stripe.Price; using StripeSubscription = Stripe.Subscription; namespace Account.Integrations.Stripe; -public sealed class StripeClient(IConfiguration configuration, IMemoryCache memoryCache, ILogger logger) : IStripeClient +public sealed class StripeClient( + IConfiguration configuration, + IMemoryCache memoryCache, + IPlatformCurrencyProvider platformCurrencyProvider, + ITelemetryEventsCollector telemetryEventsCollector, + ILogger logger +) : IStripeClient { private const string PriceCacheKey = "stripe_resolved_prices"; private const string ProductPlanCacheKey = "stripe_product_plan_map"; private static readonly TimeSpan PriceCacheDuration = TimeSpan.FromMinutes(1); private static readonly string[] LookupKeys = ["standard_monthly", "premium_monthly"]; + private static readonly string[] ReplayEventTypes = + [ + "customer.created", + "customer.updated", + "customer.deleted", + "customer.subscription.created", + "customer.subscription.updated", + "customer.subscription.deleted", + "subscription_schedule.created", + "subscription_schedule.updated", + "subscription_schedule.released", + "subscription_schedule.canceled", + "invoice.payment_succeeded", + "invoice.payment_failed", + "charge.refunded", + "payment_method.attached" + ]; + private readonly string? _apiKey = configuration["Stripe:ApiKey"]; private readonly string? _webhookSecret = configuration["Stripe:WebhookSecret"]; @@ -122,8 +149,20 @@ public sealed class StripeClient(IConfiguration configuration, IMemoryCache memo var subscriptionItem = stripeSubscription.Items.Data.SingleOrDefault(); var plan = GetPlanFromProductId(subscriptionItem?.Price.ProductId); var scheduledPlan = GetScheduledPlan(stripeSubscription); - var currentPriceAmount = subscriptionItem?.Price.UnitAmount / 100m; + // CurrentPriceAmount is ALWAYS persisted ex-VAT (revenue accounting; VAT is collected on + // behalf of tax authorities and never our revenue). Normalize at the boundary when Stripe's + // tax_behavior is "inclusive" — see NormalizePriceAmountToExcludingTax for the rate lookup + // path. Inc-VAT customer-facing amounts only appear in PaymentTransaction for invoice display. + var currentPriceAmount = NormalizePriceAmountToExcludingTax(subscriptionItem?.Price); var currentPriceCurrency = subscriptionItem?.Price.Currency?.ToUpperInvariant(); + var platformCurrency = platformCurrencyProvider.Currency; + if (currentPriceCurrency is not null && platformCurrency is not null && currentPriceCurrency != platformCurrency) + { + logger.LogError("Stripe subscription '{SubscriptionId}' for customer '{CustomerId}' uses currency '{ObservedCurrency}' which does not match the platform currency '{PlatformCurrency}'; rejecting sync", stripeSubscription.Id, stripeCustomerId, currentPriceCurrency, platformCurrency); + telemetryEventsCollector.CollectEvent(new StripeSubscriptionCurrencyMismatchRejected(stripeSubscription.Id, currentPriceCurrency, platformCurrency)); + return null; + } + var currentPeriodEnd = subscriptionItem?.CurrentPeriodEnd; var cancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd; var cancellationReason = stripeSubscription.CancelAtPeriodEnd @@ -131,20 +170,8 @@ public sealed class StripeClient(IConfiguration configuration, IMemoryCache memo : null; var cancellationFeedback = stripeSubscription.CancellationDetails?.Comment; - PaymentMethod? paymentMethod = null; var defaultPaymentMethod = stripeSubscription.DefaultPaymentMethod ?? stripeSubscription.Customer?.InvoiceSettings?.DefaultPaymentMethod; - if (defaultPaymentMethod is not null) - { - if (defaultPaymentMethod.Card is not null) - { - paymentMethod = new PaymentMethod(defaultPaymentMethod.Card.Brand, defaultPaymentMethod.Card.Last4, (int)defaultPaymentMethod.Card.ExpMonth, (int)defaultPaymentMethod.Card.ExpYear); - } - else if (defaultPaymentMethod.Link is not null) - { - var last4 = defaultPaymentMethod.Link.Email is { Length: >= 4 } email ? email[^4..] : "****"; - paymentMethod = new PaymentMethod("link", last4, 0, 0); - } - } + var paymentMethod = MapDefaultPaymentMethod(defaultPaymentMethod); return new SubscriptionSyncResult( plan, @@ -406,6 +433,24 @@ public async Task ReactivateSubscriptionAsync(StripeSubscriptionId stripeS } } + public async Task GetPlatformCurrencyAsync(CancellationToken cancellationToken) + { + await EnsurePriceCachePopulatedAsync(cancellationToken); + + if (!memoryCache.TryGetValue(PriceCacheKey, out Dictionary? cached) || cached is null || cached.Count == 0) + { + return null; + } + + var currencies = cached.Values.Select(p => p.Currency.ToUpperInvariant()).Distinct().ToArray(); + if (currencies.Length > 1) + { + throw new InvalidOperationException($"Active Stripe prices use multiple currencies ({string.Join(", ", currencies)}); the platform requires a single currency across all active prices."); + } + + return currencies[0]; + } + public async Task GetPriceCatalogAsync(CancellationToken cancellationToken) { await EnsurePriceCachePopulatedAsync(cancellationToken); @@ -420,7 +465,11 @@ public async Task GetPriceCatalogAsync(CancellationToken can foreach (var (lookupKey, price) in cached) { var plan = ParseLookupKey(lookupKey); - var unitAmount = price.UnitAmount.GetValueOrDefault() / 100m; + // PriceCatalogItem.UnitAmount is contractually ex-VAT (MRR is revenue accounting; VAT is + // collected on behalf of tax authorities and never our revenue). NormalizePriceAmountToExcludingTax + // subtracts the VAT component when Stripe's tax_behavior is "inclusive", matching the + // architectural rule that all internal recurring-revenue numbers are net-of-tax. + var unitAmount = NormalizePriceAmountToExcludingTax(price) ?? 0m; var currency = price.Currency.ToUpperInvariant(); var interval = price.Recurring?.Interval ?? "month"; var intervalCount = (int)(price.Recurring?.IntervalCount ?? 1); @@ -436,16 +485,16 @@ public async Task GetPriceCatalogAsync(CancellationToken can { try { - if (_webhookSecret is null) + if (string.IsNullOrWhiteSpace(_webhookSecret)) { - logger.LogError("Webhook secret is not configured"); + logger.LogCritical("Stripe webhook secret is missing or whitespace; all webhooks will be rejected"); return null; } var stripeEvent = EventUtility.ConstructEvent(payload, signatureHeader, _webhookSecret); var customerId = ExtractCustomerId(payload); - return new StripeWebhookEventResult(stripeEvent.Id, stripeEvent.Type, customerId); + return new StripeWebhookEventResult(stripeEvent.Id, stripeEvent.Type, customerId, stripeEvent.ApiVersion, stripeEvent.Created); } catch (StripeException ex) { @@ -490,17 +539,7 @@ public async Task GetPriceCatalogAsync(CancellationToken can var taxIds = await taxIdService.ListAsync(stripeCustomerId.Value, requestOptions: GetRequestOptions(), cancellationToken: cancellationToken); var taxId = taxIds.Data.FirstOrDefault()?.Value; - PaymentMethod? paymentMethod = null; - var defaultPaymentMethod = customer.InvoiceSettings?.DefaultPaymentMethod; - if (defaultPaymentMethod?.Card is not null) - { - paymentMethod = new PaymentMethod(defaultPaymentMethod.Card.Brand, defaultPaymentMethod.Card.Last4, (int)defaultPaymentMethod.Card.ExpMonth, (int)defaultPaymentMethod.Card.ExpYear); - } - else if (defaultPaymentMethod?.Link is not null) - { - var last4 = defaultPaymentMethod.Link.Email is { Length: >= 4 } linkEmail ? linkEmail[^4..] : "****"; - paymentMethod = new PaymentMethod("link", last4, 0, 0); - } + var paymentMethod = MapDefaultPaymentMethod(customer.InvoiceSettings?.DefaultPaymentMethod); return new CustomerBillingResult(new BillingInfo(customer.Name, address, email, taxId), false, paymentMethod); } @@ -972,41 +1011,79 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st { var invoiceService = new InvoiceService(); var invoices = await invoiceService.ListAsync( - new InvoiceListOptions { Customer = stripeCustomerId.Value, Limit = 100, Expand = ["data.payments.data.payment"] }, + new InvoiceListOptions { Customer = stripeCustomerId.Value, Limit = 100, Expand = ["data.payments.data.payment", "data.lines.data.pricing"] }, GetRequestOptions(), cancellationToken ); var chargeService = new ChargeService(); var charges = await chargeService.ListAsync( - new ChargeListOptions { Customer = stripeCustomerId.Value, Limit = 100 }, + new ChargeListOptions { Customer = stripeCustomerId.Value, Limit = 100, Expand = ["data.refunds"] }, GetRequestOptions(), cancellationToken ); var refundedAmountByPaymentIntentId = charges.Data .Where(c => c.AmountRefunded > 0 && c.PaymentIntentId is not null) .ToDictionary(c => c.PaymentIntentId!, c => c.AmountRefunded); + // Track the latest refund's timestamp per payment intent so the billing-history UI can render + // the refund as a separate row at the moment it actually happened, not at the original invoice + // date. Stripe returns the most recent refunds inline on the charge by default. + var latestRefundedAtByPaymentIntentId = charges.Data + .Where(c => c.AmountRefunded > 0 && c.PaymentIntentId is not null && c.Refunds is { Data.Count: > 0 }) + .ToDictionary(c => c.PaymentIntentId!, c => c.Refunds.Data.Max(r => r.Created)); + var creditNoteService = new CreditNoteService(); var creditNotes = await creditNoteService.ListAsync( new CreditNoteListOptions { Customer = stripeCustomerId.Value, Limit = 100 }, GetRequestOptions(), cancellationToken ); - var creditNotesByInvoiceId = creditNotes.Data.GroupBy(cn => cn.InvoiceId).ToDictionary(g => g.Key, g => g.First().Pdf); + // Capture both the PDF URL (for the operator-facing "Credit note" download button) and the + // credit note's Created timestamp (so the back-office invoices UI can show when the credit + // note was actually issued, not just the original invoice date). If multiple credit notes + // exist for one invoice, take the most recent. + var creditNotesByInvoiceId = creditNotes.Data + .GroupBy(cn => cn.InvoiceId) + .ToDictionary(g => g.Key, g => g.OrderByDescending(cn => cn.Created).First()); + + // Build a priceId → SubscriptionPlan lookup once (per Stripe customer sync) so the per-invoice loop is allocation-free. + var planByPriceId = await BuildPlanByPriceIdAsync(cancellationToken); return invoices.Data.Select(invoice => { var paymentIntentId = invoice.Payments?.Data?.FirstOrDefault()?.Payment?.PaymentIntentId; var chargeAmountRefunded = paymentIntentId is not null && refundedAmountByPaymentIntentId.TryGetValue(paymentIntentId, out var refunded) ? refunded : 0L; - var displayAmount = (invoice.Status == "paid" ? invoice.AmountPaid : invoice.Total) / 100m; + var refundedAt = paymentIntentId is not null && latestRefundedAtByPaymentIntentId.TryGetValue(paymentIntentId, out var refundedTimestamp) ? (DateTimeOffset?)refundedTimestamp : null; + var (displayAmount, amountExcludingTax, taxAmount, invoiceTotal, amountFromCredit, clamped) = ComputeInvoiceAmountBreakdown(invoice); + if (clamped) + { + // tax > display is anomalous (Stripe should never produce this) — keep the clamp so the DB + // CHECK does not 500 the webhook, but surface the row through a warning log + telemetry + // event so the back-office drift banner can show the anomaly for operator review. + logger.LogWarning( + "AmountExcludingTax clamped to 0 for Stripe payment {PaymentIntentId} (invoice {InvoiceId}, customer {CustomerId}): display={DisplayAmount}, tax={TaxAmount}", + paymentIntentId ?? "(none)", invoice.Id, stripeCustomerId, displayAmount, taxAmount + ); + telemetryEventsCollector.CollectEvent(new PaymentTransactionAmountExcludingTaxClamped(paymentIntentId ?? invoice.Id, displayAmount, taxAmount, invoice.Currency.ToUpperInvariant())); + } + + var plan = ResolvePlanForInvoice(invoice, planByPriceId); + var creditNote = creditNotesByInvoiceId.GetValueOrDefault(invoice.Id); return new PaymentTransaction( PaymentTransactionId.NewId(), displayAmount, + amountExcludingTax, + taxAmount, invoice.Currency.ToUpperInvariant(), - MapInvoiceStatus(invoice.Status, invoice.AmountPaid, invoice.PostPaymentCreditNotesAmount, chargeAmountRefunded), + MapInvoiceStatus(invoice.Status, invoice.AmountPaid, chargeAmountRefunded), invoice.Created, invoice.Status == "uncollectible" ? "Payment failed." : null, invoice.InvoicePdf, - creditNotesByInvoiceId.GetValueOrDefault(invoice.Id) + creditNote?.Pdf, + plan, + refundedAt, + invoiceTotal, + amountFromCredit, + creditNote?.Created ); } ).ToArray(); @@ -1023,6 +1100,189 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st } } + /// + /// Reverse of : resolves a Stripe priceId (the only field reliably present on + /// historical invoice line items) back to its via the cached price catalog. + /// Unknown / archived priceIds resolve to null rather than throwing — historical data should not crash + /// the sync just because a price was retired. + /// + public async Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) + { + return await BuildPlanByPriceIdAsync(cancellationToken); + } + + /// + /// Lists Stripe events for a customer via Stripe's events.list API. Authoritative source for the + /// hot path: every webhook-driven sync passes the subscription's last-synced anchor as + /// so Stripe returns the full set of events produced since the + /// last successful sync, in chronological-ascending order. Stripe retains events for only 30 days + /// (see https://docs.stripe.com/api/events); the background sweeper guarantees the anchor never + /// falls outside that window. + /// + public async Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, DateTimeOffset? sinceCreated, CancellationToken cancellationToken) + { + var collected = new List(); + try + { + var service = new EventService(); + var options = new EventListOptions + { + Limit = 100, + Types = [.. ReplayEventTypes] + }; + if (sinceCreated is { } anchor) + { + options.Created = new DateRangeOptions { GreaterThanOrEqual = anchor.UtcDateTime }; + } + + await foreach (var stripeEvent in service.ListAutoPagingAsync(options, GetRequestOptions(), cancellationToken)) + { + if (TryExtractCustomerId(stripeEvent) != stripeCustomerId.Value) continue; + collected.Add(new StripeReplayEvent(stripeEvent.Id, stripeEvent.Type, stripeEvent.Created, stripeEvent.ToJson(), stripeEvent.ApiVersion)); + } + + // Stripe returns events newest-first; reorder ascending so callers can advance the anchor + // and emit BillingEvents in the order Stripe observed them. + return new StripeEventsListResult([.. collected.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)], true); + } + catch (StripeException ex) + { + // events.list pagination failed partway through; callers MUST keep the existing anchor so the + // next sync re-pulls the events that were never observed. Returning whatever pages we did + // collect is safe because BillingEvent emission is idempotent on stripe_event_id. + logger.LogError(ex, "Failed to list Stripe events for customer '{StripeCustomerId}'", stripeCustomerId); + return new StripeEventsListResult([.. collected.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)], false); + } + catch (TaskCanceledException ex) + { + logger.LogError(ex, "Timeout listing Stripe events for customer '{StripeCustomerId}'", stripeCustomerId); + return new StripeEventsListResult([.. collected.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)], false); + } + } + + public string? BuildCustomerDashboardUrl(StripeCustomerId stripeCustomerId) + { + if (string.IsNullOrEmpty(_apiKey)) + { + return null; + } + + var modeSegment = _apiKey.StartsWith("sk_test_") ? "/test" : string.Empty; + return $"https://dashboard.stripe.com{modeSegment}/customers/{stripeCustomerId.Value}"; + } + + /// + /// Maps a Stripe default payment method to the domain . Returns null when + /// the input is null or when the payment-method kind is one we do not surface. Card-funded methods carry + /// brand and last4 / expiry directly. Stripe Link is funded by an underlying card, but the pinned Stripe.NET + /// SDK does not expose the backing card on PaymentMethodLink, so we emit a sentinel + /// ("link", "****", 0, 0) and let the UI render only the Link wordmark — never fake last4 or 00/0 expiry. + /// + public static PaymentMethod? MapDefaultPaymentMethod(StripePaymentMethod? defaultPaymentMethod) + { + if (defaultPaymentMethod is null) return null; + if (defaultPaymentMethod.Card is not null) + { + return new PaymentMethod(defaultPaymentMethod.Card.Brand, defaultPaymentMethod.Card.Last4, (int)defaultPaymentMethod.Card.ExpMonth, (int)defaultPaymentMethod.Card.ExpYear); + } + + if (defaultPaymentMethod.Link is not null) + { + return new PaymentMethod("link", "****", 0, 0); + } + + return null; + } + + /// + /// Resolves a Stripe invoice's representative plan via the supplied price-to-plan lookup. Picks the line item + /// with the largest positive amount, which on proration upgrade/downgrade invoices is the line for the new + /// active plan (the negative line credits unused time on the old plan and would otherwise mis-resolve to it). + /// Falls back to the first line item when no positive lines exist. Returns null when the resolved + /// line has no priceId (manual line items, archived prices not in the catalog) — historical data should + /// not crash the sync just because a price was retired. Public to support unit testing of the priceId + /// extraction path against a constructed . + /// + public static SubscriptionPlan? ResolvePlanForInvoice(Invoice invoice, IReadOnlyDictionary planByPriceId) + { + var lines = invoice.Lines?.Data; + var representativeLine = lines?.Where(l => l.Amount > 0).OrderByDescending(l => l.Amount).FirstOrDefault() + ?? lines?.FirstOrDefault(); + var priceId = representativeLine?.Pricing?.PriceDetails?.Price; + return priceId is not null && planByPriceId.TryGetValue(priceId, out var plan) ? plan : null; + } + + /// + /// Computes the display, excluding-tax, and tax components for a Stripe invoice. The excluding-tax value is + /// clamped at zero because Stripe's auto-tax can return a positive total_taxes alongside zero + /// amount_paid / total (e.g., a proration credit fully offsets a new charge), which would + /// otherwise produce a negative excluding-tax that silently subtracts from LTV. Public to support unit testing + /// of the clamp against a constructed . Clamped is true when the raw + /// displayAmount - taxAmount was negative — callers emit a structured warning + telemetry + + /// drift discrepancy so the anomaly is surfaced instead of silently masked. + /// + public static (decimal DisplayAmount, decimal AmountExcludingTax, decimal TaxAmount, decimal InvoiceTotal, decimal AmountFromCredit, bool Clamped) ComputeInvoiceAmountBreakdown(Invoice invoice) + { + // InvoiceTotal is what Stripe billed (gross of VAT). DisplayAmount is what the customer actually + // paid from their card on this invoice. The difference (InvoiceTotal - DisplayAmount) is the + // portion absorbed by their Stripe credit balance — e.g. from a prior credit note. We persist + // all three so LTV counts gross billed and the back-office can show "X DKK paid from card, Y DKK + // from credit". The pre-VAT split (AmountExcludingTax) is derived from the InvoiceTotal so it + // never goes negative even when AmountPaid is zero on a credit-absorbed invoice. + var invoiceTotal = invoice.Total / 100m; + var displayAmount = (invoice.Status == "paid" ? invoice.AmountPaid : invoice.Total) / 100m; + var taxAmount = (invoice.TotalTaxes ?? []).Sum(t => t.Amount) / 100m; + var amountExcludingTax = invoiceTotal - taxAmount; + var amountFromCredit = Math.Max(0m, invoiceTotal - displayAmount); + var clamped = amountExcludingTax < 0m; + return (displayAmount, Math.Max(0m, amountExcludingTax), taxAmount, invoiceTotal, amountFromCredit, clamped); + } + + /// + /// Normalizes a Stripe price's unit_amount to the platform's ex-VAT contract. Stripe encodes + /// amounts in minor units (cents/øre); the value is divided by 100 to produce the major-unit decimal. + /// For tax_behavior == "exclusive" the listed amount is already ex-VAT and is returned as-is. + /// For tax_behavior == "inclusive" we must subtract the VAT component before persisting because + /// MRR is revenue accounting and VAT is collected on behalf of tax authorities, never our revenue. + /// Returns null when the input price or its unit_amount is null. See + /// https://docs.stripe.com/api/prices/object#price_object-tax_behavior. + /// TODO: the Stripe Price object does not carry the active TaxRate for this customer's location, + /// so the inclusive-mode path currently cannot compute the exact VAT to subtract. Configure + /// tax_behavior=exclusive on every active Stripe price (the recommended setting with Automatic + /// Tax) so the listed amount is already ex-VAT and this normalizer is a pass-through. When inclusive + /// prices are required, look up the active TaxRate via the customer's billing address (Stripe Tax) and + /// extend this helper. + /// + public static decimal? NormalizePriceAmountToExcludingTax(StripePrice? price) + { + if (price?.UnitAmount is null) return null; + var listedAmount = price.UnitAmount.Value / 100m; + // TODO: When TaxBehavior == "inclusive" we should subtract the VAT component before persisting, + // but the Stripe Price object does not carry the customer's active TaxRate; extending this + // requires a second Stripe call. The recommended deployment configures every active price with + // tax_behavior=exclusive (paired with Stripe Automatic Tax), so the listed amount is already + // ex-VAT and this path is a pass-through. If you flip a price to inclusive, revisit this. + return listedAmount; + } + + private async Task> BuildPlanByPriceIdAsync(CancellationToken cancellationToken) + { + await EnsurePriceCachePopulatedAsync(cancellationToken); + + if (!memoryCache.TryGetValue(PriceCacheKey, out Dictionary? cached) || cached is null) + { + return new Dictionary(); + } + + var lookup = new Dictionary(cached.Count); + foreach (var (lookupKey, price) in cached) + { + lookup[price.Id] = ParseLookupKey(lookupKey); + } + + return lookup; + } + private async Task GetPriceIdAsync(SubscriptionPlan plan, CancellationToken cancellationToken) { var lookupKey = plan switch @@ -1201,9 +1461,16 @@ private static SubscriptionPlan ParseLookupKey(string lookupKey) }; } - private static PaymentTransactionStatus MapInvoiceStatus(string? status, long amountPaid, long postPaymentCreditNotesAmount, long chargeAmountRefunded) + // Only an actual refund of the underlying charge counts as Refunded — that is the only case + // where money leaves our Stripe balance and Stripe emits charge.refunded. A post-payment + // credit note that credits the customer's Stripe balance does NOT refund the charge + // (charge.amount_refunded stays 0, no charge.refunded event), so we keep the transaction as + // Succeeded; the credited balance is reconciled against future invoices. + // A "void" invoice was never paid (Stripe re-issued it with a credit note) — no money ever + // changed hands, so it maps to Cancelled, not Refunded. + public static PaymentTransactionStatus MapInvoiceStatus(string? status, long amountPaid, long chargeAmountRefunded) { - if (status == "paid" && amountPaid > 0 && (postPaymentCreditNotesAmount >= amountPaid || chargeAmountRefunded >= amountPaid)) + if (status == "paid" && amountPaid > 0 && chargeAmountRefunded >= amountPaid) { return PaymentTransactionStatus.Refunded; } @@ -1213,8 +1480,23 @@ private static PaymentTransactionStatus MapInvoiceStatus(string? status, long am "paid" => PaymentTransactionStatus.Succeeded, "open" => PaymentTransactionStatus.Pending, "uncollectible" => PaymentTransactionStatus.Failed, - "void" => PaymentTransactionStatus.Refunded, + "void" => PaymentTransactionStatus.Cancelled, _ => PaymentTransactionStatus.Pending }; } + + private static string? TryExtractCustomerId(Event stripeEvent) + { + var data = stripeEvent.Data?.Object; + return data switch + { + Customer customer => customer.Id, + StripeSubscription subscription => subscription.CustomerId, + SubscriptionSchedule schedule => schedule.CustomerId, + Invoice invoice => invoice.CustomerId, + Charge charge => charge.CustomerId, + StripePaymentMethod paymentMethod => paymentMethod.CustomerId, + _ => null + }; + } } diff --git a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs index f93601fbd4..1917a8ca10 100644 --- a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs @@ -64,6 +64,17 @@ public Task GetPriceCatalogAsync(CancellationToken cancellat return Task.FromResult([]); } + public Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) + { + logger.LogWarning("Stripe is not configured. Cannot get plan-by-priceId lookup"); + return Task.FromResult>(new Dictionary()); + } + + public Task GetPlatformCurrencyAsync(CancellationToken cancellationToken) + { + return Task.FromResult(null); + } + public StripeWebhookEventResult? VerifyWebhookSignature(string payload, string signatureHeader) { logger.LogWarning("Stripe is not configured. Cannot verify webhook signature"); @@ -147,4 +158,17 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu logger.LogWarning("Stripe is not configured. Cannot sync payment transactions for customer '{CustomerId}'", stripeCustomerId); return Task.FromResult(null); } + + public Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, DateTimeOffset? sinceCreated, CancellationToken cancellationToken) + { + logger.LogWarning("Stripe is not configured. Cannot list events for customer '{CustomerId}'", stripeCustomerId); + // No Stripe configured is not a partial-failure; report the empty list as a clean success so the + // anchor in the (also-unused) subscription row does not stay pinned forever. + return Task.FromResult(new StripeEventsListResult([], true)); + } + + public string? BuildCustomerDashboardUrl(StripeCustomerId stripeCustomerId) + { + return null; + } } diff --git a/application/account/Tests/Account.Tests.csproj b/application/account/Tests/Account.Tests.csproj index 9bec0d59fc..cf179dd22f 100644 --- a/application/account/Tests/Account.Tests.csproj +++ b/application/account/Tests/Account.Tests.csproj @@ -13,6 +13,9 @@ + + workers + diff --git a/application/account/Tests/ArchitectureTests/StripeEventPayloadAccessTests.cs b/application/account/Tests/ArchitectureTests/StripeEventPayloadAccessTests.cs new file mode 100644 index 0000000000..c69406386f --- /dev/null +++ b/application/account/Tests/ArchitectureTests/StripeEventPayloadAccessTests.cs @@ -0,0 +1,78 @@ +using System.Runtime.CompilerServices; +using System.Text.RegularExpressions; +using FluentAssertions; +using Xunit; + +namespace Account.Tests.ArchitectureTests; + +/// +/// Enforces the rule that the webhook hot path makes zero reads of stripe_events.payload. The +/// durable archive column is consulted only by the admin disaster-recovery handler +/// ReplayArchivedTenantStripeEvents. Any new caller that legitimately needs to read the archive +/// must be added to the allowlist with a justification comment. +/// +public sealed class StripeEventPayloadAccessTests +{ + // Only these files may dereference `.Payload` on a persisted StripeEvent value. The aggregate itself + // owns the column declaration; the EF mapping configures it; the write path stores it; and the + // disaster-recovery handler is the one legitimate reader. + private static readonly string[] AllowedFiles = + [ + Path.Combine("Subscriptions", "Domain", "StripeEvent.cs"), + Path.Combine("Subscriptions", "Domain", "StripeEventConfiguration.cs"), + Path.Combine("Subscriptions", "Commands", "AcknowledgeStripeWebhook.cs"), + Path.Combine("Tenants", "BackOffice", "Commands", "ReplayArchivedTenantStripeEvents.cs") + ]; + + // Match `.Payload` reads on a persisted StripeEvent symbol. The conventional variable names for + // EF-materialized rows in this codebase are `stripeEvent`/`pendingEvent`/`pending`/`archived`/ + // `persisted`/`webhookEvent`/`recoveredEvent`. Explicitly excludes `StripeReplayEvent` reads (an + // in-memory record carrying live events.list payloads) by checking the surrounding token does not + // form `StripeReplayEvent.Payload`. We exclude the singular `stripeEvent` token because the + // classifier hot loop uses it on `StripeReplayEvent`-typed values. + private static readonly Regex PayloadAccessPattern = new( + @"\b(?:StripeEvent|pendingEvent|pending|archived|persisted|webhookEvent|recoveredEvent)\.Payload\b", + RegexOptions.Compiled + ); + + [Fact] + public void ProductionCode_OnlyDisasterRecoveryHandler_MayReadStripeEventPayload() + { + // Arrange + var coreFeaturesRoot = GetCoreFeaturesRoot(); + var allowedAbsolutePaths = AllowedFiles + .Select(relative => Path.Combine(coreFeaturesRoot, relative)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + // Act + var violations = new List(); + foreach (var file in Directory.EnumerateFiles(coreFeaturesRoot, "*.cs", SearchOption.AllDirectories)) + { + if (allowedAbsolutePaths.Contains(file)) continue; + + var content = File.ReadAllText(file); + if (PayloadAccessPattern.IsMatch(content)) + { + violations.Add(Path.GetRelativePath(coreFeaturesRoot, file)); + } + } + + // Assert + violations.Should().BeEmpty( + "the webhook hot path makes zero reads of stripe_events.payload; only the disaster-recovery " + + "handler ReplayArchivedTenantStripeEvents may consult the durable archive. " + + $"Violations: {string.Join(", ", violations)}. " + + "If a new caller legitimately needs to read the archive, add it to AllowedFiles with a justification comment." + ); + } + + private static string GetCoreFeaturesRoot([CallerFilePath] string callerFilePath = "") + { + // Resolve via the test file's own path so the test always reads source from the same worktree it + // was compiled in. The test file lives at + // application/account/Tests/ArchitectureTests/StripeEventPayloadAccessTests.cs; Core/Features + // sits four directories up and two down at application/account/Core/Features. + var testDirectory = Path.GetDirectoryName(callerFilePath)!; + return Path.GetFullPath(Path.Combine(testDirectory, "..", "..", "Core", "Features")); + } +} diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index 05a60d9968..b3e46989e1 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -353,7 +353,8 @@ public async Task SwitchTenant_WhenSessionAlreadyRevoked_ShouldReturnUnauthorize await response1.ShouldBeSuccessfulPostRequest(hasLocation: false); TelemetryEventsCollectorSpy.Reset(); - // Act - Attempt to switch again with the same (now revoked) session + // Attempt to switch again with the same (now revoked) session + // Act var response2 = await AuthenticatedMemberHttpClient.PostAsJsonAsync( "/api/account/authentication/switch-tenant", new SwitchTenantCommand(DatabaseSeeder.Tenant1.Id) ); @@ -383,7 +384,10 @@ private void InsertSubscription(TenantId tenantId) ("cancellation_feedback", null), ("payment_transactions", "[]"), ("payment_method", null), - ("billing_info", null) + ("billing_info", null), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") ] ); } diff --git a/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs new file mode 100644 index 0000000000..e98078621f --- /dev/null +++ b/application/account/Tests/BackOffice/AcknowledgeBillingDriftTests.cs @@ -0,0 +1,100 @@ +using System.Net; +using System.Text.Json; +using Account.Features.Subscriptions.Domain; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.BackOffice; + +public sealed class AcknowledgeBillingDriftTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task AcknowledgeBillingDrift_WhenSubscriptionHasDrift_ShouldClearDrift() + { + // Arrange + var discrepancies = new[] + { + new DriftDiscrepancy(DriftDiscrepancyKind.MissingEvent, "Missing billing event for payment.", DriftSeverity.Warning) + }; + Connection.Update("subscriptions", "tenant_id", DatabaseSeeder.Tenant1.Id.Value, [ + ("has_drift_detected", true), + ("drift_checked_at", DateTimeOffset.UtcNow.AddMinutes(-5)), + ("drift_discrepancies", JsonSerializer.Serialize(discrepancies)) + ] + ); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/drift/acknowledge", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var hasDriftDetected = Connection.ExecuteScalar( + "SELECT has_drift_detected FROM subscriptions WHERE tenant_id = @tenantId", [new { tenantId = DatabaseSeeder.Tenant1.Id.Value }] + ); + hasDriftDetected.Should().Be(0); + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "TenantBillingDriftAcknowledged"); + } + + [Fact] + public async Task AcknowledgeBillingDrift_WhenSubscriptionHasNoDrift_ShouldReturnBadRequest() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/drift/acknowledge", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await response.Content.ReadAsStringAsync(); + body.Should().Contain("Subscription has no drift to acknowledge."); + } + + [Fact] + public async Task AcknowledgeBillingDrift_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); + using var client = CreateBackOfficeClientForIdentity(identity); + var unknownTenantId = TenantId.NewId(); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{unknownTenantId}/drift/acknowledge", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task AcknowledgeBillingDrift_WhenNonAdminBackOfficeUser_ShouldReturnForbidden() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/drift/acknowledge", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + + [Fact] + public async Task AcknowledgeBillingDrift_WhenUnauthenticated_ShouldReturnUnauthorized() + { + // Arrange + using var client = CreateBackOfficeClient(); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/drift/acknowledge", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} diff --git a/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs new file mode 100644 index 0000000000..b48f25cd42 --- /dev/null +++ b/application/account/Tests/BackOffice/BackOfficeBlobProxyTests.cs @@ -0,0 +1,39 @@ +using System.Net; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using SharedKernel.Integrations.BlobStorage; +using Xunit; + +namespace Account.Tests.BackOffice; + +public sealed class BackOfficeBlobProxyTests : BackOfficeEndpointBaseTest +{ + private readonly IBlobStorageClient _blobStorageClient = Substitute.For(); + + [Fact] + public async Task BackOfficeBlobProxy_WhenServingBlob_ShouldSetNoSniffHeader() + { + // Arrange + var blobBytes = "fake-image-bytes"u8.ToArray(); + _blobStorageClient + .DownloadAsync("logos", "tenant/logo/HASH.png", Arg.Any()) + .Returns((new MemoryStream(blobBytes), "image/png")); + + using var client = CreateBackOfficeClient(); + + // Act + var response = await client.GetAsync("/logos/tenant/logo/HASH.png"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + response.Headers.GetValues("X-Content-Type-Options").Should().Contain("nosniff"); + } + + protected override void ConfigureAdditionalTestServices(IServiceCollection services) + { + services.RemoveAll(typeof(IBlobStorageClient)); + services.AddKeyedSingleton("account-storage", _blobStorageClient); + } +} diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index c322747779..4f9af40d4b 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -1,4 +1,5 @@ using Account.Database; +using Account.Integrations.Stripe; using Bogus; using JetBrains.Annotations; using Microsoft.ApplicationInsights; @@ -30,6 +31,8 @@ public abstract class BackOfficeEndpointBaseTest : IDisposable protected const string BackOfficeHost = "back-office.test.localhost"; private const string TestPublicUrl = "https://localhost"; + + private static readonly Lock SpaShellLock = new(); protected readonly Faker Faker = new(); private readonly WebApplicationFactory _webApplicationFactory; @@ -46,6 +49,8 @@ protected BackOfficeEndpointBaseTest() EnsureBackOfficeSpaShell(); + TelemetryEventsCollectorSpy = new TelemetryEventsCollectorSpy(new TelemetryEventsCollector()); + Connection = new SqliteConnection($"Data Source=TestDb_{Guid.NewGuid():N};Mode=Memory;Cache=Shared"); Connection.Open(); @@ -76,13 +81,15 @@ protected BackOfficeEndpointBaseTest() services.Remove(services.Single(d => d.ServiceType == typeof(IDbContextOptionsConfiguration))); services.AddDbContext(options => options.UseSqlite(Connection).UseSnakeCaseNamingConvention()); - services.AddScoped(_ => new TelemetryEventsCollectorSpy(new TelemetryEventsCollector())); + services.AddScoped(_ => TelemetryEventsCollectorSpy); services.Remove(services.Single(d => d.ServiceType == typeof(IEmailClient))); services.AddTransient(_ => Substitute.For()); services.AddSingleton(new TelemetryClient(new TelemetryConfiguration { TelemetryChannel = Substitute.For() })); services.AddScoped(); + + ConfigureAdditionalTestServices(services); } ); } @@ -90,25 +97,36 @@ protected BackOfficeEndpointBaseTest() using var scope = _webApplicationFactory.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + DatabaseSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); } protected SqliteConnection Connection { get; } + protected DatabaseSeeder DatabaseSeeder { get; } + + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } + + protected MockStripeState StripeState => _webApplicationFactory.Services.GetRequiredService(); + public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } + protected virtual void ConfigureAdditionalTestServices(IServiceCollection services) + { + } + // SinglePageAppConfiguration.GetHtmlTemplate() reads BackOffice/dist/index.html on every SPA-shell // request. Locally that file is generated by `rsbuild dev`; in CI the test step runs before any frontend // build, so the file is missing and the fallback returns 500. The dist's index.html is just the public // template plus rsbuild's bundle