From 5e87c6ef62448911a6b87992dfe95eabf6087bd1 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 May 2026 13:57:57 +0200 Subject: [PATCH 001/158] Add back-office tenant overview API with queries, repositories, and tests --- .../BackOfficeEndpoints.cs | 2 +- .../Api/BackOffice/TenantsEndpoints.cs | 50 ++++ .../Domain/SubscriptionRepository.cs | 15 ++ .../BackOffice/Queries/GetTenantActivity.cs | 33 +++ .../BackOffice/Queries/GetTenantDetail.cs | 82 ++++++ .../Queries/GetTenantPaymentHistory.cs | 70 +++++ .../BackOffice/Queries/GetTenantUserCounts.cs | 32 +++ .../BackOffice/Queries/GetTenantUsers.cs | 92 +++++++ .../Tenants/BackOffice/Queries/GetTenants.cs | 125 +++++++++ .../Tenants/Domain/TenantRepository.cs | 23 ++ .../Features/Users/Domain/UserRepository.cs | 96 +++++++ .../BackOffice/BackOfficeEndpointBaseTest.cs | 3 + .../BackOffice/GetTenantActivityTests.cs | 46 ++++ .../BackOffice/GetTenantDetailTests.cs | 97 +++++++ .../GetTenantPaymentHistoryTests.cs | 82 ++++++ .../BackOffice/GetTenantUserCountsTests.cs | 77 ++++++ .../Tenants/BackOffice/GetTenantUsersTests.cs | 131 +++++++++ .../Tenants/BackOffice/GetTenantsTests.cs | 255 ++++++++++++++++++ 18 files changed, 1310 insertions(+), 1 deletion(-) rename application/account/Api/{Endpoints => BackOffice}/BackOfficeEndpoints.cs (97%) create mode 100644 application/account/Api/BackOffice/TenantsEndpoints.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantActivity.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantActivityTests.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs create mode 100644 application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs diff --git a/application/account/Api/Endpoints/BackOfficeEndpoints.cs b/application/account/Api/BackOffice/BackOfficeEndpoints.cs similarity index 97% rename from application/account/Api/Endpoints/BackOfficeEndpoints.cs rename to application/account/Api/BackOffice/BackOfficeEndpoints.cs index 9ac59aebbe..8914654af5 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 { diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs new file mode 100644 index 0000000000..057a9f9d6c --- /dev/null +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -0,0 +1,50 @@ +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(); + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs index 27ab0e3a00..a3884e6cf6 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs @@ -22,6 +22,12 @@ public interface ISubscriptionRepository : ICrudRepository 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); } internal sealed class SubscriptionRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -56,4 +62,13 @@ public async Task GetCurrentAsync(CancellationToken cancellationTo return DbSet.Local.SingleOrDefault(s => s.TenantId == tenantId) ?? await DbSet.IgnoreQueryFilters().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().Where(s => tenantIds.AsEnumerable().Contains(s.TenantId)).ToArrayAsync(cancellationToken); + } } 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..06db0e94be --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -0,0 +1,82 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +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, + bool CancelAtPeriodEnd, + decimal? MonthlyRecurringRevenue, + string? Currency, + DateTimeOffset? RenewalDate, + BillingAddressResponse? BillingAddress, + decimal? LifetimeValue, + TenantState State, + SuspensionReason? SuspensionReason, + DateTimeOffset? SuspendedAt, + string? LogoUrl, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt +); + +[PublicAPI] +public sealed record BillingAddressResponse( + string? Line1, + string? Line2, + string? PostalCode, + string? City, + string? State, + string? Country +); + +public sealed class GetTenantDetailHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository) + : 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); + + var lifetimeValue = subscription?.PaymentTransactions + .Where(t => t.Status == PaymentTransactionStatus.Succeeded) + .Sum(t => t.Amount); + + var billingAddress = subscription?.BillingInfo?.Address is { } address + ? new BillingAddressResponse(address.Line1, address.Line2, address.PostalCode, address.City, address.State, address.Country) + : null; + + return new TenantDetailResponse( + tenant.Id, + tenant.Name, + tenant.Plan, + subscription?.ScheduledPlan, + subscription?.CancelAtPeriodEnd ?? false, + subscription?.CurrentPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + billingAddress, + lifetimeValue, + tenant.State, + tenant.SuspensionReason, + tenant.SuspendedAt, + tenant.Logo.Url, + tenant.CreatedAt, + tenant.ModifiedAt + ); + } +} 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..4dced4a4f0 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs @@ -0,0 +1,70 @@ +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, + decimal Amount, + string Currency, + PaymentTransactionStatus Status, + DateTimeOffset Date, + string? FailureReason, + string? InvoiceUrl, + string? CreditNoteUrl +); + +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 transactions = subscription?.PaymentTransactions.OrderByDescending(t => t.Date).ToArray() ?? []; + + var totalCount = transactions.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 = transactions + .Skip(query.PageOffset * query.PageSize) + .Take(query.PageSize) + .Select(t => new TenantPaymentTransaction(t.Id, t.Amount, t.Currency, t.Status, t.Date, t.FailureReason, t.InvoiceUrl, t.CreditNoteUrl)) + .ToArray(); + + return new TenantPaymentHistoryResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } +} 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..a9a7ccdeec --- /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); + +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) = await userRepository.GetUserCountsForTenantUnfilteredAsync(tenant.Id, activeSince, cancellationToken); + return new TenantUserCountsResponse(totalUsers, activeUsers); + } +} 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..ec95ea185d --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs @@ -0,0 +1,92 @@ +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? Role = 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(); +} + +[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.Role, + 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..4074434592 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -0,0 +1,125 @@ +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.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantsQuery( + string? Search = null, + SubscriptionPlan? Plan = null, + SortableTenantProperties OrderBy = SortableTenantProperties.Name, + SortOrder SortOrder = SortOrder.Ascending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); +} + +[PublicAPI] +public sealed record TenantsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantSummary[] Tenants); + +[PublicAPI] +public sealed record TenantSummary( + TenantId Id, + string Name, + SubscriptionPlan Plan, + decimal? MonthlyRecurringRevenue, + string? Currency, + DateTimeOffset? RenewalDate, + PlannedSubscriptionChange? PlannedChange, + string? Country, + DateTimeOffset CreatedAt +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PlannedSubscriptionChange +{ + Cancellation, + ScheduledPlanChange +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableTenantProperties +{ + Name, + MonthlyRecurringRevenue, + CreatedAt +} + +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.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) + : IRequestHandler> +{ + public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.SearchAllTenantsAsync(query.Search, query.Plan, 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); + + var summaries = tenants.Select(tenant => MapTenantSummary(tenant, subscriptionsByTenantId.GetValueOrDefault(tenant.Id))).ToArray(); + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (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.CreatedAt, SortOrder.Ascending) => summaries.OrderBy(s => s.CreatedAt), + (SortableTenantProperties.CreatedAt, _) => summaries.OrderByDescending(s => s.CreatedAt), + (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 TenantSummary MapTenantSummary(Tenant tenant, Subscription? subscription) + { + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + + return new TenantSummary( + tenant.Id, + tenant.Name, + tenant.Plan, + subscription?.CurrentPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + plannedChange, + subscription?.BillingInfo?.Address?.Country, + tenant.CreatedAt + ); + } +} diff --git a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 402cd52e51..f22da808ff 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -1,4 +1,5 @@ using Account.Database; +using Account.Features.Subscriptions.Domain; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; using SharedKernel.ExecutionContext; @@ -19,6 +20,8 @@ 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? plan, CancellationToken cancellationToken); } internal sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -43,4 +46,24 @@ public async Task GetByIdsAsync(TenantId[] ids, CancellationToken canc { return await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(t => t.Id == id, cancellationToken); } + + public async Task SearchAllTenantsAsync(string? search, SubscriptionPlan? plan, 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 (plan is not null) + { + tenants = tenants.Where(t => t.Plan == plan); + } + + return await tenants.ToArrayAsync(cancellationToken); + } } diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 0f8b1fd138..4f5a8f4299 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -44,6 +44,25 @@ CancellationToken cancellationToken Task GetTenantUsers(CancellationToken cancellationToken); Task GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken); + + /// + /// Returns total and 30-day active 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)> 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? role, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ); } internal sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) @@ -260,6 +279,83 @@ public async Task GetUsersByEmailUnfilteredAsync(string email, Cancellat .ToArrayAsync(cancellationToken); } + /// + /// Returns total and 30-day active 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)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken) + { + // SQLite EF cannot translate DateTimeOffset comparisons (text-stored); test path materializes LastSeenAt and counts in memory, bounded by tenant size. + if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") + { + var lastSeen = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.TenantId == tenantId) + .Select(u => u.LastSeenAt) + .ToListAsync(cancellationToken); + return (lastSeen.Count, lastSeen.Count(t => t.HasValue && t.Value >= activeSince)); + } + + 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.LastSeenAt >= activeSince) }) + .SingleOrDefaultAsync(cancellationToken); + + return (counts?.Total ?? 0, counts?.Active ?? 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? role, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ) + { + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(u => u.TenantId == tenantId); + + if (role is not null) + { + users = users.Where(u => u.Role == 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 - 1) / pageSize + 1; + return (result, totalItems, totalPages); + } + [UsedImplicitly] private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers); + + [UsedImplicitly] + private sealed record TenantUserCountResult(int TotalUsers, int ActiveUsers); } diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index c322747779..9655df717c 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -90,12 +90,15 @@ 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; } + public void Dispose() { Dispose(true); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantActivityTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantActivityTests.cs new file mode 100644 index 0000000000..f5a3ec5612 --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantActivityTests.cs @@ -0,0 +1,46 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantActivityTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantActivity_WhenTenantExists_ShouldReturnEmptyEventsList() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/activity"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Events.Should().BeEmpty(); + } + + [Fact] + public async Task GetTenantActivity_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var tenantId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/activity"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs new file mode 100644 index 0000000000..1290c34e0b --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -0,0 +1,97 @@ +using System.Collections.Immutable; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantDetailTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() + { + // Arrange + var tenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddDays(-5)), + ("modified_at", null), + ("name", "Acme Corp"), + ("state", nameof(TenantState.Active)), + ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), + ("plan", nameof(SubscriptionPlan.Premium)) + ] + ); + + var billingInfoJson = JsonSerializer.Serialize(new BillingInfo("Acme Corp", new BillingAddress("123 Main St", null, "12345", "Springfield", "IL", "US"), null, null)); + var transactions = ImmutableArray.Create( + new PaymentTransaction(PaymentTransactionId.NewId(), 199.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, null, null) + ); + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", SubscriptionId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-5)), + ("modified_at", null), + ("plan", nameof(SubscriptionPlan.Premium)), + ("scheduled_plan", null), + ("stripe_customer_id", "cus_test"), + ("stripe_subscription_id", "sub_test"), + ("current_price_amount", 199.00), + ("current_price_currency", "USD"), + ("current_period_end", DateTimeOffset.UtcNow.AddDays(25)), + ("cancel_at_period_end", false), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())), + ("payment_method", null), + ("billing_info", billingInfoJson) + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Id.Should().Be(tenantId); + payload.Name.Should().Be("Acme Corp"); + payload.Plan.Should().Be(SubscriptionPlan.Premium); + payload.MonthlyRecurringRevenue.Should().Be(199.00m); + payload.Currency.Should().Be("USD"); + payload.LifetimeValue.Should().Be(199.00m); + payload.BillingAddress.Should().NotBeNull(); + payload.BillingAddress.Country.Should().Be("US"); + payload.BillingAddress.City.Should().Be("Springfield"); + payload.LogoUrl.Should().Be("https://example.com/logo.png"); + } + + [Fact] + public async Task GetTenantDetail_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var tenantId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs new file mode 100644 index 0000000000..1661f39417 --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantPaymentHistoryTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantPaymentHistory_WhenSubscriptionHasTransactions_ShouldReturnPagedTransactions() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + var transactions = ImmutableArray.Create( + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, "https://stripe.test/inv1", null), + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-02-01T00:00:00Z"), null, "https://stripe.test/inv2", null), + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Failed, DateTimeOffset.Parse("2025-03-01T00:00:00Z"), "Card declined.", null, null) + ); + Connection.Update("subscriptions", "tenant_id", tenant.Id.Value, [ + ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())) + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/payment-history?pageSize=2"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(3); + payload.Transactions.Should().HaveCount(2); + payload.Transactions[0].Date.Should().BeAfter(payload.Transactions[1].Date); + payload.Transactions[0].Status.Should().Be(PaymentTransactionStatus.Failed); + } + + [Fact] + public async Task GetTenantPaymentHistory_WhenSubscriptionHasNoTransactions_ShouldReturnEmpty() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/payment-history"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(0); + payload.Transactions.Should().BeEmpty(); + } + + [Fact] + public async Task GetTenantPaymentHistory_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var tenantId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/payment-history"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs new file mode 100644 index 0000000000..b5ad39bdbd --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs @@ -0,0 +1,77 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Users.Domain; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantUserCountsTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantUserCounts_WhenCalled_ShouldReturnTotalAndActiveCounts() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + SeedUser(tenant.Id, "active1@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-1)); + SeedUser(tenant.Id, "active2@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-15)); + SeedUser(tenant.Id, "inactive@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-60)); + SeedUser(tenant.Id, "neverseen@tenant-1.com", null); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/user-counts"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // DatabaseSeeder seeds Tenant1 with two users (Owner, Member) plus our four; only the two seeded users have last_seen_at = null and the two recent ones above are active. + payload.TotalUsers.Should().Be(6); + payload.ActiveUsers.Should().Be(2); + } + + [Fact] + public async Task GetTenantUserCounts_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var tenantId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/user-counts"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenAt) + { + Connection.Insert("users", [ + ("tenant_id", tenantId.Value), + ("id", UserId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("last_seen_at", (object?)lastSeenAt ?? DBNull.Value), + ("email", email), + ("external_identities", "[]"), + ("email_confirmed", true), + ("first_name", null), + ("last_name", null), + ("title", null), + ("role", nameof(UserRole.Member)), + ("locale", "en-US"), + ("avatar", JsonSerializer.Serialize(new Avatar())) + ] + ); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs new file mode 100644 index 0000000000..b2c899ba86 --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUsersTests.cs @@ -0,0 +1,131 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Users.Domain; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantUsersTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetTenantUsers_WhenCalled_ShouldReturnUsersForThatTenantOnly() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + SeedUser(tenant.Id, "alice@tenant-1.com", "Alice", "Anders", UserRole.Owner); + SeedUser(tenant.Id, "bob@tenant-1.com", "Bob", "Bear", UserRole.Member); + // Different tenant - should not be returned + var otherTenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", otherTenantId.Value), + ("created_at", DateTimeOffset.UtcNow), + ("modified_at", null), + ("name", "Other"), + ("state", "Active"), + ("plan", "Basis"), + ("logo", """{"Url":null,"Version":0}""") + ] + ); + SeedUser(otherTenantId, "outsider@other.com", "Outsider", null, UserRole.Member); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/users"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // Tenant1 is seeded with Owner + Member from DatabaseSeeder, plus alice and bob. + payload.TotalCount.Should().Be(4); + payload.Users.Should().NotContain(u => u.Email.EndsWith("@other.com")); + } + + [Fact] + public async Task GetTenantUsers_WhenSearching_ShouldReturnMatchingUsers() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + SeedUser(tenant.Id, "charlie@tenant-1.com", "Charlie", "Carter", UserRole.Member); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/users?search=charlie"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(1); + payload.Users.Single().Email.Should().Be("charlie@tenant-1.com"); + } + + [Fact] + public async Task GetTenantUsers_WhenFilteringByOwnerRole_ShouldReturnOnlyOwners() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + SeedUser(tenant.Id, "owner2@tenant-1.com", "Owen", "Two", UserRole.Owner); + SeedUser(tenant.Id, "member1@tenant-1-extra.com", "Mike", "One", UserRole.Member); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/users?role=Owner"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // DatabaseSeeder.Tenant1Owner plus owner2 above. + payload.TotalCount.Should().Be(2); + payload.Users.Should().OnlyContain(u => u.Role == UserRole.Owner); + } + + [Fact] + public async Task GetTenantUsers_WhenTenantNotFound_ShouldReturnNotFound() + { + // Arrange + var tenantId = TenantId.NewId(); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}/users"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private void SeedUser(TenantId tenantId, string email, string? firstName, string? lastName, UserRole role) + { + Connection.Insert("users", [ + ("tenant_id", tenantId.Value), + ("id", UserId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("email", email), + ("external_identities", "[]"), + ("email_confirmed", true), + ("first_name", firstName), + ("last_name", lastName), + ("title", null), + ("role", role.ToString()), + ("locale", "en-US"), + ("avatar", JsonSerializer.Serialize(new Avatar())) + ] + ); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs new file mode 100644 index 0000000000..076db04454 --- /dev/null +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -0,0 +1,255 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Tests.BackOffice; +using FluentAssertions; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.Tenants.BackOffice; + +public sealed class GetTenantsTests : BackOfficeEndpointBaseTest +{ + private readonly TenantId _tenantA; + private readonly TenantId _tenantB; + private readonly TenantId _tenantC; + + public GetTenantsTests() + { + _tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + _tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + _tenantC = SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); + } + + [Fact] + public async Task GetTenants_WhenCalled_ShouldReturnAllTenantsWithSummaryFields() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // DatabaseSeeder.Tenant1 is also returned alongside the three tenants seeded by this test class. + payload.TotalCount.Should().Be(4); + payload.Tenants.Should().HaveCount(4); + + var beta = payload.Tenants.Single(t => t.Id == _tenantB); + beta.Name.Should().Be("Beta Industries"); + beta.Plan.Should().Be(SubscriptionPlan.Premium); + beta.MonthlyRecurringRevenue.Should().Be(199.00m); + beta.Currency.Should().Be("EUR"); + beta.Country.Should().Be("DE"); + beta.PlannedChange.Should().Be(PlannedSubscriptionChange.Cancellation); + + var cyrus = payload.Tenants.Single(t => t.Id == _tenantC); + cyrus.PlannedChange.Should().Be(PlannedSubscriptionChange.ScheduledPlanChange); + cyrus.MonthlyRecurringRevenue.Should().BeNull(); + } + + [Fact] + public async Task GetTenants_WhenSearchingByName_ShouldReturnMatchingTenants() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?search=acme"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(1); + payload.Tenants.Single().Id.Should().Be(_tenantA); + } + + [Fact] + public async Task GetTenants_WhenSearchingByExactId_ShouldReturnMatchingTenant() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants?search={_tenantA.Value}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(1); + payload.Tenants.Single().Id.Should().Be(_tenantA); + } + + [Fact] + public async Task GetTenants_WhenFilteringByPlan_ShouldReturnOnlyMatchingPlan() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?plan=Premium"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.TotalCount.Should().Be(1); + payload.Tenants.Single().Id.Should().Be(_tenantB); + } + + [Fact] + public async Task GetTenants_WhenSortingByMonthlyRecurringRevenueDescending_ShouldReturnHighestFirst() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?orderBy=MonthlyRecurringRevenue&sortOrder=Descending"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Tenants.Select(t => t.Id).Should().ContainInOrder(_tenantB, _tenantA, _tenantC); + } + + [Fact] + public async Task GetTenants_WhenSortingByCreatedAtAscending_ShouldReturnOldestFirst() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?orderBy=CreatedAt&sortOrder=Ascending"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Tenants.Select(t => t.Id).Should().ContainInOrder(_tenantA, _tenantB, _tenantC); + } + + [Fact] + public async Task GetTenants_WhenPagingBeyondAvailable_ShouldReturnBadRequest() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?pageOffset=5&pageSize=25"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task GetTenants_WhenPagingWithSize_ShouldReturnPagedSlice() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/tenants?pageSize=2&orderBy=Name&sortOrder=Ascending"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + // DatabaseSeeder.Tenant1 is also returned alongside the three tenants seeded by this test class. + payload.TotalCount.Should().Be(4); + payload.TotalPages.Should().Be(2); + payload.Tenants.Should().HaveCount(2); + } + + [Fact] + public async Task GetTenants_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() + { + // Arrange + using var client = CreateBackOfficeClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await client.GetAsync("/api/back-office/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + [Fact] + public async Task GetTenants_WhenCalledViaWrongHost_ShouldReturnNotFound() + { + // Arrange + using var client = CreateClientForHost("app.test.localhost"); + client.DefaultRequestHeaders.Add(BackOfficeIdentityDefaults.PrincipalNameHeader, "Some User"); + + // Act + var response = await client.GetAsync("/api/back-office/tenants"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, string? currency, string? country, bool cancelAtPeriodEnd, SubscriptionPlan? scheduledPlan, int createdMinutesAgo) + { + var tenantId = TenantId.NewId(); + + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddMinutes(-createdMinutesAgo)), + ("modified_at", null), + ("name", name), + ("state", nameof(TenantState.Active)), + ("plan", plan.ToString()), + ("logo", """{"Url":null,"Version":0}""") + ] + ); + + var billingInfoJson = country is null + ? null + : JsonSerializer.Serialize(new BillingInfo(name, new BillingAddress(null, null, null, null, null, country), null, null)); + + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", SubscriptionId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddMinutes(-createdMinutesAgo)), + ("modified_at", null), + ("plan", plan.ToString()), + ("scheduled_plan", scheduledPlan?.ToString()), + ("stripe_customer_id", mrr is null ? null : "cus_test"), + ("stripe_subscription_id", mrr is null ? null : "sub_test"), + ("current_price_amount", (object?)mrr ?? DBNull.Value), + ("current_price_currency", currency), + ("current_period_end", mrr is null ? null : DateTimeOffset.UtcNow.AddDays(30)), + ("cancel_at_period_end", cancelAtPeriodEnd), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", "[]"), + ("payment_method", null), + ("billing_info", billingInfoJson) + ] + ); + + return tenantId; + } +} From ec5cb856bbe50858f056c59380ba2005fcbb9166 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 May 2026 14:40:22 +0200 Subject: [PATCH 002/158] Add back-office accounts list, side pane, and detail pages --- .../BackOffice/routes/accounts/$tenantId.tsx | 93 +++++++ .../-components/AccountBillingTab.tsx | 163 +++++++++++++ .../-components/AccountDetailHeader.tsx | 132 ++++++++++ .../-components/AccountOverviewTab.tsx | 146 +++++++++++ .../-components/AccountPaymentRow.tsx | 102 ++++++++ .../accounts/-components/AccountSidePane.tsx | 170 +++++++++++++ .../accounts/-components/AccountUserRow.tsx | 51 ++++ .../accounts/-components/AccountUsersTab.tsx | 197 +++++++++++++++ .../accounts/-components/AccountsTable.tsx | 189 +++++++++++++++ .../accounts/-components/AccountsTableRow.tsx | 93 +++++++ .../accounts/-components/AccountsToolbar.tsx | 94 ++++++++ .../accounts/-components/SidePaneUserList.tsx | 56 +++++ .../-components/SortableTableHead.tsx | 38 +++ .../BackOffice/routes/accounts/index.tsx | 140 +++++++++++ .../shared/components/BackOfficeSideMenu.tsx | 19 +- .../BackOffice/shared/lib/api/labels.ts | 45 +++- .../shared/translations/locale/da-DK.po | 226 +++++++++++++++++- .../shared/translations/locale/en-US.po | 226 +++++++++++++++++- 18 files changed, 2167 insertions(+), 13 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/$tenantId.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx create mode 100644 application/account/BackOffice/routes/accounts/index.tsx diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx new file mode 100644 index 0000000000..1ace145ea6 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -0,0 +1,93 @@ +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 } from "@tanstack/react-router"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import { AccountBillingTab } from "./-components/AccountBillingTab"; +import { AccountDetailHeader } from "./-components/AccountDetailHeader"; +import { AccountOverviewTab } from "./-components/AccountOverviewTab"; +import { AccountUsersTab } from "./-components/AccountUsersTab"; + +const detailSearchSchema = z.object({ + tab: z.enum(["overview", "users", "billing"]).optional() +}); + +export const Route = createFileRoute("/accounts/$tenantId")({ + staticData: { trackingTitle: "Account detail" }, + validateSearch: detailSearchSchema, + component: AccountDetailPage +}); + +function AccountDetailPage() { + const { tenantId } = Route.useParams(); + const { tab } = Route.useSearch(); + const navigate = Route.useNavigate(); + + const tenantQuery = api.useQuery("get", "/api/back-office/tenants/{id}", { + params: { path: { id: tenantId } } + }); + + const userCountsQuery = api.useQuery("get", "/api/back-office/tenants/{id}/user-counts", { + params: { path: { id: tenantId } } + }); + + const activeTab = tab ?? "overview"; + const tenant = tenantQuery.data; + + return ( + + + + +
+ + + + navigate({ + to: "/accounts/$tenantId", + params: { tenantId }, + search: { tab: value === "overview" ? undefined : (value as "users" | "billing") } + }) + } + > + + + Overview + + + Users + + + Billing & invoices + + + + + + + + + + + + + +
+
+
+
+ ); +} 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..5583153942 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -0,0 +1,163 @@ +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, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useState } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +import { AccountPaymentRow } from "./AccountPaymentRow"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountBillingTabProps { + tenant: TenantDetailResponse | undefined; + tenantId: string; +} + +export function AccountBillingTab({ tenant, tenantId }: Readonly) { + const formatDate = useFormatDate(); + const [pageOffset, setPageOffset] = useState(0); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants/{id}/payment-history", + { + params: { path: { id: tenantId }, query: { PageOffset: pageOffset || undefined } } + }, + { placeholderData: keepPreviousData } + ); + + const transactions = data?.transactions ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + + return ( +
+
+

+ Billing address +

+ {!tenant ? ( + + ) : tenant.billingAddress ? ( + + ) : ( + + + + No billing address + + + No billing address on file. + + + + )} +
+ +
+

+ Payment history +

+ {isLoading && transactions.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : transactions.length === 0 ? ( + + + + No payments + + + No payments yet. + + + + ) : ( +
+ + + + + Date + + + Amount + + + Status + + + Documents + + + + + {transactions.map((transaction) => ( + + ))} + +
+
+ )} + + {totalPages > 1 && ( +
+ setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Payment history" + className="w-full" + /> +
+ )} +
+
+ ); +} + +function BillingAddress({ address }: Readonly<{ address: components["schemas"]["BillingAddressResponse"] }>) { + const lines = [ + address.line1, + address.line2, + [address.postalCode, address.city].filter(Boolean).join(" ").trim() || null, + address.state, + address.country + ].filter((value): value is string => Boolean(value && value.trim().length > 0)); + + if (lines.length === 0) { + return ( + + + + No billing address + + + No billing address on file. + + + + ); + } + + return ( +
+ {lines.map((line) => ( +
{line}
+ ))} +
+ ); +} 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..05ad6ac8eb --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -0,0 +1,132 @@ +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 { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link } from "@tanstack/react-router"; +import { ArrowLeftIcon, CalendarClockIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +function formatAmount(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; +type TenantUserCountsResponse = components["schemas"]["TenantUserCountsResponse"]; + +interface AccountDetailHeaderProps { + tenant: TenantDetailResponse | undefined; + isLoading: boolean; + userCounts: TenantUserCountsResponse | undefined; + isLoadingUserCounts: boolean; +} + +export function AccountDetailHeader({ + tenant, + isLoading, + userCounts, + isLoadingUserCounts +}: Readonly) { + const formatDate = useFormatDate(); + + const plannedChange = ((): PlannedSubscriptionChange | null => { + if (!tenant) return null; + if (tenant.cancelAtPeriodEnd) return PlannedSubscriptionChange.Cancellation; + if (tenant.scheduledPlan) return PlannedSubscriptionChange.ScheduledPlanChange; + return null; + })(); + + return ( +
+
+ +
+ +
+ +
+ {isLoading || !tenant ? ( + <> + + + + ) : ( + <> +

{tenant.name}

+
+ {getSubscriptionPlanLabel(tenant.plan)} + {tenant.state === TenantState.Suspended && ( + + Suspended + + )} + {plannedChange === PlannedSubscriptionChange.Cancellation && ( + + + Cancelling at period end + + )} + {plannedChange === PlannedSubscriptionChange.ScheduledPlanChange && tenant.scheduledPlan && ( + + + Switching to {getSubscriptionPlanLabel(tenant.scheduledPlan)} + + )} +
+ + )} +
+
+ +
+ + {tenant ? formatAmount(tenant.monthlyRecurringRevenue, tenant.currency) : "-"} + + + {tenant ? formatAmount(tenant.lifetimeValue, tenant.currency) : "-"} + + + {tenant?.renewalDate ? formatDate(tenant.renewalDate) : "-"} + + + {userCounts ? `${userCounts.activeUsers} / ${userCounts.totalUsers}` : "-"} + +
+
+ ); +} + +function KpiTile({ + label, + loading, + children +}: Readonly<{ label: string; loading: boolean; children: React.ReactNode }>) { + return ( +
+ {label} + {loading ? ( + + ) : ( + {children} + )} +
+ ); +} 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..56c8388015 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx @@ -0,0 +1,146 @@ +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 { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getInitials } from "@repo/utils/string/getInitials"; + +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 formatDate = useFormatDate(); + + const activityQuery = api.useQuery("get", "/api/back-office/tenants/{id}/activity", { + params: { path: { id: tenantId } } + }); + + const ownersQuery = api.useQuery("get", "/api/back-office/tenants/{id}/users", { + params: { path: { id: tenantId }, query: { Role: UserRole.Owner, PageSize: 100 } } + }); + + const owners = ownersQuery.data?.users ?? []; + const events = activityQuery.data?.events ?? []; + + return ( +
+
+

+ Activity +

+ {activityQuery.isLoading ? ( + + ) : events.length === 0 ? ( + + + + No activity yet + + + No activity recorded yet. + + + + ) : ( +
    + {events.map((event, index) => ( +
  1. +
    +
    + {event.name} + {event.description && {event.description}} + {formatDate(event.timestamp, true)} +
    +
  2. + ))} +
+ )} +
+ +
+

+ 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.email} +
+ {!owner.emailConfirmed && ( + + Pending + + )} +
+ ); +} + +function ActivitySkeleton() { + return ( +
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+ ); +} + +function OwnersSkeleton() { + return ( +
+ {Array.from({ length: 3 }).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..337ab029c4 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx @@ -0,0 +1,102 @@ +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 { ExternalLinkIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PaymentTransactionStatus } from "@/shared/lib/api/client"; +import { getPaymentStatusLabel } from "@/shared/lib/api/labels"; + +type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; + +export function AccountPaymentRow({ + transaction, + formatDate +}: Readonly<{ + transaction: PaymentTransaction; + formatDate: (value: string | null | undefined) => string; +}>) { + return ( + + {formatDate(transaction.date)} + {formatCurrency(transaction.amount, transaction.currency)} + + + + +
+ {transaction.invoiceUrl && ( + + )} + {transaction.creditNoteUrl && ( + + )} +
+
+
+ ); +} + +function PaymentStatusBadge({ + status, + failureReason +}: Readonly<{ status: PaymentTransactionStatus; failureReason: string | null }>) { + 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..ae74819958 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx @@ -0,0 +1,170 @@ +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 { SidePane, SidePaneBody, SidePaneFooter, SidePaneHeader } from "@repo/ui/components/SidePane"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, CalendarClockIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, PlannedSubscriptionChange, UserRole } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; + +import { SidePaneUserList } from "./SidePaneUserList"; + +function formatMonthlyRevenue(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +type TenantSummary = components["schemas"]["TenantSummary"]; + +interface AccountSidePaneProps { + tenant: TenantSummary | null; + isOpen: boolean; + onClose: () => void; +} + +const USER_DATA_DEBOUNCE_MS = 2000; + +export function AccountSidePane({ tenant, isOpen, onClose }: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const tenantId = tenant?.id; + const debouncedTenantId = useDebounce(tenantId, USER_DATA_DEBOUNCE_MS); + const userDataReady = Boolean(debouncedTenantId) && debouncedTenantId === tenantId; + + const userCountsQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/user-counts", + { params: { path: { id: debouncedTenantId ?? "" } } }, + { enabled: userDataReady } + ); + + const ownersQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { params: { path: { id: debouncedTenantId ?? "" }, query: { Role: UserRole.Owner, PageSize: 100 } } }, + { enabled: userDataReady } + ); + + 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?.name ?? Account} + + + + {tenant && ( +
+
+
+
+ {getSubscriptionPlanLabel(tenant.plan)} + + {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} + +
+ {tenant.renewalDate && ( +
+ + Renewal + + {formatDate(tenant.renewalDate)} +
+ )} + {tenant.plannedChange === PlannedSubscriptionChange.Cancellation && ( + + + Cancellation at period end + + )} + {tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange && ( + + + Scheduled plan change + + )} +
+
+ +
+ {tenant.country ? ( + + {getCountryFlagEmoji(tenant.country)} + {tenant.country} + + ) : ( + - + )} +
+ +
+ {formatDate(tenant.createdAt)} +
+ +
+ +
+ +
+ {!userDataReady || userCountsQuery.isLoading ? ( + + ) : userCountsQuery.data ? ( + + + {userCountsQuery.data.activeUsers} active / {userCountsQuery.data.totalUsers} total + + + ) : ( + - + )} +
+
+ )} +
+ + + + +
+ ); +} + +function Section({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( +
+ {label} + {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..c1143789cd --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx @@ -0,0 +1,51 @@ +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 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.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..23341430e1 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx @@ -0,0 +1,197 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Field, FieldLabel } from "@repo/ui/components/Field"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@repo/ui/components/Select"; +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 { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { keepPreviousData } from "@tanstack/react-query"; +import { SearchIcon, XIcon } from "lucide-react"; +import { 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 [role, setRole] = useState(""); + const [pageOffset, setPageOffset] = useState(0); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + setPageOffset(0); + }, [debouncedSearch, role]); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { + params: { + path: { id: tenantId }, + query: { + Search: debouncedSearch || undefined, + Role: role || undefined, + PageOffset: pageOffset || undefined + } + } + }, + { placeholderData: keepPreviousData } + ); + + const users = data?.users ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + const hasFilters = Boolean(debouncedSearch || role); + + return ( +
+ + + {totalPages > 1 && ( + setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Tenant users" + className="w-full" + /> + )} +
+ ); +} + +function UserFilters({ + searchInput, + onSearchChange, + role, + onRoleChange +}: Readonly<{ + searchInput: string; + onSearchChange: (value: string) => void; + role: UserRole | ""; + onRoleChange: (value: UserRole | "") => void; +}>) { + return ( +
+ + {t`Search users`} + + + + + onSearchChange(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && onSearchChange("")} + /> + {searchInput && ( + + onSearchChange("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + + + + + + Role + + value={role} onValueChange={(value) => onRoleChange((value as UserRole | "") || "")}> + + {(value: string) => (value ? getUserRoleLabel(value as UserRole) : t`Any role`)} + + + + Any role + + {Object.values(UserRole).map((value) => ( + + {getUserRoleLabel(value)} + + ))} + + + +
+ ); +} + +function UserList({ + users, + isLoading, + hasFilters +}: Readonly<{ users: TenantUserSummary[]; isLoading: boolean; hasFilters: boolean }>) { + const formatDate = useFormatDate(); + + 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..7aa271f0da --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx @@ -0,0 +1,189 @@ +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, useMemo } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { SortableTenantProperties, SortOrder } from "@/shared/lib/api/client"; + +import { AccountsTableRow } from "./AccountsTableRow"; +import { SortableTableHead } from "./SortableTableHead"; + +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(selectedTenantId === key ? null : (tenant ?? null)); + }, + [onSelectTenant, selectedTenantId, tenants] + ); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableTenantProperties) => { + const isCurrent = orderBy === column; + const nextOrder = isCurrent && sortOrder === SortOrder.Descending ? SortOrder.Ascending : SortOrder.Descending; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: column, + sortOrder: nextOrder === SortOrder.Ascending ? undefined : nextOrder, + pageOffset: undefined + }) + }); + }, + [navigate, orderBy, sortOrder] + ); + + if (isLoading && tenants.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + + Name + + + Plan + + + MRR + + + Renewal + + + Country + + + Created + + + + + {tenants.map((tenant) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} 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..76edd2ec6a --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx @@ -0,0 +1,93 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +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 { PlannedSubscriptionChange } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +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) => string; +}>) { + return ( + + +
+ {tenant.name} + + {getSubscriptionPlanLabel(tenant.plan)} ·{" "} + {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} + +
+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + + {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} + + + + + + {tenant.country ? ( + + {getCountryFlagEmoji(tenant.country)} + {tenant.country} + + ) : ( + "-" + )} + + {formatDate(tenant.createdAt)} +
+ ); +} + +function RenewalCell({ + renewalDate, + plannedChange, + formatDate +}: Readonly<{ + renewalDate: string | null; + plannedChange: PlannedSubscriptionChange | null; + formatDate: (value: string | null | undefined) => string; +}>) { + if (!renewalDate) { + return -; + } + return ( +
+ {formatDate(renewalDate)} + {plannedChange === PlannedSubscriptionChange.Cancellation && ( + + + Cancelling + + )} + {plannedChange === PlannedSubscriptionChange.ScheduledPlanChange && ( + + + Plan change + + )} +
+ ); +} 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..2252481296 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx @@ -0,0 +1,94 @@ +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 { SubscriptionPlan } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +interface AccountsToolbarProps { + search: string | undefined; + plan: SubscriptionPlan | undefined; +} + +export function AccountsToolbar({ search, plan }: 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, search: debouncedSearch || undefined, pageOffset: undefined }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handlePlanChange = (values: string[]) => { + const next = values.find((value) => value !== "all") as SubscriptionPlan | undefined; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + plan: 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 + + {Object.values(SubscriptionPlan).map((value) => ( + + {getSubscriptionPlanLabel(value)} + + ))} + +
+ ); +} 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..dfcb42ff14 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx @@ -0,0 +1,56 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { getInitials } from "@repo/utils/string/getInitials"; + +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 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.email} +
+
+ ); +} 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..cd8cd4f8b7 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx @@ -0,0 +1,38 @@ +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; + const isDescending = isActive && sortOrder === SortOrder.Descending; + const ariaSort = isActive ? (isDescending ? "descending" : "ascending") : "none"; + + return ( + onSort(column)}> + {children} + {isActive && + (isDescending ? ( + + ) : ( + + ))} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/index.tsx b/application/account/BackOffice/routes/accounts/index.tsx new file mode 100644 index 0000000000..757b77cb13 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/index.tsx @@ -0,0 +1,140 @@ +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 } 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(), + plan: z.nativeEnum(SubscriptionPlan).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, plan, 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, + Plan: plan, + 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 || plan); + const showEmpty = !isLoading && tenants.length === 0; + + return ( + + + + + ) : undefined + } + > + + + {showEmpty ? ( + + + + + + + {hasFilters ? No accounts match your filters : No accounts yet} + + + {hasFilters ? ( + Try clearing the search or plan filter to see more results. + ) : ( + Tenant accounts will appear here as they are created. + )} + + + {hasFilters && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx index f615189672..df9383cf22 100644 --- a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx @@ -22,6 +22,7 @@ const normalizePath = (path: string): string => path.replace(/\/$/, "") || "/"; export function BackOfficeSideMenu() { const router = useRouter(); const currentPath = normalizePath(router.state.location.pathname); + const isAccountsActive = currentPath === "/accounts" || currentPath.startsWith("/accounts/"); return ( @@ -46,6 +47,16 @@ export function BackOfficeSideMenu() { + + + + + + Accounts + + + + @@ -55,14 +66,6 @@ export function BackOfficeSideMenu() { - - - - - Accounts - - - diff --git a/application/account/BackOffice/shared/lib/api/labels.ts b/application/account/BackOffice/shared/lib/api/labels.ts index 95b08ab9e8..e4f885a3fa 100644 --- a/application/account/BackOffice/shared/lib/api/labels.ts +++ b/application/account/BackOffice/shared/lib/api/labels.ts @@ -1,6 +1,12 @@ import { t } from "@lingui/core/macro"; -import { SubscriptionPlan, UserRole } from "@/shared/lib/api/client"; +import { + PaymentTransactionStatus, + PlannedSubscriptionChange, + SubscriptionPlan, + TenantState, + UserRole +} from "@/shared/lib/api/client"; export function getSubscriptionPlanLabel(plan: SubscriptionPlan): string { switch (plan) { @@ -15,6 +21,43 @@ 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`Succeeded`; + case PaymentTransactionStatus.Failed: + return t`Failed`; + case PaymentTransactionStatus.Pending: + return t`Pending`; + case PaymentTransactionStatus.Refunded: + return t`Refunded`; + default: + return String(status); + } +} + export function getUserRoleLabel(role: UserRole): string { switch (role) { case UserRole.Owner: diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 7389529d12..6a5d898bc9 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -13,27 +13,74 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +#. placeholder {0}: userCountsQuery.data.activeUsers +#. placeholder {1}: userCountsQuery.data.totalUsers +msgid "{0} active / {1} total" +msgstr "{0} aktive / {1} i alt" + +msgid "Account" +msgstr "Konto" + +msgid "Account preview" +msgstr "Kontoforhåndsvisning" + +msgid "Account sections" +msgstr "Kontosektioner" + msgid "Accounts" msgstr "Konti" -msgid "Accounts (coming soon)" -msgstr "Konti (kommer snart)" +msgid "Active" +msgstr "Aktiv" + +msgid "Activity" +msgstr "Aktivitet" msgid "Admin" msgstr "Admin" +msgid "All" +msgstr "Alle" + +msgid "Amount" +msgstr "Beløb" + msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." +msgid "Any role" +msgstr "Alle roller" + msgid "Back Office" msgstr "Back Office" +msgid "Back to accounts" +msgstr "Tilbage til konti" + msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" msgid "Basis" msgstr "Basis" +msgid "Billing & invoices" +msgstr "Fakturering og fakturaer" + +msgid "Billing address" +msgstr "Faktureringsadresse" + +msgid "Cancellation" +msgstr "Opsigelse" + +msgid "Cancellation at period end" +msgstr "Opsigelse ved periodens slutning" + +msgid "Cancelling" +msgstr "Opsiges" + +msgid "Cancelling at period end" +msgstr "Opsiges ved periodens slutning" + msgid "Change language" msgstr "Skift sprog" @@ -43,21 +90,51 @@ msgstr "Skift tema" msgid "Change zoom level" msgstr "Skift zoomniveau" +msgid "Clear filters" +msgstr "Ryd filtre" + +msgid "Clear search" +msgstr "Ryd søgning" + +msgid "Close account preview" +msgstr "Luk kontoforhåndsvisning" + msgid "Coming soon" msgstr "Kommer snart" msgid "Contact your administrator." msgstr "Kontakt din administrator." +msgid "Country" +msgstr "Land" + +msgid "Created" +msgstr "Oprettet" + +msgid "Credit note" +msgstr "Kreditnota" + msgid "Dark" msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" +msgid "Date" +msgstr "Dato" + msgid "Default" msgstr "Standard" +msgid "Documents" +msgstr "Dokumenter" + +msgid "Email" +msgstr "E-mail" + +msgid "Failed" +msgstr "Mislykket" + msgid "Feature flags" msgstr "Feature flags" @@ -70,6 +147,9 @@ msgstr "Gå til forsiden" msgid "Hide details" msgstr "Skjul detaljer" +msgid "Invoice" +msgstr "Faktura" + msgid "Language" msgstr "Sprog" @@ -79,6 +159,12 @@ msgstr "Stor" msgid "Larger" msgstr "Større" +msgid "Last seen" +msgstr "Sidst set" + +msgid "Lifetime value" +msgstr "Livstidsværdi" + msgid "Light" msgstr "Lys" @@ -112,18 +198,96 @@ msgstr "Administrer konti, se systemdata, se undtagelser og udfør forskellige o msgid "Member" msgstr "Medlem" +msgid "MRR" +msgstr "MRR" + +msgid "Name" +msgstr "Navn" + msgid "Navigation" msgstr "Navigation" +msgid "Next" +msgstr "Næste" + +msgid "No accounts match your filters" +msgstr "Ingen konti matcher dine filtre" + +msgid "No accounts yet" +msgstr "Ingen konti endnu" + +msgid "No activity recorded yet." +msgstr "Ingen aktivitet registreret endnu." + +msgid "No activity yet" +msgstr "Ingen aktivitet endnu" + msgid "No back-office access" msgstr "Ingen adgang til Back Office" +msgid "No billing address" +msgstr "Ingen faktureringsadresse" + +msgid "No billing address on file." +msgstr "Ingen faktureringsadresse registreret." + +msgid "No matching users" +msgstr "Ingen matchende brugere" + +msgid "No owners" +msgstr "Ingen ejere" + +msgid "No owners on this account." +msgstr "Ingen ejere på denne konto." + +msgid "No payments" +msgstr "Ingen betalinger" + +msgid "No payments yet." +msgstr "Ingen betalinger endnu." + +msgid "No users" +msgstr "Ingen brugere" + +msgid "No users match your filters." +msgstr "Ingen brugere matcher dine filtre." + +msgid "Open account" +msgstr "Åbn konto" + +msgid "Open credit note" +msgstr "Åbn kreditnota" + +msgid "Open invoice" +msgstr "Åbn faktura" + +msgid "Overview" +msgstr "Overblik" + msgid "Owner" msgstr "Ejer" +msgid "Owners" +msgstr "Ejere" + msgid "Page not found" msgstr "Siden blev ikke fundet" +msgid "Payment history" +msgstr "Betalingshistorik" + +msgid "Pending" +msgstr "Afventer" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Plan og omsætning" + +msgid "Plan change" +msgstr "Planændring" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,9 +300,39 @@ msgstr "Prøv venligst igen eller vend tilbage til forsiden." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Forrige" + +msgid "Refunded" +msgstr "Refunderet" + +msgid "Renewal" +msgstr "Fornyelse" + +msgid "Role" +msgstr "Rolle" + +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 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 tenant accounts." +msgstr "Søg, filtrér og gennemgå tenant-konti." + msgid "Show details" msgstr "Vis detaljer" @@ -151,24 +345,49 @@ msgstr "Noget gik galt" msgid "Standard" msgstr "Standard" +msgid "Status" +msgstr "Status" + +msgid "Succeeded" +msgstr "Gennemført" + msgid "Support" msgstr "Support" msgid "Support (coming soon)" msgstr "Support (kommer snart)" +msgid "Suspended" +msgstr "Suspenderet" + +#. placeholder {0}: getSubscriptionPlanLabel(tenant.scheduledPlan) +msgid "Switching to {0}" +msgstr "Skifter til {0}" + msgid "System" msgstr "System" +msgid "Tenant accounts will appear here as they are created." +msgstr "Tenant-konti vises her, efterhånden som de oprettes." + +msgid "Tenant users" +msgstr "Tenant-brugere" + 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 "Try again" msgstr "Prøv igen" +msgid "Try clearing the search or plan filter to see more results." +msgstr "Prøv at rydde søgningen eller planfilteret for at se flere resultater." + msgid "User" msgstr "Bruger" @@ -178,6 +397,9 @@ msgstr "Brugermenu" msgid "Users" msgstr "Brugere" +msgid "Users (active / total)" +msgstr "Brugere (aktive / i alt)" + msgid "Users (coming soon)" msgstr "Brugere (kommer snart)" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 3bdbf4d88d..c668f086dc 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -13,27 +13,74 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#. placeholder {0}: userCountsQuery.data.activeUsers +#. placeholder {1}: userCountsQuery.data.totalUsers +msgid "{0} active / {1} total" +msgstr "{0} active / {1} total" + +msgid "Account" +msgstr "Account" + +msgid "Account preview" +msgstr "Account preview" + +msgid "Account sections" +msgstr "Account sections" + msgid "Accounts" msgstr "Accounts" -msgid "Accounts (coming soon)" -msgstr "Accounts (coming soon)" +msgid "Active" +msgstr "Active" + +msgid "Activity" +msgstr "Activity" msgid "Admin" msgstr "Admin" +msgid "All" +msgstr "All" + +msgid "Amount" +msgstr "Amount" + msgid "An unexpected error occurred while processing your request." msgstr "An unexpected error occurred while processing your request." +msgid "Any role" +msgstr "Any role" + msgid "Back Office" msgstr "Back Office" +msgid "Back to accounts" +msgstr "Back to accounts" + msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" msgid "Basis" msgstr "Basis" +msgid "Billing & invoices" +msgstr "Billing & invoices" + +msgid "Billing address" +msgstr "Billing address" + +msgid "Cancellation" +msgstr "Cancellation" + +msgid "Cancellation at period end" +msgstr "Cancellation at period end" + +msgid "Cancelling" +msgstr "Cancelling" + +msgid "Cancelling at period end" +msgstr "Cancelling at period end" + msgid "Change language" msgstr "Change language" @@ -43,21 +90,51 @@ msgstr "Change theme" msgid "Change zoom level" msgstr "Change zoom level" +msgid "Clear filters" +msgstr "Clear filters" + +msgid "Clear search" +msgstr "Clear search" + +msgid "Close account preview" +msgstr "Close account preview" + msgid "Coming soon" msgstr "Coming soon" msgid "Contact your administrator." msgstr "Contact your administrator." +msgid "Country" +msgstr "Country" + +msgid "Created" +msgstr "Created" + +msgid "Credit note" +msgstr "Credit note" + msgid "Dark" msgstr "Dark" msgid "Dashboard" msgstr "Dashboard" +msgid "Date" +msgstr "Date" + msgid "Default" msgstr "Default" +msgid "Documents" +msgstr "Documents" + +msgid "Email" +msgstr "Email" + +msgid "Failed" +msgstr "Failed" + msgid "Feature flags" msgstr "Feature flags" @@ -70,6 +147,9 @@ msgstr "Go to home" msgid "Hide details" msgstr "Hide details" +msgid "Invoice" +msgstr "Invoice" + msgid "Language" msgstr "Language" @@ -79,6 +159,12 @@ msgstr "Large" msgid "Larger" msgstr "Larger" +msgid "Last seen" +msgstr "Last seen" + +msgid "Lifetime value" +msgstr "Lifetime value" + msgid "Light" msgstr "Light" @@ -112,18 +198,96 @@ msgstr "Manage accounts, view system data, see exceptions, and perform various t msgid "Member" msgstr "Member" +msgid "MRR" +msgstr "MRR" + +msgid "Name" +msgstr "Name" + msgid "Navigation" msgstr "Navigation" +msgid "Next" +msgstr "Next" + +msgid "No accounts match your filters" +msgstr "No accounts match your filters" + +msgid "No accounts yet" +msgstr "No accounts yet" + +msgid "No activity recorded yet." +msgstr "No activity recorded yet." + +msgid "No activity yet" +msgstr "No activity yet" + msgid "No back-office access" msgstr "No back-office access" +msgid "No billing address" +msgstr "No billing address" + +msgid "No billing address on file." +msgstr "No billing address on file." + +msgid "No matching users" +msgstr "No matching users" + +msgid "No owners" +msgstr "No owners" + +msgid "No owners on this account." +msgstr "No owners on this account." + +msgid "No payments" +msgstr "No payments" + +msgid "No payments yet." +msgstr "No payments yet." + +msgid "No users" +msgstr "No users" + +msgid "No users match your filters." +msgstr "No users match your filters." + +msgid "Open account" +msgstr "Open account" + +msgid "Open credit note" +msgstr "Open credit note" + +msgid "Open invoice" +msgstr "Open invoice" + +msgid "Overview" +msgstr "Overview" + msgid "Owner" msgstr "Owner" +msgid "Owners" +msgstr "Owners" + msgid "Page not found" msgstr "Page not found" +msgid "Payment history" +msgstr "Payment history" + +msgid "Pending" +msgstr "Pending" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Plan & revenue" + +msgid "Plan change" +msgstr "Plan change" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,9 +300,39 @@ msgstr "Please try again or return to the home page." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Previous" + +msgid "Refunded" +msgstr "Refunded" + +msgid "Renewal" +msgstr "Renewal" + +msgid "Role" +msgstr "Role" + +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 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 tenant accounts." +msgstr "Search, filter, and review tenant accounts." + msgid "Show details" msgstr "Show details" @@ -151,24 +345,49 @@ msgstr "Something went wrong" msgid "Standard" msgstr "Standard" +msgid "Status" +msgstr "Status" + +msgid "Succeeded" +msgstr "Succeeded" + msgid "Support" msgstr "Support" msgid "Support (coming soon)" msgstr "Support (coming soon)" +msgid "Suspended" +msgstr "Suspended" + +#. placeholder {0}: getSubscriptionPlanLabel(tenant.scheduledPlan) +msgid "Switching to {0}" +msgstr "Switching to {0}" + msgid "System" msgstr "System" +msgid "Tenant accounts will appear here as they are created." +msgstr "Tenant accounts will appear here as they are created." + +msgid "Tenant users" +msgstr "Tenant users" + 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 "Try again" msgstr "Try again" +msgid "Try clearing the search or plan filter to see more results." +msgstr "Try clearing the search or plan filter to see more results." + msgid "User" msgstr "User" @@ -178,6 +397,9 @@ msgstr "User menu" msgid "Users" msgstr "Users" +msgid "Users (active / total)" +msgstr "Users (active / total)" + msgid "Users (coming soon)" msgstr "Users (coming soon)" From bcacbabc7e0d6efdad48aa3ccdf77d576a50f702 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 May 2026 15:12:59 +0200 Subject: [PATCH 003/158] Polish back-office accounts pages with design fixes and new components --- .../-components/AccountDetailHeader.tsx | 5 +- .../accounts/-components/AccountSidePane.tsx | 143 ++++--------- .../-components/AccountSidePaneSections.tsx | 189 ++++++++++++++++++ .../accounts/-components/AccountsTableRow.tsx | 19 +- .../accounts/-components/AccountsToolbar.tsx | 6 +- .../accounts/-components/SidePaneSection.tsx | 12 ++ .../accounts/-components/SidePaneUserList.tsx | 21 +- .../SubscriptionStatusIndicator.tsx | 48 +++++ .../BackOffice/shared/lib/planBadge.ts | 14 ++ .../shared/translations/locale/da-DK.po | 6 +- .../shared/translations/locale/en-US.po | 4 + .../shared-webapp/ui/utils/countryFlag.ts | 11 + 12 files changed, 357 insertions(+), 121 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx create mode 100644 application/account/BackOffice/shared/lib/planBadge.ts diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index 05ad6ac8eb..ec7082dd3b 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -13,6 +13,7 @@ import type { components } from "@/shared/lib/api/client"; import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; function formatAmount(amount: number | null, currency: string | null): string { if (amount === null || currency === null) { @@ -72,7 +73,9 @@ export function AccountDetailHeader({ <>

{tenant.name}

- {getSubscriptionPlanLabel(tenant.plan)} + + {getSubscriptionPlanLabel(tenant.plan)} + {tenant.state === TenantState.Suspended && ( Suspended diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx index ae74819958..6f13118b86 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx @@ -1,29 +1,19 @@ 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 { SidePane, SidePaneBody, SidePaneFooter, SidePaneHeader } from "@repo/ui/components/SidePane"; -import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { useDebounce } from "@repo/ui/hooks/useDebounce"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; -import { formatCurrency } from "@repo/utils/currency/formatCurrency"; import { useNavigate } from "@tanstack/react-router"; -import { ArrowRightIcon, CalendarClockIcon, XCircleIcon } from "lucide-react"; +import { ArrowRightIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; -import { api, PlannedSubscriptionChange, UserRole } from "@/shared/lib/api/client"; -import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { api } from "@/shared/lib/api/client"; import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; -import { SidePaneUserList } from "./SidePaneUserList"; - -function formatMonthlyRevenue(amount: number | null, currency: string | null): string { - if (amount === null || currency === null) { - return "-"; - } - return formatCurrency(amount, currency); -} +import { AccountSidePaneSections } from "./AccountSidePaneSections"; type TenantSummary = components["schemas"]["TenantSummary"]; @@ -33,29 +23,24 @@ interface AccountSidePaneProps { onClose: () => void; } -const USER_DATA_DEBOUNCE_MS = 2000; +const DETAIL_DEBOUNCE_MS = 2000; export function AccountSidePane({ tenant, isOpen, onClose }: Readonly) { const navigate = useNavigate(); const formatDate = useFormatDate(); const tenantId = tenant?.id; - const debouncedTenantId = useDebounce(tenantId, USER_DATA_DEBOUNCE_MS); - const userDataReady = Boolean(debouncedTenantId) && debouncedTenantId === tenantId; + const debouncedTenantId = useDebounce(tenantId, DETAIL_DEBOUNCE_MS); + const detailReady = Boolean(debouncedTenantId) && debouncedTenantId === tenantId; - const userCountsQuery = api.useQuery( + const detailQuery = api.useQuery( "get", - "/api/back-office/tenants/{id}/user-counts", + "/api/back-office/tenants/{id}", { params: { path: { id: debouncedTenantId ?? "" } } }, - { enabled: userDataReady } + { enabled: detailReady } ); - const ownersQuery = api.useQuery( - "get", - "/api/back-office/tenants/{id}/users", - { params: { path: { id: debouncedTenantId ?? "" }, query: { Role: UserRole.Owner, PageSize: 100 } } }, - { enabled: userDataReady } - ); + const detail = detailQuery.data; const handleOpen = () => { if (!tenant) { @@ -72,81 +57,36 @@ export function AccountSidePane({ tenant, isOpen, onClose }: Readonly - - {tenant?.name ?? Account} + + {tenant ? ( +
+ +
+ {tenant.name} + + {formatDate(tenant.createdAt)} + {tenant.country && ( + + {getCountryFlagEmoji(tenant.country)} + + )} + +
+
+ ) : ( + Account + )}
{tenant && ( -
-
-
-
- {getSubscriptionPlanLabel(tenant.plan)} - - {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} - -
- {tenant.renewalDate && ( -
- - Renewal - - {formatDate(tenant.renewalDate)} -
- )} - {tenant.plannedChange === PlannedSubscriptionChange.Cancellation && ( - - - Cancellation at period end - - )} - {tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange && ( - - - Scheduled plan change - - )} -
-
- -
- {tenant.country ? ( - - {getCountryFlagEmoji(tenant.country)} - {tenant.country} - - ) : ( - - - )} -
- -
- {formatDate(tenant.createdAt)} -
- -
- -
- -
- {!userDataReady || userCountsQuery.isLoading ? ( - - ) : userCountsQuery.data ? ( - - - {userCountsQuery.data.activeUsers} active / {userCountsQuery.data.totalUsers} total - - - ) : ( - - - )} -
-
+ )}
@@ -159,12 +99,3 @@ export function AccountSidePane({ tenant, isOpen, onClose }: Readonly ); } - -function Section({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { - return ( -
- {label} - {children} -
- ); -} 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..4eba6e6006 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx @@ -0,0 +1,189 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +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, UserRole } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { SidePaneDivider, SidePaneSection } from "./SidePaneSection"; +import { SidePaneUserList } from "./SidePaneUserList"; +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, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountSidePaneSections({ + tenant, + detail, + detailLoading, + debouncedTenantId, + detailReady +}: Readonly) { + const formatDate = useFormatDate(); + + 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: { Role: 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 hasEverHadSubscription = (paymentHistoryQuery.data?.totalCount ?? 0) > 0; + const paymentHistoryLoading = !detailReady || paymentHistoryQuery.isLoading; + + return ( +
+ + + +
+
+
+ + MRR + + + {formatAmount(tenant.monthlyRecurringRevenue, tenant.currency)} + +
+
+ + Lifetime value + + {detailLoading ? ( + + ) : ( + + {detail ? formatAmount(detail.lifetimeValue, detail.currency) : "-"} + + )} +
+
+ +
+
+ + Plan + + + {getSubscriptionPlanLabel(tenant.plan)} + +
+
+ + Renewal + + {tenant.renewalDate ? formatDate(tenant.renewalDate) : "-"} +
+
+ + + + {paymentHistoryLoading ? ( +
+
+ + +
+
+ + +
+
+ ) : ( + hasEverHadSubscription && ( +
+
+ + Amount + + + {lastInvoice ? formatCurrency(lastInvoice.amount, lastInvoice.currency) : "-"} + +
+
+ + Last invoice + + {lastInvoice ? formatDate(lastInvoice.date) : "-"} +
+
+ ) + )} +
+
+ + + + + + + + + + + {!detailReady || userCountsQuery.isLoading ? ( + + ) : userCountsQuery.data ? ( + + + {userCountsQuery.data.activeUsers} active / {userCountsQuery.data.totalUsers} total + + + ) : ( + - + )} + +
+ ); +} + +function SubLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + {children} + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx index 76edd2ec6a..a8071198a1 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx @@ -1,6 +1,7 @@ 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 { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; import { formatCurrency } from "@repo/utils/currency/formatCurrency"; import { CalendarClockIcon, XCircleIcon } from "lucide-react"; @@ -9,6 +10,7 @@ import type { components } from "@/shared/lib/api/client"; import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; type TenantSummary = components["schemas"]["TenantSummary"]; @@ -29,16 +31,19 @@ export function AccountsTableRow({ return ( -
- {tenant.name} - - {getSubscriptionPlanLabel(tenant.plan)} ·{" "} - {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} - +
+ +
+ {tenant.name} + + {getSubscriptionPlanLabel(tenant.plan)} ·{" "} + {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} + +
- {getSubscriptionPlanLabel(tenant.plan)} + {getSubscriptionPlanLabel(tenant.plan)} {formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency)} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx index 2252481296..bae84cb935 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx @@ -48,7 +48,7 @@ export function AccountsToolbar({ search, plan }: Readonly return (
-
+
@@ -80,11 +80,11 @@ export function AccountsToolbar({ search, plan }: Readonly value={plan ? [plan] : ["all"]} onValueChange={handlePlanChange} > - + All {Object.values(SubscriptionPlan).map((value) => ( - + {getSubscriptionPlanLabel(value)} ))} 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..542202a51f --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx @@ -0,0 +1,12 @@ +export function SidePaneSection({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( +
+ {label} + {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 index dfcb42ff14..4ab5983090 100644 --- a/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx @@ -18,8 +18,7 @@ export function SidePaneUserList({ if (isLoading) { return (
- - +
); } @@ -35,13 +34,29 @@ export function SidePaneUserList({ ); } +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)} 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..6931dbc396 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx @@ -0,0 +1,48 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { CalendarClockIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +export function SubscriptionStatusIndicator({ + plannedChange, + state, + scheduledPlan +}: Readonly<{ + plannedChange: PlannedSubscriptionChange | null; + state: TenantState | undefined; + scheduledPlan: components["schemas"]["SubscriptionPlan"] | null; +}>) { + if (state === TenantState.Suspended) { + return ( + + + Suspended + + ); + } + if (plannedChange === PlannedSubscriptionChange.Cancellation) { + return ( + + + Cancellation at period end + + ); + } + if (plannedChange === PlannedSubscriptionChange.ScheduledPlanChange) { + return ( + + + {scheduledPlan ? ( + Switching to {getSubscriptionPlanLabel(scheduledPlan)} + ) : ( + Scheduled plan change + )} + + ); + } + return null; +} 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/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 6a5d898bc9..0103d39ba3 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -159,6 +159,9 @@ msgstr "Stor" msgid "Larger" msgstr "Større" +msgid "Last invoice" +msgstr "" + msgid "Last seen" msgstr "Sidst set" @@ -283,7 +286,7 @@ msgid "Plan" msgstr "Plan" msgid "Plan & revenue" -msgstr "Plan og omsætning" +msgstr "Abonnement og omsætning" msgid "Plan change" msgstr "Planændring" @@ -360,6 +363,7 @@ msgstr "Support (kommer snart)" msgid "Suspended" msgstr "Suspenderet" +#. placeholder {0}: getSubscriptionPlanLabel(scheduledPlan) #. placeholder {0}: getSubscriptionPlanLabel(tenant.scheduledPlan) msgid "Switching to {0}" msgstr "Skifter til {0}" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index c668f086dc..02b1c3080e 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -159,6 +159,9 @@ msgstr "Large" msgid "Larger" msgstr "Larger" +msgid "Last invoice" +msgstr "Last invoice" + msgid "Last seen" msgstr "Last seen" @@ -360,6 +363,7 @@ msgstr "Support (coming soon)" msgid "Suspended" msgstr "Suspended" +#. placeholder {0}: getSubscriptionPlanLabel(scheduledPlan) #. placeholder {0}: getSubscriptionPlanLabel(tenant.scheduledPlan) msgid "Switching to {0}" msgstr "Switching to {0}" diff --git a/application/shared-webapp/ui/utils/countryFlag.ts b/application/shared-webapp/ui/utils/countryFlag.ts index 55e36d79fe..56847e431e 100644 --- a/application/shared-webapp/ui/utils/countryFlag.ts +++ b/application/shared-webapp/ui/utils/countryFlag.ts @@ -13,3 +13,14 @@ export function getCountryFlagEmoji(countryCode: string | null | undefined): str upper.charCodeAt(1) + REGIONAL_INDICATOR_OFFSET ); } + +export function getCountryName(countryCode: string | null | undefined, locale: string): string { + if (!countryCode || countryCode.length !== 2) { + return ""; + } + const upper = countryCode.toUpperCase(); + if (!/^[A-Z]{2}$/.test(upper)) { + return ""; + } + return new Intl.DisplayNames([locale], { type: "region" }).of(upper) ?? upper; +} From 29eb8720ff08674d56586d0617959cac5d5ed59b Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 May 2026 21:29:34 +0200 Subject: [PATCH 004/158] Add back-office tenant subscription tracking and blob proxy --- .../Api/BackOffice/BackOfficeEndpoints.cs | 2 +- .../account/Api/BackOfficeBlobProxy.cs | 34 ++++++ application/account/Api/Program.cs | 4 + .../20260503120000_AddSubscriptionTracking.cs | 15 +++ .../20260503130000_BackfillSubscribedSince.cs | 26 +++++ .../Subscriptions/Domain/Subscription.cs | 20 +++- .../Domain/SubscriptionConfiguration.cs | 1 + .../Shared/ProcessPendingStripeEvents.cs | 12 +- .../BackOffice/Queries/GetTenantDetail.cs | 4 + .../BackOffice/Queries/GetTenantUserCounts.cs | 6 +- .../Tenants/BackOffice/Queries/GetTenants.cs | 33 ++++++ .../Features/Users/Domain/UserRepository.cs | 25 ++--- .../Subscriptions/Domain/SubscriptionTests.cs | 103 ++++++++++++++++++ .../BackOffice/GetTenantDetailTests.cs | 36 +++++- .../BackOffice/GetTenantUserCountsTests.cs | 33 +++++- .../Tenants/BackOffice/GetTenantsTests.cs | 48 ++++---- 16 files changed, 351 insertions(+), 51 deletions(-) create mode 100644 application/account/Api/BackOfficeBlobProxy.cs create mode 100644 application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs create mode 100644 application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs create mode 100644 application/account/Tests/Subscriptions/Domain/SubscriptionTests.cs diff --git a/application/account/Api/BackOffice/BackOfficeEndpoints.cs b/application/account/Api/BackOffice/BackOfficeEndpoints.cs index 8914654af5..fb7c6e96b3 100644 --- a/application/account/Api/BackOffice/BackOfficeEndpoints.cs +++ b/application/account/Api/BackOffice/BackOfficeEndpoints.cs @@ -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/BackOfficeBlobProxy.cs b/application/account/Api/BackOfficeBlobProxy.cs new file mode 100644 index 0000000000..ce721ee89a --- /dev/null +++ b/application/account/Api/BackOfficeBlobProxy.cs @@ -0,0 +1,34 @@ +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"; + return Results.Stream(blob.Value.Stream, blob.Value.ContentType); + } +} diff --git a/application/account/Api/Program.cs b/application/account/Api/Program.cs index 676dff747b..28da2b7560 100644 --- a/application/account/Api/Program.cs +++ b/application/account/Api/Program.cs @@ -62,6 +62,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) diff --git a/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs b/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs new file mode 100644 index 0000000000..70cc23ae45 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260503120000_AddSubscriptionTracking")] +public sealed class AddSubscriptionTracking : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("subscribed_since", "subscriptions", "timestamptz", nullable: true); + migrationBuilder.AddColumn("scheduled_price_amount", "subscriptions", "numeric(18,2)", nullable: true); + } +} diff --git a/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs b/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs new file mode 100644 index 0000000000..4baf0150a1 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260503130000_BackfillSubscribedSince")] +public sealed class BackfillSubscribedSince : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + // Subscriptions created before AddSubscriptionTracking have no subscribed_since because the + // column did not exist when the Basis -> paid transition occurred. Best available proxy for + // the start of their paid run is the subscription row's created_at timestamp. Only backfill + // active paid subscriptions (have a Stripe subscription id and are not on the free Basis plan). + migrationBuilder.Sql( + """ + UPDATE subscriptions + SET subscribed_since = created_at + WHERE subscribed_since IS NULL + AND stripe_subscription_id IS NOT NULL + AND plan <> 'Basis'; + """ + ); + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index a91becd2c2..8aaef61355 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -40,6 +40,8 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) public SubscriptionPlan? ScheduledPlan { get; private set; } + public decimal? ScheduledPriceAmount { get; private set; } + public StripeCustomerId? StripeCustomerId { get; private set; } public StripeSubscriptionId? StripeSubscriptionId { get; private set; } @@ -58,6 +60,8 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) public string? CancellationFeedback { get; private set; } + public DateTimeOffset? SubscribedSince { get; private set; } + public ImmutableArray PaymentTransactions { get; private set; } public PaymentMethod? PaymentMethod { get; private set; } @@ -81,14 +85,23 @@ public void SetBillingInfo(BillingInfo? billingInfo) BillingInfo = billingInfo; } - public void SetStripeSubscription(StripeSubscriptionId? stripeSubscriptionId, SubscriptionPlan plan, decimal? currentPriceAmount, string? currentPriceCurrency, DateTimeOffset? currentPeriodEnd, PaymentMethod? paymentMethod) + public void SetStripeSubscription(StripeSubscriptionId? stripeSubscriptionId, SubscriptionPlan plan, decimal? currentPriceAmount, string? currentPriceCurrency, DateTimeOffset? currentPeriodEnd, PaymentMethod? paymentMethod, DateTimeOffset now) { + var previousPlan = Plan; + StripeSubscriptionId = stripeSubscriptionId; Plan = plan; CurrentPriceAmount = currentPriceAmount; CurrentPriceCurrency = currentPriceCurrency; CurrentPeriodEnd = currentPeriodEnd; PaymentMethod = paymentMethod; + + // Capture the start of a paid run only when transitioning from Basis (free) to a paid plan. + // Plan changes between paid plans (e.g., Standard <-> Premium) preserve the original SubscribedSince. + if (previousPlan == SubscriptionPlan.Basis && plan != SubscriptionPlan.Basis) + { + SubscribedSince = now; + } } public void SetCancellation(bool cancelAtPeriodEnd, CancellationReason? cancellationReason, string? cancellationFeedback) @@ -98,9 +111,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,6 +141,7 @@ public void ResetToFreePlan() { Plan = SubscriptionPlan.Basis; ScheduledPlan = null; + ScheduledPriceAmount = null; StripeSubscriptionId = null; CurrentPriceAmount = null; CurrentPriceCurrency = null; @@ -135,6 +150,7 @@ public void ResetToFreePlan() FirstPaymentFailedAt = null; CancellationReason = null; CancellationFeedback = null; + SubscribedSince = null; } public bool HasActiveStripeSubscription() diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs index 9c80e0254d..603924ce3b 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") diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 0d9858f8e1..b10f1fec35 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -112,7 +112,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, // 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); + subscription.SetStripeSubscription(stripeState.StripeSubscriptionId, stripeState.Plan, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency, stripeState.CurrentPeriodEnd, stripeState.PaymentMethod, now); tenant.UpdatePlan(stripeState.Plan); } @@ -161,10 +161,10 @@ 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 priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); - var scheduledPlanPrice = priceCatalog.Single(p => p.Plan == subscription.ScheduledPlan!.Value).UnitAmount; + var scheduledPlanPrice = priceCatalog.Single(p => p.Plan == stripeState!.ScheduledPlan!.Value).UnitAmount; + subscription.SetScheduledPlan(stripeState!.ScheduledPlan, scheduledPlanPrice); + var daysUntilDowngrade = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; events.CollectEvent(new SubscriptionDowngradeScheduled(subscription.Id, subscription.Plan, subscription.ScheduledPlan!.Value, daysUntilDowngrade, subscription.CurrentPriceAmount!.Value, scheduledPlanPrice - subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); } @@ -172,7 +172,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, { var previousScheduledPlan = subscription.ScheduledPlan; 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; @@ -181,7 +181,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, 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!)); } diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs index 06db0e94be..8c7dc6c6c5 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -15,10 +15,12 @@ public sealed record TenantDetailResponse( string Name, SubscriptionPlan Plan, SubscriptionPlan? ScheduledPlan, + decimal? ScheduledPriceAmount, bool CancelAtPeriodEnd, decimal? MonthlyRecurringRevenue, string? Currency, DateTimeOffset? RenewalDate, + DateTimeOffset? SubscribedSince, BillingAddressResponse? BillingAddress, decimal? LifetimeValue, TenantState State, @@ -65,10 +67,12 @@ public async Task> Handle(GetTenantDetailQuery quer tenant.Name, tenant.Plan, subscription?.ScheduledPlan, + subscription?.ScheduledPriceAmount, subscription?.CancelAtPeriodEnd ?? false, subscription?.CurrentPriceAmount, subscription?.CurrentPriceCurrency, subscription?.CurrentPeriodEnd, + subscription?.SubscribedSince, billingAddress, lifetimeValue, tenant.State, diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs index a9a7ccdeec..b910eae3dc 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs @@ -10,7 +10,7 @@ namespace Account.Features.Tenants.BackOffice.Queries; public sealed record GetTenantUserCountsQuery(TenantId Id) : IRequest>; [PublicAPI] -public sealed record TenantUserCountsResponse(int TotalUsers, int ActiveUsers); +public sealed record TenantUserCountsResponse(int TotalUsers, int ActiveUsers, int PendingUsers); public sealed class GetTenantUserCountsHandler(ITenantRepository tenantRepository, IUserRepository userRepository, TimeProvider timeProvider) : IRequestHandler> @@ -26,7 +26,7 @@ public async Task> Handle(GetTenantUserCountsQu } var activeSince = timeProvider.GetUtcNow().AddDays(-ActiveWindowDays); - var (totalUsers, activeUsers) = await userRepository.GetUserCountsForTenantUnfilteredAsync(tenant.Id, activeSince, cancellationToken); - return new TenantUserCountsResponse(totalUsers, activeUsers); + 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/GetTenants.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs index 4074434592..9447609a30 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -28,11 +28,14 @@ public sealed record TenantsResponse(int TotalCount, int PageSize, int TotalPage 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 ); @@ -50,7 +53,11 @@ public enum PlannedSubscriptionChange public enum SortableTenantProperties { Name, + Plan, MonthlyRecurringRevenue, + RenewalDate, + Status, + Country, CreatedAt } @@ -81,8 +88,16 @@ public async Task> Handle(GetTenantsQuery query, Cancell 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), (SortableTenantProperties.Name, SortOrder.Descending) => summaries.OrderByDescending(s => s.Name), @@ -101,6 +116,18 @@ public async Task> Handle(GetTenantsQuery query, Cancell return new TenantsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); } + private static int StatusSortKey(TenantSummary summary) + { + // Order: Active paid, Downgrading, Canceling, Basis. Stable secondary sort on Name handled by caller. + return summary switch + { + { PlannedChange: PlannedSubscriptionChange.Cancellation } => 2, + { PlannedChange: PlannedSubscriptionChange.ScheduledPlanChange } => 1, + { Plan: not SubscriptionPlan.Basis } => 0, + _ => 3 + }; + } + private static TenantSummary MapTenantSummary(Tenant tenant, Subscription? subscription) { var plannedChange = subscription switch @@ -110,14 +137,20 @@ private static TenantSummary MapTenantSummary(Tenant tenant, Subscription? subsc _ => (PlannedSubscriptionChange?)null }; + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == 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 ); diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 4f5a8f4299..ea9c993668 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -46,10 +46,11 @@ CancellationToken cancellationToken Task GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken); /// - /// Returns total and 30-day active user counts for the given tenant without applying tenant query filters. + /// 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)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken); + 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. @@ -280,30 +281,31 @@ public async Task GetUsersByEmailUnfilteredAsync(string email, Cancellat } /// - /// Returns total and 30-day active user counts for the given tenant without applying tenant query filters. + /// 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)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken) + 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 LastSeenAt and counts in memory, bounded by tenant size. + // 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 lastSeen = await DbSet + var users = await DbSet .IgnoreQueryFilters([QueryFilterNames.Tenant]) .Where(u => u.TenantId == tenantId) - .Select(u => u.LastSeenAt) + .Select(u => new { u.LastSeenAt, u.EmailConfirmed }) .ToListAsync(cancellationToken); - return (lastSeen.Count, lastSeen.Count(t => t.HasValue && t.Value >= activeSince)); + 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.LastSeenAt >= activeSince) }) + .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); + return (counts?.Total ?? 0, counts?.Active ?? 0, counts?.Pending ?? 0); } /// @@ -355,7 +357,4 @@ CancellationToken cancellationToken [UsedImplicitly] private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers); - - [UsedImplicitly] - private sealed record TenantUserCountResult(int TotalUsers, int ActiveUsers); } diff --git a/application/account/Tests/Subscriptions/Domain/SubscriptionTests.cs b/application/account/Tests/Subscriptions/Domain/SubscriptionTests.cs new file mode 100644 index 0000000000..b36e603e14 --- /dev/null +++ b/application/account/Tests/Subscriptions/Domain/SubscriptionTests.cs @@ -0,0 +1,103 @@ +using Account.Features.Subscriptions.Domain; +using FluentAssertions; +using SharedKernel.Domain; +using Xunit; + +namespace Account.Tests.Subscriptions.Domain; + +public sealed class SubscriptionTests +{ + [Fact] + public void SetStripeSubscription_WhenFirstActivationOnPaidPlan_ShouldCaptureSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var now = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + + // Act + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Standard, 29.99m, "USD", now.AddDays(30), null, now); + + // Assert + subscription.SubscribedSince.Should().Be(now); + } + + [Fact] + public void SetStripeSubscription_WhenActivatingFreePlan_ShouldNotCaptureSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var now = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + + // Act + subscription.SetStripeSubscription(null, SubscriptionPlan.Basis, null, null, null, null, now); + + // Assert + subscription.SubscribedSince.Should().BeNull(); + } + + [Fact] + public void SetStripeSubscription_WhenAlreadyActive_ShouldNotOverwriteSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var firstActivation = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Standard, 29.99m, "USD", firstActivation.AddDays(30), null, firstActivation); + var laterUpdate = DateTimeOffset.Parse("2026-02-20T10:00:00Z"); + + // Act + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Premium, 99.99m, "USD", laterUpdate.AddDays(30), null, laterUpdate); + + // Assert + subscription.SubscribedSince.Should().Be(firstActivation); + } + + [Fact] + public void ResetToFreePlan_WhenCalled_ShouldClearSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var activationTime = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Standard, 29.99m, "USD", activationTime.AddDays(30), null, activationTime); + + // Act + subscription.ResetToFreePlan(); + + // Assert + subscription.SubscribedSince.Should().BeNull(); + } + + [Fact] + public void SetStripeSubscription_WhenChangingBetweenPaidPlans_ShouldPreserveSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var firstActivation = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Standard, 29.99m, "USD", firstActivation.AddDays(30), null, firstActivation); + var upgradeTime = DateTimeOffset.Parse("2026-02-20T10:00:00Z"); + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Premium, 99.99m, "USD", upgradeTime.AddDays(30), null, upgradeTime); + var downgradeTime = DateTimeOffset.Parse("2026-03-25T10:00:00Z"); + + // Act - downgrade Premium back to Standard + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_test"), SubscriptionPlan.Standard, 29.99m, "USD", downgradeTime.AddDays(30), null, downgradeTime); + + // Assert - SubscribedSince must remain the original first activation date through both paid-plan changes + subscription.SubscribedSince.Should().Be(firstActivation); + } + + [Fact] + public void SetStripeSubscription_WhenReactivatingAfterReset_ShouldCaptureNewSubscribedSince() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var firstActivation = DateTimeOffset.Parse("2026-01-15T10:00:00Z"); + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_first"), SubscriptionPlan.Standard, 29.99m, "USD", firstActivation.AddDays(30), null, firstActivation); + subscription.ResetToFreePlan(); + var reactivation = DateTimeOffset.Parse("2026-04-01T10:00:00Z"); + + // Act + subscription.SetStripeSubscription(new StripeSubscriptionId("sub_second"), SubscriptionPlan.Standard, 29.99m, "USD", reactivation.AddDays(30), null, reactivation); + + // Assert + subscription.SubscribedSince.Should().Be(reactivation); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index 1290c34e0b..934587a874 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -27,8 +27,8 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("modified_at", null), ("name", "Acme Corp"), ("state", nameof(TenantState.Active)), - ("logo", """{"Url":"https://example.com/logo.png","Version":1}"""), - ("plan", nameof(SubscriptionPlan.Premium)) + ("plan", nameof(SubscriptionPlan.Premium)), + ("logo", """{"Url":"https://example.com/logo.png","Version":1}""") ] ); @@ -36,6 +36,7 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() var transactions = ImmutableArray.Create( new PaymentTransaction(PaymentTransactionId.NewId(), 199.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, null, null) ); + var subscribedSince = DateTimeOffset.Parse("2025-02-01T00:00:00Z"); Connection.Insert("subscriptions", [ ("tenant_id", tenantId.Value), ("id", SubscriptionId.NewId().ToString()), @@ -52,6 +53,7 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("first_payment_failed_at", null), ("cancellation_reason", null), ("cancellation_feedback", null), + ("subscribed_since", subscribedSince), ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())), ("payment_method", null), ("billing_info", billingInfoJson) @@ -78,6 +80,36 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() payload.BillingAddress.Country.Should().Be("US"); payload.BillingAddress.City.Should().Be("Springfield"); payload.LogoUrl.Should().Be("https://example.com/logo.png"); + payload.SubscribedSince.Should().Be(subscribedSince); + } + + [Fact] + public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscribedSince() + { + // Arrange + var tenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddDays(-5)), + ("modified_at", null), + ("name", "No Subscription Inc"), + ("state", nameof(TenantState.Active)), + ("plan", nameof(SubscriptionPlan.Basis)), + ("logo", """{"Url":null,"Version":1}""") + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.SubscribedSince.Should().BeNull(); } [Fact] diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs index b5ad39bdbd..888f5b996e 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantUserCountsTests.cs @@ -15,7 +15,7 @@ namespace Account.Tests.Tenants.BackOffice; public sealed class GetTenantUserCountsTests : BackOfficeEndpointBaseTest { [Fact] - public async Task GetTenantUserCounts_WhenCalled_ShouldReturnTotalAndActiveCounts() + public async Task GetTenantUserCounts_WhenCalled_ShouldReturnTotalActiveAndPendingCounts() { // Arrange var tenant = DatabaseSeeder.Tenant1; @@ -23,6 +23,7 @@ public async Task GetTenantUserCounts_WhenCalled_ShouldReturnTotalAndActiveCount SeedUser(tenant.Id, "active2@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-15)); SeedUser(tenant.Id, "inactive@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-60)); SeedUser(tenant.Id, "neverseen@tenant-1.com", null); + SeedUser(tenant.Id, "pending@tenant-1.com", null, false); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -34,9 +35,31 @@ public async Task GetTenantUserCounts_WhenCalled_ShouldReturnTotalAndActiveCount response.StatusCode.Should().Be(HttpStatusCode.OK); var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); - // DatabaseSeeder seeds Tenant1 with two users (Owner, Member) plus our four; only the two seeded users have last_seen_at = null and the two recent ones above are active. - payload.TotalUsers.Should().Be(6); + // DatabaseSeeder seeds Tenant1 with two confirmed users (Owner, Member) without last_seen_at, plus our five users. + payload.TotalUsers.Should().Be(7); payload.ActiveUsers.Should().Be(2); + payload.PendingUsers.Should().Be(1); + } + + [Fact] + public async Task GetTenantUserCounts_WhenUserHasUnconfirmedEmail_ShouldCountAsPendingNotActive() + { + // Arrange + var tenant = DatabaseSeeder.Tenant1; + SeedUser(tenant.Id, "pending-recent@tenant-1.com", DateTimeOffset.UtcNow.AddDays(-1), false); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenant.Id}/user-counts"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.PendingUsers.Should().Be(1); + payload.ActiveUsers.Should().Be(0); } [Fact] @@ -54,7 +77,7 @@ public async Task GetTenantUserCounts_WhenTenantNotFound_ShouldReturnNotFound() response.StatusCode.Should().Be(HttpStatusCode.NotFound); } - private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenAt) + private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenAt, bool emailConfirmed = true) { Connection.Insert("users", [ ("tenant_id", tenantId.Value), @@ -64,7 +87,7 @@ private void SeedUser(TenantId tenantId, string email, DateTimeOffset? lastSeenA ("last_seen_at", (object?)lastSeenAt ?? DBNull.Value), ("email", email), ("external_identities", "[]"), - ("email_confirmed", true), + ("email_confirmed", emailConfirmed), ("first_name", null), ("last_name", null), ("title", null), diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 076db04454..242aff18a6 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -17,21 +17,13 @@ namespace Account.Tests.Tenants.BackOffice; public sealed class GetTenantsTests : BackOfficeEndpointBaseTest { - private readonly TenantId _tenantA; - private readonly TenantId _tenantB; - private readonly TenantId _tenantC; - - public GetTenantsTests() - { - _tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); - _tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); - _tenantC = SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); - } - [Fact] public async Task GetTenants_WhenCalled_ShouldReturnAllTenantsWithSummaryFields() { // Arrange + SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + var tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + var tenantC = SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -46,7 +38,7 @@ public async Task GetTenants_WhenCalled_ShouldReturnAllTenantsWithSummaryFields( payload.TotalCount.Should().Be(4); payload.Tenants.Should().HaveCount(4); - var beta = payload.Tenants.Single(t => t.Id == _tenantB); + var beta = payload.Tenants.Single(t => t.Id == tenantB); beta.Name.Should().Be("Beta Industries"); beta.Plan.Should().Be(SubscriptionPlan.Premium); beta.MonthlyRecurringRevenue.Should().Be(199.00m); @@ -54,7 +46,7 @@ public async Task GetTenants_WhenCalled_ShouldReturnAllTenantsWithSummaryFields( beta.Country.Should().Be("DE"); beta.PlannedChange.Should().Be(PlannedSubscriptionChange.Cancellation); - var cyrus = payload.Tenants.Single(t => t.Id == _tenantC); + var cyrus = payload.Tenants.Single(t => t.Id == tenantC); cyrus.PlannedChange.Should().Be(PlannedSubscriptionChange.ScheduledPlanChange); cyrus.MonthlyRecurringRevenue.Should().BeNull(); } @@ -63,6 +55,9 @@ public async Task GetTenants_WhenCalled_ShouldReturnAllTenantsWithSummaryFields( public async Task GetTenants_WhenSearchingByName_ShouldReturnMatchingTenants() { // Arrange + var tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -74,31 +69,36 @@ public async Task GetTenants_WhenSearchingByName_ShouldReturnMatchingTenants() var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); payload.TotalCount.Should().Be(1); - payload.Tenants.Single().Id.Should().Be(_tenantA); + payload.Tenants.Single().Id.Should().Be(tenantA); } [Fact] public async Task GetTenants_WhenSearchingByExactId_ShouldReturnMatchingTenant() { // Arrange + var tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); // Act - var response = await client.GetAsync($"/api/back-office/tenants?search={_tenantA.Value}"); + var response = await client.GetAsync($"/api/back-office/tenants?search={tenantA.Value}"); // Assert response.StatusCode.Should().Be(HttpStatusCode.OK); var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); payload.TotalCount.Should().Be(1); - payload.Tenants.Single().Id.Should().Be(_tenantA); + payload.Tenants.Single().Id.Should().Be(tenantA); } [Fact] public async Task GetTenants_WhenFilteringByPlan_ShouldReturnOnlyMatchingPlan() { // Arrange + SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + var tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -110,13 +110,16 @@ public async Task GetTenants_WhenFilteringByPlan_ShouldReturnOnlyMatchingPlan() var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); payload.TotalCount.Should().Be(1); - payload.Tenants.Single().Id.Should().Be(_tenantB); + payload.Tenants.Single().Id.Should().Be(tenantB); } [Fact] public async Task GetTenants_WhenSortingByMonthlyRecurringRevenueDescending_ShouldReturnHighestFirst() { // Arrange + var tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + var tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + var tenantC = SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -127,13 +130,16 @@ public async Task GetTenants_WhenSortingByMonthlyRecurringRevenueDescending_Shou response.StatusCode.Should().Be(HttpStatusCode.OK); var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); - payload.Tenants.Select(t => t.Id).Should().ContainInOrder(_tenantB, _tenantA, _tenantC); + payload.Tenants.Select(t => t.Id).Should().ContainInOrder(tenantB, tenantA, tenantC); } [Fact] public async Task GetTenants_WhenSortingByCreatedAtAscending_ShouldReturnOldestFirst() { // Arrange + var tenantA = SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + var tenantB = SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + var tenantC = SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -144,13 +150,14 @@ public async Task GetTenants_WhenSortingByCreatedAtAscending_ShouldReturnOldestF response.StatusCode.Should().Be(HttpStatusCode.OK); var payload = await response.Content.ReadFromJsonAsync(); payload.Should().NotBeNull(); - payload.Tenants.Select(t => t.Id).Should().ContainInOrder(_tenantA, _tenantB, _tenantC); + payload.Tenants.Select(t => t.Id).Should().ContainInOrder(tenantA, tenantB, tenantC); } [Fact] public async Task GetTenants_WhenPagingBeyondAvailable_ShouldReturnBadRequest() { // Arrange + SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -165,6 +172,9 @@ public async Task GetTenants_WhenPagingBeyondAvailable_ShouldReturnBadRequest() public async Task GetTenants_WhenPagingWithSize_ShouldReturnPagedSlice() { // Arrange + SeedTenant("Acme Corp", SubscriptionPlan.Standard, 49.99m, "USD", "US", false, null, 30); + SeedTenant("Beta Industries", SubscriptionPlan.Premium, 199.00m, "EUR", "DE", true, null, 20); + SeedTenant("Cyrus Co", SubscriptionPlan.Basis, null, null, null, false, SubscriptionPlan.Standard, 10); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); From d97d0f33d2ecad8245ebfc082a537e6cca8f0ed9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sun, 3 May 2026 21:29:44 +0200 Subject: [PATCH 005/158] Polish back-office tenant overview list and detail --- .../BackOffice/routes/accounts/$tenantId.tsx | 59 ++--- .../-components/AccountBillingTab.tsx | 150 ++++--------- .../-components/AccountCurrentPlanCard.tsx | 145 ++++++++++++ .../-components/AccountDetailHeader.tsx | 110 ++++----- .../accounts/-components/AccountKpiCards.tsx | 128 +++++++++++ .../-components/AccountOverviewTab.tsx | 122 +++------- .../accounts/-components/AccountSidePane.tsx | 32 +-- .../-components/AccountSidePaneSections.tsx | 211 ++++++++++-------- .../accounts/-components/AccountUserRow.tsx | 5 +- .../accounts/-components/AccountUsersTab.tsx | 57 +++-- .../accounts/-components/AccountsTable.tsx | 54 +---- .../AccountsTableColumnHeaders.tsx | 86 +++++++ .../accounts/-components/AccountsTableRow.tsx | 128 ++++++++--- .../accounts/-components/AccountsToolbar.tsx | 4 +- .../accounts/-components/SidePaneSection.tsx | 14 +- .../accounts/-components/SidePaneUserList.tsx | 11 +- .../accounts/-components/SidePaneUsersRow.tsx | 57 +++++ .../SubscriptionStatusIndicator.tsx | 35 +-- .../BackOffice/shared/lib/relativeTime.ts | 24 ++ .../shared/translations/locale/da-DK.po | 97 ++++---- .../shared/translations/locale/en-US.po | 97 ++++---- 21 files changed, 967 insertions(+), 659 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountKpiCards.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx create mode 100644 application/account/BackOffice/shared/lib/relativeTime.ts diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index 1ace145ea6..fc53519532 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -4,41 +4,35 @@ 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 } from "@tanstack/react-router"; -import { z } from "zod"; +import { LayoutGridIcon, UsersIcon } from "lucide-react"; 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 { AccountKpiCards } from "./-components/AccountKpiCards"; import { AccountOverviewTab } from "./-components/AccountOverviewTab"; import { AccountUsersTab } from "./-components/AccountUsersTab"; -const detailSearchSchema = z.object({ - tab: z.enum(["overview", "users", "billing"]).optional() -}); - export const Route = createFileRoute("/accounts/$tenantId")({ staticData: { trackingTitle: "Account detail" }, - validateSearch: detailSearchSchema, component: AccountDetailPage }); function AccountDetailPage() { const { tenantId } = Route.useParams(); - const { tab } = Route.useSearch(); - const navigate = Route.useNavigate(); const tenantQuery = api.useQuery("get", "/api/back-office/tenants/{id}", { params: { path: { id: tenantId } } }); - const userCountsQuery = api.useQuery("get", "/api/back-office/tenants/{id}/user-counts", { params: { path: { id: tenantId } } }); - const activeTab = tab ?? "overview"; const tenant = tenantQuery.data; + const totalUsers = userCountsQuery.data?.totalUsers; return ( @@ -46,44 +40,33 @@ function AccountDetailPage() {
- - - - navigate({ - to: "/accounts/$tenantId", - params: { tenantId }, - search: { tab: value === "overview" ? undefined : (value as "users" | "billing") } - }) - } - > - + + + + + Overview - Users - - - Billing & invoices + + {totalUsers === undefined ? Users : Users ({totalUsers})} - - + +
+
+ +
+
+ +
+
- + - - -
diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx index 5583153942..9432031f13 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -8,20 +8,15 @@ import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { keepPreviousData } from "@tanstack/react-query"; import { useState } from "react"; -import type { components } from "@/shared/lib/api/client"; - import { api } from "@/shared/lib/api/client"; import { AccountPaymentRow } from "./AccountPaymentRow"; -type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; - interface AccountBillingTabProps { - tenant: TenantDetailResponse | undefined; tenantId: string; } -export function AccountBillingTab({ tenant, tenantId }: Readonly) { +export function AccountBillingTab({ tenantId }: Readonly) { const formatDate = useFormatDate(); const [pageOffset, setPageOffset] = useState(0); @@ -39,54 +34,32 @@ export function AccountBillingTab({ tenant, tenantId }: Readonly -
-

- Billing address -

- {!tenant ? ( - - ) : tenant.billingAddress ? ( - - ) : ( - - - - No billing address - - - No billing address on file. - - - - )} -
- -
-

- Payment history -

- {isLoading && transactions.length === 0 ? ( -
- {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
- ) : transactions.length === 0 ? ( - - - - No payments - - - No payments yet. - - - - ) : ( -
- - +
+

+ Payment history +

+ {isLoading && transactions.length === 0 ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : transactions.length === 0 ? ( + + + + No payments + + + No payments yet. + + + + ) : ( +
+
+
+ Date @@ -97,9 +70,7 @@ export function AccountBillingTab({ tenant, tenantId }: Readonly Status - - Documents - + @@ -109,55 +80,22 @@ export function AccountBillingTab({ tenant, tenantId }: Readonly
- )} - - {totalPages > 1 && ( -
- setPageOffset(page - 1)} - previousLabel={t`Previous`} - nextLabel={t`Next`} - trackingTitle="Payment history" - className="w-full" - /> -
- )} -
-
- ); -} +
+ )} -function BillingAddress({ address }: Readonly<{ address: components["schemas"]["BillingAddressResponse"] }>) { - const lines = [ - address.line1, - address.line2, - [address.postalCode, address.city].filter(Boolean).join(" ").trim() || null, - address.state, - address.country - ].filter((value): value is string => Boolean(value && value.trim().length > 0)); - - if (lines.length === 0) { - return ( - - - - No billing address - - - No billing address on file. - - - - ); - } - - return ( -
- {lines.map((line) => ( -
{line}
- ))} -
+ {totalPages > 1 && ( +
+ setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Payment history" + className="w-full" + /> +
+ )} + ); } 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..00e4f6551d --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx @@ -0,0 +1,145 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Card } from "@repo/ui/components/Card"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +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"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountCurrentPlanCardProps { + tenant: TenantDetailResponse | undefined; + isLoading: boolean; +} + +export function AccountCurrentPlanCard({ tenant, isLoading }: Readonly) { + const formatDate = useFormatDate(); + + if (isLoading || !tenant) { + return ( +
+

+ Current plan +

+ + + + + + + + +
+ ); + } + + const monthlyAmount = + tenant.monthlyRecurringRevenue !== null && tenant.currency !== null + ? formatCurrency(tenant.monthlyRecurringRevenue, tenant.currency) + : "-"; + + const isCanceling = tenant.cancelAtPeriodEnd; + const isDowngrading = !isCanceling && tenant.scheduledPlan !== null; + 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, + tenant.billingAddress.country + ].filter((value): value is string => Boolean(value && value.trim().length > 0)) + : []; + + return ( +
+

+ Current plan +

+ +
+
+ {showStrikedAmount ? ( +
+ {monthlyAmount} + {newMonthlyAmount} +
+ ) : ( + {monthlyAmount} + )} +
+ + {getSubscriptionPlanLabel(tenant.plan)} + + {isCanceling ? ( + + + Canceling + + ) : isDowngrading ? ( + + + Downgrading + + ) : null} +
+
+ + per month, billed monthly + +
+ +
+ +
+
+
+
+ Subscribed since +
+
{tenant.subscribedSince ? formatDate(tenant.subscribedSince) : "-"}
+
+
+
+ Renewal date +
+
{tenant.renewalDate ? formatDate(tenant.renewalDate) : "-"}
+
+
+ +
+ +
+ + Billing address + + {billingAddressLines.length === 0 ? ( + + No billing address on file. + + ) : ( +
+ {billingAddressLines.map((line) => ( +
{line}
+ ))} +
+ )} +
+
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index ec7082dd3b..5a6b6c48ba 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -1,54 +1,34 @@ 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 { Skeleton } from "@repo/ui/components/Skeleton"; import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; -import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; import { Link } from "@tanstack/react-router"; import { ArrowLeftIcon, CalendarClockIcon, XCircleIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; -import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; -import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { TenantState } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel, getTenantStateLabel } from "@/shared/lib/api/labels"; import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; -function formatAmount(amount: number | null, currency: string | null): string { - if (amount === null || currency === null) { - return "-"; - } - return formatCurrency(amount, currency); -} - type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; -type TenantUserCountsResponse = components["schemas"]["TenantUserCountsResponse"]; interface AccountDetailHeaderProps { tenant: TenantDetailResponse | undefined; isLoading: boolean; - userCounts: TenantUserCountsResponse | undefined; - isLoadingUserCounts: boolean; } -export function AccountDetailHeader({ - tenant, - isLoading, - userCounts, - isLoadingUserCounts -}: Readonly) { +export function AccountDetailHeader({ tenant, isLoading }: Readonly) { const formatDate = useFormatDate(); - - const plannedChange = ((): PlannedSubscriptionChange | null => { - if (!tenant) return null; - if (tenant.cancelAtPeriodEnd) return PlannedSubscriptionChange.Cancellation; - if (tenant.scheduledPlan) return PlannedSubscriptionChange.ScheduledPlanChange; - return null; - })(); + const { i18n } = useLingui(); return ( -
+
+ + )} + + ) : ( +
+ +
+ )} + + + + ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx index a3edaad307..a4f52588cf 100644 --- a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx +++ b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx @@ -13,7 +13,7 @@ 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, FlagIcon, HomeIcon, LifeBuoyIcon, ListIcon, UsersIcon, ZapIcon } from "lucide-react"; import { BackOfficeAvatarMenu } from "./BackOfficeAvatarMenu"; @@ -24,6 +24,7 @@ export function BackOfficeSideMenu() { 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/"); return ( @@ -68,6 +69,16 @@ export function BackOfficeSideMenu() { + + + + + + Billing events + + + + diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 5d9d454072..ca96946c0c 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -133,6 +133,9 @@ msgstr "Der opstod en uventet fejl ved behandlingen." msgid "App Insights · Telemetry" msgstr "App Insights · Telemetri" +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" @@ -154,6 +157,9 @@ msgstr "Basis" msgid "Billing address" msgstr "Faktureringsadresse" +msgid "Billing events" +msgstr "Faktureringshændelser" + msgid "Billing info added" msgstr "Faktureringsoplysninger tilføjet" @@ -267,6 +273,12 @@ msgstr "E-mail bekræftet" msgid "Email pending" msgstr "E-mail afventer" +msgid "Event" +msgstr "Hændelse" + +msgid "Event type" +msgstr "Hændelsestype" + msgid "Exceptions" msgstr "Fejl" @@ -421,6 +433,12 @@ msgstr "Ingen adgang til Back Office" msgid "No billing address on file." msgstr "Ingen faktureringsadresse registreret." +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 custom events" msgstr "Ingen brugerdefinerede hændelser" @@ -484,6 +502,9 @@ msgstr "Ingen brugere matcher dine filtre." msgid "No users match your search" msgstr "Ingen brugere matcher din søgning" +msgid "Occurred" +msgstr "Tidspunkt" + msgid "One-time password" msgstr "Engangskode" @@ -547,6 +568,9 @@ msgstr "Abonnement og omsætning" msgid "Plan distribution" msgstr "Plan-fordeling" +msgid "Plan transition" +msgstr "Abonnementsændring" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -608,6 +632,9 @@ 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" @@ -660,6 +687,9 @@ msgstr "Abonneret siden" msgid "Subscription canceled" msgstr "Abonnement opsagt" +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 "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 4f1b66c803..f44c2ef4a0 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -133,6 +133,9 @@ msgstr "An unexpected error occurred while processing your request." msgid "App Insights · Telemetry" msgstr "App Insights · Telemetry" +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" @@ -154,6 +157,9 @@ msgstr "Basis" msgid "Billing address" msgstr "Billing address" +msgid "Billing events" +msgstr "Billing events" + msgid "Billing info added" msgstr "Billing info added" @@ -267,6 +273,12 @@ msgstr "Email confirmed" msgid "Email pending" msgstr "Email pending" +msgid "Event" +msgstr "Event" + +msgid "Event type" +msgstr "Event type" + msgid "Exceptions" msgstr "Exceptions" @@ -421,6 +433,12 @@ msgstr "No back-office access" msgid "No billing address on file." msgstr "No billing address on file." +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 custom events" msgstr "No custom events" @@ -484,6 +502,9 @@ msgstr "No users match your filters." msgid "No users match your search" msgstr "No users match your search" +msgid "Occurred" +msgstr "Occurred" + msgid "One-time password" msgstr "One-time password" @@ -547,6 +568,9 @@ msgstr "Plan & revenue" msgid "Plan distribution" msgstr "Plan distribution" +msgid "Plan transition" +msgstr "Plan transition" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -608,6 +632,9 @@ 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" @@ -660,6 +687,9 @@ msgstr "Subscribed since" msgid "Subscription canceled" msgstr "Subscription canceled" +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 "Subscriptions, upgrades, and cancellations will appear here." msgstr "Subscriptions, upgrades, and cancellations will appear here." From ca7f3e4549f9d371130853df4c1553f117c134e2 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 May 2026 19:39:04 +0200 Subject: [PATCH 029/158] Add @smoke e2e test for back-office /billing-events flow --- .../tests/e2e/billing-events-flows.spec.ts | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 application/account/WebApp/tests/e2e/billing-events-flows.spec.ts diff --git a/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts new file mode 100644 index 0000000000..52aa0ea272 --- /dev/null +++ b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts @@ -0,0 +1,118 @@ +import { expect } from "@playwright/test"; +import { test } from "@shared/e2e/fixtures/page-auth"; +import { getBackOfficeBaseUrl } from "@shared/e2e/utils/constants"; +import { createTestContext } from "@shared/e2e/utils/test-assertions"; +import { step } from "@shared/e2e/utils/test-step-wrapper"; + +const BACK_OFFICE_BASE_URL = getBackOfficeBaseUrl(); + +test.describe("@smoke", () => { + /** + * Covers the back-office /billing-events page end-to-end: side-menu navigation, table render with seeded + * BillingEvent rows, event-type filter, account search, and the dashboard `View all` link landing here. + * + * The side pane and date-range filter from the original PP-1203 spec are not exercised because PP-1202 + * intentionally shipped without those affordances — the table-with-filters surface covers the primary + * "scan recent events / find a specific account's events" use case. + */ + test("should render billing-events list, filter by event type and account, and navigate via dashboard view-all", async ({ + ownerPage, + browser + }) => { + createTestContext(ownerPage); + + const backOfficeContext = await browser.newContext({ baseURL: BACK_OFFICE_BASE_URL, ignoreHTTPSErrors: true }); + const page = await backOfficeContext.newPage(); + createTestContext(page); + + await step("Log in as Admin via MockEasyAuth & verify redirect to dashboard")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/`); + + await expect(page.getByRole("radio", { name: "Admin Log in with admin rights" })).toBeVisible(); + await page.getByRole("radio", { name: "Admin Log in with admin rights" }).click(); + await page.getByRole("button", { name: "Log in" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/`); + })(); + + // === SIDE MENU NAVIGATION === + + await step("Click side-menu Billing events & verify route lands on /billing-events with heading and table")( + async () => { + await page.getByRole("link", { name: "Billing events" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + await expect(page.getByRole("heading", { name: "Billing events" })).toBeVisible(); + await expect(page.getByRole("table", { name: "Billing events" })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Account" })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Event" })).toBeVisible(); + await expect(page.getByRole("columnheader", { name: "Occurred" })).toBeVisible(); + await expect(page.getByRole("row").nth(1)).toBeVisible(); + } + )(); + + // === FILTERS === + + await step("Apply Subscribed event-type filter & verify chip becomes pressed and table stays visible")(async () => { + const subscribedChip = page.getByRole("button", { name: "Subscribed" }); + await subscribedChip.click(); + + await expect(subscribedChip).toHaveAttribute("aria-pressed", "true"); + await expect(page.getByRole("table", { name: "Billing events" })).toBeVisible(); + })(); + + await step("Clear event-type filter & verify chip is unpressed and URL returns to base /billing-events")( + async () => { + const subscribedChip = page.getByRole("button", { name: "Subscribed" }); + await subscribedChip.click(); + + await expect(subscribedChip).toHaveAttribute("aria-pressed", "false"); + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + } + )(); + + await step( + "Type a deliberately non-matching tenant search & verify URL reflects search query and empty state appears" + )(async () => { + // Use a string that almost certainly will not match any seeded tenant so the empty state + // renders. This avoids coupling the test to specific dev seed tenant names. + await page.getByRole("searchbox", { name: "Search" }).fill("zzz-no-match-account-xyz"); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events?search=zzz-no-match-account-xyz`); + await expect(page.getByText("No billing events match your filters")).toBeVisible(); + })(); + + await step("Clear search & verify URL returns to base /billing-events")(async () => { + await page.getByRole("searchbox", { name: "Search" }).fill(""); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + })(); + + // === SORT TOGGLE === + + await step("Click Account column header & verify sort URL params populate")(async () => { + await page.getByRole("columnheader", { name: "Account" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events?orderBy=TenantName`); + })(); + + // === DASHBOARD `VIEW ALL` === + + await step("Navigate back to dashboard & verify Recent Stripe events card is present")(async () => { + await page.goto(`${BACK_OFFICE_BASE_URL}/`); + + await expect(page.getByText("Recent Stripe events", { exact: true })).toBeVisible(); + })(); + + await step("Click Recent Stripe events View all link & verify lands on /billing-events")(async () => { + // Target the specific View all link by its href so we are not coupled to surrounding card markup + // (the dashboard has multiple "View all" links: one for Accounts and one for Billing events). + await page.locator("a[href='/billing-events']", { hasText: "View all" }).click(); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + await expect(page.getByRole("heading", { name: "Billing events" })).toBeVisible(); + })(); + + await backOfficeContext.close(); + }); +}); From 9871b87a1c3680fd5bbcd9c56697b56390150e2a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 May 2026 19:52:22 +0200 Subject: [PATCH 030/158] Add Sync with Stripe admin action on tenant detail page --- .../Api/BackOffice/TenantsEndpoints.cs | 5 + .../BackOffice/routes/accounts/$tenantId.tsx | 2 +- .../-components/AccountDetailHeader.tsx | 7 +- .../-components/SyncWithStripeButton.tsx | 104 ++++++++++++++++++ .../shared/translations/locale/da-DK.po | 31 ++++++ .../shared/translations/locale/en-US.po | 31 ++++++ .../Shared/ProcessPendingStripeEvents.cs | 10 +- .../account/Core/Features/TelemetryEvents.cs | 3 + .../Commands/SyncTenantWithStripe.cs | 68 ++++++++++++ .../BackOffice/SyncTenantWithStripeTests.cs | 80 ++++++++++++++ 10 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx create mode 100644 application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs create mode 100644 application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs index 057a9f9d6c..ea49f9bb8a 100644 --- a/application/account/Api/BackOffice/TenantsEndpoints.cs +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -1,3 +1,4 @@ +using Account.Features.Tenants.BackOffice.Commands; using Account.Features.Tenants.BackOffice.Queries; using Microsoft.Extensions.Options; using SharedKernel.ApiResults; @@ -46,5 +47,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) 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}/sync-with-stripe", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new SyncTenantWithStripeCommand { TenantId = id }) + ).Produces(); } } diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index fc53519532..973a3bdebb 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -40,7 +40,7 @@ function AccountDetailPage() {
- + diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index 44a41c041c..5795abd66e 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -16,22 +16,24 @@ import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client" import { getSubscriptionPlanLabel, getTenantStateLabel } from "@/shared/lib/api/labels"; import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; +import { SyncWithStripeButton } from "./SyncWithStripeButton"; import { TenantStatusBadge } from "./TenantStatusBadge"; type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; interface AccountDetailHeaderProps { tenant: TenantDetailResponse | undefined; + tenantId: string; isLoading: boolean; } -export function AccountDetailHeader({ tenant, isLoading }: Readonly) { +export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly) { const formatDate = useFormatDate(); const { i18n } = useLingui(); return (
-
+
+
diff --git a/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx b/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx new file mode 100644 index 0000000000..ca46707034 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx @@ -0,0 +1,104 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogMedia, + AlertDialogTitle +} from "@repo/ui/components/AlertDialog"; +import { Button } from "@repo/ui/components/Button"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { AlertTriangleIcon, CheckCircle2Icon, RefreshCwIcon } from "lucide-react"; +import { useState } from "react"; + +import { api } from "@/shared/lib/api/client"; + +interface SyncWithStripeButtonProps { + tenantId: string; +} + +interface SyncResult { + billingEventsAppended: number; + hasDriftDetected: boolean; + driftDiscrepancyCount: number; + syncedAt: string; +} + +export function SyncWithStripeButton({ tenantId }: Readonly) { + const formatDate = useFormatDate(); + const [result, setResult] = useState(null); + const [isResultOpen, setIsResultOpen] = useState(false); + + const syncMutation = api.useMutation("post", "/api/back-office/tenants/{id}/sync-with-stripe", { + onSuccess: (data) => { + setResult(data); + setIsResultOpen(true); + } + }); + + const handleClick = () => { + syncMutation.mutate({ params: { path: { id: tenantId } } }); + }; + + return ( + <> + + + + + + + {result?.hasDriftDetected ? ( + + ) : ( + + )} + + + {result?.hasDriftDetected ? ( + Sync complete with drift detected + ) : ( + Sync 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 synced at{" "} + {formatDate(result.syncedAt)}. + + ) : ( + + Account has {result.driftDiscrepancyCount} drift discrepancies. Last synced at{" "} + {formatDate(result.syncedAt)}. + + )} + + + + + Close + + + + + + ); +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index ca96946c0c..674fce4510 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -94,6 +94,11 @@ msgstr "Konto" msgid "Account growth" msgstr "Kontovækst" +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.syncedAt) +msgid "Account has {0} drift discrepancies. Last synced at {1}." +msgstr "Kontoen har {0} afvigelser. Senest synkroniseret {1}." + msgid "Account preview" msgstr "Kontoforhåndsvisning" @@ -133,6 +138,11 @@ msgstr "Der opstod en uventet fejl ved behandlingen." msgid "App Insights · Telemetry" msgstr "App Insights · Telemetri" +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.syncedAt) +msgid "Appended {0} new billing events. Last synced at {1}." +msgstr "Tilføjede {0} nye faktureringshændelser. Senest synkroniseret {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." @@ -202,6 +212,9 @@ msgstr "Ryd filtre" msgid "Clear search" msgstr "Ryd søgning" +msgid "Close" +msgstr "Luk" + msgid "Close account preview" msgstr "Luk kontoforhåndsvisning" @@ -457,6 +470,9 @@ 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" @@ -487,6 +503,9 @@ msgstr "Ingen nylige tilmeldinger" msgid "No recent Stripe events" msgstr "Ingen nylige Stripe-hændelser" +msgid "No result available." +msgstr "Intet resultat tilgængeligt." + msgid "No sessions" msgstr "Ingen sessioner" @@ -705,6 +724,18 @@ msgstr "Support (kommer snart)" msgid "Suspended" msgstr "Suspenderet" +msgid "Sync complete" +msgstr "Synkronisering færdig" + +msgid "Sync complete with drift detected" +msgstr "Synkronisering færdig med afvigelser fundet" + +msgid "Sync with Stripe" +msgstr "Synkroniser med Stripe" + +msgid "Syncing..." +msgstr "Synkroniserer..." + msgid "System" msgstr "System" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index f44c2ef4a0..5952d46cbc 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -94,6 +94,11 @@ msgstr "Account" msgid "Account growth" msgstr "Account growth" +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.syncedAt) +msgid "Account has {0} drift discrepancies. Last synced at {1}." +msgstr "Account has {0} drift discrepancies. Last synced at {1}." + msgid "Account preview" msgstr "Account preview" @@ -133,6 +138,11 @@ msgstr "An unexpected error occurred while processing your request." msgid "App Insights · Telemetry" msgstr "App Insights · Telemetry" +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.syncedAt) +msgid "Appended {0} new billing events. Last synced at {1}." +msgstr "Appended {0} new billing events. Last synced 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." @@ -202,6 +212,9 @@ msgstr "Clear filters" msgid "Clear search" msgstr "Clear search" +msgid "Close" +msgstr "Close" + msgid "Close account preview" msgstr "Close account preview" @@ -457,6 +470,9 @@ 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" @@ -487,6 +503,9 @@ msgstr "No recent signups" msgid "No recent Stripe events" msgstr "No recent Stripe events" +msgid "No result available." +msgstr "No result available." + msgid "No sessions" msgstr "No sessions" @@ -705,6 +724,18 @@ msgstr "Support (coming soon)" msgid "Suspended" msgstr "Suspended" +msgid "Sync complete" +msgstr "Sync complete" + +msgid "Sync complete with drift detected" +msgstr "Sync complete with drift detected" + +msgid "Sync with Stripe" +msgstr "Sync with Stripe" + +msgid "Syncing..." +msgstr "Syncing..." + msgid "System" msgstr "System" diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 96d996a013..f154c8065f 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -27,7 +27,12 @@ public sealed class ProcessPendingStripeEvents( ILogger logger ) { - public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + return ExecuteAsync(stripeCustomerId, false, cancellationToken); + } + + public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync, CancellationToken cancellationToken) { // Pessimistic lock serializes concurrent webhook processing for the same customer var isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; @@ -46,7 +51,8 @@ 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) + // forceSync runs the Stripe sync even with no pending events (used by the BackOffice "Sync with Stripe" admin action) + if (pendingEvents.Length > 0 || forceSync) { await SyncStateFromStripe(tenant, subscription, cancellationToken); diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index eb02f2068f..bc22cacef4 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -214,6 +214,9 @@ public sealed class TenantLogoUpdated(string contentType, long size) public sealed class TenantSwitched(TenantId fromTenantId, TenantId toTenantId, UserId userId) : TelemetryEvent(("from_tenant_id", fromTenantId), ("to_tenant_id", toTenantId), ("user_id", userId)); +public sealed class TenantSyncedWithStripe(SubscriptionId subscriptionId, int billingEventsAppended) + : TelemetryEvent(("subscription_id", subscriptionId), ("billing_events_appended", billingEventsAppended)); + public sealed class TenantUpdated : TelemetryEvent; diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs new file mode 100644 index 0000000000..db198a9ce5 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs @@ -0,0 +1,68 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record SyncTenantWithStripeCommand : ICommand, IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +[PublicAPI] +public sealed record SyncTenantWithStripeResponse( + int BillingEventsAppended, + bool HasDriftDetected, + int DriftDiscrepancyCount, + DateTimeOffset SyncedAt +); + +public sealed class SyncTenantWithStripeHandler( + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + IBillingEventRepository billingEventRepository, + ProcessPendingStripeEvents processPendingStripeEvents, + TimeProvider timeProvider, + ITelemetryEventsCollector events +) : IRequestHandler> +{ + public async Task> Handle(SyncTenantWithStripeCommand command, CancellationToken cancellationToken) + { + 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 sync with."); + } + + var beforeEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + + await processPendingStripeEvents.ExecuteAsync(subscription.StripeCustomerId, true, cancellationToken); + + var afterEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + var billingEventsAppended = afterEvents.Length - beforeEvents.Length; + + // Drift fields are stubbed until PP-1204 lands. The shape is wired now so the frontend dialog can render + // forward-compatible content; once drift detection is in place these values reflect the actual state. + var response = new SyncTenantWithStripeResponse( + billingEventsAppended, + false, + 0, + timeProvider.GetUtcNow() + ); + + events.CollectEvent(new TenantSyncedWithStripe(subscription.Id, billingEventsAppended)); + + return response; + } +} diff --git a/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs new file mode 100644 index 0000000000..34cd26b193 --- /dev/null +++ b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Http.Json; +using Account.Features.Tenants.BackOffice.Commands; +using Account.Integrations.Stripe; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.BackOffice; + +public sealed class SyncTenantWithStripeTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task SyncTenantWithStripe_WhenSubscriptionHasStripeCustomer_ShouldReturnSyncResponse() + { + // Arrange + Connection.Update("subscriptions", "tenant_id", DatabaseSeeder.Tenant1.Id.Value, [ + ("stripe_customer_id", MockStripeClient.MockCustomerId) + ] + ); + 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}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.BillingEventsAppended.Should().BeGreaterThanOrEqualTo(0); + payload.HasDriftDetected.Should().BeFalse(); + payload.DriftDiscrepancyCount.Should().Be(0); + payload.SyncedAt.Should().BeAfter(DateTimeOffset.UtcNow.AddMinutes(-1)); + } + + [Fact] + public async Task SyncTenantWithStripe_WhenSubscriptionHasNoStripeCustomer_ShouldReturnBadRequest() + { + // 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}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + + [Fact] + public async Task SyncTenantWithStripe_WhenTenantDoesNotExist_ShouldReturnNotFound() + { + // Arrange + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + var unknownTenantId = TenantId.NewId(); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{unknownTenantId}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + } + + [Fact] + public async Task SyncTenantWithStripe_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() + { + // Arrange + using var client = CreateBackOfficeClient(); + + // Act + var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } +} From 3e521e469483a378e8ddb8b8c5742079135ce6a3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 May 2026 20:11:11 +0200 Subject: [PATCH 031/158] Add inline billing drift detection with Subscription flags and BackOffice banner --- .../Api/BackOffice/BillingDriftEndpoints.cs | 29 ++++++ .../Api/BackOffice/TenantsEndpoints.cs | 4 + .../account/BackOffice/routes/__root.tsx | 2 + .../shared/components/BillingDriftBanner.tsx | 45 +++++++++ .../shared/translations/locale/da-DK.po | 6 ++ .../shared/translations/locale/en-US.po | 6 ++ ...260506180000_AddSubscriptionDriftFields.cs | 18 ++++ .../Queries/GetBillingDriftSummary.cs | 21 +++++ .../Subscriptions/Domain/Subscription.cs | 47 ++++++++++ .../Domain/SubscriptionConfiguration.cs | 13 +++ .../Domain/SubscriptionRepository.cs | 11 +++ .../Shared/BillingDriftDetector.cs | 87 +++++++++++++++++ .../Shared/ProcessPendingStripeEvents.cs | 23 +++++ .../account/Core/Features/TelemetryEvents.cs | 3 + .../Commands/AcknowledgeBillingDrift.cs | 36 +++++++ .../Commands/SyncTenantWithStripe.cs | 12 ++- .../BackOffice/Queries/GetTenantDetail.cs | 10 +- .../Tests/Authentication/SwitchTenantTests.cs | 5 +- .../Dashboard/GetDashboardKpisTests.cs | 5 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 5 +- .../CompleteEmailLoginTests.cs | 5 +- .../CompleteExternalLoginTests.cs | 5 +- .../BillingDriftDetectorTests.cs | 94 +++++++++++++++++++ .../BackOffice/GetTenantDetailTests.cs | 5 +- .../Tenants/BackOffice/GetTenantsTests.cs | 5 +- .../BackOffice/GetBackOfficeUsersTests.cs | 5 +- 26 files changed, 493 insertions(+), 14 deletions(-) create mode 100644 application/account/Api/BackOffice/BillingDriftEndpoints.cs create mode 100644 application/account/BackOffice/shared/components/BillingDriftBanner.tsx create mode 100644 application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs create mode 100644 application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs create mode 100644 application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs create mode 100644 application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs create mode 100644 application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs diff --git a/application/account/Api/BackOffice/BillingDriftEndpoints.cs b/application/account/Api/BackOffice/BillingDriftEndpoints.cs new file mode 100644 index 0000000000..8289f09469 --- /dev/null +++ b/application/account/Api/BackOffice/BillingDriftEndpoints.cs @@ -0,0 +1,29 @@ +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(); + } +} diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs index ea49f9bb8a..07b6febb7f 100644 --- a/application/account/Api/BackOffice/TenantsEndpoints.cs +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -51,5 +51,9 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPost("/{id}/sync-with-stripe", async Task> (TenantId id, IMediator mediator) => await mediator.Send(new SyncTenantWithStripeCommand { TenantId = id }) ).Produces(); + + group.MapPost("/{id}/drift/acknowledge", async Task (TenantId id, IMediator mediator) + => await mediator.Send(new AcknowledgeBillingDriftCommand { TenantId = id }) + ); } } diff --git a/application/account/BackOffice/routes/__root.tsx b/application/account/BackOffice/routes/__root.tsx index cdddee2776..dc5181e711 100644 --- a/application/account/BackOffice/routes/__root.tsx +++ b/application/account/BackOffice/routes/__root.tsx @@ -6,6 +6,7 @@ import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { BillingDriftBanner } from "@/shared/components/BillingDriftBanner"; import { ErrorPage } from "@/shared/components/errorPages/ErrorPage"; import { NotFoundPage } from "@/shared/components/errorPages/NotFoundPage"; import { queryClient } from "@/shared/lib/api/client"; @@ -26,6 +27,7 @@ function Root() { navigate(options)}> + diff --git a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx new file mode 100644 index 0000000000..e530af1712 --- /dev/null +++ b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx @@ -0,0 +1,45 @@ +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +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 with a hidden ?driftOnly=true filter applied (the toolbar does not expose this + * filter directly; the banner is the single discovery surface). + */ +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 ( +
+
+ + + {count} accounts have billing drift detected. + +
+ + View accounts + +
+ ); +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 674fce4510..b289f52197 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -36,6 +36,9 @@ msgstr "{activationPercent}% aktivering" msgid "{activeUsers} active" msgstr "{activeUsers} aktive" +msgid "{count} accounts have billing drift detected." +msgstr "{count} konti har faktureringsafvigelser." + msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" @@ -802,6 +805,9 @@ msgstr "Brugere ({totalUsers})" msgid "Users active" msgstr "Aktive brugere" +msgid "View accounts" +msgstr "Vis konti" + msgid "View all" msgstr "Vis alle" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 5952d46cbc..dd05a91abe 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -36,6 +36,9 @@ msgstr "{activationPercent}% activation" msgid "{activeUsers} active" msgstr "{activeUsers} active" +msgid "{count} accounts have billing drift detected." +msgstr "{count} accounts have billing drift detected." + msgid "{diffDays, plural, one {# day ago} other {# days ago}}" msgstr "{diffDays, plural, one {# day ago} other {# days ago}}" @@ -802,6 +805,9 @@ msgstr "Users ({totalUsers})" msgid "Users active" msgstr "Users active" +msgid "View accounts" +msgstr "View accounts" + msgid "View all" msgstr "View all" diff --git a/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs b/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs new file mode 100644 index 0000000000..920ad73bb1 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260506180000_AddSubscriptionDriftFields")] +public sealed class AddSubscriptionDriftFields : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + 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"); + } +} 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/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index 6445f702f4..4a734fa7cc 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -34,6 +34,7 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) TenantId = tenantId; Plan = SubscriptionPlan.Basis; PaymentTransactions = ImmutableArray.Empty; + DriftDiscrepancies = ImmutableArray.Empty; } public SubscriptionPlan Plan { get; private set; } @@ -68,6 +69,12 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) 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) @@ -157,6 +164,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] @@ -187,3 +208,29 @@ public sealed record PaymentTransaction( string? CreditNoteUrl, SubscriptionPlan? Plan = null ); + +[PublicAPI] +public sealed record DriftDiscrepancy( + DriftDiscrepancyKind Kind, + string Description, + DriftSeverity Severity, + BillingEventType? ExpectedEventType = null, + string? ExpectedValue = null, + string? ActualValue = null +); + +[PublicAPI] +public enum DriftDiscrepancyKind +{ + MissingEvent, + ExtraEvent, + FieldDisagree, + SubscriptionStateMismatch +} + +[PublicAPI] +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 603924ce3b..a7d3fc765b 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs @@ -45,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 6dfb4873c4..a3d00aaa42 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs @@ -34,6 +34,12 @@ public interface ISubscriptionRepository : ICrudRepository Task GetAllActiveUnfilteredAsync(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); } internal sealed class SubscriptionRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -86,4 +92,9 @@ public async Task GetAllActiveUnfilteredAsync(CancellationToken { return await DbSet.IgnoreQueryFilters().Where(s => s.Plan != SubscriptionPlan.Basis).ToArrayAsync(cancellationToken); } + + public async Task CountWithDriftDetectedUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters().CountAsync(s => s.HasDriftDetected, cancellationToken); + } } 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..fe8973a8ad --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs @@ -0,0 +1,87 @@ +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. +/// Today the detector covers — comparing +/// `Plan`, `CancelAtPeriodEnd`, `CurrentPriceAmount`, `CurrentPriceCurrency` between the local aggregate +/// and the Stripe snapshot. These fields drive customer access and are operationally the most important +/// to keep aligned. Comparison of stored vs expected BillingEvent rows +/// ( / / +/// ) 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(Subscription subscription, StripeSyncSnapshot snapshot) + { + var discrepancies = ImmutableArray.CreateBuilder(); + + if (subscription.Plan != snapshot.Plan) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Plan differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: snapshot.Plan.ToString(), + ActualValue: subscription.Plan.ToString() + ) + ); + } + + if (subscription.CancelAtPeriodEnd != snapshot.CancelAtPeriodEnd) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Cancel-at-period-end differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: snapshot.CancelAtPeriodEnd.ToString(), + ActualValue: subscription.CancelAtPeriodEnd.ToString() + ) + ); + } + + if (subscription.CurrentPriceAmount != snapshot.CurrentPriceAmount) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price amount differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: snapshot.CurrentPriceAmount?.ToString(), + ActualValue: subscription.CurrentPriceAmount?.ToString() + ) + ); + } + + if (subscription.CurrentPriceCurrency != snapshot.CurrentPriceCurrency) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price currency differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: snapshot.CurrentPriceCurrency, + ActualValue: subscription.CurrentPriceCurrency + ) + ); + } + + return discrepancies.ToImmutable(); + } +} + +/// +/// Snapshot of Stripe's authoritative subscription state captured during a sync. Drives the drift detector +/// and is 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 +); diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index f154c8065f..29ff074caf 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -396,6 +396,29 @@ await AppendBillingEventAsync(BillingEvent.Create( tenantRepository.Update(tenant); } + // Inline drift detection. Compares the just-applied local subscription state against Stripe's + // authoritative state and records any discrepancies on the subscription. Wrapped in try/catch + // because drift detection is a safety net — a failure here must not block the sync itself. + try + { + var snapshot = new StripeSyncSnapshot( + subscription.Plan, + subscription.CancelAtPeriodEnd, + subscription.CurrentPriceAmount, + subscription.CurrentPriceCurrency + ); + // The snapshot is built from the just-synced local state, so today's detector finds zero + // discrepancies in the SubscriptionStateMismatch category. The seam stays in place so adding + // a Stripe-derived snapshot (full invoice/charge history) for the BillingEvent comparison + // becomes a localized change to this block plus the detector itself. + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + subscription.SetDriftStatus(discrepancies, now); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Drift detection threw while syncing Stripe customer '{StripeCustomerId}', existing drift status preserved", subscription.StripeCustomerId); + } + subscriptionRepository.Update(subscription); } diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index bc22cacef4..7a64f598ed 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -211,6 +211,9 @@ public sealed class TenantLogoRemoved public sealed class TenantLogoUpdated(string contentType, long size) : TelemetryEvent(("content_type", contentType), ("size", size)); +public sealed class TenantBillingDriftAcknowledged(SubscriptionId subscriptionId) + : TelemetryEvent(("subscription_id", subscriptionId)); + public sealed class TenantSwitched(TenantId fromTenantId, TenantId toTenantId, UserId userId) : TelemetryEvent(("from_tenant_id", fromTenantId), ("to_tenant_id", toTenantId), ("user_id", userId)); 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/SyncTenantWithStripe.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs index db198a9ce5..85dbf04b0b 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs @@ -52,12 +52,16 @@ public async Task> Handle(SyncTenantWithStr var afterEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); var billingEventsAppended = afterEvents.Length - beforeEvents.Length; - // Drift fields are stubbed until PP-1204 lands. The shape is wired now so the frontend dialog can render - // forward-compatible content; once drift detection is in place these values reflect the actual state. + // Reload the subscription so drift fields reflect the just-completed sync. 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; + var response = new SyncTenantWithStripeResponse( billingEventsAppended, - false, - 0, + hasDriftDetected, + driftDiscrepancyCount, timeProvider.GetUtcNow() ); diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs index b6051f3dde..fd904fd3ac 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -29,7 +29,10 @@ public sealed record TenantDetailResponse( DateTimeOffset? SuspendedAt, string? LogoUrl, DateTimeOffset CreatedAt, - DateTimeOffset? ModifiedAt + DateTimeOffset? ModifiedAt, + bool HasDriftDetected, + DateTimeOffset? DriftCheckedAt, + DriftDiscrepancy[] DriftDiscrepancies ); [PublicAPI] @@ -85,7 +88,10 @@ public async Task> Handle(GetTenantDetailQuery quer tenant.SuspendedAt, tenant.Logo.Url, tenant.CreatedAt, - tenant.ModifiedAt + tenant.ModifiedAt, + subscription?.HasDriftDetected ?? false, + subscription?.DriftCheckedAt, + subscription?.DriftDiscrepancies.ToArray() ?? [] ); } } diff --git a/application/account/Tests/Authentication/SwitchTenantTests.cs b/application/account/Tests/Authentication/SwitchTenantTests.cs index 05a60d9968..115f969895 100644 --- a/application/account/Tests/Authentication/SwitchTenantTests.cs +++ b/application/account/Tests/Authentication/SwitchTenantTests.cs @@ -383,7 +383,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/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index e34477db52..6d9528eb90 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -162,7 +162,10 @@ private void SeedSubscription(TenantId tenantId, SubscriptionPlan plan, decimal? ("cancellation_feedback", null), ("payment_transactions", paymentTransactionsJson), ("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/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 3face1c1fb..43a050c41f 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -115,7 +115,10 @@ private void SeedActiveSubscription(TenantId tenantId, decimal currentPriceAmoun ("payment_method", null), ("billing_info", null), ("subscribed_since", subscribedSince), - ("scheduled_price_amount", null) + ("scheduled_price_amount", null), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") ] ); } diff --git a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs index 88ff12234b..78439eb492 100644 --- a/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs +++ b/application/account/Tests/EmailAuthentication/CompleteEmailLoginTests.cs @@ -241,7 +241,10 @@ public async Task CompleteEmailLogin_WithValidPreferredTenant_ShouldLoginToPrefe ("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/ExternalAuthentication/CompleteExternalLoginTests.cs b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs index 680400be55..afa476d894 100644 --- a/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs +++ b/application/account/Tests/ExternalAuthentication/CompleteExternalLoginTests.cs @@ -430,7 +430,10 @@ public async Task CompleteExternalLogin_WithValidPreferredTenant_ShouldLoginToPr ("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/Subscriptions/BillingDriftDetectorTests.cs b/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs new file mode 100644 index 0000000000..b37c3ac2b0 --- /dev/null +++ b/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs @@ -0,0 +1,94 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using FluentAssertions; +using SharedKernel.Domain; +using Xunit; + +namespace Account.Tests.Subscriptions; + +public sealed class BillingDriftDetectorTests +{ + [Fact] + public void Detect_WhenSubscriptionMatchesStripe_ShouldReturnEmpty() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Premium, 99.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "DKK"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + + // Assert + discrepancies.Should().BeEmpty(); + } + + [Fact] + public void Detect_WhenPlanDiffers_ShouldReturnCriticalDiscrepancy() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Standard, 49.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 49.00m, "DKK"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + + // Assert + discrepancies.Should().ContainSingle(); + discrepancies[0].Kind.Should().Be(DriftDiscrepancyKind.SubscriptionStateMismatch); + discrepancies[0].Severity.Should().Be(DriftSeverity.Critical); + discrepancies[0].ActualValue.Should().Be(nameof(SubscriptionPlan.Standard)); + discrepancies[0].ExpectedValue.Should().Be(nameof(SubscriptionPlan.Premium)); + } + + [Fact] + public void Detect_WhenCancelAtPeriodEndDiffers_ShouldReturnWarningDiscrepancy() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Standard, 49.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + subscription.SetCancellation(false, null, null); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Standard, true, 49.00m, "DKK"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + + // Assert + discrepancies.Should().ContainSingle(); + discrepancies[0].Kind.Should().Be(DriftDiscrepancyKind.SubscriptionStateMismatch); + discrepancies[0].Severity.Should().Be(DriftSeverity.Warning); + } + + [Fact] + public void Detect_WhenMultipleFieldsDiffer_ShouldReturnAllDiscrepancies() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Standard, 49.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "USD"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + + // Assert + discrepancies.Should().HaveCount(3); + discrepancies.Select(d => d.Kind).Should().AllBeEquivalentTo(DriftDiscrepancyKind.SubscriptionStateMismatch); + } + + [Fact] + public void Detect_WhenSubscriptionHasDriftSet_AndAcknowledged_ShouldClearFlag() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + var discrepancy = new DriftDiscrepancy(DriftDiscrepancyKind.SubscriptionStateMismatch, "Plan mismatch.", DriftSeverity.Critical); + subscription.SetDriftStatus([discrepancy], DateTimeOffset.UtcNow); + + // Act + subscription.AcknowledgeDrift(DateTimeOffset.UtcNow); + + // Assert + subscription.HasDriftDetected.Should().BeFalse(); + subscription.DriftDiscrepancies.Should().ContainSingle("acknowledgement preserves the discrepancy list for audit"); + } +} diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index d5ceddb0b1..6402783d2b 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -56,7 +56,10 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())), ("payment_method", null), ("billing_info", billingInfoJson), - ("subscribed_since", subscribedSince) + ("subscribed_since", subscribedSince), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") ] ); diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 80dad20a7f..1700f90c28 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -412,7 +412,10 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st ("cancellation_feedback", null), ("payment_transactions", paymentTransactionsJson), ("payment_method", null), - ("billing_info", billingInfoJson) + ("billing_info", billingInfoJson), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") ] ); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 28379f3ffe..8884d854b9 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -314,7 +314,10 @@ private void SeedSubscriptionWithSucceededPayment(TenantId tenantId) ("cancellation_feedback", null), ("payment_transactions", paymentTransactionsJson), ("payment_method", null), - ("billing_info", null) + ("billing_info", null), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") ] ); } From 4ba60d44432520495b8f3aa3261114229c8a8c77 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 May 2026 21:00:40 +0200 Subject: [PATCH 032/158] Polish billing events surfaces and add Billing tab on tenant detail --- .../DashboardRecentSignupsCard.tsx | 2 +- .../DashboardRecentStripeEventsCard.tsx | 151 ++++------------ .../routes/-components/DashboardSections.tsx | 10 +- .../BackOffice/routes/accounts/$tenantId.tsx | 11 +- .../AccountBillingHistorySection.tsx | 86 +++++++++ .../-components/AccountBillingTab.tsx | 165 +++++++++++------- .../-components/BillingEventsTable.tsx | 9 +- .../-components/BillingEventsTableRow.tsx | 26 ++- .../-components/BillingEventsToolbar.tsx | 52 +++--- .../routes/users/-components/UserKpiCards.tsx | 2 +- .../-components/UserLoginHistorySection.tsx | 2 +- .../users/-components/UserSessionsSection.tsx | 2 +- .../users/-components/UsersTableRow.tsx | 2 +- .../shared/components/SmartDateTime.tsx | 29 +-- .../shared/lib/billingEventStyle.ts | 97 ++++++++++ .../shared/translations/locale/da-DK.po | 33 ++-- .../shared/translations/locale/en-US.po | 33 ++-- .../Queries/GetBackOfficeBillingEvents.cs | 6 + 18 files changed, 467 insertions(+), 251 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx create mode 100644 application/account/BackOffice/shared/lib/billingEventStyle.ts diff --git a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx index a4b116e00b..c913a3e642 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx @@ -59,7 +59,7 @@ export function DashboardRecentSignupsCard() { params={{ tenantId: String(signup.tenantId) }} className="-mx-2 flex items-center gap-3 rounded-md px-2 py-2.5 hover:bg-accent active:bg-accent" > - +
{signup.name} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index c60aab8453..4d35433346 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -5,106 +5,23 @@ import { Skeleton } from "@repo/ui/components/Skeleton"; import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { formatCurrency } from "@repo/utils/currency/formatCurrency"; import { Link } from "@tanstack/react-router"; -import { - ArrowDownRightIcon, - ArrowRightIcon, - ArrowUpRightIcon, - CalendarClockIcon, - CircleAlertIcon, - CircleCheckIcon, - CircleXIcon, - CreditCardIcon, - PauseCircleIcon, - RefreshCwIcon, - ReplyIcon, - RotateCcwIcon, - WalletIcon, - ZapIcon -} from "lucide-react"; +import { ArrowRightIcon, ZapIcon } from "lucide-react"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; import { api, BillingEventType } 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"; -const EVENT_VARIANT: Record< - BillingEventType, - { className: string; icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }> } -> = { - [BillingEventType.SubscriptionCreated]: { - className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - icon: CircleCheckIcon - }, - [BillingEventType.SubscriptionRenewed]: { - className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - icon: RefreshCwIcon - }, - [BillingEventType.SubscriptionUpgraded]: { - className: "bg-sky-500/10 text-sky-500 border-sky-500/20", - icon: ArrowUpRightIcon - }, - [BillingEventType.SubscriptionDowngradeScheduled]: { - className: "bg-amber-500/10 text-amber-500 border-amber-500/20", - icon: CalendarClockIcon - }, - [BillingEventType.SubscriptionDowngradeCancelled]: { - className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - icon: RotateCcwIcon - }, - [BillingEventType.SubscriptionDowngraded]: { - className: "bg-amber-500/10 text-amber-500 border-amber-500/20", - icon: ArrowDownRightIcon - }, - [BillingEventType.SubscriptionCancelled]: { - className: "bg-rose-500/10 text-rose-500 border-rose-500/20", - icon: CircleXIcon - }, - [BillingEventType.SubscriptionReactivated]: { - className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - icon: ReplyIcon - }, - [BillingEventType.SubscriptionExpired]: { - className: "bg-rose-500/10 text-rose-500 border-rose-500/20", - icon: CircleXIcon - }, - [BillingEventType.SubscriptionImmediatelyCancelled]: { - className: "bg-rose-500/10 text-rose-500 border-rose-500/20", - icon: CircleXIcon - }, - [BillingEventType.SubscriptionSuspended]: { - className: "bg-rose-500/10 text-rose-500 border-rose-500/20", - icon: PauseCircleIcon - }, - [BillingEventType.PaymentFailed]: { - className: "bg-rose-500/10 text-rose-500 border-rose-500/20", - icon: CircleAlertIcon - }, - [BillingEventType.PaymentRecovered]: { - className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", - icon: CircleCheckIcon - }, - [BillingEventType.PaymentRefunded]: { - className: "bg-amber-500/10 text-amber-500 border-amber-500/20", - icon: ArrowDownRightIcon - }, - [BillingEventType.BillingInfoAdded]: { className: "bg-sky-500/10 text-sky-500 border-sky-500/20", icon: WalletIcon }, - [BillingEventType.BillingInfoUpdated]: { - className: "bg-sky-500/10 text-sky-500 border-sky-500/20", - icon: WalletIcon - }, - [BillingEventType.PaymentMethodUpdated]: { - className: "bg-sky-500/10 text-sky-500 border-sky-500/20", - icon: CreditCardIcon - } -}; - export function DashboardRecentStripeEventsCard() { const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-stripe-events", { params: { query: { Limit: 6 } } }); - const events = data?.events ?? []; + // Filter out low-signal billing-info events from the dashboard card; they are still visible on the + // full /billing-events page for operators who need to audit them. + const events = (data?.events ?? []).filter((event) => event.type !== BillingEventType.BillingInfoAdded); return ( {events.map((event, index) => { - const variant = EVENT_VARIANT[event.type]; + const variant = BILLING_EVENT_VARIANT[event.type]; const Icon = variant.icon; + const showPlanTransition = + event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; + const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; return (
  • - -
    - {event.tenantName} - - - - {event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan && ( - <> - - - {getSubscriptionPlanLabel(event.fromPlan)} → {getSubscriptionPlanLabel(event.toPlan)} - - - )} - {event.amountDelta != null && event.currency && ( - <> - - {formatCurrency(event.amountDelta, event.currency)} - - )} + + {event.tenantName} + + + {showPlanTransition ? ( + + {getSubscriptionPlanLabel(event.fromPlan!)} → {getSubscriptionPlanLabel(event.toPlan!)} -
    - + ) : ( +
  • ); diff --git a/application/account/BackOffice/routes/-components/DashboardSections.tsx b/application/account/BackOffice/routes/-components/DashboardSections.tsx index 4f0a51c65d..1da72520d9 100644 --- a/application/account/BackOffice/routes/-components/DashboardSections.tsx +++ b/application/account/BackOffice/routes/-components/DashboardSections.tsx @@ -26,9 +26,13 @@ export function DashboardSections() {
    -
    - - +
    +
    + +
    +
    + +
    ); diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index 973a3bdebb..e9d4a1a38a 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -4,7 +4,7 @@ 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 } from "@tanstack/react-router"; -import { LayoutGridIcon, UsersIcon } from "lucide-react"; +import { LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; import { api } from "@/shared/lib/api/client"; @@ -52,6 +52,10 @@ function AccountDetailPage() { {totalUsers === undefined ? Users : Users ({totalUsers})} + + + Billing + @@ -60,13 +64,16 @@ function AccountDetailPage() {
    - +
    + + +
    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..ef88448613 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx @@ -0,0 +1,86 @@ +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 { 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 { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +interface Props { + events: BillingEventSummary[]; + isLoading: boolean; + /** Limit how many events to show. Used by the Overview tab to render a condensed summary. */ + maxItems?: number; +} + +export function AccountBillingHistorySection({ events, isLoading, maxItems }: Readonly) { + const visibleEvents = maxItems != null ? events.slice(0, maxItems) : events; + + if (isLoading && events.length === 0) { + return ( +
    + {Array.from({ length: maxItems ?? 4 }).map((_, index) => ( + + ))} +
    + ); + } + + if (visibleEvents.length === 0) { + return ( + + + + No billing events + + + Subscription, payment, and billing transitions will appear here. + + + + ); + } + + return ( +
      + {visibleEvents.map((event) => { + const variant = BILLING_EVENT_VARIANT[event.eventType]; + const Icon = variant.icon; + const showPlanTransition = event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; + const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; + return ( +
    • + + + {getBillingEventTypeLabel(event.eventType)} + + {showPlanTransition ? ( + + {getSubscriptionPlanLabel(event.fromPlan!)} → {getSubscriptionPlanLabel(event.toPlan!)} + + ) : ( + + )} + + {event.amountDelta != null && event.currency ? formatCurrency(event.amountDelta, event.currency) : ""} + + +
    • + ); + })} +
    + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx index 0f5114a655..16e29b3899 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -10,21 +10,44 @@ import { useState } from "react"; import { api } from "@/shared/lib/api/client"; +import { AccountBillingHistorySection } from "./AccountBillingHistorySection"; import { AccountPaymentRow } from "./AccountPaymentRow"; interface AccountBillingTabProps { tenantId: string; + /** + * `compact` — Overview tab: show only the last 2 events and the last invoice (no pagination). + * `full` — Billing tab: full pageable list of events and invoices. + */ + variant?: "compact" | "full"; } -export function AccountBillingTab({ tenantId }: Readonly) { +export function AccountBillingTab({ tenantId, variant = "compact" }: Readonly) { const formatDate = useFormatDate(); const [pageOffset, setPageOffset] = useState(0); + const isCompact = variant === "compact"; + const paymentsPageSize = isCompact ? 1 : 25; + const eventsPageSize = isCompact ? 5 : 50; + const eventsMaxItems = isCompact ? 2 : undefined; + const { data, isLoading } = api.useQuery( "get", "/api/back-office/tenants/{id}/payment-history", { - params: { path: { id: tenantId }, query: { PageOffset: pageOffset || undefined } } + params: { + path: { id: tenantId }, + query: { PageOffset: pageOffset || undefined, PageSize: paymentsPageSize } + } + }, + { placeholderData: keepPreviousData } + ); + + const eventsQuery = api.useQuery( + "get", + "/api/back-office/billing-events", + { + params: { query: { TenantId: tenantId, PageSize: eventsPageSize } } }, { placeholderData: keepPreviousData } ); @@ -32,71 +55,85 @@ export function AccountBillingTab({ tenantId }: Readonly const transactions = data?.transactions ?? []; const totalPages = data?.totalPages ?? 0; const currentPage = (data?.currentPageOffset ?? 0) + 1; + const billingEvents = eventsQuery.data?.billingEvents ?? []; return ( -
    -

    - Payment history -

    - {isLoading && transactions.length === 0 ? ( -
    - {Array.from({ length: 5 }).map((_, index) => ( - - ))} -
    - ) : transactions.length === 0 ? ( - - - - No payments - - - No payments yet. - - - - ) : ( -
    - - - - - Date - - - Plan - - - Amount - - - Status - - - - - - {transactions.map((transaction) => ( - - ))} - -
    -
    - )} +
    +
    +

    + Billing events +

    + +
    + +
    +

    + Billing history +

    + {isLoading && transactions.length === 0 ? ( +
    + {Array.from({ length: isCompact ? 1 : 5 }).map((_, index) => ( + + ))} +
    + ) : transactions.length === 0 ? ( + + + + No invoices + + + No invoices yet. + + + + ) : ( +
    + + + + + Date + + + Plan + + + Amount + + + Status + + + + + + {transactions.map((transaction) => ( + + ))} + +
    +
    + )} - {totalPages > 1 && ( -
    - setPageOffset(page - 1)} - previousLabel={t`Previous`} - nextLabel={t`Next`} - trackingTitle="Payment history" - className="w-full" - /> -
    - )} + {!isCompact && totalPages > 1 && ( +
    + setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Billing history" + className="w-full" + /> +
    + )} +
    ); } diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx index 9cd0b9735b..3c3d6774a5 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx @@ -72,6 +72,13 @@ export function BillingEventsTable({ [navigate, orderBy, sortOrder] ); + const handleRowClick = useCallback( + (tenantId: string) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId } }); + }, + [navigate] + ); + if (isLoading && billingEvents.length === 0) { return (
    @@ -91,7 +98,7 @@ export function BillingEventsTable({ {billingEvents.map((event) => ( - + ))} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx index 7f0b35f32a..260901807e 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx @@ -7,26 +7,36 @@ import { formatCurrency } from "@repo/utils/currency/formatCurrency"; import type { components } from "@/shared/lib/api/client"; import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; type BillingEventSummary = components["schemas"]["BillingEventSummary"]; export function BillingEventsTableRow({ event, - formatDate + formatDate, + onRowClick }: Readonly<{ event: BillingEventSummary; formatDate: (value: string | null | undefined) => 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)} + + + {getBillingEventTypeLabel(event.eventType)} + {event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan ? ( @@ -39,7 +49,9 @@ export function BillingEventsTableRow({ )} - + {event.amountDelta != null && event.currency ? ( formatCurrency(event.amountDelta, event.currency) ) : ( @@ -56,7 +68,9 @@ export function BillingEventsTableRow({ )} - {formatDate(event.occurredAt)} + + {formatDate(event.occurredAt)} +
    ); } diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx index 810cfa5566..e2a3c1475a 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx @@ -1,10 +1,10 @@ import { t } from "@lingui/core/macro"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; -import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { MultiSelect } from "@repo/ui/components/MultiSelect"; 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 { useEffect, useMemo, useState } from "react"; import type { SortableBillingEventProperties } from "@/shared/lib/api/client"; @@ -16,15 +16,26 @@ interface BillingEventsToolbarProps { eventTypes: BillingEventType[]; } -// Curated subset of the 17 BillingEventType values surfaced as quick-filter chips. The remaining values -// (BillingInfo*, PaymentMethodUpdated, etc.) are still returned by the API and visible in the table; they -// just aren't first-class quick filters because operators rarely scope to those alone. -const QUICK_FILTER_TYPES = [ +// Order matches the BillingEventType enum so the dropdown reads in the same lifecycle order operators +// see in our domain log (creation → renewal → upgrade → downgrade → cancellation → payment). +const ALL_EVENT_TYPES: BillingEventType[] = [ BillingEventType.SubscriptionCreated, + BillingEventType.SubscriptionRenewed, BillingEventType.SubscriptionUpgraded, + BillingEventType.SubscriptionDowngradeScheduled, + BillingEventType.SubscriptionDowngradeCancelled, BillingEventType.SubscriptionDowngraded, BillingEventType.SubscriptionCancelled, - BillingEventType.PaymentFailed + BillingEventType.SubscriptionReactivated, + BillingEventType.SubscriptionExpired, + BillingEventType.SubscriptionImmediatelyCancelled, + BillingEventType.SubscriptionSuspended, + BillingEventType.PaymentFailed, + BillingEventType.PaymentRecovered, + BillingEventType.PaymentRefunded, + BillingEventType.BillingInfoAdded, + BillingEventType.BillingInfoUpdated, + BillingEventType.PaymentMethodUpdated ]; export function BillingEventsToolbar({ search, eventTypes }: Readonly) { @@ -52,6 +63,11 @@ export function BillingEventsToolbar({ search, eventTypes }: Readonly ALL_EVENT_TYPES.map((value) => ({ id: value, label: getBillingEventTypeLabel(value) })), + [] + ); + const handleEventTypesChange = (values: string[]) => { const next = values as BillingEventType[]; navigate({ @@ -92,19 +108,15 @@ export function BillingEventsToolbar({ search, eventTypes }: Readonly
    - - {QUICK_FILTER_TYPES.map((value) => ( - - {getBillingEventTypeLabel(value)} - - ))} - +
    + +
    ); } diff --git a/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx b/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx index 8e88b84d80..7fe2292404 100644 --- a/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx +++ b/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx @@ -57,7 +57,7 @@ export function UserKpiCards({ user, userId, isLoading }: ReadonlyMost recent activity : Never logged in} > - {user?.lastSeenAt ? : "-"} + {user?.lastSeenAt ? : "-"}
    diff --git a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx index 282e126004..df1ee372a4 100644 --- a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx +++ b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx @@ -60,7 +60,7 @@ export function UserLoginHistorySection({ userId }: Readonly ( - + {getLoginMethodLabel(entry.method)} diff --git a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx index 6347574d8f..626d11e3bd 100644 --- a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx +++ b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx @@ -76,7 +76,7 @@ export function UserSessionsSection({ userId }: Readonly - +
    diff --git a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx index c4bc4c41c4..5c01ce8dc2 100644 --- a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx +++ b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx @@ -52,7 +52,7 @@ export function UsersTableRow({ {user.lastSeenAt ? (
    - + {formatDate(user.lastSeenAt, true)}
    ) : ( diff --git a/application/account/BackOffice/shared/components/SmartDateTime.tsx b/application/account/BackOffice/shared/components/SmartDateTime.tsx index 5d99b56113..839eb31778 100644 --- a/application/account/BackOffice/shared/components/SmartDateTime.tsx +++ b/application/account/BackOffice/shared/components/SmartDateTime.tsx @@ -5,19 +5,23 @@ 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 and always includes the time - * for older entries. + * Displays a relative timestamp that auto-updates every 10 seconds. * - * Examples: "Just now", "12 minutes ago", "Yesterday, 14:02", "2 days ago, 09:15", "Apr 22, 02:41". - * - * Reuses the shared-webapp `useSmartDate` (relative resolution within the past 24h) and - * `useFormatDate` (locale-aware short date) and adds calendar-day-relative phrasing with the - * locale-formatted clock time appended. + * 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 }: Readonly) { +export function SmartDateTime({ date, className, withTime = false }: Readonly) { const result = useSmartDate(date); const formatDate = useFormatDate(); const { i18n } = useLingui(); @@ -26,6 +30,9 @@ export function SmartDateTime({ date, className }: Readonly) 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": @@ -35,9 +42,8 @@ export function SmartDateTime({ date, className }: Readonly) text = plural(result.value, { one: "# minute ago", other: "# minutes ago" }); break; case "hoursAgo": { - const time = new Intl.DateTimeFormat(i18n.locale, { hour: "2-digit", minute: "2-digit" }).format(new Date(date)); const relative = plural(result.value, { one: "# hour ago", other: "# hours ago" }); - text = `${relative}, ${time}`; + text = withTime ? `${relative}, ${formatTime()}` : relative; break; } case "date": { @@ -47,7 +53,6 @@ export function SmartDateTime({ date, className }: Readonly) const targetStart = new Date(target); targetStart.setHours(0, 0, 0, 0); const diffDays = Math.round((todayStart.getTime() - targetStart.getTime()) / 86400000); - const time = new Intl.DateTimeFormat(i18n.locale, { hour: "2-digit", minute: "2-digit" }).format(target); let dayPart: string; if (diffDays === 1) { dayPart = t`Yesterday`; @@ -56,7 +61,7 @@ export function SmartDateTime({ date, className }: Readonly) } else { dayPart = formatDate(date); } - text = `${dayPart}, ${time}`; + text = withTime ? `${dayPart}, ${formatTime()}` : dayPart; break; } } diff --git a/application/account/BackOffice/shared/lib/billingEventStyle.ts b/application/account/BackOffice/shared/lib/billingEventStyle.ts new file mode 100644 index 0000000000..f15bf2d576 --- /dev/null +++ b/application/account/BackOffice/shared/lib/billingEventStyle.ts @@ -0,0 +1,97 @@ +import { + ArrowDownRightIcon, + ArrowUpRightIcon, + CalendarClockIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleXIcon, + CreditCardIcon, + PauseCircleIcon, + RefreshCwIcon, + ReplyIcon, + RotateCcwIcon, + 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 Stripe 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-500 border-emerald-500/20", + icon: CircleCheckIcon + }, + [BillingEventType.SubscriptionRenewed]: { + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + icon: RefreshCwIcon + }, + [BillingEventType.SubscriptionUpgraded]: { + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + icon: ArrowUpRightIcon + }, + [BillingEventType.SubscriptionDowngradeScheduled]: { + className: "bg-amber-500/10 text-amber-500 border-amber-500/20", + icon: CalendarClockIcon + }, + [BillingEventType.SubscriptionDowngradeCancelled]: { + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + icon: RotateCcwIcon + }, + [BillingEventType.SubscriptionDowngraded]: { + className: "bg-amber-500/10 text-amber-500 border-amber-500/20", + icon: ArrowDownRightIcon + }, + [BillingEventType.SubscriptionCancelled]: { + className: "bg-rose-500/10 text-rose-500 border-rose-500/20", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionReactivated]: { + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + icon: ReplyIcon + }, + [BillingEventType.SubscriptionExpired]: { + className: "bg-rose-500/10 text-rose-500 border-rose-500/20", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionImmediatelyCancelled]: { + className: "bg-rose-500/10 text-rose-500 border-rose-500/20", + icon: CircleXIcon + }, + [BillingEventType.SubscriptionSuspended]: { + className: "bg-rose-500/10 text-rose-500 border-rose-500/20", + icon: PauseCircleIcon + }, + [BillingEventType.PaymentFailed]: { + className: "bg-rose-500/10 text-rose-500 border-rose-500/20", + icon: CircleAlertIcon + }, + [BillingEventType.PaymentRecovered]: { + className: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20", + icon: CircleCheckIcon + }, + [BillingEventType.PaymentRefunded]: { + className: "bg-amber-500/10 text-amber-500 border-amber-500/20", + icon: ArrowDownRightIcon + }, + [BillingEventType.BillingInfoAdded]: { + className: "bg-sky-500/10 text-sky-500 border-sky-500/20", + icon: WalletIcon + }, + [BillingEventType.BillingInfoUpdated]: { + className: "bg-sky-500/10 text-sky-500 border-sky-500/20", + icon: WalletIcon + }, + [BillingEventType.PaymentMethodUpdated]: { + className: "bg-sky-500/10 text-sky-500 border-sky-500/20", + icon: CreditCardIcon + } +}; diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index b289f52197..e995683b95 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -129,6 +129,9 @@ msgstr "Aktivitet" msgid "Admin" msgstr "Admin" +msgid "All event types" +msgstr "" + msgid "All-time" msgstr "Samlet" @@ -167,12 +170,18 @@ msgstr "BackOffice oversigt · {today}" msgid "Basis" msgstr "Basis" +msgid "Billing" +msgstr "Fakturering" + msgid "Billing address" msgstr "Faktureringsadresse" msgid "Billing events" msgstr "Faktureringshændelser" +msgid "Billing history" +msgstr "Faktureringshistorik" + msgid "Billing info added" msgstr "Faktureringsoplysninger tilføjet" @@ -292,9 +301,6 @@ msgstr "E-mail afventer" msgid "Event" msgstr "Hændelse" -msgid "Event type" -msgstr "Hændelsestype" - msgid "Exceptions" msgstr "Fejl" @@ -449,6 +455,9 @@ msgstr "Ingen adgang til Back Office" msgid "No billing address on file." msgstr "Ingen faktureringsadresse registreret." +msgid "No billing events" +msgstr "" + msgid "No billing events match your filters" msgstr "Ingen faktureringshændelser matcher dine filtre" @@ -467,6 +476,12 @@ msgstr "Ingen fejl" msgid "No exceptions recorded for this user yet." msgstr "Ingen fejl registreret for denne bruger endnu." +msgid "No invoices" +msgstr "Ingen fakturaer" + +msgid "No invoices yet." +msgstr "Ingen fakturaer endnu." + msgid "No login history" msgstr "Ingen login-historik" @@ -491,12 +506,6 @@ msgstr "Ingen sidevisninger registreret for denne bruger endnu." msgid "No paid plan yet." msgstr "Intet betalt abonnement endnu." -msgid "No payments" -msgstr "Ingen betalinger" - -msgid "No payments yet." -msgstr "Ingen betalinger endnu." - msgid "No plan" msgstr "Intet abonnement" @@ -560,9 +569,6 @@ msgstr "Sidevisninger" msgid "Payment failed" msgstr "Betaling mislykkedes" -msgid "Payment history" -msgstr "Betalingshistorik" - msgid "Payment method updated" msgstr "Betalingsmetode opdateret" @@ -712,6 +718,9 @@ msgstr "Abonnement opsagt" 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 "Subscription, payment, and billing transitions will appear here." +msgstr "" + msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index dd05a91abe..98e0658588 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -129,6 +129,9 @@ msgstr "Activity" msgid "Admin" msgstr "Admin" +msgid "All event types" +msgstr "All event types" + msgid "All-time" msgstr "All-time" @@ -167,12 +170,18 @@ msgstr "BackOffice overview · {today}" msgid "Basis" msgstr "Basis" +msgid "Billing" +msgstr "Billing" + msgid "Billing address" msgstr "Billing address" msgid "Billing events" msgstr "Billing events" +msgid "Billing history" +msgstr "Billing history" + msgid "Billing info added" msgstr "Billing info added" @@ -292,9 +301,6 @@ msgstr "Email pending" msgid "Event" msgstr "Event" -msgid "Event type" -msgstr "Event type" - msgid "Exceptions" msgstr "Exceptions" @@ -449,6 +455,9 @@ 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" @@ -467,6 +476,12 @@ msgstr "No exceptions" msgid "No exceptions recorded for this user yet." msgstr "No exceptions recorded for this user yet." +msgid "No invoices" +msgstr "No invoices" + +msgid "No invoices yet." +msgstr "No invoices yet." + msgid "No login history" msgstr "No login history" @@ -491,12 +506,6 @@ msgstr "No page views recorded for this user yet." msgid "No paid plan yet." msgstr "No paid plan yet." -msgid "No payments" -msgstr "No payments" - -msgid "No payments yet." -msgstr "No payments yet." - msgid "No plan" msgstr "No plan" @@ -560,9 +569,6 @@ msgstr "Page views" msgid "Payment failed" msgstr "Payment failed" -msgid "Payment history" -msgstr "Payment history" - msgid "Payment method updated" msgstr "Payment method updated" @@ -712,6 +718,9 @@ msgstr "Subscription canceled" 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 "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." diff --git a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs index 155b11ab32..c1c6e995d0 100644 --- a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs +++ b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs @@ -14,6 +14,7 @@ public sealed record GetBackOfficeBillingEventsQuery( BillingEventType[]? EventTypes = null, DateTimeOffset? OccurredFrom = null, DateTimeOffset? OccurredTo = null, + TenantId? TenantId = null, SortableBillingEventProperties OrderBy = SortableBillingEventProperties.OccurredAt, SortOrder SortOrder = SortOrder.Descending, int PageOffset = 0, @@ -76,6 +77,11 @@ public async Task> Handle(GetBackOfficeBillingEven { 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 ? [] From fbfaa0f05fa11ed77070526af8754b2b66bfd82a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Wed, 6 May 2026 21:21:40 +0200 Subject: [PATCH 033/158] Reorder billing sections and add view-all links on tenant detail --- .../DashboardRecentStripeEventsCard.tsx | 1 + .../BackOffice/routes/accounts/$tenantId.tsx | 33 ++++++++- .../-components/AccountBillingTab.tsx | 68 ++++++++++++++----- .../-components/BillingEventsTable.tsx | 2 +- .../shared/translations/locale/da-DK.po | 22 +++--- .../shared/translations/locale/en-US.po | 16 +++-- 6 files changed, 107 insertions(+), 35 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index 4d35433346..a53bde1ac4 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -67,6 +67,7 @@ export function DashboardRecentStripeEventsCard() { { + 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 } } @@ -42,7 +65,7 @@ function AccountDetailPage() {
    - + @@ -64,7 +87,11 @@ function AccountDetailPage() {
    - + setActiveTab("billing")} + />
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx index 16e29b3899..8a5248da97 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -1,11 +1,13 @@ 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 { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { keepPreviousData } from "@tanstack/react-query"; +import { ArrowRightIcon } from "lucide-react"; import { useState } from "react"; import { api } from "@/shared/lib/api/client"; @@ -20,9 +22,11 @@ interface AccountBillingTabProps { * `full` — Billing tab: full pageable list of events and invoices. */ variant?: "compact" | "full"; + /** Click handler for the "View all" links rendered in compact mode. */ + onViewAll?: () => void; } -export function AccountBillingTab({ tenantId, variant = "compact" }: Readonly) { +export function AccountBillingTab({ tenantId, variant = "compact", onViewAll }: Readonly) { const formatDate = useFormatDate(); const [pageOffset, setPageOffset] = useState(0); @@ -56,24 +60,28 @@ export function AccountBillingTab({ tenantId, variant = "compact" }: Readonly
    -

    - Billing events -

    - -
    - -
    -

    - Billing history -

    +
    +

    + Billing history +

    + {isCompact && onViewAll && totalTransactions > 0 && ( + + )} +
    {isLoading && transactions.length === 0 ? (
    {Array.from({ length: isCompact ? 1 : 5 }).map((_, index) => ( @@ -84,15 +92,15 @@ export function AccountBillingTab({ tenantId, variant = "compact" }: Readonly - No invoices + No transactions - No invoices yet. + No invoices, refunds, or credit notes yet. ) : ( -
    +
    @@ -134,6 +142,30 @@ export function AccountBillingTab({ tenantId, variant = "compact" }: Readonly )} + +
    +
    +

    + Billing events +

    + {isCompact && onViewAll && totalEvents > 0 && ( + + )} +
    + +
    ); } diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx index 3c3d6774a5..8ea25d171e 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx @@ -74,7 +74,7 @@ export function BillingEventsTable({ const handleRowClick = useCallback( (tenantId: string) => { - navigate({ to: "/accounts/$tenantId", params: { tenantId } }); + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing" } }); }, [navigate] ); diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index e995683b95..d22fa5b925 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -130,7 +130,7 @@ msgid "Admin" msgstr "Admin" msgid "All event types" -msgstr "" +msgstr "Alle hændelsestyper" msgid "All-time" msgstr "Samlet" @@ -456,7 +456,7 @@ msgid "No billing address on file." msgstr "Ingen faktureringsadresse registreret." msgid "No billing events" -msgstr "" +msgstr "Ingen faktureringshændelser" msgid "No billing events match your filters" msgstr "Ingen faktureringshændelser matcher dine filtre" @@ -476,11 +476,8 @@ msgstr "Ingen fejl" msgid "No exceptions recorded for this user yet." msgstr "Ingen fejl registreret for denne bruger endnu." -msgid "No invoices" -msgstr "Ingen fakturaer" - -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" @@ -524,6 +521,9 @@ 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" @@ -719,7 +719,7 @@ msgid "Subscription, payment, and billing transitions will appear here as Stripe msgstr "Abonnements-, betalings- og faktureringsændringer vises her, når Stripe-webhooks behandles." msgid "Subscription, payment, and billing transitions will appear here." -msgstr "" +msgstr "Abonnements-, betalings- og faktureringsændringer vises her." msgid "Subscriptions, upgrades, and cancellations will appear here." msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." @@ -820,6 +820,12 @@ msgstr "Vis konti" msgid "View all" msgstr "Vis alle" +msgid "View all {totalEvents} events" +msgstr "Vis alle {totalEvents} hændelser" + +msgid "View all {totalTransactions} transactions" +msgstr "Vis alle {totalTransactions} transaktioner" + msgid "vs prior period" msgstr "mod forrige periode" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 98e0658588..c6c8afb261 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -476,11 +476,8 @@ msgstr "No exceptions" msgid "No exceptions recorded for this user yet." msgstr "No exceptions recorded for this user yet." -msgid "No invoices" -msgstr "No invoices" - -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" @@ -524,6 +521,9 @@ 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" @@ -820,6 +820,12 @@ msgstr "View accounts" msgid "View all" msgstr "View all" +msgid "View all {totalEvents} events" +msgstr "View all {totalEvents} events" + +msgid "View all {totalTransactions} transactions" +msgstr "View all {totalTransactions} transactions" + msgid "vs prior period" msgstr "vs prior period" From 7451680e1c89fe09d3d074ff36529b40aea7b51a Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Thu, 7 May 2026 23:46:30 +0200 Subject: [PATCH 034/158] Polish back-office tenant detail UI and complete Danish translations --- .../routes/-components/DashboardCardShell.tsx | 2 +- .../routes/-components/DashboardKpiTiles.tsx | 3 +- .../DashboardRecentStripeEventsCard.tsx | 8 +- .../routes/-components/DashboardSections.tsx | 8 +- .../BackOffice/routes/accounts/$tenantId.tsx | 37 ++-- .../-components/AccountBillingEventRow.tsx | 95 +++++++++ .../AccountBillingEventsSection.tsx | 117 ++++++++++ .../AccountBillingHistorySection.tsx | 201 ++++++++++++------ .../-components/AccountBillingTab.tsx | 180 +++++----------- .../-components/AccountCurrentPlanCard.tsx | 26 ++- .../-components/AccountDetailHeader.tsx | 33 +-- ...untKpiCards.tsx => AccountHealthTiles.tsx} | 106 ++++++--- .../-components/AccountOverviewTab.tsx | 6 +- .../-components/AccountPaymentRow.tsx | 81 +++++-- .../-components/AccountSidePaneSections.tsx | 3 +- .../accounts/-components/AccountUserRow.tsx | 13 +- .../accounts/-components/SidePaneUserList.tsx | 6 +- .../-components/SyncWithStripeButton.tsx | 4 +- .../-components/BillingEventsTable.tsx | 9 +- .../-components/BillingEventsTableRow.tsx | 8 +- .../routes/billing-events/index.tsx | 2 +- .../BackOffice/routes/users/$userId.tsx | 68 +++++- ...UserKpiCards.tsx => UserActivityTiles.tsx} | 64 ++++-- .../users/-components/UserDetailHeader.tsx | 27 ++- .../users/-components/UserDetailSections.tsx | 27 --- .../-components/UserLoginHistorySection.tsx | 13 +- .../users/-components/UserSectionHeader.tsx | 25 --- .../users/-components/UserSessionsSection.tsx | 11 +- .../-components/UserTelemetrySection.tsx | 54 ----- .../users/-components/UserTenantsSection.tsx | 139 +++++++----- .../users/-components/UsersTableRow.tsx | 8 +- .../BackOffice/shared/lib/api/labels.ts | 2 +- .../shared/translations/locale/da-DK.po | 103 ++++----- .../shared/translations/locale/en-US.po | 99 ++++----- .../-components/BillingHistoryTable.tsx | 2 +- .../WebApp/routes/account/billing/index.tsx | 2 +- .../shared/translations/locale/da-DK.po | 16 +- .../shared/translations/locale/en-US.po | 12 +- .../tests/e2e/subscription-flows.spec.ts | 8 +- 39 files changed, 970 insertions(+), 658 deletions(-) create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx create mode 100644 application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx rename application/account/BackOffice/routes/accounts/-components/{AccountKpiCards.tsx => AccountHealthTiles.tsx} (51%) rename application/account/BackOffice/routes/users/-components/{UserKpiCards.tsx => UserActivityTiles.tsx} (69%) delete mode 100644 application/account/BackOffice/routes/users/-components/UserDetailSections.tsx delete mode 100644 application/account/BackOffice/routes/users/-components/UserSectionHeader.tsx delete mode 100644 application/account/BackOffice/routes/users/-components/UserTelemetrySection.tsx diff --git a/application/account/BackOffice/routes/-components/DashboardCardShell.tsx b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx index 6c868f32f4..544e420ba9 100644 --- a/application/account/BackOffice/routes/-components/DashboardCardShell.tsx +++ b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx @@ -11,7 +11,7 @@ interface DashboardCardShellProps { export function DashboardCardShell({ title, subtitle, action, children }: Readonly) { return ( - + {title} {subtitle && {subtitle}} diff --git a/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx index f69334fa10..96f1eb52d9 100644 --- a/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx +++ b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx @@ -46,6 +46,7 @@ export function DashboardKpiTiles({ period }: Readonly) ) : undefined } + to="/billing-events" /> ) { diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index a53bde1ac4..be11b00ac8 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -8,7 +8,7 @@ import { Link } from "@tanstack/react-router"; import { ArrowRightIcon, ZapIcon } from "lucide-react"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; -import { api, BillingEventType } from "@/shared/lib/api/client"; +import { api } from "@/shared/lib/api/client"; import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; @@ -19,9 +19,7 @@ export function DashboardRecentStripeEventsCard() { params: { query: { Limit: 6 } } }); - // Filter out low-signal billing-info events from the dashboard card; they are still visible on the - // full /billing-events page for operators who need to audit them. - const events = (data?.events ?? []).filter((event) => event.type !== BillingEventType.BillingInfoAdded); + const events = data?.events ?? []; return ( (DashboardTrendPeriod.Last30Days); return ( -
    +
    -
    +
    -
    +
    -
    +
    diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index 087b5a76d0..71c350461d 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -4,7 +4,7 @@ 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 { LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; +import { ActivityIcon, LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; import { useCallback } from "react"; import { z } from "zod"; @@ -14,14 +14,14 @@ import { api } from "@/shared/lib/api/client"; import { AccountBillingTab } from "./-components/AccountBillingTab"; import { AccountCurrentPlanCard } from "./-components/AccountCurrentPlanCard"; import { AccountDetailHeader } from "./-components/AccountDetailHeader"; -import { AccountKpiCards } from "./-components/AccountKpiCards"; +import { AccountHealthTiles } from "./-components/AccountHealthTiles"; import { AccountOverviewTab } from "./-components/AccountOverviewTab"; import { AccountUsersTab } from "./-components/AccountUsersTab"; -type AccountDetailTab = "overview" | "users" | "billing"; +type AccountDetailTab = "overview" | "users" | "invoices" | "billing-events"; const accountDetailSearchSchema = z.object({ - tab: z.enum(["overview", "users", "billing"]).optional() + tab: z.enum(["overview", "users", "invoices", "billing-events"]).optional() }); export const Route = createFileRoute("/accounts/$tenantId")({ @@ -50,12 +50,8 @@ function AccountDetailPage() { const tenantQuery = api.useQuery("get", "/api/back-office/tenants/{id}", { params: { path: { id: tenantId } } }); - const userCountsQuery = api.useQuery("get", "/api/back-office/tenants/{id}/user-counts", { - params: { path: { id: tenantId } } - }); const tenant = tenantQuery.data; - const totalUsers = userCountsQuery.data?.totalUsers; return ( @@ -64,7 +60,7 @@ function AccountDetailPage() {
    - + @@ -73,24 +69,28 @@ function AccountDetailPage() { - {totalUsers === undefined ? Users : Users ({totalUsers})} + Users - + - Billing + Invoices + + + + Billing events -
    +
    setActiveTab("billing")} + variant="compact-both" + onViewAll={() => setActiveTab("invoices")} />
    @@ -98,8 +98,11 @@ function AccountDetailPage() { - - + + + + +
    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..62a8528677 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx @@ -0,0 +1,95 @@ +import type { ReactNode } from "react"; + +import { Trans } from "@lingui/react/macro"; +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 { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +function isSameDay(a: string, b: string): boolean { + return a.slice(0, 10) === b.slice(0, 10); +} + +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 showPlanTransition = event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; + const showEffective = event.effectiveAt != null && !isSameDay(event.effectiveAt, event.occurredAt); + return ( + + +
    + {renderDate(event.occurredAt)} + {showEffective && ( + + Effective {renderDate(event.effectiveAt)} + + )} +
    +
    + + + + {getBillingEventTypeLabel(event.eventType)} + + + + {showPlanTransition ? ( + + {getSubscriptionPlanLabel(event.fromPlan!)} + + → + + {getSubscriptionPlanLabel(event.toPlan!)} + + ) : event.toPlan != null ? ( + {getSubscriptionPlanLabel(event.toPlan)} + ) : ( + + )} + + {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..312c83e9f7 --- /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. + + + + ) : ( +
    + + + + Date + + + 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 index ef88448613..e904aea057 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingHistorySection.tsx @@ -1,86 +1,147 @@ +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 { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; import { Skeleton } from "@repo/ui/components/Skeleton"; -import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +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 { SmartDateTime } from "@/shared/components/SmartDateTime"; -import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; -import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; +import { AccountPaymentRow } from "./AccountPaymentRow"; -type BillingEventSummary = components["schemas"]["BillingEventSummary"]; +type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; +type RenderDate = (value: string | null | undefined) => ReactNode; interface Props { - events: BillingEventSummary[]; + transactions: PaymentTransaction[]; isLoading: boolean; - /** Limit how many events to show. Used by the Overview tab to render a condensed summary. */ - maxItems?: number; + isCompact: boolean; + totalTransactions: number; + totalPages: number; + currentPage: number; + onViewAll?: () => void; + onPageChange: (offset: number) => void; + renderDate: RenderDate; } -export function AccountBillingHistorySection({ events, isLoading, maxItems }: Readonly) { - const visibleEvents = maxItems != null ? events.slice(0, maxItems) : events; - - if (isLoading && events.length === 0) { - return ( -
    - {Array.from({ length: maxItems ?? 4 }).map((_, index) => ( - - ))} -
    - ); - } - - if (visibleEvents.length === 0) { - return ( - - - - No billing events - - - Subscription, payment, and billing transitions will appear here. - - - - ); - } - +export function AccountBillingHistorySection({ + transactions, + isLoading, + isCompact, + totalTransactions, + totalPages, + currentPage, + onViewAll, + onPageChange, + renderDate +}: Readonly) { return ( -
      - {visibleEvents.map((event) => { - const variant = BILLING_EVENT_VARIANT[event.eventType]; - const Icon = variant.icon; - const showPlanTransition = event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; - const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; - return ( -
    • - - - {getBillingEventTypeLabel(event.eventType)} - - {showPlanTransition ? ( - - {getSubscriptionPlanLabel(event.fromPlan!)} → {getSubscriptionPlanLabel(event.toPlan!)} - - ) : ( - - )} - + {isCompact ? ( +
      +

      + Invoices +

      + {onViewAll && totalTransactions > 0 && ( +
    • - ); - })} -
    + View all {totalTransactions} invoices + + + )} +
    + ) : ( +
    + 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 + + )} + {isCompact ? ( + + Amount + + ) : ( + <> + + Amount + + + VAT + + + Total + + + )} + + Status + + + + + + {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 index 8a5248da97..5189a4f1c6 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingTab.tsx @@ -1,41 +1,53 @@ -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 type { ReactNode } from "react"; + import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { keepPreviousData } from "@tanstack/react-query"; -import { ArrowRightIcon } from "lucide-react"; -import { useState } from "react"; +import { useCallback, useState } from "react"; import { api } from "@/shared/lib/api/client"; +import { AccountBillingEventsSection } from "./AccountBillingEventsSection"; import { AccountBillingHistorySection } from "./AccountBillingHistorySection"; -import { AccountPaymentRow } from "./AccountPaymentRow"; + +type AccountBillingTabVariant = "compact-both" | "history-full" | "events-full"; interface AccountBillingTabProps { tenantId: string; /** - * `compact` — Overview tab: show only the last 2 events and the last invoice (no pagination). - * `full` — Billing tab: full pageable list of events and invoices. + * `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?: "compact" | "full"; + variant: AccountBillingTabVariant; /** Click handler for the "View all" links rendered in compact mode. */ onViewAll?: () => void; } -export function AccountBillingTab({ tenantId, variant = "compact", onViewAll }: Readonly) { +export function AccountBillingTab({ tenantId, variant, onViewAll }: Readonly) { const formatDate = useFormatDate(); const [pageOffset, setPageOffset] = useState(0); - const isCompact = variant === "compact"; - const paymentsPageSize = isCompact ? 1 : 25; - const eventsPageSize = isCompact ? 5 : 50; - const eventsMaxItems = isCompact ? 2 : undefined; + 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 { data, isLoading } = api.useQuery( + const paymentsQuery = api.useQuery( "get", "/api/back-office/tenants/{id}/payment-history", { @@ -44,7 +56,7 @@ export function AccountBillingTab({ tenantId, variant = "compact", onViewAll }: query: { PageOffset: pageOffset || undefined, PageSize: paymentsPageSize } } }, - { placeholderData: keepPreviousData } + { placeholderData: keepPreviousData, enabled: showHistory } ); const eventsQuery = api.useQuery( @@ -53,119 +65,41 @@ export function AccountBillingTab({ tenantId, variant = "compact", onViewAll }: { params: { query: { TenantId: tenantId, PageSize: eventsPageSize } } }, - { placeholderData: keepPreviousData } + { placeholderData: keepPreviousData, enabled: showEvents } ); - const transactions = data?.transactions ?? []; - const totalPages = data?.totalPages ?? 0; - const currentPage = (data?.currentPageOffset ?? 0) + 1; + 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 = data?.totalCount ?? 0; + const totalTransactions = paymentsQuery.data?.totalCount ?? 0; return (
    -
    -
    -

    - Billing history -

    - {isCompact && onViewAll && totalTransactions > 0 && ( - - )} -
    - {isLoading && transactions.length === 0 ? ( -
    - {Array.from({ length: isCompact ? 1 : 5 }).map((_, index) => ( - - ))} -
    - ) : transactions.length === 0 ? ( - - - - No transactions - - - No invoices, refunds, or credit notes yet. - - - - ) : ( -
    - - - - - Date - - - Plan - - - Amount - - - Status - - - - - - {transactions.map((transaction) => ( - - ))} - -
    -
    - )} - - {!isCompact && totalPages > 1 && ( -
    - setPageOffset(page - 1)} - previousLabel={t`Previous`} - nextLabel={t`Next`} - trackingTitle="Billing history" - className="w-full" - /> -
    - )} -
    - -
    -
    -

    - Billing events -

    - {isCompact && onViewAll && totalEvents > 0 && ( - - )} -
    + {showHistory && ( + )} + {showEvents && ( + -
    + )}
    ); } diff --git a/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx b/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx index c337ec3ffb..44070146c0 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountCurrentPlanCard.tsx @@ -7,7 +7,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/compo import { Skeleton } from "@repo/ui/components/Skeleton"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { formatCurrency } from "@repo/utils/currency/formatCurrency"; -import { CalendarClockIcon, XCircleIcon } from "lucide-react"; +import { CalendarClockIcon, CalendarIcon, XCircleIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -29,7 +29,7 @@ export function AccountCurrentPlanCard({ tenant, isLoading }: Readonly + No plan} description={No paid plan yet.} /> ); @@ -38,7 +38,7 @@ export function AccountCurrentPlanCard({ tenant, isLoading }: Readonly + Subscription canceled} description={This account previously had a paid subscription that ended.} @@ -54,10 +54,10 @@ export function AccountCurrentPlanCard({ tenant, isLoading }: Readonly) { +function CurrentPlanShell({ children }: Readonly<{ children: ReactNode }>) { return ( -
    -

    +
    +

    Current plan

    {children} @@ -67,7 +67,7 @@ function CurrentPlanShell({ children, stretch = false }: Readonly<{ children: Re function CurrentPlanEmpty({ title, description }: Readonly<{ title: ReactNode; description: ReactNode }>) { return ( - + {title} {description} @@ -118,7 +118,7 @@ function CurrentPlanDetails({ tenant }: Readonly<{ tenant: TenantDetailResponse : []; return ( - +
    {showStrikedAmount ? ( @@ -159,13 +159,19 @@ function CurrentPlanDetails({ tenant }: Readonly<{ tenant: TenantDetailResponse
    Subscribed since
    -
    {tenant.subscribedSince ? formatDate(tenant.subscribedSince) : "-"}
    +
    + {tenant.subscribedSince && } + {tenant.subscribedSince ? formatDate(tenant.subscribedSince) : "-"} +
    Renewal date
    -
    {tenant.renewalDate ? formatDate(tenant.renewalDate) : "-"}
    +
    + {tenant.renewalDate && } + {tenant.renewalDate ? formatDate(tenant.renewalDate) : "-"} +
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index 5795abd66e..d1e3c8dde7 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -8,7 +8,7 @@ import { TenantLogo } from "@repo/ui/components/TenantLogo"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; import { Link } from "@tanstack/react-router"; -import { ArrowLeftIcon } from "lucide-react"; +import { ArrowLeftIcon, CalendarIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -47,9 +47,9 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly
    -
    +
    -
    +
    {isLoading || !tenant ? ( <> @@ -57,17 +57,19 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly ) : ( <> -
    -

    {tenant.name}

    - - {getSubscriptionPlanLabel(tenant.plan)} - - {tenant.state !== TenantState.Active && } - +
    +

    {tenant.name}

    +
    + + {getSubscriptionPlanLabel(tenant.plan)} + + {tenant.state !== TenantState.Active && } + +
    {tenant.billingAddress?.country && ( @@ -76,7 +78,8 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly{getCountryName(tenant.billingAddress.country, i18n.locale)} )} - + + Created {formatDate(tenant.createdAt)}
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountKpiCards.tsx b/application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx similarity index 51% rename from application/account/BackOffice/routes/accounts/-components/AccountKpiCards.tsx rename to application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx index d1a8ae472f..7085d37da4 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountKpiCards.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountHealthTiles.tsx @@ -1,18 +1,20 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; -import { Card } from "@repo/ui/components/Card"; -import { Progress } from "@repo/ui/components/Progress"; +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"]; -interface AccountKpiCardsProps { +interface AccountHealthTilesProps { tenant: TenantDetailResponse | undefined; tenantId: string; isLoading: boolean; @@ -25,7 +27,7 @@ function formatAmount(amount: number | null, currency: string | null): string { return formatCurrency(amount, currency); } -export function AccountKpiCards({ tenant, tenantId, isLoading }: Readonly) { +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 } } @@ -33,40 +35,77 @@ export function AccountKpiCards({ tenant, tenantId, isLoading }: Readonly - Renews {formatDate(tenant.renewalDate)} : undefined} - > - - + + {userCounts ? ( +
    + {totalUsers} +
    + {activePercent > 0 && ( +
    + )} + {inactivePercent > 0 && ( +
    + )} + {pendingPercent > 0 && ( +
    + )} +
    + + {activeUsers} active + {" · "} + {inactiveUsers} inactive + {" · "} + {pendingUsers} pending + +
    + ) : ( + - + )} + - Since {formatDate(tenant.createdAt)} : undefined} + tenantId={tenantId} + tab="invoices" + subtitle={ + tenant ? ( + + + Since {formatDate(tenant.createdAt)} + + ) : undefined + } > {tenant ? formatAmount(tenant.lifetimeValue, tenant.currency) : "-"} - + - {activationPercent}% activation : undefined} + + + Renews {formatDate(tenant.renewalDate)} + + ) : undefined + } > -
    - - {userCounts ? `${activeUsers} / ${totalUsers}` : "-"} - - {userCounts && } -
    -
    + +
    ); } @@ -98,19 +137,28 @@ function MrrAmount({ tenant }: Readonly<{ tenant: TenantDetailResponse | undefin ); } -function KpiCard({ +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 ? ( <> @@ -123,6 +171,6 @@ function KpiCard({ {subtitle && {subtitle}} )} - + ); } diff --git a/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx index e51cf9a1cb..a31851e037 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountOverviewTab.tsx @@ -6,6 +6,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/compo 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"; @@ -76,7 +77,10 @@ function OwnerRow({ owner }: Readonly<{ owner: TenantUserSummary }>) {
    {displayName} {owner.title && {owner.title}} - {owner.email} + + + {owner.email} +
    {!owner.emailConfirmed && ( diff --git a/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx index dd11d5fff6..aa8d66be06 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx @@ -1,10 +1,12 @@ +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 { ExternalLinkIcon } from "lucide-react"; +import { DownloadIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -15,32 +17,60 @@ type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; export function AccountPaymentRow({ transaction, - formatDate + renderDate, + showTaxBreakdown, + showPlan = true }: Readonly<{ transaction: PaymentTransaction; - formatDate: (value: string | null | undefined) => string; + renderDate: (value: string | null | undefined) => ReactNode; + showTaxBreakdown?: boolean; + showPlan?: boolean; }>) { + // Refunded rows show the amounts struck through — money came in, then went back out. + const isRefunded = transaction.status === PaymentTransactionStatus.Refunded; + const refundedClass = isRefunded ? "text-muted-foreground line-through" : ""; return ( - {formatDate(transaction.date)} - - {transaction.plan != null ? ( - {getSubscriptionPlanLabel(transaction.plan)} - ) : ( - - )} - - {formatCurrency(transaction.amount, transaction.currency)} + {renderDate(transaction.date)} + {showPlan && ( + + {transaction.plan != null ? ( + {getSubscriptionPlanLabel(transaction.plan)} + ) : ( + + )} + + )} + {showTaxBreakdown ? ( + <> + + {formatCurrency(transaction.amountExcludingTax, transaction.currency)} + + + {formatCurrency(transaction.taxAmount, transaction.currency)} + + + {formatCurrency(transaction.amount, transaction.currency)} + + + ) : ( + + {formatCurrency(transaction.amount, transaction.currency)} + + )} -
    +
    {transaction.invoiceUrl && ( )} {transaction.creditNoteUrl && ( )}
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx index db79cc3dbc..b835083e21 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx @@ -113,7 +113,8 @@ export function AccountSidePaneSections({ leftValue={lastInvoice ? formatDate(lastInvoice.date) : "-"} leftLoading={paymentHistoryLoading} rightLabel={t`Amount`} - rightValue={formatAmount(tenant.monthlyRecurringRevenue, tenant.currency)} + rightValue={lastInvoice ? formatAmount(lastInvoice.amountExcludingTax, lastInvoice.currency) : "-"} + rightLoading={paymentHistoryLoading} /> diff --git a/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx index cb8e64963d..70c14a54f8 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx @@ -3,6 +3,7 @@ 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"; @@ -33,7 +34,10 @@ export function AccountUserRow({
    {displayName} {user.title && {user.title}} - {user.email} + + + {user.email} +
    {!user.emailConfirmed && ( @@ -42,7 +46,12 @@ export function AccountUserRow({ )}
    - {user.email} + + + + {user.email} + + {getUserRoleLabel(user.role)} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx index 30775727ae..04e961f78c 100644 --- a/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx @@ -2,6 +2,7 @@ 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"; @@ -70,7 +71,10 @@ function UserRow({ user }: Readonly<{ user: TenantUserSummary }>) {
    {displayName} {user.title && {user.title}} - {user.email} + + + {user.email} +
    ); diff --git a/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx b/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx index ca46707034..2d4630cd2a 100644 --- a/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx +++ b/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx @@ -53,7 +53,7 @@ export function SyncWithStripeButton({ tenantId }: Readonly - + {!syncMutation.isPending && } {syncMutation.isPending ? Syncing... : Sync with Stripe} @@ -93,7 +93,7 @@ export function SyncWithStripeButton({ tenantId }: Readonly - + setIsResultOpen(false)}> Close diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx index 8ea25d171e..b9fba352db 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx @@ -74,7 +74,7 @@ export function BillingEventsTable({ const handleRowClick = useCallback( (tenantId: string) => { - navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing" } }); + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); }, [navigate] ); @@ -94,7 +94,12 @@ export function BillingEventsTable({ return ( <>
    - +
    {billingEvents.map((event) => ( diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx index 260901807e..79a99dc539 100644 --- a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx @@ -40,8 +40,12 @@ export function BillingEventsTableRow({ {event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan ? ( - - {getSubscriptionPlanLabel(event.fromPlan)} → {getSubscriptionPlanLabel(event.toPlan)} + + {getSubscriptionPlanLabel(event.fromPlan)} + + → + + {getSubscriptionPlanLabel(event.toPlan)} ) : event.toPlan != null ? ( {getSubscriptionPlanLabel(event.toPlan)} diff --git a/application/account/BackOffice/routes/billing-events/index.tsx b/application/account/BackOffice/routes/billing-events/index.tsx index 95a2aa9cf6..da9ff83114 100644 --- a/application/account/BackOffice/routes/billing-events/index.tsx +++ b/application/account/BackOffice/routes/billing-events/index.tsx @@ -60,7 +60,7 @@ function BillingEventsListPage() { { + 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 ( @@ -29,8 +60,35 @@ function UserDetailPage() { - - +
    + + + + + + + Accounts + + + + Logins + + + + Sessions + + + + + + + + + + + + +
    diff --git a/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx similarity index 69% rename from application/account/BackOffice/routes/users/-components/UserKpiCards.tsx rename to application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx index 7fe2292404..cd43cf99c2 100644 --- a/application/account/BackOffice/routes/users/-components/UserKpiCards.tsx +++ b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx @@ -3,6 +3,7 @@ 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"; @@ -12,13 +13,13 @@ import { api } from "@/shared/lib/api/client"; type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; -interface UserKpiCardsProps { +interface UserActivityTilesProps { user: BackOfficeUserDetailResponse | undefined; userId: string; isLoading: boolean; } -export function UserKpiCards({ user, userId, isLoading }: Readonly) { +export function UserActivityTiles({ user, userId, isLoading }: Readonly) { const sessionsQuery = api.useQuery("get", "/api/back-office/users/{id}/sessions", { params: { path: { id: userId } } }); @@ -28,15 +29,7 @@ export function UserKpiCards({ user, userId, isLoading }: Readonly - All-time : undefined} - > - {totalSessions !== undefined ? totalSessions : "-"} - - - {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 KpiCard({ +function ActivityTile({ label, loading, subtitle, - children + children, + linkTo, + userId }: Readonly<{ label: string; loading: boolean; subtitle?: ReactNode; children: ReactNode; + linkTo?: "overview" | "logins" | "sessions"; + userId?: string; }>) { - return ( - + const content = ( + <> {label} {loading ? ( <> @@ -89,6 +100,21 @@ function KpiCard({ {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 index 8aa9d1bef4..93ea51bd1f 100644 --- a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -6,7 +6,7 @@ import { Button } from "@repo/ui/components/Button"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { Link } from "@tanstack/react-router"; -import { ArrowLeftIcon, CheckCircle2Icon, XCircleIcon } from "lucide-react"; +import { ArrowLeftIcon, CalendarIcon, CheckCircle2Icon, MailIcon, XCircleIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -56,29 +56,34 @@ export function UserDetailHeader({ user, isLoading }: Readonly
    -

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

    +

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

    {user.emailConfirmed ? ( - Email confirmed + + Email confirmed + ) : ( - Email pending + + Email pending + )}
    - {user.email} - + + + {user.email} + + + Created {formatDate(user.createdAt)} - {user.lastSeenAt && ( - - Last seen {formatDate(user.lastSeenAt)} - - )}
    diff --git a/application/account/BackOffice/routes/users/-components/UserDetailSections.tsx b/application/account/BackOffice/routes/users/-components/UserDetailSections.tsx deleted file mode 100644 index 08ff7a9266..0000000000 --- a/application/account/BackOffice/routes/users/-components/UserDetailSections.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import type { components } from "@/shared/lib/api/client"; - -import { UserKpiCards } from "./UserKpiCards"; -import { UserLoginHistorySection } from "./UserLoginHistorySection"; -import { UserSessionsSection } from "./UserSessionsSection"; -import { UserTelemetrySection } from "./UserTelemetrySection"; -import { UserTenantsSection } from "./UserTenantsSection"; - -type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; - -interface UserDetailSectionsProps { - userId: string; - user: BackOfficeUserDetailResponse | undefined; - isLoading: boolean; -} - -export function UserDetailSections({ userId, user, isLoading }: Readonly) { - return ( -
    - - - - - -
    - ); -} diff --git a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx index df1ee372a4..5750449448 100644 --- a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx +++ b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx @@ -4,14 +4,11 @@ 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 { KeyIcon } from "lucide-react"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; import { api, LoginEventOutcome } from "@/shared/lib/api/client"; import { getLoginMethodLabel } from "@/shared/lib/api/labels"; -import { UserSectionHeader } from "./UserSectionHeader"; - interface UserLoginHistorySectionProps { userId: string; } @@ -23,11 +20,11 @@ export function UserLoginHistorySection({ userId }: Readonly - Login history} - description={Last 30 days} - /> +
    + + Every sign-in attempt over the last 30 days, successful or failed, across email and external providers. + +
    {isLoading ? ( ) : !data || data.entries.length === 0 ? ( diff --git a/application/account/BackOffice/routes/users/-components/UserSectionHeader.tsx b/application/account/BackOffice/routes/users/-components/UserSectionHeader.tsx deleted file mode 100644 index 7a7ed9a583..0000000000 --- a/application/account/BackOffice/routes/users/-components/UserSectionHeader.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { LucideIcon } from "lucide-react"; -import type { ReactNode } from "react"; - -interface UserSectionHeaderProps { - icon: LucideIcon; - title: ReactNode; - description?: ReactNode; -} - -export function UserSectionHeader({ icon: Icon, title, description }: Readonly) { - return ( -
    -

    -

    - {description && ( - - - {description} - - )} -
    - ); -} diff --git a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx index 626d11e3bd..93f22e1d49 100644 --- a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx +++ b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx @@ -5,15 +5,12 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/compo 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 { MonitorIcon } from "lucide-react"; 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"; -import { UserSectionHeader } from "./UserSectionHeader"; - interface UserSessionsSectionProps { userId: string; } @@ -25,11 +22,9 @@ export function UserSessionsSection({ userId }: Readonly - Sessions} - description={Recent active and historical sessions} - /> +
    + One row per device or browser the user is signed in from. Revoked sessions cannot sign in again. +
    {isLoading ? ( ) : !data || data.sessions.length === 0 ? ( diff --git a/application/account/BackOffice/routes/users/-components/UserTelemetrySection.tsx b/application/account/BackOffice/routes/users/-components/UserTelemetrySection.tsx deleted file mode 100644 index 212fa9f888..0000000000 --- a/application/account/BackOffice/routes/users/-components/UserTelemetrySection.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import { t } from "@lingui/core/macro"; -import { Trans } from "@lingui/react/macro"; -import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; -import { ActivityIcon } from "lucide-react"; - -import { UserSectionHeader } from "./UserSectionHeader"; - -// Application Insights telemetry is delivered separately. Until that backend lands, the tabs render empty -// states so the User detail page can ship without a hard dependency on the telemetry pipeline. -export function UserTelemetrySection() { - return ( -
    - App Insights · Telemetry} - description={Live data from Application Insights, scoped to this user} - /> - - - - Exceptions - - - Page views - - - Custom events - - - - - - - - - - - - -
    - ); -} - -function TelemetryEmpty({ title, description }: Readonly<{ title: string; description: string }>) { - return ( - - - {title} - {description} - - - ); -} diff --git a/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx index 1aaa513b01..a74fd73b01 100644 --- a/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx +++ b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx @@ -1,4 +1,3 @@ -import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react"; import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; @@ -6,19 +5,19 @@ 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 { Building2Icon, ChevronRightIcon } from "lucide-react"; +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"; -import { UserSectionHeader } from "./UserSectionHeader"; - type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; type Membership = BackOfficeUserDetailResponse["tenantMemberships"][number]; @@ -27,21 +26,11 @@ interface UserTenantsSectionProps { } export function UserTenantsSection({ user }: Readonly) { - const membershipCount = user?.tenantMemberships.length ?? 0; return (
    - Accounts} - description={ - user - ? plural(membershipCount, { - one: "# membership", - other: "# memberships" - }) - : null - } - /> +
    + All accounts this user is a member of, with their plan and role. +
    {!user ? ( ) : user.tenantMemberships.length === 0 ? ( @@ -56,7 +45,7 @@ export function UserTenantsSection({ user }: Readonly) ) : ( -
    +
    {user.tenantMemberships.map((membership) => ( ))} @@ -68,54 +57,104 @@ export function UserTenantsSection({ user }: Readonly) function MembershipCard({ membership }: Readonly<{ membership: Membership }>) { const { i18n } = useLingui(); - const mrr = + 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.tenantName} + {membership.country && ( + + + {getCountryName(membership.country, i18n.locale)} + + )} +
    +
    + {getUserRoleLabel(membership.role)} + + + {getSubscriptionPlanLabel(membership.plan)} + + {!membership.emailConfirmed && ( + + Email pending + + )} +
    - {membership.country && ( - - - {getCountryName(membership.country, i18n.locale)} - - )} -
    +
    + {/* Narrow (mobile) layout: stacked with divider, plan + renews on left, prices on right */} +
    +
    {getSubscriptionPlanLabel(membership.plan)} - - {getUserRoleLabel(membership.role)} - {!membership.emailConfirmed && ( - - Email pending - + {membership.renewalDate && ( + + + Renews {formatDate(membership.renewalDate)} + + )} +
    +
    +
    + {newMrr && {currentMrr}} + {currentMrr && {newMrr ?? currentMrr}} +
    + {currentMrr && ( + + / month + )}
    - {mrr && ( - - {mrr} - - / 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/UsersTableRow.tsx b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx index 5c01ce8dc2..4840f47301 100644 --- a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx +++ b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx @@ -1,6 +1,7 @@ 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"; @@ -32,7 +33,10 @@ export function UsersTableRow({
    {displayName} - {user.email} + + + {user.email} +
    @@ -52,7 +56,7 @@ export function UsersTableRow({ {user.lastSeenAt ? (
    - + {formatDate(user.lastSeenAt, true)}
    ) : ( diff --git a/application/account/BackOffice/shared/lib/api/labels.ts b/application/account/BackOffice/shared/lib/api/labels.ts index 62529b0e78..7ea4fe6800 100644 --- a/application/account/BackOffice/shared/lib/api/labels.ts +++ b/application/account/BackOffice/shared/lib/api/labels.ts @@ -49,7 +49,7 @@ export function getTenantStateLabel(state: TenantState): string { export function getPaymentStatusLabel(status: PaymentTransactionStatus): string { switch (status) { case PaymentTransactionStatus.Succeeded: - return t`Succeeded`; + return t`Paid`; case PaymentTransactionStatus.Failed: return t`Failed`; case PaymentTransactionStatus.Pending: diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index d22fa5b925..18de299cc7 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -30,9 +30,6 @@ msgstr "{0} samlet" msgid "{0} blended · {1} over period" msgstr "{0} samlet · {1} for perioden" -msgid "{activationPercent}% activation" -msgstr "{activationPercent}% aktivering" - msgid "{activeUsers} active" msgstr "{activeUsers} aktive" @@ -45,9 +42,6 @@ msgstr "{diffDays, plural, one {# dag siden} other {# dage siden}}" msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inaktive" -msgid "{membershipCount, plural, one {# membership} other {# memberships}}" -msgstr "{membershipCount, plural, one {# medlemskab} other {# medlemskaber}}" - msgid "{pendingUsers} pending" msgstr "{pendingUsers} afventer" @@ -129,6 +123,9 @@ msgstr "Aktivitet" msgid "Admin" msgstr "Admin" +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 event types" msgstr "Alle hændelsestyper" @@ -141,9 +138,6 @@ msgstr "Beløb" msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." -msgid "App Insights · Telemetry" -msgstr "App Insights · Telemetri" - #. placeholder {0}: result.billingEventsAppended #. placeholder {1}: formatDate(result.syncedAt) msgid "Appended {0} new billing events. Last synced at {1}." @@ -170,23 +164,17 @@ msgstr "BackOffice oversigt · {today}" msgid "Basis" msgstr "Basis" -msgid "Billing" -msgstr "Fakturering" - msgid "Billing address" msgstr "Faktureringsadresse" msgid "Billing events" msgstr "Faktureringshændelser" -msgid "Billing history" -msgstr "Faktureringshistorik" - msgid "Billing info added" -msgstr "Faktureringsoplysninger tilføjet" +msgstr "Faktureringsinfo tilføjet" msgid "Billing info updated" -msgstr "Faktureringsoplysninger opdateret" +msgstr "Faktureringsinfo opdateret" msgid "Blended MRR" msgstr "Samlet MRR" @@ -256,9 +244,6 @@ msgstr "Aktuel periode" msgid "Current plan" msgstr "Aktuelt abonnement" -msgid "Custom events" -msgstr "Brugerdefinerede hændelser" - msgid "Dark" msgstr "Mørk" @@ -289,6 +274,10 @@ msgstr "Nedgraderet" msgid "Downgrading" msgstr "Nedgraderer" +#. placeholder {0}: renderDate(event.effectiveAt) +msgid "Effective {0}" +msgstr "Gælder fra {0}" + msgid "Email" msgstr "E-mail" @@ -301,8 +290,11 @@ msgstr "E-mail afventer" msgid "Event" msgstr "Hændelse" -msgid "Exceptions" -msgstr "Fejl" +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 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 "Expired" msgstr "Udløbet" @@ -334,6 +326,9 @@ msgstr "Inaktive" msgid "Invoice" msgstr "Faktura" +msgid "Invoices" +msgstr "Fakturaer" + msgid "IP address" msgstr "IP-adresse" @@ -355,9 +350,6 @@ msgstr "Sidste {periodDays} dage" msgid "Last 24 hours" msgstr "Sidste 24 timer" -msgid "Last 30 days" -msgstr "Sidste 30 dage" - msgid "Last invoice" msgstr "Sidste faktura" @@ -367,19 +359,12 @@ msgstr "Seneste login" msgid "Last seen" msgstr "Sidst set" -#. placeholder {0}: formatDate(user.lastSeenAt) -msgid "Last seen {0}" -msgstr "Sidst set {0}" - msgid "Lifetime value" msgstr "Livstidsværdi" msgid "Light" msgstr "Lys" -msgid "Live data from Application Insights, scoped to this user" -msgstr "Live-data fra Application Insights, afgrænset til denne bruger" - msgid "Local development sign-in. Production uses Azure Container Apps built-in Entra ID authentication." msgstr "Login til lokal udvikling. Produktion bruger Azure Container Apps' indbyggede Entra ID-godkendelse." @@ -401,6 +386,9 @@ msgstr "Logger ind..." msgid "Login history" msgstr "Login-historik" +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" @@ -422,6 +410,12 @@ 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" @@ -464,18 +458,6 @@ msgstr "Ingen faktureringshændelser matcher dine filtre" msgid "No billing events yet" msgstr "Ingen faktureringshændelser endnu" -msgid "No custom events" -msgstr "Ingen brugerdefinerede hændelser" - -msgid "No custom events recorded for this user yet." -msgstr "Ingen brugerdefinerede hændelser registreret for denne bruger endnu." - -msgid "No exceptions" -msgstr "Ingen fejl" - -msgid "No exceptions recorded for this user yet." -msgstr "Ingen fejl registreret for denne bruger endnu." - msgid "No invoices, refunds, or credit notes yet." msgstr "Ingen fakturaer, refusioner eller kreditnotaer endnu." @@ -494,12 +476,6 @@ msgstr "Ingen ejere" msgid "No owners on this account." msgstr "Ingen ejere på denne konto." -msgid "No page views" -msgstr "Ingen sidevisninger" - -msgid "No page views recorded for this user yet." -msgstr "Ingen sidevisninger registreret for denne bruger endnu." - msgid "No paid plan yet." msgstr "Intet betalt abonnement endnu." @@ -536,6 +512,9 @@ msgstr "Ingen brugere matcher din søgning" 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" @@ -563,8 +542,8 @@ msgstr "Ejere" msgid "Page not found" msgstr "Siden blev ikke fundet" -msgid "Page views" -msgstr "Sidevisninger" +msgid "Paid" +msgstr "Betalt" msgid "Payment failed" msgstr "Betaling mislykkedes" @@ -593,6 +572,9 @@ 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" @@ -620,9 +602,6 @@ msgstr "Forrige periode" msgid "Reactivated" msgstr "Genaktiveret" -msgid "Recent active and historical sessions" -msgstr "Nylige aktive og historiske sessioner" - msgid "Recent signups" msgstr "Nylige tilmeldinger" @@ -641,6 +620,7 @@ msgstr "Fornyelsesdato" msgid "Renewed" msgstr "Fornyet" +#. placeholder {0}: formatDate(membership.renewalDate) #. placeholder {0}: formatDate(tenant.renewalDate) msgid "Renews {0}" msgstr "Fornyes {0}" @@ -772,6 +752,9 @@ 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" @@ -808,12 +791,12 @@ msgstr "Brugermenu" msgid "Users" msgstr "Brugere" -msgid "Users ({totalUsers})" -msgstr "Brugere ({totalUsers})" - msgid "Users active" msgstr "Aktive brugere" +msgid "VAT" +msgstr "Moms" + msgid "View accounts" msgstr "Vis konti" @@ -823,8 +806,8 @@ msgstr "Vis alle" msgid "View all {totalEvents} events" msgstr "Vis alle {totalEvents} hændelser" -msgid "View all {totalTransactions} transactions" -msgstr "Vis alle {totalTransactions} transaktioner" +msgid "View all {totalTransactions} invoices" +msgstr "Vis alle {totalTransactions} fakturaer" msgid "vs prior period" msgstr "mod forrige periode" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index c6c8afb261..93bcdd84b5 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -30,9 +30,6 @@ msgstr "{0} blended" msgid "{0} blended · {1} over period" msgstr "{0} blended · {1} over period" -msgid "{activationPercent}% activation" -msgstr "{activationPercent}% activation" - msgid "{activeUsers} active" msgstr "{activeUsers} active" @@ -45,9 +42,6 @@ msgstr "{diffDays, plural, one {# day ago} other {# days ago}}" msgid "{inactiveUsers} inactive" msgstr "{inactiveUsers} inactive" -msgid "{membershipCount, plural, one {# membership} other {# memberships}}" -msgstr "{membershipCount, plural, one {# membership} other {# memberships}}" - msgid "{pendingUsers} pending" msgstr "{pendingUsers} pending" @@ -129,6 +123,9 @@ msgstr "Activity" msgid "Admin" msgstr "Admin" +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 event types" msgstr "All event types" @@ -141,9 +138,6 @@ msgstr "Amount" msgid "An unexpected error occurred while processing your request." msgstr "An unexpected error occurred while processing your request." -msgid "App Insights · Telemetry" -msgstr "App Insights · Telemetry" - #. placeholder {0}: result.billingEventsAppended #. placeholder {1}: formatDate(result.syncedAt) msgid "Appended {0} new billing events. Last synced at {1}." @@ -170,18 +164,12 @@ msgstr "BackOffice overview · {today}" msgid "Basis" msgstr "Basis" -msgid "Billing" -msgstr "Billing" - msgid "Billing address" msgstr "Billing address" msgid "Billing events" msgstr "Billing events" -msgid "Billing history" -msgstr "Billing history" - msgid "Billing info added" msgstr "Billing info added" @@ -256,9 +244,6 @@ msgstr "Current period" msgid "Current plan" msgstr "Current plan" -msgid "Custom events" -msgstr "Custom events" - msgid "Dark" msgstr "Dark" @@ -289,6 +274,10 @@ msgstr "Downgraded" msgid "Downgrading" msgstr "Downgrading" +#. placeholder {0}: renderDate(event.effectiveAt) +msgid "Effective {0}" +msgstr "Effective {0}" + msgid "Email" msgstr "Email" @@ -301,8 +290,11 @@ msgstr "Email pending" msgid "Event" msgstr "Event" -msgid "Exceptions" -msgstr "Exceptions" +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 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 "Expired" msgstr "Expired" @@ -334,6 +326,9 @@ msgstr "Inactive" msgid "Invoice" msgstr "Invoice" +msgid "Invoices" +msgstr "Invoices" + msgid "IP address" msgstr "IP address" @@ -355,9 +350,6 @@ msgstr "Last {periodDays} days" msgid "Last 24 hours" msgstr "Last 24 hours" -msgid "Last 30 days" -msgstr "Last 30 days" - msgid "Last invoice" msgstr "Last invoice" @@ -367,19 +359,12 @@ msgstr "Last log-in" msgid "Last seen" msgstr "Last seen" -#. placeholder {0}: formatDate(user.lastSeenAt) -msgid "Last seen {0}" -msgstr "Last seen {0}" - msgid "Lifetime value" msgstr "Lifetime value" msgid "Light" msgstr "Light" -msgid "Live data from Application Insights, scoped to this user" -msgstr "Live data from Application Insights, scoped to this user" - msgid "Local development sign-in. Production uses Azure Container Apps built-in Entra ID authentication." msgstr "Local development sign-in. Production uses Azure Container Apps built-in Entra ID authentication." @@ -401,6 +386,9 @@ msgstr "Logging in..." msgid "Login history" msgstr "Login history" +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" @@ -422,6 +410,12 @@ 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" @@ -464,18 +458,6 @@ msgstr "No billing events match your filters" msgid "No billing events yet" msgstr "No billing events yet" -msgid "No custom events" -msgstr "No custom events" - -msgid "No custom events recorded for this user yet." -msgstr "No custom events recorded for this user yet." - -msgid "No exceptions" -msgstr "No exceptions" - -msgid "No exceptions recorded for this user yet." -msgstr "No exceptions recorded for this user yet." - msgid "No invoices, refunds, or credit notes yet." msgstr "No invoices, refunds, or credit notes yet." @@ -494,12 +476,6 @@ msgstr "No owners" msgid "No owners on this account." msgstr "No owners on this account." -msgid "No page views" -msgstr "No page views" - -msgid "No page views recorded for this user yet." -msgstr "No page views recorded for this user yet." - msgid "No paid plan yet." msgstr "No paid plan yet." @@ -536,6 +512,9 @@ msgstr "No users match your search" 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" @@ -563,8 +542,8 @@ msgstr "Owners" msgid "Page not found" msgstr "Page not found" -msgid "Page views" -msgstr "Page views" +msgid "Paid" +msgstr "Paid" msgid "Payment failed" msgstr "Payment failed" @@ -593,6 +572,9 @@ 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" @@ -620,9 +602,6 @@ msgstr "Prior period" msgid "Reactivated" msgstr "Reactivated" -msgid "Recent active and historical sessions" -msgstr "Recent active and historical sessions" - msgid "Recent signups" msgstr "Recent signups" @@ -641,6 +620,7 @@ msgstr "Renewal date" msgid "Renewed" msgstr "Renewed" +#. placeholder {0}: formatDate(membership.renewalDate) #. placeholder {0}: formatDate(tenant.renewalDate) msgid "Renews {0}" msgstr "Renews {0}" @@ -772,6 +752,9 @@ 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" @@ -808,12 +791,12 @@ msgstr "User menu" msgid "Users" msgstr "Users" -msgid "Users ({totalUsers})" -msgstr "Users ({totalUsers})" - msgid "Users active" msgstr "Users active" +msgid "VAT" +msgstr "VAT" + msgid "View accounts" msgstr "View accounts" @@ -823,8 +806,8 @@ msgstr "View all" msgid "View all {totalEvents} events" msgstr "View all {totalEvents} events" -msgid "View all {totalTransactions} transactions" -msgstr "View all {totalTransactions} transactions" +msgid "View all {totalTransactions} invoices" +msgstr "View all {totalTransactions} invoices" msgid "vs prior period" msgstr "vs prior period" diff --git a/application/account/WebApp/routes/account/billing/-components/BillingHistoryTable.tsx b/application/account/WebApp/routes/account/billing/-components/BillingHistoryTable.tsx index 167824efce..5ebb8674cc 100644 --- a/application/account/WebApp/routes/account/billing/-components/BillingHistoryTable.tsx +++ b/application/account/WebApp/routes/account/billing/-components/BillingHistoryTable.tsx @@ -28,7 +28,7 @@ function getStatusVariant(status: PaymentTransactionStatus): "default" | "second function getStatusLabel(status: PaymentTransactionStatus): string { switch (status) { case PaymentTransactionStatus.Succeeded: - return t`Succeeded`; + return t`Paid`; case PaymentTransactionStatus.Failed: return t`Failed`; case PaymentTransactionStatus.Pending: diff --git a/application/account/WebApp/routes/account/billing/index.tsx b/application/account/WebApp/routes/account/billing/index.tsx index 669794fddc..72ee9d7239 100644 --- a/application/account/WebApp/routes/account/billing/index.tsx +++ b/application/account/WebApp/routes/account/billing/index.tsx @@ -127,7 +127,7 @@ function BillingPage() { />

    - Billing history + Invoices

    diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index 35f4e2c682..8a0f80e2b1 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -435,9 +435,6 @@ msgstr "Fakturering" msgid "Billing email" msgstr "Faktureringsmail" -msgid "Billing history" -msgstr "Betalingshistorik" - msgid "Billing information" msgstr "Faktureringsoplysninger" @@ -1327,6 +1324,9 @@ msgstr "Inviterede brugere" msgid "Invoice" msgstr "Faktura" +msgid "Invoices" +msgstr "Fakturaer" + msgid "IP address" msgstr "IP-adresse" @@ -1349,7 +1349,7 @@ msgid "Item — clickable row" msgstr "Element — klikbar række" msgid "Item — image media" -msgstr "Element — billedmedie" +msgstr "Element — billede" msgid "Item — variants" msgstr "Element — varianter" @@ -1825,6 +1825,9 @@ msgstr "Siden blev ikke fundet" msgid "Page Views" msgstr "Sidevisninger" +msgid "Paid" +msgstr "Betalt" + msgid "Palak paneer" msgstr "Palak paneer" @@ -2393,7 +2396,7 @@ msgid "Sidebar footer" msgstr "Sidemenu-sidefod" msgid "Sign in without a password using your device." -msgstr "Log ind uden adgangskode med din enhed." +msgstr "Log ind uden adgangskode ved hjælp af din enhed." msgid "Sign up" msgstr "Tilmeld dig" @@ -2519,9 +2522,6 @@ msgstr "Abonnement" msgid "Subtle background for grouped rows inside a panel." msgstr "Diskret baggrund til grupperede rækker i et panel." -msgid "Succeeded" -msgstr "Gennemført" - msgid "Suggestions" msgstr "Forslag" diff --git a/application/account/WebApp/shared/translations/locale/en-US.po b/application/account/WebApp/shared/translations/locale/en-US.po index c686305b60..3bef32b1c9 100644 --- a/application/account/WebApp/shared/translations/locale/en-US.po +++ b/application/account/WebApp/shared/translations/locale/en-US.po @@ -435,9 +435,6 @@ msgstr "Billing" msgid "Billing email" msgstr "Billing email" -msgid "Billing history" -msgstr "Billing history" - msgid "Billing information" msgstr "Billing information" @@ -1327,6 +1324,9 @@ msgstr "Invited users" msgid "Invoice" msgstr "Invoice" +msgid "Invoices" +msgstr "Invoices" + msgid "IP address" msgstr "IP address" @@ -1825,6 +1825,9 @@ msgstr "Page not found" msgid "Page Views" msgstr "Page Views" +msgid "Paid" +msgstr "Paid" + msgid "Palak paneer" msgstr "Palak paneer" @@ -2519,9 +2522,6 @@ msgstr "Subscription" msgid "Subtle background for grouped rows inside a panel." msgstr "Subtle background for grouped rows inside a panel." -msgid "Succeeded" -msgstr "Succeeded" - msgid "Suggestions" msgstr "Suggestions" diff --git a/application/account/WebApp/tests/e2e/subscription-flows.spec.ts b/application/account/WebApp/tests/e2e/subscription-flows.spec.ts index c183d45ee3..9e2db24c3d 100644 --- a/application/account/WebApp/tests/e2e/subscription-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/subscription-flows.spec.ts @@ -178,7 +178,7 @@ test.describe("@smoke", () => { await expect(ownerPage.getByRole("columnheader", { name: "Date" })).toBeVisible(); await expect(ownerPage.getByRole("columnheader", { name: "Amount" })).toBeVisible(); await expect(ownerPage.getByRole("columnheader", { name: "Status" })).toBeVisible(); - await expect(ownerPage.getByText("Succeeded")).toBeVisible(); + await expect(ownerPage.getByText("Paid")).toBeVisible(); await expect(ownerPage.getByRole("link", { name: "Invoice" })).toBeVisible(); await ownerPage.unroute("**/api/account/subscriptions/current"); @@ -648,8 +648,8 @@ test.describe("@comprehensive", () => { })(); // === EMPTY PAYMENT HISTORY === - await step("Scroll to billing history & verify empty state message")(async () => { - await expect(ownerPage.getByRole("heading", { name: "Billing history" })).toBeVisible(); + await step("Scroll to invoices section & verify empty state message")(async () => { + await expect(ownerPage.getByRole("heading", { name: "Invoices" })).toBeVisible(); await expect(ownerPage.getByText("No payment history available.")).toBeVisible(); await ownerPage.unroute("**/api/account/billing/payment-history**"); @@ -712,7 +712,7 @@ test.describe("@comprehensive", () => { await ownerPage.goto("/account/billing"); - await expect(ownerPage.getByText("Succeeded")).toBeVisible(); + await expect(ownerPage.getByText("Paid")).toBeVisible(); await expect(ownerPage.getByText("Refunded")).toBeVisible(); const invoiceLinks = ownerPage.getByRole("link", { name: "Invoice" }); From 58586d0a3322f190a83952f56ca1afe1e83cfc24 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 00:12:31 +0200 Subject: [PATCH 035/158] Add back-office tenant overview with Stripe billing reconciliation, billing event log, drift detection, forward MRR, and non-nullable tax breakdown invariant --- ...aymentTransactionTaxBreakdownConstraint.cs | 18 + .../Queries/GetBackOfficeBillingEvents.cs | 4 + .../Dashboard/Queries/GetDashboardKpis.cs | 7 +- .../Dashboard/Queries/GetDashboardMrrTrend.cs | 9 +- .../Domain/StripeEventRepository.cs | 18 + .../Subscriptions/Domain/Subscription.cs | 5 +- .../Shared/BillingDriftDetector.cs | 30 +- .../Shared/ProcessPendingStripeEvents.cs | 51 ++- .../Shared/StripeEventReplayer.cs | 430 ++++++++++++++++++ .../Commands/SyncTenantWithStripe.cs | 7 + .../BackOffice/Queries/GetTenantDetail.cs | 2 +- .../Queries/GetTenantPaymentHistory.cs | 5 +- .../Queries/GetBackOfficeUserDetail.cs | 4 + .../Core/Integrations/Stripe/IStripeClient.cs | 2 + .../Integrations/Stripe/MockStripeClient.cs | 15 +- .../Core/Integrations/Stripe/StripeClient.cs | 48 +- .../Stripe/UnconfiguredStripeClient.cs | 6 + .../BackOffice/BackOfficeEndpointBaseTest.cs | 6 +- .../Dashboard/GetDashboardKpisTests.cs | 72 ++- .../Dashboard/GetDashboardMrrTrendTests.cs | 2 +- .../BackOffice/SyncTenantWithStripeTests.cs | 22 + .../BillingDriftDetectorTests.cs | 46 +- .../Tests/Subscriptions/StripeClientTests.cs | 44 ++ .../BackOffice/GetTenantDetailTests.cs | 62 ++- .../GetTenantPaymentHistoryTests.cs | 6 +- .../Tenants/BackOffice/GetTenantsTests.cs | 2 +- .../GetBackOfficeUserDetailTests.cs | 2 + .../BackOffice/GetBackOfficeUsersTests.cs | 2 +- 28 files changed, 883 insertions(+), 44 deletions(-) create mode 100644 application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs create mode 100644 application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs diff --git a/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs b/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs new file mode 100644 index 0000000000..ac6028000b --- /dev/null +++ b/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260507205500_AddPaymentTransactionTaxBreakdownConstraint")] +public sealed class AddPaymentTransactionTaxBreakdownConstraint : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddCheckConstraint( + "chk_subscriptions_payment_transactions_tax_breakdown", + "subscriptions", + """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')""" + ); + } +} diff --git a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs index c1c6e995d0..a8357a170e 100644 --- a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs +++ b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs @@ -40,6 +40,8 @@ public sealed record BillingEventSummary( SubscriptionPlan? FromPlan, SubscriptionPlan? ToPlan, decimal? AmountDelta, + decimal? PreviousAmount, + decimal? NewAmount, string? Currency, int? DaysOnPreviousPlan, DateTimeOffset? ScheduledFor, @@ -109,6 +111,8 @@ public async Task> Handle(GetBackOfficeBillingEven e.FromPlan, e.ToPlan, e.AmountDelta, + e.PreviousAmount, + e.NewAmount, e.Currency, e.DaysOnPreviousPlan, e.ScheduledFor, diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs index 07c8e3dc0c..39a700bd63 100644 --- a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs @@ -93,9 +93,14 @@ public async Task> Handle(GetDashboardKp var activeUsersInPeriod = allUsers.LongCount(u => u.LastSeenAt >= periodStart); + // Forward MRR per subscription mirrors the per-account MrrAmount tile: 0 if cancelling at period end, + // the scheduled (downgraded) price if a downgrade is queued, otherwise the current price. var totalMonthlyRecurringRevenue = paidSubscriptions .Where(s => s.CurrentPriceAmount.HasValue) - .Sum(s => s.CurrentPriceAmount!.Value); + .Sum(s => s.CancelAtPeriodEnd + ? 0m + : s.ScheduledPriceAmount ?? s.CurrentPriceAmount!.Value + ); // Period-over-period MRR delta is approximated from the new-tenant signup ratio because the domain does // not store historical MRR snapshots. Operators get a directional signal without a daily snapshot table. diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs index 2983d0cac7..54f95859cb 100644 --- a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs @@ -58,12 +58,17 @@ public async Task> Handle(GetDashboa // A subscription contributes to MRR on a day if it was already subscribed (or backdated) at end-of-day, and has a // known price. Cancellations are not stored as a separate timestamp, so the historical signal is approximated from - // SubscribedSince forward; CurrentPriceAmount is treated as steady over the period. + // SubscribedSince forward; the per-subscription contribution is forward MRR (0 when cancelling at period end, + // ScheduledPriceAmount when a downgrade is queued, otherwise CurrentPriceAmount), matching the KPI tile and the + // per-account MrrAmount tile. The scheduled state is treated as steady over the period. private static decimal ComputeDailyMrr(Subscription[] subscriptions, DateOnly date) { var endOfDay = new DateTimeOffset(date.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); return subscriptions .Where(s => s is { CurrentPriceAmount: not null, SubscribedSince: { } subscribedSince } && subscribedSince < endOfDay) - .Sum(s => s.CurrentPriceAmount!.Value); + .Sum(s => s.CancelAtPeriodEnd + ? 0m + : s.ScheduledPriceAmount ?? s.CurrentPriceAmount!.Value + ); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs index 6d8a23753e..03f281f963 100644 --- a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs @@ -18,6 +18,14 @@ public interface IStripeEventRepository : IAppendRepository Task HasPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns previously-processed stripe_event rows for a customer ordered by CreatedAt. + /// Used by the legacy-data backfill to replay the customer's historical webhook chain + /// into the BillingEvent log. Pending events are excluded — they belong to the live + /// sync path which produces BillingEvents directly from state transitions. + /// + Task GetProcessedByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); } internal sealed class StripeEventRepository(AccountDbContext accountDbContext) @@ -40,4 +48,14 @@ public async Task HasPendingByStripeCustomerIdAsync(StripeCustomerId strip { return await DbSet.AnyAsync(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Pending, cancellationToken); } + + public async Task GetProcessedByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + // SQLite (used in tests) cannot translate DateTimeOffset comparisons in ORDER BY, so we order + // in memory after materializing. The set is bounded per-customer (typically <200 webhooks). + var events = await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Processed) + .ToArrayAsync(cancellationToken); + return events.OrderBy(e => e.CreatedAt).ToArray(); + } } diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index 4a734fa7cc..7e21ead8db 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -200,13 +200,16 @@ 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, - SubscriptionPlan? Plan = null + SubscriptionPlan? Plan = null, + DateTimeOffset? RefundedAt = null ); [PublicAPI] diff --git a/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs index fe8973a8ad..b72f855936 100644 --- a/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs +++ b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs @@ -7,21 +7,37 @@ 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. -/// Today the detector covers — comparing +/// The detector covers — comparing /// `Plan`, `CancelAtPeriodEnd`, `CurrentPriceAmount`, `CurrentPriceCurrency` between the local aggregate /// and the Stripe snapshot. These fields drive customer access and are operationally the most important -/// to keep aligned. Comparison of stored vs expected BillingEvent rows -/// ( / / -/// ) requires a deterministic -/// `ComputeExpectedEvents(StripeSyncSnapshot)` helper that consumes full Stripe history; this is a -/// follow-up extension that plugs into the same return type. +/// to keep aligned. It also flags a coarse when there +/// are stored PaymentTransactions but zero BillingEvent rows for the subscription — the legacy case for +/// subscriptions persisted before the BillingEvent log existed. 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(Subscription subscription, StripeSyncSnapshot snapshot) + public static ImmutableArray Detect(Subscription subscription, StripeSyncSnapshot snapshot, int billingEventCount) { var discrepancies = ImmutableArray.CreateBuilder(); + // Surfaces legacy subscriptions persisted before the BillingEvent log existed (or any other case + // where invoices made it to the local PaymentTransactions array without a corresponding event row). + // The Sync admin action is the natural trigger to fix this with a backfill. + if (subscription.PaymentTransactions.Length > 0 && billingEventCount == 0) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.MissingEvent, + $"Subscription has {subscription.PaymentTransactions.Length} payment transactions but no billing events recorded.", + DriftSeverity.Warning, + ExpectedValue: subscription.PaymentTransactions.Length.ToString(), + ActualValue: "0" + ) + ); + } + if (subscription.Plan != snapshot.Plan) { discrepancies.Add(new DriftDiscrepancy( diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 29ff074caf..1b1ec3df46 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -27,6 +27,8 @@ public sealed class ProcessPendingStripeEvents( ILogger logger ) { + private int _eventsAppendedInCurrentSync; + public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) { return ExecuteAsync(stripeCustomerId, false, cancellationToken); @@ -34,6 +36,7 @@ public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken ca public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync, CancellationToken cancellationToken) { + _eventsAppendedInCurrentSync = 0; // Pessimistic lock serializes concurrent webhook processing for the same customer var isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; await using var transaction = isSqlite @@ -138,6 +141,11 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.SetPaymentTransactions([.. syncedTransactions]); } + if (!subscriptionCreated) + { + await BackfillLegacyBillingEventsAsync(subscription, cancellationToken); + } + var paymentRefunded = subscription.PaymentTransactions.Count(t => t.Status == PaymentTransactionStatus.Refunded) > previousRefundCount; if (billingInfoAdded) @@ -407,11 +415,15 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.CurrentPriceAmount, subscription.CurrentPriceCurrency ); - // The snapshot is built from the just-synced local state, so today's detector finds zero - // discrepancies in the SubscriptionStateMismatch category. The seam stays in place so adding - // a Stripe-derived snapshot (full invoice/charge history) for the BillingEvent comparison - // becomes a localized change to this block plus the detector itself. - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + // The snapshot is built from the just-synced local state, so the SubscriptionStateMismatch + // category finds zero discrepancies today. The seam stays in place so adding a Stripe-derived + // snapshot (full invoice/charge history) for per-event comparison becomes a localized change + // to this block plus the detector itself. + var persistedBillingEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + // Add the count appended during this sync — they are tracked in the DbContext but not yet + // flushed to the database, so the query above wouldn't otherwise see them. + var totalBillingEvents = persistedBillingEvents.Length + _eventsAppendedInCurrentSync; + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, totalBillingEvents); subscription.SetDriftStatus(discrepancies, now); } catch (Exception ex) @@ -434,6 +446,35 @@ private async Task AppendBillingEventAsync(BillingEvent billingEvent, Cancellati if (existing is null) { await billingEventRepository.AddAsync(billingEvent, cancellationToken); + _eventsAppendedInCurrentSync++; + } + } + + /// + /// One-time backfill that replays every stored stripe_event for a customer into the BillingEvent + /// log. Used for subscriptions persisted before the BillingEvent log existed (the live webhook + /// path writes events directly during normal sync). Skipped when the subscription already has + /// BillingEvents — the live path is the authoritative source going forward. + /// + private async Task BackfillLegacyBillingEventsAsync(Subscription subscription, CancellationToken cancellationToken) + { + if (subscription.StripeCustomerId is null) return; + + var existingEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); + if (existingEvents.Length > 0) return; + + var stripeEvents = await stripeEventRepository.GetProcessedByStripeCustomerIdAsync(subscription.StripeCustomerId, cancellationToken); + if (stripeEvents.Length == 0) return; + + 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 replayedEvents = StripeEventReplayer.Replay(subscription, stripeEvents, planByPriceId, priceByPlan); + + foreach (var billingEvent in replayedEvents) + { + await AppendBillingEventAsync(billingEvent, cancellationToken); } } 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..d8c1ddd22d --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs @@ -0,0 +1,430 @@ +using System.Text.Json; +using Account.Features.Subscriptions.Domain; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Replays a customer's stored stripe_events into the BillingEvent log. Used as a one-time backfill +/// for subscriptions persisted before the BillingEvent log existed (the live webhook path writes +/// events directly via ). +/// The replayer is a state machine: it iterates events chronologically and tracks the running +/// subscription state (current plan, current price, scheduled-downgrade plan/price, cancel-at- +/// period-end flag, committed forward MRR). Each event reads its plan label and amounts from the +/// state at that point in time — never from subscription.CurrentPriceAmount or +/// subscription.Plan — so historical rows reflect the truth at the time they happened. After +/// processing each event the state advances. The "MRR after" of one row equals the "MRR before" +/// of the next, making the BillingEvent log a faithful audit trail. +/// Deterministic IDs (derived from the stripe_event Id and BillingEventType) keep the operation +/// idempotent across repeated syncs. +/// +public static class StripeEventReplayer +{ + public static IReadOnlyList Replay( + Subscription subscription, + StripeEvent[] stripeEvents, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan + ) + { + var emitted = new List(); + var state = new ReplayState(); + var currency = subscription.CurrentPriceCurrency ?? "USD"; + + foreach (var stripeEvent in stripeEvents) + { + var occurredAt = stripeEvent.CreatedAt; + var stripeReference = stripeEvent.Id.Value; + var billingEvents = MapEvent(stripeEvent, occurredAt, stripeReference, subscription, state, planByPriceId, priceByPlan, currency); + emitted.AddRange(billingEvents); + } + + return emitted; + } + + private static IEnumerable MapEvent( + StripeEvent stripeEvent, + DateTimeOffset occurredAt, + string stripeReference, + Subscription subscription, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency + ) + { + var subscriptionId = subscription.Id; + var tenantId = subscription.TenantId; + var payload = ParsePayload(stripeEvent.Payload); + + switch (stripeEvent.EventType) + { + case "customer.created": + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.BillingInfoAdded, occurredAt, stripeReference + ); + break; + + case "customer.updated": + if (HasBillingFieldsChanged(payload)) + { + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.BillingInfoUpdated, occurredAt, stripeReference + ); + } + + break; + + case "payment_method.attached": + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.PaymentMethodUpdated, occurredAt, stripeReference + ); + break; + + case "customer.subscription.created": + { + var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId) ?? subscription.Plan; + var newPrice = priceByPlan.TryGetValue(newPlan, out var p) ? p : 0m; + var previousMrr = state.CommittedMrr; + state.Plan = newPlan; + state.PlanPrice = newPrice; + state.CancelAtPeriodEnd = false; + state.ScheduledPlan = null; + state.CommittedMrr = newPrice; + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.SubscriptionCreated, occurredAt, stripeReference, + toPlan: newPlan, + previousAmount: previousMrr, newAmount: newPrice, + amountDelta: newPrice - previousMrr, + currency: currency + ); + break; + } + + case "customer.subscription.updated": + foreach (var billingEvent in MapSubscriptionUpdated(payload, occurredAt, stripeReference, subscription, state, planByPriceId, priceByPlan, currency)) + { + yield return billingEvent; + } + + break; + + // customer.subscription.pending_update_applied fires alongside customer.subscription.updated + // for the same upgrade transition. The updated event carries previous_attributes (the from-plan) + // so it is the higher-fidelity source — we skip pending_update_applied to avoid emitting two + // SubscriptionUpgraded rows for the same logical event. + + case "customer.subscription.deleted": + { + var eventType = MapSubscriptionDeleted(payload); + var previousMrr = state.CommittedMrr; + var fromPlan = state.Plan; + state.Plan = null; + state.PlanPrice = 0m; + state.CancelAtPeriodEnd = false; + state.ScheduledPlan = null; + state.CommittedMrr = 0m; + yield return BillingEvent.Create( + subscriptionId, tenantId, eventType, occurredAt, stripeReference, + fromPlan, SubscriptionPlan.Basis, + previousMrr, 0m, + -previousMrr, + currency + ); + break; + } + + // subscription_schedule.created carries only the current phase — the future-phase plan that + // defines the downgrade target only shows up in the subscription_schedule.updated event that + // fires immediately after. We skip created and let updated drive the DowngradeScheduled row. + + case "subscription_schedule.updated": + { + var scheduledPlan = ResolveScheduledTargetPlan(payload, planByPriceId, state.Plan); + if (scheduledPlan is null) break; + if (scheduledPlan == state.ScheduledPlan) break; + + var scheduledPrice = priceByPlan.TryGetValue(scheduledPlan.Value, out var sp) ? sp : 0m; + var previousMrr = state.CommittedMrr; + state.ScheduledPlan = scheduledPlan; + state.CommittedMrr = scheduledPrice; + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.SubscriptionDowngradeScheduled, occurredAt, stripeReference, + state.Plan, scheduledPlan, + previousMrr, scheduledPrice, + scheduledPrice - previousMrr, + currency + ); + break; + } + + case "subscription_schedule.released": + case "subscription_schedule.canceled": + { + if (state.ScheduledPlan is null) break; + + var previousMrr = state.CommittedMrr; + var newMrr = state.PlanPrice; + state.ScheduledPlan = null; + state.CommittedMrr = newMrr; + var delta = newMrr - previousMrr; + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.SubscriptionDowngradeCancelled, occurredAt, stripeReference, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: newMrr, + amountDelta: delta == 0m ? null : delta, + currency: currency + ); + break; + } + + case "invoice.payment_succeeded": + { + // Only emit a Renewed 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. Renewals don't change + // committed MRR so amountDelta stays null. + var billingReason = ExtractInvoiceBillingReason(payload); + if (billingReason != "subscription_cycle") break; + + var eventType = HasMultiplePaymentAttempts(payload) ? BillingEventType.PaymentRecovered : BillingEventType.SubscriptionRenewed; + yield return BillingEvent.Create( + subscriptionId, tenantId, eventType, occurredAt, stripeReference, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + break; + } + + case "invoice.payment_failed": + // Skip 3DS challenges that succeed on first attempt (attempt_count == 1) — those produce a + // payment_failed event followed shortly by payment_succeeded. Only emit PaymentFailed when + // Stripe has retried (attempt_count > 1), which is a real persistent failure. Failures don't + // change committed MRR — the customer is still on the plan, just behind on payment. + if (HasMultiplePaymentAttempts(payload)) + { + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.PaymentFailed, occurredAt, stripeReference, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + } + + break; + + case "charge.refunded": + { + // A refund is a one-time cash event, not an MRR change going forward, so amountDelta is null. + var refundTransaction = FindClosestRefundedTransaction(subscription, occurredAt); + yield return BillingEvent.Create( + subscriptionId, tenantId, BillingEventType.PaymentRefunded, occurredAt, stripeReference, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: refundTransaction?.Currency ?? currency + ); + break; + } + } + } + + private static IEnumerable MapSubscriptionUpdated( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeReference, + Subscription subscription, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency + ) + { + var previous = payload.TryGetProperty("data", out var data) && data.TryGetProperty("previous_attributes", out var prev) ? prev : default; + if (previous.ValueKind != JsonValueKind.Object) yield break; + + // Cancel-at-period-end toggle. 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 here. + if (previous.TryGetProperty("cancel_at_period_end", out var prevCancel) && prevCancel.ValueKind == JsonValueKind.False) + { + var previousMrr = state.CommittedMrr; + state.CancelAtPeriodEnd = true; + state.CommittedMrr = 0m; + yield return BillingEvent.Create( + subscription.Id, subscription.TenantId, BillingEventType.SubscriptionCancelled, occurredAt, stripeReference, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: 0m, amountDelta: -previousMrr, + currency: currency, + cancellationReason: subscription.CancellationReason + ); + } + else if (previous.TryGetProperty("cancel_at_period_end", out var prevCancelTrue) && prevCancelTrue.ValueKind == JsonValueKind.True) + { + var previousMrr = state.CommittedMrr; + state.CancelAtPeriodEnd = false; + state.CommittedMrr = state.PlanPrice; + yield return BillingEvent.Create( + subscription.Id, subscription.TenantId, BillingEventType.SubscriptionReactivated, occurredAt, stripeReference, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: state.CommittedMrr, + amountDelta: state.CommittedMrr - previousMrr, + currency: currency + ); + } + + // 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 newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId); + var previousPlan = ResolvePlanFromPreviousAttributes(previous, planByPriceId); + if (newPlan is not null && previousPlan is not null && newPlan != previousPlan) + { + var eventType = newPlan.Value > previousPlan.Value ? BillingEventType.SubscriptionUpgraded : BillingEventType.SubscriptionDowngraded; + var previousMrr = state.CommittedMrr; + var newPrice = priceByPlan.TryGetValue(newPlan.Value, out var np) ? np : 0m; + state.Plan = newPlan; + state.PlanPrice = newPrice; + state.CommittedMrr = state.CancelAtPeriodEnd ? 0m : newPrice; + yield return BillingEvent.Create( + subscription.Id, subscription.TenantId, eventType, occurredAt, stripeReference, + previousPlan, newPlan, + previousMrr, state.CommittedMrr, + state.CommittedMrr - previousMrr, + currency + ); + } + } + + private static BillingEventType MapSubscriptionDeleted(JsonElement payload) + { + var data = payload.TryGetProperty("data", out var d) ? d : default; + var sub = data.TryGetProperty("object", out var obj) ? obj : default; + var status = sub.TryGetProperty("status", out var s) ? s.GetString() : null; + var cancelAtPeriodEnd = sub.TryGetProperty("cancel_at_period_end", out var cape) && cape.ValueKind == JsonValueKind.True; + + if (status is "past_due" or "unpaid" or "incomplete_expired") return BillingEventType.SubscriptionSuspended; + if (cancelAtPeriodEnd) return BillingEventType.SubscriptionExpired; + return BillingEventType.SubscriptionImmediatelyCancelled; + } + + private static SubscriptionPlan? ResolvePlanFromSubscriptionPayload(JsonElement payload, IReadOnlyDictionary planByPriceId) + { + var data = payload.TryGetProperty("data", out var d) ? d : default; + var sub = data.TryGetProperty("object", out var obj) ? obj : default; + var items = sub.TryGetProperty("items", out var i) ? i : default; + var itemsData = items.TryGetProperty("data", out var id) ? id : 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 pid) ? pid.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.TryGetProperty("items", out var items)) 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 pid) ? pid.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) + { + var data = payload.TryGetProperty("data", out var d) ? d : default; + var schedule = data.TryGetProperty("object", out var obj) ? obj : default; + var phases = schedule.TryGetProperty("phases", out var ph) ? ph : 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 i) ? i : 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 bool HasBillingFieldsChanged(JsonElement payload) + { + var previous = payload.TryGetProperty("data", out var data) && data.TryGetProperty("previous_attributes", out var prev) ? prev : 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) + { + var data = payload.TryGetProperty("data", out var d) ? d : default; + var invoice = data.TryGetProperty("object", out var obj) ? obj : default; + return invoice.TryGetProperty("billing_reason", out var br) ? br.GetString() : null; + } + + private static bool HasMultiplePaymentAttempts(JsonElement payload) + { + var data = payload.TryGetProperty("data", out var d) ? d : default; + var invoice = data.TryGetProperty("object", out var obj) ? obj : default; + var attemptCount = invoice.TryGetProperty("attempt_count", out var ac) && ac.ValueKind == JsonValueKind.Number ? ac.GetInt32() : 0; + return attemptCount > 1; + } + + private static PaymentTransaction? FindClosestRefundedTransaction(Subscription subscription, DateTimeOffset occurredAt) + { + return subscription.PaymentTransactions + .Where(t => t.Status == PaymentTransactionStatus.Refunded) + .OrderBy(t => Math.Abs((t.Date - occurredAt).TotalSeconds)) + .FirstOrDefault(); + } + + 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; + } + } + + private 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; } + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs index 85dbf04b0b..c971a53437 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs @@ -1,6 +1,7 @@ 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; @@ -28,12 +29,18 @@ public sealed class SyncTenantWithStripeHandler( ISubscriptionRepository subscriptionRepository, IBillingEventRepository billingEventRepository, ProcessPendingStripeEvents processPendingStripeEvents, + StripeClientFactory stripeClientFactory, TimeProvider timeProvider, ITelemetryEventsCollector events ) : IRequestHandler> { public async Task> Handle(SyncTenantWithStripeCommand command, CancellationToken cancellationToken) { + if (stripeClientFactory.GetClient() is UnconfiguredStripeClient) + { + return Result.BadRequest("Stripe is not configured in this environment, sync is unavailable."); + } + var tenant = await tenantRepository.GetByIdUnfilteredAsync(command.TenantId, cancellationToken); if (tenant is null) return Result.NotFound($"Tenant with id '{command.TenantId}' not found."); diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs index fd904fd3ac..cab060aaac 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -60,7 +60,7 @@ public async Task> Handle(GetTenantDetailQuery quer var lifetimeValue = subscription?.PaymentTransactions .Where(t => t.Status == PaymentTransactionStatus.Succeeded) - .Sum(t => t.Amount); + .Sum(t => t.AmountExcludingTax); var hasEverSubscribed = subscription?.PaymentTransactions .Any(t => t.Status == PaymentTransactionStatus.Succeeded) == true; diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs index d3453e81b4..cbd6408c39 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs @@ -21,9 +21,12 @@ public sealed record TenantPaymentHistoryResponse(int TotalCount, int PageSize, public sealed record TenantPaymentTransaction( PaymentTransactionId Id, decimal Amount, + decimal AmountExcludingTax, + decimal TaxAmount, string Currency, PaymentTransactionStatus Status, DateTimeOffset Date, + DateTimeOffset? RefundedAt, string? FailureReason, string? InvoiceUrl, string? CreditNoteUrl, @@ -63,7 +66,7 @@ public async Task> Handle(GetTenantPaymentH var paged = transactions .Skip(query.PageOffset * query.PageSize) .Take(query.PageSize) - .Select(t => new TenantPaymentTransaction(t.Id, t.Amount, t.Currency, t.Status, t.Date, t.FailureReason, t.InvoiceUrl, t.CreditNoteUrl, t.Plan)) + .Select(t => new TenantPaymentTransaction(t.Id, t.Amount, t.AmountExcludingTax, t.TaxAmount, t.Currency, t.Status, t.Date, t.RefundedAt, t.FailureReason, t.InvoiceUrl, t.CreditNoteUrl, t.Plan)) .ToArray(); return new TenantPaymentHistoryResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs index de089426b1..5e5e079a54 100644 --- a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs @@ -44,7 +44,9 @@ public sealed record BackOfficeUserTenantMembership( PlannedSubscriptionChange? PlannedChange, bool HasEverSubscribed, decimal? MonthlyRecurringRevenue, + decimal? ScheduledPriceAmount, string? Currency, + DateTimeOffset? RenewalDate, string? Country, UserRole Role, bool EmailConfirmed, @@ -97,7 +99,9 @@ public async Task> Handle(GetBackOfficeUser plannedChange, hasEverSubscribed, subscription?.CurrentPriceAmount, + subscription?.ScheduledPriceAmount, subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, subscription?.BillingInfo?.Address?.Country, u.Role, u.EmailConfirmed, diff --git a/application/account/Core/Integrations/Stripe/IStripeClient.cs b/application/account/Core/Integrations/Stripe/IStripeClient.cs index c3f6ce0b74..f09261f0cf 100644 --- a/application/account/Core/Integrations/Stripe/IStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/IStripeClient.cs @@ -24,6 +24,8 @@ public interface IStripeClient Task GetPriceCatalogAsync(CancellationToken cancellationToken); + Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken); + StripeWebhookEventResult? VerifyWebhookSignature(string payload, string signatureHeader); Task GetCustomerBillingInfoAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); diff --git a/application/account/Core/Integrations/Stripe/MockStripeClient.cs b/application/account/Core/Integrations/Stripe/MockStripeClient.cs index bf47c90888..45bfb48374 100644 --- a/application/account/Core/Integrations/Stripe/MockStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/MockStripeClient.cs @@ -52,6 +52,8 @@ public sealed class MockStripeClient(IConfiguration configuration, TimeProvider new PaymentTransaction( PaymentTransactionId.NewId(), 29.99m, + 23.99m, + 6.00m, "USD", PaymentTransactionStatus.Succeeded, now, @@ -126,6 +128,17 @@ public Task GetPriceCatalogAsync(CancellationToken cancellat ); } + public Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) + { + EnsureEnabled(); + return Task.FromResult>(new Dictionary + { + ["price_mock_standard"] = SubscriptionPlan.Standard, + ["price_mock_premium"] = SubscriptionPlan.Premium + } + ); + } + public StripeWebhookEventResult? VerifyWebhookSignature(string payload, string signatureHeader) { EnsureEnabled(); @@ -259,7 +272,7 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu var now = timeProvider.GetUtcNow(); return Task.FromResult( [ - new PaymentTransaction(PaymentTransactionId.NewId(), 29.99m, "USD", PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), 29.99m, 23.99m, 6.00m, "USD", PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, null, SubscriptionPlan.Standard) ] ); } diff --git a/application/account/Core/Integrations/Stripe/StripeClient.cs b/application/account/Core/Integrations/Stripe/StripeClient.cs index 653025e6bb..d4cace7708 100644 --- a/application/account/Core/Integrations/Stripe/StripeClient.cs +++ b/application/account/Core/Integrations/Stripe/StripeClient.cs @@ -985,6 +985,13 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st .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 }, @@ -999,19 +1006,25 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st { var paymentIntentId = invoice.Payments?.Data?.FirstOrDefault()?.Payment?.PaymentIntentId; var chargeAmountRefunded = paymentIntentId is not null && refundedAmountByPaymentIntentId.TryGetValue(paymentIntentId, out var refunded) ? refunded : 0L; + var refundedAt = paymentIntentId is not null && latestRefundedAtByPaymentIntentId.TryGetValue(paymentIntentId, out var rAt) ? (DateTimeOffset?)rAt : null; var displayAmount = (invoice.Status == "paid" ? invoice.AmountPaid : invoice.Total) / 100m; + var taxAmount = (invoice.TotalTaxes ?? []).Sum(t => t.Amount) / 100m; + var amountExcludingTax = displayAmount - taxAmount; var plan = ResolvePlanForInvoice(invoice, planByPriceId); return new PaymentTransaction( PaymentTransactionId.NewId(), displayAmount, + amountExcludingTax, + taxAmount, invoice.Currency.ToUpperInvariant(), MapInvoiceStatus(invoice.Status, invoice.AmountPaid, invoice.PostPaymentCreditNotesAmount, chargeAmountRefunded), invoice.Created, invoice.Status == "uncollectible" ? "Payment failed." : null, invoice.InvoicePdf, creditNotesByInvoiceId.GetValueOrDefault(invoice.Id), - plan + plan, + refundedAt ); } ).ToArray(); @@ -1029,23 +1042,34 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st } /// - /// Resolves a Stripe invoice's first line-item priceId to its via the supplied - /// lookup table. Returns null when the line item has no priceId (e.g., manual line items or 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 . + /// 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 static SubscriptionPlan? ResolvePlanForInvoice(Invoice invoice, IReadOnlyDictionary planByPriceId) + public async Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) { - var priceId = invoice.Lines?.Data?.FirstOrDefault()?.Pricing?.PriceDetails?.Price; - return priceId is not null && planByPriceId.TryGetValue(priceId, out var plan) ? plan : null; + return await BuildPlanByPriceIdAsync(cancellationToken); } /// - /// 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. + /// 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; + } + private async Task> BuildPlanByPriceIdAsync(CancellationToken cancellationToken) { await EnsurePriceCachePopulatedAsync(cancellationToken); diff --git a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs index f93601fbd4..448543f4d3 100644 --- a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs @@ -64,6 +64,12 @@ 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 StripeWebhookEventResult? VerifyWebhookSignature(string payload, string signatureHeader) { logger.LogWarning("Stripe is not configured. Cannot verify webhook signature"); diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index 9655df717c..36c21bf386 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -46,6 +46,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,7 +78,7 @@ 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()); @@ -99,6 +101,8 @@ protected BackOfficeEndpointBaseTest() protected DatabaseSeeder DatabaseSeeder { get; } + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } + public void Dispose() { Dispose(true); diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs index 6d9528eb90..2b262bb5c4 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardKpisTests.cs @@ -90,6 +90,34 @@ public async Task GetDashboardKpis_WhenCalled_ShouldReturnUserAndSessionAggregat payload.NewTenantsInPeriod.Should().Be(2); } + [Fact] + public async Task GetDashboardKpis_WhenSubscriptionsAreCancellingOrDowngrading_ShouldUseForwardMrr() + { + // Arrange — three paid subscriptions: one stable Premium, one Premium scheduled to downgrade to Standard, + // one Standard cancelling at period end. Forward MRR sums to 299 + 149 + 0 = 448. + var now = DateTimeOffset.UtcNow; + var stable = SeedTenant("Stable Premium", SubscriptionPlan.Premium, now.AddDays(-30)); + SeedPaidSubscription(stable, SubscriptionPlan.Premium, 299m, false, null, null); + + var downgrading = SeedTenant("Downgrading", SubscriptionPlan.Premium, now.AddDays(-30)); + SeedPaidSubscription(downgrading, SubscriptionPlan.Premium, 299m, false, SubscriptionPlan.Standard, 149m); + + var cancelling = SeedTenant("Cancelling", SubscriptionPlan.Standard, now.AddDays(-30)); + SeedPaidSubscription(cancelling, SubscriptionPlan.Standard, 149m, true, null, null); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/dashboard/kpis"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.BlendedMonthlyRecurringRevenue.Should().Be(448m); + } + [Fact] public async Task GetDashboardKpis_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() { @@ -134,12 +162,54 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, DateTimeOffset c return tenantId; } + private void SeedPaidSubscription( + TenantId tenantId, + SubscriptionPlan plan, + decimal currentPriceAmount, + bool cancelAtPeriodEnd, + SubscriptionPlan? scheduledPlan, + decimal? scheduledPriceAmount + ) + { + var paymentTransactionsJson = JsonSerializer.Serialize(new[] + { + new PaymentTransaction(PaymentTransactionId.NewId(), currentPriceAmount, currentPriceAmount, 0m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, plan) + } + ); + + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", SubscriptionId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("plan", plan.ToString()), + ("scheduled_plan", scheduledPlan?.ToString()), + ("stripe_customer_id", "cus_test"), + ("stripe_subscription_id", "sub_test"), + ("current_price_amount", currentPriceAmount), + ("current_price_currency", "DKK"), + ("current_period_end", DateTimeOffset.UtcNow.AddDays(30)), + ("cancel_at_period_end", cancelAtPeriodEnd), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", paymentTransactionsJson), + ("payment_method", null), + ("billing_info", null), + ("scheduled_price_amount", (object?)scheduledPriceAmount ?? DBNull.Value), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") + ] + ); + } + private void SeedSubscription(TenantId tenantId, SubscriptionPlan plan, decimal? currentPriceAmount, bool hasSucceededPayment) { var paymentTransactionsJson = hasSucceededPayment ? JsonSerializer.Serialize(new[] { - new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, 49.99m, 0m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) } ) : "[]"; diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 43a050c41f..98e7c88a2f 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -91,7 +91,7 @@ private void SeedActiveSubscription(TenantId tenantId, decimal currentPriceAmoun { var paymentTransactions = JsonSerializer.Serialize(new[] { - new PaymentTransaction(PaymentTransactionId.NewId(), currentPriceAmount, "DKK", PaymentTransactionStatus.Succeeded, subscribedSince, null, null, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), currentPriceAmount, currentPriceAmount, 0m, "DKK", PaymentTransactionStatus.Succeeded, subscribedSince, null, null, null, SubscriptionPlan.Standard) } ); diff --git a/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs index 34cd26b193..34f12327bf 100644 --- a/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs +++ b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using Account.Features.Tenants.BackOffice.Commands; +using Account.Integrations.OAuth; using Account.Integrations.Stripe; using FluentAssertions; using SharedKernel.Authentication.MockEasyAuth; @@ -22,6 +23,7 @@ public async Task SyncTenantWithStripe_WhenSubscriptionHasStripeCustomer_ShouldR ); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); + client.DefaultRequestHeaders.Add("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); // Act var response = await client.PostAsync($"/api/back-office/tenants/{DatabaseSeeder.Tenant1.Id}/sync-with-stripe", null); @@ -34,6 +36,7 @@ public async Task SyncTenantWithStripe_WhenSubscriptionHasStripeCustomer_ShouldR payload.HasDriftDetected.Should().BeFalse(); payload.DriftDiscrepancyCount.Should().Be(0); payload.SyncedAt.Should().BeAfter(DateTimeOffset.UtcNow.AddMinutes(-1)); + TelemetryEventsCollectorSpy.CollectedEvents.Should().ContainSingle(e => e.GetType().Name == "TenantSyncedWithStripe"); } [Fact] @@ -56,6 +59,7 @@ public async Task SyncTenantWithStripe_WhenTenantDoesNotExist_ShouldReturnNotFou // Arrange var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); + client.DefaultRequestHeaders.Add("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); var unknownTenantId = TenantId.NewId(); // Act @@ -65,6 +69,24 @@ public async Task SyncTenantWithStripe_WhenTenantDoesNotExist_ShouldReturnNotFou response.StatusCode.Should().Be(HttpStatusCode.NotFound); } + [Fact] + public async Task SyncTenantWithStripe_WhenStripeNotConfigured_ShouldReturnBadRequest() + { + // Arrange + Connection.Update("subscriptions", "tenant_id", DatabaseSeeder.Tenant1.Id.Value, [ + ("stripe_customer_id", MockStripeClient.MockCustomerId) + ] + ); + 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}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + } + [Fact] public async Task SyncTenantWithStripe_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() { diff --git a/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs b/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs index b37c3ac2b0..a742355cd7 100644 --- a/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs +++ b/application/account/Tests/Subscriptions/BillingDriftDetectorTests.cs @@ -17,7 +17,7 @@ public void Detect_WhenSubscriptionMatchesStripe_ShouldReturnEmpty() var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "DKK"); // Act - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 0); // Assert discrepancies.Should().BeEmpty(); @@ -32,7 +32,7 @@ public void Detect_WhenPlanDiffers_ShouldReturnCriticalDiscrepancy() var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 49.00m, "DKK"); // Act - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 0); // Assert discrepancies.Should().ContainSingle(); @@ -52,7 +52,7 @@ public void Detect_WhenCancelAtPeriodEndDiffers_ShouldReturnWarningDiscrepancy() var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Standard, true, 49.00m, "DKK"); // Act - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 0); // Assert discrepancies.Should().ContainSingle(); @@ -69,13 +69,51 @@ public void Detect_WhenMultipleFieldsDiffer_ShouldReturnAllDiscrepancies() var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "USD"); // Act - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot); + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 0); // Assert discrepancies.Should().HaveCount(3); discrepancies.Select(d => d.Kind).Should().AllBeEquivalentTo(DriftDiscrepancyKind.SubscriptionStateMismatch); } + [Fact] + public void Detect_WhenPaymentTransactionsExistButNoBillingEvents_ShouldReturnMissingEventDiscrepancy() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Premium, 99.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + subscription.SetPaymentTransactions( + [new PaymentTransaction(PaymentTransactionId.NewId(), 99.00m, 99.00m, 0m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow, null, null, null)] + ); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "DKK"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 0); + + // Assert + discrepancies.Should().ContainSingle(); + discrepancies[0].Kind.Should().Be(DriftDiscrepancyKind.MissingEvent); + discrepancies[0].Severity.Should().Be(DriftSeverity.Warning); + } + + [Fact] + public void Detect_WhenPaymentTransactionsAndBillingEventsBothPresent_ShouldNotReturnMissingEventDiscrepancy() + { + // Arrange + var subscription = Subscription.Create(TenantId.NewId()); + subscription.SetStripeSubscription(null, SubscriptionPlan.Premium, 99.00m, "DKK", DateTimeOffset.UtcNow.AddDays(30), null, DateTimeOffset.UtcNow); + subscription.SetPaymentTransactions( + [new PaymentTransaction(PaymentTransactionId.NewId(), 99.00m, 99.00m, 0m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow, null, null, null)] + ); + var snapshot = new StripeSyncSnapshot(SubscriptionPlan.Premium, false, 99.00m, "DKK"); + + // Act + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 1); + + // Assert + discrepancies.Should().BeEmpty(); + } + [Fact] public void Detect_WhenSubscriptionHasDriftSet_AndAcknowledged_ShouldClearFlag() { diff --git a/application/account/Tests/Subscriptions/StripeClientTests.cs b/application/account/Tests/Subscriptions/StripeClientTests.cs index 2cf8b77996..1e2522b507 100644 --- a/application/account/Tests/Subscriptions/StripeClientTests.cs +++ b/application/account/Tests/Subscriptions/StripeClientTests.cs @@ -97,6 +97,50 @@ public void ResolvePlanForInvoice_WithoutLineItems_ShouldReturnNull() plan.Should().BeNull(); } + [Fact] + public void ResolvePlanForInvoice_WithProrationUpgrade_ShouldReturnNewPlan() + { + // Arrange — proration invoice for an upgrade has two lines: a negative credit on the old plan and a + // positive charge on the new plan. Stripe may return the credit line first; we must still resolve + // to the new plan being charged for, not the old one being credited. + var invoice = new Invoice + { + Lines = new StripeList + { + Data = + [ + new InvoiceLineItem + { + Amount = -14654, + Pricing = new InvoiceLineItemPricing + { + PriceDetails = new InvoiceLineItemPricingPriceDetails { Price = "price_standard" } + } + }, + new InvoiceLineItem + { + Amount = 29406, + Pricing = new InvoiceLineItemPricing + { + PriceDetails = new InvoiceLineItemPricingPriceDetails { Price = "price_premium" } + } + } + ] + } + }; + IReadOnlyDictionary planByPriceId = new Dictionary + { + ["price_standard"] = SubscriptionPlan.Standard, + ["price_premium"] = SubscriptionPlan.Premium + }; + + // Act + var plan = AccountStripeClient.ResolvePlanForInvoice(invoice, planByPriceId); + + // Assert + plan.Should().Be(SubscriptionPlan.Premium); + } + [Fact] public void ResolvePlanForInvoice_WithMissingPricing_ShouldReturnNull() { diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs index 6402783d2b..c269cfb8c8 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantDetailTests.cs @@ -34,7 +34,7 @@ public async Task GetTenantDetail_WhenTenantExists_ShouldReturnFullDetail() var billingInfoJson = JsonSerializer.Serialize(new BillingInfo("Acme Corp", new BillingAddress("123 Main St", null, "12345", "Springfield", "IL", "US"), null, null)); var transactions = ImmutableArray.Create( - new PaymentTransaction(PaymentTransactionId.NewId(), 199.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, null, null, SubscriptionPlan.Premium) + new PaymentTransaction(PaymentTransactionId.NewId(), 199.00m, 199.00m, 0m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, null, null, SubscriptionPlan.Premium) ); var subscribedSince = DateTimeOffset.Parse("2025-02-01T00:00:00Z"); Connection.Insert("subscriptions", [ @@ -117,6 +117,66 @@ public async Task GetTenantDetail_WhenSubscriptionMissing_ShouldReturnNullSubscr payload.HasEverSubscribed.Should().BeFalse(); } + [Fact] + public async Task GetTenantDetail_WhenSubscriptionHasRefundedTransaction_ShouldExcludeRefundFromLifetimeValue() + { + // Arrange + var tenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddDays(-5)), + ("modified_at", null), + ("name", "Refunded Customer"), + ("state", nameof(TenantState.Active)), + ("plan", nameof(SubscriptionPlan.Premium)), + ("logo", """{"Url":null,"Version":1}""") + ] + ); + + var transactions = ImmutableArray.Create( + new PaymentTransaction(PaymentTransactionId.NewId(), 100.00m, 80.00m, 20.00m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, null, null, SubscriptionPlan.Premium), + new PaymentTransaction(PaymentTransactionId.NewId(), 100.00m, 80.00m, 20.00m, "DKK", PaymentTransactionStatus.Refunded, DateTimeOffset.Parse("2025-02-01T00:00:00Z"), null, null, null, SubscriptionPlan.Premium), + new PaymentTransaction(PaymentTransactionId.NewId(), 100.00m, 80.00m, 20.00m, "DKK", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-03-01T00:00:00Z"), null, null, null, SubscriptionPlan.Premium) + ); + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", SubscriptionId.NewId().ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-5)), + ("modified_at", null), + ("plan", nameof(SubscriptionPlan.Premium)), + ("scheduled_plan", null), + ("stripe_customer_id", "cus_test_refund"), + ("stripe_subscription_id", "sub_test_refund"), + ("current_price_amount", 100.00), + ("current_price_currency", "DKK"), + ("current_period_end", DateTimeOffset.UtcNow.AddDays(25)), + ("cancel_at_period_end", false), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())), + ("payment_method", null), + ("billing_info", null), + ("subscribed_since", DateTimeOffset.Parse("2025-01-01T00:00:00Z")), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") + ] + ); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync($"/api/back-office/tenants/{tenantId}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.LifetimeValue.Should().Be(160.00m); + } + [Fact] public async Task GetTenantDetail_WhenTenantNotFound_ShouldReturnNotFound() { diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs index 3242bd877a..064ce616e6 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantPaymentHistoryTests.cs @@ -21,9 +21,9 @@ public async Task GetTenantPaymentHistory_WhenSubscriptionHasTransactions_Should // Arrange var tenant = DatabaseSeeder.Tenant1; var transactions = ImmutableArray.Create( - new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, "https://stripe.test/inv1", null, SubscriptionPlan.Standard), - new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-02-01T00:00:00Z"), null, "https://stripe.test/inv2", null, SubscriptionPlan.Standard), - new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, "USD", PaymentTransactionStatus.Failed, DateTimeOffset.Parse("2025-03-01T00:00:00Z"), "Card declined.", null, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, 29.00m, 0m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-01-01T00:00:00Z"), null, "https://stripe.test/inv1", null, SubscriptionPlan.Standard), + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, 29.00m, 0m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.Parse("2025-02-01T00:00:00Z"), null, "https://stripe.test/inv2", null, SubscriptionPlan.Standard), + new PaymentTransaction(PaymentTransactionId.NewId(), 29.00m, 29.00m, 0m, "USD", PaymentTransactionStatus.Failed, DateTimeOffset.Parse("2025-03-01T00:00:00Z"), "Card declined.", null, null, SubscriptionPlan.Standard) ); Connection.Update("subscriptions", "tenant_id", tenant.Id.Value, [ ("payment_transactions", JsonSerializer.Serialize(transactions.ToArray())) diff --git a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs index 1700f90c28..496757c6e9 100644 --- a/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs +++ b/application/account/Tests/Tenants/BackOffice/GetTenantsTests.cs @@ -389,7 +389,7 @@ private TenantId SeedTenant(string name, SubscriptionPlan plan, decimal? mrr, st var paymentTransactionsJson = hasEverSubscribed ? JsonSerializer.Serialize(new[] { - new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, 49.99m, 0m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) } ) : "[]"; diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs index be1c12d165..d61292d1d0 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUserDetailTests.cs @@ -65,6 +65,8 @@ public async Task GetBackOfficeUserDetail_WhenUserBelongsToMultipleTenants_Shoul payload.TenantMemberships.Should().OnlyContain(m => m.PlannedChange == null); payload.TenantMemberships.Should().OnlyContain(m => m.HasEverSubscribed == false); payload.TenantMemberships.Should().OnlyContain(m => m.MonthlyRecurringRevenue == null); + payload.TenantMemberships.Should().OnlyContain(m => m.ScheduledPriceAmount == null); + payload.TenantMemberships.Should().OnlyContain(m => m.RenewalDate == null); payload.TenantMemberships.Should().OnlyContain(m => m.Currency == null); payload.TenantMemberships.Should().OnlyContain(m => m.Country == null); payload.TenantMemberships.Should().OnlyContain(m => m.TenantLogoUrl == null); diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 8884d854b9..50d1058137 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -292,7 +292,7 @@ private void SeedSubscriptionWithSucceededPayment(TenantId tenantId) { var paymentTransactionsJson = JsonSerializer.Serialize(new[] { - new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) + new PaymentTransaction(PaymentTransactionId.NewId(), 49.99m, 49.99m, 0m, "USD", PaymentTransactionStatus.Succeeded, DateTimeOffset.UtcNow.AddDays(-30), null, null, null, SubscriptionPlan.Standard) } ); From 797ebef79a79833df72135ea5c3528e2cad73464 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 00:23:49 +0200 Subject: [PATCH 036/158] List all back-office users newest-first by default with pagination --- .../users/-components/UsersTableRow.tsx | 10 +----- .../account/BackOffice/routes/users/index.tsx | 30 ++++++++-------- .../shared/translations/locale/da-DK.po | 18 +++++----- .../shared/translations/locale/en-US.po | 18 +++++----- .../BackOffice/Queries/GetBackOfficeUsers.cs | 12 +++---- .../Features/Users/Domain/UserRepository.cs | 34 +++++++++++-------- .../BackOffice/GetBackOfficeUsersTests.cs | 31 +++++++---------- .../tests/e2e/back-office-flows.spec.ts | 4 +-- 8 files changed, 73 insertions(+), 84 deletions(-) diff --git a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx index 4840f47301..10399a8d4b 100644 --- a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx +++ b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx @@ -5,7 +5,6 @@ import { MailIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; -import { TenantStatusBadge } from "@/routes/accounts/-components/TenantStatusBadge"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; import { getUserRoleLabel } from "@/shared/lib/api/labels"; @@ -41,14 +40,7 @@ export function UsersTableRow({
    -
    - {user.tenantName} - -
    + {user.tenantName}
    {getUserRoleLabel(user.role)} diff --git a/application/account/BackOffice/routes/users/index.tsx b/application/account/BackOffice/routes/users/index.tsx index c7922aa569..d62c055430 100644 --- a/application/account/BackOffice/routes/users/index.tsx +++ b/application/account/BackOffice/routes/users/index.tsx @@ -5,7 +5,7 @@ import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@r import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; import { keepPreviousData } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { SearchIcon, UsersIcon } from "lucide-react"; +import { UsersIcon } from "lucide-react"; import { z } from "zod"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; @@ -27,12 +27,10 @@ export const Route = createFileRoute("/users/")({ component: UsersSearchPage }); -const minSearchLength = 2; - function UsersSearchPage() { const { search, roles, activity, pageOffset } = Route.useSearch(); const trimmed = search?.trim() ?? ""; - const hasSearch = trimmed.length >= minSearchLength; + const hasSearchOrFilter = trimmed.length > 0 || (roles?.length ?? 0) > 0 || activity !== undefined; const { data, isLoading } = api.useQuery( "get", @@ -40,19 +38,19 @@ function UsersSearchPage() { { params: { query: { - Search: trimmed, + Search: trimmed.length > 0 ? trimmed : undefined, Roles: roles, Activity: activity, PageOffset: pageOffset } } }, - { placeholderData: keepPreviousData, enabled: hasSearch } + { placeholderData: keepPreviousData } ); const users = data?.users ?? []; - const showEmptySearch = !hasSearch; - const showNoResults = hasSearch && !isLoading && users.length === 0; + const showNoResults = !isLoading && users.length === 0 && hasSearchOrFilter; + const showEmpty = !isLoading && users.length === 0 && !hasSearchOrFilter; return ( @@ -63,35 +61,35 @@ function UsersSearchPage() { maxWidth="64rem" browserTitle={t`Users`} title={t`Users`} - subtitle={t`Search users by email, name, or account.`} + subtitle={t`All users across every account, newest first. Search and filter to narrow down.`} > - {showEmptySearch ? ( + {showNoResults ? ( - + - Type to search + No users match your search - Search by user email, first or last name, or account name. At least 2 characters. + Try a different search term or clear the role and activity filters. - ) : showNoResults ? ( + ) : showEmpty ? ( - No users match your search + No users yet - Try a different search term or clear the role and activity filters. + Users will appear here as accounts are created. diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 18de299cc7..946dc7d832 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -129,6 +129,9 @@ msgstr "Alle konti, brugeren er medlem af, med deres plan og rolle." msgid "All event types" msgstr "Alle hændelsestyper" +msgid "All users across every account, newest first. Search and filter to narrow down." +msgstr "Alle brugere på tværs af konti, nyeste først. Søg og filtrér for at indsnævre." + msgid "All-time" msgstr "Samlet" @@ -509,6 +512,9 @@ 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 "Occurred" msgstr "Tidspunkt" @@ -652,15 +658,9 @@ msgstr "Søg efter navn" msgid "Search by name or email" msgstr "Søg efter navn eller e-mail" -msgid "Search by user email, first or last name, or account name. At least 2 characters." -msgstr "Søg på brugerens e-mail, for- eller efternavn, eller kontonavn. Mindst 2 tegn." - msgid "Search users" msgstr "Søg brugere" -msgid "Search users by email, name, or account." -msgstr "Søg brugere på e-mail, navn eller konto." - msgid "Search, filter, and review accounts." msgstr "Søg, filtrér og gennemse konti." @@ -767,9 +767,6 @@ 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 "Type to search" -msgstr "Skriv for at søge" - msgid "Unknown" msgstr "Ukendt" @@ -794,6 +791,9 @@ msgstr "Brugere" 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" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 93bcdd84b5..1a9af194e6 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -129,6 +129,9 @@ msgstr "All accounts this user is a member of, with their plan and role." msgid "All event types" msgstr "All event types" +msgid "All users across every account, newest first. Search and filter to narrow down." +msgstr "All users across every account, newest first. Search and filter to narrow down." + msgid "All-time" msgstr "All-time" @@ -509,6 +512,9 @@ 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 "Occurred" msgstr "Occurred" @@ -652,15 +658,9 @@ msgstr "Search by name" msgid "Search by name or email" msgstr "Search by name or email" -msgid "Search by user email, first or last name, or account name. At least 2 characters." -msgstr "Search by user email, first or last name, or account name. At least 2 characters." - msgid "Search users" msgstr "Search users" -msgid "Search users by email, name, or account." -msgstr "Search users by email, name, or account." - msgid "Search, filter, and review accounts." msgstr "Search, filter, and review accounts." @@ -767,9 +767,6 @@ 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 "Type to search" -msgstr "Type to search" - msgid "Unknown" msgstr "Unknown" @@ -794,6 +791,9 @@ msgstr "Users" 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" diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs index 1b5b2f71b7..9e8f9376bf 100644 --- a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs @@ -15,8 +15,8 @@ public sealed record GetBackOfficeUsersQuery( string? Search = null, UserRole[]? Roles = null, UserActivityFilter? Activity = null, - SortableBackOfficeUserProperties OrderBy = SortableBackOfficeUserProperties.Name, - SortOrder SortOrder = SortOrder.Ascending, + SortableBackOfficeUserProperties OrderBy = SortableBackOfficeUserProperties.CreatedAt, + SortOrder SortOrder = SortOrder.Descending, int PageOffset = 0, int PageSize = 25 ) : IRequest> @@ -73,9 +73,9 @@ public sealed class GetBackOfficeUsersQueryValidator : AbstractValidator x.Search).Must(s => !string.IsNullOrEmpty(s) && s.Length is >= 2 and <= 100).WithMessage("Search must be between 2 and 100 characters."); + // 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."); @@ -92,7 +92,7 @@ TimeProvider timeProvider public async Task> Handle(GetBackOfficeUsersQuery query, CancellationToken cancellationToken) { var (users, totalCount, totalPages) = await userRepository.SearchAllUsersUnfilteredAsync( - query.Search!, + query.Search ?? "", query.Roles, query.Activity, timeProvider.GetUtcNow(), diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 50fbf4da59..549d5f42a6 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -390,13 +390,14 @@ 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. + /// 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). The Users page is search-only by design, so the candidate set after the search predicate is small. + /// SQLite). /// public async Task<(User[] Users, int TotalItems, int TotalPages)> SearchAllUsersUnfilteredAsync( string search, @@ -410,21 +411,24 @@ CancellationToken cancellationToken CancellationToken cancellationToken ) { - // 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() - .Where(t => t.Name.ToLower().Contains(search)) - .Select(t => t.Id) - .ToArrayAsync(cancellationToken); + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]); - var users = DbSet - .IgnoreQueryFilters([QueryFilterNames.Tenant]) - .Where(u => + 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() + .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) { diff --git a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs index 50d1058137..1dfef2c575 100644 --- a/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs +++ b/application/account/Tests/Users/BackOffice/GetBackOfficeUsersTests.cs @@ -86,9 +86,13 @@ public async Task GetBackOfficeUsers_WhenSearchByTenantName_ShouldReturnUsersInT } [Fact] - public async Task GetBackOfficeUsers_WhenSearchIsMissing_ShouldReturnBadRequest() + public async Task GetBackOfficeUsers_WhenSearchIsMissing_ShouldReturnAllUsersNewestFirst() { // Arrange + var tenant = SeedTenant("Listing Inc"); + SeedUser(tenant, "first@listing.com", "First", "User", UserRole.Owner, true, createdAt: DateTimeOffset.UtcNow.AddDays(-3)); + SeedUser(tenant, "middle@listing.com", "Middle", "User", UserRole.Member, true, createdAt: DateTimeOffset.UtcNow.AddDays(-2)); + SeedUser(tenant, "last@listing.com", "Last", "User", UserRole.Member, true, createdAt: DateTimeOffset.UtcNow.AddDays(-1)); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -96,21 +100,12 @@ public async Task GetBackOfficeUsers_WhenSearchIsMissing_ShouldReturnBadRequest( var response = await client.GetAsync("/api/back-office/users"); // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); - } - - [Fact] - public async Task GetBackOfficeUsers_WhenSearchTooShort_ShouldReturnBadRequest() - { - // Arrange - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); - using var client = CreateBackOfficeClientForIdentity(identity); - - // Act - var response = await client.GetAsync("/api/back-office/users?search=a"); - - // Assert - response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.Users.Length.Should().BeGreaterThanOrEqualTo(3); + var seeded = payload.Users.Where(u => u.Email.EndsWith("@listing.com")).Select(u => u.Email).ToArray(); + seeded.Should().Equal("last@listing.com", "middle@listing.com", "first@listing.com"); } [Fact] @@ -322,12 +317,12 @@ private void SeedSubscriptionWithSucceededPayment(TenantId tenantId) ); } - private void SeedUser(TenantId tenantId, string email, string? firstName, string? lastName, UserRole role, bool emailConfirmed, DateTimeOffset? lastSeenAt = null) + private void SeedUser(TenantId tenantId, string email, string? firstName, string? lastName, UserRole role, bool emailConfirmed, DateTimeOffset? lastSeenAt = null, DateTimeOffset? createdAt = null) { Connection.Insert("users", [ ("tenant_id", tenantId.Value), ("id", UserId.NewId().ToString()), - ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("created_at", createdAt ?? DateTimeOffset.UtcNow.AddDays(-30)), ("modified_at", null), ("last_seen_at", lastSeenAt), ("email", email), diff --git a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts index b3b0962c56..baa2a18ff6 100644 --- a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts @@ -272,10 +272,10 @@ test.describe("@smoke", () => { // === USERS LIST === - await step("Navigate to users list & verify search prompt is shown before typing")(async () => { + await step("Navigate to users list & verify table renders with users by default")(async () => { await page.goto(`${BACK_OFFICE_BASE_URL}/users`); - await expect(page.getByText("Type to search")).toBeVisible(); + await expect(page.getByRole("table", { name: "Users" })).toBeVisible(); await expect(page.getByRole("searchbox", { name: "Search" })).toBeVisible(); })(); From db11a1426770c8a8833be5d785ec76d30ade97f9 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 01:44:18 +0200 Subject: [PATCH 037/158] Rewrite MRR trend using BillingEvent log and add data-quality banners --- .../Api/BackOffice/BillingDriftEndpoints.cs | 8 + .../account/BackOffice/routes/__root.tsx | 7 +- .../shared/components/BackOfficeBanners.tsx | 33 ++++ .../shared/components/BillingDriftBanner.tsx | 24 ++- .../shared/components/MrrMismatchBanner.tsx | 43 +++++ .../components/UnsyncedAccountsBanner.tsx | 40 +++++ .../shared/translations/locale/da-DK.po | 17 +- .../shared/translations/locale/en-US.po | 17 +- .../GetDashboardMrrConsistencySummary.cs | 32 ++++ .../GetUnsyncedSubscriptionsSummary.cs | 21 +++ .../Dashboard/Queries/GetDashboardKpis.cs | 10 +- .../Dashboard/Queries/GetDashboardMrrTrend.cs | 36 +++-- .../Domain/BillingEventRepository.cs | 16 ++ .../Domain/SubscriptionRepository.cs | 16 ++ .../Subscriptions/Shared/MrrCalculator.cs | 19 +++ .../Shared/ProcessPendingStripeEvents.cs | 2 +- .../GetDashboardMrrConsistencySummaryTests.cs | 152 ++++++++++++++++++ .../GetUnsyncedSubscriptionsSummaryTests.cs | 129 +++++++++++++++ .../Dashboard/GetDashboardMrrTrendTests.cs | 36 ++++- 19 files changed, 601 insertions(+), 57 deletions(-) create mode 100644 application/account/BackOffice/shared/components/BackOfficeBanners.tsx create mode 100644 application/account/BackOffice/shared/components/MrrMismatchBanner.tsx create mode 100644 application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx create mode 100644 application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs create mode 100644 application/account/Core/Features/BackOffice/BillingDrift/Queries/GetUnsyncedSubscriptionsSummary.cs create mode 100644 application/account/Core/Features/Subscriptions/Shared/MrrCalculator.cs create mode 100644 application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs create mode 100644 application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs diff --git a/application/account/Api/BackOffice/BillingDriftEndpoints.cs b/application/account/Api/BackOffice/BillingDriftEndpoints.cs index 8289f09469..ab0d17e907 100644 --- a/application/account/Api/BackOffice/BillingDriftEndpoints.cs +++ b/application/account/Api/BackOffice/BillingDriftEndpoints.cs @@ -25,5 +25,13 @@ public void MapEndpoints(IEndpointRouteBuilder routes) 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/BackOffice/routes/__root.tsx b/application/account/BackOffice/routes/__root.tsx index dc5181e711..edcd7cd954 100644 --- a/application/account/BackOffice/routes/__root.tsx +++ b/application/account/BackOffice/routes/__root.tsx @@ -2,11 +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 { BillingDriftBanner } from "@/shared/components/BillingDriftBanner"; +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"; @@ -26,8 +27,10 @@ function Root() { navigate(options)}> + + + - 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/BillingDriftBanner.tsx b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx index e530af1712..d6bf0924f7 100644 --- a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx +++ b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx @@ -1,5 +1,6 @@ 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"; @@ -8,8 +9,7 @@ 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 with a hidden ?driftOnly=true filter applied (the toolbar does not expose this - * filter directly; the banner is the single discovery surface). + * navigates to /accounts. */ export function BillingDriftBanner() { const userInfo = useUserInfo(); @@ -26,20 +26,14 @@ export function BillingDriftBanner() { } return ( -
    -
    - - - {count} accounts have billing drift detected. - -
    - +
    + + + {count} 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..7f8c948b97 --- /dev/null +++ b/application/account/BackOffice/shared/components/MrrMismatchBanner.tsx @@ -0,0 +1,43 @@ +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.kpiMonthlyRecurringRevenue === data.trendLatestMonthlyRecurringRevenue) { + return null; + } + + return ( +
    + + + + Dashboard MRR mismatch: KPI shows {formatCurrency(data.kpiMonthlyRecurringRevenue, data.currency)}, trend + latest shows {formatCurrency(data.trendLatestMonthlyRecurringRevenue, data.currency)}. + + + +
    + ); +} diff --git a/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx new file mode 100644 index 0000000000..e7f608748c --- /dev/null +++ b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx @@ -0,0 +1,40 @@ +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 ( +
    + + + {count} accounts have not been synced yet — MRR trend is incomplete. + + +
    + ); +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 946dc7d832..ba62869177 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -36,6 +36,9 @@ msgstr "{activeUsers} aktive" msgid "{count} accounts have billing drift detected." msgstr "{count} konti har faktureringsafvigelser." +msgid "{count} accounts have not been synced yet — MRR trend is incomplete." +msgstr "{count} 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}}" @@ -152,12 +155,6 @@ msgstr "Autoritativ log over abonnements-, betalings- og faktureringsændringer msgid "Back Office" msgstr "Back Office" -msgid "Back to accounts" -msgstr "Tilbage til konti" - -msgid "Back to users" -msgstr "Tilbage til brugere" - msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" @@ -253,6 +250,11 @@ msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, data.currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, data.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" @@ -809,6 +811,9 @@ 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 "vs prior period" msgstr "mod forrige periode" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 1a9af194e6..77650bbb40 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -36,6 +36,9 @@ msgstr "{activeUsers} active" msgid "{count} accounts have billing drift detected." msgstr "{count} accounts have billing drift detected." +msgid "{count} accounts have not been synced yet — MRR trend is incomplete." +msgstr "{count} 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}}" @@ -152,12 +155,6 @@ msgstr "Authoritative log of subscription, payment, and billing transitions acro msgid "Back Office" msgstr "Back Office" -msgid "Back to accounts" -msgstr "Back to accounts" - -msgid "Back to users" -msgstr "Back to users" - msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" @@ -253,6 +250,11 @@ msgstr "Dark" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, data.currency) +#. placeholder {1}: formatCurrency(data.trendLatestMonthlyRecurringRevenue, data.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" @@ -809,6 +811,9 @@ msgstr "View all {totalEvents} events" msgid "View all {totalTransactions} invoices" msgstr "View all {totalTransactions} invoices" +msgid "View billing events" +msgstr "View billing events" + msgid "vs prior period" msgstr "vs prior period" 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..1ed77b0f6f --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs @@ -0,0 +1,32 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +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) + : IRequestHandler> +{ + private const string DefaultCurrency = "DKK"; + + 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); + + return new DashboardMrrConsistencySummaryResponse(kpiMrr, trendLatestMrr, DefaultCurrency); + } +} 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/Dashboard/Queries/GetDashboardKpis.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs index 39a700bd63..9be6a9638b 100644 --- a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs @@ -1,5 +1,6 @@ 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 FluentValidation; @@ -93,14 +94,7 @@ public async Task> Handle(GetDashboardKp var activeUsersInPeriod = allUsers.LongCount(u => u.LastSeenAt >= periodStart); - // Forward MRR per subscription mirrors the per-account MrrAmount tile: 0 if cancelling at period end, - // the scheduled (downgraded) price if a downgrade is queued, otherwise the current price. - var totalMonthlyRecurringRevenue = paidSubscriptions - .Where(s => s.CurrentPriceAmount.HasValue) - .Sum(s => s.CancelAtPeriodEnd - ? 0m - : s.ScheduledPriceAmount ?? s.CurrentPriceAmount!.Value - ); + var totalMonthlyRecurringRevenue = paidSubscriptions.Sum(MrrCalculator.ForwardMrr); // Period-over-period MRR delta is approximated from the new-tenant signup ratio because the domain does // not store historical MRR snapshots. Operators get a directional signal without a daily snapshot table. diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs index 54f95859cb..443fb16ba1 100644 --- a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs @@ -28,7 +28,7 @@ public GetDashboardMrrTrendQueryValidator() } } -public sealed class GetDashboardMrrTrendHandler(ISubscriptionRepository subscriptionRepository, TimeProvider timeProvider) +public sealed class GetDashboardMrrTrendHandler(IBillingEventRepository billingEventRepository, TimeProvider timeProvider) : IRequestHandler> { private const string DefaultCurrency = "DKK"; @@ -41,7 +41,13 @@ public async Task> Handle(GetDashboa var startDate = today.AddDays(-(days - 1)); var priorStartDate = startDate.AddDays(-days); - var subscriptions = await subscriptionRepository.GetAllActiveUnfilteredAsync(cancellationToken); + // 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. + // Subscriptions backfilled via BackfillLegacyBillingEventsAsync are covered the same way. + var events = await billingEventRepository.GetMrrChangeEventsUnfilteredAsync(cancellationToken); + var eventsBySubscription = events + .GroupBy(e => e.SubscriptionId) + .ToDictionary(g => g.Key, g => g.OrderBy(e => e.OccurredAt).ToArray()); var points = new BackOfficeDashboardMrrTrendPoint[days]; var priorPoints = new BackOfficeDashboardMrrTrendPoint[days]; @@ -49,26 +55,24 @@ public async Task> Handle(GetDashboa { var currentDate = startDate.AddDays(index); var priorDate = priorStartDate.AddDays(index); - points[index] = new BackOfficeDashboardMrrTrendPoint(currentDate, ComputeDailyMrr(subscriptions, currentDate)); - priorPoints[index] = new BackOfficeDashboardMrrTrendPoint(priorDate, ComputeDailyMrr(subscriptions, priorDate)); + points[index] = new BackOfficeDashboardMrrTrendPoint(currentDate, ComputeDailyMrr(eventsBySubscription, currentDate)); + priorPoints[index] = new BackOfficeDashboardMrrTrendPoint(priorDate, ComputeDailyMrr(eventsBySubscription, priorDate)); } return new BackOfficeDashboardMrrTrendResponse(query.Period, DefaultCurrency, points, priorPoints); } - // A subscription contributes to MRR on a day if it was already subscribed (or backdated) at end-of-day, and has a - // known price. Cancellations are not stored as a separate timestamp, so the historical signal is approximated from - // SubscribedSince forward; the per-subscription contribution is forward MRR (0 when cancelling at period end, - // ScheduledPriceAmount when a downgrade is queued, otherwise CurrentPriceAmount), matching the KPI tile and the - // per-account MrrAmount tile. The scheduled state is treated as steady over the period. - private static decimal ComputeDailyMrr(Subscription[] subscriptions, DateOnly date) + private static decimal ComputeDailyMrr(Dictionary eventsBySubscription, DateOnly date) { var endOfDay = new DateTimeOffset(date.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); - return subscriptions - .Where(s => s is { CurrentPriceAmount: not null, SubscribedSince: { } subscribedSince } && subscribedSince < endOfDay) - .Sum(s => s.CancelAtPeriodEnd - ? 0m - : s.ScheduledPriceAmount ?? s.CurrentPriceAmount!.Value - ); + var total = 0m; + foreach (var subscriptionEvents in eventsBySubscription.Values) + { + // Events are sorted by OccurredAt asc — LastOrDefault picks the latest event up to end-of-day. + var latest = subscriptionEvents.LastOrDefault(e => e.OccurredAt < endOfDay); + if (latest?.NewAmount is { } amount) total += amount; + } + + return total; } } diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs index 4ff9099473..df426fe492 100644 --- a/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs @@ -30,6 +30,14 @@ public interface IBillingEventRepository : IAppendRepository Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken); + + /// + /// Returns every billing event with a populated NewAmount across all tenants — the events that + /// change committed MRR. Used by the dashboard MRR-trend computation to reconstruct historical + /// MRR per subscription. Bypasses the tenant query filter because the back-office is cross-tenant + /// by design. + /// + Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken); } public sealed class BillingEventRepository(AccountDbContext accountDbContext) @@ -51,6 +59,14 @@ public async Task GetRecentUnfilteredAsync(int limit, Cancellati return events.OrderByDescending(e => e.OccurredAt).Take(limit).ToArray(); } + public async Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.NewAmount != null) + .ToArrayAsync(cancellationToken); + } + public async Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken) { var queryable = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]); diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs index a3d00aaa42..5a1c5269b3 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs @@ -40,6 +40,14 @@ public interface ISubscriptionRepository : ICrudRepository 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) @@ -97,4 +105,12 @@ public async Task CountWithDriftDetectedUnfilteredAsync(CancellationToken c { return await DbSet.IgnoreQueryFilters().CountAsync(s => s.HasDriftDetected, cancellationToken); } + + public async Task CountWithoutBillingEventsUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters() + .Where(s => s.CurrentPriceAmount != null) + .Where(s => !accountDbContext.Set().IgnoreQueryFilters().Any(e => e.SubscriptionId == s.Id)) + .CountAsync(cancellationToken); + } } 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..b8fe33aa7c --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/MrrCalculator.cs @@ -0,0 +1,19 @@ +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. +/// +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/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 1b1ec3df46..9c6ca7d3b4 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -281,7 +281,7 @@ await AppendBillingEventAsync(BillingEvent.Create( await AppendBillingEventAsync(BillingEvent.Create( subscription.Id, subscription.TenantId, BillingEventType.SubscriptionCancelled, now, subscription.Id.Value, subscription.Plan, - previousAmount: subscription.CurrentPriceAmount, amountDelta: -subscription.CurrentPriceAmount, + previousAmount: subscription.CurrentPriceAmount, newAmount: 0m, amountDelta: -subscription.CurrentPriceAmount, currency: subscription.CurrentPriceCurrency, daysOnPreviousPlan: daysOnCurrentPlan, daysUntilEffective: daysUntilExpiry, effectiveAt: subscription.CurrentPeriodEnd, diff --git a/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs new file mode 100644 index 0000000000..625de9e85f --- /dev/null +++ b/application/account/Tests/BackOffice/BillingDrift/GetDashboardMrrConsistencySummaryTests.cs @@ -0,0 +1,152 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Account.Features.BackOffice.BillingDrift.Queries; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.BackOffice.BillingDrift; + +public sealed class GetDashboardMrrConsistencySummaryTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetDashboardMrrConsistencySummary_WhenSubscriptionsAndEventsAgree_ShouldReturnEqualValues() + { + // Arrange — one paid subscription with a matching SubscriptionCreated billing event. + var tenantId = SeedTenant("Healthy Co"); + var subscriptionId = SubscriptionId.NewId(); + SeedPaidSubscription(tenantId, subscriptionId, 149m, false, null); + SeedSubscriptionCreatedEvent(tenantId, subscriptionId, 149m); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/billing-drift/mrr-consistency-summary"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.KpiMonthlyRecurringRevenue.Should().Be(149m); + payload.TrendLatestMonthlyRecurringRevenue.Should().Be(149m); + } + + [Fact] + public async Task GetDashboardMrrConsistencySummary_WhenSubscriptionCancelledButNoCancellationEvent_ShouldReturnDifferingValues() + { + // Arrange — paid subscription cancelled at period end (KPI forward MRR contribution = 0) + // but the only billing event is SubscriptionCreated with NewAmount = 149 (trend latest = 149). + // The endpoint exists to flag exactly this divergence. + var tenantId = SeedTenant("Drifted Co"); + var subscriptionId = SubscriptionId.NewId(); + SeedPaidSubscription(tenantId, subscriptionId, 149m, true, null); + SeedSubscriptionCreatedEvent(tenantId, subscriptionId, 149m); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/billing-drift/mrr-consistency-summary"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.KpiMonthlyRecurringRevenue.Should().Be(0m); + payload.TrendLatestMonthlyRecurringRevenue.Should().Be(149m); + } + + [Fact] + public async Task GetDashboardMrrConsistencySummary_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() + { + // Arrange + using var client = CreateBackOfficeClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await client.GetAsync("/api/back-office/billing-drift/mrr-consistency-summary"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + private TenantId SeedTenant(string name) + { + var tenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("name", name), + ("state", nameof(TenantState.Active)), + ("plan", nameof(SubscriptionPlan.Premium)), + ("logo", """{"Url":null,"Version":0}""") + ] + ); + return tenantId; + } + + private void SeedPaidSubscription(TenantId tenantId, SubscriptionId subscriptionId, decimal currentPriceAmount, bool cancelAtPeriodEnd, decimal? scheduledPriceAmount) + { + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", subscriptionId.ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("plan", nameof(SubscriptionPlan.Premium)), + ("scheduled_plan", null), + ("stripe_customer_id", "cus_test"), + ("stripe_subscription_id", "sub_test"), + ("current_price_amount", currentPriceAmount), + ("current_price_currency", "DKK"), + ("current_period_end", DateTimeOffset.UtcNow.AddDays(30)), + ("cancel_at_period_end", cancelAtPeriodEnd), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", "[]"), + ("payment_method", null), + ("billing_info", null), + ("scheduled_price_amount", (object?)scheduledPriceAmount ?? DBNull.Value), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") + ] + ); + } + + private void SeedSubscriptionCreatedEvent(TenantId tenantId, SubscriptionId subscriptionId, decimal newAmount) + { + var occurredAt = DateTimeOffset.UtcNow.AddDays(-30); + Connection.Insert("billing_events", [ + ("tenant_id", tenantId.Value), + ("id", BillingEventId.FromComponents(subscriptionId, BillingEventType.SubscriptionCreated, "evt_test", occurredAt).ToString()), + ("subscription_id", subscriptionId.ToString()), + ("created_at", occurredAt), + ("modified_at", null), + ("event_type", nameof(BillingEventType.SubscriptionCreated)), + ("from_plan", null), + ("to_plan", nameof(SubscriptionPlan.Premium)), + ("previous_amount", 0m), + ("new_amount", newAmount), + ("amount_delta", newAmount), + ("currency", "DKK"), + ("days_on_previous_plan", null), + ("days_until_effective", null), + ("days_since_cancelled", null), + ("scheduled_for", null), + ("effective_at", null), + ("occurred_at", occurredAt), + ("cancellation_reason", null), + ("suspension_reason", null), + ("stripe_reference", "evt_test") + ] + ); + } +} diff --git a/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs new file mode 100644 index 0000000000..ab84445638 --- /dev/null +++ b/application/account/Tests/BackOffice/BillingDrift/GetUnsyncedSubscriptionsSummaryTests.cs @@ -0,0 +1,129 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Account.Features.BackOffice.BillingDrift.Queries; +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentAssertions; +using SharedKernel.Authentication.MockEasyAuth; +using SharedKernel.Domain; +using SharedKernel.Tests.Persistence; +using Xunit; + +namespace Account.Tests.BackOffice.BillingDrift; + +public sealed class GetUnsyncedSubscriptionsSummaryTests : BackOfficeEndpointBaseTest +{ + [Fact] + public async Task GetUnsyncedSubscriptionsSummary_WhenCalled_ShouldReturnPaidSubscriptionsWithoutBillingEvents() + { + // Arrange — two paid subscriptions, only one has a billing event. + var syncedTenant = SeedTenant("Synced Co"); + var syncedSubscriptionId = SubscriptionId.NewId(); + SeedPaidSubscription(syncedTenant, syncedSubscriptionId, 149m); + SeedSubscriptionCreatedEvent(syncedTenant, syncedSubscriptionId, 149m); + + var unsyncedTenant = SeedTenant("Unsynced Co"); + SeedPaidSubscription(unsyncedTenant, SubscriptionId.NewId(), 299m); + + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + using var client = CreateBackOfficeClientForIdentity(identity); + + // Act + var response = await client.GetAsync("/api/back-office/billing-drift/unsynced-summary"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + var payload = await response.Content.ReadFromJsonAsync(); + payload.Should().NotBeNull(); + payload.UnsyncedSubscriptionsCount.Should().Be(1); + } + + [Fact] + public async Task GetUnsyncedSubscriptionsSummary_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() + { + // Arrange + using var client = CreateBackOfficeClient(); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + // Act + var response = await client.GetAsync("/api/back-office/billing-drift/unsynced-summary"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Unauthorized); + } + + private TenantId SeedTenant(string name) + { + var tenantId = TenantId.NewId(); + Connection.Insert("tenants", [ + ("id", tenantId.Value), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("name", name), + ("state", nameof(TenantState.Active)), + ("plan", nameof(SubscriptionPlan.Premium)), + ("logo", """{"Url":null,"Version":0}""") + ] + ); + return tenantId; + } + + private void SeedPaidSubscription(TenantId tenantId, SubscriptionId subscriptionId, decimal currentPriceAmount) + { + Connection.Insert("subscriptions", [ + ("tenant_id", tenantId.Value), + ("id", subscriptionId.ToString()), + ("created_at", DateTimeOffset.UtcNow.AddDays(-30)), + ("modified_at", null), + ("plan", nameof(SubscriptionPlan.Premium)), + ("scheduled_plan", null), + ("stripe_customer_id", "cus_test"), + ("stripe_subscription_id", "sub_test"), + ("current_price_amount", currentPriceAmount), + ("current_price_currency", "DKK"), + ("current_period_end", DateTimeOffset.UtcNow.AddDays(30)), + ("cancel_at_period_end", false), + ("first_payment_failed_at", null), + ("cancellation_reason", null), + ("cancellation_feedback", null), + ("payment_transactions", "[]"), + ("payment_method", null), + ("billing_info", null), + ("scheduled_price_amount", null), + ("has_drift_detected", false), + ("drift_checked_at", null), + ("drift_discrepancies", "[]") + ] + ); + } + + private void SeedSubscriptionCreatedEvent(TenantId tenantId, SubscriptionId subscriptionId, decimal newAmount) + { + var occurredAt = DateTimeOffset.UtcNow.AddDays(-30); + Connection.Insert("billing_events", [ + ("tenant_id", tenantId.Value), + ("id", BillingEventId.FromComponents(subscriptionId, BillingEventType.SubscriptionCreated, "evt_test", occurredAt).ToString()), + ("subscription_id", subscriptionId.ToString()), + ("created_at", occurredAt), + ("modified_at", null), + ("event_type", nameof(BillingEventType.SubscriptionCreated)), + ("from_plan", null), + ("to_plan", nameof(SubscriptionPlan.Premium)), + ("previous_amount", 0m), + ("new_amount", newAmount), + ("amount_delta", newAmount), + ("currency", "DKK"), + ("days_on_previous_plan", null), + ("days_until_effective", null), + ("days_since_cancelled", null), + ("scheduled_for", null), + ("effective_at", null), + ("occurred_at", occurredAt), + ("cancellation_reason", null), + ("suspension_reason", null), + ("stripe_reference", "evt_test") + ] + ); + } +} diff --git a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs index 98e7c88a2f..0ebcef4444 100644 --- a/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs +++ b/application/account/Tests/BackOffice/Dashboard/GetDashboardMrrTrendTests.cs @@ -20,7 +20,9 @@ public async Task GetDashboardMrrTrend_WhenCalled_ShouldReturnDailyMrrSeriesForP // Arrange — one paid subscription that has been running for 5 days, contributing 49.99 to MRR each day since. var now = DateTimeOffset.UtcNow; var paidTenant = SeedTenant("Paying Co"); - SeedActiveSubscription(paidTenant, 49.99m, now.AddDays(-5)); + var subscriptionId = SubscriptionId.NewId(); + SeedActiveSubscription(paidTenant, subscriptionId, 49.99m, now.AddDays(-5)); + SeedSubscriptionCreatedEvent(paidTenant, subscriptionId, 49.99m, now.AddDays(-5)); var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); using var client = CreateBackOfficeClientForIdentity(identity); @@ -87,7 +89,7 @@ private TenantId SeedTenant(string name) return tenantId; } - private void SeedActiveSubscription(TenantId tenantId, decimal currentPriceAmount, DateTimeOffset subscribedSince) + private void SeedActiveSubscription(TenantId tenantId, SubscriptionId subscriptionId, decimal currentPriceAmount, DateTimeOffset subscribedSince) { var paymentTransactions = JsonSerializer.Serialize(new[] { @@ -97,7 +99,7 @@ private void SeedActiveSubscription(TenantId tenantId, decimal currentPriceAmoun Connection.Insert("subscriptions", [ ("tenant_id", tenantId.Value), - ("id", SubscriptionId.NewId().ToString()), + ("id", subscriptionId.ToString()), ("created_at", subscribedSince), ("modified_at", null), ("plan", nameof(SubscriptionPlan.Standard)), @@ -122,4 +124,32 @@ private void SeedActiveSubscription(TenantId tenantId, decimal currentPriceAmoun ] ); } + + private void SeedSubscriptionCreatedEvent(TenantId tenantId, SubscriptionId subscriptionId, decimal newAmount, DateTimeOffset occurredAt) + { + Connection.Insert("billing_events", [ + ("tenant_id", tenantId.Value), + ("id", BillingEventId.FromComponents(subscriptionId, BillingEventType.SubscriptionCreated, "evt_test", occurredAt).ToString()), + ("subscription_id", subscriptionId.ToString()), + ("created_at", occurredAt), + ("modified_at", null), + ("event_type", nameof(BillingEventType.SubscriptionCreated)), + ("from_plan", null), + ("to_plan", nameof(SubscriptionPlan.Standard)), + ("previous_amount", 0m), + ("new_amount", newAmount), + ("amount_delta", newAmount), + ("currency", "DKK"), + ("days_on_previous_plan", null), + ("days_until_effective", null), + ("days_since_cancelled", null), + ("scheduled_for", null), + ("effective_at", null), + ("occurred_at", occurredAt), + ("cancellation_reason", null), + ("suspension_reason", null), + ("stripe_reference", "evt_test") + ] + ); + } } From 06a679f06ee78209b7b6055f4aef4a5cc8f48c06 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 01:58:47 +0200 Subject: [PATCH 038/158] Move Sync with Stripe into a kebab menu and align detail header avatars --- ...tripeButton.tsx => AccountActionsMenu.tsx} | 52 +++++--- .../-components/AccountDetailHeader.tsx | 94 ++++++-------- .../users/-components/UserDetailHeader.tsx | 118 ++++++++---------- .../shared/translations/locale/da-DK.po | 3 + .../shared/translations/locale/en-US.po | 3 + 5 files changed, 132 insertions(+), 138 deletions(-) rename application/account/BackOffice/routes/accounts/-components/{SyncWithStripeButton.tsx => AccountActionsMenu.tsx} (66%) diff --git a/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx similarity index 66% rename from application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx rename to application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx index 2d4630cd2a..1f254e3bf6 100644 --- a/application/account/BackOffice/routes/accounts/-components/SyncWithStripeButton.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx @@ -11,13 +11,20 @@ import { 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 { useFormatDate } from "@repo/ui/hooks/useSmartDate"; -import { AlertTriangleIcon, CheckCircle2Icon, RefreshCwIcon } from "lucide-react"; +import { AlertTriangleIcon, CheckCircle2Icon, MoreVerticalIcon, RefreshCwIcon } from "lucide-react"; import { useState } from "react"; import { api } from "@/shared/lib/api/client"; -interface SyncWithStripeButtonProps { +interface AccountActionsMenuProps { tenantId: string; } @@ -28,7 +35,7 @@ interface SyncResult { syncedAt: string; } -export function SyncWithStripeButton({ tenantId }: Readonly) { +export function AccountActionsMenu({ tenantId }: Readonly) { const formatDate = useFormatDate(); const [result, setResult] = useState(null); const [isResultOpen, setIsResultOpen] = useState(false); @@ -40,22 +47,39 @@ export function SyncWithStripeButton({ tenantId }: Readonly { + const handleSync = () => { syncMutation.mutate({ params: { path: { id: tenantId } } }); }; return ( <> - + + + + + + } + /> + } + /> + {t`Account actions`} + + + + + {syncMutation.isPending ? Syncing... : Sync with Stripe} + + + diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index d1e3c8dde7..99431892b7 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -1,14 +1,11 @@ -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 { 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 { Link } from "@tanstack/react-router"; -import { ArrowLeftIcon, CalendarIcon } from "lucide-react"; +import { CalendarIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -16,7 +13,7 @@ import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client" import { getSubscriptionPlanLabel, getTenantStateLabel } from "@/shared/lib/api/labels"; import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; -import { SyncWithStripeButton } from "./SyncWithStripeButton"; +import { AccountActionsMenu } from "./AccountActionsMenu"; import { TenantStatusBadge } from "./TenantStatusBadge"; type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; @@ -32,61 +29,46 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly -
    - - -
    - -
    - -
    - {isLoading || !tenant ? ( - <> - - - - ) : ( - <> -
    -

    {tenant.name}

    -
    - - {getSubscriptionPlanLabel(tenant.plan)} - - {tenant.state !== TenantState.Active && } - -
    +
    + +
    + {isLoading || !tenant ? ( + <> + + + + ) : ( + <> +
    +

    {tenant.name}

    +
    + + {getSubscriptionPlanLabel(tenant.plan)} + + {tenant.state !== TenantState.Active && } +
    -
    - {tenant.billingAddress?.country && ( - - {getCountryFlagEmoji(tenant.billingAddress.country)} - {getCountryName(tenant.billingAddress.country, i18n.locale)} - - )} +
    +
    + {tenant.billingAddress?.country && ( - - Created {formatDate(tenant.createdAt)} + {getCountryFlagEmoji(tenant.billingAddress.country)} + {getCountryName(tenant.billingAddress.country, i18n.locale)} -
    - - )} -
    + )} + + + Created {formatDate(tenant.createdAt)} + +
    + + )}
    +
    ); } diff --git a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx index 93ea51bd1f..0ff874c8b1 100644 --- a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -1,12 +1,9 @@ -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 { Button } from "@repo/ui/components/Button"; import { Skeleton } from "@repo/ui/components/Skeleton"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; -import { Link } from "@tanstack/react-router"; -import { ArrowLeftIcon, CalendarIcon, CheckCircle2Icon, MailIcon, XCircleIcon } from "lucide-react"; +import { CalendarIcon, CheckCircle2Icon, MailIcon, XCircleIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -23,72 +20,57 @@ export function UserDetailHeader({ user, isLoading }: Readonly -
    - -
    - -
    - {isLoading || !user ? ( - <> - -
    - - -
    - - ) : ( - <> - - {user.avatarUrl && ( - +
    + {isLoading || !user ? ( + <> + +
    + + +
    + + ) : ( + <> + + {user.avatarUrl && ( + + )} + {getUserInitials(user.firstName, user.lastName, user.email)} + +
    +
    +

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

    + {user.emailConfirmed ? ( + + + + Email confirmed + + + ) : ( + + + + Email pending + + )} - {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)} - -
    - - )} -
    +
    + + + {user.email} + + + + Created {formatDate(user.createdAt)} + +
    +
    + + )}
    ); } diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index ba62869177..0cf4243c33 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -91,6 +91,9 @@ msgstr "90d" msgid "Account" msgstr "Konto" +msgid "Account actions" +msgstr "Kontohandlinger" + msgid "Account growth" msgstr "Kontovækst" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 77650bbb40..5eab5120b2 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -91,6 +91,9 @@ msgstr "90d" msgid "Account" msgstr "Account" +msgid "Account actions" +msgstr "Account actions" + msgid "Account growth" msgstr "Account growth" From 4170f87b8344d6a0ed2fdd60a4d6252de1d0db86 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 02:36:27 +0200 Subject: [PATCH 039/158] Polish detail headers with responsive layout and align Current plan height --- .../BackOffice/routes/accounts/$tenantId.tsx | 6 +++--- .../-components/AccountCurrentPlanCard.tsx | 8 ++++---- .../-components/AccountDetailHeader.tsx | 19 +++++++++++++++---- .../users/-components/UserDetailHeader.tsx | 11 ++++++++--- .../shared/translations/locale/da-DK.po | 10 ++++++---- .../shared/translations/locale/en-US.po | 10 ++++++---- 6 files changed, 42 insertions(+), 22 deletions(-) diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx index 71c350461d..c71ab28bb7 100644 --- a/application/account/BackOffice/routes/accounts/$tenantId.tsx +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -82,11 +82,11 @@ function AccountDetailPage() { -
    -
    +
    +
    -
    +
    ) { return ( -
    +

    Current plan

    @@ -67,7 +67,7 @@ function CurrentPlanShell({ children }: Readonly<{ children: ReactNode }>) { function CurrentPlanEmpty({ title, description }: Readonly<{ title: ReactNode; description: ReactNode }>) { return ( - + {title} {description} @@ -78,7 +78,7 @@ function CurrentPlanEmpty({ title, description }: Readonly<{ title: ReactNode; d function renderSkeleton() { return ( - + @@ -118,7 +118,7 @@ function CurrentPlanDetails({ tenant }: Readonly<{ tenant: TenantDetailResponse : []; return ( - +
    {showStrikedAmount ? ( diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index 99431892b7..6f2861e443 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -39,9 +39,9 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly ) : ( <> -
    +

    {tenant.name}

    -
    +
    {getSubscriptionPlanLabel(tenant.plan)} @@ -54,15 +54,26 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly
    +
    + {tenant.state !== TenantState.Active && } + +
    {tenant.billingAddress?.country && ( {getCountryFlagEmoji(tenant.billingAddress.country)} - {getCountryName(tenant.billingAddress.country, i18n.locale)} + {getCountryName(tenant.billingAddress.country, i18n.locale)} )} - Created {formatDate(tenant.createdAt)} + + Created {formatDate(tenant.createdAt, false, false, true)} + {formatDate(tenant.createdAt)} +
    diff --git a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx index 0ff874c8b1..2c2939768a 100644 --- a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -31,11 +31,13 @@ export function UserDetailHeader({ user, isLoading }: Readonly ) : ( <> - + {user.avatarUrl && ( )} - {getUserInitials(user.firstName, user.lastName, user.email)} + + {getUserInitials(user.firstName, user.lastName, user.email)} +
    @@ -65,7 +67,10 @@ export function UserDetailHeader({ user, isLoading }: Readonly - Created {formatDate(user.createdAt)} + + Created {formatDate(user.createdAt, false, false, true)} + {formatDate(user.createdAt)} +
    diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 0cf4243c33..4fd465a3fe 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -233,10 +233,12 @@ msgstr "Land" msgid "Created" msgstr "Oprettet" -#. placeholder {0}: formatDate(tenant.createdAt) -#. placeholder {0}: formatDate(user.createdAt) -msgid "Created {0}" -msgstr "Oprettet {0}" +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Oprettet <0>{0}<1>{1}" msgid "Credit note" msgstr "Kreditnota" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 5eab5120b2..aedf73de63 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -233,10 +233,12 @@ msgstr "Country" msgid "Created" msgstr "Created" -#. placeholder {0}: formatDate(tenant.createdAt) -#. placeholder {0}: formatDate(user.createdAt) -msgid "Created {0}" -msgstr "Created {0}" +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Created <0>{0}<1>{1}" msgid "Credit note" msgstr "Credit note" From 4010f9ced064ac7bad47d20f30e45d66adc6eb2f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 02:47:42 +0200 Subject: [PATCH 040/158] Restrict Sync with Stripe to back-office admins --- .../Api/BackOffice/TenantsEndpoints.cs | 2 +- .../-components/AccountActionsMenu.tsx | 8 +++++++ .../BackOffice/SyncTenantWithStripeTests.cs | 22 +++++++++++++++---- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs index 07b6febb7f..75edb990bc 100644 --- a/application/account/Api/BackOffice/TenantsEndpoints.cs +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -50,7 +50,7 @@ public void MapEndpoints(IEndpointRouteBuilder routes) group.MapPost("/{id}/sync-with-stripe", async Task> (TenantId id, IMediator mediator) => await mediator.Send(new SyncTenantWithStripeCommand { TenantId = id }) - ).Produces(); + ).Produces().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); group.MapPost("/{id}/drift/acknowledge", async Task (TenantId id, IMediator mediator) => await mediator.Send(new AcknowledgeBillingDriftCommand { TenantId = id }) diff --git a/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx index 1f254e3bf6..3735a0cfb1 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx @@ -22,6 +22,7 @@ import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { AlertTriangleIcon, CheckCircle2Icon, MoreVerticalIcon, RefreshCwIcon } from "lucide-react"; import { useState } from "react"; +import { useMe } from "@/shared/hooks/useMe"; import { api } from "@/shared/lib/api/client"; interface AccountActionsMenuProps { @@ -37,6 +38,7 @@ interface SyncResult { export function AccountActionsMenu({ tenantId }: Readonly) { const formatDate = useFormatDate(); + const { data: me } = useMe(); const [result, setResult] = useState(null); const [isResultOpen, setIsResultOpen] = useState(false); @@ -51,6 +53,12 @@ export function AccountActionsMenu({ tenantId }: Readonly diff --git a/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs index 34f12327bf..bd35428da2 100644 --- a/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs +++ b/application/account/Tests/BackOffice/SyncTenantWithStripeTests.cs @@ -21,7 +21,7 @@ public async Task SyncTenantWithStripe_WhenSubscriptionHasStripeCustomer_ShouldR ("stripe_customer_id", MockStripeClient.MockCustomerId) ] ); - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); using var client = CreateBackOfficeClientForIdentity(identity); client.DefaultRequestHeaders.Add("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); @@ -43,7 +43,7 @@ public async Task SyncTenantWithStripe_WhenSubscriptionHasStripeCustomer_ShouldR public async Task SyncTenantWithStripe_WhenSubscriptionHasNoStripeCustomer_ShouldReturnBadRequest() { // Arrange - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); using var client = CreateBackOfficeClientForIdentity(identity); // Act @@ -57,7 +57,7 @@ public async Task SyncTenantWithStripe_WhenSubscriptionHasNoStripeCustomer_Shoul public async Task SyncTenantWithStripe_WhenTenantDoesNotExist_ShouldReturnNotFound() { // Arrange - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); using var client = CreateBackOfficeClientForIdentity(identity); client.DefaultRequestHeaders.Add("Cookie", $"{OAuthProviderFactory.UseMockProviderCookieName}=true"); var unknownTenantId = TenantId.NewId(); @@ -77,7 +77,7 @@ public async Task SyncTenantWithStripe_WhenStripeNotConfigured_ShouldReturnBadRe ("stripe_customer_id", MockStripeClient.MockCustomerId) ] ); - var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "user"); + var identity = MockEasyAuthIdentities.Default.Single(i => i.Id == "admin"); using var client = CreateBackOfficeClientForIdentity(identity); // Act @@ -87,6 +87,20 @@ public async Task SyncTenantWithStripe_WhenStripeNotConfigured_ShouldReturnBadRe response.StatusCode.Should().Be(HttpStatusCode.BadRequest); } + [Fact] + public async Task SyncTenantWithStripe_WhenCalledByNonAdmin_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}/sync-with-stripe", null); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.Forbidden); + } + [Fact] public async Task SyncTenantWithStripe_WhenCalledWithoutAuthentication_ShouldReturnUnauthorized() { From 694f965dfa6d02fd4089f41ef4010045f7e9f7b7 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 03:12:01 +0200 Subject: [PATCH 041/158] Add kiosk mode toggle to back-office dashboard --- .../routes/-components/DashboardHeader.tsx | 78 ++++++++++++++----- .../shared/translations/locale/da-DK.po | 6 ++ .../shared/translations/locale/en-US.po | 6 ++ .../SinglePageAppConfiguration.cs | 2 +- application/shared-webapp/ui/tailwind.css | 12 +++ 5 files changed, 82 insertions(+), 22 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardHeader.tsx b/application/account/BackOffice/routes/-components/DashboardHeader.tsx index ea7b90d591..62d3fce1a1 100644 --- a/application/account/BackOffice/routes/-components/DashboardHeader.tsx +++ b/application/account/BackOffice/routes/-components/DashboardHeader.tsx @@ -1,7 +1,11 @@ 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"; @@ -15,6 +19,26 @@ export function DashboardHeader({ period, onPeriodChange }: Readonly { + 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 (
    @@ -25,27 +49,39 @@ export function DashboardHeader({ period, onPeriodChange }: ReadonlyBackOffice overview · {today}

    - { - const next = values[0]; - if (next) { - onPeriodChange(next as DashboardTrendPeriod); - } - }} - > - - 7d - - - 30d - - - 90d - - +
    + { + const next = values[0]; + if (next) { + onPeriodChange(next as DashboardTrendPeriod); + } + }} + > + + 7d + + + 30d + + + 90d + + + + + {isFullscreen ? : } + + } + /> + {fullscreenLabel} + +
    ); } diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 4fd465a3fe..88cc672ddb 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -297,6 +297,9 @@ msgstr "E-mail bekræftet" msgid "Email pending" msgstr "E-mail afventer" +msgid "Enter kiosk mode" +msgstr "Aktivér kiosktilstand" + msgid "Event" msgstr "Hændelse" @@ -306,6 +309,9 @@ msgstr "Alle fakturaer, refusioner og kreditnotaer — pengene ind og ud for det 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" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index aedf73de63..1931e654de 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -297,6 +297,9 @@ msgstr "Email confirmed" msgid "Email pending" msgstr "Email pending" +msgid "Enter kiosk mode" +msgstr "Enter kiosk mode" + msgid "Event" msgstr "Event" @@ -306,6 +309,9 @@ msgstr "Every invoice, refund, and credit note — the money in and out for this 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" diff --git a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs index dcdf87f3a3..782da137c9 100644 --- a/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs +++ b/application/shared-kernel/SharedKernel/SinglePageApp/SinglePageAppConfiguration.cs @@ -165,7 +165,7 @@ private static StringValues GetPermissionsPolicies() { "camera", [] }, { "picture-in-picture", [] }, { "display-capture", [] }, - { "fullscreen", [] }, + { "fullscreen", ["self"] }, { "web-share", [] }, { "identity-credentials-get", [] } }; diff --git a/application/shared-webapp/ui/tailwind.css b/application/shared-webapp/ui/tailwind.css index d8cb97cf4b..164fea1f20 100644 --- a/application/shared-webapp/ui/tailwind.css +++ b/application/shared-webapp/ui/tailwind.css @@ -313,6 +313,18 @@ body { } } +/* Kiosk mode: when the document enters browser fullscreen (via Fullscreen API), hide the + sidebar and any banners so only the page content fills the screen. Triggered by the + back-office Dashboard maximize button. */ +:fullscreen #root { + padding-top: 0; +} + +:fullscreen [data-slot="sidebar"], +:fullscreen #banner-root { + display: none !important; +} + /* Toast styling - solid colors, larger padding for visibility */ /* Diverges from stock ShadCN: scaled sizing using rem for responsive font sizes */ [data-sonner-toast] { From 158f0ff2f699502c5ae797f0d1a90e7e16f19711 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 03:15:23 +0200 Subject: [PATCH 042/158] Hide mobile floating sidebar trigger in kiosk mode and center dashboard header --- .../BackOffice/routes/-components/DashboardHeader.tsx | 2 +- application/shared-webapp/ui/components/Sidebar.tsx | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardHeader.tsx b/application/account/BackOffice/routes/-components/DashboardHeader.tsx index 62d3fce1a1..bec82e11f8 100644 --- a/application/account/BackOffice/routes/-components/DashboardHeader.tsx +++ b/application/account/BackOffice/routes/-components/DashboardHeader.tsx @@ -40,7 +40,7 @@ export function DashboardHeader({ period, onPeriodChange }: Readonly +

    Dashboard diff --git a/application/shared-webapp/ui/components/Sidebar.tsx b/application/shared-webapp/ui/components/Sidebar.tsx index da82c63cff..589c21781f 100644 --- a/application/shared-webapp/ui/components/Sidebar.tsx +++ b/application/shared-webapp/ui/components/Sidebar.tsx @@ -299,7 +299,10 @@ function Sidebar({ return ( <> {!openMobile && ( -
    +

    + + + + Account + + + Plan + + + Country + + + Created + + + + + {signups.map((signup) => ( + + +
    + + {signup.name} +
    +
    + + + {getSubscriptionPlanLabel(signup.plan)} + + + + {signup.country ? ( + + + {getCountryName(signup.country, i18n.locale)} + + ) : ( + + )} + + + + +
    + ))} +
    +
    )} ); diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index be11b00ac8..c567dd4f0e 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -1,11 +1,16 @@ +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 } from "@tanstack/react-router"; +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"; @@ -15,15 +20,24 @@ 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: "invoices" } }); + }, + [navigate] + ); + return ( Recent Stripe events} + title={Recent billing events} action={ ); diff --git a/application/account/BackOffice/shared/lib/billingEventStyle.ts b/application/account/BackOffice/shared/lib/billingEventStyle.ts index f15bf2d576..f0673dba96 100644 --- a/application/account/BackOffice/shared/lib/billingEventStyle.ts +++ b/application/account/BackOffice/shared/lib/billingEventStyle.ts @@ -21,7 +21,7 @@ export interface BillingEventVariant { } /** - * Centralised badge styling for the BillingEventType enum. Used by the dashboard "Recent Stripe events" + * 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. */ diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 88cc672ddb..fa8a79c51c 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -498,12 +498,12 @@ msgstr "Intet betalt abonnement endnu." msgid "No plan" msgstr "Intet abonnement" +msgid "No recent billing events" +msgstr "Ingen nylige faktureringshændelser" + msgid "No recent signups" msgstr "Ingen nylige tilmeldinger" -msgid "No recent Stripe events" -msgstr "Ingen nylige Stripe-hændelser" - msgid "No result available." msgstr "Intet resultat tilgængeligt." @@ -621,12 +621,12 @@ msgstr "Forrige periode" msgid "Reactivated" msgstr "Genaktiveret" +msgid "Recent billing events" +msgstr "Nylige faktureringshændelser" + msgid "Recent signups" msgstr "Nylige tilmeldinger" -msgid "Recent Stripe events" -msgstr "Nylige Stripe-hændelser" - msgid "Refunded" msgstr "Refunderet" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 1931e654de..e40cd3434a 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -498,12 +498,12 @@ msgstr "No paid plan yet." msgid "No plan" msgstr "No plan" +msgid "No recent billing events" +msgstr "No recent billing events" + msgid "No recent signups" msgstr "No recent signups" -msgid "No recent Stripe events" -msgstr "No recent Stripe events" - msgid "No result available." msgstr "No result available." @@ -621,12 +621,12 @@ msgstr "Prior period" msgid "Reactivated" msgstr "Reactivated" +msgid "Recent billing events" +msgstr "Recent billing events" + msgid "Recent signups" msgstr "Recent signups" -msgid "Recent Stripe events" -msgstr "Recent Stripe events" - msgid "Refunded" msgstr "Refunded" diff --git a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts index baa2a18ff6..f39d2b5c95 100644 --- a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts @@ -252,7 +252,7 @@ test.describe("@smoke", () => { await expect(page.getByText("Account growth", { exact: true })).toBeVisible(); await expect(page.getByText("User logins / day", { exact: true })).toBeVisible(); await expect(page.getByText("Recent signups", { exact: true })).toBeVisible(); - await expect(page.getByText("Recent Stripe events", { exact: true })).toBeVisible(); + await expect(page.getByText("Recent billing events", { exact: true })).toBeVisible(); const periodGroup = page.getByRole("group", { name: "Period" }); await expect(periodGroup.getByRole("button", { name: "7d" })).toBeVisible(); diff --git a/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts index 52aa0ea272..e500299894 100644 --- a/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts @@ -98,13 +98,13 @@ test.describe("@smoke", () => { // === DASHBOARD `VIEW ALL` === - await step("Navigate back to dashboard & verify Recent Stripe events card is present")(async () => { + await step("Navigate back to dashboard & verify Recent billing events card is present")(async () => { await page.goto(`${BACK_OFFICE_BASE_URL}/`); - await expect(page.getByText("Recent Stripe events", { exact: true })).toBeVisible(); + await expect(page.getByText("Recent billing events", { exact: true })).toBeVisible(); })(); - await step("Click Recent Stripe events View all link & verify lands on /billing-events")(async () => { + await step("Click Recent billing events View all link & verify lands on /billing-events")(async () => { // Target the specific View all link by its href so we are not coupled to surrounding card markup // (the dashboard has multiple "View all" links: one for Accounts and one for Billing events). await page.locator("a[href='/billing-events']", { hasText: "View all" }).click(); From 358a3e3e4f7340655eea2ab2cdc4a96fceb0f406 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 03:51:35 +0200 Subject: [PATCH 044/158] Fix Recent billing events nav target and update e2e tests for back-office UI drift --- .../DashboardRecentStripeEventsCard.tsx | 2 +- .../tests/e2e/back-office-flows.spec.ts | 30 +++++++------------ .../tests/e2e/billing-events-flows.spec.ts | 29 ++++++++++-------- 3 files changed, 27 insertions(+), 34 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index c567dd4f0e..5cce56c05c 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -30,7 +30,7 @@ export function DashboardRecentStripeEventsCard() { const handleActivate = useCallback( (key: RowKey) => { const tenantId = String(key).split("|")[0]; - navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "invoices" } }); + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); }, [navigate] ); diff --git a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts index f39d2b5c95..b5a1fc5365 100644 --- a/application/account/WebApp/tests/e2e/back-office-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/back-office-flows.spec.ts @@ -191,7 +191,6 @@ test.describe("@smoke", () => { const main = page.getByRole("main"); - await expect(page.getByRole("button", { name: "Back to accounts" })).toBeVisible(); await expect(main.getByText("MRR")).toBeVisible(); await expect(main.getByText("Lifetime value")).toBeVisible(); await expect(main.getByRole("tab", { name: "Users" })).toBeVisible(); @@ -279,32 +278,23 @@ test.describe("@smoke", () => { await expect(page.getByRole("searchbox", { name: "Search" })).toBeVisible(); })(); - await step("Search for e2e owner first name & verify results table with at least one row")(async () => { + await step("Search for e2e owner first name & verify URL filters to TestOwner row")(async () => { await page.getByRole("searchbox", { name: "Search" }).fill("testowner"); + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/users?search=testowner`); await expect(page.getByRole("table", { name: "Users" })).toBeVisible(); await expect(page.getByRole("columnheader", { name: "User" })).toBeVisible(); - await expect(page.getByRole("row").nth(1)).toBeVisible(); + await expect(page.getByRole("row").filter({ hasText: "TestOwner" }).first()).toBeVisible(); })(); - await step("Click first user row & verify navigation to user detail page with display name and KPI cards")( - async () => { - await page.getByRole("row").nth(1).click(); - - await expect(page.getByRole("heading", { level: 1 })).toContainText("TestOwner"); - await expect(page.getByText("Last log-in")).toBeVisible(); - await expect(page.getByRole("heading", { name: "Accounts" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Sessions" })).toBeVisible(); - await expect(page.getByRole("heading", { name: "Login history" })).toBeVisible(); - } - )(); - - await step("Click Page views tab & verify telemetry section renders all three tabs")(async () => { - await page.getByRole("tab", { name: "Page views" }).click(); + await step("Click TestOwner row & verify navigation to user detail page with display name and tabs")(async () => { + await page.getByRole("row").filter({ hasText: "TestOwner" }).first().click(); - await expect(page.getByRole("tab", { name: "Exceptions" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Page views" })).toBeVisible(); - await expect(page.getByRole("tab", { name: "Custom events" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 1 })).toContainText("TestOwner"); + await expect(page.getByText("Last log-in")).toBeVisible(); + await expect(page.getByRole("tab", { name: "Accounts" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Logins" })).toBeVisible(); + await expect(page.getByRole("tab", { name: "Sessions" })).toBeVisible(); })(); await backOfficeContext.close(); diff --git a/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts index e500299894..05a5fb2946 100644 --- a/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts +++ b/application/account/WebApp/tests/e2e/billing-events-flows.spec.ts @@ -53,24 +53,27 @@ test.describe("@smoke", () => { // === FILTERS === - await step("Apply Subscribed event-type filter & verify chip becomes pressed and table stays visible")(async () => { - const subscribedChip = page.getByRole("button", { name: "Subscribed" }); - await subscribedChip.click(); - - await expect(subscribedChip).toHaveAttribute("aria-pressed", "true"); - await expect(page.getByRole("table", { name: "Billing events" })).toBeVisible(); - })(); - - await step("Clear event-type filter & verify chip is unpressed and URL returns to base /billing-events")( + await step("Apply Subscribed event-type filter & verify URL reflects selection and table stays visible")( async () => { - const subscribedChip = page.getByRole("button", { name: "Subscribed" }); - await subscribedChip.click(); + await page.getByRole("combobox", { name: "All event types" }).click(); + await page.getByRole("option", { name: "Subscribed" }).click(); + await page.keyboard.press("Escape"); - await expect(subscribedChip).toHaveAttribute("aria-pressed", "false"); - await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + await expect(page).toHaveURL( + `${BACK_OFFICE_BASE_URL}/billing-events?eventTypes=%5B%22SubscriptionCreated%22%5D` + ); + await expect(page.getByRole("table", { name: "Billing events" })).toBeVisible(); } )(); + await step("Clear Subscribed event-type filter & verify URL returns to base /billing-events")(async () => { + await page.getByRole("combobox", { name: "All event types" }).click(); + await page.getByRole("option", { name: "Subscribed" }).click(); + await page.keyboard.press("Escape"); + + await expect(page).toHaveURL(`${BACK_OFFICE_BASE_URL}/billing-events`); + })(); + await step( "Type a deliberately non-matching tenant search & verify URL reflects search query and empty state appears" )(async () => { From 2770703803cf37f94bea9296ab35ac0acd40548f Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 04:15:54 +0200 Subject: [PATCH 045/158] Collapse five back-office migrations into one --- .../20260503120000_AddSubscriptionTracking.cs | 15 --- .../20260503130000_BackfillSubscribedSince.cs | 26 ----- .../20260505140000_AddBillingEvents.cs | 45 --------- ...260506180000_AddSubscriptionDriftFields.cs | 18 ---- ...aymentTransactionTaxBreakdownConstraint.cs | 18 ---- ...21500_AddBillingEventsAndDriftDetection.cs | 94 +++++++++++++++++++ 6 files changed, 94 insertions(+), 122 deletions(-) delete mode 100644 application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs delete mode 100644 application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs delete mode 100644 application/account/Core/Database/Migrations/20260505140000_AddBillingEvents.cs delete mode 100644 application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs delete mode 100644 application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs create mode 100644 application/account/Core/Database/Migrations/20260508021500_AddBillingEventsAndDriftDetection.cs diff --git a/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs b/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs deleted file mode 100644 index 70cc23ae45..0000000000 --- a/application/account/Core/Database/Migrations/20260503120000_AddSubscriptionTracking.cs +++ /dev/null @@ -1,15 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260503120000_AddSubscriptionTracking")] -public sealed class AddSubscriptionTracking : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddColumn("subscribed_since", "subscriptions", "timestamptz", nullable: true); - migrationBuilder.AddColumn("scheduled_price_amount", "subscriptions", "numeric(18,2)", nullable: true); - } -} diff --git a/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs b/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs deleted file mode 100644 index 4baf0150a1..0000000000 --- a/application/account/Core/Database/Migrations/20260503130000_BackfillSubscribedSince.cs +++ /dev/null @@ -1,26 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260503130000_BackfillSubscribedSince")] -public sealed class BackfillSubscribedSince : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - // Subscriptions created before AddSubscriptionTracking have no subscribed_since because the - // column did not exist when the Basis -> paid transition occurred. Best available proxy for - // the start of their paid run is the subscription row's created_at timestamp. Only backfill - // active paid subscriptions (have a Stripe subscription id and are not on the free Basis plan). - migrationBuilder.Sql( - """ - UPDATE subscriptions - SET subscribed_since = created_at - WHERE subscribed_since IS NULL - AND stripe_subscription_id IS NOT NULL - AND plan <> 'Basis'; - """ - ); - } -} diff --git a/application/account/Core/Database/Migrations/20260505140000_AddBillingEvents.cs b/application/account/Core/Database/Migrations/20260505140000_AddBillingEvents.cs deleted file mode 100644 index 2cb04847a4..0000000000 --- a/application/account/Core/Database/Migrations/20260505140000_AddBillingEvents.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260505140000_AddBillingEvents")] -public sealed class AddBillingEvents : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - 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), - 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), - currency = table.Column("text", nullable: true), - days_on_previous_plan = table.Column("integer", nullable: true), - days_until_effective = table.Column("integer", nullable: true), - days_since_cancelled = table.Column("integer", nullable: true), - scheduled_for = table.Column("timestamptz", nullable: true), - effective_at = table.Column("timestamptz", nullable: true), - occurred_at = table.Column("timestamptz", nullable: false), - cancellation_reason = table.Column("text", nullable: true), - suspension_reason = table.Column("text", nullable: true), - stripe_reference = table.Column("text", nullable: false) - }, - constraints: table => { table.PrimaryKey("pk_billing_events", x => x.id); } - ); - - 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"); - } -} diff --git a/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs b/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs deleted file mode 100644 index 920ad73bb1..0000000000 --- a/application/account/Core/Database/Migrations/20260506180000_AddSubscriptionDriftFields.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260506180000_AddSubscriptionDriftFields")] -public sealed class AddSubscriptionDriftFields : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - 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"); - } -} diff --git a/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs b/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs deleted file mode 100644 index ac6028000b..0000000000 --- a/application/account/Core/Database/Migrations/20260507205500_AddPaymentTransactionTaxBreakdownConstraint.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; - -namespace Account.Database.Migrations; - -[DbContext(typeof(AccountDbContext))] -[Migration("20260507205500_AddPaymentTransactionTaxBreakdownConstraint")] -public sealed class AddPaymentTransactionTaxBreakdownConstraint : Migration -{ - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.AddCheckConstraint( - "chk_subscriptions_payment_transactions_tax_breakdown", - "subscriptions", - """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')""" - ); - } -} diff --git a/application/account/Core/Database/Migrations/20260508021500_AddBillingEventsAndDriftDetection.cs b/application/account/Core/Database/Migrations/20260508021500_AddBillingEventsAndDriftDetection.cs new file mode 100644 index 0000000000..6c565c6da8 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260508021500_AddBillingEventsAndDriftDetection.cs @@ -0,0 +1,94 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260508021500_AddBillingEventsAndDriftDetection")] +public sealed class AddBillingEventsAndDriftDetection : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("subscribed_since", "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"); + + // Subscriptions created before this migration have no subscribed_since because the column did not + // exist when the Basis -> paid transition occurred. Best available proxy for the start of their paid + // run is the subscription row's created_at timestamp. Only backfill active paid subscriptions + // (those that have a Stripe subscription id and are not on the free Basis plan). + migrationBuilder.Sql( + """ + UPDATE subscriptions + SET subscribed_since = created_at + WHERE subscribed_since IS NULL + AND stripe_subscription_id IS NOT NULL + AND plan <> 'Basis'; + """ + ); + + // PaymentTransaction.AmountExcludingTax and TaxAmount became non-nullable in the C# domain alongside + // this migration. Existing rows synced from Stripe before that change may have those keys missing or + // null. Default AmountExcludingTax to the gross Amount and TaxAmount to 0 so the CHECK constraint + // below passes. The next Stripe sync per tenant overwrites these with the real breakdown. + migrationBuilder.Sql( + """ + UPDATE subscriptions + SET payment_transactions = ( + SELECT jsonb_agg( + e || jsonb_build_object( + 'AmountExcludingTax', COALESCE((e->>'AmountExcludingTax')::numeric, (e->>'Amount')::numeric, 0), + 'TaxAmount', COALESCE((e->>'TaxAmount')::numeric, 0) + ) + ) + FROM jsonb_array_elements(payment_transactions) e + ) + WHERE jsonb_array_length(payment_transactions) > 0 + AND jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))'); + """ + ); + + migrationBuilder.AddCheckConstraint( + "chk_subscriptions_payment_transactions_tax_breakdown", + "subscriptions", + """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')""" + ); + + 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), + 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), + currency = table.Column("text", nullable: true), + days_on_previous_plan = table.Column("integer", nullable: true), + days_until_effective = table.Column("integer", nullable: true), + days_since_cancelled = table.Column("integer", nullable: true), + scheduled_for = table.Column("timestamptz", nullable: true), + effective_at = table.Column("timestamptz", nullable: true), + occurred_at = table.Column("timestamptz", nullable: false), + cancellation_reason = table.Column("text", nullable: true), + suspension_reason = table.Column("text", nullable: true), + stripe_reference = table.Column("text", nullable: false) + }, + constraints: table => { table.PrimaryKey("pk_billing_events", x => x.id); } + ); + + 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"); + } +} From 70cf72e526a79cf50ab6e39412b0a3ae4740e5a3 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Fri, 8 May 2026 21:22:31 +0200 Subject: [PATCH 046/158] Display tenant and user IDs on detail surfaces and polish dashboard cards --- .../DashboardRecentSignupsCard.tsx | 35 ++++++++----------- .../DashboardRecentStripeEventsCard.tsx | 15 ++------ .../routes/-components/DashboardSections.tsx | 6 ++-- .../AccountBillingEventsSection.tsx | 2 +- .../-components/AccountDetailHeader.tsx | 6 +++- .../BackOffice/routes/users/$userId.tsx | 2 +- .../users/-components/UserDetailHeader.tsx | 15 +++++--- .../Queries/GetDashboardRecentSignups.cs | 29 +++++++-------- .../Features/Users/Domain/UserRepository.cs | 26 ++++++++++++++ .../-components/AccountInfoFields.tsx | 6 +++- .../users/-components/UserProfileContent.tsx | 6 +++- .../WebApp/routes/user/profile/index.tsx | 12 +++++++ .../shared/components/UserProfileFields.tsx | 11 ++++-- .../shared/translations/locale/da-DK.po | 5 ++- .../shared/translations/locale/en-US.po | 3 ++ 15 files changed, 113 insertions(+), 66 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx index 910163f44c..af15f01478 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx @@ -1,9 +1,7 @@ import type { RowKey } from "@repo/ui/components/Table"; 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 { 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"; @@ -14,13 +12,15 @@ import { useCallback } from "react"; import { SmartDateTime } from "@/shared/components/SmartDateTime"; import { api } from "@/shared/lib/api/client"; -import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; -import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; 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 { i18n } = useLingui(); const navigate = useNavigate(); const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-signups", { params: { query: { Limit: 6 } } @@ -64,17 +64,20 @@ export function DashboardRecentSignupsCard() { ) : ( - +
    Account - Plan - - - Country + Owner Created @@ -96,16 +99,8 @@ export function DashboardRecentSignupsCard() { - - {getSubscriptionPlanLabel(signup.plan)} - - - - {signup.country ? ( - - - {getCountryName(signup.country, i18n.locale)} - + {signup.owner ? ( + {getOwnerDisplayName(signup.owner)} ) : ( )} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx index 5cce56c05c..4931437a99 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -72,6 +72,7 @@ export function DashboardRecentStripeEventsCard() { aria-label={t`Recent billing events`} selectionMode="single" onActivate={handleActivate} + containerClassName="border-0 bg-transparent" > @@ -88,7 +89,7 @@ export function DashboardRecentStripeEventsCard() { MRR impact - Date + Occurred @@ -96,8 +97,6 @@ export function DashboardRecentStripeEventsCard() { {events.map((event, index) => { const variant = BILLING_EVENT_VARIANT[event.type]; const Icon = variant.icon; - const showPlanTransition = - event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; const isNegativeAmount = event.amountDelta != null && event.amountDelta < 0; return ( - {showPlanTransition ? ( - - {getSubscriptionPlanLabel(event.fromPlan!)} - - → - - {getSubscriptionPlanLabel(event.toPlan!)} - - ) : event.toPlan != null ? ( + {event.toPlan != null ? ( {getSubscriptionPlanLabel(event.toPlan)} ) : ( diff --git a/application/account/BackOffice/routes/-components/DashboardSections.tsx b/application/account/BackOffice/routes/-components/DashboardSections.tsx index 943d21982b..cfaa3dedf6 100644 --- a/application/account/BackOffice/routes/-components/DashboardSections.tsx +++ b/application/account/BackOffice/routes/-components/DashboardSections.tsx @@ -26,11 +26,11 @@ export function DashboardSections() { -
    -
    +
    +
    -
    +
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx index 312c83e9f7..dfb9ac854e 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx @@ -81,7 +81,7 @@ export function AccountBillingEventsSection({ - Date + Occurred Event diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index 6f2861e443..ca4883eb25 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -5,7 +5,7 @@ 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 } from "lucide-react"; +import { CalendarIcon, HashIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -75,6 +75,10 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly{formatDate(tenant.createdAt)} + + + {tenantId} +
    )} diff --git a/application/account/BackOffice/routes/users/$userId.tsx b/application/account/BackOffice/routes/users/$userId.tsx index 701afc64f9..5c5ae99c8e 100644 --- a/application/account/BackOffice/routes/users/$userId.tsx +++ b/application/account/BackOffice/routes/users/$userId.tsx @@ -61,7 +61,7 @@ function UserDetailPage() {
    - + diff --git a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx index 2c2939768a..410d7f4762 100644 --- a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -3,7 +3,7 @@ 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, MailIcon, XCircleIcon } from "lucide-react"; +import { CalendarIcon, CheckCircle2Icon, HashIcon, MailIcon, XCircleIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -13,18 +13,19 @@ type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailR interface UserDetailHeaderProps { user: BackOfficeUserDetailResponse | undefined; + userId: string; isLoading: boolean; } -export function UserDetailHeader({ user, isLoading }: Readonly) { +export function UserDetailHeader({ user, userId, isLoading }: Readonly) { const formatDate = useFormatDate(); return ( -
    +
    {isLoading || !user ? ( <> -
    +
    @@ -39,7 +40,7 @@ export function UserDetailHeader({ user, isLoading }: Readonly -
    +

    {getUserDisplayName(user.firstName, user.lastName, user.email)} @@ -72,6 +73,10 @@ export function UserDetailHeader({ user, isLoading }: Readonly{formatDate(user.createdAt)} + + + {userId} +

    diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs index db1a7b6b60..ff421877ff 100644 --- a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs @@ -1,5 +1,5 @@ -using Account.Features.Subscriptions.Domain; using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; using FluentValidation; using JetBrains.Annotations; using SharedKernel.Cqrs; @@ -18,12 +18,14 @@ public sealed record BackOfficeDashboardRecentSignupsResponse(BackOfficeDashboar public sealed record BackOfficeDashboardRecentSignup( TenantId TenantId, string Name, - string? Country, - SubscriptionPlan Plan, string? TenantLogoUrl, - DateTimeOffset CreatedAt + DateTimeOffset CreatedAt, + BackOfficeDashboardRecentSignupOwner? Owner ); +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignupOwner(UserId UserId, string? FirstName, string? LastName, string Email); + public sealed class GetDashboardRecentSignupsQueryValidator : AbstractValidator { public GetDashboardRecentSignupsQueryValidator() @@ -32,31 +34,24 @@ public GetDashboardRecentSignupsQueryValidator() } } -public sealed class GetDashboardRecentSignupsHandler( - ITenantRepository tenantRepository, - ISubscriptionRepository subscriptionRepository -) : IRequestHandler> +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 subscriptions = tenantIds.Length == 0 - ? [] - : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); - var subscriptionByTenantId = subscriptions.ToDictionary(s => s.TenantId); + var ownerByTenantId = await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); var signups = tenants.Select(tenant => { - var subscription = subscriptionByTenantId.GetValueOrDefault(tenant.Id); - var country = subscription?.BillingInfo?.Address?.Country; + var owner = ownerByTenantId.GetValueOrDefault(tenant.Id); return new BackOfficeDashboardRecentSignup( tenant.Id, tenant.Name, - country, - tenant.Plan, tenant.Logo.Url, - tenant.CreatedAt + tenant.CreatedAt, + owner is null ? null : new BackOfficeDashboardRecentSignupOwner(owner.Id, owner.FirstName, owner.LastName, owner.Email) ); } ).ToArray(); diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 549d5f42a6..0caa4b8bcb 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -91,6 +91,12 @@ CancellationToken cancellationToken /// 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 @@ -505,6 +511,26 @@ public async Task GetAllUnfilteredAsync(CancellationToken cancellationTo 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/WebApp/routes/account/settings/-components/AccountInfoFields.tsx b/application/account/WebApp/routes/account/settings/-components/AccountInfoFields.tsx index 6289732eba..33f346fda5 100644 --- a/application/account/WebApp/routes/account/settings/-components/AccountInfoFields.tsx +++ b/application/account/WebApp/routes/account/settings/-components/AccountInfoFields.tsx @@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { Link } from "@tanstack/react-router"; +import { HashIcon } from "lucide-react"; import { api, type Schemas, SuspensionReason, TenantState } from "@/shared/lib/api/client"; import { getPlanLabelWithFree } from "@/shared/lib/api/subscriptionPlan"; @@ -41,7 +42,10 @@ export function AccountInfoFields({ tenant }: Readonly) Account ID - {tenant?.id} + + + {tenant?.id} +
    diff --git a/application/account/WebApp/routes/account/users/-components/UserProfileContent.tsx b/application/account/WebApp/routes/account/users/-components/UserProfileContent.tsx index d29cc4af15..dec6124ed3 100644 --- a/application/account/WebApp/routes/account/users/-components/UserProfileContent.tsx +++ b/application/account/WebApp/routes/account/users/-components/UserProfileContent.tsx @@ -7,7 +7,7 @@ import { Separator } from "@repo/ui/components/Separator"; import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; import { getInitials } from "@repo/utils/string/getInitials"; -import { PencilIcon } from "lucide-react"; +import { HashIcon, PencilIcon } from "lucide-react"; import type { components } from "@/shared/lib/api/client"; @@ -40,6 +40,10 @@ export function UserProfileContent({ {user.firstName} {user.lastName} {user.title && {user.title}} + + + {user.id} +
    {/* Contact Information */} diff --git a/application/account/WebApp/routes/user/profile/index.tsx b/application/account/WebApp/routes/user/profile/index.tsx index 8cb8be8461..54ac015594 100644 --- a/application/account/WebApp/routes/user/profile/index.tsx +++ b/application/account/WebApp/routes/user/profile/index.tsx @@ -10,6 +10,7 @@ import { mutationSubmitter } from "@repo/ui/forms/mutationSubmitter"; import { useUnsavedChangesGuard } from "@repo/ui/hooks/useUnsavedChangesGuard"; import { useMutation } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; +import { HashIcon } from "lucide-react"; import { useContext, useState } from "react"; import { toast } from "sonner"; @@ -121,6 +122,17 @@ function ProfilePage() { isPending={saveMutation.isPending} onAvatarFileSelect={handleAvatarFileSelect} onAvatarRemove={handleAvatarRemove} + infoFields={ +
    + + User ID + + + + {user.id} + +
    + } />
    diff --git a/application/account/WebApp/shared/components/UserProfileFields.tsx b/application/account/WebApp/shared/components/UserProfileFields.tsx index 4d5ff7f583..2cf6d4ec0b 100644 --- a/application/account/WebApp/shared/components/UserProfileFields.tsx +++ b/application/account/WebApp/shared/components/UserProfileFields.tsx @@ -2,7 +2,7 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { TextField } from "@repo/ui/components/TextField"; import { MailIcon } from "lucide-react"; -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import type { Schemas } from "@/shared/lib/api/client"; @@ -15,6 +15,7 @@ export interface UserProfileFieldsProps { onAvatarRemove?: () => void; autoFocus?: boolean; layout?: "stacked" | "horizontal"; + infoFields?: ReactNode; } export function UserProfileFields({ @@ -23,7 +24,8 @@ export function UserProfileFields({ onAvatarFileSelect, onAvatarRemove, autoFocus, - layout = "stacked" + layout = "stacked", + infoFields }: UserProfileFieldsProps) { // Snapshot user once so TextField defaultValue stays stable. A later refetch (e.g. after // saving the profile) would otherwise change defaultValue between renders and trigger @@ -92,7 +94,10 @@ export function UserProfileFields({ {avatarSection}
    -
    {fieldsSection}
    +
    + {fieldsSection} + {infoFields} +
    ); } diff --git a/application/account/WebApp/shared/translations/locale/da-DK.po b/application/account/WebApp/shared/translations/locale/da-DK.po index 8a0f80e2b1..c7d0a8dd1f 100644 --- a/application/account/WebApp/shared/translations/locale/da-DK.po +++ b/application/account/WebApp/shared/translations/locale/da-DK.po @@ -158,7 +158,7 @@ msgid "Account deleted" msgstr "Konto slettet" msgid "Account ID" -msgstr "Konto-ID" +msgstr "Konto-id" msgid "Account logo" msgstr "Kontologo" @@ -2835,6 +2835,9 @@ msgstr "Brugerdata opdateret" msgid "User deleted successfully: {userDisplayName}" msgstr "Bruger slettet succesfuldt: {userDisplayName}" +msgid "User ID" +msgstr "Bruger-id" + msgid "User invited successfully" msgstr "Bruger inviteret succesfuldt" diff --git a/application/account/WebApp/shared/translations/locale/en-US.po b/application/account/WebApp/shared/translations/locale/en-US.po index 3bef32b1c9..1acd05d6af 100644 --- a/application/account/WebApp/shared/translations/locale/en-US.po +++ b/application/account/WebApp/shared/translations/locale/en-US.po @@ -2835,6 +2835,9 @@ msgstr "User data updated" msgid "User deleted successfully: {userDisplayName}" msgstr "User deleted successfully: {userDisplayName}" +msgid "User ID" +msgstr "User ID" + msgid "User invited successfully" msgstr "User invited successfully" From b743b0e4d5b57d7464f8bee14eb8804dcb506f24 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 9 May 2026 10:16:37 +0200 Subject: [PATCH 047/158] Replace Created with Signed up for accounts in back-office surfaces --- .../routes/-components/DashboardRecentSignupsCard.tsx | 2 +- .../accounts/-components/AccountDetailHeader.tsx | 2 +- .../accounts/-components/AccountSidePaneSections.tsx | 2 +- .../-components/AccountsTableColumnHeaders.tsx | 2 +- .../BackOffice/shared/translations/locale/da-DK.po | 10 ++++++++-- .../BackOffice/shared/translations/locale/en-US.po | 10 ++++++++-- 6 files changed, 20 insertions(+), 8 deletions(-) diff --git a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx index af15f01478..92e7d5c5f8 100644 --- a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx +++ b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx @@ -80,7 +80,7 @@ export function DashboardRecentSignupsCard() { Owner - Created + Signed up diff --git a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx index ca4883eb25..78712d7bbf 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -71,7 +71,7 @@ export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly - Created {formatDate(tenant.createdAt, false, false, true)} + Signed up {formatDate(tenant.createdAt, false, false, true)} {formatDate(tenant.createdAt)} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx index b835083e21..ca8bf8657c 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx @@ -143,7 +143,7 @@ export function AccountSidePaneSections({ - + {formatDate(tenant.createdAt)} {formatRelativeTime(tenant.createdAt, i18n.locale)} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx index 08748e20be..b1c308fbaf 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx @@ -78,7 +78,7 @@ export function AccountsTableColumnHeaders({ orderBy, sortOrder, onSort }: Reado onSort={onSort} className="hidden w-[6.5rem] xl:table-cell" > - Created + Signed up diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index fa8a79c51c..bf6625e95d 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -233,9 +233,7 @@ msgstr "Land" msgid "Created" msgstr "Oprettet" -#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) #. placeholder {0}: formatDate(user.createdAt, false, false, true) -#. placeholder {1}: formatDate(tenant.createdAt) #. placeholder {1}: formatDate(user.createdAt) msgid "Created <0>{0}<1>{1}" msgstr "Oprettet <0>{0}<1>{1}" @@ -683,6 +681,14 @@ 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.createdAt) msgid "Since {0}" msgstr "Siden {0}" diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index e40cd3434a..01e843a1a4 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -233,9 +233,7 @@ msgstr "Country" msgid "Created" msgstr "Created" -#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) #. placeholder {0}: formatDate(user.createdAt, false, false, true) -#. placeholder {1}: formatDate(tenant.createdAt) #. placeholder {1}: formatDate(user.createdAt) msgid "Created <0>{0}<1>{1}" msgstr "Created <0>{0}<1>{1}" @@ -683,6 +681,14 @@ 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.createdAt) msgid "Since {0}" msgstr "Since {0}" From 56ac3c3ce3fa65be9e9c4c37f015b45c239ec690 Mon Sep 17 00:00:00 2001 From: Thomas Jespersen Date: Sat, 9 May 2026 13:31:39 +0200 Subject: [PATCH 048/158] Make billing events 1:1 with Stripe events and add unsynced and drift filters on accounts --- .../-components/AccountBillingEventRow.tsx | 11 - .../accounts/-components/AccountsToolbar.tsx | 51 +- .../BackOffice/routes/accounts/index.tsx | 21 +- .../shared/components/BillingDriftBanner.tsx | 2 +- .../components/UnsyncedAccountsBanner.tsx | 2 +- .../BackOffice/shared/lib/api/labels.ts | 4 + .../shared/lib/billingEventStyle.ts | 10 + .../shared/translations/locale/da-DK.po | 17 +- .../shared/translations/locale/en-US.po | 17 +- ...0000_AddBillingEventsAndDriftDetection.cs} | 49 +- .../Queries/GetBackOfficeBillingEvents.cs | 8 +- .../Subscriptions/Domain/BillingEvent.cs | 135 ++-- .../Domain/BillingEventConfiguration.cs | 4 +- .../Domain/BillingEventRepository.cs | 72 ++- .../Subscriptions/Domain/Subscription.cs | 11 +- .../Shared/ProcessPendingStripeEvents.cs | 277 ++------- .../Shared/StripeEventReplayer.cs | 588 ++++++++++++------ .../Tenants/BackOffice/Queries/GetTenants.cs | 25 +- .../Core/Integrations/Stripe/IStripeClient.cs | 10 + .../Integrations/Stripe/MockStripeClient.cs | 39 ++ .../Core/Integrations/Stripe/StripeClient.cs | 59 ++ .../Stripe/UnconfiguredStripeClient.cs | 6 + .../BackOffice/BackOfficeEndpointBaseTest.cs | 19 +- .../GetDashboardMrrConsistencySummaryTests.cs | 12 +- .../GetUnsyncedSubscriptionsSummaryTests.cs | 12 +- .../Dashboard/GetDashboardMrrTrendTests.cs | 12 +- .../GetDashboardRecentStripeEventsTests.cs | 18 +- .../GetBackOfficeBillingEventsTests.cs | 13 +- .../BackOffice/SyncTenantWithStripeTests.cs | 6 +- .../Subscriptions/BillingEventAppendTests.cs | 57 +- 30 files changed, 910 insertions(+), 657 deletions(-) rename application/account/Core/Database/Migrations/{20260508021500_AddBillingEventsAndDriftDetection.cs => 20260509120000_AddBillingEventsAndDriftDetection.cs} (63%) diff --git a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx index 62a8528677..a0057ac261 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx @@ -1,6 +1,5 @@ import type { ReactNode } from "react"; -import { Trans } from "@lingui/react/macro"; import { Badge } from "@repo/ui/components/Badge"; import { TableCell, TableRow } from "@repo/ui/components/Table"; import { formatCurrency } from "@repo/utils/currency/formatCurrency"; @@ -12,10 +11,6 @@ import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; type BillingEventSummary = components["schemas"]["BillingEventSummary"]; -function isSameDay(a: string, b: string): boolean { - return a.slice(0, 10) === b.slice(0, 10); -} - export function AccountBillingEventRow({ event, renderDate, @@ -28,17 +23,11 @@ export function AccountBillingEventRow({ const variant = BILLING_EVENT_VARIANT[event.eventType]; const Icon = variant.icon; const showPlanTransition = event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; - const showEffective = event.effectiveAt != null && !isSameDay(event.effectiveAt, event.occurredAt); return (
    {renderDate(event.occurredAt)} - {showEffective && ( - - Effective {renderDate(event.effectiveAt)} - - )}
    diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx index 56d6b6079c..30d05c9bae 100644 --- a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx +++ b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx @@ -1,10 +1,12 @@ 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 { SearchIcon, XIcon } from "lucide-react"; +import { CloudOffIcon, SearchIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useEffect, useState } from "react"; import type { SortableTenantProperties } from "@/shared/lib/api/client"; @@ -16,9 +18,11 @@ interface AccountsToolbarProps { search: string | undefined; plans: SubscriptionPlan[]; statuses: TenantStatusFilter[]; + unsynced: boolean; + driftDetected: boolean; } -export function AccountsToolbar({ search, plans, statuses }: Readonly) { +export function AccountsToolbar({ search, plans, statuses, unsynced, driftDetected }: Readonly) { const navigate = useNavigate(); const [searchInput, setSearchInput] = useState(search ?? ""); const debouncedSearch = useDebounce(searchInput, 500); @@ -137,6 +141,49 @@ export function AccountsToolbar({ search, plans, statuses }: ReadonlyFree + +
    ); } + +function IssueFilterBadges({ unsynced, driftDetected }: Readonly<{ unsynced: boolean; driftDetected: boolean }>) { + const navigate = useNavigate(); + const clear = (key: "unsynced" | "driftDetected") => () => + navigate({ + to: "/accounts", + search: (previous) => ({ + search: previous.search, + plans: previous.plans, + statuses: previous.statuses, + unsynced: key === "unsynced" ? undefined : previous.unsynced, + driftDetected: key === "driftDetected" ? undefined : previous.driftDetected, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + sortOrder: previous.sortOrder, + pageOffset: undefined + }) + }); + + return ( + <> + {unsynced && ( + + + Not synced yet + + + )} + {driftDetected && ( + + + Drift detected + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/index.tsx b/application/account/BackOffice/routes/accounts/index.tsx index 25ed8faea3..a9fe680fb3 100644 --- a/application/account/BackOffice/routes/accounts/index.tsx +++ b/application/account/BackOffice/routes/accounts/index.tsx @@ -31,6 +31,8 @@ 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() @@ -43,7 +45,7 @@ export const Route = createFileRoute("/accounts/")({ }); function AccountsListPage() { - const { search, plans, statuses, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const { search, plans, statuses, unsynced, driftDetected, orderBy, sortOrder, pageOffset } = Route.useSearch(); const navigate = useNavigate(); const [previewTenant, setPreviewTenant] = useState(null); @@ -56,6 +58,8 @@ function AccountsListPage() { Search: search, Plans: plans, Statuses: statuses, + Unsynced: unsynced, + DriftDetected: driftDetected, OrderBy: orderBy, SortOrder: sortOrder, PageOffset: pageOffset @@ -72,7 +76,12 @@ function AccountsListPage() { const handleClosePane = useCallback(() => setPreviewTenant(null), []); const tenants = data?.tenants ?? []; - const hasFilters = Boolean(search) || (plans?.length ?? 0) > 0 || (statuses?.length ?? 0) > 0; + const hasFilters = + Boolean(search) || + (plans?.length ?? 0) > 0 || + (statuses?.length ?? 0) > 0 || + Boolean(unsynced) || + Boolean(driftDetected); const showEmpty = !isLoading && tenants.length === 0; return ( @@ -91,7 +100,13 @@ function AccountsListPage() { ) : undefined } > - + {showEmpty ? ( diff --git a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx index d6bf0924f7..2f131e47ad 100644 --- a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx +++ b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx @@ -31,7 +31,7 @@ export function BillingDriftBanner() { {count} accounts have billing drift detected. -
    diff --git a/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx index e7f608748c..3fb886920e 100644 --- a/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx +++ b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx @@ -32,7 +32,7 @@ export function UnsyncedAccountsBanner() { {count} accounts have not been synced yet — MRR trend is incomplete. -
    diff --git a/application/account/BackOffice/shared/lib/api/labels.ts b/application/account/BackOffice/shared/lib/api/labels.ts index 7ea4fe6800..40fa71195b 100644 --- a/application/account/BackOffice/shared/lib/api/labels.ts +++ b/application/account/BackOffice/shared/lib/api/labels.ts @@ -136,6 +136,10 @@ export function getBillingEventTypeLabel(type: BillingEventType): string { 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/billingEventStyle.ts b/application/account/BackOffice/shared/lib/billingEventStyle.ts index f0673dba96..590ef708ef 100644 --- a/application/account/BackOffice/shared/lib/billingEventStyle.ts +++ b/application/account/BackOffice/shared/lib/billingEventStyle.ts @@ -4,12 +4,14 @@ import { CalendarClockIcon, CircleAlertIcon, CircleCheckIcon, + CircleSlashIcon, CircleXIcon, CreditCardIcon, PauseCircleIcon, RefreshCwIcon, ReplyIcon, RotateCcwIcon, + TriangleAlertIcon, WalletIcon } from "lucide-react"; @@ -93,5 +95,13 @@ export const BILLING_EVENT_VARIANT: Record("subscribed_since", "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: "[]"); + // Subscription drift columns. IF NOT EXISTS so this migration is idempotent on staging where an + // earlier iteration of this migration already added them. Removed before merging to main; new + // environments only see plain ADD COLUMN. + migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS subscribed_since timestamptz;"); + migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS scheduled_price_amount numeric(18,2);"); + migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS has_drift_detected boolean NOT NULL DEFAULT false;"); + migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS drift_checked_at timestamptz;"); + migrationBuilder.Sql("ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS drift_discrepancies jsonb NOT NULL DEFAULT '[]';"); - migrationBuilder.CreateIndex("ix_subscriptions_has_drift_detected", "subscriptions", "has_drift_detected", filter: "has_drift_detected = true"); + migrationBuilder.Sql("CREATE INDEX IF NOT EXISTS ix_subscriptions_has_drift_detected ON subscriptions (has_drift_detected) WHERE has_drift_detected = true;"); // Subscriptions created before this migration have no subscribed_since because the column did not // exist when the Basis -> paid transition occurred. Best available proxy for the start of their paid @@ -52,10 +55,23 @@ WHERE jsonb_array_length(payment_transactions) > 0 """ ); - migrationBuilder.AddCheckConstraint( - "chk_subscriptions_payment_transactions_tax_breakdown", - "subscriptions", - """NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')""" + // Add the check constraint only if it doesn't exist. Removed before merging to main; new + // environments only see a plain ALTER TABLE … ADD CONSTRAINT. + migrationBuilder.Sql( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'chk_subscriptions_payment_transactions_tax_breakdown' + AND conrelid = 'subscriptions'::regclass + ) THEN + ALTER TABLE subscriptions + ADD CONSTRAINT chk_subscriptions_payment_transactions_tax_breakdown + CHECK (NOT jsonb_path_exists(payment_transactions, '$[*] ? (!(@.AmountExcludingTax.type() == "number") || !(@.TaxAmount.type() == "number"))')); + END IF; + END $$; + """ ); migrationBuilder.CreateTable( @@ -67,26 +83,23 @@ WHERE jsonb_array_length(payment_transactions) > 0 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), - days_on_previous_plan = table.Column("integer", nullable: true), - days_until_effective = table.Column("integer", nullable: true), - days_since_cancelled = table.Column("integer", nullable: true), - scheduled_for = table.Column("timestamptz", nullable: true), - effective_at = table.Column("timestamptz", nullable: true), occurred_at = table.Column("timestamptz", nullable: false), cancellation_reason = table.Column("text", nullable: true), - suspension_reason = table.Column("text", nullable: true), - stripe_reference = table.Column("text", nullable: false) + 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"); diff --git a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs index a8357a170e..31b0f62cc5 100644 --- a/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs +++ b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs @@ -42,10 +42,8 @@ public sealed record BillingEventSummary( decimal? AmountDelta, decimal? PreviousAmount, decimal? NewAmount, + decimal CommittedMrr, string? Currency, - int? DaysOnPreviousPlan, - DateTimeOffset? ScheduledFor, - DateTimeOffset? EffectiveAt, DateTimeOffset OccurredAt ); @@ -113,10 +111,8 @@ public async Task> Handle(GetBackOfficeBillingEven e.AmountDelta, e.PreviousAmount, e.NewAmount, + e.CommittedMrr, e.Currency, - e.DaysOnPreviousPlan, - e.ScheduledFor, - e.EffectiveAt, e.OccurredAt ); } diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs index a8e3314327..bf9d1b848e 100644 --- a/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs @@ -1,5 +1,3 @@ -using System.Security.Cryptography; -using System.Text; using Account.Features.Tenants.Domain; using JetBrains.Annotations; using SharedKernel.Domain; @@ -7,90 +5,44 @@ namespace Account.Features.Subscriptions.Domain; -/// -/// Strongly-typed ID for . Unlike most aggregate IDs in the codebase -/// (which extend StronglyTypedUlid and generate fresh ULIDs at creation time), this ID is -/// issued as a deterministic SHA-256 hash of the event's identity components. Webhook redelivery -/// after a transaction rollback re-runs detection and produces the same ID, making the append -/// helper's existence-check skip path naturally idempotent. -/// [PublicAPI] -[IdPrefix("bilev")] +[IdPrefix("bilevt")] [JsonConverter(typeof(StronglyTypedIdJsonConverter))] -public sealed record BillingEventId(string Value) : StronglyTypedString(Value) +public sealed record BillingEventId(string Value) : StronglyTypedUlid(Value) { public override string ToString() { return Value; } - - /// - /// Builds a deterministic ID from the inputs that anchor a billing event to Stripe data. - /// Re-running reconciliation for the same Stripe state produces the same ID, so reconciliation - /// becomes a clean upsert with no duplicates and no fresh ULIDs on every sync. - /// - public static BillingEventId FromComponents(SubscriptionId subscriptionId, BillingEventType eventType, string stripeReference, DateTimeOffset occurredAt) - { - var key = $"{subscriptionId.Value}|{eventType}|{stripeReference}|{occurredAt:O}"; - var hash = SHA256.HashData(Encoding.UTF8.GetBytes(key)); - // Take 16 bytes (128 bits — the same width as a ULID) and base32-encode without padding. - var token = Base32Encode(hash.AsSpan(0, 16)); - return NewId($"bilev_{token}"); - } - - private static string Base32Encode(ReadOnlySpan bytes) - { - // RFC 4648 base32 extended hex alphabet (NOT Crockford / ULID): 0-9 then A-V. We use this rather - // than ULID's Crockford alphabet because the ID is a pure SHA-256 hash, never sorted by time, - // never read by humans for typing, and never compared visually with ULIDs in the same UI. - const string alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUV"; - Span output = stackalloc char[26]; - var bitBuffer = 0; - var bitCount = 0; - var outputIndex = 0; - foreach (var b in bytes) - { - bitBuffer = (bitBuffer << 8) | b; - bitCount += 8; - while (bitCount >= 5 && outputIndex < output.Length) - { - bitCount -= 5; - output[outputIndex++] = alphabet[(bitBuffer >> bitCount) & 0x1F]; - } - } - - if (outputIndex < output.Length && bitCount > 0) - { - output[outputIndex++] = alphabet[(bitBuffer << (5 - bitCount)) & 0x1F]; - } - - return new string(output[..outputIndex]); - } } /// -/// A durable, append-only record of a subscription/billing lifecycle transition. Each row is the -/// authoritative log of what actually happened — once written, it is never updated and never -/// deleted. New rows are appended only when a real transition is detected during a Stripe sync. -/// Deterministic IDs make webhook retries idempotent: a redelivered event computes the same ID -/// and is silently skipped on PK conflict, so the log never accumulates duplicates. -/// A separate BillingDriftDetector service compares this log against Stripe history and -/// surfaces discrepancies in the back-office UI. It never rewrites history — manual reconciliation -/// is an explicit admin action, not an automatic side effect of the sync. +/// 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. /// public sealed class BillingEvent : AggregateRoot, ITenantScopedEntity { - private BillingEvent(BillingEventId id, TenantId tenantId) : base(id) + private BillingEvent(TenantId tenantId, SubscriptionId subscriptionId, string stripeEventId) + : base(BillingEventId.NewId()) { TenantId = tenantId; - SubscriptionId = null!; + SubscriptionId = subscriptionId; + StripeEventId = stripeEventId; EventType = default; OccurredAt = default; - StripeReference = string.Empty; } public SubscriptionId SubscriptionId { get; private set; } + public string StripeEventId { get; private set; } + public BillingEventType EventType { get; private set; } public SubscriptionPlan? FromPlan { get; private set; } @@ -103,17 +55,9 @@ private BillingEvent(BillingEventId id, TenantId tenantId) : base(id) public decimal? AmountDelta { get; private set; } - public string? Currency { get; private set; } + public decimal CommittedMrr { get; private set; } - public int? DaysOnPreviousPlan { get; private set; } - - public int? DaysUntilEffective { get; private set; } - - public int? DaysSinceCancelled { get; private set; } - - public DateTimeOffset? ScheduledFor { get; private set; } - - public DateTimeOffset? EffectiveAt { get; private set; } + public string? Currency { get; private set; } public DateTimeOffset OccurredAt { get; private set; } @@ -121,49 +65,36 @@ private BillingEvent(BillingEventId id, TenantId tenantId) : base(id) public SuspensionReason? SuspensionReason { get; private set; } - public string StripeReference { get; private set; } - public TenantId TenantId { get; } public static BillingEvent Create( - SubscriptionId subscriptionId, TenantId tenantId, + SubscriptionId subscriptionId, + string stripeEventId, BillingEventType eventType, DateTimeOffset occurredAt, - string stripeReference, + decimal committedMrr, SubscriptionPlan? fromPlan = null, SubscriptionPlan? toPlan = null, decimal? previousAmount = null, decimal? newAmount = null, decimal? amountDelta = null, string? currency = null, - int? daysOnPreviousPlan = null, - int? daysUntilEffective = null, - int? daysSinceCancelled = null, - DateTimeOffset? scheduledFor = null, - DateTimeOffset? effectiveAt = null, CancellationReason? cancellationReason = null, SuspensionReason? suspensionReason = null ) { - var id = BillingEventId.FromComponents(subscriptionId, eventType, stripeReference, occurredAt); - return new BillingEvent(id, tenantId) + return new BillingEvent(tenantId, subscriptionId, stripeEventId) { - SubscriptionId = subscriptionId, EventType = eventType, OccurredAt = occurredAt, - StripeReference = stripeReference, + CommittedMrr = committedMrr, FromPlan = fromPlan, ToPlan = toPlan, PreviousAmount = previousAmount, NewAmount = newAmount, AmountDelta = amountDelta, Currency = currency, - DaysOnPreviousPlan = daysOnPreviousPlan, - DaysUntilEffective = daysUntilEffective, - DaysSinceCancelled = daysSinceCancelled, - ScheduledFor = scheduledFor, - EffectiveAt = effectiveAt, CancellationReason = cancellationReason, SuspensionReason = suspensionReason }; @@ -190,5 +121,21 @@ public enum BillingEventType PaymentRefunded, BillingInfoAdded, BillingInfoUpdated, - PaymentMethodUpdated + 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 index 2fe831601b..32b02de69a 100644 --- a/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs @@ -9,14 +9,16 @@ public sealed class BillingEventConfiguration : IEntityTypeConfiguration builder) { - builder.MapStronglyTypedString(e => e.Id); + 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 index df426fe492..3e98a01eab 100644 --- a/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs @@ -9,13 +9,20 @@ namespace Account.Features.Subscriptions.Domain; public interface IBillingEventRepository : IAppendRepository { /// - /// Returns every billing event for a subscription. Used by the drift detector to compare the - /// stored append-only log against expected events computed from Stripe history. Bypasses the - /// tenant query filter because the drift detector and webhook pipeline both run without an - /// authenticated tenant context. + /// 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. @@ -32,12 +39,19 @@ public interface IBillingEventRepository : IAppendRepository SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken); /// - /// Returns every billing event with a populated NewAmount across all tenants — the events that - /// change committed MRR. Used by the dashboard MRR-trend computation to reconstruct historical - /// MRR per subscription. Bypasses the tenant query filter because the back-office is cross-tenant - /// by design. + /// 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) @@ -51,11 +65,25 @@ public async Task GetBySubscriptionIdUnfilteredAsync(Subscriptio .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). - var events = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + // 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(); } @@ -63,18 +91,30 @@ public async Task GetMrrChangeEventsUnfilteredAsync(Cancellation { return await DbSet .IgnoreQueryFilters([QueryFilterNames.Tenant]) - .Where(e => e.NewAmount != null) + .Where(e => e.AmountDelta != null) .ToArrayAsync(cancellationToken); } - public async Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken) + public async Task> GetSubscriptionIdsWithEventsUnfilteredAsync(SubscriptionId[] subscriptionIds, CancellationToken cancellationToken) { - var queryable = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]); + if (subscriptionIds.Length == 0) return []; - if (eventTypes.Length > 0) - { - queryable = queryable.Where(e => eventTypes.AsEnumerable().Contains(e.EventType)); - } + 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); diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index 7e21ead8db..9699ccf781 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -228,7 +228,16 @@ public enum DriftDiscrepancyKind MissingEvent, ExtraEvent, FieldDisagree, - SubscriptionStateMismatch + 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 } [PublicAPI] diff --git a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs index 9c6ca7d3b4..3d679b30d0 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -10,9 +10,11 @@ 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, syncs current state from Stripe, then writes the new +/// BillingEvent rows by replaying the customer's full stripe_events history. The unique +/// stripe_event_id index on billing_events makes the replay idempotent: redelivered webhooks and +/// re-pulls from the Stripe events API are no-ops. /// public sealed class ProcessPendingStripeEvents( AccountDbContext dbContext, @@ -27,8 +29,6 @@ public sealed class ProcessPendingStripeEvents( ILogger logger ) { - private int _eventsAppendedInCurrentSync; - public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) { return ExecuteAsync(stripeCustomerId, false, cancellationToken); @@ -36,7 +36,6 @@ public Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationToken ca public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync, CancellationToken cancellationToken) { - _eventsAppendedInCurrentSync = 0; // Pessimistic lock serializes concurrent webhook processing for the same customer var isSqlite = dbContext.Database.ProviderName == "Microsoft.EntityFrameworkCore.Sqlite"; await using var transaction = isSqlite @@ -58,6 +57,7 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync if (pendingEvents.Length > 0 || forceSync) { await SyncStateFromStripe(tenant, subscription, cancellationToken); + await ReplayStripeEventsAsync(subscription, cancellationToken); MarkAllEventsAsProcessed(pendingEvents, subscription); } @@ -70,7 +70,6 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, bool forceSync private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, CancellationToken cancellationToken) { - // Fetch current state from Stripe var stripeClient = stripeClientFactory.GetClient(); var customerResult = await stripeClient.GetCustomerBillingInfoAsync(subscription.StripeCustomerId!, cancellationToken); @@ -93,19 +92,14 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, tenantRepository.Update(tenant); subscriptionRepository.Update(subscription); events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.CustomerDeleted, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionSuspended, nowAtCustomerDeleted, subscription.Id.Value, - previousPlan, SubscriptionPlan.Basis, - previousPriceAmount, amountDelta: -previousPriceAmount, - currency: previousPriceCurrency, suspensionReason: SuspensionReason.CustomerDeleted - ), cancellationToken - ); return; } var stripeState = await stripeClient.SyncSubscriptionStateAsync(subscription.StripeCustomerId!, cancellationToken); - // Detect state transitions in lifecycle order (variables and if-blocks below follow the same order) + // 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 ReplayStripeEventsAsync running over the customer's stripe_events. 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; @@ -127,96 +121,52 @@ await AppendBillingEventAsync(BillingEvent.Create( 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, now); 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) { subscription.SetPaymentTransactions([.. syncedTransactions]); } - if (!subscriptionCreated) - { - await BackfillLegacyBillingEventsAsync(subscription, cancellationToken); - } - var paymentRefunded = subscription.PaymentTransactions.Count(t => t.Status == PaymentTransactionStatus.Refunded) > previousRefundCount; if (billingInfoAdded) { subscription.SetBillingInfo(customerResult.BillingInfo); events.CollectEvent(new BillingInfoAdded(subscription.Id, customerResult.BillingInfo?.Address?.Country, customerResult.BillingInfo?.Address?.PostalCode, customerResult.BillingInfo?.Address?.City)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.BillingInfoAdded, now, subscription.Id.Value - ), cancellationToken - ); } if (billingInfoUpdated) { subscription.SetBillingInfo(customerResult.BillingInfo); events.CollectEvent(new BillingInfoUpdated(subscription.Id, customerResult.BillingInfo?.Address?.Country, customerResult.BillingInfo?.Address?.PostalCode, customerResult.BillingInfo?.Address?.City)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.BillingInfoUpdated, now, subscription.Id.Value - ), cancellationToken - ); } if (paymentMethodUpdated) { subscription.SetPaymentMethod(latestPaymentMethod); events.CollectEvent(new PaymentMethodUpdated(subscription.Id)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.PaymentMethodUpdated, now, subscription.Id.Value - ), cancellationToken - ); } if (subscriptionCreated) { tenant.Activate(); events.CollectEvent(new SubscriptionCreated(subscription.Id, subscription.Plan, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionCreated, now, stripeState!.StripeSubscriptionId!.Value, - toPlan: subscription.Plan, - newAmount: subscription.CurrentPriceAmount, amountDelta: subscription.CurrentPriceAmount, - currency: subscription.CurrentPriceCurrency - ), cancellationToken - ); } if (subscriptionRenewed) { events.CollectEvent(new SubscriptionRenewed(subscription.Id, subscription.Plan, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value - previousPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionRenewed, now, $"{subscription.Id.Value}|{stripeState?.CurrentPeriodEnd:O}", - toPlan: subscription.Plan, - previousAmount: previousPriceAmount, newAmount: subscription.CurrentPriceAmount, - amountDelta: subscription.CurrentPriceAmount!.Value - previousPriceAmount.Value, - currency: subscription.CurrentPriceCurrency, - effectiveAt: stripeState?.CurrentPeriodEnd - ), cancellationToken - ); } if (subscriptionUpgraded) { events.CollectEvent(new SubscriptionUpgraded(subscription.Id, previousPlan, subscription.Plan, daysOnCurrentPlan, previousPriceAmount!.Value, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value - previousPriceAmount.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionUpgraded, now, subscription.Id.Value, - previousPlan, subscription.Plan, - previousPriceAmount, subscription.CurrentPriceAmount, - subscription.CurrentPriceAmount!.Value - previousPriceAmount.Value, - subscription.CurrentPriceCurrency, - daysOnCurrentPlan - ), cancellationToken - ); } if (downgradeScheduled) @@ -226,16 +176,6 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.SetScheduledPlan(stripeState!.ScheduledPlan, scheduledPlanPrice); var daysUntilDowngrade = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; events.CollectEvent(new SubscriptionDowngradeScheduled(subscription.Id, subscription.Plan, subscription.ScheduledPlan!.Value, daysUntilDowngrade, subscription.CurrentPriceAmount!.Value, scheduledPlanPrice - subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionDowngradeScheduled, now, subscription.Id.Value, - subscription.Plan, subscription.ScheduledPlan, - subscription.CurrentPriceAmount, scheduledPlanPrice, - scheduledPlanPrice - subscription.CurrentPriceAmount!.Value, - subscription.CurrentPriceCurrency, - daysUntilEffective: daysUntilDowngrade, - scheduledFor: subscription.CurrentPeriodEnd - ), cancellationToken - ); } if (downgradeCancelled) @@ -247,30 +187,12 @@ await AppendBillingEventAsync(BillingEvent.Create( 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!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionDowngradeCancelled, now, subscription.Id.Value, - subscription.Plan, previousScheduledPlan, - scheduledPlanPrice, subscription.CurrentPriceAmount, - subscription.CurrentPriceAmount!.Value - scheduledPlanPrice, - subscription.CurrentPriceCurrency, - daysUntilEffective: daysUntilDowngrade - ), cancellationToken - ); } if (subscriptionDowngraded) { 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!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionDowngraded, now, subscription.Id.Value, - previousPlan, subscription.Plan, - previousPriceAmount, subscription.CurrentPriceAmount, - subscription.CurrentPriceAmount!.Value - previousPriceAmount.Value, - subscription.CurrentPriceCurrency, - daysOnCurrentPlan - ), cancellationToken - ); } if (subscriptionCancelled) @@ -278,16 +200,6 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.SetCancellation(stripeState!.CancelAtPeriodEnd, stripeState.CancellationReason, stripeState.CancellationFeedback); var daysUntilExpiry = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; events.CollectEvent(new SubscriptionCancelled(subscription.Id, subscription.Plan, subscription.CancellationReason ?? CancellationReason.CancelledByAdmin, daysUntilExpiry, daysOnCurrentPlan, subscription.CurrentPriceAmount!.Value, -subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionCancelled, now, subscription.Id.Value, - subscription.Plan, - previousAmount: subscription.CurrentPriceAmount, newAmount: 0m, amountDelta: -subscription.CurrentPriceAmount, - currency: subscription.CurrentPriceCurrency, - daysOnPreviousPlan: daysOnCurrentPlan, daysUntilEffective: daysUntilExpiry, - effectiveAt: subscription.CurrentPeriodEnd, - cancellationReason: subscription.CancellationReason - ), cancellationToken - ); } if (subscriptionReactivated) @@ -296,14 +208,6 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.SetCancellation(stripeState!.CancelAtPeriodEnd, stripeState.CancellationReason, stripeState.CancellationFeedback); var daysUntilExpiry = subscription.CurrentPeriodEnd is not null ? (int)(subscription.CurrentPeriodEnd.Value - now).TotalDays : (int?)null; events.CollectEvent(new SubscriptionReactivated(subscription.Id, subscription.Plan, daysUntilExpiry, daysSinceCancelled, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionReactivated, now, subscription.Id.Value, - toPlan: subscription.Plan, - newAmount: subscription.CurrentPriceAmount, amountDelta: subscription.CurrentPriceAmount, - currency: subscription.CurrentPriceCurrency, - daysSinceCancelled: daysSinceCancelled, daysUntilEffective: daysUntilExpiry - ), cancellationToken - ); } if (subscriptionExpired) @@ -311,14 +215,6 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.ResetToFreePlan(); tenant.UpdatePlan(SubscriptionPlan.Basis); events.CollectEvent(new SubscriptionExpired(subscription.Id, previousPlan, daysOnCurrentPlan, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionExpired, now, subscription.Id.Value, - previousPlan, SubscriptionPlan.Basis, - previousPriceAmount, amountDelta: -previousPriceAmount, - currency: previousPriceCurrency, - daysOnPreviousPlan: daysOnCurrentPlan - ), cancellationToken - ); } if (subscriptionImmediatelyCancelled) @@ -326,15 +222,6 @@ await AppendBillingEventAsync(BillingEvent.Create( subscription.ResetToFreePlan(); tenant.UpdatePlan(SubscriptionPlan.Basis); events.CollectEvent(new SubscriptionCancelled(subscription.Id, previousPlan, CancellationReason.CancelledByAdmin, 0, daysOnCurrentPlan, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionImmediatelyCancelled, now, subscription.Id.Value, - previousPlan, SubscriptionPlan.Basis, - previousPriceAmount, amountDelta: -previousPriceAmount, - currency: previousPriceCurrency, - daysOnPreviousPlan: daysOnCurrentPlan, - cancellationReason: CancellationReason.CancelledByAdmin - ), cancellationToken - ); } if (subscriptionSuspended) @@ -343,27 +230,12 @@ await AppendBillingEventAsync(BillingEvent.Create( tenant.UpdatePlan(SubscriptionPlan.Basis); tenant.Suspend(SuspensionReason.PaymentFailed, now); events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.PaymentFailed, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionSuspended, now, subscription.Id.Value, - previousPlan, SubscriptionPlan.Basis, - previousPriceAmount, amountDelta: -previousPriceAmount, - currency: previousPriceCurrency, - suspensionReason: SuspensionReason.PaymentFailed - ), cancellationToken - ); } if (paymentFailed) { subscription.SetPaymentFailed(now); events.CollectEvent(new PaymentFailed(subscription.Id, subscription.Plan, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.PaymentFailed, now, subscription.Id.Value, - toPlan: subscription.Plan, - newAmount: subscription.CurrentPriceAmount, - currency: subscription.CurrentPriceCurrency - ), cancellationToken - ); } if (paymentRecovered) @@ -371,14 +243,6 @@ await AppendBillingEventAsync(BillingEvent.Create( var daysInPastDue = (int)(now - subscription.FirstPaymentFailedAt!.Value).TotalDays; subscription.ClearPaymentFailure(); events.CollectEvent(new PaymentRecovered(subscription.Id, subscription.Plan, daysInPastDue, subscription.CurrentPriceAmount!.Value, subscription.CurrentPriceCurrency!)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.PaymentRecovered, now, subscription.Id.Value, - toPlan: subscription.Plan, - newAmount: subscription.CurrentPriceAmount, - currency: subscription.CurrentPriceCurrency, - daysOnPreviousPlan: daysInPastDue - ), cancellationToken - ); } if (paymentRefunded) @@ -388,93 +252,87 @@ await AppendBillingEventAsync(BillingEvent.Create( var latestRefund = refundedTransactions[^1]; var plan = stripeState is not null ? subscription.Plan : previousPlan; events.CollectEvent(new PaymentRefunded(subscription.Id, plan, refundCount, latestRefund.Amount, latestRefund.Currency)); - await AppendBillingEventAsync(BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.PaymentRefunded, latestRefund.Date, latestRefund.Id.Value, - toPlan: plan, - newAmount: latestRefund.Amount, amountDelta: -latestRefund.Amount, - currency: latestRefund.Currency - ), cancellationToken - ); } - // Persist all aggregate mutations and mark pending events as processed var tenantChanged = stripeState is not null || subscriptionCreated || subscriptionExpired || subscriptionImmediatelyCancelled || subscriptionSuspended; if (tenantChanged) { tenantRepository.Update(tenant); } - // Inline drift detection. Compares the just-applied local subscription state against Stripe's - // authoritative state and records any discrepancies on the subscription. Wrapped in try/catch - // because drift detection is a safety net — a failure here must not block the sync itself. - try - { - var snapshot = new StripeSyncSnapshot( - subscription.Plan, - subscription.CancelAtPeriodEnd, - subscription.CurrentPriceAmount, - subscription.CurrentPriceCurrency - ); - // The snapshot is built from the just-synced local state, so the SubscriptionStateMismatch - // category finds zero discrepancies today. The seam stays in place so adding a Stripe-derived - // snapshot (full invoice/charge history) for per-event comparison becomes a localized change - // to this block plus the detector itself. - var persistedBillingEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); - // Add the count appended during this sync — they are tracked in the DbContext but not yet - // flushed to the database, so the query above wouldn't otherwise see them. - var totalBillingEvents = persistedBillingEvents.Length + _eventsAppendedInCurrentSync; - var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, totalBillingEvents); - subscription.SetDriftStatus(discrepancies, now); - } - catch (Exception ex) - { - logger.LogWarning(ex, "Drift detection threw while syncing Stripe customer '{StripeCustomerId}', existing drift status preserved", subscription.StripeCustomerId); - } - subscriptionRepository.Update(subscription); } /// - /// Append-only write to the BillingEvent log. The deterministic ID makes webhook redeliveries - /// idempotent — if the same logical event was already recorded (the previous webhook attempt - /// committed before crashing, then redelivered), the existing row is the authoritative record - /// and we never overwrite it. + /// Replays the customer's Stripe events history (fetched directly from Stripe's events API, not + /// the local stripe_events webhook inbox) through and + /// appends any newly recognized rows to billing_events. Stripe is the source of truth here: + /// local webhooks may have gaps (missed deliveries, misconfiguration) and the API call closes them. + /// 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 Sync action) + /// is safe. + /// Drift detection runs at the end and incorporates any Unclassified events the replayer + /// flagged so the existing drift banner picks them up. /// - private async Task AppendBillingEventAsync(BillingEvent billingEvent, CancellationToken cancellationToken) - { - var existing = await billingEventRepository.GetByIdAsync(billingEvent.Id, cancellationToken); - if (existing is null) - { - await billingEventRepository.AddAsync(billingEvent, cancellationToken); - _eventsAppendedInCurrentSync++; - } - } - - /// - /// One-time backfill that replays every stored stripe_event for a customer into the BillingEvent - /// log. Used for subscriptions persisted before the BillingEvent log existed (the live webhook - /// path writes events directly during normal sync). Skipped when the subscription already has - /// BillingEvents — the live path is the authoritative source going forward. - /// - private async Task BackfillLegacyBillingEventsAsync(Subscription subscription, CancellationToken cancellationToken) + private async Task ReplayStripeEventsAsync(Subscription subscription, CancellationToken cancellationToken) { if (subscription.StripeCustomerId is null) return; - var existingEvents = await billingEventRepository.GetBySubscriptionIdUnfilteredAsync(subscription.Id, cancellationToken); - if (existingEvents.Length > 0) return; - - var stripeEvents = await stripeEventRepository.GetProcessedByStripeCustomerIdAsync(subscription.StripeCustomerId, cancellationToken); - if (stripeEvents.Length == 0) return; - var stripeClient = stripeClientFactory.GetClient(); + var stripeEvents = await stripeClient.GetEventsForCustomerAsync(subscription.StripeCustomerId, cancellationToken); + if (stripeEvents.Length == 0) + { + DetectDrift(subscription, 0, false); + return; + } + var planByPriceId = await stripeClient.GetPlanByPriceIdAsync(cancellationToken); var priceCatalog = await stripeClient.GetPriceCatalogAsync(cancellationToken); var priceByPlan = priceCatalog.ToDictionary(p => p.Plan, p => p.UnitAmount); - var replayedEvents = StripeEventReplayer.Replay(subscription, stripeEvents, planByPriceId, priceByPlan); + var existingStripeEventIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + var state = new StripeEventReplayer.ReplayState(); + var replayedEvents = StripeEventReplayer.Replay(subscription, stripeEvents, planByPriceId, priceByPlan, state); + + var appendedCount = 0; foreach (var billingEvent in replayedEvents) { - await AppendBillingEventAsync(billingEvent, cancellationToken); + if (existingStripeEventIds.Contains(billingEvent.StripeEventId)) continue; + await billingEventRepository.AddAsync(billingEvent, cancellationToken); + appendedCount++; + } + + var totalBillingEvents = existingStripeEventIds.Count + appendedCount; + DetectDrift(subscription, totalBillingEvents, state.HasUnclassifiedEvent); + } + + private void DetectDrift(Subscription subscription, int billingEventCount, bool hasUnclassifiedEvent) + { + var now = timeProvider.GetUtcNow(); + try + { + var snapshot = new StripeSyncSnapshot( + subscription.Plan, + subscription.CancelAtPeriodEnd, + subscription.CurrentPriceAmount, + subscription.CurrentPriceCurrency + ); + var discrepancies = BillingDriftDetector.Detect(subscription, snapshot, 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 + ) + ); + } + + subscription.SetDriftStatus(discrepancies, now); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Drift detection threw while syncing Stripe customer '{StripeCustomerId}', existing drift status preserved", subscription.StripeCustomerId); } } @@ -495,7 +353,6 @@ 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(); diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs index d8c1ddd22d..cc82181fd3 100644 --- a/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs @@ -1,50 +1,52 @@ 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; /// -/// Replays a customer's stored stripe_events into the BillingEvent log. Used as a one-time backfill -/// for subscriptions persisted before the BillingEvent log existed (the live webhook path writes -/// events directly via ). -/// The replayer is a state machine: it iterates events chronologically and tracks the running -/// subscription state (current plan, current price, scheduled-downgrade plan/price, cancel-at- -/// period-end flag, committed forward MRR). Each event reads its plan label and amounts from the -/// state at that point in time — never from subscription.CurrentPriceAmount or -/// subscription.Plan — so historical rows reflect the truth at the time they happened. After -/// processing each event the state advances. The "MRR after" of one row equals the "MRR before" -/// of the next, making the BillingEvent log a faithful audit trail. -/// Deterministic IDs (derived from the stripe_event Id and BillingEventType) keep the operation -/// idempotent across repeated syncs. +/// Maps Stripe events into BillingEvent rows under a strict 1:1 invariant: every recognized +/// subscription-relevant Stripe event yields exactly one row. +/// 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, - StripeEvent[] stripeEvents, + StripeReplayEvent[] stripeEvents, IReadOnlyDictionary planByPriceId, - IReadOnlyDictionary priceByPlan + IReadOnlyDictionary priceByPlan, + ReplayState? state = null ) { var emitted = new List(); - var state = new ReplayState(); + state ??= new ReplayState(); var currency = subscription.CurrentPriceCurrency ?? "USD"; - foreach (var stripeEvent in stripeEvents) + foreach (var stripeEvent in stripeEvents.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)) { - var occurredAt = stripeEvent.CreatedAt; - var stripeReference = stripeEvent.Id.Value; - var billingEvents = MapEvent(stripeEvent, occurredAt, stripeReference, subscription, state, planByPriceId, priceByPlan, currency); - emitted.AddRange(billingEvents); + var billingEvent = MapEvent(stripeEvent, subscription, state, planByPriceId, priceByPlan, currency); + if (billingEvent is not null) + { + emitted.Add(billingEvent); + } } return emitted; } - private static IEnumerable MapEvent( - StripeEvent stripeEvent, - DateTimeOffset occurredAt, - string stripeReference, + private static BillingEvent? MapEvent( + StripeReplayEvent stripeEvent, Subscription subscription, ReplayState state, IReadOnlyDictionary planByPriceId, @@ -54,217 +56,179 @@ string currency { var subscriptionId = subscription.Id; var tenantId = subscription.TenantId; + var occurredAt = stripeEvent.CreatedAt; + var stripeEventId = stripeEvent.EventId; var payload = ParsePayload(stripeEvent.Payload); switch (stripeEvent.EventType) { case "customer.created": - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.BillingInfoAdded, occurredAt, stripeReference + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.BillingInfoAdded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency ); - break; case "customer.updated": - if (HasBillingFieldsChanged(payload)) - { - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.BillingInfoUpdated, occurredAt, stripeReference - ); - } - - break; + 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": - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.PaymentMethodUpdated, occurredAt, stripeReference + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentMethodUpdated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency ); - break; case "customer.subscription.created": - { - var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId) ?? subscription.Plan; - var newPrice = priceByPlan.TryGetValue(newPlan, out var p) ? p : 0m; - var previousMrr = state.CommittedMrr; - state.Plan = newPlan; - state.PlanPrice = newPrice; - state.CancelAtPeriodEnd = false; - state.ScheduledPlan = null; - state.CommittedMrr = newPrice; - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.SubscriptionCreated, occurredAt, stripeReference, - toPlan: newPlan, - previousAmount: previousMrr, newAmount: newPrice, - amountDelta: newPrice - previousMrr, - currency: currency - ); - break; - } + return MapSubscriptionCreated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency, subscription.Plan); case "customer.subscription.updated": - foreach (var billingEvent in MapSubscriptionUpdated(payload, occurredAt, stripeReference, subscription, state, planByPriceId, priceByPlan, currency)) - { - yield return billingEvent; - } + return MapSubscriptionUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency, subscription.CancellationReason); - break; - - // customer.subscription.pending_update_applied fires alongside customer.subscription.updated - // for the same upgrade transition. The updated event carries previous_attributes (the from-plan) - // so it is the higher-fidelity source — we skip pending_update_applied to avoid emitting two - // SubscriptionUpgraded rows for the same logical event. + // 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": - { - var eventType = MapSubscriptionDeleted(payload); - var previousMrr = state.CommittedMrr; - var fromPlan = state.Plan; - state.Plan = null; - state.PlanPrice = 0m; - state.CancelAtPeriodEnd = false; - state.ScheduledPlan = null; - state.CommittedMrr = 0m; - yield return BillingEvent.Create( - subscriptionId, tenantId, eventType, occurredAt, stripeReference, - fromPlan, SubscriptionPlan.Basis, - previousMrr, 0m, - -previousMrr, - currency - ); - break; - } + 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 subscription_schedule.updated event that - // fires immediately after. We skip created and let updated drive the DowngradeScheduled row. + // 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": - { - var scheduledPlan = ResolveScheduledTargetPlan(payload, planByPriceId, state.Plan); - if (scheduledPlan is null) break; - if (scheduledPlan == state.ScheduledPlan) break; - - var scheduledPrice = priceByPlan.TryGetValue(scheduledPlan.Value, out var sp) ? sp : 0m; - var previousMrr = state.CommittedMrr; - state.ScheduledPlan = scheduledPlan; - state.CommittedMrr = scheduledPrice; - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.SubscriptionDowngradeScheduled, occurredAt, stripeReference, - state.Plan, scheduledPlan, - previousMrr, scheduledPrice, - scheduledPrice - previousMrr, - currency - ); - break; - } + return MapScheduleUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency); case "subscription_schedule.released": case "subscription_schedule.canceled": - { - if (state.ScheduledPlan is null) break; - - var previousMrr = state.CommittedMrr; - var newMrr = state.PlanPrice; - state.ScheduledPlan = null; - state.CommittedMrr = newMrr; - var delta = newMrr - previousMrr; - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.SubscriptionDowngradeCancelled, occurredAt, stripeReference, - toPlan: state.Plan, - previousAmount: previousMrr, newAmount: newMrr, - amountDelta: delta == 0m ? null : delta, - currency: currency - ); - break; - } + return MapScheduleTerminated(occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); case "invoice.payment_succeeded": - { - // Only emit a Renewed 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. Renewals don't change - // committed MRR so amountDelta stays null. - var billingReason = ExtractInvoiceBillingReason(payload); - if (billingReason != "subscription_cycle") break; - - var eventType = HasMultiplePaymentAttempts(payload) ? BillingEventType.PaymentRecovered : BillingEventType.SubscriptionRenewed; - yield return BillingEvent.Create( - subscriptionId, tenantId, eventType, occurredAt, stripeReference, - toPlan: state.Plan, - newAmount: state.CommittedMrr, - currency: currency - ); - break; - } + return MapInvoicePaymentSucceeded(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); case "invoice.payment_failed": - // Skip 3DS challenges that succeed on first attempt (attempt_count == 1) — those produce a - // payment_failed event followed shortly by payment_succeeded. Only emit PaymentFailed when - // Stripe has retried (attempt_count > 1), which is a real persistent failure. Failures don't - // change committed MRR — the customer is still on the plan, just behind on payment. - if (HasMultiplePaymentAttempts(payload)) - { - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.PaymentFailed, occurredAt, stripeReference, - toPlan: state.Plan, - newAmount: state.CommittedMrr, - currency: currency - ); - } - - break; + return MapInvoicePaymentFailed(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); case "charge.refunded": - { - // A refund is a one-time cash event, not an MRR change going forward, so amountDelta is null. - var refundTransaction = FindClosestRefundedTransaction(subscription, occurredAt); - yield return BillingEvent.Create( - subscriptionId, tenantId, BillingEventType.PaymentRefunded, occurredAt, stripeReference, - toPlan: state.Plan, - newAmount: state.CommittedMrr, - currency: refundTransaction?.Currency ?? currency + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentRefunded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, newAmount: state.CommittedMrr, currency: currency ); - break; - } + + 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 IEnumerable MapSubscriptionUpdated( + private static BillingEvent MapSubscriptionCreated( JsonElement payload, DateTimeOffset occurredAt, - string stripeReference, - Subscription subscription, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, ReplayState state, IReadOnlyDictionary planByPriceId, IReadOnlyDictionary priceByPlan, - string currency + string currency, + SubscriptionPlan fallbackPlan ) { + var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId) ?? fallbackPlan; + var newPrice = priceByPlan.TryGetValue(newPlan, out var p) ? p : 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 prev) ? prev : default; - if (previous.ValueKind != JsonValueKind.Object) yield break; + if (previous.ValueKind != JsonValueKind.Object) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var cancelAtPeriodEndChanged = previous.TryGetProperty("cancel_at_period_end", out var prevCancel) && prevCancel.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 + ); + } - // Cancel-at-period-end toggle. 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 here. - if (previous.TryGetProperty("cancel_at_period_end", out var prevCancel) && prevCancel.ValueKind == JsonValueKind.False) + if (cancelAtPeriodEndChanged && prevCancel.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; - yield return BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionCancelled, occurredAt, stripeReference, + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionCancelled, occurredAt, state.CommittedMrr, toPlan: state.Plan, previousAmount: previousMrr, newAmount: 0m, amountDelta: -previousMrr, currency: currency, - cancellationReason: subscription.CancellationReason + cancellationReason: subscriptionCancellationReason ); } - else if (previous.TryGetProperty("cancel_at_period_end", out var prevCancelTrue) && prevCancelTrue.ValueKind == JsonValueKind.True) + + if (cancelAtPeriodEndChanged && prevCancel.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; - yield return BillingEvent.Create( - subscription.Id, subscription.TenantId, BillingEventType.SubscriptionReactivated, occurredAt, stripeReference, + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionReactivated, occurredAt, state.CommittedMrr, toPlan: state.Plan, previousAmount: previousMrr, newAmount: state.CommittedMrr, amountDelta: state.CommittedMrr - previousMrr, @@ -272,46 +236,238 @@ string currency ); } - // 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 newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId); - var previousPlan = ResolvePlanFromPreviousAttributes(previous, planByPriceId); - if (newPlan is not null && previousPlan is not null && newPlan != previousPlan) + if (planChanged) { - var eventType = newPlan.Value > previousPlan.Value ? BillingEventType.SubscriptionUpgraded : BillingEventType.SubscriptionDowngraded; + // 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 = priceByPlan.TryGetValue(newPlan.Value, out var np) ? np : 0m; state.Plan = newPlan; state.PlanPrice = newPrice; state.CommittedMrr = state.CancelAtPeriodEnd ? 0m : newPrice; - yield return BillingEvent.Create( - subscription.Id, subscription.TenantId, eventType, occurredAt, stripeReference, + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, previousPlan, newPlan, previousMrr, state.CommittedMrr, state.CommittedMrr - previousMrr, 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 BillingEventType MapSubscriptionDeleted(JsonElement payload) + private static BillingEvent MapSubscriptionDeleted( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) { - var data = payload.TryGetProperty("data", out var d) ? d : default; - var sub = data.TryGetProperty("object", out var obj) ? obj : default; - var status = sub.TryGetProperty("status", out var s) ? s.GetString() : null; - var cancelAtPeriodEnd = sub.TryGetProperty("cancel_at_period_end", out var cape) && cape.ValueKind == JsonValueKind.True; + var data = payload.ValueKind == JsonValueKind.Object && payload.TryGetProperty("data", out var d) ? d : default; + var sub = data.ValueKind == JsonValueKind.Object && data.TryGetProperty("object", out var obj) ? obj : default; + var status = sub.ValueKind == JsonValueKind.Object && sub.TryGetProperty("status", out var s) ? s.GetString() : null; + var cancelAtPeriodEnd = sub.ValueKind == JsonValueKind.Object && sub.TryGetProperty("cancel_at_period_end", out var cape) && cape.ValueKind == JsonValueKind.True; + + var eventType = status is "past_due" or "unpaid" or "incomplete_expired" + ? BillingEventType.SubscriptionSuspended + : cancelAtPeriodEnd + ? BillingEventType.SubscriptionExpired + : 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: eventType == BillingEventType.SubscriptionSuspended ? SuspensionReason.PaymentFailed : null + ); + } + + 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 = priceByPlan.TryGetValue(scheduledPlan.Value, out var sp) ? sp : 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); + } - if (status is "past_due" or "unpaid" or "incomplete_expired") return BillingEventType.SubscriptionSuspended; - if (cancelAtPeriodEnd) return BillingEventType.SubscriptionExpired; - return BillingEventType.SubscriptionImmediatelyCancelled; + 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 + ) + { + // Skip 3DS challenges that succeed on first attempt (attempt_count == 1) — those produce a + // payment_failed event followed shortly by payment_succeeded. Only emit PaymentFailed when Stripe + // has retried (attempt_count > 1), which is a real persistent failure. Failures don't change + // committed MRR — the customer is still on the plan, just behind on payment. + if (!HasMultiplePaymentAttempts(payload)) + { + 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 + ); + } + + 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 d) ? d : default; + if (data.ValueKind != JsonValueKind.Object) return null; var sub = data.TryGetProperty("object", out var obj) ? obj : default; + if (sub.ValueKind != JsonValueKind.Object) return null; var items = sub.TryGetProperty("items", out var i) ? i : default; + if (items.ValueKind != JsonValueKind.Object) return null; var itemsData = items.TryGetProperty("data", out var id) ? id : default; if (itemsData.ValueKind != JsonValueKind.Array) return null; foreach (var item in itemsData.EnumerateArray()) @@ -325,7 +481,8 @@ private static BillingEventType MapSubscriptionDeleted(JsonElement payload) private static SubscriptionPlan? ResolvePlanFromPreviousAttributes(JsonElement previousAttributes, IReadOnlyDictionary planByPriceId) { - if (!previousAttributes.TryGetProperty("items", out var items)) return null; + 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()) { @@ -344,8 +501,11 @@ private static BillingEventType MapSubscriptionDeleted(JsonElement payload) /// private static SubscriptionPlan? ResolveScheduledTargetPlan(JsonElement payload, IReadOnlyDictionary planByPriceId, SubscriptionPlan? currentPlan) { + if (payload.ValueKind != JsonValueKind.Object) return null; var data = payload.TryGetProperty("data", out var d) ? d : default; + if (data.ValueKind != JsonValueKind.Object) return null; var schedule = data.TryGetProperty("object", out var obj) ? obj : default; + if (schedule.ValueKind != JsonValueKind.Object) return null; var phases = schedule.TryGetProperty("phases", out var ph) ? ph : default; if (phases.ValueKind != JsonValueKind.Array) return null; @@ -368,9 +528,20 @@ private static BillingEventType MapSubscriptionDeleted(JsonElement payload) return lastPhasePlan; } + private static string? ResolveScheduleStatus(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var d) ? d : default; + if (data.ValueKind != JsonValueKind.Object) return null; + var schedule = data.TryGetProperty("object", out var obj) ? obj : default; + if (schedule.ValueKind != JsonValueKind.Object) return null; + return schedule.TryGetProperty("status", out var s) ? s.GetString() : null; + } + private static bool HasBillingFieldsChanged(JsonElement payload) { - var previous = payload.TryGetProperty("data", out var data) && data.TryGetProperty("previous_attributes", out var prev) ? prev : default; + 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 prev) ? prev : default; if (previous.ValueKind != JsonValueKind.Object) return false; return previous.TryGetProperty("address", out _) || previous.TryGetProperty("email", out _) @@ -380,27 +551,25 @@ private static bool HasBillingFieldsChanged(JsonElement payload) private static string? ExtractInvoiceBillingReason(JsonElement payload) { + if (payload.ValueKind != JsonValueKind.Object) return null; var data = payload.TryGetProperty("data", out var d) ? d : default; + if (data.ValueKind != JsonValueKind.Object) return null; var invoice = data.TryGetProperty("object", out var obj) ? obj : default; + if (invoice.ValueKind != JsonValueKind.Object) return null; return invoice.TryGetProperty("billing_reason", out var br) ? br.GetString() : null; } private static bool HasMultiplePaymentAttempts(JsonElement payload) { + if (payload.ValueKind != JsonValueKind.Object) return false; var data = payload.TryGetProperty("data", out var d) ? d : default; + if (data.ValueKind != JsonValueKind.Object) return false; var invoice = data.TryGetProperty("object", out var obj) ? obj : default; + if (invoice.ValueKind != JsonValueKind.Object) return false; var attemptCount = invoice.TryGetProperty("attempt_count", out var ac) && ac.ValueKind == JsonValueKind.Number ? ac.GetInt32() : 0; return attemptCount > 1; } - private static PaymentTransaction? FindClosestRefundedTransaction(Subscription subscription, DateTimeOffset occurredAt) - { - return subscription.PaymentTransactions - .Where(t => t.Status == PaymentTransactionStatus.Refunded) - .OrderBy(t => Math.Abs((t.Date - occurredAt).TotalSeconds)) - .FirstOrDefault(); - } - private static JsonElement ParsePayload(string? rawPayload) { if (string.IsNullOrWhiteSpace(rawPayload)) return default; @@ -415,7 +584,7 @@ private static JsonElement ParsePayload(string? rawPayload) } } - private sealed class ReplayState + public sealed class ReplayState { public SubscriptionPlan? Plan { get; set; } @@ -426,5 +595,12 @@ private sealed class ReplayState 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/Tenants/BackOffice/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs index 7999185f08..d154f4d983 100644 --- a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -13,6 +13,8 @@ public sealed record GetTenantsQuery( string? Search = null, SubscriptionPlan[]? Plans = null, TenantStatusFilter[]? Statuses = null, + bool Unsynced = false, + bool DriftDetected = false, SortableTenantProperties OrderBy = SortableTenantProperties.Name, SortOrder SortOrder = SortOrder.Ascending, int PageOffset = 0, @@ -89,7 +91,7 @@ public GetTenantsQueryValidator() } } -public sealed class GetTenantsHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository) +public sealed class GetTenantsHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, IBillingEventRepository billingEventRepository) : IRequestHandler> { public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) @@ -102,6 +104,27 @@ public async Task> Handle(GetTenantsQuery query, Cancell : 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 summaries = tenants.Select(tenant => MapTenantSummary(tenant, subscriptionsByTenantId.GetValueOrDefault(tenant.Id))).ToArray(); if (query.Statuses.Length > 0) diff --git a/application/account/Core/Integrations/Stripe/IStripeClient.cs b/application/account/Core/Integrations/Stripe/IStripeClient.cs index f09261f0cf..e40f28cfdc 100644 --- a/application/account/Core/Integrations/Stripe/IStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/IStripeClient.cs @@ -53,8 +53,18 @@ public interface IStripeClient Task CreateSubscriptionWithSavedPaymentMethodAsync(StripeCustomerId stripeCustomerId, SubscriptionPlan plan, CancellationToken cancellationToken); Task SyncPaymentTransactionsAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns Stripe events related to a customer, ordered by creation time. Used by the BillingEvent + /// writer to enforce the strict 1:1 invariant: every recognized Stripe event yields exactly one + /// billing_events row. Stripe's events API is the source of truth — local stripe_events + /// rows are only a webhook inbox/queue and may have gaps that this method fills in. + /// + Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); } +public sealed record StripeReplayEvent(string EventId, string EventType, DateTimeOffset CreatedAt, string Payload); + public sealed record StripeWebhookEventResult( string EventId, string EventType, diff --git a/application/account/Core/Integrations/Stripe/MockStripeClient.cs b/application/account/Core/Integrations/Stripe/MockStripeClient.cs index 45bfb48374..25384c9207 100644 --- a/application/account/Core/Integrations/Stripe/MockStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/MockStripeClient.cs @@ -23,6 +23,10 @@ public sealed class MockStripeClient(IConfiguration configuration, TimeProvider public const string MockInvoiceUrl = "https://mock.stripe.local/invoice/12345"; public const string MockWebhookEventId = "evt_mock_12345"; + public const string MockSubscriptionCreatedEventId = "evt_mock_subscription_created"; + public const string MockPaymentFailedEventId = "evt_mock_payment_failed"; + public const string MockCustomerDeletedEventId = "evt_mock_customer_deleted"; + private readonly bool _isEnabled = configuration.GetValue("Stripe:AllowMockProvider"); public Task CreateCustomerAsync(string tenantName, string email, long tenantId, CancellationToken cancellationToken) @@ -277,6 +281,41 @@ public Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId stripeCu ); } + public Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + EnsureEnabled(); + var now = timeProvider.GetUtcNow(); + var events = new List + { + // Default timeline always starts with a subscription.created event mirroring the mock's + // SyncSubscriptionStateAsync result (Standard plan on price_mock_standard). + new( + MockSubscriptionCreatedEventId, + "customer.subscription.created", + now.AddMinutes(-5), + """{"data":{"object":{"items":{"data":[{"price":{"id":"price_mock_standard"}}]}}}}""" + ) + }; + + if (state.OverrideSubscriptionStatus == StripeSubscriptionStatus.PastDue) + { + events.Add(new StripeReplayEvent( + MockPaymentFailedEventId, + "invoice.payment_failed", + now.AddMinutes(-1), + """{"data":{"object":{"attempt_count":2,"billing_reason":"subscription_cycle"}}}""" + ) + ); + } + + if (state.SimulateCustomerDeleted) + { + events.Add(new StripeReplayEvent(MockCustomerDeletedEventId, "customer.deleted", now, "{}")); + } + + return Task.FromResult(events.ToArray()); + } + private void EnsureEnabled() { if (!_isEnabled) diff --git a/application/account/Core/Integrations/Stripe/StripeClient.cs b/application/account/Core/Integrations/Stripe/StripeClient.cs index d4cace7708..890d426f96 100644 --- a/application/account/Core/Integrations/Stripe/StripeClient.cs +++ b/application/account/Core/Integrations/Stripe/StripeClient.cs @@ -19,6 +19,24 @@ public sealed class StripeClient(IConfiguration configuration, IMemoryCache memo 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"]; @@ -1052,6 +1070,32 @@ public async Task> GetPlanByPriceI return await BuildPlanByPriceIdAsync(cancellationToken); } + public async Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + try + { + var service = new EventService(); + var options = new EventListOptions + { + Limit = 100, + Types = [.. ReplayEventTypes] + }; + var collected = new List(); + 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())); + } + + return [.. collected]; + } + catch (StripeException ex) + { + logger.LogError(ex, "Failed to list Stripe events for customer '{StripeCustomerId}'", stripeCustomerId); + return []; + } + } + /// /// 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 @@ -1282,4 +1326,19 @@ private static PaymentTransactionStatus MapInvoiceStatus(string? status, long am _ => 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, + global::Stripe.PaymentMethod paymentMethod => paymentMethod.CustomerId, + _ => null + }; + } } diff --git a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs index 448543f4d3..7620962020 100644 --- a/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/UnconfiguredStripeClient.cs @@ -153,4 +153,10 @@ 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, CancellationToken cancellationToken) + { + logger.LogWarning("Stripe is not configured. Cannot list events for customer '{CustomerId}'", stripeCustomerId); + return Task.FromResult([]); + } } diff --git a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index 36c21bf386..03e0a80e00 100644 --- a/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs +++ b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs @@ -30,6 +30,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; @@ -114,8 +116,8 @@ public void Dispose() // 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