diff --git a/BCards.sln b/BCards.sln
index bec54d6..af5c4e7 100644
--- a/BCards.sln
+++ b/BCards.sln
@@ -28,16 +28,21 @@ Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
+ Testing|Any CPU = Testing|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
+ {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Testing|Any CPU.Build.0 = Testing|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.ActiveCfg = Testing|Any CPU
+ {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Testing|Any CPU.Build.0 = Testing|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
index 52be975..706e425 100644
--- a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
+++ b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
@@ -6,6 +6,7 @@
enable
false
true
+ Debug;Release;Testing
diff --git a/src/BCards.Web/BCards.Web.csproj b/src/BCards.Web/BCards.Web.csproj
index 802d00d..67d0eeb 100644
--- a/src/BCards.Web/BCards.Web.csproj
+++ b/src/BCards.Web/BCards.Web.csproj
@@ -5,7 +5,8 @@
enable
enable
false
- linux-x64;linux-arm64
+ linux-x64;linux-arm64
+ Debug;Release;Testing
@@ -19,21 +20,25 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+ $(DefineConstants);TESTING
+
+
+
diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs
index c03bd18..1889d66 100644
--- a/src/BCards.Web/Controllers/AdminController.cs
+++ b/src/BCards.Web/Controllers/AdminController.cs
@@ -24,6 +24,7 @@ public class AdminController : Controller
private readonly ILivePageService _livePageService;
private readonly IImageStorageService _imageStorage;
private readonly IPaymentService _paymentService;
+ private readonly IDowngradeService _downgradeService;
private readonly ILogger _logger;
public AdminController(
@@ -36,6 +37,7 @@ public class AdminController : Controller
ILivePageService livePageService,
IImageStorageService imageStorage,
IPaymentService paymentService,
+ IDowngradeService downgradeService,
ILogger logger)
{
_authService = authService;
@@ -47,6 +49,7 @@ public class AdminController : Controller
_livePageService = livePageService;
_imageStorage = imageStorage;
_paymentService = paymentService;
+ _downgradeService = downgradeService;
_logger = logger;
}
@@ -299,6 +302,19 @@ public class AdminController : Controller
if (model.IsNewPage)
{
+ // CRITICAL: Check if user can create new page (validate MaxPages limit)
+ var existingPages = await _userPageService.GetUserPagesAsync(user.Id);
+ var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial;
+ var maxPages = userPlanType.GetMaxPages();
+
+ if (existingPages.Count >= maxPages)
+ {
+ TempData["Error"] = $"Você já atingiu o limite de {maxPages} página(s) do seu plano atual. Faça upgrade para criar mais páginas.";
+ model.AvailableCategories = await _categoryService.GetAllCategoriesAsync();
+ model.AvailableThemes = await _themeService.GetAvailableThemesAsync();
+ return View(model);
+ }
+
// Generate slug if not provided
if (string.IsNullOrEmpty(model.Slug))
{
@@ -1126,6 +1142,18 @@ public class AdminController : Controller
}
}
+ // Método auxiliar para critérios de downgrade (usado na API)
+ private DowngradeCriteria GetDowngradeCriteria(PlanType plan)
+ {
+ return new DowngradeCriteria
+ {
+ MaxPages = plan.GetMaxPages(),
+ MaxLinksPerPage = plan.GetMaxLinksPerPage(),
+ SelectionCriteria = "Páginas mais antigas têm prioridade",
+ LinksCriteria = "Páginas com muitos links são automaticamente suspensas"
+ };
+ }
+
private void CleanSocialMediaFields(ManagePageViewModel model)
{
// Tratar espaço em branco como campo vazio para redes sociais
@@ -1142,6 +1170,67 @@ public class AdminController : Controller
model.TwitterUrl = string.Empty;
}
+ // Endpoint para validar impacto de downgrade
+ [HttpPost]
+ [Route("ValidateDowngrade")]
+ public async Task ValidateDowngrade(string targetPlan)
+ {
+ try
+ {
+ var user = await _authService.GetCurrentUserAsync(User);
+ if (user == null)
+ return Json(new { error = "Usuário não encontrado" });
+
+ if (!Enum.TryParse(targetPlan, true, out var newPlan))
+ return Json(new { error = "Plano inválido" });
+
+ var analysis = await _downgradeService.AnalyzeDowngradeImpact(user.Id, newPlan);
+
+ // CASO CRÍTICO: Nenhuma página atende os critérios
+ if (analysis.IsCritical)
+ {
+ return Json(new
+ {
+ canDowngrade = false,
+ critical = true,
+ title = "⚠️ Downgrade não recomendado",
+ message = "Nenhuma de suas páginas atende aos limites do novo plano. Todas seriam suspensas.",
+ details = analysis.Issues,
+ suggestion = "Considere editar suas páginas para reduzir o número de links antes do downgrade.",
+ criteria = GetDowngradeCriteria(newPlan)
+ });
+ }
+
+ // CASO NORMAL: Algumas páginas serão afetadas
+ return Json(new
+ {
+ canDowngrade = true,
+ critical = false,
+ title = "Confirmação de Downgrade",
+ summary = analysis.Summary,
+ eligiblePages = analysis.EligiblePages.Select(p => new
+ {
+ name = p.DisplayName,
+ linkCount = p.LinkCount,
+ reason = "✅ Dentro dos limites"
+ }),
+ suspendedPages = analysis.AffectedPages.Select(p => new
+ {
+ name = p.DisplayName,
+ linkCount = p.LinkCount,
+ reason = p.SuspensionReason
+ }),
+ criteria = GetDowngradeCriteria(newPlan)
+ });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Erro ao validar downgrade para usuário {User}", User.Identity?.Name);
+ return Json(new { error = "Erro interno do servidor" });
+ }
+ }
+
+
// 🔥 OTIMIZAÇÃO: Endpoint para manter a sessão do usuário ativa
[HttpPost]
[Route("KeepAlive")]
diff --git a/src/BCards.Web/Controllers/AuthController.cs b/src/BCards.Web/Controllers/AuthController.cs
index 1b1307b..d1f57f7 100644
--- a/src/BCards.Web/Controllers/AuthController.cs
+++ b/src/BCards.Web/Controllers/AuthController.cs
@@ -6,7 +6,9 @@ using Microsoft.AspNetCore.Authentication.MicrosoftAccount;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
+#if TESTING
using BCards.Web.TestSupport;
+#endif
namespace BCards.Web.Controllers;
@@ -148,8 +150,10 @@ public class AuthController : Controller
return Challenge(properties, MicrosoftAccountDefaults.AuthenticationScheme);
}
+#if TESTING
[HttpPost]
[Route("LoginWithTest")]
+
public IActionResult LoginWithTest(string? returnUrl = null)
{
if (!_env.IsEnvironment("Testing"))
@@ -170,6 +174,7 @@ public class AuthController : Controller
var properties = new AuthenticationProperties { RedirectUri = redirectUrlString };
return Challenge(properties, TestAuthConstants.AuthenticationScheme);
}
+#endif
[HttpGet]
[Route("GoogleCallback")]
@@ -251,4 +256,4 @@ public class AuthController : Controller
return RedirectToAction("Dashboard", "Admin");
}
-}
\ No newline at end of file
+}
diff --git a/src/BCards.Web/Controllers/TestToolsController.cs b/src/BCards.Web/Controllers/TestToolsController.cs
new file mode 100644
index 0000000..6c67a97
--- /dev/null
+++ b/src/BCards.Web/Controllers/TestToolsController.cs
@@ -0,0 +1,170 @@
+#if TESTING
+using BCards.Web.Models;
+using BCards.Web.Repositories;
+using BCards.Web.Services;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+
+namespace BCards.Web.Controllers;
+
+[Authorize]
+[ApiController]
+[Route("testing/tools")]
+public class TestToolsController : ControllerBase
+{
+ private readonly IUserRepository _userRepository;
+ private readonly ISubscriptionRepository _subscriptionRepository;
+ private readonly IUserPageService _userPageService;
+ private readonly ILivePageService _livePageService;
+ private readonly IPlanConfigurationService _planConfigurationService;
+ private readonly IWebHostEnvironment _environment;
+
+ public TestToolsController(
+ IUserRepository userRepository,
+ ISubscriptionRepository subscriptionRepository,
+ IUserPageService userPageService,
+ ILivePageService livePageService,
+ IPlanConfigurationService planConfigurationService,
+ IWebHostEnvironment environment)
+ {
+ _userRepository = userRepository;
+ _subscriptionRepository = subscriptionRepository;
+ _userPageService = userPageService;
+ _livePageService = livePageService;
+ _planConfigurationService = planConfigurationService;
+ _environment = environment;
+ }
+
+ [HttpPost("plan")]
+ public async Task SetPlan([FromBody] SetPlanRequest request)
+ {
+ if (!_environment.IsEnvironment("Testing"))
+ {
+ return NotFound();
+ }
+
+ if (!Enum.TryParse(request.Plan, true, out var planType))
+ {
+ return BadRequest(new { error = $"Plano desconhecido: {request.Plan}" });
+ }
+
+ var email = string.IsNullOrWhiteSpace(request.Email)
+ ? TestUserDefaults.Email
+ : request.Email!.Trim();
+
+ var user = await _userRepository.GetByEmailAsync(email);
+ if (user == null)
+ {
+ return NotFound(new { error = $"Usuário de teste não encontrado para o e-mail {email}" });
+ }
+
+ var planLimits = _planConfigurationService.GetPlanLimitations(planType);
+ var normalizedPlan = planLimits.PlanType ?? planType.ToString().ToLowerInvariant();
+
+ user.CurrentPlan = normalizedPlan;
+ user.SubscriptionStatus = "active";
+ user.UpdatedAt = DateTime.UtcNow;
+ await _userRepository.UpdateAsync(user);
+
+ var subscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
+ if (subscription == null)
+ {
+ subscription = new Subscription
+ {
+ UserId = user.Id,
+ StripeSubscriptionId = $"test-{Guid.NewGuid():N}",
+ CreatedAt = DateTime.UtcNow,
+ UpdatedAt = DateTime.UtcNow
+ };
+ }
+
+ subscription.PlanType = normalizedPlan;
+ subscription.Status = "active";
+ subscription.MaxLinks = planLimits.MaxLinks;
+ subscription.AllowAnalytics = planLimits.AllowAnalytics;
+ subscription.AllowCustomThemes = planLimits.AllowCustomThemes;
+ subscription.AllowCustomDomain = planLimits.AllowCustomDomain;
+ subscription.AllowMultipleDomains = planLimits.AllowMultipleDomains;
+ subscription.PrioritySupport = planLimits.PrioritySupport;
+ subscription.CurrentPeriodStart = DateTime.UtcNow.Date;
+ subscription.CurrentPeriodEnd = DateTime.UtcNow.Date.AddMonths(1);
+ subscription.CancelAtPeriodEnd = false;
+ subscription.UpdatedAt = DateTime.UtcNow;
+
+ if (string.IsNullOrEmpty(subscription.Id))
+ {
+ await _subscriptionRepository.CreateAsync(subscription);
+ }
+ else
+ {
+ await _subscriptionRepository.UpdateAsync(subscription);
+ }
+
+ var pages = await _userPageService.GetUserPagesAsync(user.Id);
+ var originalPagesCount = pages.Count;
+ var removed = 0;
+ if (request.ResetPages)
+ {
+ foreach (var page in pages)
+ {
+ await _livePageService.DeleteByOriginalPageIdAsync(page.Id);
+ await _userPageService.DeletePageAsync(page.Id);
+ removed++;
+ }
+
+ pages = new List();
+ }
+ else
+ {
+ foreach (var page in pages)
+ {
+ page.PlanLimitations = ClonePlanLimitations(planLimits);
+ page.UpdatedAt = DateTime.UtcNow;
+ await _userPageService.UpdatePageAsync(page);
+ }
+ }
+
+ return Ok(new
+ {
+ email,
+ plan = normalizedPlan,
+ pagesAffected = request.ResetPages ? originalPagesCount : pages.Count,
+ pagesRemoved = removed
+ });
+ }
+
+ private static PlanLimitations ClonePlanLimitations(PlanLimitations source)
+ {
+ return new PlanLimitations
+ {
+ MaxLinks = source.MaxLinks,
+ AllowCustomThemes = source.AllowCustomThemes,
+ AllowAnalytics = source.AllowAnalytics,
+ AllowCustomDomain = source.AllowCustomDomain,
+ AllowMultipleDomains = source.AllowMultipleDomains,
+ PrioritySupport = source.PrioritySupport,
+ PlanType = source.PlanType,
+ MaxProductLinks = source.MaxProductLinks,
+ MaxOGExtractionsPerDay = source.MaxOGExtractionsPerDay,
+ AllowProductLinks = source.AllowProductLinks,
+ SpecialModeration = source.SpecialModeration,
+ OGExtractionsUsedToday = 0,
+ LastExtractionDate = null
+ };
+ }
+
+ private static class TestUserDefaults
+ {
+ public const string Email = "test.user@example.com";
+ }
+
+ public class SetPlanRequest
+ {
+ public string? Email { get; set; }
+
+ public string Plan { get; set; } = "trial";
+
+ public bool ResetPages { get; set; }
+ }
+}
+#endif
diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs
index 6c06e93..9ea0f6c 100644
--- a/src/BCards.Web/Program.cs
+++ b/src/BCards.Web/Program.cs
@@ -18,7 +18,9 @@ using Serilog;
using Serilog.Events;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog.Sinks.OpenSearch;
+#if TESTING
using BCards.Web.TestSupport;
+#endif
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
@@ -421,6 +423,7 @@ authBuilder.AddGoogle(options =>
};
});
+#if TESTING
// Conditionally set the DefaultChallengeScheme and register the Test scheme
if (builder.Environment.IsEnvironment("Testing"))
{
@@ -437,6 +440,12 @@ else
options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
});
}
+#else
+authBuilder.Services.Configure(options =>
+{
+ options.DefaultChallengeScheme = GoogleDefaults.AuthenticationScheme;
+});
+#endif
// Localization
builder.Services.AddLocalization(options => options.ResourcesPath = "Resources");
@@ -471,6 +480,7 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
diff --git a/src/BCards.Web/Services/DowngradeService.cs b/src/BCards.Web/Services/DowngradeService.cs
new file mode 100644
index 0000000..8e8385f
--- /dev/null
+++ b/src/BCards.Web/Services/DowngradeService.cs
@@ -0,0 +1,225 @@
+using BCards.Web.Models;
+using BCards.Web.ViewModels;
+
+namespace BCards.Web.Services;
+
+public class DowngradeService : IDowngradeService
+{
+ private readonly IUserPageService _userPageService;
+ private readonly ILogger _logger;
+
+ // Ordem de prioridade dos planos (menor = melhor)
+ private static readonly Dictionary PlanPriority = new()
+ {
+ { PlanType.Trial, 0 },
+ { PlanType.Basic, 1 },
+ { PlanType.Professional, 2 },
+ { PlanType.Premium, 3 },
+ { PlanType.PremiumAffiliate, 4 }
+ };
+
+ public DowngradeService(IUserPageService userPageService, ILogger logger)
+ {
+ _userPageService = userPageService;
+ _logger = logger;
+ }
+
+ public async Task AnalyzeDowngradeImpact(string userId, PlanType newPlan)
+ {
+ var pages = await _userPageService.GetUserPagesAsync(userId);
+ var activePagesOnly = pages.Where(p => p.Status == PageStatus.Active).ToList();
+
+ var newMaxPages = newPlan.GetMaxPages();
+ var newMaxLinks = newPlan.GetMaxLinksPerPage();
+
+ var eligiblePages = activePagesOnly
+ .Where(p => newMaxLinks == -1 || (p.Links?.Count ?? 0) <= newMaxLinks)
+ .OrderBy(p => p.CreatedAt) // Mais antigas primeiro
+ .Take(newMaxPages)
+ .Select(p => new PageInfo
+ {
+ Id = p.Id,
+ DisplayName = p.DisplayName,
+ LinkCount = p.Links?.Count ?? 0,
+ CreatedAt = p.CreatedAt
+ })
+ .ToList();
+
+ var affectedPages = activePagesOnly
+ .Where(p => !eligiblePages.Any(ep => ep.Id == p.Id))
+ .Select(p => new PageInfo
+ {
+ Id = p.Id,
+ DisplayName = p.DisplayName,
+ LinkCount = p.Links?.Count ?? 0,
+ CreatedAt = p.CreatedAt,
+ SuspensionReason = GetSuspensionReason(p, newPlan)
+ })
+ .ToList();
+
+ var issues = new List();
+
+ var pagesWithTooManyLinks = activePagesOnly
+ .Where(p => newMaxLinks != -1 && (p.Links?.Count ?? 0) > newMaxLinks)
+ .Count();
+
+ if (pagesWithTooManyLinks > 0)
+ issues.Add($"{pagesWithTooManyLinks} páginas têm muitos links");
+
+ if (activePagesOnly.Count > newMaxPages)
+ issues.Add($"Total de páginas ({activePagesOnly.Count}) excede limite ({newMaxPages})");
+
+ return new DowngradeAnalysis
+ {
+ EligiblePages = eligiblePages,
+ AffectedPages = affectedPages,
+ Issues = issues
+ };
+ }
+
+ public async Task ProcessAutomaticDowngrade(string userId, PlanType newPlan)
+ {
+ try
+ {
+ var analysis = await AnalyzeDowngradeImpact(userId, newPlan);
+
+ // PROTEÇÃO: Se nenhuma página for elegível, cancelar downgrade
+ if (analysis.IsCritical)
+ {
+ throw new InvalidOperationException(
+ "Downgrade cancelado: nenhuma página atende aos critérios do novo plano. " +
+ "Edite suas páginas para reduzir links ou escolha um plano superior."
+ );
+ }
+
+ // Processar normalmente - suspender páginas afetadas
+ foreach (var pageInfo in analysis.AffectedPages)
+ {
+ var page = await _userPageService.GetPageByIdAsync(pageInfo.Id);
+ if (page != null)
+ {
+ page.Status = PageStatus.SuspendedByPlanLimit;
+ var detailedReason = GetDetailedSuspensionReason(page, newPlan);
+ await _userPageService.UpdatePageAsync(page);
+
+ _logger.LogInformation(
+ "Página {PageName} (ID: {PageId}) suspensa por downgrade para {PlanType}. Motivo: {Reason}",
+ page.DisplayName, page.Id, newPlan, detailedReason
+ );
+ }
+ }
+
+ var result = new DowngradeResult
+ {
+ Success = true,
+ KeptActive = analysis.EligiblePages.Count,
+ Suspended = analysis.AffectedPages.Count,
+ Message = $"Downgrade concluído. {analysis.EligiblePages.Count} páginas ativas, {analysis.AffectedPages.Count} suspensas.",
+ Details = analysis.AffectedPages.Select(p => $"{p.DisplayName}: {p.SuspensionReason}").ToList()
+ };
+
+ _logger.LogInformation(
+ "Downgrade automático concluído para usuário {UserId}: {KeptActive} mantidas, {Suspended} suspensas",
+ userId, result.KeptActive, result.Suspended
+ );
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Erro no processamento automático de downgrade para usuário {UserId}", userId);
+ return new DowngradeResult
+ {
+ Success = false,
+ Message = $"Erro no processamento: {ex.Message}",
+ Details = new List { ex.Message }
+ };
+ }
+ }
+
+ public async Task ProcessAutomaticUpgrade(string userId, PlanType newPlan)
+ {
+ try
+ {
+ var pages = await _userPageService.GetUserPagesAsync(userId);
+ var suspendedPages = pages.Where(p => p.Status == PageStatus.SuspendedByPlanLimit).ToList();
+
+ var newMaxPages = newPlan.GetMaxPages();
+ var newMaxLinks = newPlan.GetMaxLinksPerPage();
+
+ var reactivated = 0;
+
+ // Reativar páginas que agora cabem no novo plano
+ foreach (var page in suspendedPages.OrderBy(p => p.CreatedAt).Take(newMaxPages))
+ {
+ // Verificar se a página atende aos critérios de links
+ if (newMaxLinks == -1 || (page.Links?.Count ?? 0) <= newMaxLinks)
+ {
+ page.Status = PageStatus.Active;
+ await _userPageService.UpdatePageAsync(page);
+ reactivated++;
+
+ _logger.LogInformation(
+ "Página {PageName} (ID: {PageId}) reativada por upgrade para {PlanType}",
+ page.DisplayName, page.Id, newPlan
+ );
+ }
+ }
+
+ return new DowngradeResult
+ {
+ Success = true,
+ KeptActive = reactivated,
+ Suspended = 0,
+ Message = $"Upgrade concluído. {reactivated} páginas reativadas.",
+ Details = suspendedPages.Take(reactivated).Select(p => $"{p.DisplayName}: Reativada").ToList()
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Erro no processamento automático de upgrade para usuário {UserId}", userId);
+ return new DowngradeResult
+ {
+ Success = false,
+ Message = $"Erro no processamento: {ex.Message}",
+ Details = new List { ex.Message }
+ };
+ }
+ }
+
+ public bool IsDowngrade(PlanType oldPlan, PlanType newPlan)
+ {
+ return PlanPriority.TryGetValue(oldPlan, out var oldPriority) &&
+ PlanPriority.TryGetValue(newPlan, out var newPriority) &&
+ newPriority < oldPriority;
+ }
+
+ public bool IsUpgrade(PlanType oldPlan, PlanType newPlan)
+ {
+ return PlanPriority.TryGetValue(oldPlan, out var oldPriority) &&
+ PlanPriority.TryGetValue(newPlan, out var newPriority) &&
+ newPriority > oldPriority;
+ }
+
+ private string GetSuspensionReason(UserPage page, PlanType newPlan)
+ {
+ var maxLinks = newPlan.GetMaxLinksPerPage();
+ var currentLinks = page.Links?.Count ?? 0;
+
+ if (maxLinks != -1 && currentLinks > maxLinks)
+ return $"❌ Tem {currentLinks} links (limite: {maxLinks})";
+
+ return "❌ Excesso de páginas (critério: mais recentes são suspensas)";
+ }
+
+ private string GetDetailedSuspensionReason(UserPage page, PlanType newPlan)
+ {
+ var maxLinks = newPlan.GetMaxLinksPerPage();
+ var currentLinks = page.Links?.Count ?? 0;
+
+ if (maxLinks != -1 && currentLinks > maxLinks)
+ return $"Página suspensa: possui {currentLinks} links, mas o plano {newPlan.GetDisplayName()} permite apenas {maxLinks} links por página.";
+
+ return $"Página suspensa: plano {newPlan.GetDisplayName()} permite apenas {newPlan.GetMaxPages()} páginas ativas. Páginas mais recentes foram suspensas automaticamente.";
+ }
+}
\ No newline at end of file
diff --git a/src/BCards.Web/Services/IDowngradeService.cs b/src/BCards.Web/Services/IDowngradeService.cs
new file mode 100644
index 0000000..dc5ee35
--- /dev/null
+++ b/src/BCards.Web/Services/IDowngradeService.cs
@@ -0,0 +1,32 @@
+using BCards.Web.Models;
+using BCards.Web.ViewModels;
+
+namespace BCards.Web.Services;
+
+public interface IDowngradeService
+{
+ ///
+ /// Analisa o impacto de um downgrade para um usuário
+ ///
+ Task AnalyzeDowngradeImpact(string userId, PlanType newPlan);
+
+ ///
+ /// Processa automaticamente um downgrade, suspendendo páginas necessárias
+ ///
+ Task ProcessAutomaticDowngrade(string userId, PlanType newPlan);
+
+ ///
+ /// Reativa páginas suspensas por limite quando há upgrade
+ ///
+ Task ProcessAutomaticUpgrade(string userId, PlanType newPlan);
+
+ ///
+ /// Detecta se uma mudança de plano é um downgrade
+ ///
+ bool IsDowngrade(PlanType oldPlan, PlanType newPlan);
+
+ ///
+ /// Detecta se uma mudança de plano é um upgrade
+ ///
+ bool IsUpgrade(PlanType oldPlan, PlanType newPlan);
+}
\ No newline at end of file
diff --git a/src/BCards.Web/TestSupport/TestAuthHandler.cs b/src/BCards.Web/TestSupport/TestAuthHandler.cs
index 18975f6..ee4adde 100644
--- a/src/BCards.Web/TestSupport/TestAuthHandler.cs
+++ b/src/BCards.Web/TestSupport/TestAuthHandler.cs
@@ -1,4 +1,5 @@
+#if TESTING
using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
@@ -82,3 +83,4 @@ namespace BCards.Web.TestSupport
}
}
}
+#endif
diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs
index 25c00ac..1cb29c0 100644
--- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs
+++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs
@@ -134,10 +134,48 @@ public enum PageStatus
{
Active, // Funcionando normalmente
Expired, // Trial vencido -> 301 redirect
- PendingPayment, // Pagamento atrasado -> aviso na página
+ PendingPayment, // Pagamento atrasado -> aviso na página
Inactive, // Pausada pelo usuário
PendingModeration = 4, // Aguardando moderação
Rejected = 5, // Rejeitada na moderação
Creating = 6, // Em desenvolvimento/criação
- Approved = 7 // Aprovada
+ Approved = 7, // Aprovada
+ SuspendedByPlanLimit = 8 // Suspensa por limite de plano (downgrade)
+}
+
+// Modelos para análise de downgrade
+public class DowngradeAnalysis
+{
+ public List EligiblePages { get; set; } = new();
+ public List AffectedPages { get; set; } = new();
+ public List Issues { get; set; } = new();
+ public bool IsCritical => EligiblePages.Count == 0;
+ public string Summary => $"{EligiblePages.Count} mantidas, {AffectedPages.Count} suspensas";
+}
+
+public class PageInfo
+{
+ public string Id { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = string.Empty;
+ public int LinkCount { get; set; }
+ public DateTime CreatedAt { get; set; }
+ public string SuspensionReason { get; set; } = string.Empty;
+}
+
+public class DowngradeResult
+{
+ public bool Success { get; set; }
+ public int KeptActive { get; set; }
+ public int Suspended { get; set; }
+ public string Message { get; set; } = string.Empty;
+ public List Details { get; set; } = new();
+}
+
+public class DowngradeCriteria
+{
+ public int MaxPages { get; set; }
+ public int MaxLinksPerPage { get; set; }
+ public string MaxLinksDisplay => MaxLinksPerPage == -1 ? "Ilimitado" : MaxLinksPerPage.ToString();
+ public string SelectionCriteria { get; set; } = "Páginas mais antigas têm prioridade";
+ public string LinksCriteria { get; set; } = "Páginas com muitos links são automaticamente suspensas";
}
\ No newline at end of file
diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml
index 49af073..dade940 100644
--- a/src/BCards.Web/Views/Admin/ManagePage.cshtml
+++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml
@@ -280,6 +280,7 @@
+