using System.Net; using System.Text.Json; using AutopilotMonitor.Functions.Helpers; using AutopilotMonitor.Functions.Security; using AutopilotMonitor.Functions.Services; using AutopilotMonitor.Shared.DataAccess; using AutopilotMonitor.Shared.Models; using AutopilotMonitor.Shared.Models.Config; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Http; using Microsoft.Extensions.Logging; namespace AutopilotMonitor.Functions.Functions.Config { /// /// Plan/edition management: GA-only plan+trial mutation (PATCH plan), tenant-admin self-service /// trial (POST trial), and the global usage-plan definitions (SectionUsagePlans). All writes go /// through so the 5-minute config cache is invalidated, /// and every mutation is audited under the target tenant's trail. /// public class PlanManagementFunction { /// Self-service trial length. GA can grant arbitrary end dates via PATCH. internal const int SelfServiceTrialDays = 30; private readonly ILogger _logger; private readonly TenantConfigurationService _configService; private readonly AdminConfigurationService _adminConfigService; private readonly IMaintenanceRepository _maintenanceRepo; private readonly OpsEventService _opsEvents; private readonly ProConferralService _proConferral; private readonly TimeProvider _time; public PlanManagementFunction( ILogger logger, TenantConfigurationService configService, AdminConfigurationService adminConfigService, IMaintenanceRepository maintenanceRepo, OpsEventService opsEvents, ProConferralService proConferral) : this(logger, configService, adminConfigService, maintenanceRepo, opsEvents, proConferral, TimeProvider.System) { } /// Test seam — inject a fake for deterministic trial math. public PlanManagementFunction( ILogger logger, TenantConfigurationService configService, AdminConfigurationService adminConfigService, IMaintenanceRepository maintenanceRepo, OpsEventService opsEvents, ProConferralService proConferral, TimeProvider time) { _logger = logger; _configService = configService; _adminConfigService = adminConfigService; _maintenanceRepo = maintenanceRepo; _opsEvents = opsEvents; _proConferral = proConferral; _time = time; } /// /// PATCH /api/config/{tenantId}/plan — GlobalAdminOnly (catalog-enforced). /// Body: { "planTier"?: "community"|"pro", "trialExpiresUtc"?: ISO-8601 | null, "maxDelegatedTenants"?: int | null, /// "mcpUsagePlan"?: string | null, "payingCustomer"?: bool }. /// payingCustomer is sales bookkeeping only (no entitlement effect). A permanent-Pro tenant that drops to /// Community here takes conferred Pro away from every tenant it manages — their retention grace anchor /// is stamped in the same request (ProConferralService). /// maxDelegatedTenants sets the delegated (MSP) slot override (null clears it — the plan entitlement applies). /// mcpUsagePlan sets the tenant-wide MCP usage-plan override — the name of a SectionUsagePlans plan, validated /// against the current definitions (400 for an unknown name, so a typo can never silently drop the tenant /// to the Community fallback); null clears it (the edition default applies). It does NOT change the edition. /// The legacy stored value "enterprise" stays readable (resolves as Pro) but is no longer /// accepted on writes. /// Setting a trial date grants/extends the trial (TrialConsumed is NOT touched — GA /// re-grants stay possible); explicit null ends the trial. Absent properties are unchanged. /// [Function("SetTenantPlanTier")] public async Task SetPlanTier( [HttpTrigger(AuthorizationLevel.Anonymous, "patch", Route = "config/{tenantId}/plan")] HttpRequestData req, string tenantId) { try { var requestCtx = req.GetRequestContext(); var caller = requestCtx.UserPrincipalName ?? "Unknown"; _logger.LogInformation("SetTenantPlanTier: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller); var body = await req.ReadAsStringAsync() ?? string.Empty; JsonDocument doc; try { doc = JsonDocument.Parse(string.IsNullOrWhiteSpace(body) ? "{}" : body); } catch (JsonException) { return await BadRequestAsync(req, "Invalid JSON body"); } string? newPlanTier = null; bool trialProvided = false; DateTime? newTrialExpiresUtc = null; bool slotsProvided = false; int? newMaxDelegatedTenants = null; bool mcpPlanProvided = false; string? newMcpUsagePlan = null; bool payingProvided = false; bool newPayingCustomer = false; using (doc) { if (doc.RootElement.ValueKind != JsonValueKind.Object) return await BadRequestAsync(req, "Body must be a JSON object"); if (doc.RootElement.TryGetProperty("planTier", out var tierProp)) { if (tierProp.ValueKind != JsonValueKind.String) return await BadRequestAsync(req, "planTier must be a string"); newPlanTier = tierProp.GetString()!.Trim().ToLowerInvariant(); if (newPlanTier != FeatureEntitlementCatalog.CommunityTierName && newPlanTier != FeatureEntitlementCatalog.ProTierName) { return await BadRequestAsync(req, $"Invalid planTier. Valid values: {FeatureEntitlementCatalog.CommunityTierName}, {FeatureEntitlementCatalog.ProTierName}"); } } if (doc.RootElement.TryGetProperty("trialExpiresUtc", out var trialProp)) { trialProvided = true; if (trialProp.ValueKind == JsonValueKind.Null) { newTrialExpiresUtc = null; // explicit null = end trial } else if (trialProp.ValueKind == JsonValueKind.String && trialProp.TryGetDateTime(out var parsed)) { newTrialExpiresUtc = parsed.Kind == DateTimeKind.Utc ? parsed : parsed.ToUniversalTime(); } else { return await BadRequestAsync(req, "trialExpiresUtc must be an ISO-8601 date-time string or null"); } } if (doc.RootElement.TryGetProperty("maxDelegatedTenants", out var slotsProp)) { slotsProvided = true; if (slotsProp.ValueKind == JsonValueKind.Null) { newMaxDelegatedTenants = null; // explicit null = clear the override } else if (slotsProp.ValueKind == JsonValueKind.Number && slotsProp.TryGetInt32(out var slots) && slots >= 0) { newMaxDelegatedTenants = slots; } else { return await BadRequestAsync(req, "maxDelegatedTenants must be a non-negative integer or null"); } } if (doc.RootElement.TryGetProperty("mcpUsagePlan", out var mcpPlanProp)) { mcpPlanProvided = true; if (mcpPlanProp.ValueKind == JsonValueKind.Null) { newMcpUsagePlan = null; // explicit null = clear the override } else if (mcpPlanProp.ValueKind == JsonValueKind.String) { // Blank = clear as well (the select's "edition default" option); a name is validated below. newMcpUsagePlan = TenantEntitlementService.NormalizePlanName(mcpPlanProp.GetString()); } else { return await BadRequestAsync(req, "mcpUsagePlan must be a plan name or null"); } } if (doc.RootElement.TryGetProperty("payingCustomer", out var payingProp)) { if (payingProp.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return await BadRequestAsync(req, "payingCustomer must be a boolean"); payingProvided = true; newPayingCustomer = payingProp.GetBoolean(); } } if (newPlanTier == null && !trialProvided && !slotsProvided && !mcpPlanProvided && !payingProvided) return await BadRequestAsync(req, "Provide planTier, trialExpiresUtc, maxDelegatedTenants, mcpUsagePlan and/or payingCustomer"); if (newMcpUsagePlan != null) { // Fail-loud on SET: an override naming a plan that exists nowhere would resolve fail-closed to // the Community fallback at read time — silently shrinking the tenant instead of raising it. var adminConfig = await _adminConfigService.GetConfigurationAsync(); var definitions = PlanTierDefinitionParser.Parse(adminConfig.PlanTierDefinitionsJson); if (!IsKnownUsagePlan(newMcpUsagePlan, definitions)) { var known = string.Join(", ", KnownUsagePlanNames(definitions)); return await BadRequestAsync(req, $"Unknown mcpUsagePlan '{newMcpUsagePlan}'. Known plans: {known}"); } } var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId); if (config == null) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); await notFound.WriteAsJsonAsync(new { error = "Tenant not found" }); return notFound; } var nowUtc = _time.GetUtcNow().UtcDateTime; var changes = new Dictionary(); // Conferred Pro follows the PERMANENT tier of the managing tenant (a trial confers nothing): // remember the standing before the mutation so a permanent-Pro → non-permanent drop can // stamp the managed tenants' grace anchors below. var wasPermanentPro = FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier); var (editionBefore, editionAfter) = ApplyPlanChanges(config, newPlanTier, trialProvided, newTrialExpiresUtc, caller, nowUtc, changes); ApplyDelegatedSlotChange(config, slotsProvided, newMaxDelegatedTenants, changes); ApplyMcpUsagePlanChange(config, mcpPlanProvided, newMcpUsagePlan, changes); ApplyPayingCustomerChange(config, payingProvided, newPayingCustomer, changes); if (changes.Count > 0) { config.UpdatedBy = caller; // Fail-loud: SaveConfigurationAsync throws when the write did not persist (and // invalidates the cached — possibly mutated — instance in its finally), so a // failed save can never be audited or returned as 200. await _configService.SaveConfigurationAsync(config, "plan", "plan tier change"); await _maintenanceRepo.LogAuditEntryAsync( requestCtx.TargetTenantId, "UPDATE", "TenantPlan", requestCtx.TargetTenantId, caller, changes); if (editionBefore == TenantEdition.Pro && editionAfter == TenantEdition.Community) { await _opsEvents.RecordTenantPlanDowngradedAsync( requestCtx.TargetTenantId, config.DomainName, caller, TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), config.DataRetentionDays); } if (wasPermanentPro && !FeatureEntitlementCatalog.IsPermanentProTier(config.PlanTier)) { // After the save, so the managed tenants' index rebuild already sees the new tier. var stamped = await _proConferral.RecordLossForOwnedGroupAsync( requestCtx.TargetTenantId, "manager-lost-permanent-pro"); if (stamped > 0) changes["ConferredProEndedForManagedTenants"] = stamped.ToString(); } // The mirror image of the downgrade event: a GA granting or extending a trial // is the same conversion moment as the self-service start, and sales/support // channels bind to one event type, not two. Keyed off the recorded change so // an unrelated planTier-only edit stays silent; ending a trial (explicit null) // is a downgrade, already covered above. if (changes.ContainsKey("TrialExpiresUtc") && config.TrialExpiresUtc.HasValue) { await _opsEvents.RecordTenantTrialStartedAsync( requestCtx.TargetTenantId, config.DomainName, config.ContactEmail, config.CompanyName, config.TrialStartedUtc, config.TrialExpiresUtc, caller, selfService: false); } } var resolution = FeatureEntitlementCatalog.Resolve(config, nowUtc); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new SetTenantPlanTierResponse { TenantId = requestCtx.TargetTenantId, PlanTier = config.PlanTier, TrialExpiresUtc = config.TrialExpiresUtc, TrialConsumed = config.TrialConsumed, EffectiveEdition = resolution.EditionName, EditionSource = resolution.SourceName, RetentionGraceEndsUtc = TenantEntitlementService.GetRetentionGraceEndUtc(config, nowUtc), MaxDelegatedTenants = TenantEntitlementService.GetMaxDelegatedTenants(config, nowUtc), MaxDelegatedTenantsOverride = config.MaxDelegatedTenantsOverride, McpUsagePlan = TenantEntitlementService.GetMcpUsagePlanName(config, nowUtc), McpUsagePlanOverride = config.McpUsagePlanOverride, PayingCustomer = config.PayingCustomer, }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error setting plan tier"); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" }); return errorResponse; } } /// /// Pure mutation core of the PATCH plan endpoint — no I/O, unit-testable. Applies the /// plan/trial changes to , records human-readable entries in /// , and returns the EFFECTIVE edition before/after (same /// for both, so only the mutation itself can flip it). /// /// Maintains the retention downgrade grace anchor: an effective Pro→Community /// transition stamps (covers both a /// planTier downgrade and an explicitly ended trial); any state that is effectively Pro /// afterwards clears the anchor, so a re-upgrade also resets the grace clock. /// internal static (TenantEdition Before, TenantEdition After) ApplyPlanChanges( TenantConfiguration config, string? newPlanTier, bool trialProvided, DateTime? newTrialExpiresUtc, string caller, DateTime nowUtc, Dictionary changes) { var before = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc); if (newPlanTier != null && !string.Equals(config.PlanTier, newPlanTier, StringComparison.Ordinal)) { changes["PlanTier"] = $"{config.PlanTier} -> {newPlanTier}"; config.PlanTier = newPlanTier; } if (trialProvided && config.TrialExpiresUtc != newTrialExpiresUtc) { changes["TrialExpiresUtc"] = $"{FormatUtc(config.TrialExpiresUtc)} -> {FormatUtc(newTrialExpiresUtc)}"; config.TrialExpiresUtc = newTrialExpiresUtc; if (newTrialExpiresUtc.HasValue) { config.TrialStartedUtc ??= nowUtc; config.TrialGrantedBy = caller; } } var after = FeatureEntitlementCatalog.ResolveEdition(config, nowUtc); if (before == TenantEdition.Pro && after == TenantEdition.Community) { changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> {FormatUtc(nowUtc)}"; config.ProDowngradedUtc = nowUtc; } else if (after == TenantEdition.Pro && config.ProDowngradedUtc.HasValue) { changes["ProDowngradedUtc"] = $"{FormatUtc(config.ProDowngradedUtc)} -> (none)"; config.ProDowngradedUtc = null; } return (before, after); } /// /// Pure mutation core for the delegated (MSP) slot override: records and applies the change only /// when a value was provided AND differs. The audit entry names "(catalog)" for the absent override. /// internal static void ApplyDelegatedSlotChange( TenantConfiguration config, bool provided, int? maxDelegatedTenants, Dictionary changes) { if (!provided || config.MaxDelegatedTenantsOverride == maxDelegatedTenants) return; changes["MaxDelegatedTenantsOverride"] = $"{config.MaxDelegatedTenantsOverride?.ToString() ?? "(catalog)"} -> {maxDelegatedTenants?.ToString() ?? "(catalog)"}"; config.MaxDelegatedTenantsOverride = maxDelegatedTenants; } /// /// Pure mutation core for the tenant-wide MCP usage-plan override: records and applies the change only /// when a value was provided AND differs (case-insensitive — the stored value is normalized on write). /// The audit entry names "(edition default)" for the absent override. Validation of the plan name against /// the definitions happens in the endpoint (I/O), not here. /// internal static void ApplyMcpUsagePlanChange( TenantConfiguration config, bool provided, string? mcpUsagePlan, Dictionary changes) { var normalized = TenantEntitlementService.NormalizePlanName(mcpUsagePlan); if (!provided || string.Equals(config.McpUsagePlanOverride, normalized, StringComparison.OrdinalIgnoreCase)) return; changes["McpUsagePlanOverride"] = $"{config.McpUsagePlanOverride ?? "(edition default)"} -> {normalized ?? "(edition default)"}"; config.McpUsagePlanOverride = normalized; } /// /// Pure mutation core for the sales bookkeeping flag: records and applies the change only when a value /// was provided AND differs. No entitlement effect — see . /// internal static void ApplyPayingCustomerChange( TenantConfiguration config, bool provided, bool payingCustomer, Dictionary changes) { if (!provided || config.PayingCustomer == payingCustomer) return; changes["PayingCustomer"] = $"{config.PayingCustomer} -> {payingCustomer}"; config.PayingCustomer = payingCustomer; } /// Pure: whether (normalized) names a SectionUsagePlans definition. internal static bool IsKnownUsagePlan(string planName, IEnumerable definitions) => definitions.Any(d => string.Equals(TenantEntitlementService.NormalizePlanName(d.Name), planName, StringComparison.OrdinalIgnoreCase)); private static IEnumerable KnownUsagePlanNames(IEnumerable definitions) => definitions.Select(d => TenantEntitlementService.NormalizePlanName(d.Name)).Where(n => n != null).Select(n => n!).OrderBy(n => n, StringComparer.Ordinal); /// /// POST /api/config/{tenantId}/trial — TenantAdminOrGA (catalog-enforced). Self-service /// 30-day Pro trial, exactly once per tenant. 409 when the trial was already /// consumed or the tenant is already effectively Pro. /// /// /// Pure verdict for the self-service trial start; null = allowed. Order matters and is /// test-pinned: the terminal conditions (trial consumed, already Pro) win over the /// contact-profile prompt — asking for an address would be pointless there. The contact /// profile (address + company name) is enforced ONLY at this plan entry point, never as /// a runtime gate on Pro features (existing Pro tenants get the dashboard banner instead /// of a lockout); GA plan assignment (PATCH plan) deliberately has no such block — the /// admin UI warns. One verdict names everything that is missing, so a caller never has /// to fix the profile in two round trips. /// internal static (string Error, string Message)? EvaluateTrialStart(TenantConfiguration config, DateTime nowUtc) { if (config.TrialConsumed) { return ("TrialAlreadyConsumed", "This tenant has already used its one self-service Pro trial. Contact support to extend."); } // Effective edition, conferred Pro included: a tenant managed by a Pro tenant needs no trial. if (FeatureEntitlementCatalog.ResolveEdition(config, nowUtc) == TenantEdition.Pro) { return ("AlreadyPro", "This tenant is already on the Pro plan."); } var missing = MissingContactProfileParts(config); if (missing.Count > 0) { return ("ContactProfileRequired", $"Pro requires a tenant contact profile so we can reach and identify you for service or security matters. Missing: {string.Join(" and ", missing)}. Set it under Settings → Tenant → Contact, then start the trial."); } return null; } /// /// The parts of the contact profile the Pro entry point requires and this config lacks, /// in display order. Empty = complete. /// internal static IReadOnlyList MissingContactProfileParts(TenantConfiguration config) { var missing = new List(2); if (string.IsNullOrWhiteSpace(config.ContactEmail)) missing.Add("contact address"); if (string.IsNullOrWhiteSpace(config.CompanyName)) missing.Add("company name"); return missing; } [Function("StartTenantTrial")] public async Task StartTrial( [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "config/{tenantId}/trial")] HttpRequestData req, string tenantId) { try { var requestCtx = req.GetRequestContext(); var caller = requestCtx.UserPrincipalName ?? "Unknown"; _logger.LogInformation("StartTenantTrial: tenantId={TenantId} by {User}", requestCtx.TargetTenantId, caller); var config = await _configService.GetConfigurationIfExistsAsync(requestCtx.TargetTenantId); if (config == null) { var notFound = req.CreateResponse(HttpStatusCode.NotFound); await notFound.WriteAsJsonAsync(new { error = "Tenant not found" }); return notFound; } var nowUtc = _time.GetUtcNow().UtcDateTime; if (EvaluateTrialStart(config, nowUtc) is { } deny) { var conflict = req.CreateResponse(HttpStatusCode.Conflict); await conflict.WriteAsJsonAsync(new { error = deny.Error, message = deny.Message }); return conflict; } config.TrialStartedUtc = nowUtc; config.TrialExpiresUtc = nowUtc.AddDays(SelfServiceTrialDays); config.TrialConsumed = true; config.TrialGrantedBy = caller; // Effectively Pro again — a leftover retention grace anchor is obsolete. config.ProDowngradedUtc = null; config.UpdatedBy = caller; // Fail-loud: throws when the write did not persist (cache invalidated in finally). await _configService.SaveConfigurationAsync(config, "plan", "self-service trial start"); await _maintenanceRepo.LogAuditEntryAsync( requestCtx.TargetTenantId, "CREATE", "TenantTrial", requestCtx.TargetTenantId, caller, new Dictionary { { "TrialStartedUtc", FormatUtc(config.TrialStartedUtc) }, { "TrialExpiresUtc", FormatUtc(config.TrialExpiresUtc) } }); // The conversion moment. Fired after the write persisted (SaveConfigurationAsync // is fail-loud), so an alert never announces a trial that was not stored. await _opsEvents.RecordTenantTrialStartedAsync( requestCtx.TargetTenantId, config.DomainName, config.ContactEmail, config.CompanyName, config.TrialStartedUtc, config.TrialExpiresUtc, caller, selfService: true); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new StartTenantTrialResponse { TenantId = requestCtx.TargetTenantId, TrialStartedUtc = config.TrialStartedUtc, TrialExpiresUtc = config.TrialExpiresUtc, EffectiveEdition = FeatureEntitlementCatalog.ProTierName }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error starting trial"); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" }); return errorResponse; } } /// /// GET /api/global/config/plan-tiers /// Returns plan tier definitions from AdminConfiguration.PlanTierDefinitionsJson. /// [Function("GetPlanTierDefinitions")] public async Task GetPlanTierDefinitions( [HttpTrigger(AuthorizationLevel.Anonymous, "get", Route = "global/config/plan-tiers")] HttpRequestData req) { try { var config = await _adminConfigService.GetConfigurationAsync(); var tiers = PlanTierDefinitionParser.Parse(config.PlanTierDefinitionsJson); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = tiers }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error getting plan tier definitions"); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" }); return errorResponse; } } /// /// PUT /api/global/config/plan-tiers /// Saves plan tier definitions. Body: { "tiers": [...] } /// [Function("SetPlanTierDefinitions")] public async Task SetPlanTierDefinitions( [HttpTrigger(AuthorizationLevel.Anonymous, "put", Route = "global/config/plan-tiers")] HttpRequestData req) { try { var body = await req.ReadFromJsonAsync(); if (body?.Tiers == null || body.Tiers.Count == 0) { return await BadRequestAsync(req, "At least one tier definition is required"); } // Validate tier names are unique var names = body.Tiers.Select(t => t.Name.ToLowerInvariant()).ToList(); if (names.Distinct().Count() != names.Count) { return await BadRequestAsync(req, "Tier names must be unique"); } // Normalize names to lowercase foreach (var tier in body.Tiers) tier.Name = tier.Name.ToLowerInvariant(); var config = await _adminConfigService.GetConfigurationAsync(); config.PlanTierDefinitionsJson = JsonSerializer.Serialize(body.Tiers); await _adminConfigService.SaveConfigurationAsync(config); var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new PlanTierDefinitionsResponse { Tiers = body.Tiers }); return response; } catch (Exception ex) { _logger.LogError(ex, "Error saving plan tier definitions"); var errorResponse = req.CreateResponse(HttpStatusCode.InternalServerError); await errorResponse.WriteAsJsonAsync(new { error = "Internal server error" }); return errorResponse; } } private static string FormatUtc(DateTime? value) => value?.ToString("yyyy-MM-ddTHH:mm:ssZ") ?? "(none)"; private static async Task BadRequestAsync(HttpRequestData req, string message) { var badRequest = req.CreateResponse(HttpStatusCode.BadRequest); await badRequest.WriteAsJsonAsync(new { error = message }); return badRequest; } private class SetPlanTierDefinitionsRequest { public List Tiers { get; set; } = new(); } } }