diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index dd3059e..0d26f71 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -21,6 +21,7 @@ public class AdminController : Controller private readonly IEmailService _emailService; private readonly ILivePageService _livePageService; private readonly IImageStorageService _imageStorage; + private readonly IPaymentService _paymentService; private readonly ILogger _logger; public AdminController( @@ -32,6 +33,7 @@ public class AdminController : Controller IEmailService emailService, ILivePageService livePageService, IImageStorageService imageStorage, + IPaymentService paymentService, ILogger logger) { _authService = authService; @@ -42,6 +44,7 @@ public class AdminController : Controller _emailService = emailService; _livePageService = livePageService; _imageStorage = imageStorage; + _paymentService = paymentService; _logger = logger; } @@ -151,12 +154,14 @@ public class AdminController : Controller } // CRIAR NOVA PÁGINA + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); var model = new ManagePageViewModel { IsNewPage = true, AvailableCategories = categories, AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), - MaxLinksAllowed = userPlanType.GetMaxLinksPerPage() + MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), + AllowProductLinks = planLimitations.AllowProductLinks }; return View(model); } @@ -167,7 +172,7 @@ public class AdminController : Controller if (page == null || page.UserId != user.Id) return NotFound(); - var model = MapToManageViewModel(page, categories, themes, userPlanType); + var model = await MapToManageViewModel(page, categories, themes, userPlanType); return View(model); } } @@ -610,7 +615,7 @@ public class AdminController : Controller return RedirectToAction("Dashboard"); } - private ManagePageViewModel MapToManageViewModel(UserPage page, List categories, List themes, PlanType userPlanType) + private async Task MapToManageViewModel(UserPage page, List categories, List themes, PlanType userPlanType) { return new ManagePageViewModel { @@ -641,7 +646,8 @@ public class AdminController : Controller }).ToList() ?? new List(), AvailableCategories = categories, AvailableThemes = themes.Where(t => !t.IsPremium || userPlanType.AllowsCustomThemes()).ToList(), - MaxLinksAllowed = userPlanType.GetMaxLinksPerPage() + MaxLinksAllowed = userPlanType.GetMaxLinksPerPage(), + AllowProductLinks = (await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString())).AllowProductLinks }; } @@ -761,8 +767,23 @@ public class AdminController : Controller // Add regular links if (model.Links?.Any() == true) { - page.Links.AddRange(model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)) - .Select((l, index) => new LinkItem + // Validar links de produto baseado no plano do usuário + var user = await _authService.GetCurrentUserAsync(User); + var userPlanType = Enum.TryParse(user.CurrentPlan, true, out var planType) ? planType : PlanType.Trial; + var planLimitations = await _paymentService.GetPlanLimitationsAsync(userPlanType.ToString()); + + var filteredLinks = model.Links.Where(l => !string.IsNullOrEmpty(l.Title) && !string.IsNullOrEmpty(l.Url)); + + foreach (var link in filteredLinks) + { + // Verificar se usuário pode criar links de produto + if (link.Type == LinkType.Product && !planLimitations.AllowProductLinks) + { + throw new InvalidOperationException("Links de produto disponíveis apenas no plano Premium + Afiliados"); + } + } + + page.Links.AddRange(filteredLinks.Select((l, index) => new LinkItem { Title = l.Title, Url = l.Url, diff --git a/src/BCards.Web/Controllers/ModerationController.cs b/src/BCards.Web/Controllers/ModerationController.cs index 3cac646..d5e1bd0 100644 --- a/src/BCards.Web/Controllers/ModerationController.cs +++ b/src/BCards.Web/Controllers/ModerationController.cs @@ -30,10 +30,10 @@ public class ModerationController : Controller } [HttpGet("Dashboard")] - public async Task Dashboard(int page = 1, int size = 20) + public async Task Dashboard(int page = 1, int size = 20, string? filter = null) { var skip = (page - 1) * size; - var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size); + var pendingPages = await _moderationService.GetPendingModerationAsync(skip, size, filter); var stats = await _moderationService.GetModerationStatsAsync(); var viewModel = new ModerationDashboardViewModel @@ -47,6 +47,7 @@ public class ModerationController : Controller CreatedAt = p.CreatedAt, ModerationAttempts = p.ModerationAttempts, PlanType = p.PlanLimitations.PlanType.ToString(), + IsSpecialModeration = p.PlanLimitations.SpecialModeration ?? false, PreviewUrl = !string.IsNullOrEmpty(p.PreviewToken) ? $"/page/{p.Category}/{p.Slug}?preview={p.PreviewToken}" : null @@ -54,7 +55,8 @@ public class ModerationController : Controller Stats = stats, CurrentPage = page, PageSize = size, - HasNextPage = pendingPages.Count == size + HasNextPage = pendingPages.Count == size, + CurrentFilter = filter ?? "all" }; return View(viewModel); diff --git a/src/BCards.Web/Controllers/PaymentController.cs b/src/BCards.Web/Controllers/PaymentController.cs index b0b1410..6dc9c8b 100644 --- a/src/BCards.Web/Controllers/PaymentController.cs +++ b/src/BCards.Web/Controllers/PaymentController.cs @@ -16,13 +16,15 @@ public class PaymentController : Controller private readonly IAuthService _authService; private readonly IUserRepository _userService; private readonly ISubscriptionRepository _subscriptionRepository; + private readonly IConfiguration _configuration; - public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository) + public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository, IConfiguration configuration) { _paymentService = paymentService; _authService = authService; _userService = userService; _subscriptionRepository = subscriptionRepository; + _configuration = configuration; } [HttpPost] @@ -220,47 +222,33 @@ public class PaymentController : Controller private List GetAvailablePlans(string currentPlan) { - var plans = new List + var plansConfig = _configuration.GetSection("Plans"); + var plans = new List(); + + // Adicionar planos mensais apenas (excluir Trial e planos anuais) + var monthlyPlans = new[] { "Basic", "Professional", "Premium", "PremiumAffiliate" }; + + foreach (var planKey in monthlyPlans) { - // Plano Trial não é incluído aqui pois é gerenciado internamente - // O "downgrade" para Trial acontece via cancelamento da assinatura - new() + var planSection = plansConfig.GetSection(planKey); + if (planSection.Exists()) { - PlanType = "basic", - DisplayName = "Básico", - Price = 9.90m, - PriceId = "price_basic", // Substitua pelos IDs reais do Stripe - MaxLinks = 5, - AllowAnalytics = true, - Features = new List { "5 links", "Temas básicos", "Análises básicas" }, - IsCurrentPlan = currentPlan == "basic" - }, - new() - { - PlanType = "professional", - DisplayName = "Profissional", - Price = 24.90m, - PriceId = "price_professional", // Substitua pelos IDs reais do Stripe - MaxLinks = 15, - AllowAnalytics = true, - AllowCustomDomain = true, - Features = new List { "15 links", "Todos os temas", "Página rápida", "Análises avançadas" }, - IsCurrentPlan = currentPlan == "professional" - }, - new() - { - PlanType = "premium", - DisplayName = "Premium", - Price = 29.90m, - PriceId = "price_premium", // Substitua pelos IDs reais do Stripe - MaxLinks = -1, // Ilimitado - AllowCustomThemes = true, - AllowAnalytics = true, - AllowCustomDomain = true, - Features = new List { "Links ilimitados", "Temas personalizados", "Página rápida", "Suporte prioritário" }, - IsCurrentPlan = currentPlan == "premium" + plans.Add(new AvailablePlanViewModel + { + PlanType = planKey.ToLower(), + DisplayName = planSection["Name"] ?? planKey, + Price = decimal.Parse(planSection["Price"] ?? "0"), + PriceId = planSection["PriceId"] ?? "", + MaxLinks = int.Parse(planSection["MaxLinks"] ?? "0"), + AllowAnalytics = bool.Parse(planSection["AllowAnalytics"] ?? "false"), + AllowCustomDomain = true, // URL personalizada em todos os planos pagos + AllowCustomThemes = bool.Parse(planSection["AllowPremiumThemes"] ?? "false"), + AllowProductLinks = bool.Parse(planSection["AllowProductLinks"] ?? "false"), + Features = planSection.GetSection("Features").Get>() ?? new List(), + IsCurrentPlan = currentPlan.Equals(planKey, StringComparison.OrdinalIgnoreCase) + }); } - }; + } // Marcar upgrades e filtrar downgrades var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan); diff --git a/src/BCards.Web/Controllers/StripeWebhookController.cs b/src/BCards.Web/Controllers/StripeWebhookController.cs index db1c560..b69db47 100644 --- a/src/BCards.Web/Controllers/StripeWebhookController.cs +++ b/src/BCards.Web/Controllers/StripeWebhookController.cs @@ -215,10 +215,19 @@ public class StripeWebhookController : ControllerBase // This would be configured based on your actual Stripe price IDs return priceId switch { - var id when id.Contains("basic") => "basic", - var id when id.Contains("professional") => "professional", - var id when id.Contains("premium") => "premium", - _ => "trial" + "price_1RjUskBMIadsOxJVgLwlVo1y" => "Basic", + "price_1RjUv9BMIadsOxJVORqlM4E9" => "Professional", + "price_1RjUw0BMIadsOxJVmdouNV1g" => "Premium", + "price_basic_yearly_placeholder" => "BasicYearly", + "price_professional_yearly_placeholder" => "ProfessionalYearly", + "price_premium_yearly_placeholder" => "PremiumYearly", + var id when id.Contains("basic") && id.Contains("yearly") => "BasicYearly", + var id when id.Contains("professional") && id.Contains("yearly") => "ProfessionalYearly", + var id when id.Contains("premium") && id.Contains("yearly") => "PremiumYearly", + var id when id.Contains("basic") => "Basic", + var id when id.Contains("professional") => "Professional", + var id when id.Contains("premium") => "Premium", + _ => "Trial" }; } diff --git a/src/BCards.Web/Controllers/SubscriptionController.cs b/src/BCards.Web/Controllers/SubscriptionController.cs new file mode 100644 index 0000000..a73b0ab --- /dev/null +++ b/src/BCards.Web/Controllers/SubscriptionController.cs @@ -0,0 +1,155 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using BCards.Web.Services; +using BCards.Web.ViewModels; +using System.Security.Claims; + +namespace BCards.Web.Controllers; + +[Authorize] +[Route("subscription")] +public class SubscriptionController : Controller +{ + private readonly IPaymentService _paymentService; + private readonly ILogger _logger; + + public SubscriptionController( + IPaymentService paymentService, + ILogger logger) + { + _paymentService = paymentService; + _logger = logger; + } + + [HttpGet("cancel")] + public async Task Cancel() + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + var subscription = await _paymentService.GetSubscriptionDetailsAsync(userId); + if (subscription == null) + { + TempData["Error"] = "Nenhuma assinatura ativa encontrada."; + return RedirectToAction("ManageSubscription", "Payment"); + } + + // Calcular opções de reembolso + var (canRefundFull, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(subscription.Id); + + // Obter datas via SubscriptionItem + var subscriptionItemService = new Stripe.SubscriptionItemService(); + var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); + + var viewModel = new CancelSubscriptionViewModel + { + SubscriptionId = subscription.Id, + PlanName = subscription.Items.Data.FirstOrDefault()?.Price.Nickname ?? "Plano Atual", + CurrentPeriodEnd = subItem.CurrentPeriodEnd, + CanRefundFull = canRefundFull, + CanRefundPartial = canRefundPartial, + RefundAmount = refundAmount, + DaysRemaining = (subItem.CurrentPeriodEnd - DateTime.UtcNow).Days + }; + + return View(viewModel); + } + + [HttpPost("cancel")] + [ValidateAntiForgeryToken] + public async Task ProcessCancel(CancelSubscriptionRequest request) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + try + { + bool success = false; + string message = ""; + + switch (request.CancelType) + { + case "immediate_with_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, true); + message = success ? "Assinatura cancelada. Reembolso será processado manualmente em até 10 dias úteis." : "Erro ao processar cancelamento."; + break; + + case "immediate_no_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); + message = success ? "Assinatura cancelada imediatamente." : "Erro ao cancelar assinatura."; + break; + + case "partial_refund": + success = await _paymentService.CancelSubscriptionImmediatelyAsync(request.SubscriptionId, false); + var (_, canRefundPartial, refundAmount) = await _paymentService.CalculateRefundAsync(request.SubscriptionId); + if (success && canRefundPartial && refundAmount > 0) + { + message = $"Assinatura cancelada. Reembolso parcial de R$ {refundAmount:F2} será processado manualmente em até 10 dias úteis."; + } + else + { + message = success ? "Assinatura cancelada. Reembolso parcial não disponível." : "Erro ao cancelar assinatura."; + } + break; + + case "at_period_end": + default: + success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(request.SubscriptionId); + message = success ? "Assinatura será cancelada no final do período atual." : "Erro ao agendar cancelamento."; + break; + } + + if (success) + { + TempData["Success"] = message; + _logger.LogInformation($"User {userId} cancelled subscription {request.SubscriptionId} with type {request.CancelType}"); + } + else + { + TempData["Error"] = message; + _logger.LogError($"Failed to cancel subscription {request.SubscriptionId} for user {userId}"); + } + } + catch (Exception ex) + { + TempData["Error"] = "Ocorreu um erro ao processar o cancelamento. Tente novamente."; + _logger.LogError(ex, $"Error cancelling subscription {request.SubscriptionId} for user {userId}"); + } + + return RedirectToAction("ManageSubscription", "Payment"); + } + + [HttpPost("reactivate")] + [ValidateAntiForgeryToken] + public async Task Reactivate(string subscriptionId) + { + var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value; + if (string.IsNullOrEmpty(userId)) + return RedirectToAction("Login", "Auth"); + + try + { + // Remover agendamento de cancelamento + var success = await _paymentService.CancelSubscriptionAtPeriodEndAsync(subscriptionId); + + if (success) + { + TempData["Success"] = "Assinatura reativada com sucesso!"; + _logger.LogInformation($"User {userId} reactivated subscription {subscriptionId}"); + } + else + { + TempData["Error"] = "Erro ao reativar assinatura."; + } + } + catch (Exception ex) + { + TempData["Error"] = "Ocorreu um erro ao reativar a assinatura."; + _logger.LogError(ex, $"Error reactivating subscription {subscriptionId} for user {userId}"); + } + + return RedirectToAction("ManageSubscription", "Payment"); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Models/PlanLimitations.cs b/src/BCards.Web/Models/PlanLimitations.cs index bdf5d78..45607d6 100644 --- a/src/BCards.Web/Models/PlanLimitations.cs +++ b/src/BCards.Web/Models/PlanLimitations.cs @@ -35,6 +35,9 @@ public class PlanLimitations [BsonElement("allowProductLinks")] public bool AllowProductLinks { get; set; } = false; + [BsonElement("specialModeration")] + public bool? SpecialModeration { get; set; } = false; + [BsonElement("ogExtractionsUsedToday")] public int OGExtractionsUsedToday { get; set; } = 0; diff --git a/src/BCards.Web/Services/IModerationService.cs b/src/BCards.Web/Services/IModerationService.cs index 8619f30..916cb30 100644 --- a/src/BCards.Web/Services/IModerationService.cs +++ b/src/BCards.Web/Services/IModerationService.cs @@ -6,7 +6,7 @@ public interface IModerationService { Task GeneratePreviewTokenAsync(string pageId); Task ValidatePreviewTokenAsync(string pageId, string token); - Task> GetPendingModerationAsync(int skip = 0, int take = 20); + Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null); Task GetPageForModerationAsync(string pageId); Task ApprovePageAsync(string pageId, string moderatorId, string? notes = null); Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues); diff --git a/src/BCards.Web/Services/IPaymentService.cs b/src/BCards.Web/Services/IPaymentService.cs index 98ce2e6..3b51e82 100644 --- a/src/BCards.Web/Services/IPaymentService.cs +++ b/src/BCards.Web/Services/IPaymentService.cs @@ -17,4 +17,10 @@ public interface IPaymentService Task GetSubscriptionDetailsAsync(string userId); Task> GetPaymentHistoryAsync(string userId); Task CreatePortalSessionAsync(string customerId, string returnUrl); + + // Métodos para cancelamento com diferentes políticas + Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false); + Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId); + Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount); + Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId); } \ No newline at end of file diff --git a/src/BCards.Web/Services/ModerationService.cs b/src/BCards.Web/Services/ModerationService.cs index 960d804..7246bfa 100644 --- a/src/BCards.Web/Services/ModerationService.cs +++ b/src/BCards.Web/Services/ModerationService.cs @@ -52,16 +52,44 @@ public class ModerationService : IModerationService return isValid; } - public async Task> GetPendingModerationAsync(int skip = 0, int take = 20) + + public async Task> GetPendingModerationAsync(int skip = 0, int take = 20, string? filter = null) { - var filter = Builders.Filter.Eq(p => p.Status, PageStatus.PendingModeration); + var filterBuilder = Builders.Filter; + var baseFilter = filterBuilder.Eq(p => p.Status, PageStatus.PendingModeration); - // Ordenar por prioridade do plano e depois por data + FilterDefinition finalFilter = baseFilter; + + // Aplicar filtro de moderação especial + if (!string.IsNullOrEmpty(filter)) + { + switch (filter.ToLower()) + { + case "special": + // Filtrar apenas páginas com moderação especial (Premium + Afiliados) + var specialFilter = filterBuilder.Eq("planLimitations.specialModeration", true); + finalFilter = filterBuilder.And(baseFilter, specialFilter); + break; + case "normal": + // Filtrar apenas páginas sem moderação especial + var normalFilter = filterBuilder.Or( + filterBuilder.Eq("planLimitations.specialModeration", false), + filterBuilder.Exists("planLimitations.specialModeration", false) + ); + finalFilter = filterBuilder.And(baseFilter, normalFilter); + break; + default: + // "all" ou qualquer outro valor: sem filtro adicional + break; + } + } + + // Ordenar por moderação especial primeiro (SLA reduzido), depois por data var sort = Builders.Sort - .Ascending("planLimitations.planType") + .Descending("planLimitations.specialModeration") .Ascending(p => p.CreatedAt); - var pages = await _userPageRepository.GetManyAsync(filter, sort, skip, take); + var pages = await _userPageRepository.GetManyAsync(finalFilter, sort, skip, take); return pages.ToList(); } diff --git a/src/BCards.Web/Services/PaymentService.cs b/src/BCards.Web/Services/PaymentService.cs index 84c00b7..f0dd585 100644 --- a/src/BCards.Web/Services/PaymentService.cs +++ b/src/BCards.Web/Services/PaymentService.cs @@ -205,22 +205,26 @@ public class PaymentService : IPaymentService { "basic" => new PlanLimitations { - MaxLinks = 5, + MaxLinks = 8, AllowCustomThemes = false, AllowAnalytics = true, - AllowCustomDomain = false, + AllowCustomDomain = true, // URL personalizada em todos os planos pagos AllowMultipleDomains = false, PrioritySupport = false, + AllowProductLinks = false, + MaxProductLinks = 0, PlanType = "basic" }, "professional" => new PlanLimitations { - MaxLinks = 15, + MaxLinks = 20, AllowCustomThemes = false, AllowAnalytics = true, AllowCustomDomain = true, AllowMultipleDomains = false, PrioritySupport = false, + AllowProductLinks = false, + MaxProductLinks = 0, PlanType = "professional" }, "premium" => new PlanLimitations @@ -231,17 +235,33 @@ public class PaymentService : IPaymentService AllowCustomDomain = true, AllowMultipleDomains = true, PrioritySupport = true, + AllowProductLinks = false, + MaxProductLinks = 0, PlanType = "premium" }, + "premiumaffiliate" => new PlanLimitations + { + MaxLinks = -1, // Unlimited + AllowCustomThemes = true, + AllowAnalytics = true, + AllowCustomDomain = true, + AllowMultipleDomains = true, + PrioritySupport = true, + AllowProductLinks = true, + MaxProductLinks = 10, + PlanType = "premiumaffiliate" + }, _ => new PlanLimitations { - MaxLinks = 5, + MaxLinks = 3, AllowCustomThemes = false, AllowAnalytics = false, AllowCustomDomain = false, AllowMultipleDomains = false, PrioritySupport = false, - PlanType = "free" + AllowProductLinks = false, + MaxProductLinks = 0, + PlanType = "trial" } }; @@ -396,6 +416,145 @@ public class PaymentService : IPaymentService } } + // Métodos de cancelamento com diferentes políticas + public async Task CancelSubscriptionImmediatelyAsync(string subscriptionId, bool refund = false) + { + try + { + var service = new SubscriptionService(); + + if (refund) + { + // Para reembolso completo, apenas cancela - reembolso deve ser feito manualmente via Stripe Dashboard + await service.CancelAsync(subscriptionId); + } + else + { + await service.CancelAsync(subscriptionId); + } + + // Atualizar subscription local + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.Status = "cancelled"; + localSubscription.UpdatedAt = DateTime.UtcNow; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return true; + } + catch (StripeException) + { + return false; + } + } + + public async Task CancelSubscriptionAtPeriodEndAsync(string subscriptionId) + { + try + { + var service = new SubscriptionService(); + var options = new SubscriptionUpdateOptions + { + CancelAtPeriodEnd = true + }; + + await service.UpdateAsync(subscriptionId, options); + + // Atualizar subscription local + var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId); + if (localSubscription != null) + { + localSubscription.CancelAtPeriodEnd = true; + localSubscription.UpdatedAt = DateTime.UtcNow; + await _subscriptionRepository.UpdateAsync(localSubscription); + } + + return true; + } + catch (StripeException) + { + return false; + } + } + + public async Task CreatePartialRefundAsync(string subscriptionId, decimal refundAmount) + { + // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente via Stripe Dashboard + // Este método retorna null para indicar que o reembolso precisa ser feito manualmente + await Task.CompletedTask; + throw new InvalidOperationException("Reembolsos parciais para assinaturas devem ser processados manualmente via Stripe Dashboard ou Portal de Cobrança"); + } + + public async Task<(bool CanRefundFull, bool CanRefundPartial, decimal RefundAmount)> CalculateRefundAsync(string subscriptionId) + { + try + { + var service = new SubscriptionService(); + var subscription = await service.GetAsync(subscriptionId); + + // Obter datas via SubscriptionItem + var subscriptionItemService = new SubscriptionItemService(); + var subItem = await subscriptionItemService.GetAsync(subscription.Items.Data[0].Id); + + var daysSinceStart = (DateTime.UtcNow - subItem.CurrentPeriodStart).TotalDays; + var totalDays = (subItem.CurrentPeriodEnd - subItem.CurrentPeriodStart).TotalDays; + var daysRemaining = totalDays - daysSinceStart; + + // Direito de arrependimento (7 dias) + bool canRefundFull = daysSinceStart <= 7; + + // Reembolso proporcional para planos anuais + bool canRefundPartial = false; + decimal refundAmount = 0; + + if (!canRefundFull && daysRemaining > 0) + { + // Verificar se é plano anual (pela duração do período) + bool isYearlyPlan = totalDays > 300; // Aproximadamente 1 ano + + if (isYearlyPlan) + { + canRefundPartial = true; + + // Buscar valor pago na última fatura + var invoiceService = new InvoiceService(); + var invoices = await invoiceService.ListAsync(new InvoiceListOptions + { + Subscription = subscriptionId, + Status = "paid", + Limit = 1 + }); + + if (invoices.Data.Any()) + { + var latestInvoice = invoices.Data.First(); + var totalPaid = (decimal)latestInvoice.AmountPaid / 100; // Converter de centavos + + // Calcular proporção do tempo restante + var proportionRemaining = daysRemaining / totalDays; + refundAmount = totalPaid * (decimal)proportionRemaining; + refundAmount = Math.Round(refundAmount, 2); + } + } + } + + return (canRefundFull, canRefundPartial, refundAmount); + } + catch (StripeException) + { + return (false, false, 0); + } + } + + private async Task CreateFullRefundAsync(string? chargeId) + { + // NOTA: Para planos de assinatura, reembolsos devem ser processados manualmente + await Task.CompletedTask; + return null; + } + private string GetSubscriptionId(Invoice? invoice) { if (invoice!=null) diff --git a/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs new file mode 100644 index 0000000..b235d71 --- /dev/null +++ b/src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs @@ -0,0 +1,18 @@ +namespace BCards.Web.ViewModels; + +public class CancelSubscriptionViewModel +{ + public string SubscriptionId { get; set; } = string.Empty; + public string PlanName { get; set; } = string.Empty; + public DateTime CurrentPeriodEnd { get; set; } + public bool CanRefundFull { get; set; } + public bool CanRefundPartial { get; set; } + public decimal RefundAmount { get; set; } + public int DaysRemaining { get; set; } +} + +public class CancelSubscriptionRequest +{ + public string SubscriptionId { get; set; } = string.Empty; + public string CancelType { get; set; } = "at_period_end"; // immediate_with_refund, immediate_no_refund, partial_refund, at_period_end +} \ No newline at end of file diff --git a/src/BCards.Web/ViewModels/ManagePageViewModel.cs b/src/BCards.Web/ViewModels/ManagePageViewModel.cs index ea2ac7c..c8fc4b5 100644 --- a/src/BCards.Web/ViewModels/ManagePageViewModel.cs +++ b/src/BCards.Web/ViewModels/ManagePageViewModel.cs @@ -46,6 +46,7 @@ public class ManagePageViewModel // Plan limitations public int MaxLinksAllowed { get; set; } = 3; + public bool AllowProductLinks { get; set; } = false; public bool CanUseTheme(string themeName) => AvailableThemes.Any(t => t.Name.ToLower() == themeName.ToLower()); /// diff --git a/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs index 968930e..9ab3058 100644 --- a/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs +++ b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs @@ -18,6 +18,7 @@ public class ManageSubscriptionViewModel public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium"; public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic"; public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true; + public string? StripeSubscriptionId => StripeSubscription?.Id; public DateTime? CurrentPeriodEnd { get; set; } public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null; @@ -56,6 +57,7 @@ public class AvailablePlanViewModel public bool AllowCustomThemes { get; set; } public bool AllowAnalytics { get; set; } public bool AllowCustomDomain { get; set; } + public bool AllowProductLinks { get; set; } public bool IsCurrentPlan { get; set; } public bool IsUpgrade { get; set; } public bool IsDowngrade { get; set; } diff --git a/src/BCards.Web/ViewModels/ModerationViewModel.cs b/src/BCards.Web/ViewModels/ModerationViewModel.cs index 0aaa803..2cd8fbc 100644 --- a/src/BCards.Web/ViewModels/ModerationViewModel.cs +++ b/src/BCards.Web/ViewModels/ModerationViewModel.cs @@ -10,6 +10,7 @@ public class ModerationDashboardViewModel public int PageSize { get; set; } = 20; public bool HasNextPage { get; set; } = false; public bool HasPreviousPage => CurrentPage > 1; + public string CurrentFilter { get; set; } = "all"; } public class ModerationPageViewModel @@ -78,4 +79,5 @@ public class PendingPageViewModel public string PreviewUrl { get; set; } = ""; public string PriorityLabel { get; set; } = ""; public string PriorityColor { get; set; } = ""; + public bool IsSpecialModeration { get; set; } = false; } diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index 132b811..69f5eac 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -288,7 +288,7 @@