diff --git a/application/account/Api/Endpoints/BackOfficeEndpoints.cs b/application/account/Api/BackOffice/BackOfficeEndpoints.cs similarity index 94% rename from application/account/Api/Endpoints/BackOfficeEndpoints.cs rename to application/account/Api/BackOffice/BackOfficeEndpoints.cs index 9ac59aebbe..fb7c6e96b3 100644 --- a/application/account/Api/Endpoints/BackOfficeEndpoints.cs +++ b/application/account/Api/BackOffice/BackOfficeEndpoints.cs @@ -5,7 +5,7 @@ using SharedKernel.Endpoints; using SharedKernel.OpenApi; -namespace Account.Api.Endpoints; +namespace Account.Api.BackOffice; public sealed class BackOfficeEndpoints : IEndpoints { @@ -14,7 +14,7 @@ public sealed class BackOfficeEndpoints : IEndpoints public void MapEndpoints(IEndpointRouteBuilder routes) { // BackOffice:Host is required (validated at startup via ValidateOnStart in - // ApiDependencyConfiguration.AddBackOfficeHostOptions). PP-1149 must keep that validation in place + // ApiDependencyConfiguration.AddBackOfficeHostOptions). The startup validation must stay in place // so a missing/blank value fails loudly rather than silently 404-ing back-office endpoints. var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; diff --git a/application/account/Api/BackOffice/BillingDriftEndpoints.cs b/application/account/Api/BackOffice/BillingDriftEndpoints.cs new file mode 100644 index 0000000000..ab0d17e907 --- /dev/null +++ b/application/account/Api/BackOffice/BillingDriftEndpoints.cs @@ -0,0 +1,37 @@ +using Account.Features.BackOffice.BillingDrift.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class BillingDriftEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/billing-drift"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeBillingDrift") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/summary", async Task> ([AsParameters] GetBillingDriftSummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/unsynced-summary", async Task> ([AsParameters] GetUnsyncedSubscriptionsSummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/mrr-consistency-summary", async Task> ([AsParameters] GetDashboardMrrConsistencySummaryQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/BillingEventsEndpoints.cs b/application/account/Api/BackOffice/BillingEventsEndpoints.cs new file mode 100644 index 0000000000..1270907ad5 --- /dev/null +++ b/application/account/Api/BackOffice/BillingEventsEndpoints.cs @@ -0,0 +1,29 @@ +using Account.Features.BackOffice.BillingEvents.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class BillingEventsEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/billing-events"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeBillingEvents") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetBackOfficeBillingEventsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/DashboardEndpoints.cs b/application/account/Api/BackOffice/DashboardEndpoints.cs new file mode 100644 index 0000000000..9dafb506d2 --- /dev/null +++ b/application/account/Api/BackOffice/DashboardEndpoints.cs @@ -0,0 +1,49 @@ +using Account.Features.BackOffice.Dashboard.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class DashboardEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/dashboard"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeDashboard") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/kpis", async Task> ([AsParameters] GetDashboardKpisQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/trends", async Task> ([AsParameters] GetDashboardTrendsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/mrr-trend", async Task> ([AsParameters] GetDashboardMrrTrendQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/plan-distribution", async Task> ([AsParameters] GetDashboardPlanDistributionQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-signups", async Task> ([AsParameters] GetDashboardRecentSignupsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/recent-stripe-events", async Task> ([AsParameters] GetDashboardRecentStripeEventsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + } +} diff --git a/application/account/Api/BackOffice/TenantsEndpoints.cs b/application/account/Api/BackOffice/TenantsEndpoints.cs new file mode 100644 index 0000000000..15c216e8bf --- /dev/null +++ b/application/account/Api/BackOffice/TenantsEndpoints.cs @@ -0,0 +1,59 @@ +using Account.Features.Tenants.BackOffice.Commands; +using Account.Features.Tenants.BackOffice.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Domain; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class TenantsEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/tenants"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeTenants") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetTenantsQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/{id}", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantDetailQuery(id)) + ).Produces(); + + group.MapGet("/{id}/user-counts", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantUserCountsQuery(id)) + ).Produces(); + + group.MapGet("/{id}/users", async Task> (TenantId id, [AsParameters] GetTenantUsersQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapGet("/{id}/activity", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new GetTenantActivityQuery(id)) + ).Produces(); + + group.MapGet("/{id}/payment-history", async Task> (TenantId id, [AsParameters] GetTenantPaymentHistoryQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapPost("/{id}/sync-with-stripe", async Task> (TenantId id, IMediator mediator) + => await mediator.Send(new SyncTenantWithStripeCommand { TenantId = id }) + ).Produces().RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + + group.MapPost("/{id}/drift/acknowledge", async Task (TenantId id, IMediator mediator) + => await mediator.Send(new AcknowledgeBillingDriftCommand { TenantId = id }) + ).RequireAuthorization(BackOfficeIdentityDefaults.AdminPolicyName); + } +} diff --git a/application/account/Api/BackOffice/UsersEndpoints.cs b/application/account/Api/BackOffice/UsersEndpoints.cs new file mode 100644 index 0000000000..f2f2b69288 --- /dev/null +++ b/application/account/Api/BackOffice/UsersEndpoints.cs @@ -0,0 +1,42 @@ +using Account.Features.Users.BackOffice.Queries; +using Microsoft.Extensions.Options; +using SharedKernel.ApiResults; +using SharedKernel.Authentication.BackOfficeIdentity; +using SharedKernel.Domain; +using SharedKernel.Endpoints; +using SharedKernel.OpenApi; + +namespace Account.Api.BackOffice; + +public sealed class UsersEndpoints : IEndpoints +{ + private const string RoutesPrefix = "/api/back-office/users"; + + public void MapEndpoints(IEndpointRouteBuilder routes) + { + var backOfficeHost = routes.ServiceProvider.GetRequiredService>().Value.Host; + + var group = routes.MapGroup(RoutesPrefix) + .WithTags("BackOfficeUsers") + .WithGroupName(OpenApiDocumentNames.BackOffice) + .RequireHost(backOfficeHost) + .RequireAuthorization(BackOfficeIdentityDefaults.PolicyName) + .ProducesValidationProblem(); + + group.MapGet("/", async Task> ([AsParameters] GetBackOfficeUsersQuery query, IMediator mediator) + => await mediator.Send(query) + ).Produces(); + + group.MapGet("/{id}", async Task> (UserId id, IMediator mediator) + => await mediator.Send(new GetBackOfficeUserDetailQuery(id)) + ).Produces(); + + group.MapGet("/{id}/sessions", async Task> (UserId id, [AsParameters] GetBackOfficeUserSessionsQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + + group.MapGet("/{id}/login-history", async Task> (UserId id, [AsParameters] GetBackOfficeUserLoginHistoryQuery query, IMediator mediator) + => await mediator.Send(query with { Id = id }) + ).Produces(); + } +} diff --git a/application/account/Api/BackOfficeBlobProxy.cs b/application/account/Api/BackOfficeBlobProxy.cs new file mode 100644 index 0000000000..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/BackOffice/routes/-components/DashboardCardShell.tsx b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx new file mode 100644 index 0000000000..544e420ba9 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardCardShell.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from "react"; + +import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@repo/ui/components/Card"; + +interface DashboardCardShellProps { + title: ReactNode; + subtitle?: ReactNode; + action?: ReactNode; + children: ReactNode; +} + +export function DashboardCardShell({ title, subtitle, action, children }: Readonly) { + return ( + + + {title} + {subtitle && {subtitle}} + {action && {action}} + + {children} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardHeader.tsx b/application/account/BackOffice/routes/-components/DashboardHeader.tsx new file mode 100644 index 0000000000..bec82e11f8 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardHeader.tsx @@ -0,0 +1,87 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@repo/ui/components/Tooltip"; +import { MaximizeIcon, MinimizeIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +interface DashboardHeaderProps { + period: DashboardTrendPeriod; + onPeriodChange: (period: DashboardTrendPeriod) => void; +} + +export function DashboardHeader({ period, onPeriodChange }: Readonly) { + const { i18n } = useLingui(); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { weekday: "long", month: "long", day: "numeric" }); + const today = dateFormatter.format(new Date()); + + // Browser fullscreen for kiosk mode — chrome (sidebar, tabs) hides until the user exits. + const [isFullscreen, setIsFullscreen] = useState(false); + + useEffect(() => { + const updateState = () => setIsFullscreen(document.fullscreenElement !== null); + updateState(); + document.addEventListener("fullscreenchange", updateState); + return () => document.removeEventListener("fullscreenchange", updateState); + }, []); + + const toggleFullscreen = () => { + if (document.fullscreenElement === null) { + void document.documentElement.requestFullscreen(); + } else { + void document.exitFullscreen(); + } + }; + + const fullscreenLabel = isFullscreen ? t`Exit kiosk mode` : t`Enter kiosk mode`; + + return ( +
+
+

+ Dashboard +

+

+ BackOffice overview · {today} +

+
+
+ { + const next = values[0]; + if (next) { + onPeriodChange(next as DashboardTrendPeriod); + } + }} + > + + 7d + + + 30d + + + 90d + + + + + {isFullscreen ? : } + + } + /> + {fullscreenLabel} + +
+
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx new file mode 100644 index 0000000000..96f1eb52d9 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardKpiTiles.tsx @@ -0,0 +1,134 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Card } from "@repo/ui/components/Card"; +import { LinkCard } from "@repo/ui/components/LinkCard"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { ActivityIcon, BuildingIcon, CoinsIcon, UsersIcon } from "lucide-react"; + +import { api, DashboardTrendPeriod } from "@/shared/lib/api/client"; + +interface DashboardKpiTilesProps { + period: DashboardTrendPeriod; +} + +export function DashboardKpiTiles({ period }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/kpis", { + params: { query: { Period: period } } + }); + + const periodDays = periodToDays(period); + + return ( +
+ + +{data.newTenantsInPeriod} new in last {periodDays} days + + ) : undefined + } + to="/accounts" + /> + + + ) : undefined + } + to="/billing-events" + /> + + Last {periodDays} days} + to="/users" + /> + + Last 24 hours} + /> +
+ ); +} + +function periodToDays(period: DashboardTrendPeriod): number { + switch (period) { + case DashboardTrendPeriod.Last7Days: + return 7; + case DashboardTrendPeriod.Last30Days: + return 30; + case DashboardTrendPeriod.Last90Days: + return 90; + } +} + +function DeltaSubtitle({ deltaPercent }: Readonly<{ deltaPercent: number }>) { + const positive = deltaPercent >= 0; + const className = positive ? "text-emerald-500" : "text-rose-500"; + const sign = positive ? "+" : ""; + return ( + + {sign} + {deltaPercent}% vs prior period + + ); +} + +interface KpiTileProps { + label: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; + value: React.ReactNode; + loading: boolean; + subtitle?: React.ReactNode; + to?: "/accounts" | "/users" | "/billing-events"; +} + +function KpiTile({ label, icon: Icon, value, loading, subtitle, to }: Readonly) { + const content = ( + <> + + + {loading ? ( + <> + + + + ) : ( + <> + {value ?? "-"} + {subtitle && {subtitle}} + + )} + + ); + + if (to) { + return ( + + {content} + + ); + } + + return {content}; +} diff --git a/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx b/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx new file mode 100644 index 0000000000..e047607bdb --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardMrrTrendCard.tsx @@ -0,0 +1,131 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +interface DashboardMrrTrendCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardMrrTrendCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/mrr-trend", { + params: { query: { Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + current: point.monthlyRecurringRevenue, + prior: priorPoints[index]?.monthlyRecurringRevenue ?? 0 + })); + const currency = data?.currency ?? "DKK"; + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + const compactNumberFormatter = new Intl.NumberFormat(i18n.locale, { notation: "compact", maximumFractionDigits: 1 }); + + const blended = points.length > 0 ? points[points.length - 1].monthlyRecurringRevenue : 0; + const first = points.length > 0 ? points[0].monthlyRecurringRevenue : 0; + const deltaPercent = first === 0 ? null : Math.round(((blended - first) / first) * 100); + + return ( + MRR trend} + subtitle={ + data && deltaPercent !== null ? ( + + {formatCurrency(blended, currency)} blended · {formatDelta(deltaPercent)} over period + + ) : data ? ( + {formatCurrency(blended, currency)} blended + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + compactNumberFormatter.format(value)} + /> + dateFormatter.format(new Date(value as string))} + formatter={(value, name) => [formatCurrency(Number(value), currency), name]} + contentStyle={{ + backgroundColor: "var(--popover)", + borderColor: "var(--border)", + borderRadius: "0.5rem", + color: "var(--popover-foreground)" + }} + /> + + + + + + )} + + ); +} + +function formatDelta(deltaPercent: number): string { + const sign = deltaPercent >= 0 ? "+" : ""; + return `${sign}${deltaPercent}%`; +} diff --git a/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx b/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx new file mode 100644 index 0000000000..fca25daf50 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardPlanDistributionCard.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import { api, SubscriptionPlan } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +const PLAN_COLORS: Record = { + Basis: "var(--chart-5)", + Standard: "var(--chart-3)", + Premium: "var(--chart-1)" +}; + +const PLAN_ORDER: SubscriptionPlan[] = [SubscriptionPlan.Premium, SubscriptionPlan.Standard, SubscriptionPlan.Basis]; + +export function DashboardPlanDistributionCard() { + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/plan-distribution"); + + const total = data?.totalTenants ?? 0; + const distribution = (data?.distribution ?? []) + .slice() + .sort((a, b) => PLAN_ORDER.indexOf(a.plan) - PLAN_ORDER.indexOf(b.plan)); + + return ( + Plan distribution} + subtitle={data ? {total} accounts : undefined} + > + {isLoading ? ( + + ) : ( +
+
+ + + + + {distribution.map((entry) => ( + + ))} + + + +
+ {total} + + accounts + +
+
+
    + {distribution.map((entry) => ( +
  • + + + {getSubscriptionPlanLabel(entry.plan)} + + + {entry.count} + {entry.percentage}% + +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx new file mode 100644 index 0000000000..92e7d5c5f8 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentSignupsCard.tsx @@ -0,0 +1,118 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, BuildingIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +function getOwnerDisplayName(owner: { firstName: string | null; lastName: string | null; email: string }): string { + const fullName = [owner.firstName, owner.lastName].filter((part) => part != null && part.trim() !== "").join(" "); + return fullName !== "" ? fullName : owner.email; +} + +export function DashboardRecentSignupsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-signups", { + params: { query: { Limit: 6 } } + }); + + const signups = data?.signups ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId: String(key) } }); + }, + [navigate] + ); + + return ( + Recent signups} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx new file mode 100644 index 0000000000..4931437a99 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardRecentStripeEventsCard.tsx @@ -0,0 +1,153 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon, ZapIcon } from "lucide-react"; +import { useCallback } from "react"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; +import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +export function DashboardRecentStripeEventsCard() { + const navigate = useNavigate(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/recent-stripe-events", { + params: { query: { Limit: 6 } } + }); + + const events = data?.events ?? []; + + const handleActivate = useCallback( + (key: RowKey) => { + const tenantId = String(key).split("|")[0]; + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); + }, + [navigate] + ); + + return ( + Recent billing events} + action={ + + View all + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardSections.tsx b/application/account/BackOffice/routes/-components/DashboardSections.tsx new file mode 100644 index 0000000000..cfaa3dedf6 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardSections.tsx @@ -0,0 +1,39 @@ +import { useState } from "react"; + +import { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { DashboardHeader } from "./DashboardHeader"; +import { DashboardKpiTiles } from "./DashboardKpiTiles"; +import { DashboardMrrTrendCard } from "./DashboardMrrTrendCard"; +import { DashboardPlanDistributionCard } from "./DashboardPlanDistributionCard"; +import { DashboardRecentSignupsCard } from "./DashboardRecentSignupsCard"; +import { DashboardRecentStripeEventsCard } from "./DashboardRecentStripeEventsCard"; +import { DashboardTenantGrowthCard } from "./DashboardTenantGrowthCard"; +import { DashboardUserLoginsCard } from "./DashboardUserLoginsCard"; + +export function DashboardSections() { + const [period, setPeriod] = useState(DashboardTrendPeriod.Last30Days); + + return ( +
+ + +
+ + +
+
+ + +
+
+
+ +
+
+ +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx b/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx new file mode 100644 index 0000000000..7f449a16c8 --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardTenantGrowthCard.tsx @@ -0,0 +1,101 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Bar, + BarChart, + CartesianGrid, + Legend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api, DashboardTrendMetric } from "@/shared/lib/api/client"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +interface DashboardTenantGrowthCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardTenantGrowthCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/trends", { + params: { query: { Metric: DashboardTrendMetric.NewTenants, Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + current: point.value, + prior: priorPoints[index]?.value ?? 0 + })); + const total = points.reduce((acc, p) => acc + p.value, 0); + const priorTotal = priorPoints.reduce((acc, p) => acc + p.value, 0); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + + return ( + Account growth} + subtitle={ + data ? ( + + {total} new signups · {priorTotal} prior period + + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + + dateFormatter.format(new Date(value as string))} + contentStyle={{ + backgroundColor: "var(--popover)", + borderColor: "var(--border)", + borderRadius: "0.5rem", + color: "var(--popover-foreground)" + }} + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx b/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx new file mode 100644 index 0000000000..216096df9e --- /dev/null +++ b/application/account/BackOffice/routes/-components/DashboardUserLoginsCard.tsx @@ -0,0 +1,118 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { + Area, + AreaChart, + CartesianGrid, + Legend, + Line, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis +} from "@repo/ui/components/Chart"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { DashboardTrendPeriod } from "@/shared/lib/api/client"; + +import { api, DashboardTrendMetric } from "@/shared/lib/api/client"; + +import { DashboardCardShell } from "./DashboardCardShell"; + +interface DashboardUserLoginsCardProps { + period: DashboardTrendPeriod; +} + +export function DashboardUserLoginsCard({ period }: Readonly) { + const { i18n } = useLingui(); + const { data, isLoading } = api.useQuery("get", "/api/back-office/dashboard/trends", { + params: { query: { Metric: DashboardTrendMetric.LoginActivity, Period: period } } + }); + + const points = data?.points ?? []; + const priorPoints = data?.priorPoints ?? []; + const chartData = points.map((point, index) => ({ + date: point.date, + current: point.value, + prior: priorPoints[index]?.value ?? 0 + })); + const total = points.reduce((acc, p) => acc + p.value, 0); + const average = points.length === 0 ? 0 : Math.round(total / points.length); + const dateFormatter = new Intl.DateTimeFormat(i18n.locale, { month: "short", day: "numeric" }); + + return ( + User logins / day} + subtitle={ + data ? ( + + {total} total · avg {average}/day + + ) : undefined + } + > + {isLoading ? ( + + ) : ( + + + + + + + + + + dateFormatter.format(new Date(value))} + stroke="var(--muted-foreground)" + /> + + dateFormatter.format(new Date(value as string))} + contentStyle={{ + backgroundColor: "var(--popover)", + borderColor: "var(--border)", + borderRadius: "0.5rem", + color: "var(--popover-foreground)" + }} + /> + + + + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/__root.tsx b/application/account/BackOffice/routes/__root.tsx index cdddee2776..edcd7cd954 100644 --- a/application/account/BackOffice/routes/__root.tsx +++ b/application/account/BackOffice/routes/__root.tsx @@ -2,10 +2,12 @@ import { PageTracker } from "@repo/infrastructure/applicationInsights/PageTracke import { AuthenticationProvider } from "@repo/infrastructure/auth/AuthenticationProvider"; import { useErrorTrigger } from "@repo/infrastructure/development/useErrorTrigger"; import { useInitializeLocale } from "@repo/infrastructure/translations/useInitializeLocale"; +import { BannerPortal } from "@repo/ui/components/BannerPortal"; import { ThemeModeProvider } from "@repo/ui/theme/mode/ThemeMode"; import { QueryClientProvider } from "@tanstack/react-query"; import { createRootRoute, Outlet, useNavigate } from "@tanstack/react-router"; +import { BackOfficeBanners } from "@/shared/components/BackOfficeBanners"; import { ErrorPage } from "@/shared/components/errorPages/ErrorPage"; import { NotFoundPage } from "@/shared/components/errorPages/NotFoundPage"; import { queryClient } from "@/shared/lib/api/client"; @@ -25,6 +27,9 @@ function Root() { navigate(options)}> + + + diff --git a/application/account/BackOffice/routes/accounts/$tenantId.tsx b/application/account/BackOffice/routes/accounts/$tenantId.tsx new file mode 100644 index 0000000000..c71ab28bb7 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/$tenantId.tsx @@ -0,0 +1,113 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { ActivityIcon, LayoutGridIcon, ReceiptIcon, UsersIcon } from "lucide-react"; +import { useCallback } from "react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import { AccountBillingTab } from "./-components/AccountBillingTab"; +import { AccountCurrentPlanCard } from "./-components/AccountCurrentPlanCard"; +import { AccountDetailHeader } from "./-components/AccountDetailHeader"; +import { AccountHealthTiles } from "./-components/AccountHealthTiles"; +import { AccountOverviewTab } from "./-components/AccountOverviewTab"; +import { AccountUsersTab } from "./-components/AccountUsersTab"; + +type AccountDetailTab = "overview" | "users" | "invoices" | "billing-events"; + +const accountDetailSearchSchema = z.object({ + tab: z.enum(["overview", "users", "invoices", "billing-events"]).optional() +}); + +export const Route = createFileRoute("/accounts/$tenantId")({ + staticData: { trackingTitle: "Account detail" }, + validateSearch: accountDetailSearchSchema, + component: AccountDetailPage +}); + +function AccountDetailPage() { + const { tenantId } = Route.useParams(); + const { tab } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const activeTab = tab ?? "overview"; + + const setActiveTab = useCallback( + (value: string) => { + const next = value as AccountDetailTab; + navigate({ + search: { tab: next === "overview" ? undefined : next }, + replace: true + }); + }, + [navigate] + ); + + const tenantQuery = api.useQuery("get", "/api/back-office/tenants/{id}", { + params: { path: { id: tenantId } } + }); + + const tenant = tenantQuery.data; + + return ( + + + + +
+ + + + + + + Overview + + + + Users + + + + Invoices + + + + Billing events + + + + +
+
+ +
+
+ setActiveTab("invoices")} + /> +
+
+
+ + + + + + + + + +
+
+
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx new file mode 100644 index 0000000000..3735a0cfb1 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountActionsMenu.tsx @@ -0,0 +1,136 @@ +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 { + 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, MoreVerticalIcon, RefreshCwIcon } from "lucide-react"; +import { useState } from "react"; + +import { useMe } from "@/shared/hooks/useMe"; +import { api } from "@/shared/lib/api/client"; + +interface AccountActionsMenuProps { + tenantId: string; +} + +interface SyncResult { + billingEventsAppended: number; + hasDriftDetected: boolean; + driftDiscrepancyCount: number; + syncedAt: string; +} + +export function AccountActionsMenu({ tenantId }: Readonly) { + const formatDate = useFormatDate(); + const { data: me } = useMe(); + 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 handleSync = () => { + syncMutation.mutate({ params: { path: { id: tenantId } } }); + }; + + // Sync with Stripe is admin-only on the server (TenantsEndpoints.cs). Hide the trigger for + // non-admins so the UI matches the policy. + if (!me?.isAdmin) { + return null; + } + + return ( + <> + + + + + + } + /> + } + /> + {t`Account actions`} + + + + + {syncMutation.isPending ? Syncing... : Sync with Stripe} + + + + + + + + + {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)}. + + )} + + + + setIsResultOpen(false)}> + Close + + + + + + ); +} 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..a0057ac261 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventRow.tsx @@ -0,0 +1,84 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { getBillingEventTypeLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { BILLING_EVENT_VARIANT } from "@/shared/lib/billingEventStyle"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +export function AccountBillingEventRow({ + event, + renderDate, + isCompact +}: Readonly<{ + event: BillingEventSummary; + renderDate: (value: string | null | undefined) => ReactNode; + isCompact: boolean; +}>) { + const variant = BILLING_EVENT_VARIANT[event.eventType]; + const Icon = variant.icon; + const showPlanTransition = event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan; + return ( + + +
+ {renderDate(event.occurredAt)} +
+
+ + + + {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..dfb9ac854e --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountBillingEventsSection.tsx @@ -0,0 +1,117 @@ +import type { ReactNode } from "react"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { ArrowRightIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { AccountBillingEventRow } from "./AccountBillingEventRow"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; +type RenderDate = (value: string | null | undefined) => ReactNode; + +interface Props { + billingEvents: BillingEventSummary[]; + isLoading: boolean; + isCompact: boolean; + totalEvents: number; + onViewAll?: () => void; + renderDate: RenderDate; +} + +export function AccountBillingEventsSection({ + billingEvents, + isLoading, + isCompact, + totalEvents, + onViewAll, + renderDate +}: Readonly) { + return ( +
+ {isCompact ? ( +
+

+ Billing events +

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

+ Invoices +

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

+ Current plan +

+ {children} +
+ ); +} + +function CurrentPlanEmpty({ title, description }: Readonly<{ title: ReactNode; description: ReactNode }>) { + return ( + + + {title} + {description} + + + ); +} + +function renderSkeleton() { + return ( + + + + + + + + + ); +} + +function CurrentPlanDetails({ tenant }: Readonly<{ tenant: TenantDetailResponse }>) { + const formatDate = useFormatDate(); + + const monthlyAmount = + tenant.monthlyRecurringRevenue !== null && tenant.currency !== null + ? formatCurrency(tenant.monthlyRecurringRevenue, tenant.currency) + : "-"; + + const isCanceling = tenant.cancelAtPeriodEnd; + const isDowngrading = !isCanceling && tenant.scheduledPlan !== null; + const 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 ( + +
+
+ {showStrikedAmount ? ( +
+ {monthlyAmount} + {newMonthlyAmount} +
+ ) : ( + {monthlyAmount} + )} +
+ + {getSubscriptionPlanLabel(tenant.plan)} + + {isCanceling ? ( + + + Canceling + + ) : isDowngrading ? ( + + + Downgrading + + ) : null} +
+
+ + per month, billed monthly + +
+ +
+ +
+
+
+
+ Subscribed since +
+
+ {tenant.subscribedSince && } + {tenant.subscribedSince ? formatDate(tenant.subscribedSince) : "-"} +
+
+
+
+ Renewal date +
+
+ {tenant.renewalDate && } + {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 new file mode 100644 index 0000000000..78712d7bbf --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountDetailHeader.tsx @@ -0,0 +1,107 @@ +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { CalendarIcon, HashIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange, TenantState } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel, getTenantStateLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { AccountActionsMenu } from "./AccountActionsMenu"; +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountDetailHeaderProps { + tenant: TenantDetailResponse | undefined; + tenantId: string; + isLoading: boolean; +} + +export function AccountDetailHeader({ tenant, tenantId, isLoading }: Readonly) { + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + return ( +
+ +
+ {isLoading || !tenant ? ( + <> + + + + ) : ( + <> +
+

{tenant.name}

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

+ Owners +

+ {ownersQuery.isLoading || isLoading || !tenant ? ( + + ) : owners.length === 0 ? ( + + + + No owners + + + No owners on this account. + + + + ) : ( +
+ {owners.map((owner) => ( + + ))} +
+ )} +
+ ); +} + +function OwnerRow({ owner }: Readonly<{ owner: TenantUserSummary }>) { + const displayName = + owner.firstName || owner.lastName ? `${owner.firstName ?? ""} ${owner.lastName ?? ""}`.trim() : owner.email; + + return ( + + + + + + {getInitials(owner.firstName ?? undefined, owner.lastName ?? undefined, owner.email)} + + +
+ {displayName} + {owner.title && {owner.title}} + + + {owner.email} + +
+ {!owner.emailConfirmed && ( + + Pending + + )} + +
+ ); +} + +function OwnersSkeleton() { + return ( +
+ {Array.from({ length: 2 }).map((_, index) => ( + + ))} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx new file mode 100644 index 0000000000..aa8d66be06 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountPaymentRow.tsx @@ -0,0 +1,144 @@ +import type { ReactNode } from "react"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { DownloadIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { PaymentTransactionStatus } from "@/shared/lib/api/client"; +import { getPaymentStatusLabel, getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +type PaymentTransaction = components["schemas"]["TenantPaymentTransaction"]; + +export function AccountPaymentRow({ + transaction, + renderDate, + showTaxBreakdown, + showPlan = true +}: Readonly<{ + transaction: PaymentTransaction; + 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 ( + + {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 && ( + + )} +
+
+
+ ); +} + +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..a860ce9b31 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePane.tsx @@ -0,0 +1,113 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { SidePane, SidePaneBody, SidePaneFooter, SidePaneHeader } from "@repo/ui/components/SidePane"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowRightIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { AccountSidePaneSections } from "./AccountSidePaneSections"; +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +interface AccountSidePaneProps { + tenant: TenantSummary | null; + isOpen: boolean; + onClose: () => void; +} + +const DETAIL_DEBOUNCE_MS = 200; + +export function AccountSidePane({ tenant, isOpen, onClose }: Readonly) { + const navigate = useNavigate(); + const { i18n } = useLingui(); + + const tenantId = tenant?.id; + const debouncedTenantId = useDebounce(tenantId, DETAIL_DEBOUNCE_MS); + const detailReady = Boolean(debouncedTenantId) && debouncedTenantId === tenantId; + + const detailQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}", + { params: { path: { id: debouncedTenantId ?? "" } } }, + { enabled: detailReady } + ); + + const detail = detailQuery.data; + + const handleOpen = () => { + if (!tenant) { + return; + } + navigate({ to: "/accounts/$tenantId", params: { tenantId: tenant.id } }); + }; + + return ( + !open && onClose()} + trackingTitle="Account preview" + trackingKey={tenant?.id} + aria-label={t`Account preview`} + > + + {tenant ? ( +
+ +
+ {tenant.name} + + + {getSubscriptionPlanLabel(tenant.plan)} + + + {tenant.country && ( + + {getCountryFlagEmoji(tenant.country)} + {getCountryName(tenant.country, i18n.locale)} + + )} + +
+
+ ) : ( + Account + )} +
+ + + {tenant && ( + + )} + + + + + +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx new file mode 100644 index 0000000000..ca8bf8657c --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountSidePaneSections.tsx @@ -0,0 +1,194 @@ +import { t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, PlannedSubscriptionChange, UserRole } from "@/shared/lib/api/client"; +import { formatRelativeTime } from "@/shared/lib/relativeTime"; + +import { SidePaneDivider, SidePaneSection } from "./SidePaneSection"; +import { SidePaneUserList } from "./SidePaneUserList"; +import { SidePaneUsersRow } from "./SidePaneUsersRow"; +import { SubscriptionStatusIndicator } from "./SubscriptionStatusIndicator"; + +type TenantSummary = components["schemas"]["TenantSummary"]; +type TenantDetailResponse = components["schemas"]["TenantDetailResponse"]; + +interface AccountSidePaneSectionsProps { + tenant: TenantSummary; + detail: TenantDetailResponse | null; + detailLoading: boolean; + debouncedTenantId: string; + detailReady: boolean; +} + +function formatAmount(amount: number | null | undefined, currency: string | null | undefined): string { + if (amount === null || amount === undefined || currency === null || currency === undefined) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountSidePaneSections({ + tenant, + detail, + detailLoading, + debouncedTenantId, + detailReady +}: Readonly) { + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + const userCountsQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/user-counts", + { params: { path: { id: debouncedTenantId } } }, + { enabled: detailReady } + ); + + const ownersQuery = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { params: { path: { id: debouncedTenantId }, query: { 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 paymentHistoryLoading = !detailReady || paymentHistoryQuery.isLoading; + const subscribedSince = detail?.subscribedSince ?? null; + + const isCanceling = tenant.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const newMrrAmount = isCanceling ? 0 : isDowngrading ? (detail?.scheduledPriceAmount ?? null) : null; + const showStrikedMrr = (isCanceling || isDowngrading) && newMrrAmount !== null; + + return ( +
+ + + +
+ + + {formatAmount(tenant.monthlyRecurringRevenue, tenant.currency)} + + + → + + {formatAmount(newMrrAmount, tenant.currency)} + + ) : ( + formatAmount(tenant.monthlyRecurringRevenue, tenant.currency) + ) + } + /> + + + + + + +
+
+ + + + + + + + + + + + + + + + + + {formatDate(tenant.createdAt)} + {formatRelativeTime(tenant.createdAt, i18n.locale)} + + +
+ ); +} + +interface KpiRowProps { + leftLabel: string; + leftValue: React.ReactNode; + leftLoading?: boolean; + rightLabel: string; + rightValue: React.ReactNode; + rightLoading?: boolean; +} + +function KpiRow({ leftLabel, leftValue, leftLoading, rightLabel, rightValue, rightLoading }: Readonly) { + return ( +
+
+ {leftLabel} + {leftLoading ? ( + + ) : ( + {leftValue} + )} +
+
+ {rightLabel} + {rightLoading ? ( + + ) : ( + {rightValue} + )} +
+
+ ); +} + +function SubLabel({ children }: Readonly<{ children: React.ReactNode }>) { + return ( + + {children} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx new file mode 100644 index 0000000000..70c14a54f8 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUserRow.tsx @@ -0,0 +1,61 @@ +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +export function AccountUserRow({ + user, + formatDate +}: Readonly<{ + user: TenantUserSummary; + formatDate: (value: string | null | undefined) => string; +}>) { + const displayName = + user.firstName || user.lastName ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() : user.email; + + return ( + + +
+ + + + {getInitials(user.firstName ?? undefined, user.lastName ?? undefined, user.email)} + + +
+ {displayName} + {user.title && {user.title}} + + + {user.email} + +
+ {!user.emailConfirmed && ( + + Pending + + )} +
+
+ + + + {user.email} + + + + {getUserRoleLabel(user.role)} + + {user.lastSeenAt ? formatDate(user.lastSeenAt) : "-"} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx b/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx new file mode 100644 index 0000000000..f42ffdb2d6 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountUsersTab.tsx @@ -0,0 +1,203 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { api, UserRole } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +import { AccountUserRow } from "./AccountUserRow"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +interface AccountUsersTabProps { + tenantId: string; +} + +export function AccountUsersTab({ tenantId }: Readonly) { + const [searchInput, setSearchInput] = useState(""); + const [roles, setRoles] = useState([]); + const [pageOffset, setPageOffset] = useState(0); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + setPageOffset(0); + }, [debouncedSearch, roles]); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants/{id}/users", + { + params: { + path: { id: tenantId }, + query: { + Search: debouncedSearch || undefined, + Roles: roles.length === 0 ? undefined : roles, + PageOffset: pageOffset || undefined + } + } + }, + { placeholderData: keepPreviousData } + ); + + const users = data?.users ?? []; + const totalPages = data?.totalPages ?? 0; + const currentPage = (data?.currentPageOffset ?? 0) + 1; + const hasFilters = Boolean(debouncedSearch) || roles.length > 0; + + return ( +
+ + + {totalPages > 1 && ( + setPageOffset(page - 1)} + previousLabel={t`Previous`} + nextLabel={t`Next`} + trackingTitle="Account users" + className="w-full" + /> + )} +
+ ); +} + +function UserFilters({ + searchInput, + onSearchChange, + roles, + onRolesChange +}: Readonly<{ + searchInput: string; + onSearchChange: (value: string) => void; + roles: UserRole[]; + onRolesChange: (value: UserRole[]) => void; +}>) { + const handleRolesChange = (values: string[]) => { + onRolesChange(values as UserRole[]); + }; + + return ( +
+
+ + + + + onSearchChange(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && onSearchChange("")} + /> + {searchInput && ( + + onSearchChange("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[UserRole.Owner, UserRole.Admin, UserRole.Member].map((value) => ( + + {getUserRoleLabel(value)} + + ))} + +
+ ); +} + +function UserList({ + users, + isLoading, + hasFilters +}: Readonly<{ users: TenantUserSummary[]; isLoading: boolean; hasFilters: boolean }>) { + const formatDate = useFormatDate(); + const navigate = useNavigate(); + const handleActivate = useCallback( + (key: RowKey) => { + const user = users.find((entry) => entry.id === key); + if (!user) return; + navigate({ to: "/users/$userId", params: { userId: user.id } }); + }, + [navigate, users] + ); + + if (isLoading && users.length === 0) { + return ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ); + } + + if (users.length === 0) { + return ( + + + {hasFilters ? No matching users : No users} + + {hasFilters ? No users match your filters. : This account has no users.} + + + + ); + } + + return ( + + + + + Name + + + Email + + + Role + + + Last seen + + + + + {users.map((user) => ( + + ))} + +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx new file mode 100644 index 0000000000..f96d9ccb41 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTable.tsx @@ -0,0 +1,152 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback, useMemo } from "react"; + +import type { components, SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +import { AccountsTableColumnHeaders } from "./AccountsTableColumnHeaders"; +import { AccountsTableRow } from "./AccountsTableRow"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +interface AccountsTableProps { + tenants: TenantSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; + selectedTenantId: string | undefined; + onSelectTenant: (tenant: TenantSummary | null) => void; + orderBy: SortableTenantProperties | undefined; + sortOrder: SortOrder | undefined; +} + +export function AccountsTable({ + tenants, + isLoading, + totalPages, + currentPageOffset, + selectedTenantId, + onSelectTenant, + orderBy, + sortOrder +}: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const selectedKeys = useMemo>( + () => (selectedTenantId ? new Set([selectedTenantId]) : new Set()), + [selectedTenantId] + ); + + const handleSelectionChange = useCallback( + (keys: Set) => { + if (keys.size === 0) { + onSelectTenant(null); + return; + } + const [first] = keys; + const tenant = tenants.find((entry) => entry.id === first); + onSelectTenant(tenant ?? null); + }, + [onSelectTenant, tenants] + ); + + const handleActivate = useCallback( + (key: RowKey) => { + const tenant = tenants.find((entry) => entry.id === key); + onSelectTenant(tenant ?? null); + }, + [onSelectTenant, tenants] + ); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableTenantProperties) => { + 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 ( + <> +
+ + + + {tenants.map((tenant) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx new file mode 100644 index 0000000000..b1c308fbaf --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableColumnHeaders.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { TableHeader, TableRow } from "@repo/ui/components/Table"; + +import type { SortOrder } from "@/shared/lib/api/client"; + +import { SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SortableTableHead } from "./SortableTableHead"; + +interface AccountsTableColumnHeadersProps { + orderBy: SortableTenantProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableTenantProperties) => void; +} + +export function AccountsTableColumnHeaders({ orderBy, sortOrder, onSort }: Readonly) { + return ( + + + + Name + + + Plan + + + MRR + + + Renewal + + + Status + + + Country + + + Signed up + + + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx new file mode 100644 index 0000000000..0f773e1274 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsTableRow.tsx @@ -0,0 +1,120 @@ +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +import { TenantStatusBadge } from "./TenantStatusBadge"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +function formatMonthlyRevenue(amount: number | null, currency: string | null): string { + if (amount === null || currency === null) { + return "-"; + } + return formatCurrency(amount, currency); +} + +export function AccountsTableRow({ + tenant, + formatDate +}: Readonly<{ + tenant: TenantSummary; + formatDate: (value: string | null | undefined) => string; +}>) { + return ( + + +
+ +
+ {tenant.name} +
+ + {getSubscriptionPlanLabel(tenant.plan)} + +
+
+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + +
+
+ +
+
+
+
+
+ + {getSubscriptionPlanLabel(tenant.plan)} + + + + + + {tenant.renewalDate ? formatDate(tenant.renewalDate) : -} + + +
+ + {tenant.renewalDate && ( + + {formatDate(tenant.renewalDate)} + + )} +
+
+ + {tenant.country ? ( + + {getCountryFlagEmoji(tenant.country)} + {tenant.country} + + ) : ( + "-" + )} + + {formatDate(tenant.createdAt)} +
+ ); +} + +function MrrCell({ tenant, align = "start" }: Readonly<{ tenant: TenantSummary; align?: "start" | "end" }>) { + const currentAmount = formatMonthlyRevenue(tenant.monthlyRecurringRevenue, tenant.currency); + const isCanceling = tenant.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = tenant.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const newAmount = + isCanceling && tenant.currency !== null + ? formatCurrency(0, tenant.currency) + : isDowngrading && tenant.scheduledPriceAmount !== null && tenant.currency !== null + ? formatCurrency(tenant.scheduledPriceAmount, tenant.currency) + : null; + + if (newAmount === null) { + return {currentAmount}; + } + + return ( +
+ {currentAmount} + {newAmount} +
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx new file mode 100644 index 0000000000..19dbab88a1 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/AccountsToolbar.tsx @@ -0,0 +1,180 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Button } from "@repo/ui/components/Button"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { CloudOffIcon, SearchIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import type { SortableTenantProperties } from "@/shared/lib/api/client"; + +import { SubscriptionPlan, TenantStatusFilter } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel } from "@/shared/lib/api/labels"; + +interface AccountsToolbarProps { + search: string | undefined; + plans: SubscriptionPlan[]; + statuses: TenantStatusFilter[]; + unsynced: boolean; + driftDetected: boolean; +} + +export function AccountsToolbar({ search, plans, statuses, unsynced, driftDetected }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + search: debouncedSearch || undefined, + pageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handlePlansChange = (values: string[]) => { + const next = values as SubscriptionPlan[]; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + plans: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + const handleStatusesChange = (values: string[]) => { + const next = values as TenantStatusFilter[]; + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + statuses: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[SubscriptionPlan.Premium, SubscriptionPlan.Standard, SubscriptionPlan.Basis].map((value) => ( + + {getSubscriptionPlanLabel(value)} + + ))} + + + + + Active + + + Downgrading + + + Canceling + + + Canceled + + + Free + + + + +
+ ); +} + +function IssueFilterBadges({ unsynced, driftDetected }: Readonly<{ unsynced: boolean; driftDetected: boolean }>) { + const navigate = useNavigate(); + const clear = (key: "unsynced" | "driftDetected") => () => + navigate({ + to: "/accounts", + search: (previous) => ({ + ...previous, + orderBy: previous.orderBy as SortableTenantProperties | undefined, + unsynced: key === "unsynced" ? undefined : previous.unsynced, + driftDetected: key === "driftDetected" ? undefined : previous.driftDetected, + pageOffset: undefined + }) + }); + + return ( + <> + {unsynced && ( + + + Not synced yet + + + )} + {driftDetected && ( + + + Drift detected + + + )} + + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx new file mode 100644 index 0000000000..261d4e67a9 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneSection.tsx @@ -0,0 +1,20 @@ +export function SidePaneSection({ + label, + trailing, + children, + className +}: Readonly<{ label: string; trailing?: React.ReactNode; children: React.ReactNode; className?: string }>) { + return ( +
+
+ {label} + {trailing} +
+ {children} +
+ ); +} + +export function SidePaneDivider() { + return
; +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx new file mode 100644 index 0000000000..04e961f78c --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUserList.tsx @@ -0,0 +1,81 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { getInitials } from "@repo/utils/string/getInitials"; +import { Link } from "@tanstack/react-router"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +type TenantUserSummary = components["schemas"]["TenantUserSummary"]; + +export function SidePaneUserList({ + users, + isLoading, + emptyMessage +}: Readonly<{ + users: TenantUserSummary[]; + isLoading: boolean; + emptyMessage: string; +}>) { + if (isLoading) { + return ( +
+ +
+ ); + } + if (users.length === 0) { + return {emptyMessage}; + } + return ( +
+ {users.map((user) => ( + + ))} +
+ ); +} + +function UserRowSkeleton() { + return ( +
+ +
+ + + + + + +
+
+ ); +} + +function UserRow({ user }: Readonly<{ user: TenantUserSummary }>) { + const displayName = + user.firstName || user.lastName ? `${user.firstName ?? ""} ${user.lastName ?? ""}`.trim() : user.email; + + return ( + + + + + {getInitials(user.firstName ?? undefined, user.lastName ?? undefined, user.email)} + + +
+ {displayName} + {user.title && {user.title}} + + + {user.email} + +
+ + ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx b/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx new file mode 100644 index 0000000000..f09d66f65e --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SidePaneUsersRow.tsx @@ -0,0 +1,57 @@ +import { Trans } from "@lingui/react/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { components } from "@/shared/lib/api/client"; + +export function SidePaneUsersRow({ + detailReady, + userCounts, + isLoading +}: Readonly<{ + detailReady: boolean; + userCounts: components["schemas"]["TenantUserCountsResponse"] | undefined; + isLoading: boolean; +}>) { + if (!detailReady || isLoading) { + return ( +
+ + +
+ ); + } + if (!userCounts) { + return -; + } + const { totalUsers, activeUsers, pendingUsers } = userCounts; + const inactiveUsers = Math.max(0, totalUsers - activeUsers - pendingUsers); + + const activePercent = totalUsers === 0 ? 0 : (activeUsers / totalUsers) * 100; + const inactivePercent = totalUsers === 0 ? 0 : (inactiveUsers / totalUsers) * 100; + const pendingPercent = totalUsers === 0 ? 0 : (pendingUsers / totalUsers) * 100; + + return ( +
+ + + {totalUsers} total + + {" · "} + {activeUsers} active + {" · "} + {inactiveUsers} inactive + {" · "} + {pendingUsers} pending + +
+ {activePercent > 0 &&
} + {inactivePercent > 0 && ( +
+ )} + {pendingPercent > 0 && ( +
+ )} +
+
+ ); +} diff --git a/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx b/application/account/BackOffice/routes/accounts/-components/SortableTableHead.tsx new file mode 100644 index 0000000000..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/-components/SubscriptionStatusIndicator.tsx b/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx new file mode 100644 index 0000000000..6b26f51f81 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/SubscriptionStatusIndicator.tsx @@ -0,0 +1,21 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { XCircleIcon } from "lucide-react"; + +import { TenantState } from "@/shared/lib/api/client"; + +export function SubscriptionStatusIndicator({ + state +}: Readonly<{ + state: TenantState | undefined; +}>) { + if (state === TenantState.Suspended) { + return ( + + + Suspended + + ); + } + return null; +} diff --git a/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx b/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx new file mode 100644 index 0000000000..bdf89366e0 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/-components/TenantStatusBadge.tsx @@ -0,0 +1,51 @@ +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { CalendarClockIcon, CheckCircle2Icon, MinusCircleIcon, XCircleIcon } from "lucide-react"; + +import { PlannedSubscriptionChange, SubscriptionPlan } from "@/shared/lib/api/client"; + +interface TenantStatusBadgeProps { + plan: SubscriptionPlan; + plannedChange: PlannedSubscriptionChange | null | undefined; + hasEverSubscribed: boolean; +} + +export function TenantStatusBadge({ plan, plannedChange, hasEverSubscribed }: Readonly) { + if (plannedChange === PlannedSubscriptionChange.Cancellation) { + return ( + + + Canceling + + ); + } + if (plannedChange === PlannedSubscriptionChange.ScheduledPlanChange) { + return ( + + + Downgrading + + ); + } + if (plan !== SubscriptionPlan.Basis) { + return ( + + + Active + + ); + } + if (hasEverSubscribed) { + return ( + + + Canceled + + ); + } + return ( + + Free + + ); +} diff --git a/application/account/BackOffice/routes/accounts/index.tsx b/application/account/BackOffice/routes/accounts/index.tsx new file mode 100644 index 0000000000..a9fe680fb3 --- /dev/null +++ b/application/account/BackOffice/routes/accounts/index.tsx @@ -0,0 +1,163 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Button } from "@repo/ui/components/Button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Building2Icon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { z } from "zod"; + +import type { components } from "@/shared/lib/api/client"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { + api, + SortableTenantProperties, + SortOrder, + SubscriptionPlan, + TenantStatusFilter +} from "@/shared/lib/api/client"; + +import { AccountSidePane } from "./-components/AccountSidePane"; +import { AccountsTable } from "./-components/AccountsTable"; +import { AccountsToolbar } from "./-components/AccountsToolbar"; + +type TenantSummary = components["schemas"]["TenantSummary"]; + +const accountsSearchSchema = z.object({ + search: z.string().optional(), + plans: z.array(z.nativeEnum(SubscriptionPlan)).max(10).optional(), + statuses: z.array(z.nativeEnum(TenantStatusFilter)).max(10).optional(), + unsynced: z.boolean().optional(), + driftDetected: z.boolean().optional(), + orderBy: z.nativeEnum(SortableTenantProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/accounts/")({ + staticData: { trackingTitle: "Accounts" }, + validateSearch: accountsSearchSchema, + component: AccountsListPage +}); + +function AccountsListPage() { + const { search, plans, statuses, unsynced, driftDetected, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const navigate = useNavigate(); + const [previewTenant, setPreviewTenant] = useState(null); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/tenants", + { + params: { + query: { + Search: search, + Plans: plans, + Statuses: statuses, + Unsynced: unsynced, + DriftDetected: driftDetected, + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const handleSelectTenant = useCallback((tenant: TenantSummary | null) => { + setPreviewTenant(tenant); + }, []); + + const handleClosePane = useCallback(() => setPreviewTenant(null), []); + + const tenants = data?.tenants ?? []; + const hasFilters = + Boolean(search) || + (plans?.length ?? 0) > 0 || + (statuses?.length ?? 0) > 0 || + Boolean(unsynced) || + Boolean(driftDetected); + const showEmpty = !isLoading && tenants.length === 0; + + return ( + + + + + ) : undefined + } + > + + + {showEmpty ? ( + + + + + + + {hasFilters ? No accounts match your filters : No accounts yet} + + + {hasFilters ? ( + Try clearing the search or filters to see more results. + ) : ( + Accounts will appear here as they are created. + )} + + + {hasFilters && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx new file mode 100644 index 0000000000..b9fba352db --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTable.tsx @@ -0,0 +1,127 @@ +import { t } from "@lingui/core/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import type { components, SortableBillingEventProperties } from "@/shared/lib/api/client"; + +import { SortOrder } from "@/shared/lib/api/client"; + +import { BillingEventsTableColumnHeaders } from "./BillingEventsTableColumnHeaders"; +import { BillingEventsTableRow } from "./BillingEventsTableRow"; + +type BillingEventSummary = components["schemas"]["BillingEventSummary"]; + +interface BillingEventsTableProps { + billingEvents: BillingEventSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; +} + +export function BillingEventsTable({ + billingEvents, + isLoading, + totalPages, + currentPageOffset, + orderBy, + sortOrder +}: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + eventTypes: previous.eventTypes, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + const handleSort = useCallback( + (column: SortableBillingEventProperties) => { + // Backend default is Descending, so the URL stores Descending as undefined. + // Treat both undefined and explicit Descending as the descending state when computing the next direction. + const isCurrent = orderBy === column; + const isCurrentlyDescending = (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const nextOrder = isCurrent && isCurrentlyDescending ? SortOrder.Ascending : SortOrder.Descending; + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + eventTypes: previous.eventTypes, + orderBy: column, + sortOrder: nextOrder === SortOrder.Descending ? undefined : nextOrder, + pageOffset: undefined + }) + }); + }, + [navigate, orderBy, sortOrder] + ); + + const handleRowClick = useCallback( + (tenantId: string) => { + navigate({ to: "/accounts/$tenantId", params: { tenantId }, search: { tab: "billing-events" } }); + }, + [navigate] + ); + + if (isLoading && billingEvents.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + {billingEvents.map((event) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx new file mode 100644 index 0000000000..6b31a21122 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableColumnHeaders.tsx @@ -0,0 +1,96 @@ +import { Trans } from "@lingui/react/macro"; +import { TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { ChevronDownIcon, ChevronUpIcon } from "lucide-react"; + +import { SortableBillingEventProperties, SortOrder } from "@/shared/lib/api/client"; + +interface BillingEventsTableColumnHeadersProps { + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBillingEventProperties) => void; +} + +// Local SortableTableHead so the column reuses the same sort-affordance as Accounts without +// coupling /billing-events to /accounts internal components. +function SortableHead({ + column, + orderBy, + sortOrder, + onSort, + className, + children +}: Readonly<{ + column: SortableBillingEventProperties; + orderBy: SortableBillingEventProperties | undefined; + sortOrder: SortOrder | undefined; + onSort: (column: SortableBillingEventProperties) => void; + className?: string; + children: React.ReactNode; +}>) { + const isActive = orderBy === column; + // Backend default is Descending, stored as undefined in the URL — treat undefined as Descending here so the + // chevron renders correctly when the active column is in its default descending state. + const isDescending = isActive && (sortOrder ?? SortOrder.Descending) === SortOrder.Descending; + const ariaSort = isActive ? (isDescending ? "descending" : "ascending") : "none"; + + return ( + onSort(column)}> + {children} + {isActive && + (isDescending ? ( + + ) : ( + + ))} + + ); +} + +export function BillingEventsTableColumnHeaders({ + orderBy, + sortOrder, + onSort +}: Readonly) { + return ( + + + + Account + + + Event + + + Plan transition + + + Amount + + + Country + + + Occurred + + + + ); +} diff --git a/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx new file mode 100644 index 0000000000..79a99dc539 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsTableRow.tsx @@ -0,0 +1,80 @@ +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { getCountryFlagEmoji } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; + +import type { components } from "@/shared/lib/api/client"; + +import { 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, + 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)} + + + + {event.fromPlan != null && event.toPlan != null && event.fromPlan !== event.toPlan ? ( + + {getSubscriptionPlanLabel(event.fromPlan)} + + → + + {getSubscriptionPlanLabel(event.toPlan)} + + ) : event.toPlan != null ? ( + {getSubscriptionPlanLabel(event.toPlan)} + ) : ( + + )} + + + {event.amountDelta != null && event.currency ? ( + formatCurrency(event.amountDelta, event.currency) + ) : ( + + )} + + + {event.country ? ( + + {getCountryFlagEmoji(event.country)} + {event.country} + + ) : ( + + )} + + + {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 new file mode 100644 index 0000000000..4b3fe57e17 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx @@ -0,0 +1,128 @@ +import { t } from "@lingui/core/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +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, useMemo, useState } from "react"; + +import type { SortableBillingEventProperties } from "@/shared/lib/api/client"; + +import { BillingEventType } from "@/shared/lib/api/client"; +import { getBillingEventTypeLabel } from "@/shared/lib/api/labels"; + +interface BillingEventsToolbarProps { + search: string | undefined; + eventTypes: BillingEventType[]; +} + +// 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 → audit). +// IMPORTANT: this list mirrors the C# BillingEventType enum (see application/account/Core/Features/ +// Subscriptions/Domain/BillingEvent.cs). Add new enum values here too — the enum's doc comment also +// flags this requirement. +const ALL_EVENT_TYPES: BillingEventType[] = [ + BillingEventType.SubscriptionCreated, + BillingEventType.SubscriptionRenewed, + BillingEventType.SubscriptionUpgraded, + BillingEventType.SubscriptionDowngradeScheduled, + BillingEventType.SubscriptionDowngradeCancelled, + BillingEventType.SubscriptionDowngraded, + BillingEventType.SubscriptionCancelled, + BillingEventType.SubscriptionReactivated, + BillingEventType.SubscriptionExpired, + BillingEventType.SubscriptionImmediatelyCancelled, + BillingEventType.SubscriptionSuspended, + BillingEventType.SubscriptionPastDue, + BillingEventType.PaymentFailed, + BillingEventType.PaymentRecovered, + BillingEventType.PaymentRefunded, + BillingEventType.BillingInfoAdded, + BillingEventType.BillingInfoUpdated, + BillingEventType.PaymentMethodUpdated, + BillingEventType.NoOp, + BillingEventType.Unclassified +]; + +export function BillingEventsToolbar({ search, eventTypes }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/billing-events", + search: (previous) => ({ + eventTypes: previous.eventTypes, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + search: debouncedSearch || undefined, + pageOffset: undefined + }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const eventTypeItems = useMemo( + () => ALL_EVENT_TYPES.map((value) => ({ id: value, label: getBillingEventTypeLabel(value) })), + [] + ); + + const handleEventTypesChange = (values: string[]) => { + const next = values as BillingEventType[]; + navigate({ + to: "/billing-events", + search: (previous) => ({ + search: previous.search, + orderBy: previous.orderBy as SortableBillingEventProperties | undefined, + sortOrder: previous.sortOrder, + eventTypes: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ +
+ +
+
+ ); +} diff --git a/application/account/BackOffice/routes/billing-events/index.tsx b/application/account/BackOffice/routes/billing-events/index.tsx new file mode 100644 index 0000000000..da9ff83114 --- /dev/null +++ b/application/account/BackOffice/routes/billing-events/index.tsx @@ -0,0 +1,126 @@ +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 { ZapIcon } from "lucide-react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api, BillingEventType, SortableBillingEventProperties, SortOrder } from "@/shared/lib/api/client"; + +import { BillingEventsTable } from "./-components/BillingEventsTable"; +import { BillingEventsToolbar } from "./-components/BillingEventsToolbar"; + +const billingEventsSearchSchema = z.object({ + search: z.string().optional(), + eventTypes: z.array(z.nativeEnum(BillingEventType)).max(25).optional(), + orderBy: z.nativeEnum(SortableBillingEventProperties).optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/billing-events/")({ + staticData: { trackingTitle: "Billing events" }, + validateSearch: billingEventsSearchSchema, + component: BillingEventsListPage +}); + +function BillingEventsListPage() { + const { search, eventTypes, orderBy, sortOrder, pageOffset } = Route.useSearch(); + const navigate = useNavigate(); + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/billing-events", + { + params: { + query: { + Search: search, + EventTypes: eventTypes, + OrderBy: orderBy, + SortOrder: sortOrder, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const billingEvents = data?.billingEvents ?? []; + const hasFilters = Boolean(search) || (eventTypes?.length ?? 0) > 0; + const showEmpty = !isLoading && billingEvents.length === 0; + + return ( + + + + + + + {showEmpty ? ( + + + + + + + {hasFilters ? ( + No billing events match your filters + ) : ( + No billing events yet + )} + + + {hasFilters ? ( + Try clearing the search or filters to see more results. + ) : ( + + Subscription, payment, and billing transitions will appear here as Stripe webhooks are processed. + + )} + + + {hasFilters && ( + + + + )} + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/index.tsx b/application/account/BackOffice/routes/index.tsx index 22876395bb..95f421a70e 100644 --- a/application/account/BackOffice/routes/index.tsx +++ b/application/account/BackOffice/routes/index.tsx @@ -5,6 +5,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { DashboardSections } from "./-components/DashboardSections"; + export const Route = createFileRoute("/")({ staticData: { trackingTitle: "Back office dashboard" }, component: DashboardPage @@ -15,12 +17,8 @@ function DashboardPage() { - - {null} + + diff --git a/application/account/BackOffice/routes/users/$userId.tsx b/application/account/BackOffice/routes/users/$userId.tsx new file mode 100644 index 0000000000..5c5ae99c8e --- /dev/null +++ b/application/account/BackOffice/routes/users/$userId.tsx @@ -0,0 +1,96 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@repo/ui/components/Tabs"; +import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { Building2Icon, KeyIcon, MonitorIcon } from "lucide-react"; +import { useCallback } from "react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api } from "@/shared/lib/api/client"; + +import { UserActivityTiles } from "./-components/UserActivityTiles"; +import { UserDetailHeader } from "./-components/UserDetailHeader"; +import { getUserDisplayName } from "./-components/userDisplay"; +import { UserLoginHistorySection } from "./-components/UserLoginHistorySection"; +import { UserSessionsSection } from "./-components/UserSessionsSection"; +import { UserTenantsSection } from "./-components/UserTenantsSection"; + +type UserDetailTab = "overview" | "sessions" | "logins"; + +const userDetailSearchSchema = z.object({ + tab: z.enum(["overview", "sessions", "logins"]).optional() +}); + +export const Route = createFileRoute("/users/$userId")({ + staticData: { trackingTitle: "User detail" }, + validateSearch: userDetailSearchSchema, + component: UserDetailPage +}); + +function UserDetailPage() { + const { userId } = Route.useParams(); + const { tab } = Route.useSearch(); + const navigate = useNavigate({ from: Route.fullPath }); + const activeTab = tab ?? "overview"; + + const setActiveTab = useCallback( + (value: string) => { + const next = value as UserDetailTab; + navigate({ + search: { tab: next === "overview" ? undefined : next }, + replace: true + }); + }, + [navigate] + ); + + const userQuery = api.useQuery("get", "/api/back-office/users/{id}", { + params: { path: { id: userId } } + }); + + const user = userQuery.data; + + const browserTitle = user ? getUserDisplayName(user.firstName, user.lastName, user.email) : t`User detail`; + + return ( + + + + +
+ + + + + + + Accounts + + + + Logins + + + + Sessions + + + + + + + + + + + + +
+
+
+
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx new file mode 100644 index 0000000000..cd43cf99c2 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserActivityTiles.tsx @@ -0,0 +1,120 @@ +import type { ReactNode } from "react"; + +import { plural, t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Card } from "@repo/ui/components/Card"; +import { LinkCard } from "@repo/ui/components/LinkCard"; +import { Skeleton } from "@repo/ui/components/Skeleton"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; + +interface UserActivityTilesProps { + user: BackOfficeUserDetailResponse | undefined; + userId: string; + isLoading: boolean; +} + +export function UserActivityTiles({ user, userId, isLoading }: Readonly) { + const sessionsQuery = api.useQuery("get", "/api/back-office/users/{id}/sessions", { + params: { path: { id: userId } } + }); + const sessionsLoading = sessionsQuery.isLoading; + const totalSessions = sessionsQuery.data?.totalCount; + const tenantCount = user?.tenantMemberships.length ?? 0; + + return ( +
+ + {user ? tenantCount : "-"} + + + Most recent activity : Never logged in} + linkTo={user?.lastSeenAt ? "logins" : undefined} + userId={userId} + > + + {user?.lastSeenAt ? : "-"} + + + + All-time : undefined} + linkTo={totalSessions !== undefined && totalSessions > 0 ? "sessions" : undefined} + userId={userId} + > + {totalSessions !== undefined ? totalSessions : "-"} + +
+ ); +} + +function ActivityTile({ + label, + loading, + subtitle, + children, + linkTo, + userId +}: Readonly<{ + label: string; + loading: boolean; + subtitle?: ReactNode; + children: ReactNode; + linkTo?: "overview" | "logins" | "sessions"; + userId?: string; +}>) { + const content = ( + <> + {label} + {loading ? ( + <> + + + + ) : ( + <> + {children} + {subtitle && {subtitle}} + + )} + + ); + + if (linkTo && userId) { + return ( + + {content} + + ); + } + + return {content}; +} diff --git a/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx new file mode 100644 index 0000000000..410d7f4762 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserDetailHeader.tsx @@ -0,0 +1,86 @@ +import { Trans } from "@lingui/react/macro"; +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { CalendarIcon, CheckCircle2Icon, HashIcon, MailIcon, XCircleIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { getUserDisplayName, getUserInitials } from "./userDisplay"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; + +interface UserDetailHeaderProps { + user: BackOfficeUserDetailResponse | undefined; + userId: string; + isLoading: boolean; +} + +export function UserDetailHeader({ user, userId, isLoading }: Readonly) { + const formatDate = useFormatDate(); + + return ( +
+ {isLoading || !user ? ( + <> + +
+ + +
+ + ) : ( + <> + + {user.avatarUrl && ( + + )} + + {getUserInitials(user.firstName, user.lastName, user.email)} + + +
+
+

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

+ {user.emailConfirmed ? ( + + + + Email confirmed + + + ) : ( + + + + Email pending + + + )} +
+
+ + + {user.email} + + + + + Created {formatDate(user.createdAt, false, false, true)} + {formatDate(user.createdAt)} + + + + + {userId} + +
+
+ + )} +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx new file mode 100644 index 0000000000..5750449448 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserLoginHistorySection.tsx @@ -0,0 +1,105 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api, LoginEventOutcome } from "@/shared/lib/api/client"; +import { getLoginMethodLabel } from "@/shared/lib/api/labels"; + +interface UserLoginHistorySectionProps { + userId: string; +} + +export function UserLoginHistorySection({ userId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/users/{id}/login-history", { + params: { path: { id: userId } } + }); + + return ( +
+
+ + Every sign-in attempt over the last 30 days, successful or failed, across email and external providers. + +
+ {isLoading ? ( + + ) : !data || data.entries.length === 0 ? ( + + + + No login history + + + No sign-in attempts in the last 30 days. + + + + ) : ( + + + + + When + + + Method + + + Outcome + + + + + {data.entries.map((entry, index) => ( + + + + + {getLoginMethodLabel(entry.method)} + + + + + ))} + +
+ )} +
+ ); +} + +function OutcomeBadge({ + outcome, + failureReason +}: Readonly<{ outcome: LoginEventOutcome; failureReason: string | null }>) { + if (outcome === LoginEventOutcome.Succeeded) { + return ( + + Succeeded + + ); + } + if (failureReason) { + return ( + + {failureReason} + + ); + } + if (outcome === LoginEventOutcome.Pending) { + return ( + + Pending + + ); + } + return ( + + Failed + + ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx new file mode 100644 index 0000000000..93f22e1d49 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserSessionsSection.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { api } from "@/shared/lib/api/client"; +import { getDeviceTypeLabel, getLoginMethodLabel } from "@/shared/lib/api/labels"; +import { parseUserAgent } from "@/shared/lib/userAgent"; + +interface UserSessionsSectionProps { + userId: string; +} + +export function UserSessionsSection({ userId }: Readonly) { + const { data, isLoading } = api.useQuery("get", "/api/back-office/users/{id}/sessions", { + params: { path: { id: userId } } + }); + + return ( +
+
+ One row per device or browser the user is signed in from. Revoked sessions cannot sign in again. +
+ {isLoading ? ( + + ) : !data || data.sessions.length === 0 ? ( + + + + No sessions + + + This user has no recorded sessions. + + + + ) : ( + + + + + Last seen + + + Account + + + Browser + + + Device + + + Method + + + IP address + + + Status + + + + + {data.sessions.map((session) => { + const { browser, os } = parseUserAgent(session.userAgent); + return ( + + + + + +
+ + {session.tenantName} +
+
+ +
+ {browser} + {os} +
+
+ {getDeviceTypeLabel(session.deviceType)} + {getLoginMethodLabel(session.loginMethod)} + {session.ipAddress} + + {session.revokedAt ? ( + + Revoked + + ) : ( + + Active + + )} + +
+ ); + })} +
+
+ )} +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx new file mode 100644 index 0000000000..a74fd73b01 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UserTenantsSection.tsx @@ -0,0 +1,161 @@ +import { useLingui } from "@lingui/react"; +import { Trans } from "@lingui/react/macro"; +import { Badge } from "@repo/ui/components/Badge"; +import { Card } from "@repo/ui/components/Card"; +import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "@repo/ui/components/Empty"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { TenantLogo } from "@repo/ui/components/TenantLogo"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { getCountryFlagEmoji, getCountryName } from "@repo/ui/utils/countryFlag"; +import { formatCurrency } from "@repo/utils/currency/formatCurrency"; +import { Link } from "@tanstack/react-router"; +import { CalendarIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { TenantStatusBadge } from "@/routes/accounts/-components/TenantStatusBadge"; +import { PlannedSubscriptionChange } from "@/shared/lib/api/client"; +import { getSubscriptionPlanLabel, getUserRoleLabel } from "@/shared/lib/api/labels"; +import { getSubscriptionPlanBadgeClass } from "@/shared/lib/planBadge"; + +type BackOfficeUserDetailResponse = components["schemas"]["BackOfficeUserDetailResponse"]; +type Membership = BackOfficeUserDetailResponse["tenantMemberships"][number]; + +interface UserTenantsSectionProps { + user: BackOfficeUserDetailResponse | undefined; +} + +export function UserTenantsSection({ user }: Readonly) { + return ( +
+
+ All accounts this user is a member of, with their plan and role. +
+ {!user ? ( + + ) : user.tenantMemberships.length === 0 ? ( + + + + No account memberships + + + This user is not a member of any account. + + + + ) : ( +
+ {user.tenantMemberships.map((membership) => ( + + ))} +
+ )} +
+ ); +} + +function MembershipCard({ membership }: Readonly<{ membership: Membership }>) { + const { i18n } = useLingui(); + const formatDate = useFormatDate(); + const isCanceling = membership.plannedChange === PlannedSubscriptionChange.Cancellation; + const isDowngrading = membership.plannedChange === PlannedSubscriptionChange.ScheduledPlanChange; + const currentMrr = + membership.monthlyRecurringRevenue !== null && membership.currency !== null + ? formatCurrency(membership.monthlyRecurringRevenue, membership.currency) + : null; + const newMrr = + isCanceling && membership.currency !== null + ? formatCurrency(0, membership.currency) + : isDowngrading && membership.scheduledPriceAmount !== null && membership.currency !== null + ? formatCurrency(membership.scheduledPriceAmount, membership.currency) + : null; + return ( + + +
+ +
+
+ {membership.tenantName} + {membership.country && ( + + + {getCountryName(membership.country, i18n.locale)} + + )} +
+
+ {getUserRoleLabel(membership.role)} + + + {getSubscriptionPlanLabel(membership.plan)} + + {!membership.emailConfirmed && ( + + Email pending + + )} +
+
+
+ {/* Narrow (mobile) layout: stacked with divider, plan + renews on left, prices on right */} +
+
+ + {getSubscriptionPlanLabel(membership.plan)} + + {membership.renewalDate && ( + + + Renews {formatDate(membership.renewalDate)} + + )} +
+
+
+ {newMrr && {currentMrr}} + {currentMrr && {newMrr ?? currentMrr}} +
+ {currentMrr && ( + + / month + + )} +
+
+ {/* Wide layout: prices + /month inline, renews below, all right-aligned next to the badges */} +
+ {currentMrr && ( +
+ {newMrr && {currentMrr}} + {newMrr ?? currentMrr} + + / month + +
+ )} + {membership.renewalDate && ( + + + Renews {formatDate(membership.renewalDate)} + + )} +
+ +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersTable.tsx b/application/account/BackOffice/routes/users/-components/UsersTable.tsx new file mode 100644 index 0000000000..24fd965240 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersTable.tsx @@ -0,0 +1,116 @@ +import type { RowKey } from "@repo/ui/components/Table"; + +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Skeleton } from "@repo/ui/components/Skeleton"; +import { Table, TableBody, TableHead, TableHeader, TableRow } from "@repo/ui/components/Table"; +import { TablePagination } from "@repo/ui/components/TablePagination"; +import { useFormatDate } from "@repo/ui/hooks/useSmartDate"; +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; + +import type { components } from "@/shared/lib/api/client"; + +import { UsersTableRow } from "./UsersTableRow"; + +type BackOfficeUserSummary = components["schemas"]["BackOfficeUserSummary"]; + +interface UsersTableProps { + users: BackOfficeUserSummary[]; + isLoading: boolean; + totalPages: number; + currentPageOffset: number; +} + +export function UsersTable({ users, isLoading, totalPages, currentPageOffset }: Readonly) { + const navigate = useNavigate(); + const formatDate = useFormatDate(); + + const handleActivate = useCallback( + (key: RowKey) => { + const user = users.find((entry) => entry.id === key); + if (!user) return; + navigate({ to: "/users/$userId", params: { userId: user.id } }); + }, + [navigate, users] + ); + + const handlePageChange = useCallback( + (page: number) => { + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + pageOffset: page === 1 ? undefined : page - 1 + }) + }); + }, + [navigate] + ); + + if (isLoading && users.length === 0) { + return ( +
+ {Array.from({ length: 8 }).map((_, index) => ( + + ))} +
+ ); + } + + const currentPage = currentPageOffset + 1; + + return ( + <> +
+ + + + + User + + + Account + + + Role + + + Last seen + + + Created + + + + + {users.map((user) => ( + + ))} + +
+
+ + {totalPages > 1 && ( +
+ +
+ )} + + ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx new file mode 100644 index 0000000000..10399a8d4b --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersTableRow.tsx @@ -0,0 +1,61 @@ +import { Avatar, AvatarFallback, AvatarImage } from "@repo/ui/components/Avatar"; +import { Badge } from "@repo/ui/components/Badge"; +import { TableCell, TableRow } from "@repo/ui/components/Table"; +import { MailIcon } from "lucide-react"; + +import type { components } from "@/shared/lib/api/client"; + +import { SmartDateTime } from "@/shared/components/SmartDateTime"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +import { getUserDisplayName, getUserInitials } from "./userDisplay"; + +type BackOfficeUserSummary = components["schemas"]["BackOfficeUserSummary"]; + +export function UsersTableRow({ + user, + formatDate +}: Readonly<{ + user: BackOfficeUserSummary; + formatDate: (value: string | null | undefined, includeTime?: boolean) => string; +}>) { + const displayName = getUserDisplayName(user.firstName, user.lastName, user.email); + const initials = getUserInitials(user.firstName, user.lastName, user.email); + + return ( + + +
+ + {user.avatarUrl && } + {initials} + +
+ {displayName} + + + {user.email} + +
+
+
+ + {user.tenantName} + + + {getUserRoleLabel(user.role)} + + + {user.lastSeenAt ? ( +
+ + {formatDate(user.lastSeenAt, true)} +
+ ) : ( + - + )} +
+ {formatDate(user.createdAt)} +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx b/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx new file mode 100644 index 0000000000..5b93618e45 --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/UsersToolbar.tsx @@ -0,0 +1,125 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@repo/ui/components/InputGroup"; +import { ToggleGroup, ToggleGroupItem } from "@repo/ui/components/ToggleGroup"; +import { useDebounce } from "@repo/ui/hooks/useDebounce"; +import { useNavigate } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; + +import { UserActivityFilter, UserRole } from "@/shared/lib/api/client"; +import { getUserRoleLabel } from "@/shared/lib/api/labels"; + +interface UsersToolbarProps { + search: string | undefined; + roles: UserRole[]; + activity: UserActivityFilter | undefined; +} + +export function UsersToolbar({ search, roles, activity }: Readonly) { + const navigate = useNavigate(); + const [searchInput, setSearchInput] = useState(search ?? ""); + const debouncedSearch = useDebounce(searchInput, 500); + + useEffect(() => { + if ((debouncedSearch || undefined) === search) { + return; + } + navigate({ + to: "/users", + search: (previous) => ({ ...previous, search: debouncedSearch || undefined, pageOffset: undefined }) + }); + }, [debouncedSearch, navigate, search]); + + useEffect(() => { + setSearchInput(search ?? ""); + }, [search]); + + const handleRolesChange = (values: string[]) => { + const next = values as UserRole[]; + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + roles: next.length === 0 ? undefined : next, + pageOffset: undefined + }) + }); + }; + + // Activity is single-select on the backend; the toggle group exposes a multi-select array, so we keep only the + // newly toggled value (last item in the array) and clear when the user deselects. + const handleActivityChange = (values: string[]) => { + const next = values.length === 0 ? undefined : (values[values.length - 1] as UserActivityFilter); + navigate({ + to: "/users", + search: (previous) => ({ + ...previous, + activity: next, + pageOffset: undefined + }) + }); + }; + + return ( +
+
+ + + + + setSearchInput(event.target.value)} + onKeyDown={(event) => event.key === "Escape" && searchInput && setSearchInput("")} + /> + {searchInput && ( + + setSearchInput("")} size="icon-xs" aria-label={t`Clear search`}> + + + + )} + +
+ + + {[UserRole.Owner, UserRole.Admin, UserRole.Member].map((value) => ( + + {getUserRoleLabel(value)} + + ))} + + + + + 24h + + + 7 days + + + 30 days + + + Inactive + + +
+ ); +} diff --git a/application/account/BackOffice/routes/users/-components/userDisplay.ts b/application/account/BackOffice/routes/users/-components/userDisplay.ts new file mode 100644 index 0000000000..25b9e497ee --- /dev/null +++ b/application/account/BackOffice/routes/users/-components/userDisplay.ts @@ -0,0 +1,15 @@ +export function getUserDisplayName(firstName: string | null, lastName: string | null, email: string): string { + if (firstName && lastName) return `${firstName} ${lastName}`; + if (firstName) return firstName; + if (lastName) return lastName; + return email; +} + +export function getUserInitials(firstName: string | null, lastName: string | null, email: string): string { + if (firstName && lastName) { + return `${firstName[0]}${lastName[0]}`.toUpperCase(); + } + if (firstName) return firstName.slice(0, 2).toUpperCase(); + if (lastName) return lastName.slice(0, 2).toUpperCase(); + return email.slice(0, 2).toUpperCase(); +} diff --git a/application/account/BackOffice/routes/users/index.tsx b/application/account/BackOffice/routes/users/index.tsx new file mode 100644 index 0000000000..d62c055430 --- /dev/null +++ b/application/account/BackOffice/routes/users/index.tsx @@ -0,0 +1,110 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { AppLayout } from "@repo/ui/components/AppLayout"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@repo/ui/components/Empty"; +import { SidebarInset, SidebarProvider } from "@repo/ui/components/Sidebar"; +import { keepPreviousData } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { UsersIcon } from "lucide-react"; +import { z } from "zod"; + +import { BackOfficeSideMenu } from "@/shared/components/BackOfficeSideMenu"; +import { api, UserActivityFilter, UserRole } from "@/shared/lib/api/client"; + +import { UsersTable } from "./-components/UsersTable"; +import { UsersToolbar } from "./-components/UsersToolbar"; + +const usersSearchSchema = z.object({ + search: z.string().optional(), + roles: z.array(z.nativeEnum(UserRole)).max(10).optional(), + activity: z.nativeEnum(UserActivityFilter).optional(), + pageOffset: z.number().int().nonnegative().optional() +}); + +export const Route = createFileRoute("/users/")({ + staticData: { trackingTitle: "Users" }, + validateSearch: usersSearchSchema, + component: UsersSearchPage +}); + +function UsersSearchPage() { + const { search, roles, activity, pageOffset } = Route.useSearch(); + const trimmed = search?.trim() ?? ""; + const hasSearchOrFilter = trimmed.length > 0 || (roles?.length ?? 0) > 0 || activity !== undefined; + + const { data, isLoading } = api.useQuery( + "get", + "/api/back-office/users", + { + params: { + query: { + Search: trimmed.length > 0 ? trimmed : undefined, + Roles: roles, + Activity: activity, + PageOffset: pageOffset + } + } + }, + { placeholderData: keepPreviousData } + ); + + const users = data?.users ?? []; + const showNoResults = !isLoading && users.length === 0 && hasSearchOrFilter; + const showEmpty = !isLoading && users.length === 0 && !hasSearchOrFilter; + + return ( + + + + + + + {showNoResults ? ( + + + + + + + No users match your search + + + Try a different search term or clear the role and activity filters. + + + + ) : showEmpty ? ( + + + + + + + No users yet + + + Users will appear here as accounts are created. + + + + ) : ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeBanners.tsx b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx new file mode 100644 index 0000000000..0269e91caf --- /dev/null +++ b/application/account/BackOffice/shared/components/BackOfficeBanners.tsx @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { createPortal } from "react-dom"; + +import { BillingDriftBanner } from "./BillingDriftBanner"; +import { MrrMismatchBanner } from "./MrrMismatchBanner"; +import { UnsyncedAccountsBanner } from "./UnsyncedAccountsBanner"; + +/** + * Portals all back-office banners into the fixed-top BannerPortal target so they render above the + * sidebar and content rather than being clipped by the layout. The user-facing Banners federated + * module relies on a lazy boundary to defer mount until BannerPortal's DOM is committed; we render + * synchronously, so the target lookup runs in useEffect to avoid the first-render race. + */ +export function BackOfficeBanners() { + const [target, setTarget] = useState(null); + + useEffect(() => { + setTarget(document.getElementById("banner-root")); + }, []); + + if (!target) { + return null; + } + + return createPortal( + <> + + + + , + target + ); +} diff --git a/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx b/application/account/BackOffice/shared/components/BackOfficeSideMenu.tsx index f615189672..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"; @@ -22,6 +22,9 @@ 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/"); + const isUsersActive = currentPath === "/users" || currentPath.startsWith("/users/"); + const isBillingEventsActive = currentPath === "/billing-events" || currentPath.startsWith("/billing-events/"); return ( @@ -46,6 +49,36 @@ export function BackOfficeSideMenu() { + + + + + + Accounts + + + + + + + + + + Users + + + + + + + + + + Billing events + + + + @@ -55,22 +88,6 @@ export function BackOfficeSideMenu() { - - - - - Accounts - - - - - - - - Users - - - diff --git a/application/account/BackOffice/shared/components/BillingDriftBanner.tsx b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx new file mode 100644 index 0000000000..2f131e47ad --- /dev/null +++ b/application/account/BackOffice/shared/components/BillingDriftBanner.tsx @@ -0,0 +1,39 @@ +import { Trans } from "@lingui/react/macro"; +import { useUserInfo } from "@repo/infrastructure/auth/hooks"; +import { Button } from "@repo/ui/components/Button"; +import { Link } from "@tanstack/react-router"; +import { AlertTriangleIcon } from "lucide-react"; + +import { api } from "@/shared/lib/api/client"; + +/** + * Global banner that surfaces accounts with detected billing drift. Renders only when at least one + * subscription has unacknowledged drift, so the banner is invisible in a healthy system. Click-through + * navigates to /accounts. + */ +export function BillingDriftBanner() { + const userInfo = useUserInfo(); + const { data } = api.useQuery( + "get", + "/api/back-office/billing-drift/summary", + {}, + { enabled: userInfo?.isAuthenticated === true, refetchInterval: 60_000 } + ); + + const count = data?.subscriptionsWithDriftCount ?? 0; + if (count === 0) { + return null; + } + + return ( +
+ + + {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/SmartDateTime.tsx b/application/account/BackOffice/shared/components/SmartDateTime.tsx new file mode 100644 index 0000000000..839eb31778 --- /dev/null +++ b/application/account/BackOffice/shared/components/SmartDateTime.tsx @@ -0,0 +1,70 @@ +import { plural, t } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react"; +import { useFormatDate, useSmartDate } from "@repo/ui/hooks/useSmartDate"; + +interface SmartDateTimeProps { + date: string | undefined | null; + className?: string; + /** + * Append the clock time to older entries (e.g. "Yesterday, 14:02"). Default is `false` — + * for billing/business surfaces a relative phrase is enough. Opt in only on security-sensitive + * surfaces (login history, sessions, last-seen) where the exact time matters. + */ + withTime?: boolean; +} + +/** + * Displays a relative timestamp that auto-updates every 10 seconds. + * + * Examples without time (default): "Just now", "12 minutes ago", "1 hour ago", "Yesterday", + * "2 days ago", "Apr 22". + * Examples with time (`withTime`): "Just now", "12 minutes ago", "1 hour ago, 14:02", + * "Yesterday, 14:02", "2 days ago, 09:15", "Apr 22, 02:41". + */ +export function SmartDateTime({ date, className, withTime = false }: Readonly) { + const result = useSmartDate(date); + const formatDate = useFormatDate(); + const { i18n } = useLingui(); + + if (!result || !date) { + return null; + } + + const formatTime = () => + new Intl.DateTimeFormat(i18n.locale, { hour: "2-digit", minute: "2-digit" }).format(new Date(date)); + + let text: string; + switch (result.type) { + case "justNow": + text = t`Just now`; + break; + case "minutesAgo": + text = plural(result.value, { one: "# minute ago", other: "# minutes ago" }); + break; + case "hoursAgo": { + const relative = plural(result.value, { one: "# hour ago", other: "# hours ago" }); + text = withTime ? `${relative}, ${formatTime()}` : relative; + break; + } + case "date": { + const target = new Date(date); + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + const targetStart = new Date(target); + targetStart.setHours(0, 0, 0, 0); + const diffDays = Math.round((todayStart.getTime() - targetStart.getTime()) / 86400000); + let dayPart: string; + if (diffDays === 1) { + dayPart = t`Yesterday`; + } else if (diffDays >= 2 && diffDays <= 5) { + dayPart = plural(diffDays, { one: "# day ago", other: "# days ago" }); + } else { + dayPart = formatDate(date); + } + text = withTime ? `${dayPart}, ${formatTime()}` : dayPart; + break; + } + } + + return {text}; +} diff --git a/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx b/application/account/BackOffice/shared/components/UnsyncedAccountsBanner.tsx new file mode 100644 index 0000000000..3fb886920e --- /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/lib/api/labels.ts b/application/account/BackOffice/shared/lib/api/labels.ts index 95b08ab9e8..1b0463ecb3 100644 --- a/application/account/BackOffice/shared/lib/api/labels.ts +++ b/application/account/BackOffice/shared/lib/api/labels.ts @@ -1,6 +1,15 @@ import { t } from "@lingui/core/macro"; -import { SubscriptionPlan, UserRole } from "@/shared/lib/api/client"; +import { + BillingEventType, + DeviceType, + LoginMethod, + PaymentTransactionStatus, + PlannedSubscriptionChange, + SubscriptionPlan, + TenantState, + UserRole +} from "@/shared/lib/api/client"; export function getSubscriptionPlanLabel(plan: SubscriptionPlan): string { switch (plan) { @@ -15,6 +24,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`Paid`; + 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: @@ -27,3 +73,76 @@ export function getUserRoleLabel(role: UserRole): string { return String(role); } } + +export function getDeviceTypeLabel(deviceType: DeviceType): string { + switch (deviceType) { + case DeviceType.Desktop: + return t`Desktop`; + case DeviceType.Mobile: + return t`Mobile`; + case DeviceType.Tablet: + return t`Tablet`; + case DeviceType.Unknown: + return t`Unknown`; + default: + return String(deviceType); + } +} + +export function getLoginMethodLabel(method: LoginMethod): string { + switch (method) { + case LoginMethod.OneTimePassword: + return t`One-time password`; + case LoginMethod.Google: + return t`Google`; + default: + return String(method); + } +} + +export function getBillingEventTypeLabel(type: BillingEventType): string { + switch (type) { + case BillingEventType.SubscriptionCreated: + return t`Subscribed`; + case BillingEventType.SubscriptionRenewed: + return t`Renewed`; + case BillingEventType.SubscriptionUpgraded: + return t`Upgraded`; + case BillingEventType.SubscriptionDowngradeScheduled: + return t`Downgrade scheduled`; + case BillingEventType.SubscriptionDowngradeCancelled: + return t`Downgrade cancelled`; + case BillingEventType.SubscriptionDowngraded: + return t`Downgraded`; + case BillingEventType.SubscriptionCancelled: + return t`Cancelled`; + case BillingEventType.SubscriptionReactivated: + return t`Reactivated`; + case BillingEventType.SubscriptionExpired: + return t`Expired`; + case BillingEventType.SubscriptionImmediatelyCancelled: + return t`Cancelled immediately`; + case BillingEventType.SubscriptionSuspended: + return t`Suspended`; + case BillingEventType.SubscriptionPastDue: + return t`Past due`; + case BillingEventType.PaymentFailed: + return t`Payment failed`; + case BillingEventType.PaymentRecovered: + return t`Payment recovered`; + case BillingEventType.PaymentRefunded: + return t`Payment refunded`; + case BillingEventType.BillingInfoAdded: + return t`Billing info added`; + case BillingEventType.BillingInfoUpdated: + return t`Billing info updated`; + case BillingEventType.PaymentMethodUpdated: + return t`Payment method updated`; + case BillingEventType.NoOp: + return t`No change`; + case BillingEventType.Unclassified: + return t`Unclassified`; + default: + return String(type); + } +} diff --git a/application/account/BackOffice/shared/lib/billingEventStyle.ts b/application/account/BackOffice/shared/lib/billingEventStyle.ts new file mode 100644 index 0000000000..9085cf08e5 --- /dev/null +++ b/application/account/BackOffice/shared/lib/billingEventStyle.ts @@ -0,0 +1,111 @@ +import { + ArrowDownRightIcon, + ArrowUpRightIcon, + CalendarClockIcon, + CircleAlertIcon, + CircleCheckIcon, + CircleSlashIcon, + CircleXIcon, + CreditCardIcon, + PauseCircleIcon, + RefreshCwIcon, + ReplyIcon, + RotateCcwIcon, + TriangleAlertIcon, + WalletIcon +} from "lucide-react"; + +import { BillingEventType } from "@/shared/lib/api/client"; + +export interface BillingEventVariant { + className: string; + icon: React.ComponentType<{ className?: string; "aria-hidden"?: boolean | "true" | "false" }>; +} + +/** + * Centralised badge styling for the BillingEventType enum. Used by the dashboard "Recent billing events" + * card and the /billing-events table so the colour and icon are consistent everywhere a billing event is + * surfaced. + */ +export const BILLING_EVENT_VARIANT: Record = { + [BillingEventType.SubscriptionCreated]: { + className: "bg-emerald-500/10 text-emerald-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.SubscriptionPastDue]: { + className: "bg-amber-500/10 text-amber-600 border-amber-500/30", + icon: CircleAlertIcon + }, + [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 + }, + [BillingEventType.NoOp]: { + className: "bg-muted text-muted-foreground border-border", + icon: CircleSlashIcon + }, + [BillingEventType.Unclassified]: { + className: "bg-amber-500/10 text-amber-600 border-amber-500/30", + icon: TriangleAlertIcon + } +}; diff --git a/application/account/BackOffice/shared/lib/planBadge.ts b/application/account/BackOffice/shared/lib/planBadge.ts new file mode 100644 index 0000000000..9a744b6678 --- /dev/null +++ b/application/account/BackOffice/shared/lib/planBadge.ts @@ -0,0 +1,14 @@ +import { SubscriptionPlan } from "@/shared/lib/api/client"; + +export function getSubscriptionPlanBadgeClass(plan: SubscriptionPlan): string { + switch (plan) { + case SubscriptionPlan.Basis: + return "border-transparent bg-muted text-foreground"; + case SubscriptionPlan.Standard: + return "border-transparent bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300"; + case SubscriptionPlan.Premium: + return "border-transparent bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300"; + default: + return ""; + } +} diff --git a/application/account/BackOffice/shared/lib/relativeTime.ts b/application/account/BackOffice/shared/lib/relativeTime.ts new file mode 100644 index 0000000000..3f0a48e5ca --- /dev/null +++ b/application/account/BackOffice/shared/lib/relativeTime.ts @@ -0,0 +1,24 @@ +const SECOND_MS = 1000; +const MINUTE_MS = 60 * SECOND_MS; +const HOUR_MS = 60 * MINUTE_MS; +const DAY_MS = 24 * HOUR_MS; +const WEEK_MS = 7 * DAY_MS; +const MONTH_MS = 30 * DAY_MS; +const YEAR_MS = 365 * DAY_MS; + +export function formatRelativeTime(input: string | null | undefined, locale: string): string { + if (!input) { + return ""; + } + const date = new Date(input); + const diffMs = date.getTime() - Date.now(); + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + const absMs = Math.abs(diffMs); + if (absMs < MINUTE_MS) return formatter.format(Math.round(diffMs / SECOND_MS), "second"); + if (absMs < HOUR_MS) return formatter.format(Math.round(diffMs / MINUTE_MS), "minute"); + if (absMs < DAY_MS) return formatter.format(Math.round(diffMs / HOUR_MS), "hour"); + if (absMs < WEEK_MS) return formatter.format(Math.round(diffMs / DAY_MS), "day"); + if (absMs < MONTH_MS) return formatter.format(Math.round(diffMs / WEEK_MS), "week"); + if (absMs < YEAR_MS) return formatter.format(Math.round(diffMs / MONTH_MS), "month"); + return formatter.format(Math.round(diffMs / YEAR_MS), "year"); +} diff --git a/application/account/BackOffice/shared/lib/userAgent.ts b/application/account/BackOffice/shared/lib/userAgent.ts new file mode 100644 index 0000000000..c51bd2ff71 --- /dev/null +++ b/application/account/BackOffice/shared/lib/userAgent.ts @@ -0,0 +1,44 @@ +import { t } from "@lingui/core/macro"; + +// Order matters: more specific patterns first. Modern Chromium-based browsers like Opera and Edge +// also include `Chrome/` in their user-agent string, so the `OPR/` and `Edg/` matches must run +// before `Chrome/`. Firefox occasionally announces a Safari token for compatibility, and every +// Android browser identifies itself as Linux too — keep the platform-specific markers above the +// generic ones so detection lands on the most specific match. +const browserPatterns: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /OPR\/[\d.]+/, name: "Opera" }, + { pattern: /Edg\/[\d.]+/, name: "Edge" }, + { pattern: /Firefox\/[\d.]+/, name: "Firefox" }, + { pattern: /Chrome\/[\d.]+/, name: "Chrome" }, + { pattern: /Safari\/[\d.]+/, name: "Safari" } +]; + +const osPatterns: Array<{ pattern: RegExp; name: string }> = [ + { pattern: /Android/, name: "Android" }, + { pattern: /iPhone|iPad/, name: "iOS" }, + { pattern: /Windows NT/, name: "Windows" }, + { pattern: /Mac OS X/, name: "macOS" }, + { pattern: /Linux/, name: "Linux" } +]; + +export function parseUserAgent(userAgent: string): { browser: string; os: string } { + const unknown = t`Unknown`; + let browser = unknown; + let os = unknown; + + for (const { pattern, name } of browserPatterns) { + if (pattern.test(userAgent)) { + browser = name; + break; + } + } + + for (const { pattern, name } of osPatterns) { + if (pattern.test(userAgent)) { + os = name; + break; + } + } + + return { browser, os }; +} diff --git a/application/account/BackOffice/shared/translations/locale/da-DK.po b/application/account/BackOffice/shared/translations/locale/da-DK.po index 7389529d12..266622d9fb 100644 --- a/application/account/BackOffice/shared/translations/locale/da-DK.po +++ b/application/account/BackOffice/shared/translations/locale/da-DK.po @@ -13,27 +13,193 @@ msgstr "" "Plural-Forms: \n" "X-Generator: @lingui/cli\n" +#. placeholder {0}: result.value +msgid "{0, plural, one {# hour ago} other {# hours ago}}" +msgstr "{0, plural, one {# time siden} other {# timer siden}}" + +#. placeholder {0}: result.value +msgid "{0, plural, one {# minute ago} other {# minutes ago}}" +msgstr "{0, plural, one {# minut siden} other {# minutter siden}}" + +#. placeholder {0}: formatCurrency(blended, currency) +msgid "{0} blended" +msgstr "{0} samlet" + +#. placeholder {0}: formatCurrency(blended, currency) +#. placeholder {1}: formatDelta(deltaPercent) +msgid "{0} blended · {1} over period" +msgstr "{0} samlet · {1} for perioden" + +msgid "{activeUsers} active" +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}}" + +msgid "{inactiveUsers} inactive" +msgstr "{inactiveUsers} inaktive" + +msgid "{pendingUsers} pending" +msgstr "{pendingUsers} afventer" + +msgid "{tenantCount, plural, one {# membership} other {# memberships}}" +msgstr "{tenantCount, plural, one {# medlemskab} other {# medlemskaber}}" + +msgid "{total} accounts" +msgstr "{total} konti" + +msgid "{total} new signups · {priorTotal} prior period" +msgstr "{total} nye tilmeldinger · {priorTotal} forrige periode" + +msgid "{total} total · avg {average}/day" +msgstr "{total} i alt · gns. {average}/dag" + +msgid "/ month" +msgstr "/ måned" + +#. placeholder {0}: data.newTenantsInPeriod +msgid "+{0} new in last {periodDays} days" +msgstr "+{0} nye sidste {periodDays} dage" + +msgid "<0>{totalUsers} total" +msgstr "<0>{totalUsers} i alt" + +msgid "24h" +msgstr "24t" + +msgid "30 days" +msgstr "30 dage" + +msgid "30d" +msgstr "30d" + +msgid "7 days" +msgstr "7 dage" + +msgid "7d" +msgstr "7d" + +msgid "90d" +msgstr "90d" + +msgid "Account" +msgstr "Konto" + +msgid "Account actions" +msgstr "Kontohandlinger" + +msgid "Account growth" +msgstr "Kontovækst" + +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.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" + +msgid "Account users" +msgstr "Kontobrugere" + +msgid "accounts" +msgstr "konti" + msgid "Accounts" msgstr "Konti" -msgid "Accounts (coming soon)" -msgstr "Konti (kommer snart)" +msgid "Accounts will appear here as they are created." +msgstr "Konti vises her, når de oprettes." + +msgid "Active" +msgstr "Aktiv" + +msgid "Active sessions" +msgstr "Aktive sessioner" + +msgid "Activity" +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" + +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" + +msgid "Amount" +msgstr "Beløb" + msgid "An unexpected error occurred while processing your request." msgstr "Der opstod en uventet fejl ved behandlingen." +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.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." + msgid "Back Office" msgstr "Back Office" msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" +msgid "BackOffice overview · {today}" +msgstr "BackOffice oversigt · {today}" + msgid "Basis" msgstr "Basis" +msgid "Billing address" +msgstr "Faktureringsadresse" + +msgid "Billing events" +msgstr "Faktureringshændelser" + +msgid "Billing info added" +msgstr "Faktureringsinfo tilføjet" + +msgid "Billing info updated" +msgstr "Faktureringsinfo opdateret" + +msgid "Blended MRR" +msgstr "Samlet MRR" + +msgid "Browser" +msgstr "Browser" + +msgid "Canceled" +msgstr "Opsagt" + +msgid "Canceling" +msgstr "Opsiges" + +msgid "Cancellation" +msgstr "Opsigelse" + +msgid "Cancelled" +msgstr "Annulleret" + +msgid "Cancelled immediately" +msgstr "Annulleret med det samme" + msgid "Change language" msgstr "Skift sprog" @@ -43,33 +209,148 @@ msgstr "Skift tema" msgid "Change zoom level" msgstr "Skift zoomniveau" +msgid "Clear filter" +msgstr "Ryd filter" + +msgid "Clear filters" +msgstr "Ryd filtre" + +msgid "Clear search" +msgstr "Ryd søgning" + +msgid "Close" +msgstr "Luk" + +msgid "Close account preview" +msgstr "Luk kontoforhåndsvisning" + msgid "Coming soon" msgstr "Kommer snart" msgid "Contact your administrator." msgstr "Kontakt din administrator." +msgid "Country" +msgstr "Land" + +msgid "Created" +msgstr "Oprettet" + +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Oprettet <0>{0}<1>{1}" + +msgid "Credit note" +msgstr "Kreditnota" + +msgid "Current period" +msgstr "Aktuel periode" + +msgid "Current plan" +msgstr "Aktuelt abonnement" + msgid "Dark" msgstr "Mørk" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, 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" + msgid "Default" msgstr "Standard" +msgid "Desktop" +msgstr "Computer" + +msgid "Device" +msgstr "Enhed" + +msgid "Downgrade cancelled" +msgstr "Nedgradering annulleret" + +msgid "Downgrade scheduled" +msgstr "Nedgradering planlagt" + +msgid "Downgraded" +msgstr "Nedgraderet" + +msgid "Downgrading" +msgstr "Nedgraderer" + +msgid "Drift detected" +msgstr "Afvigelser fundet" + +msgid "Email" +msgstr "E-mail" + +msgid "Email confirmed" +msgstr "E-mail bekræftet" + +msgid "Email pending" +msgstr "E-mail afventer" + +msgid "Enter kiosk mode" +msgstr "Aktivér kiosktilstand" + +msgid "Event" +msgstr "Hændelse" + +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 "Exit kiosk mode" +msgstr "Afslut kiosktilstand" + +msgid "Expired" +msgstr "Udløbet" + +msgid "Failed" +msgstr "Mislykket" + msgid "Feature flags" msgstr "Feature flags" msgid "Feature flags (coming soon)" msgstr "Feature flags (kommer snart)" +msgid "Free" +msgstr "Gratis" + msgid "Go to home" msgstr "Gå til forsiden" +msgid "Google" +msgstr "Google" + msgid "Hide details" msgstr "Skjul detaljer" +msgid "Inactive" +msgstr "Inaktive" + +msgid "Invoice" +msgstr "Faktura" + +msgid "Invoices" +msgstr "Fakturaer" + +msgid "IP address" +msgstr "IP-adresse" + +msgid "Just now" +msgstr "Lige nu" + msgid "Language" msgstr "Sprog" @@ -79,6 +360,24 @@ msgstr "Stor" msgid "Larger" msgstr "Større" +msgid "Last {periodDays} days" +msgstr "Sidste {periodDays} dage" + +msgid "Last 24 hours" +msgstr "Sidste 24 timer" + +msgid "Last invoice" +msgstr "Sidste faktura" + +msgid "Last log-in" +msgstr "Seneste login" + +msgid "Last seen" +msgstr "Sidst set" + +msgid "Lifetime value" +msgstr "Livstidsværdi" + msgid "Light" msgstr "Lys" @@ -100,30 +399,216 @@ msgstr "Log ud" msgid "Logging in..." msgstr "Logger ind..." +msgid "Login history" +msgstr "Login-historik" + +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" msgid "Main navigation" msgstr "Hovednavigation" -msgid "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." -msgstr "Administrer konti, se systemdata, se undtagelser og udfør forskellige opgaver for drifts- og supportteams." - msgid "Member" msgstr "Medlem" +msgid "Method" +msgstr "Metode" + +msgid "Mobile" +msgstr "Mobil" + +msgid "Most recent activity" +msgstr "Seneste aktivitet" + +msgid "MRR" +msgstr "MRR" + +msgid "MRR after" +msgstr "MRR efter" + +msgid "MRR impact" +msgstr "MRR-effekt" + +msgid "MRR trend" +msgstr "MRR-tendens" + +msgid "Name" +msgstr "Navn" + msgid "Navigation" msgstr "Navigation" +msgid "Never logged in" +msgstr "Aldrig logget ind" + +msgid "New accounts will appear here as they sign up." +msgstr "Nye konti vises her, når de tilmelder sig." + +msgid "Next" +msgstr "Næste" + +msgid "No account memberships" +msgstr "Ingen kontomedlemskaber" + +msgid "No accounts match your filters" +msgstr "Ingen konti matcher dine filtre" + +msgid "No accounts yet" +msgstr "Ingen konti endnu" + msgid "No back-office access" msgstr "Ingen adgang til Back Office" +msgid "No billing address on file." +msgstr "Ingen faktureringsadresse registreret." + +msgid "No billing events" +msgstr "Ingen faktureringshændelser" + +msgid "No billing events match your filters" +msgstr "Ingen faktureringshændelser matcher dine filtre" + +msgid "No billing events yet" +msgstr "Ingen faktureringshændelser endnu" + +msgid "No change" +msgstr "Ingen ændring" + +msgid "No invoices, refunds, or credit notes yet." +msgstr "Ingen fakturaer, refusioner eller kreditnotaer endnu." + +msgid "No login history" +msgstr "Ingen login-historik" + +msgid "No matching users" +msgstr "Ingen matchende brugere" + +msgid "No new billing events were appended. Account state matches Stripe." +msgstr "Ingen nye faktureringshændelser blev tilføjet. Kontotilstand matcher Stripe." + +msgid "No owners" +msgstr "Ingen ejere" + +msgid "No owners on this account." +msgstr "Ingen ejere på denne konto." + +msgid "No paid plan yet." +msgstr "Intet betalt abonnement endnu." + +msgid "No plan" +msgstr "Intet abonnement" + +msgid "No recent billing events" +msgstr "Ingen nylige faktureringshændelser" + +msgid "No recent signups" +msgstr "Ingen nylige tilmeldinger" + +msgid "No result available." +msgstr "Intet resultat tilgængeligt." + +msgid "No sessions" +msgstr "Ingen sessioner" + +msgid "No sign-in attempts in the last 30 days." +msgstr "Ingen login-forsøg de seneste 30 dage." + +msgid "No transactions" +msgstr "Ingen transaktioner" + +msgid "No users" +msgstr "Ingen brugere" + +msgid "No users match your filters." +msgstr "Ingen brugere matcher dine filtre." + +msgid "No users match your search" +msgstr "Ingen brugere matcher din søgning" + +msgid "No users yet" +msgstr "Ingen brugere endnu" + +msgid "Not synced yet" +msgstr "Ikke synkroniseret endnu" + +msgid "Occurred" +msgstr "Tidspunkt" + +msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." +msgstr "En række pr. enhed eller browser, brugeren er logget ind fra. Tilbagekaldte sessioner kan ikke logge ind igen." + +msgid "One-time password" +msgstr "Engangskode" + +msgid "Open account" +msgstr "Åbn konto" + +msgid "Open credit note" +msgstr "Åbn kreditnota" + +msgid "Open invoice" +msgstr "Åbn faktura" + +msgid "Outcome" +msgstr "Resultat" + +msgid "Overview" +msgstr "Overblik" + msgid "Owner" msgstr "Ejer" +msgid "Owners" +msgstr "Ejere" + msgid "Page not found" msgstr "Siden blev ikke fundet" +msgid "Paid" +msgstr "Betalt" + +msgid "Past due" +msgstr "Forfalden" + +msgid "Payment failed" +msgstr "Betaling mislykkedes" + +msgid "Payment method updated" +msgstr "Betalingsmetode opdateret" + +msgid "Payment recovered" +msgstr "Betaling gendannet" + +msgid "Payment refunded" +msgstr "Betaling refunderet" + +msgid "Pending" +msgstr "Afventer" + +msgid "per month, billed monthly" +msgstr "pr. måned, faktureres månedligt" + +msgid "Period" +msgstr "Periode" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Abonnement og omsætning" + +msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." +msgstr "Planændringer, fornyelser, opsigelser og betalingsresultater — abonnementets livscyklus og dets MRR-effekt over tid." + +msgid "Plan distribution" +msgstr "Plan-fordeling" + +msgid "Plan transition" +msgstr "Abonnementsændring" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,12 +621,89 @@ msgstr "Prøv venligst igen eller vend tilbage til forsiden." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Forrige" + +msgid "Prior period" +msgstr "Forrige periode" + +msgid "Reactivated" +msgstr "Genaktiveret" + +msgid "Recent billing events" +msgstr "Nylige faktureringshændelser" + +msgid "Recent signups" +msgstr "Nylige tilmeldinger" + +msgid "Refunded" +msgstr "Refunderet" + +msgid "Renewal" +msgstr "Fornyelse" + +msgid "Renewal date" +msgstr "Fornyelsesdato" + +msgid "Renewed" +msgstr "Fornyet" + +#. placeholder {0}: formatDate(membership.renewalDate) +#. placeholder {0}: formatDate(tenant.renewalDate) +msgid "Renews {0}" +msgstr "Fornyes {0}" + +msgid "Revoked" +msgstr "Tilbagekaldt" + +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 account name" +msgstr "Søg efter kontonavn" + +msgid "Search by email, name, or account" +msgstr "Søg på e-mail, navn eller konto" + +msgid "Search by name" +msgstr "Søg efter navn" + +msgid "Search by name or email" +msgstr "Søg efter navn eller e-mail" + +msgid "Search users" +msgstr "Søg brugere" + +msgid "Search, filter, and review accounts." +msgstr "Søg, filtrér og gennemse konti." + +msgid "Sessions" +msgstr "Sessioner" + msgid "Show details" msgstr "Vis detaljer" +msgid "Signed up" +msgstr "Tilmeldt" + +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +msgid "Signed up <0>{0}<1>{1}" +msgstr "Tilmeldt <0>{0}<1>{1}" + +#. placeholder {0}: formatDate(tenant.createdAt) +msgid "Since {0}" +msgstr "Siden {0}" + msgid "Small" msgstr "Lille" @@ -151,35 +713,140 @@ msgstr "Noget gik galt" msgid "Standard" msgstr "Standard" +msgid "Status" +msgstr "Status" + +msgid "Subscribed" +msgstr "Tilmeldt" + +msgid "Subscribed since" +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 "Subscription, payment, and billing transitions will appear here." +msgstr "Abonnements-, betalings- og faktureringsændringer vises her." + +msgid "Subscriptions, upgrades, and cancellations will appear here." +msgstr "Tilmeldinger, opgraderinger og opsigelser vises her." + +msgid "Succeeded" +msgstr "Gennemført" + msgid "Support" msgstr "Support" msgid "Support (coming soon)" 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" +msgid "Tablet" +msgstr "Tablet" + msgid "The page you are looking for does not exist or was moved." msgstr "Siden du leder efter findes ikke eller er blevet flyttet." msgid "Theme" msgstr "Tema" +msgid "This account has no users." +msgstr "Denne konto har ingen brugere." + +msgid "This account previously had a paid subscription that ended." +msgstr "Denne konto havde tidligere et betalt abonnement, der sluttede." + +msgid "This user has no recorded sessions." +msgstr "Denne bruger har ingen registrerede sessioner." + +msgid "This user is not a member of any account." +msgstr "Denne bruger er ikke medlem af nogen konto." + +msgid "Total" +msgstr "I alt" + +msgid "Total accounts" +msgstr "Konti i alt" + +msgid "Try a different search term or clear the role and activity filters." +msgstr "Prøv et andet søgeord, eller ryd rolle- og aktivitetsfiltrene." + msgid "Try again" msgstr "Prøv igen" +msgid "Try clearing the search or filters to see more results." +msgstr "Prøv at rydde søgningen eller filtrene for at se flere resultater." + +msgid "Unclassified" +msgstr "Uklassificeret" + +msgid "Unknown" +msgstr "Ukendt" + +msgid "Upgraded" +msgstr "Opgraderet" + msgid "User" msgstr "Bruger" +msgid "User detail" +msgstr "Brugerdetalje" + +msgid "User logins / day" +msgstr "Brugerlogins / dag" + msgid "User menu" msgstr "Brugermenu" msgid "Users" msgstr "Brugere" -msgid "Users (coming soon)" -msgstr "Brugere (kommer snart)" +msgid "Users active" +msgstr "Aktive brugere" + +msgid "Users will appear here as accounts are created." +msgstr "Brugere vises her, efterhånden som konti oprettes." + +msgid "VAT" +msgstr "Moms" + +msgid "View accounts" +msgstr "Vis konti" + +msgid "View all" +msgstr "Vis alle" + +msgid "View all {totalEvents} events" +msgstr "Vis alle {totalEvents} hændelser" + +msgid "View all {totalTransactions} invoices" +msgstr "Vis alle {totalTransactions} fakturaer" + +msgid "View billing events" +msgstr "Vis faktureringshændelser" + +msgid "vs prior period" +msgstr "mod forrige periode" msgid "Wait list" msgstr "Venteliste" @@ -187,8 +854,11 @@ msgstr "Venteliste" msgid "Wait list (coming soon)" msgstr "Venteliste (kommer snart)" -msgid "Welcome to the Back Office" -msgstr "Velkommen til Back Office" +msgid "When" +msgstr "Hvornår" + +msgid "Yesterday" +msgstr "I går" msgid "Your account is not in the required group." msgstr "Din konto er ikke i den krævede gruppe." diff --git a/application/account/BackOffice/shared/translations/locale/en-US.po b/application/account/BackOffice/shared/translations/locale/en-US.po index 3bdbf4d88d..1788e63de1 100644 --- a/application/account/BackOffice/shared/translations/locale/en-US.po +++ b/application/account/BackOffice/shared/translations/locale/en-US.po @@ -13,27 +13,193 @@ msgstr "" "Language-Team: \n" "Plural-Forms: \n" +#. placeholder {0}: result.value +msgid "{0, plural, one {# hour ago} other {# hours ago}}" +msgstr "{0, plural, one {# hour ago} other {# hours ago}}" + +#. placeholder {0}: result.value +msgid "{0, plural, one {# minute ago} other {# minutes ago}}" +msgstr "{0, plural, one {# minute ago} other {# minutes ago}}" + +#. placeholder {0}: formatCurrency(blended, currency) +msgid "{0} blended" +msgstr "{0} blended" + +#. placeholder {0}: formatCurrency(blended, currency) +#. placeholder {1}: formatDelta(deltaPercent) +msgid "{0} blended · {1} over period" +msgstr "{0} blended · {1} over period" + +msgid "{activeUsers} active" +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}}" + +msgid "{inactiveUsers} inactive" +msgstr "{inactiveUsers} inactive" + +msgid "{pendingUsers} pending" +msgstr "{pendingUsers} pending" + +msgid "{tenantCount, plural, one {# membership} other {# memberships}}" +msgstr "{tenantCount, plural, one {# membership} other {# memberships}}" + +msgid "{total} accounts" +msgstr "{total} accounts" + +msgid "{total} new signups · {priorTotal} prior period" +msgstr "{total} new signups · {priorTotal} prior period" + +msgid "{total} total · avg {average}/day" +msgstr "{total} total · avg {average}/day" + +msgid "/ month" +msgstr "/ month" + +#. placeholder {0}: data.newTenantsInPeriod +msgid "+{0} new in last {periodDays} days" +msgstr "+{0} new in last {periodDays} days" + +msgid "<0>{totalUsers} total" +msgstr "<0>{totalUsers} total" + +msgid "24h" +msgstr "24h" + +msgid "30 days" +msgstr "30 days" + +msgid "30d" +msgstr "30d" + +msgid "7 days" +msgstr "7 days" + +msgid "7d" +msgstr "7d" + +msgid "90d" +msgstr "90d" + +msgid "Account" +msgstr "Account" + +msgid "Account actions" +msgstr "Account actions" + +msgid "Account growth" +msgstr "Account growth" + +#. placeholder {0}: result.driftDiscrepancyCount +#. placeholder {1}: formatDate(result.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" + +msgid "Account users" +msgstr "Account users" + +msgid "accounts" +msgstr "accounts" + msgid "Accounts" msgstr "Accounts" -msgid "Accounts (coming soon)" -msgstr "Accounts (coming soon)" +msgid "Accounts will appear here as they are created." +msgstr "Accounts will appear here as they are created." + +msgid "Active" +msgstr "Active" + +msgid "Active sessions" +msgstr "Active sessions" + +msgid "Activity" +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" + +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" + +msgid "Amount" +msgstr "Amount" + msgid "An unexpected error occurred while processing your request." msgstr "An unexpected error occurred while processing your request." +#. placeholder {0}: result.billingEventsAppended +#. placeholder {1}: formatDate(result.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." + msgid "Back Office" msgstr "Back Office" msgid "BackOffice - Localhost" msgstr "BackOffice - Localhost" +msgid "BackOffice overview · {today}" +msgstr "BackOffice overview · {today}" + msgid "Basis" msgstr "Basis" +msgid "Billing address" +msgstr "Billing address" + +msgid "Billing events" +msgstr "Billing events" + +msgid "Billing info added" +msgstr "Billing info added" + +msgid "Billing info updated" +msgstr "Billing info updated" + +msgid "Blended MRR" +msgstr "Blended MRR" + +msgid "Browser" +msgstr "Browser" + +msgid "Canceled" +msgstr "Canceled" + +msgid "Canceling" +msgstr "Canceling" + +msgid "Cancellation" +msgstr "Cancellation" + +msgid "Cancelled" +msgstr "Cancelled" + +msgid "Cancelled immediately" +msgstr "Cancelled immediately" + msgid "Change language" msgstr "Change language" @@ -43,33 +209,148 @@ msgstr "Change theme" msgid "Change zoom level" msgstr "Change zoom level" +msgid "Clear filter" +msgstr "Clear filter" + +msgid "Clear filters" +msgstr "Clear filters" + +msgid "Clear search" +msgstr "Clear search" + +msgid "Close" +msgstr "Close" + +msgid "Close account preview" +msgstr "Close account preview" + msgid "Coming soon" msgstr "Coming soon" msgid "Contact your administrator." msgstr "Contact your administrator." +msgid "Country" +msgstr "Country" + +msgid "Created" +msgstr "Created" + +#. placeholder {0}: formatDate(user.createdAt, false, false, true) +#. placeholder {1}: formatDate(user.createdAt) +msgid "Created <0>{0}<1>{1}" +msgstr "Created <0>{0}<1>{1}" + +msgid "Credit note" +msgstr "Credit note" + +msgid "Current period" +msgstr "Current period" + +msgid "Current plan" +msgstr "Current plan" + msgid "Dark" msgstr "Dark" msgid "Dashboard" msgstr "Dashboard" +#. placeholder {0}: formatCurrency(data.kpiMonthlyRecurringRevenue, 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" + msgid "Default" msgstr "Default" +msgid "Desktop" +msgstr "Desktop" + +msgid "Device" +msgstr "Device" + +msgid "Downgrade cancelled" +msgstr "Downgrade cancelled" + +msgid "Downgrade scheduled" +msgstr "Downgrade scheduled" + +msgid "Downgraded" +msgstr "Downgraded" + +msgid "Downgrading" +msgstr "Downgrading" + +msgid "Drift detected" +msgstr "Drift detected" + +msgid "Email" +msgstr "Email" + +msgid "Email confirmed" +msgstr "Email confirmed" + +msgid "Email pending" +msgstr "Email pending" + +msgid "Enter kiosk mode" +msgstr "Enter kiosk mode" + +msgid "Event" +msgstr "Event" + +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 "Exit kiosk mode" +msgstr "Exit kiosk mode" + +msgid "Expired" +msgstr "Expired" + +msgid "Failed" +msgstr "Failed" + msgid "Feature flags" msgstr "Feature flags" msgid "Feature flags (coming soon)" msgstr "Feature flags (coming soon)" +msgid "Free" +msgstr "Free" + msgid "Go to home" msgstr "Go to home" +msgid "Google" +msgstr "Google" + msgid "Hide details" msgstr "Hide details" +msgid "Inactive" +msgstr "Inactive" + +msgid "Invoice" +msgstr "Invoice" + +msgid "Invoices" +msgstr "Invoices" + +msgid "IP address" +msgstr "IP address" + +msgid "Just now" +msgstr "Just now" + msgid "Language" msgstr "Language" @@ -79,6 +360,24 @@ msgstr "Large" msgid "Larger" msgstr "Larger" +msgid "Last {periodDays} days" +msgstr "Last {periodDays} days" + +msgid "Last 24 hours" +msgstr "Last 24 hours" + +msgid "Last invoice" +msgstr "Last invoice" + +msgid "Last log-in" +msgstr "Last log-in" + +msgid "Last seen" +msgstr "Last seen" + +msgid "Lifetime value" +msgstr "Lifetime value" + msgid "Light" msgstr "Light" @@ -100,30 +399,216 @@ msgstr "Log out" msgid "Logging in..." msgstr "Logging in..." +msgid "Login history" +msgstr "Login history" + +msgid "Logins" +msgstr "Logins" + msgid "Logo" msgstr "Logo" msgid "Main navigation" msgstr "Main navigation" -msgid "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." -msgstr "Manage accounts, view system data, see exceptions, and perform various tasks for operational and support teams." - msgid "Member" msgstr "Member" +msgid "Method" +msgstr "Method" + +msgid "Mobile" +msgstr "Mobile" + +msgid "Most recent activity" +msgstr "Most recent activity" + +msgid "MRR" +msgstr "MRR" + +msgid "MRR after" +msgstr "MRR after" + +msgid "MRR impact" +msgstr "MRR impact" + +msgid "MRR trend" +msgstr "MRR trend" + +msgid "Name" +msgstr "Name" + msgid "Navigation" msgstr "Navigation" +msgid "Never logged in" +msgstr "Never logged in" + +msgid "New accounts will appear here as they sign up." +msgstr "New accounts will appear here as they sign up." + +msgid "Next" +msgstr "Next" + +msgid "No account memberships" +msgstr "No account memberships" + +msgid "No accounts match your filters" +msgstr "No accounts match your filters" + +msgid "No accounts yet" +msgstr "No accounts yet" + msgid "No back-office access" msgstr "No back-office access" +msgid "No billing address on file." +msgstr "No billing address on file." + +msgid "No billing events" +msgstr "No billing events" + +msgid "No billing events match your filters" +msgstr "No billing events match your filters" + +msgid "No billing events yet" +msgstr "No billing events yet" + +msgid "No change" +msgstr "No change" + +msgid "No invoices, refunds, or credit notes yet." +msgstr "No invoices, refunds, or credit notes yet." + +msgid "No login history" +msgstr "No login history" + +msgid "No matching users" +msgstr "No matching users" + +msgid "No new billing events were appended. Account state matches Stripe." +msgstr "No new billing events were appended. Account state matches Stripe." + +msgid "No owners" +msgstr "No owners" + +msgid "No owners on this account." +msgstr "No owners on this account." + +msgid "No paid plan yet." +msgstr "No paid plan yet." + +msgid "No plan" +msgstr "No plan" + +msgid "No recent billing events" +msgstr "No recent billing events" + +msgid "No recent signups" +msgstr "No recent signups" + +msgid "No result available." +msgstr "No result available." + +msgid "No sessions" +msgstr "No sessions" + +msgid "No sign-in attempts in the last 30 days." +msgstr "No sign-in attempts in the last 30 days." + +msgid "No transactions" +msgstr "No transactions" + +msgid "No users" +msgstr "No users" + +msgid "No users match your filters." +msgstr "No users match your filters." + +msgid "No users match your search" +msgstr "No users match your search" + +msgid "No users yet" +msgstr "No users yet" + +msgid "Not synced yet" +msgstr "Not synced yet" + +msgid "Occurred" +msgstr "Occurred" + +msgid "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." +msgstr "One row per device or browser the user is signed in from. Revoked sessions cannot sign in again." + +msgid "One-time password" +msgstr "One-time password" + +msgid "Open account" +msgstr "Open account" + +msgid "Open credit note" +msgstr "Open credit note" + +msgid "Open invoice" +msgstr "Open invoice" + +msgid "Outcome" +msgstr "Outcome" + +msgid "Overview" +msgstr "Overview" + msgid "Owner" msgstr "Owner" +msgid "Owners" +msgstr "Owners" + msgid "Page not found" msgstr "Page not found" +msgid "Paid" +msgstr "Paid" + +msgid "Past due" +msgstr "Past due" + +msgid "Payment failed" +msgstr "Payment failed" + +msgid "Payment method updated" +msgstr "Payment method updated" + +msgid "Payment recovered" +msgstr "Payment recovered" + +msgid "Payment refunded" +msgstr "Payment refunded" + +msgid "Pending" +msgstr "Pending" + +msgid "per month, billed monthly" +msgstr "per month, billed monthly" + +msgid "Period" +msgstr "Period" + +msgid "Plan" +msgstr "Plan" + +msgid "Plan & revenue" +msgstr "Plan & revenue" + +msgid "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." +msgstr "Plan changes, renewals, cancellations, and payment outcomes — the subscription lifecycle and its MRR impact over time." + +msgid "Plan distribution" +msgstr "Plan distribution" + +msgid "Plan transition" +msgstr "Plan transition" + msgid "PlatformPlatform logo" msgstr "PlatformPlatform logo" @@ -136,12 +621,89 @@ msgstr "Please try again or return to the home page." msgid "Premium" msgstr "Premium" +msgid "Previous" +msgstr "Previous" + +msgid "Prior period" +msgstr "Prior period" + +msgid "Reactivated" +msgstr "Reactivated" + +msgid "Recent billing events" +msgstr "Recent billing events" + +msgid "Recent signups" +msgstr "Recent signups" + +msgid "Refunded" +msgstr "Refunded" + +msgid "Renewal" +msgstr "Renewal" + +msgid "Renewal date" +msgstr "Renewal date" + +msgid "Renewed" +msgstr "Renewed" + +#. placeholder {0}: formatDate(membership.renewalDate) +#. placeholder {0}: formatDate(tenant.renewalDate) +msgid "Renews {0}" +msgstr "Renews {0}" + +msgid "Revoked" +msgstr "Revoked" + +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 account name" +msgstr "Search by account name" + +msgid "Search by email, name, or account" +msgstr "Search by email, name, or account" + +msgid "Search by name" +msgstr "Search by name" + +msgid "Search by name or email" +msgstr "Search by name or email" + +msgid "Search users" +msgstr "Search users" + +msgid "Search, filter, and review accounts." +msgstr "Search, filter, and review accounts." + +msgid "Sessions" +msgstr "Sessions" + msgid "Show details" msgstr "Show details" +msgid "Signed up" +msgstr "Signed up" + +#. placeholder {0}: formatDate(tenant.createdAt, false, false, true) +#. placeholder {1}: formatDate(tenant.createdAt) +msgid "Signed up <0>{0}<1>{1}" +msgstr "Signed up <0>{0}<1>{1}" + +#. placeholder {0}: formatDate(tenant.createdAt) +msgid "Since {0}" +msgstr "Since {0}" + msgid "Small" msgstr "Small" @@ -151,35 +713,140 @@ msgstr "Something went wrong" msgid "Standard" msgstr "Standard" +msgid "Status" +msgstr "Status" + +msgid "Subscribed" +msgstr "Subscribed" + +msgid "Subscribed since" +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 "Subscription, payment, and billing transitions will appear here." +msgstr "Subscription, payment, and billing transitions will appear here." + +msgid "Subscriptions, upgrades, and cancellations will appear here." +msgstr "Subscriptions, upgrades, and cancellations will appear here." + +msgid "Succeeded" +msgstr "Succeeded" + msgid "Support" msgstr "Support" msgid "Support (coming soon)" 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" +msgid "Tablet" +msgstr "Tablet" + msgid "The page you are looking for does not exist or was moved." msgstr "The page you are looking for does not exist or was moved." msgid "Theme" msgstr "Theme" +msgid "This account has no users." +msgstr "This account has no users." + +msgid "This account previously had a paid subscription that ended." +msgstr "This account previously had a paid subscription that ended." + +msgid "This user has no recorded sessions." +msgstr "This user has no recorded sessions." + +msgid "This user is not a member of any account." +msgstr "This user is not a member of any account." + +msgid "Total" +msgstr "Total" + +msgid "Total accounts" +msgstr "Total accounts" + +msgid "Try a different search term or clear the role and activity filters." +msgstr "Try a different search term or clear the role and activity filters." + msgid "Try again" msgstr "Try again" +msgid "Try clearing the search or filters to see more results." +msgstr "Try clearing the search or filters to see more results." + +msgid "Unclassified" +msgstr "Unclassified" + +msgid "Unknown" +msgstr "Unknown" + +msgid "Upgraded" +msgstr "Upgraded" + msgid "User" msgstr "User" +msgid "User detail" +msgstr "User detail" + +msgid "User logins / day" +msgstr "User logins / day" + msgid "User menu" msgstr "User menu" msgid "Users" msgstr "Users" -msgid "Users (coming soon)" -msgstr "Users (coming soon)" +msgid "Users active" +msgstr "Users active" + +msgid "Users will appear here as accounts are created." +msgstr "Users will appear here as accounts are created." + +msgid "VAT" +msgstr "VAT" + +msgid "View accounts" +msgstr "View accounts" + +msgid "View all" +msgstr "View all" + +msgid "View all {totalEvents} events" +msgstr "View all {totalEvents} events" + +msgid "View all {totalTransactions} invoices" +msgstr "View all {totalTransactions} invoices" + +msgid "View billing events" +msgstr "View billing events" + +msgid "vs prior period" +msgstr "vs prior period" msgid "Wait list" msgstr "Wait list" @@ -187,8 +854,11 @@ msgstr "Wait list" msgid "Wait list (coming soon)" msgstr "Wait list (coming soon)" -msgid "Welcome to the Back Office" -msgstr "Welcome to the Back Office" +msgid "When" +msgstr "When" + +msgid "Yesterday" +msgstr "Yesterday" msgid "Your account is not in the required group." msgstr "Your account is not in the required group." diff --git a/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs b/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs new file mode 100644 index 0000000000..5e17be9790 --- /dev/null +++ b/application/account/Core/Database/Migrations/20260509180000_AddBillingEventsAndDriftDetection.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace Account.Database.Migrations; + +[DbContext(typeof(AccountDbContext))] +[Migration("20260509180000_AddBillingEventsAndDriftDetection")] +public sealed class AddBillingEventsAndDriftDetection : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn("subscribed_since", "subscriptions", "timestamptz", nullable: true); + migrationBuilder.AddColumn("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"))')""" + ); + + // The billing_events table is append-only. The unique index on stripe_event_id enforces strict + // 1:1 with Stripe events: every recognized Stripe event yields exactly one row. Stripe's events.list + // API has a 30-day retention window (see https://docs.stripe.com/api/events), so the local + // stripe_events table is the authoritative source for replays beyond that window. + // Hard rule: NO migration ever drops, deletes from, or truncates this table. Schema changes use + // ALTER TABLE ADD/DROP COLUMN. Forensics and audit depend on full history being preserved. + // tenant_id is the soft-scope query filter for ITenantScopedEntity; no FK to tenants because the + // back-office is cross-tenant by design and uses IgnoreQueryFilters([QueryFilterNames.Tenant]). + // modified_at is inherited from the framework's AggregateRoot shape and remains NULL by design — + // billing_events is append-only forever (rows are never updated after insert). + migrationBuilder.CreateTable( + "billing_events", + table => new + { + tenant_id = table.Column("bigint", nullable: false), + id = table.Column("text", nullable: false), + subscription_id = table.Column("text", nullable: false), + created_at = table.Column("timestamptz", nullable: false), + modified_at = table.Column("timestamptz", nullable: true), + stripe_event_id = table.Column("text", nullable: false), + event_type = table.Column("text", nullable: false), + from_plan = table.Column("text", nullable: true), + to_plan = table.Column("text", nullable: true), + previous_amount = table.Column("numeric(18,2)", nullable: true), + new_amount = table.Column("numeric(18,2)", nullable: true), + amount_delta = table.Column("numeric(18,2)", nullable: true), + committed_mrr = table.Column("numeric(18,2)", nullable: false), + currency = table.Column("text", nullable: true), + occurred_at = table.Column("timestamptz", nullable: false), + cancellation_reason = table.Column("text", nullable: true), + suspension_reason = table.Column("text", nullable: true) + }, + constraints: table => { table.PrimaryKey("pk_billing_events", x => x.id); } + ); + + migrationBuilder.CreateIndex("ix_billing_events_stripe_event_id", "billing_events", "stripe_event_id", unique: true); + migrationBuilder.CreateIndex("ix_billing_events_tenant_id_occurred_at", "billing_events", ["tenant_id", "occurred_at"], descending: [false, true]); + migrationBuilder.CreateIndex("ix_billing_events_occurred_at", "billing_events", "occurred_at", descending: [true]); + migrationBuilder.CreateIndex("ix_billing_events_subscription_id", "billing_events", "subscription_id"); + + // stripe_events extensions for the multi-source reconciliation architecture: + // - api_version: pinned at event creation per https://docs.stripe.com/api/events; lets the + // replayer dispatch to the correct payload resolver when Stripe ships a new API version. + // - payload_hash: SHA-256 of the raw payload at first observation; lets AcknowledgeStripeWebhook + // detect StripeEventPayloadDivergence (same id, different payload) without comparing JSON bodies. + // - recovered_at / recovery_source: non-null when the event was added by reconciliation + // (events.list or webhook_endpoint_deliveries) rather than via webhook delivery — forensic + // marker that a webhook delivery was missed. + migrationBuilder.AddColumn("api_version", "stripe_events", "text", nullable: true); + migrationBuilder.AddColumn("recovered_at", "stripe_events", "timestamptz", nullable: true); + migrationBuilder.AddColumn("recovery_source", "stripe_events", "text", nullable: true); + migrationBuilder.AddColumn("payload_hash", "stripe_events", "text", nullable: true); + + migrationBuilder.CreateIndex("ix_stripe_events_recovered_at", "stripe_events", "recovered_at", filter: "recovered_at IS NOT NULL"); + } +} diff --git a/application/account/Core/Features/Authentication/Domain/SessionRepository.cs b/application/account/Core/Features/Authentication/Domain/SessionRepository.cs index 79dc40e21d..eb39bfee22 100644 --- a/application/account/Core/Features/Authentication/Domain/SessionRepository.cs +++ b/application/account/Core/Features/Authentication/Domain/SessionRepository.cs @@ -4,6 +4,7 @@ using Npgsql; using SharedKernel.Authentication.TokenGeneration; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.Persistence; namespace Account.Features.Authentication.Domain; @@ -37,6 +38,27 @@ public interface ISessionRepository : ICrudRepository /// This method should only be used during token refresh where tenant context comes from the token claims. /// Task TryRevokeForReplayUnfilteredAsync(SessionId sessionId, DateTimeOffset now, CancellationToken cancellationToken); + + /// + /// Returns the paged session history for a single user without applying tenant query filters. Used by the + /// back-office User detail page where tenant context is not established. Includes both active and revoked + /// sessions, ordered most-recent first. + /// + Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUserUnfilteredAsync(UserId userId, int pageOffset, int pageSize, CancellationToken cancellationToken); + + /// + /// Returns the paged session history for any of the supplied user ids without applying tenant query filters. + /// Used by the back-office User detail page to surface sessions across every user record sharing the same email + /// across tenants. Includes active and revoked sessions, ordered most-recent first. + /// + Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUsersUnfilteredAsync(UserId[] userIds, int pageOffset, int pageSize, CancellationToken cancellationToken); + + /// + /// Counts active (not revoked) sessions created at or after across all tenants + /// without applying tenant query filters. Used by the back-office dashboard KPI snapshot for active sessions + /// in the last 24 hours. + /// + Task CountActiveSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class SessionRepository(AccountDbContext accountDbContext, IServiceProvider serviceProvider) @@ -117,6 +139,67 @@ public async Task GetActiveSessionsForUsersUnfilteredAsync(UserId[] u return sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); } + /// + /// Returns the paged session history for a single user without applying tenant query filters. Used by the + /// back-office User detail page where tenant context is not established. Includes both active and revoked + /// sessions, ordered most-recent first. SQLite cannot translate DateTimeOffset comparisons in ORDER BY, so + /// sessions are materialized and ordered in memory; a single user has very few sessions so scale is not a + /// concern. + /// + public async Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUserUnfilteredAsync(UserId userId, int pageOffset, int pageSize, CancellationToken cancellationToken) + { + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => s.UserId == userId) + .ToArrayAsync(cancellationToken); + + var ordered = sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); + + var totalItems = ordered.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var page = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (page, totalItems, totalPages); + } + + /// + /// Returns the paged session history for any of the supplied user ids without applying tenant query filters. + /// Used by the back-office User detail page to surface sessions across every user record sharing the same email + /// across tenants. SQLite cannot translate DateTimeOffset comparisons in ORDER BY, so sessions are materialized + /// and ordered in memory; the cross-tenant set for one person is small enough that scale is not a concern. + /// + public async Task<(Session[] Sessions, int TotalItems, int TotalPages)> GetSessionsForUsersUnfilteredAsync(UserId[] userIds, int pageOffset, int pageSize, CancellationToken cancellationToken) + { + if (userIds.Length == 0) return ([], 0, 0); + + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => userIds.AsEnumerable().Contains(s.UserId)) + .ToArrayAsync(cancellationToken); + + var ordered = sessions.OrderByDescending(s => s.ModifiedAt ?? s.CreatedAt).ToArray(); + + var totalItems = ordered.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var page = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (page, totalItems, totalPages); + } + + /// + /// Counts active (not revoked) sessions created at or after across all tenants + /// without applying tenant query filters. Used by the back-office dashboard KPI snapshot for active sessions + /// in the last 24 hours. SQLite cannot translate DateTimeOffset comparisons in WHERE, so sessions are + /// materialized and filtered in memory; the bounded 24-hour window keeps the set small. + /// + public async Task CountActiveSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var sessions = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(s => s.RevokedAt == null) + .Select(s => new { s.CreatedAt }) + .ToArrayAsync(cancellationToken); + return sessions.LongCount(s => s.CreatedAt >= since); + } + private async Task OpenFallbackConnectionAsync(CancellationToken cancellationToken) { var existingConnection = accountDbContext.Database.GetDbConnection(); diff --git a/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs new file mode 100644 index 0000000000..3ff63cfb70 --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetBillingDriftSummary.cs @@ -0,0 +1,21 @@ +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.BillingDrift.Queries; + +[PublicAPI] +public sealed record GetBillingDriftSummaryQuery : IRequest>; + +[PublicAPI] +public sealed record BillingDriftSummaryResponse(int SubscriptionsWithDriftCount); + +public sealed class GetBillingDriftSummaryHandler(ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + public async Task> Handle(GetBillingDriftSummaryQuery query, CancellationToken cancellationToken) + { + var count = await subscriptionRepository.CountWithDriftDetectedUnfilteredAsync(cancellationToken); + return new BillingDriftSummaryResponse(count); + } +} diff --git a/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs b/application/account/Core/Features/BackOffice/BillingDrift/Queries/GetDashboardMrrConsistencySummary.cs new file mode 100644 index 0000000000..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/BillingEvents/Queries/GetBackOfficeBillingEvents.cs b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs new file mode 100644 index 0000000000..31b0f62cc5 --- /dev/null +++ b/application/account/Core/Features/BackOffice/BillingEvents/Queries/GetBackOfficeBillingEvents.cs @@ -0,0 +1,148 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.BackOffice.BillingEvents.Queries; + +[PublicAPI] +public sealed record GetBackOfficeBillingEventsQuery( + string? Search = null, + BillingEventType[]? EventTypes = null, + DateTimeOffset? OccurredFrom = null, + DateTimeOffset? OccurredTo = null, + TenantId? TenantId = null, + SortableBillingEventProperties OrderBy = SortableBillingEventProperties.OccurredAt, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public BillingEventType[] EventTypes { get; } = EventTypes ?? []; +} + +[PublicAPI] +public sealed record BillingEventsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BillingEventSummary[] BillingEvents); + +[PublicAPI] +public sealed record BillingEventSummary( + BillingEventId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + string? Country, + BillingEventType EventType, + SubscriptionPlan? FromPlan, + SubscriptionPlan? ToPlan, + decimal? AmountDelta, + decimal? PreviousAmount, + decimal? NewAmount, + decimal CommittedMrr, + string? Currency, + DateTimeOffset OccurredAt +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableBillingEventProperties +{ + OccurredAt, + EventType, + TenantName +} + +public sealed class GetBackOfficeBillingEventsQueryValidator : AbstractValidator +{ + public GetBackOfficeBillingEventsQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.EventTypes.Length).LessThanOrEqualTo(25).WithMessage("Event types filter must contain no more than 25 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeBillingEventsHandler( + IBillingEventRepository billingEventRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeBillingEventsQuery query, CancellationToken cancellationToken) + { + var billingEvents = await billingEventRepository.SearchAllUnfilteredAsync(query.EventTypes, query.OccurredFrom, query.OccurredTo, cancellationToken); + + if (query.TenantId is not null) + { + billingEvents = billingEvents.Where(e => e.TenantId == query.TenantId).ToArray(); + } + + var tenantIds = billingEvents.Select(e => e.TenantId).Distinct().ToArray(); + var tenants = tenantIds.Length == 0 + ? [] + : await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var subscriptions = tenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + var summaries = billingEvents + .Where(e => tenantsById.ContainsKey(e.TenantId)) + .Select(e => + { + var tenant = tenantsById[e.TenantId]; + var subscription = subscriptionsByTenantId.GetValueOrDefault(tenant.Id); + return new BillingEventSummary( + e.Id, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + subscription?.BillingInfo?.Address?.Country, + e.EventType, + e.FromPlan, + e.ToPlan, + e.AmountDelta, + e.PreviousAmount, + e.NewAmount, + e.CommittedMrr, + e.Currency, + e.OccurredAt + ); + } + ) + .ToArray(); + + if (!string.IsNullOrWhiteSpace(query.Search)) + { + summaries = summaries.Where(s => s.TenantName.ToLower().Contains(query.Search)).ToArray(); + } + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (SortableBillingEventProperties.EventType, SortOrder.Ascending) => summaries.OrderBy(s => s.EventType).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.EventType, _) => summaries.OrderByDescending(s => s.EventType).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.TenantName, SortOrder.Ascending) => summaries.OrderBy(s => s.TenantName).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.TenantName, _) => summaries.OrderByDescending(s => s.TenantName).ThenByDescending(s => s.OccurredAt), + (SortableBillingEventProperties.OccurredAt, SortOrder.Ascending) => summaries.OrderBy(s => s.OccurredAt), + _ => summaries.OrderByDescending(s => s.OccurredAt) + }; + + var totalCount = summaries.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new BillingEventsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs new file mode 100644 index 0000000000..b0e56107cd --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardKpis.cs @@ -0,0 +1,125 @@ +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; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardKpisQuery(DashboardTrendPeriod Period = DashboardTrendPeriod.Last30Days) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardKpisResponse( + DashboardTrendPeriod Period, + long TotalTenants, + long ActiveTenants, + long TrialTenants, + long CanceledTenants, + long NewTenantsInPeriod, + long? NewTenantsDeltaPercent, + long TotalUsers, + long ActiveUsersInPeriod, + decimal BlendedMonthlyRecurringRevenue, + decimal? BlendedMonthlyRecurringRevenueDeltaPercent, + string Currency, + long ActiveSessionsLast24Hours +); + +public sealed class GetDashboardKpisQueryValidator : AbstractValidator +{ + public GetDashboardKpisQueryValidator() + { + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +public sealed class GetDashboardKpisHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + ISessionRepository sessionRepository, + ISubscriptionRepository subscriptionRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + // Single supported currency until multi-currency MRR is in scope; matches the existing Subscription DTO style. + private const string DefaultCurrency = "DKK"; + + public async Task> Handle(GetDashboardKpisQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var twentyFourHoursAgo = now.AddHours(-24); + var periodStart = now.AddDays(-days); + var priorPeriodStart = now.AddDays(-days * 2); + + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var paidSubscriptions = await subscriptionRepository.GetAllActiveUnfilteredAsync(cancellationToken); + var allUsers = await userRepository.GetAllUnfilteredAsync(cancellationToken); + var activeSessions = await sessionRepository.CountActiveSinceUnfilteredAsync(twentyFourHoursAgo, cancellationToken); + + // HasEverSubscribed is the same heuristic used by GetTenants/GetTenantsResponse: a successful payment + // exists in the subscription's payment history. We need it to disambiguate Trial (never paid) from Canceled + // (was paying, now on free Basis plan). Subscriptions on the free plan are not loaded by GetAllActiveUnfilteredAsync, + // so look up the full set via tenant ids only when needed. + var freePlanTenantIds = tenants.Where(t => t.Plan == SubscriptionPlan.Basis).Select(t => t.Id).ToArray(); + var freePlanSubscriptions = freePlanTenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(freePlanTenantIds, cancellationToken); + var hasEverSubscribedByTenantId = freePlanSubscriptions.ToDictionary( + s => s.TenantId, + s => s.PaymentTransactions.Any(t => t.Status == PaymentTransactionStatus.Succeeded) + ); + + var totalTenants = tenants.LongLength; + var activeTenants = tenants.LongCount(t => t.State == TenantState.Active && t.Plan != SubscriptionPlan.Basis); + var trialTenants = tenants.LongCount(t => + t is { State: TenantState.Active, Plan: SubscriptionPlan.Basis } && + !hasEverSubscribedByTenantId.GetValueOrDefault(t.Id) + ); + var canceledTenants = tenants.LongCount(t => + t.Plan == SubscriptionPlan.Basis && + hasEverSubscribedByTenantId.GetValueOrDefault(t.Id) + ); + + var newTenantsInPeriod = tenants.LongCount(t => t.CreatedAt >= periodStart); + var newTenantsInPriorPeriod = tenants.LongCount(t => t.CreatedAt >= priorPeriodStart && t.CreatedAt < periodStart); + var newTenantsDeltaPercent = newTenantsInPriorPeriod == 0 + ? (long?)null + : (long)Math.Round((double)(newTenantsInPeriod - newTenantsInPriorPeriod) / newTenantsInPriorPeriod * 100d); + + var activeUsersInPeriod = allUsers.LongCount(u => u.LastSeenAt >= periodStart); + + var totalMonthlyRecurringRevenue = paidSubscriptions.Sum(MrrCalculator.ForwardMrr); + + // Period-over-period MRR delta uses the MRR contribution of subscriptions that already existed + // at the start of the period as the prior baseline. The domain does not store historical MRR + // snapshots, so this approximation treats subscriptions still active today and created before + // periodStart as the prior-period MRR. Subscriptions created within the period contribute the + // delta. This is an MRR-driven directional signal rather than a signup-count proxy. + var priorMonthlyRecurringRevenue = paidSubscriptions.Where(s => s.CreatedAt < periodStart).Sum(MrrCalculator.ForwardMrr); + var mrrDeltaPercent = priorMonthlyRecurringRevenue == 0m + ? (decimal?)null + : Math.Round((totalMonthlyRecurringRevenue - priorMonthlyRecurringRevenue) / priorMonthlyRecurringRevenue * 100m, 1); + + return new BackOfficeDashboardKpisResponse( + query.Period, + totalTenants, + activeTenants, + trialTenants, + canceledTenants, + newTenantsInPeriod, + newTenantsDeltaPercent, + allUsers.LongLength, + activeUsersInPeriod, + totalMonthlyRecurringRevenue, + mrrDeltaPercent, + DefaultCurrency, + activeSessions + ); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs new file mode 100644 index 0000000000..443fb16ba1 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardMrrTrend.cs @@ -0,0 +1,78 @@ +using Account.Features.Subscriptions.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardMrrTrendQuery(DashboardTrendPeriod Period = DashboardTrendPeriod.Last30Days) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardMrrTrendResponse( + DashboardTrendPeriod Period, + string Currency, + BackOfficeDashboardMrrTrendPoint[] Points, + BackOfficeDashboardMrrTrendPoint[] PriorPoints +); + +[PublicAPI] +public sealed record BackOfficeDashboardMrrTrendPoint(DateOnly Date, decimal MonthlyRecurringRevenue); + +public sealed class GetDashboardMrrTrendQueryValidator : AbstractValidator +{ + public GetDashboardMrrTrendQueryValidator() + { + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +public sealed class GetDashboardMrrTrendHandler(IBillingEventRepository billingEventRepository, TimeProvider timeProvider) + : IRequestHandler> +{ + private const string DefaultCurrency = "DKK"; + + public async Task> Handle(GetDashboardMrrTrendQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var today = DateOnly.FromDateTime(now.UtcDateTime); + var startDate = today.AddDays(-(days - 1)); + var priorStartDate = startDate.AddDays(-days); + + // Reconstruct historical MRR from the BillingEvent log: for each subscription, the most recent + // event with NewAmount set (and OccurredAt before end-of-day) is its committed MRR for that day. + // 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]; + for (var index = 0; index < days; index++) + { + var currentDate = startDate.AddDays(index); + var priorDate = priorStartDate.AddDays(index); + points[index] = new BackOfficeDashboardMrrTrendPoint(currentDate, ComputeDailyMrr(eventsBySubscription, currentDate)); + priorPoints[index] = new BackOfficeDashboardMrrTrendPoint(priorDate, ComputeDailyMrr(eventsBySubscription, priorDate)); + } + + return new BackOfficeDashboardMrrTrendResponse(query.Period, DefaultCurrency, points, priorPoints); + } + + private static decimal ComputeDailyMrr(Dictionary eventsBySubscription, DateOnly date) + { + var endOfDay = new DateTimeOffset(date.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + var total = 0m; + foreach (var subscriptionEvents in eventsBySubscription.Values) + { + // 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/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs new file mode 100644 index 0000000000..e1f9877918 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardPlanDistribution.cs @@ -0,0 +1,42 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardPlanDistributionQuery : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardPlanDistributionResponse( + long TotalTenants, + BackOfficeDashboardPlanDistributionEntry[] Distribution +); + +[PublicAPI] +public sealed record BackOfficeDashboardPlanDistributionEntry(SubscriptionPlan Plan, long Count, double Percentage); + +public sealed class GetDashboardPlanDistributionHandler(ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardPlanDistributionQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetAllUnfilteredAsync(cancellationToken); + var totalTenants = tenants.LongLength; + + // Distribution always returns one entry per known plan, even when zero, so the donut renders consistent + // legend slots regardless of the current data shape. + var distribution = Enum.GetValues() + .Select(plan => + { + var count = tenants.LongCount(t => t.Plan == plan); + var percentage = totalTenants == 0 ? 0d : Math.Round((double)count / totalTenants * 100d, 1); + return new BackOfficeDashboardPlanDistributionEntry(plan, count, percentage); + } + ) + .ToArray(); + + return new BackOfficeDashboardPlanDistributionResponse(totalTenants, distribution); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs new file mode 100644 index 0000000000..ff421877ff --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentSignups.cs @@ -0,0 +1,61 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentSignupsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignupsResponse(BackOfficeDashboardRecentSignup[] Signups); + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignup( + TenantId TenantId, + string Name, + string? TenantLogoUrl, + DateTimeOffset CreatedAt, + BackOfficeDashboardRecentSignupOwner? Owner +); + +[PublicAPI] +public sealed record BackOfficeDashboardRecentSignupOwner(UserId UserId, string? FirstName, string? LastName, string Email); + +public sealed class GetDashboardRecentSignupsQueryValidator : AbstractValidator +{ + public GetDashboardRecentSignupsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentSignupsHandler(ITenantRepository tenantRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRecentSignupsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetMostRecentSignupsUnfilteredAsync(query.Limit, cancellationToken); + var tenantIds = tenants.Select(t => t.Id).ToArray(); + var ownerByTenantId = await userRepository.GetFirstOwnerByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + + var signups = tenants.Select(tenant => + { + var owner = ownerByTenantId.GetValueOrDefault(tenant.Id); + return new BackOfficeDashboardRecentSignup( + tenant.Id, + tenant.Name, + tenant.Logo.Url, + tenant.CreatedAt, + owner is null ? null : new BackOfficeDashboardRecentSignupOwner(owner.Id, owner.FirstName, owner.LastName, owner.Email) + ); + } + ).ToArray(); + + return new BackOfficeDashboardRecentSignupsResponse(signups); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs new file mode 100644 index 0000000000..368e10a6ea --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardRecentStripeEvents.cs @@ -0,0 +1,74 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardRecentStripeEventsQuery(int Limit = 6) + : IRequest>; + +[PublicAPI] +public sealed record BackOfficeDashboardRecentStripeEventsResponse(BackOfficeDashboardStripeEvent[] Events); + +[PublicAPI] +public sealed record BackOfficeDashboardStripeEvent( + BillingEventId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + BillingEventType Type, + SubscriptionPlan? FromPlan, + SubscriptionPlan? ToPlan, + decimal? AmountDelta, + string? Currency, + DateTimeOffset OccurredAt +); + +public sealed class GetDashboardRecentStripeEventsQueryValidator : AbstractValidator +{ + public GetDashboardRecentStripeEventsQueryValidator() + { + RuleFor(x => x.Limit).InclusiveBetween(1, 50).WithMessage("Limit must be between 1 and 50."); + } +} + +public sealed class GetDashboardRecentStripeEventsHandler(IBillingEventRepository billingEventRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetDashboardRecentStripeEventsQuery query, CancellationToken cancellationToken) + { + var billingEvents = await billingEventRepository.GetRecentUnfilteredAsync(query.Limit, cancellationToken); + if (billingEvents.Length == 0) return new BackOfficeDashboardRecentStripeEventsResponse([]); + + var tenantIds = billingEvents.Select(e => e.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var events = billingEvents + .Where(e => tenantsById.ContainsKey(e.TenantId)) + .Select(e => + { + var tenant = tenantsById[e.TenantId]; + return new BackOfficeDashboardStripeEvent( + e.Id, + tenant.Id, + tenant.Name, + tenant.Logo.Url, + e.EventType, + e.FromPlan, + e.ToPlan, + e.AmountDelta, + e.Currency, + e.OccurredAt + ); + } + ) + .ToArray(); + + return new BackOfficeDashboardRecentStripeEventsResponse(events); + } +} diff --git a/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs new file mode 100644 index 0000000000..ca09fe2283 --- /dev/null +++ b/application/account/Core/Features/BackOffice/Dashboard/Queries/GetDashboardTrends.cs @@ -0,0 +1,132 @@ +using Account.Features.EmailAuthentication.Domain; +using Account.Features.ExternalAuthentication.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; + +namespace Account.Features.BackOffice.Dashboard.Queries; + +[PublicAPI] +public sealed record GetDashboardTrendsQuery(DashboardTrendMetric Metric, DashboardTrendPeriod Period) + : IRequest>; + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DashboardTrendMetric +{ + NewTenants, + NewUsers, + LoginActivity +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum DashboardTrendPeriod +{ + Last7Days, + Last30Days, + Last90Days +} + +[PublicAPI] +public sealed record BackOfficeDashboardTrendsResponse( + DashboardTrendMetric Metric, + DashboardTrendPeriod Period, + BackOfficeDashboardTrendPoint[] Points, + BackOfficeDashboardTrendPoint[] PriorPoints +); + +[PublicAPI] +public sealed record BackOfficeDashboardTrendPoint(DateOnly Date, long Value); + +public sealed class GetDashboardTrendsQueryValidator : AbstractValidator +{ + public GetDashboardTrendsQueryValidator() + { + RuleFor(x => x.Metric).Must(m => Enum.IsDefined(typeof(DashboardTrendMetric), m)).WithMessage("Metric must be one of NewTenants, NewUsers, or LoginActivity."); + RuleFor(x => x.Period).Must(p => Enum.IsDefined(typeof(DashboardTrendPeriod), p)).WithMessage("Period must be one of Last7Days, Last30Days, or Last90Days."); + } +} + +public sealed class GetDashboardTrendsHandler( + ITenantRepository tenantRepository, + IUserRepository userRepository, + IEmailLoginRepository emailLoginRepository, + IExternalLoginRepository externalLoginRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + public async Task> Handle(GetDashboardTrendsQuery query, CancellationToken cancellationToken) + { + var days = DashboardTrendPeriods.GetDays(query.Period); + var now = timeProvider.GetUtcNow(); + var today = DateOnly.FromDateTime(now.UtcDateTime); + var startDate = today.AddDays(-(days - 1)); + var priorStartDate = startDate.AddDays(-days); + // Pull the prior window in the same query so the chart can render a comparison overlay without a second round-trip. + var since = new DateTimeOffset(priorStartDate.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero); + + var counts = query.Metric switch + { + DashboardTrendMetric.NewTenants => await CountNewTenantsPerDay(since, cancellationToken), + DashboardTrendMetric.NewUsers => await CountNewUsersPerDay(since, cancellationToken), + DashboardTrendMetric.LoginActivity => await CountLoginActivityPerDay(since, cancellationToken), + _ => throw new UnreachableException($"Unsupported metric '{query.Metric}'.") + }; + + var points = new BackOfficeDashboardTrendPoint[days]; + var priorPoints = new BackOfficeDashboardTrendPoint[days]; + for (var index = 0; index < days; index++) + { + var currentDate = startDate.AddDays(index); + var priorDate = priorStartDate.AddDays(index); + points[index] = new BackOfficeDashboardTrendPoint(currentDate, counts.GetValueOrDefault(currentDate)); + priorPoints[index] = new BackOfficeDashboardTrendPoint(priorDate, counts.GetValueOrDefault(priorDate)); + } + + return new BackOfficeDashboardTrendsResponse(query.Metric, query.Period, points, priorPoints); + } + + private async Task> CountNewTenantsPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.GetCreatedSinceUnfilteredAsync(since, cancellationToken); + return BucketByDay(tenants.Select(t => t.CreatedAt)); + } + + private async Task> CountNewUsersPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var users = await userRepository.GetCreatedSinceUnfilteredAsync(since, cancellationToken); + return BucketByDay(users.Select(u => u.CreatedAt)); + } + + private async Task> CountLoginActivityPerDay(DateTimeOffset since, CancellationToken cancellationToken) + { + var emailLogins = await emailLoginRepository.GetCompletedSinceAsync(since, cancellationToken); + var externalLogins = await externalLoginRepository.GetSucceededSinceAsync(since, cancellationToken); + var timestamps = emailLogins.Select(l => l.CreatedAt).Concat(externalLogins.Select(l => l.CreatedAt)); + return BucketByDay(timestamps); + } + + private static Dictionary BucketByDay(IEnumerable timestamps) + { + return timestamps + .GroupBy(timestamp => DateOnly.FromDateTime(timestamp.UtcDateTime)) + .ToDictionary(group => group.Key, group => group.LongCount()); + } +} + +public static class DashboardTrendPeriods +{ + public static int GetDays(DashboardTrendPeriod period) + { + return period switch + { + DashboardTrendPeriod.Last7Days => 7, + DashboardTrendPeriod.Last30Days => 30, + DashboardTrendPeriod.Last90Days => 90, + _ => throw new UnreachableException($"Unsupported period '{period}'.") + }; + } +} diff --git a/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs b/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs index 2761ac15d5..c9a2d9b869 100644 --- a/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs +++ b/application/account/Core/Features/EmailAuthentication/Domain/EmailLoginRepository.cs @@ -1,4 +1,5 @@ using Account.Database; +using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; using SharedKernel.Persistence; @@ -9,6 +10,19 @@ public interface IEmailLoginRepository : IAppendRepository + /// Returns every email login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts), not just the active in-progress logins returned by . + /// + Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every completed email login created at or after . Used by the back-office + /// dashboard to aggregate successful email login activity per day across all tenants. + /// + Task GetCompletedSinceAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class EmailLoginRepository(AccountDbContext accountDbContext) @@ -21,4 +35,31 @@ public EmailLogin[] GetByEmail(string email) .Where(el => el.Email == email.ToLowerInvariant()) .ToArray(); } + + /// + /// Returns every email login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts), not just the active in-progress logins returned by . + /// SQLite cannot translate DateTimeOffset comparisons, so the time filter runs in memory; the email filter + /// keeps the materialized set bounded. + /// + public async Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet + .Where(el => el.Email == email.ToLowerInvariant()) + .ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every completed email login created at or after . Used by the back-office + /// dashboard to aggregate successful email login activity per day across all tenants. SQLite cannot translate + /// DateTimeOffset comparisons, so the time filter runs in memory; the dashboard period is bounded (max 90 days) + /// so the materialized set stays small. + /// + public async Task GetCompletedSinceAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet.Where(el => el.Completed).ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } } diff --git a/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs b/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs index d7d9859bb1..3ef222da1d 100644 --- a/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs +++ b/application/account/Core/Features/ExternalAuthentication/Domain/ExternalLoginRepository.cs @@ -1,4 +1,5 @@ using Account.Database; +using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; using SharedKernel.Persistence; @@ -7,7 +8,47 @@ namespace Account.Features.ExternalAuthentication.Domain; public interface IExternalLoginRepository : IAppendRepository { void Update(ExternalLogin aggregate); + + /// + /// Returns every external login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts). + /// + Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every successful external login created at or after . Used by the back-office + /// dashboard to aggregate successful external login activity per day across all tenants. + /// + Task GetSucceededSinceAsync(DateTimeOffset since, CancellationToken cancellationToken); } public sealed class ExternalLoginRepository(AccountDbContext accountDbContext) - : RepositoryBase(accountDbContext), IExternalLoginRepository; + : RepositoryBase(accountDbContext), IExternalLoginRepository +{ + /// + /// Returns every external login for the given email address created at or after . + /// Used by the back-office login history endpoint to surface the full sign-in history (including failed + /// and pending attempts). SQLite cannot translate DateTimeOffset comparisons, so the time filter runs in + /// memory; the email filter keeps the materialized set bounded. + /// + public async Task GetByEmailSinceAsync(string email, DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet + .Where(el => el.Email == email.ToLowerInvariant()) + .ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every successful external login created at or after . Used by the back-office + /// dashboard to aggregate successful external login activity per day across all tenants. SQLite cannot translate + /// DateTimeOffset comparisons, so the time filter runs in memory; the dashboard period is bounded (max 90 days) + /// so the materialized set stays small. + /// + public async Task GetSucceededSinceAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var logins = await DbSet.Where(el => el.LoginResult == ExternalLoginResult.Success).ToArrayAsync(cancellationToken); + return logins.Where(el => el.CreatedAt >= since).ToArray(); + } +} diff --git a/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs b/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs index aebfc5c666..87a01f5576 100644 --- a/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs +++ b/application/account/Core/Features/Subscriptions/Commands/AcknowledgeStripeWebhook.cs @@ -1,7 +1,9 @@ using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; using Account.Integrations.Stripe; using JetBrains.Annotations; using SharedKernel.Cqrs; +using SharedKernel.Telemetry; namespace Account.Features.Subscriptions.Commands; @@ -15,7 +17,9 @@ public sealed record AcknowledgeStripeWebhookCommand(string Payload, string Sign public sealed class AcknowledgeStripeWebhookHandler( IStripeEventRepository stripeEventRepository, StripeClientFactory stripeClientFactory, - TimeProvider timeProvider + ITelemetryEventsCollector events, + TimeProvider timeProvider, + ILogger logger ) : IRequestHandler> { public async Task> Handle(AcknowledgeStripeWebhookCommand command, CancellationToken cancellationToken) @@ -27,15 +31,31 @@ TimeProvider timeProvider return Result.BadRequest("Invalid webhook signature."); } - if (await stripeEventRepository.ExistsAsync(webhookEvent.EventId, cancellationToken)) + var payloadHash = StripeEventPayloadHasher.Hash(command.Payload); + + // Idempotency: Stripe redelivers webhooks on transient errors (network, our 5xx, etc.). Same event + // id arriving twice with the same payload is a no-op. Same id with a *different* payload is a + // forensic anomaly: the existing row is preserved unchanged and a divergence telemetry event is + // emitted so the drift banner can surface it. We never overwrite stripe_events rows. + var existing = await stripeEventRepository.GetByIdAsync(StripeEventId.NewId(webhookEvent.EventId), cancellationToken); + if (existing is not null) { + if (existing.PayloadHash is not null && existing.PayloadHash != payloadHash) + { + logger.LogWarning( + "Stripe event {EventId} arrived twice with different payloads (existing hash {ExistingHash} vs new {NewHash}); existing row preserved", + webhookEvent.EventId, existing.PayloadHash, payloadHash + ); + events.CollectEvent(new StripeEventPayloadMismatch(webhookEvent.EventId, webhookEvent.EventType, existing.PayloadHash, payloadHash)); + } + return Result.Success(webhookEvent.CustomerId); } var now = timeProvider.GetUtcNow(); var customerId = webhookEvent.CustomerId; - var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload); + var stripeEvent = StripeEvent.Create(webhookEvent.EventId, webhookEvent.EventType, customerId, command.Payload, webhookEvent.ApiVersion, payloadHash); if (customerId is null) { diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs new file mode 100644 index 0000000000..9afc5ba3b6 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEvent.cs @@ -0,0 +1,163 @@ +using Account.Features.Tenants.Domain; +using JetBrains.Annotations; +using SharedKernel.Domain; +using SharedKernel.StronglyTypedIds; + +namespace Account.Features.Subscriptions.Domain; + +[PublicAPI] +[IdPrefix("bilevt")] +[JsonConverter(typeof(StronglyTypedIdJsonConverter))] +public sealed record BillingEventId(string Value) : StronglyTypedUlid(Value) +{ + public override string ToString() + { + return Value; + } +} + +/// +/// A durable, append-only record of one subscription-relevant Stripe event. +/// The invariant is strict 1:1: every recognized Stripe event for a subscription produces exactly +/// one row. Events that don't move state we care about are written as . +/// Events whose Stripe payload combines multiple changes that don't decompose into one of our domain +/// transitions (e.g. a subscription update that toggles cancel-at-period-end *and* changes price in +/// the same payload) are written as and flip the +/// subscription's drift flag for admin review. +/// Idempotent on (unique index): redelivered webhooks and re-pulls from +/// the Stripe events API are no-ops. +/// Source of truth: the local stripe_events archive, NOT Stripe's events.list API. Stripe only +/// retains events for 30 days (see https://docs.stripe.com/api/events) — anything older must come from +/// our local archive. The events.list API is used only as a reconciliation source for detecting +/// webhooks that never reached us within the retention window. +/// Hard rule: rows in this table are never deleted, never updated. Schema changes use ALTER TABLE +/// ADD/DROP COLUMN, never DROP/TRUNCATE/DELETE FROM. +/// +public sealed class BillingEvent : AggregateRoot, ITenantScopedEntity +{ + private BillingEvent(TenantId tenantId, SubscriptionId subscriptionId, string stripeEventId) + : base(BillingEventId.NewId()) + { + TenantId = tenantId; + SubscriptionId = subscriptionId; + StripeEventId = stripeEventId; + EventType = default; + OccurredAt = default; + } + + public SubscriptionId SubscriptionId { get; private set; } + + public string StripeEventId { get; private set; } + + public BillingEventType EventType { get; private set; } + + public SubscriptionPlan? FromPlan { get; private set; } + + public SubscriptionPlan? ToPlan { get; private set; } + + public decimal? PreviousAmount { get; private set; } + + public decimal? NewAmount { get; private set; } + + public decimal? AmountDelta { get; private set; } + + public decimal CommittedMrr { get; private set; } + + public string? Currency { get; private set; } + + public DateTimeOffset OccurredAt { get; private set; } + + public CancellationReason? CancellationReason { get; private set; } + + public SuspensionReason? SuspensionReason { get; private set; } + + public TenantId TenantId { get; } + + public static BillingEvent Create( + TenantId tenantId, + SubscriptionId subscriptionId, + string stripeEventId, + BillingEventType eventType, + DateTimeOffset occurredAt, + decimal committedMrr, + SubscriptionPlan? fromPlan = null, + SubscriptionPlan? toPlan = null, + decimal? previousAmount = null, + decimal? newAmount = null, + decimal? amountDelta = null, + string? currency = null, + CancellationReason? cancellationReason = null, + SuspensionReason? suspensionReason = null + ) + { + return new BillingEvent(tenantId, subscriptionId, stripeEventId) + { + EventType = eventType, + OccurredAt = occurredAt, + CommittedMrr = committedMrr, + FromPlan = fromPlan, + ToPlan = toPlan, + PreviousAmount = previousAmount, + NewAmount = newAmount, + AmountDelta = amountDelta, + Currency = currency, + CancellationReason = cancellationReason, + SuspensionReason = suspensionReason + }; + } +} + +/// +/// The type of subscription-relevant Stripe event recorded by the BillingEvent log. +/// IMPORTANT: when adding a new value, also add it to the multi-select on /billing-events +/// (see application/account/BackOffice/routes/billing-events/-components/BillingEventsToolbar.tsx, +/// constant ALL_EVENT_TYPES). The toolbar is hand-maintained and does not enumerate the +/// enum at runtime — operators won't be able to filter by a new type until that list is updated. +/// +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum BillingEventType +{ + SubscriptionCreated, + SubscriptionRenewed, + SubscriptionUpgraded, + SubscriptionDowngradeScheduled, + SubscriptionDowngradeCancelled, + SubscriptionDowngraded, + SubscriptionCancelled, + SubscriptionReactivated, + SubscriptionExpired, + SubscriptionImmediatelyCancelled, + SubscriptionSuspended, + + /// + /// Stripe transitioned the subscription's status from active to past_due (a payment failed). + /// Fires alongside from the corresponding invoice.payment_failed event; + /// pairs with when payment recovers and status returns to active. + /// Carries forward CommittedMrr unchanged and AmountDelta=null — the customer is still on the plan, + /// just behind on payment. + /// + SubscriptionPastDue, + PaymentFailed, + PaymentRecovered, + PaymentRefunded, + BillingInfoAdded, + BillingInfoUpdated, + PaymentMethodUpdated, + + /// + /// A recognized subscription-relevant Stripe event that doesn't move state we care about (e.g. + /// a subscription_schedule.updated arriving with status=canceled after a cancellation, where + /// phases haven't changed). Hidden from the timeline UI; carries forward CommittedMrr unchanged + /// and AmountDelta=null so it's invisible to MRR trend computation. + /// + NoOp, + + /// + /// A Stripe event whose payload combines multiple state changes that the writer can't decompose + /// into a single domain transition (e.g. a customer.subscription.updated whose previous_attributes + /// contain both a cancel_at_period_end toggle and a price change). Triggers the drift banner so + /// an admin can investigate in Stripe Dashboard. + /// + Unclassified +} diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs new file mode 100644 index 0000000000..32b02de69a --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventConfiguration.cs @@ -0,0 +1,26 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using SharedKernel.Domain; +using SharedKernel.EntityFramework; + +namespace Account.Features.Subscriptions.Domain; + +public sealed class BillingEventConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.MapStronglyTypedUuid(e => e.Id); + builder.MapStronglyTypedLongId(e => e.TenantId); + builder.MapStronglyTypedUuid(e => e.SubscriptionId); + + builder.Property(e => e.PreviousAmount).HasPrecision(18, 2); + builder.Property(e => e.NewAmount).HasPrecision(18, 2); + builder.Property(e => e.AmountDelta).HasPrecision(18, 2); + builder.Property(e => e.CommittedMrr).HasPrecision(18, 2); + + builder.HasIndex(e => e.StripeEventId).IsUnique(); + builder.HasIndex(e => new { e.TenantId, e.OccurredAt }).IsDescending(false, true); + builder.HasIndex(e => e.OccurredAt).IsDescending(); + builder.HasIndex(e => e.SubscriptionId); + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs new file mode 100644 index 0000000000..3e98a01eab --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Domain/BillingEventRepository.cs @@ -0,0 +1,133 @@ +using Account.Database; +using Microsoft.EntityFrameworkCore; +using SharedKernel.Domain; +using SharedKernel.EntityFramework; +using SharedKernel.Persistence; + +namespace Account.Features.Subscriptions.Domain; + +public interface IBillingEventRepository : IAppendRepository +{ + /// + /// Returns every billing event for a subscription. Used by drift detection and projection logic + /// that walks subscription history. Bypasses the tenant query filter because the drift detector + /// and webhook pipeline both run without an authenticated tenant context. + /// + Task GetBySubscriptionIdUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns the set of Stripe event ids already recorded for a subscription. Used to enforce the + /// 1:1 invariant idempotently — a redelivered webhook or a re-pull from the Stripe events API + /// skips events whose ids are already in this set. Bypasses the tenant query filter because the + /// webhook pipeline runs without an authenticated tenant context. + /// + Task> GetExistingStripeEventIdsUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken); + + /// + /// Returns the most recent billing events across all tenants. Bypasses the tenant query filter + /// because the back-office is cross-tenant by design. + /// + Task GetRecentUnfilteredAsync(int limit, CancellationToken cancellationToken); + + /// + /// Returns all billing events matching the optional event-type filter, across all tenants. + /// Bypasses the tenant query filter because the back-office is cross-tenant by design. Date-range + /// filtering is applied in memory because SQLite (used in tests) cannot translate DateTimeOffset + /// comparisons to SQL; the materialized set stays small in practice because event-type filtering + /// happens at the database level and dashboard windows are bounded. + /// + Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken); + + /// + /// Returns every billing event with a non-null AmountDelta across all tenants — the events that + /// actually move committed MRR. Used by the dashboard MRR-trend computation. Bypasses the tenant + /// query filter because the back-office is cross-tenant by design. + /// + Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Returns the subset of that have at least one billing event + /// recorded. Used by the back-office accounts list to filter to "unsynced" subscriptions (paid + /// subscriptions with no events). Bypasses the tenant query filter because the back-office is + /// cross-tenant by design. + /// + Task> GetSubscriptionIdsWithEventsUnfilteredAsync(SubscriptionId[] subscriptionIds, CancellationToken cancellationToken); +} + +public sealed class BillingEventRepository(AccountDbContext accountDbContext) + : RepositoryBase(accountDbContext), IBillingEventRepository +{ + public async Task GetBySubscriptionIdUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.SubscriptionId == subscriptionId) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetExistingStripeEventIdsUnfilteredAsync(SubscriptionId subscriptionId, CancellationToken cancellationToken) + { + var ids = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.SubscriptionId == subscriptionId) + .Select(e => e.StripeEventId) + .ToArrayAsync(cancellationToken); + return [.. ids]; + } + + public async Task GetRecentUnfilteredAsync(int limit, CancellationToken cancellationToken) + { + // SQLite (used in tests) cannot translate DateTimeOffset comparisons in ORDER BY, so the sort runs + // in memory. The materialized set is bounded by the dashboard's small request limit (max 50 rows). + // NoOp rows are audit-only and hidden from the timeline display. + var events = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.EventType != BillingEventType.NoOp) + .ToArrayAsync(cancellationToken); + return events.OrderByDescending(e => e.OccurredAt).Take(limit).ToArray(); + } + + public async Task GetMrrChangeEventsUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => e.AmountDelta != null) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetSubscriptionIdsWithEventsUnfilteredAsync(SubscriptionId[] subscriptionIds, CancellationToken cancellationToken) + { + if (subscriptionIds.Length == 0) return []; + + var ids = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(e => subscriptionIds.AsEnumerable().Contains(e.SubscriptionId)) + .Select(e => e.SubscriptionId) + .Distinct() + .ToArrayAsync(cancellationToken); + return [.. ids]; + } + + public async Task SearchAllUnfilteredAsync(BillingEventType[] eventTypes, DateTimeOffset? occurredFrom, DateTimeOffset? occurredTo, CancellationToken cancellationToken) + { + // NoOp rows are audit-only — hidden from the timeline display unless an admin explicitly filters + // for them via the eventTypes parameter. + var queryable = eventTypes.Length > 0 + ? DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(e => eventTypes.AsEnumerable().Contains(e.EventType)) + : DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(e => e.EventType != BillingEventType.NoOp); + + var events = await queryable.ToArrayAsync(cancellationToken); + + if (occurredFrom.HasValue) + { + events = events.Where(e => e.OccurredAt >= occurredFrom.Value).ToArray(); + } + + if (occurredTo.HasValue) + { + events = events.Where(e => e.OccurredAt <= occurredTo.Value).ToArray(); + } + + return events; + } +} diff --git a/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs b/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs index 466e652dd5..9049d393bb 100644 --- a/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs +++ b/application/account/Core/Features/Subscriptions/Domain/StripeEvent.cs @@ -15,6 +15,19 @@ public override string ToString() } } +/// +/// A durable archive of every Stripe webhook payload we have observed for this account. Two roles: +/// (1) inbox for two-phase webhook processing (Pending → Processed), and +/// (2) authoritative source for replaying BillingEvents beyond Stripe's 30-day events.list retention +/// (see https://docs.stripe.com/api/events). +/// Rows are immutable after . The state machine is one-way: +/// Pending → Processed (or Ignored when no customer match, or Failed on processing error). Subsequent +/// redeliveries of the same event id are deduplicated at insert time and never overwrite an existing row. +/// If the same event id is observed with a different payload, it is logged as a forensic anomaly and the +/// existing row is preserved unchanged. +/// Hard rule: rows in this table are never deleted. Schema changes use ALTER TABLE ADD/DROP COLUMN, +/// never DROP/TRUNCATE/DELETE FROM. +/// public sealed class StripeEvent : AggregateRoot { private StripeEvent(StripeEventId id) : base(id) @@ -39,28 +52,106 @@ private StripeEvent(StripeEventId id) : base(id) public string? Error { get; private set; } + /// + /// The Stripe API version active when Stripe created this event. Pinned at event creation time and + /// never changes (see https://docs.stripe.com/api/events). The replayer uses this to dispatch to + /// the correct IStripeEventPayloadResolver when the JSON shape changes between Stripe API + /// versions. Null only on rows recorded before this column existed. + /// + public string? ApiVersion { get; private set; } + + /// + /// When this event was recovered from a reconciliation source (events.list or + /// webhook_endpoint_deliveries) instead of arriving via webhook delivery. Null for events that came + /// via normal webhook delivery. Forensics: a row with non-null RecoveredAt is a webhook we + /// didn't receive in real-time. + /// + public DateTimeOffset? RecoveredAt { get; private set; } + + /// + /// The reconciliation source that recovered this event. "events_list" means we found it via + /// Stripe's events.list API; "delivery_audit" means Stripe's webhook_endpoint_deliveries API + /// showed us a delivery attempt we never acked. Null when arrived via webhook delivery. + /// + public string? RecoverySource { get; private set; } + + /// + /// SHA-256 hash of the raw payload when this row was first stored. Used by AcknowledgeStripeWebhook + /// to detect StripeEventPayloadDivergence: if the same event id arrives twice with different + /// payloads, the existing row is preserved unchanged and the divergence is surfaced as a drift + /// discrepancy. Null only on rows recorded before this column existed. + /// + public string? PayloadHash { get; private set; } + /// /// Factory method for phase 1 webhook acknowledgment. Creates a Pending event that will be - /// batch-processed in phase 2. TenantId and StripeSubscriptionId are backfilled by phase 2 - /// via SetTenantId() and SetStripeSubscriptionId(). + /// batch-processed in phase 2. TenantId and StripeSubscriptionId are filled in by phase 2 via + /// . + /// + public static StripeEvent Create( + string stripeEventId, + string eventType, + StripeCustomerId? stripeCustomerId, + string? payload, + string? apiVersion, + string? payloadHash + ) + { + return new StripeEvent(StripeEventId.NewId(stripeEventId)) + { + EventType = eventType, + StripeCustomerId = stripeCustomerId, + Payload = payload, + ApiVersion = apiVersion, + PayloadHash = payloadHash + }; + } + + /// + /// Factory method for events recovered via reconciliation (events.list or webhook_endpoint_deliveries). + /// Lands directly as Processed because reconciliation runs inside the same transaction as the replayer, + /// and there's no signature to verify (events.list and webhook_endpoint_deliveries are authenticated by + /// API key, not webhook signature). The two-phase pending → processed split exists for incoming + /// webhooks; recovered events skip phase 1. /// - public static StripeEvent Create(string stripeEventId, string eventType, StripeCustomerId? stripeCustomerId, string? payload) + public static StripeEvent CreateRecovered( + string stripeEventId, + string eventType, + StripeCustomerId? stripeCustomerId, + string? payload, + string? apiVersion, + string? payloadHash, + DateTimeOffset recoveredAt, + string recoverySource + ) { return new StripeEvent(StripeEventId.NewId(stripeEventId)) { EventType = eventType, + Status = StripeEventStatus.Processed, + ProcessedAt = recoveredAt, StripeCustomerId = stripeCustomerId, - Payload = payload + Payload = payload, + ApiVersion = apiVersion, + PayloadHash = payloadHash, + RecoveredAt = recoveredAt, + RecoverySource = recoverySource }; } /// - /// Marks the event as successfully processed during phase 2 batch processing. + /// Marks the event as successfully processed during phase 2 batch processing. Backfills the + /// resolved tenant id and Stripe subscription id at the same moment so the row is fully populated + /// before transitioning out of the Pending state. After this call the row is logically immutable — + /// no method on this aggregate mutates state once Status is Processed. /// - public void MarkProcessed(DateTimeOffset processedAt) + public void MarkProcessed(DateTimeOffset processedAt, TenantId? tenantId, StripeSubscriptionId? stripeSubscriptionId) { + EnsurePending(); Status = StripeEventStatus.Processed; ProcessedAt = processedAt; + TenantId = tenantId; + StripeSubscriptionId = stripeSubscriptionId; } /// @@ -68,6 +159,7 @@ public void MarkProcessed(DateTimeOffset processedAt) /// public void MarkIgnored(DateTimeOffset processedAt) { + EnsurePending(); Status = StripeEventStatus.Ignored; ProcessedAt = processedAt; } @@ -77,18 +169,17 @@ public void MarkIgnored(DateTimeOffset processedAt) /// public void MarkFailed(DateTimeOffset failedAt, string error) { + EnsurePending(); Status = StripeEventStatus.Failed; ProcessedAt = failedAt; Error = error; } - public void SetStripeSubscriptionId(StripeSubscriptionId? stripeSubscriptionId) + private void EnsurePending() { - StripeSubscriptionId = stripeSubscriptionId; - } - - public void SetTenantId(TenantId? tenantId) - { - TenantId = tenantId; + if (Status is not StripeEventStatus.Pending) + { + throw new InvalidOperationException($"StripeEvent '{Id.Value}' is no longer Pending (status: {Status}); refusing to mutate."); + } } } diff --git a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs index 6d8a23753e..36c779c01c 100644 --- a/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/StripeEventRepository.cs @@ -18,6 +18,23 @@ public interface IStripeEventRepository : IAppendRepository Task HasPendingByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns the durable-archive stripe_event rows for a customer that the replayer should + /// consume to (re)build the BillingEvent log. Includes both webhook-delivered and + /// reconciliation-recovered events (Status=Processed); excludes Pending (not yet + /// processed), Ignored (no customer match), and Failed. Source of truth for replay: + /// this archive, not Stripe's events.list (which only retains 30 days per + /// https://docs.stripe.com/api/events). + /// + Task GetReplayableByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns the set of Stripe event ids (regardless of Status) already recorded for a customer. + /// Used by the reconciliation passes to detect events that exist in Stripe but not in our + /// archive — those are inserted as recovered events with status=Processed. + /// + Task> GetExistingEventIdsByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); } internal sealed class StripeEventRepository(AccountDbContext accountDbContext) @@ -40,4 +57,25 @@ public async Task HasPendingByStripeCustomerIdAsync(StripeCustomerId strip { return await DbSet.AnyAsync(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Pending, cancellationToken); } + + public async Task GetReplayableByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + // No ORDER BY: the replayer is the canonical sort site (orders by CreatedAt then EventId for + // a stable tie-break). Materializing here keeps the query SQLite-translatable and the set is + // bounded per-customer (typically <200 webhooks over a subscription's lifetime). Status=Processed + // covers both webhook-delivered events and reconciliation-recovered events (CreateRecovered lands + // them as Processed directly). + return await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId && e.Status == StripeEventStatus.Processed) + .ToArrayAsync(cancellationToken); + } + + public async Task> GetExistingEventIdsByStripeCustomerIdAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + var ids = await DbSet + .Where(e => e.StripeCustomerId == stripeCustomerId) + .Select(e => e.Id.Value) + .ToArrayAsync(cancellationToken); + return [.. ids]; + } } diff --git a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs index a91becd2c2..1949a3d866 100644 --- a/application/account/Core/Features/Subscriptions/Domain/Subscription.cs +++ b/application/account/Core/Features/Subscriptions/Domain/Subscription.cs @@ -34,12 +34,15 @@ private Subscription(TenantId tenantId) : base(SubscriptionId.NewId()) TenantId = tenantId; Plan = SubscriptionPlan.Basis; PaymentTransactions = ImmutableArray.Empty; + DriftDiscrepancies = ImmutableArray.Empty; } public SubscriptionPlan Plan { get; private set; } public SubscriptionPlan? ScheduledPlan { get; private set; } + public decimal? ScheduledPriceAmount { get; private set; } + public StripeCustomerId? StripeCustomerId { get; private set; } public StripeSubscriptionId? StripeSubscriptionId { get; private set; } @@ -58,12 +61,20 @@ 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; } 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) @@ -81,14 +92,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 +118,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 +148,7 @@ public void ResetToFreePlan() { Plan = SubscriptionPlan.Basis; ScheduledPlan = null; + ScheduledPriceAmount = null; StripeSubscriptionId = null; CurrentPriceAmount = null; CurrentPriceCurrency = null; @@ -135,12 +157,27 @@ public void ResetToFreePlan() FirstPaymentFailedAt = null; CancellationReason = null; CancellationFeedback = null; + SubscribedSince = null; } public bool HasActiveStripeSubscription() { return StripeSubscriptionId is not null && Plan != SubscriptionPlan.Basis && !CancelAtPeriodEnd; } + + public void SetDriftStatus(ImmutableArray discrepancies, DateTimeOffset checkedAt) + { + DriftDiscrepancies = discrepancies; + HasDriftDetected = !discrepancies.IsDefaultOrEmpty; + DriftCheckedAt = checkedAt; + } + + public void AcknowledgeDrift(DateTimeOffset acknowledgedAt) + { + // Manual override clears the flag but preserves the discrepancy list for audit. + HasDriftDetected = false; + DriftCheckedAt = acknowledgedAt; + } } [PublicAPI] @@ -163,10 +200,83 @@ public sealed record PaymentMethod(string Brand, string Last4, int ExpMonth, int public sealed record PaymentTransaction( PaymentTransactionId Id, decimal Amount, + decimal AmountExcludingTax, + decimal TaxAmount, string Currency, PaymentTransactionStatus Status, DateTimeOffset Date, string? FailureReason, string? InvoiceUrl, - string? CreditNoteUrl + string? CreditNoteUrl, + SubscriptionPlan? Plan = null, + DateTimeOffset? RefundedAt = null +); + +[PublicAPI] +public sealed record DriftDiscrepancy( + DriftDiscrepancyKind Kind, + string Description, + DriftSeverity Severity, + BillingEventType? ExpectedEventType = null, + string? ExpectedValue = null, + string? ActualValue = null, + DateTimeOffset? OccurredAt = null ); + +[PublicAPI] +public enum DriftDiscrepancyKind +{ + MissingEvent, + ExtraEvent, + FieldDisagree, + SubscriptionStateMismatch, + + /// + /// A Stripe event arrived whose payload combined multiple state changes that the writer couldn't + /// decompose into a single domain transition (e.g. a customer.subscription.updated whose + /// previous_attributes contain both a cancel_at_period_end toggle and a price change). The + /// event is recorded as BillingEventType.Unclassified; this discrepancy surfaces it on + /// the drift banner so an admin can investigate in Stripe Dashboard. + /// + UnclassifiedStripeEvent, + + /// + /// A subscription resource (payment transaction, schedule, etc.) implies a Stripe event + /// should exist in our archive but doesn't. The event is still within Stripe's 30-day + /// events.list retention window, so the next reconciliation pass should automatically + /// recover it. The drift banner shows a countdown of the remaining time before this + /// escalates to . + /// + MissingHistoricalEvent, + + /// + /// A subscription resource implies a Stripe event should exist in our archive but doesn't, + /// and Stripe's 30-day events.list retention window has closed. The data is permanently + /// lost from Stripe — escalates to a P1 incident on the drift banner so the missed + /// reconciliation can be investigated and the underlying bug fixed. + /// + MissingHistoricalEventUnrecoverable, + + /// + /// Stripe sent an event whose api_version doesn't have a matching + /// IStripeEventPayloadResolver. The event is preserved unchanged in + /// stripe_events; the replayer skips it and surfaces this discrepancy so the + /// resolver-per-version mapping can be extended. + /// + UnsupportedStripeApiVersion, + + /// + /// The same Stripe event id was observed twice with different payloads (SHA-256 hash + /// mismatch on the second arrival). The original row is preserved; the divergence is + /// surfaced for forensic review. Either Stripe redelivered an event with mutated content + /// (their bug to investigate) or our hashing is broken (our bug to investigate). + /// + StripeEventPayloadDivergence +} + +[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 9c80e0254d..a7d3fc765b 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionConfiguration.cs @@ -22,6 +22,7 @@ public void Configure(EntityTypeBuilder builder) builder.MapStronglyTypedNullableId(s => s.StripeSubscriptionId); builder.Property(s => s.CurrentPriceAmount).HasPrecision(18, 2); + builder.Property(s => s.ScheduledPriceAmount).HasPrecision(18, 2); builder.Property(s => s.PaymentTransactions) .HasColumnType("jsonb") @@ -44,5 +45,18 @@ public void Configure(EntityTypeBuilder builder) v => v == null ? null : JsonSerializer.Serialize(v, JsonSerializerOptions), v => v == null ? null : JsonSerializer.Deserialize(v, JsonSerializerOptions) ); + + builder.Property(s => s.DriftDiscrepancies) + .HasColumnType("jsonb") + .HasConversion( + v => JsonSerializer.Serialize(v.ToArray(), JsonSerializerOptions), + v => JsonSerializer.Deserialize>(v, JsonSerializerOptions) + ) + .Metadata.SetValueComparer(new ValueComparer>( + (c1, c2) => c1.SequenceEqual(c2), + c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())), + c => c + ) + ); } } diff --git a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs index 27ab0e3a00..f6b578b683 100644 --- a/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs +++ b/application/account/Core/Features/Subscriptions/Domain/SubscriptionRepository.cs @@ -1,6 +1,7 @@ using Account.Database; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.ExecutionContext; using SharedKernel.Persistence; @@ -22,6 +23,32 @@ 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); + + /// + /// Retrieves every subscription on a paid plan (Plan != Basis) without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute total monthly recurring revenue across all tenants. + /// + Task GetAllActiveUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Counts subscriptions where billing drift has been detected and not yet acknowledged. Bypasses the + /// tenant query filter because the back-office is cross-tenant by design. + /// + Task CountWithDriftDetectedUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Counts paid subscriptions that have no rows in billing_events — i.e. subscriptions that have + /// never been synced into the BillingEvent log. The dashboard's MRR trend silently under-counts + /// these, so the back-office surfaces the count as a banner. Bypasses the tenant query filter + /// because the back-office is cross-tenant by design. + /// + Task CountWithoutBillingEventsUnfilteredAsync(CancellationToken cancellationToken); } internal sealed class SubscriptionRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -42,18 +69,50 @@ public async Task GetCurrentAsync(CancellationToken cancellationTo { if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") { - return await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(s => s.StripeCustomerId == stripeCustomerId, cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(s => s.StripeCustomerId == stripeCustomerId, cancellationToken); } return await DbSet .FromSqlInterpolated($"SELECT * FROM subscriptions WHERE stripe_customer_id = {stripeCustomerId.Value} FOR UPDATE") - .IgnoreQueryFilters() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) .SingleOrDefaultAsync(cancellationToken); } public async Task GetByTenantIdUnfilteredAsync(TenantId tenantId, CancellationToken cancellationToken) { return DbSet.Local.SingleOrDefault(s => s.TenantId == tenantId) - ?? await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(s => s.TenantId == tenantId, cancellationToken); + ?? await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(s => s.TenantId == tenantId, cancellationToken); + } + + /// + /// Retrieves all subscriptions for the given tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task GetByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(s => tenantIds.AsEnumerable().Contains(s.TenantId)).ToArrayAsync(cancellationToken); + } + + /// + /// Retrieves every subscription on a paid plan (Plan != Basis) without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute total monthly recurring revenue across all tenants. + /// + public async Task GetAllActiveUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(s => s.Plan != SubscriptionPlan.Basis).ToArrayAsync(cancellationToken); + } + + public async Task CountWithDriftDetectedUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).CountAsync(s => s.HasDriftDetected, cancellationToken); + } + + public async Task CountWithoutBillingEventsUnfilteredAsync(CancellationToken cancellationToken) + { + var tenantFilterName = new[] { QueryFilterNames.Tenant }; + return await DbSet.IgnoreQueryFilters(tenantFilterName) + .Where(s => s.CurrentPriceAmount != null) + .Where(s => !accountDbContext.Set().IgnoreQueryFilters(tenantFilterName).Any(e => e.SubscriptionId == s.Id)) + .CountAsync(cancellationToken); } } diff --git a/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs new file mode 100644 index 0000000000..765b1895d6 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/BillingDriftDetector.cs @@ -0,0 +1,118 @@ +using System.Collections.Immutable; +using Account.Features.Subscriptions.Domain; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Pure function that detects drift between the local subscription state and Stripe's authoritative state. +/// Runs inline at the end of every Stripe sync (per-customer) so drift is surfaced immediately on the next +/// webhook for that account, with no scheduled job required. +/// The detector covers — comparing +/// `Plan`, `CancelAtPeriodEnd`, `CurrentPriceAmount`, `CurrentPriceCurrency` between the local snapshot +/// captured before sync mutations and the Stripe snapshot captured from Stripe's response. These fields +/// drive customer access and are operationally the most important to keep aligned. It also flags a coarse +/// when there are stored PaymentTransactions but zero +/// BillingEvent rows for the subscription — 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(StripeSyncSnapshot localSnapshot, StripeSyncSnapshot stripeSnapshot, int paymentTransactionCount, 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 (paymentTransactionCount > 0 && billingEventCount == 0) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.MissingEvent, + $"Subscription has {paymentTransactionCount} payment transactions but no billing events recorded.", + DriftSeverity.Warning, + ExpectedValue: paymentTransactionCount.ToString(), + ActualValue: "0" + ) + ); + } + + if (localSnapshot.Plan != stripeSnapshot.Plan) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Plan differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: stripeSnapshot.Plan.ToString(), + ActualValue: localSnapshot.Plan.ToString() + ) + ); + } + + if (localSnapshot.CancelAtPeriodEnd != stripeSnapshot.CancelAtPeriodEnd) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Cancel-at-period-end differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: stripeSnapshot.CancelAtPeriodEnd.ToString(), + ActualValue: localSnapshot.CancelAtPeriodEnd.ToString() + ) + ); + } + + if (localSnapshot.CurrentPriceAmount != stripeSnapshot.CurrentPriceAmount) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price amount differs between local subscription and Stripe.", + DriftSeverity.Critical, + ExpectedValue: stripeSnapshot.CurrentPriceAmount?.ToString(), + ActualValue: localSnapshot.CurrentPriceAmount?.ToString() + ) + ); + } + + if (localSnapshot.CurrentPriceCurrency != stripeSnapshot.CurrentPriceCurrency) + { + discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.SubscriptionStateMismatch, + "Current price currency differs between local subscription and Stripe.", + DriftSeverity.Warning, + ExpectedValue: stripeSnapshot.CurrentPriceCurrency, + ActualValue: localSnapshot.CurrentPriceCurrency + ) + ); + } + + return discrepancies.ToImmutable(); + } +} + +/// +/// Snapshot of subscription state captured at a point in time. Used twice during drift detection: once for +/// the local subscription state captured before any sync mutations are applied, and once for Stripe's +/// authoritative view captured from the SubscriptionSyncResult returned by the Stripe client. Comparing +/// the two surfaces real drift even though the local subscription is mutated to match Stripe later in the +/// same sync. The shape is also the seam where additional Stripe data (full invoice history, charge +/// history with refunds, scheduled-phase data) plugs in for the BillingEvent-comparison extension. +/// +public sealed record StripeSyncSnapshot( + SubscriptionPlan Plan, + bool CancelAtPeriodEnd, + decimal? CurrentPriceAmount, + string? CurrentPriceCurrency +) +{ + public static StripeSyncSnapshot FromSubscription(Subscription subscription) + { + return new StripeSyncSnapshot( + subscription.Plan, + subscription.CancelAtPeriodEnd, + subscription.CurrentPriceAmount, + subscription.CurrentPriceCurrency + ); + } +} 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 0d9858f8e1..4fd3999ea7 100644 --- a/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs +++ b/application/account/Core/Features/Subscriptions/Shared/ProcessPendingStripeEvents.cs @@ -10,14 +10,17 @@ 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, ISubscriptionRepository subscriptionRepository, IStripeEventRepository stripeEventRepository, + IBillingEventRepository billingEventRepository, ITenantRepository tenantRepository, StripeClientFactory stripeClientFactory, TimeProvider timeProvider, @@ -26,7 +29,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"; @@ -45,9 +53,12 @@ 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); + var recoveredEvents = await ReconcileEventLogFromEventsListAsync(stripeCustomerId, cancellationToken); + var driftSnapshots = await SyncStateFromStripe(tenant, subscription, cancellationToken); + await SyncBillingEventsAsync(subscription, pendingEvents, recoveredEvents, driftSnapshots, cancellationToken); MarkAllEventsAsProcessed(pendingEvents, subscription); } @@ -58,12 +69,14 @@ public async Task ExecuteAsync(StripeCustomerId stripeCustomerId, CancellationTo SendTelemetryEvents(tenant, subscription); } - private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, CancellationToken cancellationToken) + 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); + // Snapshot captured before any mutation so the drift detector can compare local-pre-sync against Stripe. + var localSnapshot = StripeSyncSnapshot.FromSubscription(subscription); + var previousPlan = subscription.Plan; var previousPriceAmount = subscription.CurrentPriceAmount; var previousPriceCurrency = subscription.CurrentPriceCurrency; @@ -71,23 +84,27 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, if (customerResult is null) { logger.LogError("Failed to fetch billing info for Stripe customer '{StripeCustomerId}'", subscription.StripeCustomerId); - return; + return new DriftSnapshots(localSnapshot, null); } if (customerResult.IsCustomerDeleted) { + var nowAtCustomerDeleted = timeProvider.GetUtcNow(); subscription.ResetToFreePlan(); tenant.UpdatePlan(SubscriptionPlan.Basis); - tenant.Suspend(SuspensionReason.CustomerDeleted, timeProvider.GetUtcNow()); + tenant.Suspend(SuspensionReason.CustomerDeleted, nowAtCustomerDeleted); tenantRepository.Update(tenant); subscriptionRepository.Update(subscription); events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.CustomerDeleted, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); - return; + // Stripe's view: customer is gone, no subscription. Pair with the pre-sync local snapshot above. + return new DriftSnapshots(localSnapshot, new StripeSyncSnapshot(SubscriptionPlan.Basis, false, null, null)); } 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 SyncBillingEventsAsync 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; @@ -109,14 +126,12 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, var now = timeProvider.GetUtcNow(); var daysOnCurrentPlan = (int)(now - (subscription.ModifiedAt ?? subscription.CreatedAt)).TotalDays; - // Apply Stripe state to aggregate (after detection, before side effects) if (stripeState is not null) { - subscription.SetStripeSubscription(stripeState.StripeSubscriptionId, stripeState.Plan, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency, stripeState.CurrentPeriodEnd, stripeState.PaymentMethod); + 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) { @@ -161,10 +176,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 +187,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 +196,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!)); } @@ -218,7 +233,7 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, { subscription.ResetToFreePlan(); tenant.UpdatePlan(SubscriptionPlan.Basis); - tenant.Suspend(SuspensionReason.PaymentFailed, timeProvider.GetUtcNow()); + tenant.Suspend(SuspensionReason.PaymentFailed, now); events.CollectEvent(new SubscriptionSuspended(subscription.Id, previousPlan, SuspensionReason.PaymentFailed, previousPriceAmount!.Value, -previousPriceAmount.Value, previousPriceCurrency!)); } @@ -244,7 +259,6 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, events.CollectEvent(new PaymentRefunded(subscription.Id, plan, refundCount, latestRefund.Amount, latestRefund.Currency)); } - // Persist all aggregate mutations and mark pending events as processed var tenantChanged = stripeState is not null || subscriptionCreated || subscriptionExpired || subscriptionImmediatelyCancelled || subscriptionSuspended; if (tenantChanged) { @@ -252,6 +266,293 @@ private async Task SyncStateFromStripe(Tenant tenant, Subscription subscription, } subscriptionRepository.Update(subscription); + + // Stripe snapshot built from the just-fetched Stripe state, paired with the pre-sync local snapshot + // captured at the start of this method. When stripeState is null Stripe has no active subscription + // for the customer, so the equivalent snapshot is the free plan with no price. + var stripeSnapshot = stripeState is null + ? new StripeSyncSnapshot(SubscriptionPlan.Basis, false, null, null) + : new StripeSyncSnapshot(stripeState.Plan, stripeState.CancelAtPeriodEnd, stripeState.CurrentPriceAmount, stripeState.CurrentPriceCurrency); + return new DriftSnapshots(localSnapshot, stripeSnapshot); + } + + /// + /// Synchronizes the BillingEvent ledger with the customer's Stripe events history. Unions three + /// in-memory event sources — the durable stripe_events archive (Status=Processed), + /// the just-arrived webhook events (still Pending in this transaction), and any events recovered + /// in this same transaction by — dedupes by + /// event id, then runs the union through and appends any + /// newly recognized rows to billing_events. The just-arrived webhook events come in via + /// the parameter so they participate in the same pass — without + /// this, the most recent webhook would not be reflected in the BillingEvent ledger until the next + /// webhook (or admin Sync) ran, since the archive query filters Status=Processed and + /// flips Pending → Processed only after this method returns. + /// The local archive is the durable source of truth — Stripe's events.list only retains events + /// for 30 days (see https://docs.stripe.com/api/events), so beyond that window the archive is the + /// only complete history. + /// 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 SyncBillingEventsAsync(Subscription subscription, StripeEvent[] pendingEvents, StripeEvent[] recoveredEvents, DriftSnapshots driftSnapshots, CancellationToken cancellationToken) + { + if (subscription.StripeCustomerId is null) return; + + // Read the durable archive and union with events that haven't been SaveChanges'd yet in this + // transaction (the just-arrived Pending webhook events and any events recovered from events.list). + // Querying alone would miss them: archive query filters Status=Processed, and the EF DbSet + // doesn't surface entities only added in the current change set. All three sources are dedup'd by + // event id. Sort is owned by the replayer (canonical sort site: CreatedAt then EventId for a + // stable tie-break). + var archivedEvents = await stripeEventRepository.GetReplayableByStripeCustomerIdAsync(subscription.StripeCustomerId, cancellationToken); + var allEvents = archivedEvents + .Concat(pendingEvents) + .Concat(recoveredEvents) + .GroupBy(e => e.Id.Value) + .Select(g => g.First()) + .ToArray(); + + if (allEvents.Length == 0) + { + await DetectDrift(subscription, driftSnapshots, 0, false, [], cancellationToken); + return; + } + + var unsupportedVersions = new HashSet(); + var supportedEvents = new List(allEvents.Length); + foreach (var stripeEvent in allEvents) + { + if (StripeEventPayloadResolverFactory.TryFor(stripeEvent.ApiVersion, out _)) + { + supportedEvents.Add(stripeEvent); + continue; + } + + if (unsupportedVersions.Add(stripeEvent.ApiVersion)) + { + logger.LogWarning( + "Stripe event {EventId} has unsupported api_version '{ApiVersion}'; replay skipped — add an IStripeEventPayloadResolver implementation", + stripeEvent.Id.Value, stripeEvent.ApiVersion ?? "null" + ); + } + } + + 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 replayEvents = supportedEvents + .Select(e => new StripeReplayEvent(e.Id.Value, e.EventType, e.CreatedAt, e.Payload ?? "", e.ApiVersion)) + .ToArray(); + + var existingStripeEventIds = await billingEventRepository.GetExistingStripeEventIdsUnfilteredAsync(subscription.Id, cancellationToken); + var state = new StripeEventReplayer.ReplayState(); + var replayedEvents = StripeEventReplayer.Replay(subscription, replayEvents, planByPriceId, priceByPlan, state); + + var appendedCount = 0; + foreach (var billingEvent in replayedEvents) + { + if (existingStripeEventIds.Contains(billingEvent.StripeEventId)) continue; + await billingEventRepository.AddAsync(billingEvent, cancellationToken); + appendedCount++; + } + + var totalBillingEvents = existingStripeEventIds.Count + appendedCount; + await DetectDrift(subscription, driftSnapshots, totalBillingEvents, state.HasUnclassifiedEvent, unsupportedVersions, cancellationToken); + } + + private async Task DetectDrift(Subscription subscription, DriftSnapshots driftSnapshots, int billingEventCount, bool hasUnclassifiedEvent, HashSet unsupportedApiVersions, CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + try + { + // The Stripe snapshot is null when the customer fetch failed earlier in the sync, so fall back + // to the local pre-sync view (no SubscriptionStateMismatch can be detected without a Stripe view, + // but the other coverage checks still run). + var stripeSnapshot = driftSnapshots.Stripe ?? driftSnapshots.LocalBeforeSync; + var discrepancies = BillingDriftDetector.Detect(driftSnapshots.LocalBeforeSync, stripeSnapshot, subscription.PaymentTransactions.Length, billingEventCount); + if (hasUnclassifiedEvent) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.UnclassifiedStripeEvent, + "Stripe sent a subscription update combining multiple changes that don't decompose into a single domain transition. Investigate in Stripe Dashboard.", + DriftSeverity.Warning + ) + ); + } + + foreach (var version in unsupportedApiVersions) + { + discrepancies = discrepancies.Add(new DriftDiscrepancy( + DriftDiscrepancyKind.UnsupportedStripeApiVersion, + $"Stripe sent an event using api_version '{version ?? "null"}' for which no IStripeEventPayloadResolver is registered. The event is preserved in stripe_events but not replayed into billing_events. Add a resolver and re-sync.", + DriftSeverity.Critical, + ActualValue: version + ) + ); + } + + var coverageDiscrepancies = await CheckResourceCoverageAsync(subscription, now, cancellationToken); + discrepancies = discrepancies.AddRange(coverageDiscrepancies); + + subscription.SetDriftStatus(discrepancies, now); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Drift detection threw while syncing Stripe customer '{StripeCustomerId}', existing drift status preserved", subscription.StripeCustomerId); + } + } + + /// + /// Per-resource audit: walks every Stripe-tracked resource on the subscription and verifies + /// a corresponding event exists in the local archive. Each missing event becomes a drift + /// discrepancy. The recovery countdown is driven by the resource's known timestamp: if the + /// resource timestamp is within Stripe's 30-day events.list window the discrepancy is + /// (auto-recoverable on next + /// reconciliation pass); past the window it escalates to + /// (P1: data is + /// permanently lost from Stripe and must be investigated as a reconciliation bug). + /// TODO: if a coverage kind proves too noisy in production, expose per-kind suppression via + /// IConfiguration (e.g. BillingDrift:DisabledCoverageKinds:0=PaymentMethodAttached) so operators + /// can silence it without a deploy. + /// + private async Task CheckResourceCoverageAsync(Subscription subscription, DateTimeOffset now, CancellationToken cancellationToken) + { + var discrepancies = new List(); + if (subscription.StripeCustomerId is null) return [.. discrepancies]; + + var archive = await stripeEventRepository.GetReplayableByStripeCustomerIdAsync(subscription.StripeCustomerId, cancellationToken); + var eventTypesPresent = archive.Select(e => e.EventType).ToHashSet(); + + if (subscription.SubscribedSince is { } subscribedSince && !eventTypesPresent.Contains("customer.subscription.created")) + { + discrepancies.Add(BuildCoverageDiscrepancy( + "customer.subscription.created", subscribedSince, now, + $"Subscription started on {subscribedSince:O} but no customer.subscription.created event is recorded.", + BillingEventType.SubscriptionCreated + ) + ); + } + + var succeededTransactions = subscription.PaymentTransactions.Where(t => t.Status == PaymentTransactionStatus.Succeeded).ToArray(); + if (succeededTransactions.Length > 0 && !eventTypesPresent.Contains("invoice.payment_succeeded")) + { + var earliest = succeededTransactions.Min(t => t.Date); + discrepancies.Add(BuildCoverageDiscrepancy( + "invoice.payment_succeeded", earliest, now, + $"Subscription has {succeededTransactions.Length} succeeded payments but no invoice.payment_succeeded event is recorded.", + BillingEventType.SubscriptionRenewed + ) + ); + } + + var refundedTransactions = subscription.PaymentTransactions.Where(t => t.Status == PaymentTransactionStatus.Refunded).ToArray(); + if (refundedTransactions.Length > 0 && !eventTypesPresent.Contains("charge.refunded")) + { + var earliestRefund = refundedTransactions.Min(t => t.RefundedAt ?? t.Date); + discrepancies.Add(BuildCoverageDiscrepancy( + "charge.refunded", earliestRefund, now, + $"Subscription has {refundedTransactions.Length} refunded payments but no charge.refunded event is recorded.", + BillingEventType.PaymentRefunded + ) + ); + } + + if (subscription.ScheduledPlan is not null && !eventTypesPresent.Contains("subscription_schedule.updated")) + { + var scheduledAt = subscription.ModifiedAt ?? subscription.CreatedAt; + discrepancies.Add(BuildCoverageDiscrepancy( + "subscription_schedule.updated", scheduledAt, now, + $"Subscription has a scheduled plan ({subscription.ScheduledPlan}) but no subscription_schedule.updated event is recorded.", + BillingEventType.SubscriptionDowngradeScheduled + ) + ); + } + + if (subscription.PaymentMethod is not null && !eventTypesPresent.Contains("payment_method.attached")) + { + var attachedAt = subscription.SubscribedSince ?? subscription.CreatedAt; + discrepancies.Add(BuildCoverageDiscrepancy( + "payment_method.attached", attachedAt, now, + "Subscription has a payment method but no payment_method.attached event is recorded.", + BillingEventType.PaymentMethodUpdated + ) + ); + } + + return [.. discrepancies]; + } + + private static DriftDiscrepancy BuildCoverageDiscrepancy( + string expectedEventType, + DateTimeOffset eventOccurredAt, + DateTimeOffset now, + string description, + BillingEventType billingEventType + ) + { + var stripeRetentionWindow = TimeSpan.FromDays(30); + var withinWindow = now - eventOccurredAt < stripeRetentionWindow; + var kind = withinWindow ? DriftDiscrepancyKind.MissingHistoricalEvent : DriftDiscrepancyKind.MissingHistoricalEventUnrecoverable; + var severity = withinWindow ? DriftSeverity.Warning : DriftSeverity.Critical; + return new DriftDiscrepancy( + kind, + $"{description} Expected event type: {expectedEventType}.", + severity, + billingEventType, + OccurredAt: eventOccurredAt + ); + } + + /// + /// Closes gaps in the local stripe_events archive by listing Stripe's events.list API + /// (30-day retention window — see https://docs.stripe.com/api/events) and inserting any event + /// ids Stripe knows about but we don't. Recovered events land as Status=Processed with + /// recovery_source = "events_list"; the subsequent + /// pass picks them up (alongside the durable archive) and emits the corresponding BillingEvent + /// rows. Returns the recovered events so the replayer can union them with the archive without + /// needing an intermediate SaveChanges (the EF DbSet query won't see entities only added in + /// the current change set). + /// + private async Task ReconcileEventLogFromEventsListAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) + { + var stripeClient = stripeClientFactory.GetClient(); + var stripeEvents = await stripeClient.GetEventsForCustomerAsync(stripeCustomerId, cancellationToken); + if (stripeEvents.Length == 0) return []; + + var existingIds = await stripeEventRepository.GetExistingEventIdsByStripeCustomerIdAsync(stripeCustomerId, cancellationToken); + var now = timeProvider.GetUtcNow(); + var recovered = new List(); + + foreach (var stripeEvent in stripeEvents) + { + if (existingIds.Contains(stripeEvent.EventId)) continue; + + var payloadHash = StripeEventPayloadHasher.Hash(stripeEvent.Payload); + var recoveredEvent = StripeEvent.CreateRecovered( + stripeEvent.EventId, + stripeEvent.EventType, + stripeCustomerId, + stripeEvent.Payload, + stripeEvent.ApiVersion, + payloadHash, + now, + "events_list" + ); + await stripeEventRepository.AddAsync(recoveredEvent, cancellationToken); + recovered.Add(recoveredEvent); + + events.CollectEvent(new WebhookDeliveryRecovered(stripeEvent.EventId, stripeEvent.EventType, "events_list")); + logger.LogWarning( + "Recovered Stripe event {EventId} ({EventType}) for customer '{StripeCustomerId}' from events.list — webhook delivery was missed", + stripeEvent.EventId, stripeEvent.EventType, stripeCustomerId + ); + } + + return [.. recovered]; } private void MarkAllEventsAsProcessed(StripeEvent[] pendingEvents, Subscription subscription) @@ -260,9 +561,7 @@ private void MarkAllEventsAsProcessed(StripeEvent[] pendingEvents, Subscription foreach (var pendingEvent in pendingEvents) { - pendingEvent.MarkProcessed(now); - pendingEvent.SetStripeSubscriptionId(subscription.StripeSubscriptionId); - pendingEvent.SetTenantId(subscription.TenantId); + pendingEvent.MarkProcessed(now, subscription.TenantId, subscription.StripeSubscriptionId); stripeEventRepository.Update(pendingEvent); } } @@ -271,7 +570,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(); @@ -279,4 +577,6 @@ private void SendTelemetryEvents(Tenant tenant, Subscription subscription) logger.LogInformation("Telemetry: {EventName} {EventProperties}", telemetryEvent.GetType().Name, string.Join(", ", telemetryEvent.Properties.Select(p => $"{p.Key}={p.Value}"))); } } + + private sealed record DriftSnapshots(StripeSyncSnapshot LocalBeforeSync, StripeSyncSnapshot? Stripe); } diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs new file mode 100644 index 0000000000..5c19366008 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadHasher.cs @@ -0,0 +1,20 @@ +using System.Security.Cryptography; +using System.Text; + +namespace Account.Features.Subscriptions.Shared; + +/// +/// Stable SHA-256 hash of a raw Stripe webhook payload. Used by AcknowledgeStripeWebhook on insert +/// and by the reconciliation pass on recovered rows so the same value lands in stripe_events.payload_hash +/// regardless of the entry path. Comparing hashes is how StripeEventPayloadDivergence detects the +/// forensic anomaly of a redelivered event with a different body. +/// Hex-lower so the same hash format works for grep/log pivots regardless of Stripe API version. +/// +public static class StripeEventPayloadHasher +{ + public static string Hash(string payload) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); + return Convert.ToHexStringLower(bytes); + } +} diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs new file mode 100644 index 0000000000..d54fbc8f98 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventPayloadResolver.cs @@ -0,0 +1,76 @@ +namespace Account.Features.Subscriptions.Shared; + +/// +/// Per-Stripe-API-version dispatcher for the JSON-shape navigation that the replayer relies on. +/// Stripe events carry an immutable api_version at creation time +/// (see https://docs.stripe.com/api/events). When Stripe ships a new API version that renames +/// fields or restructures the payload, a new resolver implementation handles the new shape while +/// existing rows keep their original resolver — old fixtures keep passing without modification. +/// throws +/// for unknown versions; the calling code +/// catches it and adds a UnsupportedStripeApiVersion drift discrepancy so an admin knows +/// to add the new resolver. +/// +public interface IStripeEventPayloadResolver; + +/// +/// Default resolver for Stripe API versions whose JSON shape matches what +/// currently parses. Extends as new versions ship — +/// each new resolver implementation handles its own payload navigation. +/// +public sealed class DefaultStripeEventPayloadResolver : IStripeEventPayloadResolver; + +/// +/// Routes a Stripe event's api_version to the matching resolver. The legacy null version +/// covers rows recorded before the api_version column existed and is mapped to the default +/// resolver. +/// +public static class StripeEventPayloadResolverFactory +{ + private static readonly DefaultStripeEventPayloadResolver Default = new(); + + // TODO: When Stripe rolls a new pinned API version, update this list AND add a new + // IStripeEventPayloadResolver implementation. Future: derive from Stripe.NET's + // StripeConfiguration.ApiVersion. Keeping it explicit for now so adding a version is a + // deliberate, reviewable change rather than a silent dependency upgrade. + private static readonly HashSet RecognizedVersions = ["2025-09-30.preview", "2025-10-29.clover", null]; + + public static IStripeEventPayloadResolver For(string? apiVersion) + { + if (!TryFor(apiVersion, out var resolver)) + { + throw new UnsupportedStripeApiVersionException(apiVersion); + } + + return resolver; + } + + /// + /// Non-throwing variant of . Returns false when the api_version is unknown so + /// callers in hot paths (the replayer) can branch on the result instead of paying for an + /// exception. The throwing remains for genuinely unreachable cases. + /// + public static bool TryFor(string? apiVersion, out IStripeEventPayloadResolver resolver) + { + if (RecognizedVersions.Contains(apiVersion)) + { + resolver = Default; + return true; + } + + resolver = null!; + return false; + } +} + +/// +/// Thrown by when Stripe sends an event +/// whose api_version we don't have a resolver for. The replayer catches this, logs the event, +/// and surfaces a UnsupportedStripeApiVersion drift discrepancy so the missing resolver +/// can be added. +/// +public sealed class UnsupportedStripeApiVersionException(string? apiVersion) + : InvalidOperationException($"Stripe event api_version '{apiVersion ?? "null"}' is not supported by any registered IStripeEventPayloadResolver.") +{ + public string? ApiVersion { get; } = apiVersion; +} diff --git a/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs new file mode 100644 index 0000000000..c993b41606 --- /dev/null +++ b/application/account/Core/Features/Subscriptions/Shared/StripeEventReplayer.cs @@ -0,0 +1,649 @@ +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; + +/// +/// 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, + StripeReplayEvent[] stripeEvents, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + ReplayState? state = null + ) + { + var emitted = new List(); + state ??= new ReplayState(); + var currency = subscription.CurrentPriceCurrency ?? "USD"; + + foreach (var stripeEvent in stripeEvents.OrderBy(e => e.CreatedAt).ThenBy(e => e.EventId)) + { + var billingEvent = MapEvent(stripeEvent, subscription, state, planByPriceId, priceByPlan, currency); + if (billingEvent is not null) + { + emitted.Add(billingEvent); + } + } + + return emitted; + } + + private static BillingEvent? MapEvent( + StripeReplayEvent stripeEvent, + Subscription subscription, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency + ) + { + var subscriptionId = subscription.Id; + var tenantId = subscription.TenantId; + var occurredAt = stripeEvent.CreatedAt; + var stripeEventId = stripeEvent.EventId; + var payload = ParsePayload(stripeEvent.Payload); + + switch (stripeEvent.EventType) + { + case "customer.created": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.BillingInfoAdded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + + case "customer.updated": + return HasBillingFieldsChanged(payload) + ? BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.BillingInfoUpdated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ) + : NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + + case "payment_method.attached": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentMethodUpdated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, currency: currency + ); + + case "customer.subscription.created": + return MapSubscriptionCreated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency, subscription.Plan); + + case "customer.subscription.updated": + return MapSubscriptionUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency, subscription.CancellationReason); + + // customer.subscription.pending_update_applied fires alongside customer.subscription.updated for + // the same upgrade transition. The updated event carries previous_attributes and is the higher- + // fidelity source — pending_update_applied is recorded as NoOp to preserve the 1:1 audit row. + case "customer.subscription.pending_update_applied": + case "customer.subscription.pending_update_expired": + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + + case "customer.subscription.deleted": + return MapSubscriptionDeleted(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); + + case "customer.deleted": + return MapCustomerDeleted(occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); + + // subscription_schedule.created carries only the current phase — the future-phase plan that + // defines the downgrade target only shows up in the subsequent subscription_schedule.updated + // event. The created row is preserved as NoOp for the audit trail. + case "subscription_schedule.created": + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + + case "subscription_schedule.updated": + return MapScheduleUpdated(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, planByPriceId, priceByPlan, currency); + + case "subscription_schedule.released": + case "subscription_schedule.canceled": + return MapScheduleTerminated(occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); + + case "invoice.payment_succeeded": + return MapInvoicePaymentSucceeded(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); + + case "invoice.payment_failed": + return MapInvoicePaymentFailed(payload, occurredAt, stripeEventId, tenantId, subscriptionId, state, currency); + + case "charge.refunded": + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentRefunded, occurredAt, state.CommittedMrr, + toPlan: state.Plan, newAmount: state.CommittedMrr, currency: currency + ); + + default: + // Stripe event we don't have a case for. The 1:1 invariant only applies to events the + // writer recognizes — unknown events are not subscription-relevant and are skipped. + return null; + } + } + + private static BillingEvent MapSubscriptionCreated( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + IReadOnlyDictionary planByPriceId, + IReadOnlyDictionary priceByPlan, + string currency, + SubscriptionPlan fallbackPlan + ) + { + var newPlan = ResolvePlanFromSubscriptionPayload(payload, planByPriceId) ?? fallbackPlan; + var newPrice = 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) + { + 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 + ); + } + + 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; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionCancelled, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: 0m, amountDelta: -previousMrr, + currency: currency, + cancellationReason: subscriptionCancellationReason + ); + } + + if (cancelAtPeriodEndChanged && 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; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionReactivated, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: state.CommittedMrr, + amountDelta: state.CommittedMrr - previousMrr, + currency: currency + ); + } + + if (planChanged) + { + // Plan change (items.data[0].price changed). MRR impact is the real price diff between plans + // looked up from the catalog — so an upgrade Standard→Premium shows +150 and a downgrade + // Premium→Standard shows -150. + var eventType = newPlan!.Value > previousPlan!.Value ? BillingEventType.SubscriptionUpgraded : BillingEventType.SubscriptionDowngraded; + var previousMrr = state.CommittedMrr; + var newPrice = priceByPlan.TryGetValue(newPlan.Value, out var np) ? np : 0m; + state.Plan = newPlan; + state.PlanPrice = newPrice; + state.CommittedMrr = state.CancelAtPeriodEnd ? 0m : newPrice; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, + previousPlan, newPlan, + previousMrr, state.CommittedMrr, + state.CommittedMrr - previousMrr, + currency + ); + } + + // status: active → past_due (or unpaid). Customer's payment failed; subscription remains on plan but is + // delinquent. Both Stripe statuses indicate the same business state from our perspective — the + // dunning escalation path (past_due → unpaid → canceled) is a Stripe-side detail. Pairs with the + // PaymentFailed row that the invoice.payment_failed event produces at the same timestamp; both rows + // describe different facets of the same business event. Committed MRR unchanged. + if (previous.TryGetProperty("status", out var prevStatus) && prevStatus.GetString() == "active") + { + var newStatus = ResolveSubscriptionStatus(payload); + if (newStatus is "past_due" or "unpaid") + { + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionPastDue, occurredAt, state.CommittedMrr, + toPlan: state.Plan, newAmount: state.CommittedMrr, currency: currency + ); + } + } + + // latest_invoice rolled to a new invoice id — Stripe started a new billing cycle. This branch is + // intentionally a NoOp: + // * Happy-path renewal: invoice.payment_succeeded fires next and emits SubscriptionRenewed (or + // PaymentRecovered for retry success). Emitting SubscriptionRenewed here too would duplicate it. + // * Past_due renewal: the active → past_due status change in the same payload is handled by the + // branch above, which emits SubscriptionPastDue. The latest_invoice change is the same business + // event from a different angle and adds no unique signal. + // Audit row preserved so the 1:1 invariant holds. + if (previous.TryGetProperty("latest_invoice", out _)) + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + // previous_attributes carried fields we don't track (e.g. metadata, period dates). Audit row. + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + private static string? ResolveSubscriptionStatus(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var 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; + return sub.TryGetProperty("status", out var s) ? s.GetString() : null; + } + + private static BillingEvent MapSubscriptionDeleted( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + 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); + } + + var previousMrr = state.CommittedMrr; + var newMrr = state.PlanPrice; + state.ScheduledPlan = null; + state.CommittedMrr = newMrr; + var delta = newMrr - previousMrr; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.SubscriptionDowngradeCancelled, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + previousAmount: previousMrr, newAmount: newMrr, + amountDelta: delta == 0m ? null : delta, + currency: currency + ); + } + + private static BillingEvent MapInvoicePaymentSucceeded( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Only emit a Renewed/Recovered row for genuine recurring renewals (billing_reason == + // subscription_cycle). subscription_create is covered by customer.subscription.created; + // subscription_update is the proration invoice from a plan change and is covered by the + // customer.subscription.updated upgrade/downgrade row — emitting Renewed here would duplicate it. + var billingReason = ExtractInvoiceBillingReason(payload); + if (billingReason != "subscription_cycle") + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + var eventType = HasMultiplePaymentAttempts(payload) ? BillingEventType.PaymentRecovered : BillingEventType.SubscriptionRenewed; + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, eventType, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + } + + private static BillingEvent MapInvoicePaymentFailed( + JsonElement payload, + DateTimeOffset occurredAt, + string stripeEventId, + TenantId tenantId, + SubscriptionId subscriptionId, + ReplayState state, + string currency + ) + { + // Only emit PaymentFailed for genuine recurring billing failures (billing_reason == + // subscription_cycle). The proration invoice from a plan change can also fail and is covered by + // the customer.subscription.updated upgrade/downgrade row instead. Failures don't change committed + // MRR — the customer is still on the plan, just behind on payment. If Stripe later succeeds via a + // retry (3DS or smart-retry), invoice.payment_succeeded fires and emits PaymentRecovered — that's + // accurate history rather than swallowing the initial failure. + var billingReason = ExtractInvoiceBillingReason(payload); + if (billingReason != "subscription_cycle") + { + return NoOp(tenantId, subscriptionId, stripeEventId, occurredAt, state, currency); + } + + return BillingEvent.Create( + tenantId, subscriptionId, stripeEventId, BillingEventType.PaymentFailed, occurredAt, state.CommittedMrr, + toPlan: state.Plan, + newAmount: state.CommittedMrr, + currency: currency + ); + } + + 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()) + { + 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.ValueKind != JsonValueKind.Object) return null; + if (!previousAttributes.TryGetProperty("items", out var items) || items.ValueKind != JsonValueKind.Object) return null; + if (!items.TryGetProperty("data", out var itemsData) || itemsData.ValueKind != JsonValueKind.Array) return null; + foreach (var item in itemsData.EnumerateArray()) + { + var priceId = item.TryGetProperty("price", out var price) && price.TryGetProperty("id", out var 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) + { + 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; + + 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 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) + { + 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 _) + || previous.TryGetProperty("name", out _) + || previous.TryGetProperty("tax_ids", out _); + } + + private static string? ExtractInvoiceBillingReason(JsonElement payload) + { + if (payload.ValueKind != JsonValueKind.Object) return null; + var data = payload.TryGetProperty("data", out var 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 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; + } + } + + public sealed class ReplayState + { + public SubscriptionPlan? Plan { get; set; } + + public decimal PlanPrice { get; set; } + + public bool CancelAtPeriodEnd { get; set; } + + public SubscriptionPlan? ScheduledPlan { get; set; } + + public decimal CommittedMrr { get; set; } + + /// + /// Set to true when the replayer encounters a Stripe event whose payload combines multiple + /// state changes that don't decompose into a single domain transition. Callers translate this + /// into a Subscription.SetDriftStatus call so the existing drift banner picks it up. + /// + public bool HasUnclassifiedEvent { get; set; } + } +} diff --git a/application/account/Core/Features/TelemetryEvents.cs b/application/account/Core/Features/TelemetryEvents.cs index eb02f2068f..d5838143c1 100644 --- a/application/account/Core/Features/TelemetryEvents.cs +++ b/application/account/Core/Features/TelemetryEvents.cs @@ -100,6 +100,9 @@ public sealed class SignupCompleted(TenantId tenantId, int signupTimeInSeconds) public sealed class SignupStarted : TelemetryEvent; +public sealed class StripeEventPayloadMismatch(string eventId, string eventType, string existingHash, string newHash) + : TelemetryEvent(("event_id", eventId), ("event_type", eventType), ("existing_hash", existingHash), ("new_hash", newHash)); + public sealed class SubscriptionCancelled( SubscriptionId subscriptionId, SubscriptionPlan plan, @@ -211,9 +214,15 @@ 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)); +public sealed class TenantSyncedWithStripe(SubscriptionId subscriptionId, int billingEventsAppended) + : TelemetryEvent(("subscription_id", subscriptionId), ("billing_events_appended", billingEventsAppended)); + public sealed class TenantUpdated : TelemetryEvent; @@ -261,3 +270,6 @@ public sealed class UserZoomLevelChanged(string fromZoomLevel, string toZoomLeve public sealed class UsersBulkDeleted(int count) : TelemetryEvent(("count", count)); + +public sealed class WebhookDeliveryRecovered(string eventId, string eventType, string recoverySource) + : TelemetryEvent(("event_id", eventId), ("event_type", eventType), ("recovery_source", recoverySource)); diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs new file mode 100644 index 0000000000..b47c1f78bb --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/AcknowledgeBillingDrift.cs @@ -0,0 +1,36 @@ +using Account.Features.Subscriptions.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record AcknowledgeBillingDriftCommand : ICommand, IRequest +{ + [JsonIgnore] // Removes from API contract + public TenantId TenantId { get; init; } = null!; +} + +public sealed class AcknowledgeBillingDriftHandler( + ISubscriptionRepository subscriptionRepository, + TimeProvider timeProvider, + ITelemetryEventsCollector events +) : IRequestHandler +{ + public async Task Handle(AcknowledgeBillingDriftCommand command, CancellationToken cancellationToken) + { + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(command.TenantId, cancellationToken); + if (subscription is null) return Result.NotFound($"Subscription for tenant '{command.TenantId}' not found."); + + if (!subscription.HasDriftDetected) return Result.BadRequest("Subscription has no drift to acknowledge."); + + subscription.AcknowledgeDrift(timeProvider.GetUtcNow()); + subscriptionRepository.Update(subscription); + + events.CollectEvent(new TenantBillingDriftAcknowledged(subscription.Id)); + + return Result.Success(); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs new file mode 100644 index 0000000000..c971a53437 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Commands/SyncTenantWithStripe.cs @@ -0,0 +1,79 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Subscriptions.Shared; +using Account.Features.Tenants.Domain; +using Account.Integrations.Stripe; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Telemetry; + +namespace Account.Features.Tenants.BackOffice.Commands; + +[PublicAPI] +public sealed record 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, + 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."); + + 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; + + // 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, + hasDriftDetected, + driftDiscrepancyCount, + timeProvider.GetUtcNow() + ); + + events.CollectEvent(new TenantSyncedWithStripe(subscription.Id, billingEventsAppended)); + + return response; + } +} 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..cab060aaac --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantDetail.cs @@ -0,0 +1,97 @@ +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, + decimal? ScheduledPriceAmount, + bool CancelAtPeriodEnd, + decimal? MonthlyRecurringRevenue, + string? Currency, + DateTimeOffset? RenewalDate, + DateTimeOffset? SubscribedSince, + bool HasEverSubscribed, + BillingAddressResponse? BillingAddress, + decimal? LifetimeValue, + TenantState State, + SuspensionReason? SuspensionReason, + DateTimeOffset? SuspendedAt, + string? LogoUrl, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + bool HasDriftDetected, + DateTimeOffset? DriftCheckedAt, + DriftDiscrepancy[] DriftDiscrepancies +); + +[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.AmountExcludingTax); + + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(t => t.Status == PaymentTransactionStatus.Succeeded) == true; + + 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?.ScheduledPriceAmount, + subscription?.CancelAtPeriodEnd ?? false, + subscription?.CurrentPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + subscription?.SubscribedSince, + hasEverSubscribed, + billingAddress, + lifetimeValue, + tenant.State, + tenant.SuspensionReason, + tenant.SuspendedAt, + tenant.Logo.Url, + tenant.CreatedAt, + tenant.ModifiedAt, + subscription?.HasDriftDetected ?? false, + subscription?.DriftCheckedAt, + subscription?.DriftDiscrepancies.ToArray() ?? [] + ); + } +} 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..cbd6408c39 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantPaymentHistory.cs @@ -0,0 +1,74 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.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, + decimal AmountExcludingTax, + decimal TaxAmount, + string Currency, + PaymentTransactionStatus Status, + DateTimeOffset Date, + DateTimeOffset? RefundedAt, + string? FailureReason, + string? InvoiceUrl, + string? CreditNoteUrl, + SubscriptionPlan? Plan +); + +public sealed class GetTenantPaymentHistoryQueryValidator : AbstractValidator +{ + public GetTenantPaymentHistoryQueryValidator() + { + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantPaymentHistoryHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantPaymentHistoryQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var subscription = await subscriptionRepository.GetByTenantIdUnfilteredAsync(tenant.Id, cancellationToken); + var 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.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/Tenants/BackOffice/Queries/GetTenantUserCounts.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs new file mode 100644 index 0000000000..b910eae3dc --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUserCounts.cs @@ -0,0 +1,32 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantUserCountsQuery(TenantId Id) : IRequest>; + +[PublicAPI] +public sealed record TenantUserCountsResponse(int TotalUsers, int ActiveUsers, int PendingUsers); + +public sealed class GetTenantUserCountsHandler(ITenantRepository tenantRepository, IUserRepository userRepository, TimeProvider timeProvider) + : IRequestHandler> +{ + private const int ActiveWindowDays = 30; + + public async Task> Handle(GetTenantUserCountsQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var activeSince = timeProvider.GetUtcNow().AddDays(-ActiveWindowDays); + var (totalUsers, activeUsers, pendingUsers) = await userRepository.GetUserCountsForTenantUnfilteredAsync(tenant.Id, activeSince, cancellationToken); + return new TenantUserCountsResponse(totalUsers, activeUsers, pendingUsers); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs new file mode 100644 index 0000000000..ef638d553f --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenantUsers.cs @@ -0,0 +1,94 @@ +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Tenants.BackOffice.Queries; + +[PublicAPI] +public sealed record GetTenantUsersQuery( + string? Search = null, + UserRole[]? Roles = null, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + [JsonIgnore] // Removes from API contract + public TenantId Id { get; init; } = null!; + + public string? Search { get; } = Search?.Trim().ToLower(); + + public UserRole[] Roles { get; } = Roles ?? []; +} + +[PublicAPI] +public sealed record TenantUsersResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantUserSummary[] Users); + +[PublicAPI] +public sealed record TenantUserSummary( + UserId Id, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl +); + +public sealed class GetTenantUsersQueryValidator : AbstractValidator +{ + public GetTenantUsersQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantUsersHandler(ITenantRepository tenantRepository, IUserRepository userRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantUsersQuery query, CancellationToken cancellationToken) + { + var tenant = await tenantRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (tenant is null) + { + return Result.NotFound($"Tenant with id '{query.Id}' was not found."); + } + + var (users, totalCount, totalPages) = await userRepository.SearchTenantUsersUnfilteredAsync( + tenant.Id, + query.Search, + query.Roles, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var summaries = users.Select(u => new TenantUserSummary( + u.Id, + u.Email, + u.FirstName, + u.LastName, + u.Title, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt, + u.Avatar.Url + ) + ).ToArray(); + + return new TenantUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs new file mode 100644 index 0000000000..d154f4d983 --- /dev/null +++ b/application/account/Core/Features/Tenants/BackOffice/Queries/GetTenants.cs @@ -0,0 +1,217 @@ +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[]? Plans = null, + TenantStatusFilter[]? Statuses = null, + bool Unsynced = false, + bool DriftDetected = false, + SortableTenantProperties OrderBy = SortableTenantProperties.Name, + SortOrder SortOrder = SortOrder.Ascending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public SubscriptionPlan[] Plans { get; } = Plans ?? []; + + public TenantStatusFilter[] Statuses { get; } = Statuses ?? []; +} + +[PublicAPI] +public sealed record TenantsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, TenantSummary[] Tenants); + +[PublicAPI] +public sealed record TenantSummary( + TenantId Id, + string Name, + string? LogoUrl, + SubscriptionPlan Plan, + decimal? MonthlyRecurringRevenue, + decimal? ScheduledPriceAmount, + string? Currency, + DateTimeOffset? RenewalDate, + PlannedSubscriptionChange? PlannedChange, + bool HasEverSubscribed, + string? Country, + DateTimeOffset CreatedAt +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum PlannedSubscriptionChange +{ + Cancellation, + ScheduledPlanChange +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum TenantStatusFilter +{ + Active, + Downgrading, + Canceling, + Canceled, + Free +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableTenantProperties +{ + Name, + Plan, + MonthlyRecurringRevenue, + RenewalDate, + Status, + Country, + CreatedAt +} + +public sealed class GetTenantsQueryValidator : AbstractValidator +{ + public GetTenantsQueryValidator() + { + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be no longer than 100 characters."); + RuleFor(x => x.Plans.Length).LessThanOrEqualTo(10).WithMessage("Plans filter must contain no more than 10 values."); + RuleFor(x => x.Statuses.Length).LessThanOrEqualTo(10).WithMessage("Statuses filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetTenantsHandler(ITenantRepository tenantRepository, ISubscriptionRepository subscriptionRepository, IBillingEventRepository billingEventRepository) + : IRequestHandler> +{ + public async Task> Handle(GetTenantsQuery query, CancellationToken cancellationToken) + { + var tenants = await tenantRepository.SearchAllTenantsAsync(query.Search, query.Plans, cancellationToken); + + var tenantIds = tenants.Select(t => t.Id).ToArray(); + var subscriptions = tenantIds.Length == 0 + ? [] + : await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + // Tenant-issue filters from the back-office banners. DriftDetected is a per-subscription flag set + // by the writer when the replayer hits an Unclassified event; Unsynced means a paid subscription + // has no BillingEvent rows yet (the dashboard MRR trend silently under-counts these). + if (query.DriftDetected) + { + tenants = tenants.Where(t => subscriptionsByTenantId.GetValueOrDefault(t.Id)?.HasDriftDetected == true).ToArray(); + } + + if (query.Unsynced) + { + var subscriptionIdsWithEvents = subscriptions.Length == 0 + ? new HashSet() + : await billingEventRepository.GetSubscriptionIdsWithEventsUnfilteredAsync([.. subscriptions.Select(s => s.Id)], cancellationToken); + tenants = tenants.Where(t => + { + var subscription = subscriptionsByTenantId.GetValueOrDefault(t.Id); + return subscription is { CurrentPriceAmount: not null } && !subscriptionIdsWithEvents.Contains(subscription.Id); + } + ).ToArray(); + } + + var summaries = tenants.Select(tenant => MapTenantSummary(tenant, subscriptionsByTenantId.GetValueOrDefault(tenant.Id))).ToArray(); + + if (query.Statuses.Length > 0) + { + summaries = summaries.Where(s => query.Statuses.Contains(GetStatus(s))).ToArray(); + } + + var ordered = (query.OrderBy, query.SortOrder) switch + { + (SortableTenantProperties.Plan, SortOrder.Ascending) => summaries.OrderBy(s => s.Plan).ThenBy(s => s.Name), + (SortableTenantProperties.Plan, _) => summaries.OrderByDescending(s => s.Plan).ThenBy(s => s.Name), + (SortableTenantProperties.MonthlyRecurringRevenue, SortOrder.Ascending) => summaries.OrderBy(s => s.MonthlyRecurringRevenue ?? 0).ThenBy(s => s.Name), + (SortableTenantProperties.MonthlyRecurringRevenue, _) => summaries.OrderByDescending(s => s.MonthlyRecurringRevenue ?? 0).ThenBy(s => s.Name), + (SortableTenantProperties.RenewalDate, SortOrder.Ascending) => summaries.OrderBy(s => s.RenewalDate ?? DateTimeOffset.MaxValue).ThenBy(s => s.Name), + (SortableTenantProperties.RenewalDate, _) => summaries.OrderByDescending(s => s.RenewalDate ?? DateTimeOffset.MinValue).ThenBy(s => s.Name), + (SortableTenantProperties.Status, SortOrder.Ascending) => summaries.OrderBy(StatusSortKey).ThenBy(s => s.Name), + (SortableTenantProperties.Status, _) => summaries.OrderByDescending(StatusSortKey).ThenBy(s => s.Name), + (SortableTenantProperties.Country, SortOrder.Ascending) => summaries.OrderBy(s => s.Country ?? string.Empty).ThenBy(s => s.Name), + (SortableTenantProperties.Country, _) => summaries.OrderByDescending(s => s.Country ?? string.Empty).ThenBy(s => s.Name), + (SortableTenantProperties.CreatedAt, SortOrder.Ascending) => summaries.OrderBy(s => s.CreatedAt), + (SortableTenantProperties.CreatedAt, _) => summaries.OrderByDescending(s => s.CreatedAt), + (SortableTenantProperties.Name, SortOrder.Descending) => summaries.OrderByDescending(s => s.Name), + _ => summaries.OrderBy(s => s.Name) + }; + + var totalCount = summaries.Length; + var totalPages = totalCount == 0 ? 0 : (totalCount - 1) / query.PageSize + 1; + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var paged = ordered.Skip(query.PageOffset * query.PageSize).Take(query.PageSize).ToArray(); + + return new TenantsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, paged); + } + + private static int StatusSortKey(TenantSummary summary) + { + return GetStatus(summary) switch + { + TenantStatusFilter.Active => 0, + TenantStatusFilter.Downgrading => 1, + TenantStatusFilter.Canceling => 2, + TenantStatusFilter.Canceled => 3, + TenantStatusFilter.Free => 4, + _ => 5 + }; + } + + private static TenantStatusFilter GetStatus(TenantSummary summary) + { + return summary switch + { + { PlannedChange: PlannedSubscriptionChange.Cancellation } => TenantStatusFilter.Canceling, + { PlannedChange: PlannedSubscriptionChange.ScheduledPlanChange } => TenantStatusFilter.Downgrading, + { Plan: not SubscriptionPlan.Basis } => TenantStatusFilter.Active, + { HasEverSubscribed: true } => TenantStatusFilter.Canceled, + _ => TenantStatusFilter.Free + }; + } + + private static TenantSummary MapTenantSummary(Tenant tenant, Subscription? subscription) + { + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == true; + + return new 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/Tenants/Domain/TenantRepository.cs b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs index 402cd52e51..195e09400a 100644 --- a/application/account/Core/Features/Tenants/Domain/TenantRepository.cs +++ b/application/account/Core/Features/Tenants/Domain/TenantRepository.cs @@ -1,6 +1,8 @@ using Account.Database; +using Account.Features.Subscriptions.Domain; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; +using SharedKernel.EntityFramework; using SharedKernel.ExecutionContext; using SharedKernel.Persistence; @@ -19,6 +21,40 @@ public interface ITenantRepository : ICrudRepository, ISoftDel /// This method should only be used in webhook processing where tenant context is not established. /// Task GetByIdUnfilteredAsync(TenantId id, CancellationToken cancellationToken); + + Task SearchAllTenantsAsync(string? search, SubscriptionPlan[] plans, CancellationToken cancellationToken); + + /// + /// Looks up tenant names for a set of tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries that need to attach the tenant name + /// to a list of records (users, sessions, ...) where tenant context is not established. + /// + Task> GetNamesByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); + + /// + /// Loads tenants by id without applying tenant query filters. + /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where + /// tenant context is not established. + /// + Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken); + + /// + /// Returns every tenant created at or after without applying tenant query filters. + /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. + /// + Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns every tenant without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// + Task GetAllUnfilteredAsync(CancellationToken cancellationToken); + + /// + /// Returns the most recently created tenants without applying tenant query filters. + /// Used by the back-office dashboard "Recent signups" list. + /// + Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken); } internal sealed class TenantRepository(AccountDbContext accountDbContext, IExecutionContext executionContext) @@ -41,6 +77,86 @@ public async Task GetByIdsAsync(TenantId[] ids, CancellationToken canc /// public async Task GetByIdUnfilteredAsync(TenantId id, CancellationToken cancellationToken) { - return await DbSet.IgnoreQueryFilters().SingleOrDefaultAsync(t => t.Id == id, cancellationToken); + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).SingleOrDefaultAsync(t => t.Id == id, cancellationToken); + } + + public async Task SearchAllTenantsAsync(string? search, SubscriptionPlan[] plans, CancellationToken cancellationToken) + { + IQueryable tenants = DbSet; + + if (!string.IsNullOrWhiteSpace(search)) + { + // TenantId is a long, so an exact match on a parsable id is the only way to filter by id at the DB level. + // Partial id matches are not supported - operators search by tenant name for fuzzy matches. + var idMatch = long.TryParse(search, out var parsedId) ? new TenantId(parsedId) : null; + tenants = tenants.Where(t => t.Name.ToLower().Contains(search) || (idMatch != null && t.Id == idMatch)); + } + + if (plans.Length > 0) + { + tenants = tenants.Where(t => plans.AsEnumerable().Contains(t.Plan)); + } + + return await tenants.ToArrayAsync(cancellationToken); + } + + /// + /// Looks up tenant names for a set of tenant ids without applying tenant query filters. + /// This method is used by back-office cross-tenant queries that need to attach the tenant name + /// to a list of records (users, sessions, ...) where tenant context is not established. + /// + public async Task> GetNamesByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) + { + if (ids.Length == 0) return new Dictionary(); + + return await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(t => ids.AsEnumerable().Contains(t.Id)) + .ToDictionaryAsync(t => t.Id, t => t.Name, cancellationToken); + } + + /// + /// Loads tenants by id without applying tenant query filters. + /// Used by back-office cross-tenant queries that need full tenant data (logo, plan, ...) where + /// tenant context is not established. + /// + public async Task GetByIdsUnfilteredAsync(TenantId[] ids, CancellationToken cancellationToken) + { + if (ids.Length == 0) return []; + + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(t => ids.AsEnumerable().Contains(t.Id)).ToArrayAsync(cancellationToken); + } + + /// + /// Returns every tenant created at or after without applying tenant query filters. + /// Used by the back-office dashboard to compute new-tenant trend buckets across all tenants. + /// SQLite cannot translate DateTimeOffset comparisons in WHERE, so the time filter runs in memory; the + /// dashboard period is bounded (max 90 days) so the materialized set stays small. + /// + public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var tenants = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return tenants.Where(t => t.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every tenant without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to count tenants by state and plan across all tenants. + /// + public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + } + + /// + /// Returns the most recently created tenants without applying tenant query filters. + /// Used by the back-office dashboard "Recent signups" list. + /// SQLite cannot translate DateTimeOffset comparisons, so the order-by runs in memory; the limit keeps the + /// materialized set small. + /// + public async Task GetMostRecentSignupsUnfilteredAsync(int limit, CancellationToken cancellationToken) + { + var tenants = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return tenants.OrderByDescending(t => t.CreatedAt).Take(limit).ToArray(); } } diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs new file mode 100644 index 0000000000..5e5e079a54 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserDetail.cs @@ -0,0 +1,132 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserDetailQuery(UserId Id) : IRequest>; + +[PublicAPI] +public sealed record BackOfficeUserDetailResponse( + UserId Id, + TenantId TenantId, + string TenantName, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + string Locale, + DateTimeOffset CreatedAt, + DateTimeOffset? ModifiedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl, + BackOfficeUserTenantMembership[] TenantMemberships +); + +// A "tenant membership" is another user record sharing the same email in a different tenant. Each row in the back-office +// User detail Tenants section corresponds to a single user-record-per-tenant; we expose its UserId so the frontend can +// link the row to that other user's detail page when needed. We also surface the tenant logo, plan, currency, MRR and +// country to render a rich tenant card without requiring a per-membership tenant detail fetch from the SPA. +[PublicAPI] +public sealed record BackOfficeUserTenantMembership( + UserId UserId, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + SubscriptionPlan Plan, + PlannedSubscriptionChange? PlannedChange, + bool HasEverSubscribed, + decimal? MonthlyRecurringRevenue, + decimal? ScheduledPriceAmount, + string? Currency, + DateTimeOffset? RenewalDate, + string? Country, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt +); + +public sealed class GetBackOfficeUserDetailHandler( + IUserRepository userRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUserDetailQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The "Tenants" section on the User detail page lists every tenant this person belongs to. Each tenant has its + // own user record (same email, different TenantId), so we look them up unfiltered by email. The lookup always + // includes the queried user record itself, so its tenant is naturally part of the result. + var membershipUsers = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken); + var tenantIds = membershipUsers.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + var subscriptions = await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + var memberships = membershipUsers.Select(u => + { + var tenant = tenantsById.GetValueOrDefault(u.TenantId); + var subscription = subscriptionsByTenantId.GetValueOrDefault(u.TenantId); + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == true; + return new BackOfficeUserTenantMembership( + u.Id, + u.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Logo.Url, + tenant?.Plan ?? SubscriptionPlan.Basis, + plannedChange, + hasEverSubscribed, + subscription?.CurrentPriceAmount, + subscription?.ScheduledPriceAmount, + subscription?.CurrentPriceCurrency, + subscription?.CurrentPeriodEnd, + subscription?.BillingInfo?.Address?.Country, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt + ); + } + ).ToArray(); + + return new BackOfficeUserDetailResponse( + user.Id, + user.TenantId, + tenantsById.GetValueOrDefault(user.TenantId)?.Name ?? string.Empty, + user.Email, + user.FirstName, + user.LastName, + user.Title, + user.Role, + user.EmailConfirmed, + user.Locale, + user.CreatedAt, + user.ModifiedAt, + user.LastSeenAt, + user.Avatar.Url, + memberships + ); + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs new file mode 100644 index 0000000000..2f15ac1e32 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserLoginHistory.cs @@ -0,0 +1,131 @@ +using Account.Features.Authentication.Domain; +using Account.Features.EmailAuthentication.Domain; +using Account.Features.ExternalAuthentication.Domain; +using Account.Features.Users.Domain; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserLoginHistoryQuery : IRequest> +{ + [JsonIgnore] // Removes from API contract + public UserId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record BackOfficeUserLoginHistoryResponse(BackOfficeUserLoginEntry[] Entries); + +[PublicAPI] +public sealed record BackOfficeUserLoginEntry( + LoginEventKind Kind, + LoginMethod Method, + LoginEventOutcome Outcome, + DateTimeOffset OccurredAt, + string? FailureReason, + ExternalProviderType? ExternalProvider +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginEventKind +{ + Email, + External +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LoginEventOutcome +{ + Pending, + Succeeded, + Failed +} + +public sealed class GetBackOfficeUserLoginHistoryHandler( + IUserRepository userRepository, + IEmailLoginRepository emailLoginRepository, + IExternalLoginRepository externalLoginRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + private const int LookbackDays = 30; + + public async Task> Handle(GetBackOfficeUserLoginHistoryQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The PRD calls this section "real sign-in attempts" - so we union both authentication aggregates by email. + // The aggregates don't track IP or country today; back-office shows only what we have until those columns land. + var since = timeProvider.GetUtcNow().AddDays(-LookbackDays); + var emailLogins = await emailLoginRepository.GetByEmailSinceAsync(user.Email, since, cancellationToken); + var externalLogins = await externalLoginRepository.GetByEmailSinceAsync(user.Email, since, cancellationToken); + + var entries = new List(emailLogins.Length + externalLogins.Length); + + entries.AddRange(emailLogins.Select(e => new BackOfficeUserLoginEntry( + LoginEventKind.Email, + LoginMethod.OneTimePassword, + MapEmailOutcome(e), + e.CreatedAt, + MapEmailFailureReason(e), + null + ) + ) + ); + + entries.AddRange(externalLogins.Select(e => new BackOfficeUserLoginEntry( + LoginEventKind.External, + MapExternalMethod(e.ProviderType), + MapExternalOutcome(e.LoginResult), + e.CreatedAt, + e.LoginResult is null or ExternalLoginResult.Success ? null : e.LoginResult.ToString(), + e.ProviderType + ) + ) + ); + + var ordered = entries.OrderByDescending(e => e.OccurredAt).ToArray(); + return new BackOfficeUserLoginHistoryResponse(ordered); + } + + private static LoginEventOutcome MapEmailOutcome(EmailLogin login) + { + if (login.Completed) return LoginEventOutcome.Succeeded; + if (login.RetryCount >= EmailLogin.MaxAttempts) return LoginEventOutcome.Failed; + return LoginEventOutcome.Pending; + } + + private static string? MapEmailFailureReason(EmailLogin login) + { + if (login.Completed) return null; + if (login.RetryCount >= EmailLogin.MaxAttempts) return "TooManyRetries"; + return null; + } + + private static LoginEventOutcome MapExternalOutcome(ExternalLoginResult? result) + { + return result switch + { + null => LoginEventOutcome.Pending, + ExternalLoginResult.Success => LoginEventOutcome.Succeeded, + _ => LoginEventOutcome.Failed + }; + } + + private static LoginMethod MapExternalMethod(ExternalProviderType providerType) + { + return providerType switch + { + ExternalProviderType.Google => LoginMethod.Google, + _ => throw new UnreachableException($"Unknown external provider type '{providerType}'.") + }; + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs new file mode 100644 index 0000000000..a2876437a9 --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUserSessions.cs @@ -0,0 +1,104 @@ +using Account.Features.Authentication.Domain; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Authentication.TokenGeneration; +using SharedKernel.Cqrs; +using SharedKernel.Domain; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUserSessionsQuery(int PageOffset = 0, int PageSize = 25) : IRequest> +{ + [JsonIgnore] // Removes from API contract + public UserId Id { get; init; } = null!; +} + +[PublicAPI] +public sealed record BackOfficeUserSessionsResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BackOfficeUserSession[] Sessions); + +[PublicAPI] +public sealed record BackOfficeUserSession( + SessionId Id, + TenantId TenantId, + string TenantName, + string? TenantLogoUrl, + LoginMethod LoginMethod, + DeviceType DeviceType, + string UserAgent, + string IpAddress, + DateTimeOffset CreatedAt, + DateTimeOffset? LastActiveAt, + DateTimeOffset? RevokedAt, + SessionRevokedReason? RevokedReason, + DateTimeOffset ExpiresAt +); + +public sealed class GetBackOfficeUserSessionsQueryValidator : AbstractValidator +{ + public GetBackOfficeUserSessionsQueryValidator() + { + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeUserSessionsHandler(IUserRepository userRepository, ISessionRepository sessionRepository, ITenantRepository tenantRepository) + : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUserSessionsQuery query, CancellationToken cancellationToken) + { + var user = await userRepository.GetByIdUnfilteredAsync(query.Id, cancellationToken); + if (user is null) + { + return Result.NotFound($"User with id '{query.Id}' was not found."); + } + + // The Sessions list aggregates activity across every user record sharing this email (one record per tenant), + // so we look up all sibling user ids and ask for their sessions together. The lookup always includes the + // queried user record itself, so its sessions are naturally part of the result. + var membershipUsers = await userRepository.GetUsersByEmailUnfilteredAsync(user.Email, cancellationToken); + var userIds = membershipUsers.Select(u => u.Id).ToArray(); + + var (sessions, totalCount, totalPages) = await sessionRepository.GetSessionsForUsersUnfilteredAsync( + userIds, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var tenantIds = sessions.Select(s => s.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + + var summaries = sessions.Select(s => + { + var tenant = tenantsById.GetValueOrDefault(s.TenantId); + return new BackOfficeUserSession( + s.Id, + s.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Logo.Url, + s.LoginMethod, + s.DeviceType, + s.UserAgent, + s.IpAddress, + s.CreatedAt, + s.ModifiedAt, + s.RevokedAt, + s.RevokedReason, + s.ExpiresAt + ); + } + ).ToArray(); + + return new BackOfficeUserSessionsResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs new file mode 100644 index 0000000000..9e8f9376bf --- /dev/null +++ b/application/account/Core/Features/Users/BackOffice/Queries/GetBackOfficeUsers.cs @@ -0,0 +1,151 @@ +using Account.Features.Subscriptions.Domain; +using Account.Features.Tenants.BackOffice.Queries; +using Account.Features.Tenants.Domain; +using Account.Features.Users.Domain; +using FluentValidation; +using JetBrains.Annotations; +using SharedKernel.Cqrs; +using SharedKernel.Domain; +using SharedKernel.Persistence; + +namespace Account.Features.Users.BackOffice.Queries; + +[PublicAPI] +public sealed record GetBackOfficeUsersQuery( + string? Search = null, + UserRole[]? Roles = null, + UserActivityFilter? Activity = null, + SortableBackOfficeUserProperties OrderBy = SortableBackOfficeUserProperties.CreatedAt, + SortOrder SortOrder = SortOrder.Descending, + int PageOffset = 0, + int PageSize = 25 +) : IRequest> +{ + public string? Search { get; } = Search?.Trim().ToLower(); + + public UserRole[] Roles { get; } = Roles ?? []; +} + +[PublicAPI] +public sealed record BackOfficeUsersResponse(int TotalCount, int PageSize, int TotalPages, int CurrentPageOffset, BackOfficeUserSummary[] Users); + +[PublicAPI] +public sealed record BackOfficeUserSummary( + UserId Id, + TenantId TenantId, + string TenantName, + SubscriptionPlan TenantPlan, + PlannedSubscriptionChange? TenantPlannedChange, + bool TenantHasEverSubscribed, + string Email, + string? FirstName, + string? LastName, + string? Title, + UserRole Role, + bool EmailConfirmed, + DateTimeOffset CreatedAt, + DateTimeOffset? LastSeenAt, + string? AvatarUrl +); + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum UserActivityFilter +{ + ActiveLast24Hours, + ActiveLast7Days, + ActiveLast30Days, + InactiveOver30Days +} + +[PublicAPI] +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum SortableBackOfficeUserProperties +{ + Name, + Email, + Role, + LastSeenAt, + CreatedAt +} + +public sealed class GetBackOfficeUsersQueryValidator : AbstractValidator +{ + public GetBackOfficeUsersQueryValidator() + { + // Search is optional. When omitted or empty, the page lists every user newest-first. When provided, the cap of + // 100 characters guards against malicious input — the WebApp normally sends short tokens. + RuleFor(x => x.Search).MaximumLength(100).WithMessage("Search must be at most 100 characters."); + RuleFor(x => x.Roles.Length).LessThanOrEqualTo(10).WithMessage("Roles filter must contain no more than 10 values."); + RuleFor(x => x.PageSize).InclusiveBetween(1, 100).WithMessage("Page size must be between 1 and 100."); + RuleFor(x => x.PageOffset).GreaterThanOrEqualTo(0).WithMessage("Page offset must be greater than or equal to 0."); + } +} + +public sealed class GetBackOfficeUsersHandler( + IUserRepository userRepository, + ITenantRepository tenantRepository, + ISubscriptionRepository subscriptionRepository, + TimeProvider timeProvider +) : IRequestHandler> +{ + public async Task> Handle(GetBackOfficeUsersQuery query, CancellationToken cancellationToken) + { + var (users, totalCount, totalPages) = await userRepository.SearchAllUsersUnfilteredAsync( + query.Search ?? "", + query.Roles, + query.Activity, + timeProvider.GetUtcNow(), + query.OrderBy, + query.SortOrder, + query.PageOffset, + query.PageSize, + cancellationToken + ); + + var tenantIds = users.Select(u => u.TenantId).Distinct().ToArray(); + var tenants = await tenantRepository.GetByIdsUnfilteredAsync(tenantIds, cancellationToken); + var tenantsById = tenants.ToDictionary(t => t.Id); + var subscriptions = await subscriptionRepository.GetByTenantIdsUnfilteredAsync(tenantIds, cancellationToken); + var subscriptionsByTenantId = subscriptions.ToDictionary(s => s.TenantId); + + if (query.PageOffset > 0 && query.PageOffset >= totalPages) + { + return Result.BadRequest($"The page offset '{query.PageOffset}' is greater than the total number of pages."); + } + + var summaries = users.Select(u => + { + var tenant = tenantsById.GetValueOrDefault(u.TenantId); + var subscription = subscriptionsByTenantId.GetValueOrDefault(u.TenantId); + var plannedChange = subscription switch + { + { CancelAtPeriodEnd: true } => PlannedSubscriptionChange.Cancellation, + { ScheduledPlan: not null } => PlannedSubscriptionChange.ScheduledPlanChange, + _ => (PlannedSubscriptionChange?)null + }; + var hasEverSubscribed = subscription?.PaymentTransactions + .Any(transaction => transaction.Status == PaymentTransactionStatus.Succeeded) == true; + return new BackOfficeUserSummary( + u.Id, + u.TenantId, + tenant?.Name ?? string.Empty, + tenant?.Plan ?? SubscriptionPlan.Basis, + plannedChange, + hasEverSubscribed, + u.Email, + u.FirstName, + u.LastName, + u.Title, + u.Role, + u.EmailConfirmed, + u.CreatedAt, + u.LastSeenAt, + u.Avatar.Url + ); + } + ).ToArray(); + + return new BackOfficeUsersResponse(totalCount, query.PageSize, totalPages, query.PageOffset, summaries); + } +} diff --git a/application/account/Core/Features/Users/Domain/UserRepository.cs b/application/account/Core/Features/Users/Domain/UserRepository.cs index 0f8b1fd138..8502fc8316 100644 --- a/application/account/Core/Features/Users/Domain/UserRepository.cs +++ b/application/account/Core/Features/Users/Domain/UserRepository.cs @@ -1,4 +1,6 @@ using Account.Database; +using Account.Features.Tenants.Domain; +using Account.Features.Users.BackOffice.Queries; using JetBrains.Annotations; using Microsoft.EntityFrameworkCore; using SharedKernel.Domain; @@ -44,6 +46,64 @@ CancellationToken cancellationToken Task GetTenantUsers(CancellationToken cancellationToken); Task GetUsersByEmailUnfilteredAsync(string email, CancellationToken cancellationToken); + + /// + /// Returns total, 30-day active, and pending (unconfirmed email) user counts for the given tenant without applying + /// tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken); + + /// + /// Searches users belonging to a specific tenant without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + Task<(User[] Users, int TotalItems, int TotalPages)> SearchTenantUsersUnfilteredAsync( + TenantId tenantId, + string? search, + UserRole[] roles, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ); + + /// + /// Searches users across every tenant without applying tenant query filters. Search is required and matches + /// user email, full name, or tenant name. The activity filter compares to + /// a sliding window relative to . This method is used by the back-office cross-tenant + /// Users search page where tenant context is not established. + /// + Task<(User[] Users, int TotalItems, int TotalPages)> SearchAllUsersUnfilteredAsync( + string search, + UserRole[] roles, + UserActivityFilter? activity, + DateTimeOffset now, + SortableBackOfficeUserProperties orderBy, + SortOrder sortOrder, + int pageOffset, + int pageSize, + CancellationToken cancellationToken + ); + + /// + /// Returns every user created at or after across all tenants without applying tenant + /// query filters. Used by the back-office dashboard to compute new-user trend buckets across all tenants. + /// + Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken); + + /// + /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. + /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. + /// + Task> GetFirstOwnerByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken); + + /// + /// Returns every non-deleted user across all tenants without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute period-active users (last_seen_at within + /// the selected period) across all tenants. SQLite cannot translate DateTimeOffset comparisons in WHERE, + /// so the time filter runs in memory; the user count is bounded by the dashboard's audience. + /// + Task GetAllUnfilteredAsync(CancellationToken cancellationToken); } internal sealed class UserRepository(AccountDbContext accountDbContext, IExecutionContext executionContext, TimeProvider timeProvider) @@ -237,7 +297,7 @@ CancellationToken cancellationToken ? result.Length // If the first page returns fewer items than page size, skip querying the total count : await users.CountAsync(cancellationToken); - var totalPages = (totalItems - 1) / pageSize.Value + 1; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize.Value + 1; return (result, totalItems, totalPages); } @@ -260,6 +320,217 @@ public async Task GetUsersByEmailUnfilteredAsync(string email, Cancellat .ToArrayAsync(cancellationToken); } + /// + /// Returns total, 30-day active, and pending (unconfirmed email) user counts for the given tenant without applying + /// tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task<(int TotalUsers, int ActiveUsers, int PendingUsers)> GetUserCountsForTenantUnfilteredAsync(TenantId tenantId, DateTimeOffset activeSince, CancellationToken cancellationToken) + { + // SQLite EF cannot translate DateTimeOffset comparisons (text-stored); test path materializes the relevant columns and counts in memory, bounded by tenant size. + if (accountDbContext.Database.ProviderName is "Microsoft.EntityFrameworkCore.Sqlite") + { + var users = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.TenantId == tenantId) + .Select(u => new { u.LastSeenAt, u.EmailConfirmed }) + .ToListAsync(cancellationToken); + return (users.Count, users.Count(u => u.EmailConfirmed && u.LastSeenAt.HasValue && u.LastSeenAt.Value >= activeSince), users.Count(u => !u.EmailConfirmed)); + } + + var counts = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.TenantId == tenantId) + .GroupBy(_ => 1) + .Select(g => new { Total = g.Count(), Active = g.Count(u => u.EmailConfirmed && u.LastSeenAt >= activeSince), Pending = g.Count(u => !u.EmailConfirmed) }) + .SingleOrDefaultAsync(cancellationToken); + + return (counts?.Total ?? 0, counts?.Active ?? 0, counts?.Pending ?? 0); + } + + /// + /// Searches users belonging to a specific tenant without applying tenant query filters. + /// This method is used by back-office cross-tenant queries where tenant context is not established. + /// + public async Task<(User[] Users, int TotalItems, int TotalPages)> SearchTenantUsersUnfilteredAsync( + TenantId tenantId, + string? search, + UserRole[] roles, + int? pageOffset, + int pageSize, + CancellationToken cancellationToken + ) + { + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).Where(u => u.TenantId == tenantId); + + if (roles.Length > 0) + { + users = users.Where(u => roles.AsEnumerable().Contains(u.Role)); + } + + if (!string.IsNullOrWhiteSpace(search)) + { + users = users.Where(u => + u.Email.Contains(search) || + (u.FirstName + " " + u.LastName).Contains(search) || + (u.Title ?? "").Contains(search) + ); + } + + users = users + .OrderBy(u => u.FirstName == null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName == null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email); + + var itemOffset = (pageOffset ?? 0) * pageSize; + var result = await users.Skip(itemOffset).Take(pageSize).ToArrayAsync(cancellationToken); + + var totalItems = pageOffset == 0 && result.Length < pageSize + ? result.Length + : await users.CountAsync(cancellationToken); + + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + return (result, totalItems, totalPages); + } + + /// + /// Searches users across every tenant without applying tenant query filters. When + /// is empty, every user is returned (subject to role/activity filters and pagination). When non-empty, + /// matches user email, full name, or tenant name. The activity filter compares + /// to a sliding window relative to . This method is used by the back-office + /// cross-tenant Users page where tenant context is not established. + /// Search and role filters run in the database. Activity filter, sort, and pagination run in memory because + /// SQLite cannot translate DateTimeOffset comparisons in WHERE or ORDER BY clauses (the test database is + /// SQLite). + /// + public async Task<(User[] Users, int TotalItems, int TotalPages)> SearchAllUsersUnfilteredAsync( + string search, + UserRole[] roles, + UserActivityFilter? activity, + DateTimeOffset now, + SortableBackOfficeUserProperties orderBy, + SortOrder sortOrder, + int pageOffset, + int pageSize, + CancellationToken cancellationToken + ) + { + var users = DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]); + + if (!string.IsNullOrEmpty(search)) + { + // Tenant name search is implemented as a separate lookup so we don't need an EF join. We then OR the + // resulting ids into the user predicate alongside email and full-name matches. + var matchingTenantIds = await accountDbContext.Set() + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(t => t.Name.ToLower().Contains(search)) + .Select(t => t.Id) + .ToArrayAsync(cancellationToken); + + users = users.Where(u => + u.Email.Contains(search) || + ((u.FirstName ?? "") + " " + (u.LastName ?? "")).ToLower().Contains(search) || + matchingTenantIds.AsEnumerable().Contains(u.TenantId) + ); + } + + if (roles.Length > 0) + { + users = users.Where(u => roles.AsEnumerable().Contains(u.Role)); + } + + var candidates = await users.ToArrayAsync(cancellationToken); + + if (activity is not null) + { + var oneDayAgo = now.AddDays(-1); + var sevenDaysAgo = now.AddDays(-7); + var thirtyDaysAgo = now.AddDays(-30); + candidates = activity switch + { + UserActivityFilter.ActiveLast24Hours => candidates.Where(u => u.LastSeenAt >= oneDayAgo).ToArray(), + UserActivityFilter.ActiveLast7Days => candidates.Where(u => u.LastSeenAt >= sevenDaysAgo).ToArray(), + UserActivityFilter.ActiveLast30Days => candidates.Where(u => u.LastSeenAt >= thirtyDaysAgo).ToArray(), + UserActivityFilter.InactiveOver30Days => candidates.Where(u => u.LastSeenAt is null || u.LastSeenAt < thirtyDaysAgo).ToArray(), + _ => candidates + }; + } + + IEnumerable ordered = (orderBy, sortOrder) switch + { + (SortableBackOfficeUserProperties.Email, SortOrder.Ascending) => candidates.OrderBy(u => u.Email), + (SortableBackOfficeUserProperties.Email, _) => candidates.OrderByDescending(u => u.Email), + (SortableBackOfficeUserProperties.Role, SortOrder.Ascending) => candidates.OrderBy(u => u.Role).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.Role, _) => candidates.OrderByDescending(u => u.Role).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.LastSeenAt, SortOrder.Ascending) => candidates.OrderBy(u => u.LastSeenAt ?? DateTimeOffset.MinValue).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.LastSeenAt, _) => candidates.OrderByDescending(u => u.LastSeenAt ?? DateTimeOffset.MinValue).ThenBy(u => u.Email), + (SortableBackOfficeUserProperties.CreatedAt, SortOrder.Ascending) => candidates.OrderBy(u => u.CreatedAt), + (SortableBackOfficeUserProperties.CreatedAt, _) => candidates.OrderByDescending(u => u.CreatedAt), + (_, SortOrder.Descending) => candidates + .OrderBy(u => u.FirstName is null ? 0 : 1) + .ThenByDescending(u => u.FirstName) + .ThenBy(u => u.LastName is null ? 0 : 1) + .ThenByDescending(u => u.LastName) + .ThenBy(u => u.Email), + _ => candidates + .OrderBy(u => u.FirstName is null ? 1 : 0) + .ThenBy(u => u.FirstName) + .ThenBy(u => u.LastName is null ? 1 : 0) + .ThenBy(u => u.LastName) + .ThenBy(u => u.Email) + }; + + var totalItems = candidates.Length; + var totalPages = totalItems == 0 ? 0 : (totalItems - 1) / pageSize + 1; + var pageUsers = ordered.Skip(pageOffset * pageSize).Take(pageSize).ToArray(); + return (pageUsers, totalItems, totalPages); + } + + /// + /// Returns every user created at or after across all tenants without applying tenant + /// query filters. Used by the back-office dashboard to compute new-user trend buckets across all tenants. + /// SQLite cannot translate DateTimeOffset comparisons in WHERE, so the time filter runs in memory; the + /// dashboard period is bounded (max 90 days) so the materialized set stays small. + /// + public async Task GetCreatedSinceUnfilteredAsync(DateTimeOffset since, CancellationToken cancellationToken) + { + var users = await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + return users.Where(u => u.CreatedAt >= since).ToArray(); + } + + /// + /// Returns every non-deleted user across all tenants without applying tenant query filters. + /// Used by the back-office dashboard KPI snapshot to compute period-active users (last_seen_at within + /// the selected period) across all tenants. SQLite cannot translate DateTimeOffset comparisons in WHERE, + /// so the time filter runs in memory; the user count is bounded by the dashboard's audience. + /// + public async Task GetAllUnfilteredAsync(CancellationToken cancellationToken) + { + return await DbSet.IgnoreQueryFilters([QueryFilterNames.Tenant]).ToArrayAsync(cancellationToken); + } + + /// + /// Returns the earliest-created Owner for each of the given tenants without applying tenant query filters. + /// Used by the back-office recent signups dashboard to attribute each new tenant to the user who signed up. + /// + public async Task> GetFirstOwnerByTenantIdsUnfilteredAsync(TenantId[] tenantIds, CancellationToken cancellationToken) + { + if (tenantIds.Length == 0) return new Dictionary(); + + // SQLite cannot translate DateTimeOffset ORDER BY clauses, so materialize the candidate Owners and pick + // the earliest in memory. Bounded by the number of tenants on the dashboard recent-signups list. + var owners = await DbSet + .IgnoreQueryFilters([QueryFilterNames.Tenant]) + .Where(u => u.Role == UserRole.Owner && tenantIds.AsEnumerable().Contains(u.TenantId)) + .ToArrayAsync(cancellationToken); + + return owners + .GroupBy(u => u.TenantId) + .ToDictionary(g => g.Key, g => g.OrderBy(u => u.CreatedAt).ThenBy(u => u.Id.Value).First()); + } + [UsedImplicitly] private sealed record UserSummaryResult(int TotalUsers, int ActiveUsers, int PendingUsers); } diff --git a/application/account/Core/Integrations/Stripe/IStripeClient.cs b/application/account/Core/Integrations/Stripe/IStripeClient.cs index c3f6ce0b74..2cfb9baae7 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); @@ -51,12 +53,24 @@ public interface IStripeClient Task CreateSubscriptionWithSavedPaymentMethodAsync(StripeCustomerId stripeCustomerId, SubscriptionPlan plan, CancellationToken cancellationToken); Task SyncPaymentTransactionsAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); + + /// + /// Returns Stripe events related to a customer (last 30 days — see + /// https://docs.stripe.com/api/events) via the events.list API. Used as a reconciliation source + /// to detect webhook deliveries we missed: any event id Stripe knows about that's not in our + /// local stripe_events archive gets inserted as a recovered row. The local archive is + /// the durable source of truth for replay; events.list is only the recovery channel. + /// + Task GetEventsForCustomerAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken); } +public sealed record StripeReplayEvent(string EventId, string EventType, DateTimeOffset CreatedAt, string Payload, string? ApiVersion); + public sealed record StripeWebhookEventResult( string EventId, string EventType, - StripeCustomerId? CustomerId + StripeCustomerId? CustomerId, + string? ApiVersion ); public sealed record CheckoutSessionResult(string SessionId, string ClientSecret); diff --git a/application/account/Core/Integrations/Stripe/MockStripeClient.cs b/application/account/Core/Integrations/Stripe/MockStripeClient.cs index ea66d28705..018db73a44 100644 --- a/application/account/Core/Integrations/Stripe/MockStripeClient.cs +++ b/application/account/Core/Integrations/Stripe/MockStripeClient.cs @@ -23,6 +23,12 @@ 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"; + + public const string MockApiVersion = "2025-09-30.preview"; + private readonly bool _isEnabled = configuration.GetValue("Stripe:AllowMockProvider"); public Task CreateCustomerAsync(string tenantName, string email, long tenantId, CancellationToken cancellationToken) @@ -52,12 +58,15 @@ public sealed class MockStripeClient(IConfiguration configuration, TimeProvider new PaymentTransaction( PaymentTransactionId.NewId(), 29.99m, + 23.99m, + 6.00m, "USD", PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, - null + null, + SubscriptionPlan.Standard ) }; @@ -125,6 +134,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(); @@ -147,7 +167,7 @@ public Task GetPriceCatalogAsync(CancellationToken cancellat var customerIdString = payload.StartsWith("customer:") ? payload.Split(':')[1] : payload == "no_customer" ? null : MockCustomerId; StripeCustomerId.TryParse(customerIdString, out var customerId); - return new StripeWebhookEventResult(eventId, eventType, customerId); + return new StripeWebhookEventResult(eventId, eventType, customerId, MockApiVersion); } public Task GetCustomerBillingInfoAsync(StripeCustomerId stripeCustomerId, CancellationToken cancellationToken) @@ -258,11 +278,48 @@ 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) + new PaymentTransaction(PaymentTransactionId.NewId(), 29.99m, 23.99m, 6.00m, "USD", PaymentTransactionStatus.Succeeded, now, null, MockInvoiceUrl, null, SubscriptionPlan.Standard) ] ); } + 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"}}]}}}}""", + MockApiVersion + ) + }; + + if (state.OverrideSubscriptionStatus == StripeSubscriptionStatus.PastDue) + { + events.Add(new StripeReplayEvent( + MockPaymentFailedEventId, + "invoice.payment_failed", + now.AddMinutes(-1), + """{"data":{"object":{"attempt_count":1,"billing_reason":"subscription_cycle"}}}""", + MockApiVersion + ) + ); + } + + if (state.SimulateCustomerDeleted) + { + events.Add(new StripeReplayEvent(MockCustomerDeletedEventId, "customer.deleted", now, "{}", MockApiVersion)); + } + + 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 52bcc550da..385e21481d 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"]; @@ -445,7 +463,7 @@ public async Task GetPriceCatalogAsync(CancellationToken can var stripeEvent = EventUtility.ConstructEvent(payload, signatureHeader, _webhookSecret); var customerId = ExtractCustomerId(payload); - return new StripeWebhookEventResult(stripeEvent.Id, stripeEvent.Type, customerId); + return new StripeWebhookEventResult(stripeEvent.Id, stripeEvent.Type, customerId, stripeEvent.ApiVersion); } catch (StripeException ex) { @@ -972,7 +990,7 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st { var invoiceService = new InvoiceService(); var invoices = await invoiceService.ListAsync( - new InvoiceListOptions { Customer = stripeCustomerId.Value, Limit = 100, Expand = ["data.payments.data.payment"] }, + new InvoiceListOptions { Customer = stripeCustomerId.Value, Limit = 100, Expand = ["data.payments.data.payment", "data.lines.data.pricing"] }, GetRequestOptions(), cancellationToken ); @@ -985,6 +1003,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 }, @@ -992,21 +1017,32 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st ); var creditNotesByInvoiceId = creditNotes.Data.GroupBy(cn => cn.InvoiceId).ToDictionary(g => g.Key, g => g.First().Pdf); + // Build a priceId → SubscriptionPlan lookup once (per Stripe customer sync) so the per-invoice loop is allocation-free. + var planByPriceId = await BuildPlanByPriceIdAsync(cancellationToken); + return invoices.Data.Select(invoice => { var paymentIntentId = invoice.Payments?.Data?.FirstOrDefault()?.Payment?.PaymentIntentId; var chargeAmountRefunded = paymentIntentId is not null && refundedAmountByPaymentIntentId.TryGetValue(paymentIntentId, out var refunded) ? refunded : 0L; + var 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) + creditNotesByInvoiceId.GetValueOrDefault(invoice.Id), + plan, + refundedAt ); } ).ToArray(); @@ -1023,6 +1059,86 @@ public async Task SetCustomerDefaultPaymentMethodAsync(StripeCustomerId st } } + /// + /// Reverse of : resolves a Stripe priceId (the only field reliably present on + /// historical invoice line items) back to its via the cached price catalog. + /// Unknown / archived priceIds resolve to null rather than throwing — historical data should not crash + /// the sync just because a price was retired. + /// + public async Task> GetPlanByPriceIdAsync(CancellationToken cancellationToken) + { + return await BuildPlanByPriceIdAsync(cancellationToken); + } + + /// + /// Lists Stripe events for a customer via Stripe's events.list API. Used as a reconciliation source + /// to detect webhooks we missed: any event id Stripe returns that we don't have in stripe_events + /// locally is a delivery we lost. Stripe retains events for only 30 days + /// (see https://docs.stripe.com/api/events) — anything older is unreachable through this API and + /// must be backed by our local stripe_events archive instead. + /// + 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(), stripeEvent.ApiVersion)); + } + + 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 + /// 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); + + if (!memoryCache.TryGetValue(PriceCacheKey, out Dictionary? cached) || cached is null) + { + return new Dictionary(); + } + + var lookup = new Dictionary(cached.Count); + foreach (var (lookupKey, price) in cached) + { + lookup[price.Id] = ParseLookupKey(lookupKey); + } + + return lookup; + } + private async Task GetPriceIdAsync(SubscriptionPlan plan, CancellationToken cancellationToken) { var lookupKey = plan switch @@ -1217,4 +1333,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 f93601fbd4..7620962020 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"); @@ -147,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/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/BackOfficeEndpointBaseTest.cs b/application/account/Tests/BackOffice/BackOfficeEndpointBaseTest.cs index c322747779..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; @@ -46,6 +48,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 +80,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()); @@ -90,12 +94,17 @@ protected BackOfficeEndpointBaseTest() using var scope = _webApplicationFactory.Services.CreateScope(); scope.ServiceProvider.GetRequiredService().Database.EnsureCreated(); + DatabaseSeeder = ActivatorUtilities.CreateInstance(scope.ServiceProvider); Environment.SetEnvironmentVariable("BypassAntiforgeryValidation", "true"); } protected SqliteConnection Connection { get; } + protected DatabaseSeeder DatabaseSeeder { get; } + + protected TelemetryEventsCollectorSpy TelemetryEventsCollectorSpy { get; } + public void Dispose() { Dispose(true); @@ -107,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