From aa2c8646891ac84eb427494374f6d848e3a0ca68 Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Wed, 20 Aug 2025 22:56:36 -0300 Subject: [PATCH] =?UTF-8?q?fix:=20ajustes=20de=20rodape=20e=20visualiza?= =?UTF-8?q?=C3=A7=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controllers/StripeWebhookController.cs | 17 +- .../Controllers/SubscriptionController.cs | 155 ++++++++++++++++ src/BCards.Web/Services/IPaymentService.cs | 6 + src/BCards.Web/Services/PaymentService.cs | 139 +++++++++++++++ .../ViewModels/CancelSubscriptionViewModel.cs | 18 ++ .../ViewModels/ManageSubscriptionViewModel.cs | 1 + src/BCards.Web/Views/Home/Pricing.cshtml | 134 +++++++++++--- .../Views/Payment/ManageSubscription.cshtml | 14 +- .../Views/Subscription/Cancel.cshtml | 167 ++++++++++++++++++ src/BCards.Web/Views/UserPage/Display.cshtml | 138 +++++++++++++++ src/BCards.Web/appsettings.Release.json | 25 ++- src/BCards.Web/appsettings.json | 25 ++- src/BCards.Web/wwwroot/css/site.css | 115 +++++++++++- 13 files changed, 912 insertions(+), 42 deletions(-) create mode 100644 src/BCards.Web/Controllers/SubscriptionController.cs create mode 100644 src/BCards.Web/ViewModels/CancelSubscriptionViewModel.cs create mode 100644 src/BCards.Web/Views/Subscription/Cancel.cshtml 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/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/PaymentService.cs b/src/BCards.Web/Services/PaymentService.cs index 84c00b7..982816e 100644 --- a/src/BCards.Web/Services/PaymentService.cs +++ b/src/BCards.Web/Services/PaymentService.cs @@ -396,6 +396,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/ManageSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs index 968930e..4d28d07 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; diff --git a/src/BCards.Web/Views/Home/Pricing.cshtml b/src/BCards.Web/Views/Home/Pricing.cshtml index f037068..2e9fa7a 100644 --- a/src/BCards.Web/Views/Home/Pricing.cshtml +++ b/src/BCards.Web/Views/Home/Pricing.cshtml @@ -9,6 +9,20 @@

Escolha o plano ideal para você

Comece grátis e faça upgrade quando precisar de mais recursos

+ + +
+
+ + + + + +
+
@@ -65,8 +79,16 @@

Básico

- R$ 9,90 - /mês +
+ R$ 9,90 + /mês +
+
+ R$ 99,00 + /ano +
+ Economize R$ 19,80 (2 meses grátis) +
@@ -100,10 +122,18 @@ -} \ No newline at end of file +} + + \ No newline at end of file diff --git a/src/BCards.Web/Views/Payment/ManageSubscription.cshtml b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml index 9b27d78..2aa87b8 100644 --- a/src/BCards.Web/Views/Payment/ManageSubscription.cshtml +++ b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml @@ -103,10 +103,20 @@
@if (!Model.WillCancelAtPeriodEnd) { - + + } + else + { +
+ + +
}
diff --git a/src/BCards.Web/Views/Subscription/Cancel.cshtml b/src/BCards.Web/Views/Subscription/Cancel.cshtml new file mode 100644 index 0000000..6e15969 --- /dev/null +++ b/src/BCards.Web/Views/Subscription/Cancel.cshtml @@ -0,0 +1,167 @@ +@model BCards.Web.ViewModels.CancelSubscriptionViewModel +@{ + ViewData["Title"] = "Cancelar Assinatura"; + Layout = "_Layout"; +} + +
+
+
+
+
+

+ + Cancelar Assinatura +

+
+
+
+
Informações da Assinatura
+

Plano: @Model.PlanName

+

Válido até: @Model.CurrentPeriodEnd.ToString("dd/MM/yyyy")

+

Dias restantes: @Model.DaysRemaining dias

+
+ +
Escolha uma opção de cancelamento:
+ + + + +
+ + @if (Model.CanRefundFull) + { +
+
+
+
+ + +
+
+
+
+ } + + + @if (Model.CanRefundPartial && Model.RefundAmount > 0) + { +
+
+
+
+ + +
+
+
+
+ } + + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
Importante:
+
    +
  • Reembolsos são processados em até 10 dias úteis
  • +
  • O valor retorna para o mesmo cartão usado na compra
  • +
  • Após o cancelamento, suas páginas podem ser desativadas conforme o plano escolhido
  • +
+
+
+ +
+ + Voltar + + +
+ +
+
+
+
+
+ + \ No newline at end of file diff --git a/src/BCards.Web/Views/UserPage/Display.cshtml b/src/BCards.Web/Views/UserPage/Display.cshtml index 5f4a116..34d23d9 100644 --- a/src/BCards.Web/Views/UserPage/Display.cshtml +++ b/src/BCards.Web/Views/UserPage/Display.cshtml @@ -42,12 +42,150 @@ } +@if (!isPreview) +{ + +} + @if (isPreview) {