BCards/src/BCards.Web/Services/PaymentService.cs
Ricardo Carneiro 6e70202fce
All checks were successful
BCards Deployment Pipeline / Run Tests (push) Successful in 3s
BCards Deployment Pipeline / PR Validation (push) Has been skipped
BCards Deployment Pipeline / Build and Push Image (push) Successful in 15m47s
BCards Deployment Pipeline / Deploy to Production (ARM - OCI) (push) Successful in 1m12s
BCards Deployment Pipeline / Deploy to Staging (x86 - Local) (push) Has been skipped
BCards Deployment Pipeline / Cleanup Old Resources (push) Has been skipped
BCards Deployment Pipeline / Deployment Summary (push) Successful in 0s
feat: reativar assinatura cancelada
2025-09-07 17:00:50 -03:00

611 lines
21 KiB
C#

using BCards.Web.Configuration;
using BCards.Web.Models;
using BCards.Web.Repositories;
using Microsoft.Extensions.Options;
using Stripe;
using Stripe.Checkout;
using Stripe.BillingPortal;
using System.Numerics;
namespace BCards.Web.Services;
public class PaymentService : IPaymentService
{
private readonly StripeSettings _stripeSettings;
private readonly IUserRepository _userRepository;
private readonly ISubscriptionRepository _subscriptionRepository;
private readonly IConfiguration _configuration;
public PaymentService(
IOptions<StripeSettings> stripeSettings,
IUserRepository userRepository,
ISubscriptionRepository subscriptionRepository,
IConfiguration configuration)
{
_stripeSettings = stripeSettings.Value;
_userRepository = userRepository;
_subscriptionRepository = subscriptionRepository;
_configuration = configuration;
StripeConfiguration.ApiKey = _stripeSettings.SecretKey;
}
public async Task<string> CreateCheckoutSessionAsync(string userId, string planType, string returnUrl, string cancelUrl)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null) throw new InvalidOperationException("User not found");
var planConfig = _configuration.GetSection($"Plans:{planType}");
var priceId = planConfig["PriceId"];
if (string.IsNullOrEmpty(priceId))
throw new InvalidOperationException($"Price ID not found for plan: {planType}");
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
var options = new Stripe.Checkout.SessionCreateOptions
{
PaymentMethodTypes = new List<string> { "card" },
Mode = "subscription",
Customer = customer.Id,
LineItems = new List<SessionLineItemOptions>
{
new()
{
Price = priceId,
Quantity = 1
}
},
SuccessUrl = returnUrl,
CancelUrl = cancelUrl,
Metadata = new Dictionary<string, string>
{
{ "user_id", userId },
{ "plan_type", planType }
}
};
var service = new Stripe.Checkout.SessionService();
var session = await service.CreateAsync(options);
return session.Url;
}
public async Task<Customer> CreateOrGetCustomerAsync(string userId, string email, string name)
{
var user = await _userRepository.GetByIdAsync(userId);
if (!string.IsNullOrEmpty(user?.StripeCustomerId))
{
var customerService = new CustomerService();
try
{
return await customerService.GetAsync(user.StripeCustomerId);
}
catch (StripeException)
{
// Customer doesn't exist, create new one
}
}
// Create new customer
var options = new CustomerCreateOptions
{
Email = email,
Name = name,
Metadata = new Dictionary<string, string>
{
{ "user_id", userId }
}
};
var service = new CustomerService();
var customer = await service.CreateAsync(options);
// Update user with customer ID
if (user != null)
{
user.StripeCustomerId = customer.Id;
await _userRepository.UpdateAsync(user);
}
return customer;
}
public async Task<Stripe.Subscription> HandleWebhookAsync(string requestBody, string signature)
{
try
{
var stripeEvent = EventUtility.ConstructEvent(requestBody, signature, _stripeSettings.WebhookSecret, throwOnApiVersionMismatch: false);
switch (stripeEvent.Type)
{
case "checkout.session.completed":
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleCheckoutSessionCompletedAsync(session!);
break;
case "invoice.finalized":
var invoice = stripeEvent.Data.Object as Invoice;
await HandleInvoicePaymentSucceededAsync(invoice!);
break;
case "customer.subscription.updated":
case "customer.subscription.deleted":
var subscription = stripeEvent.Data.Object as Stripe.Subscription;
await HandleSubscriptionUpdatedAsync(subscription!);
break;
}
return stripeEvent.Data.Object as Stripe.Subscription ?? new Stripe.Subscription();
}
catch (StripeException ex)
{
throw new InvalidOperationException($"Webhook signature verification failed: {ex.Message}");
}
}
public async Task<List<Price>> GetPricesAsync()
{
var service = new PriceService();
var options = new PriceListOptions
{
Active = true,
Type = "recurring"
};
var prices = await service.ListAsync(options);
return prices.Data;
}
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
{
var service = new SubscriptionService();
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = true
};
var subscription = await service.UpdateAsync(subscriptionId, options);
// Update local subscription
var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (localSubscription != null)
{
localSubscription.CancelAtPeriodEnd = true;
await _subscriptionRepository.UpdateAsync(localSubscription);
}
return subscription.CancelAtPeriodEnd;
}
public async Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId)
{
var service = new SubscriptionService();
var subscription = await service.GetAsync(subscriptionId);
var options = new SubscriptionUpdateOptions
{
Items = new List<SubscriptionItemOptions>
{
new()
{
Id = subscription.Items.Data[0].Id,
Price = newPriceId
}
}
};
return await service.UpdateAsync(subscriptionId, options);
}
public Task<PlanLimitations> GetPlanLimitationsAsync(string planType)
{
var limitations = planType.ToLower() switch
{
"basic" => new PlanLimitations
{
MaxLinks = 8,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = true, // URL personalizada em todos os planos pagos
AllowMultipleDomains = false,
PrioritySupport = false,
AllowProductLinks = false,
MaxProductLinks = 0,
PlanType = "basic"
},
"professional" => new PlanLimitations
{
MaxLinks = 20,
AllowCustomThemes = false,
AllowAnalytics = true,
AllowCustomDomain = true,
AllowMultipleDomains = false,
PrioritySupport = false,
AllowProductLinks = false,
MaxProductLinks = 0,
PlanType = "professional"
},
"premium" => new PlanLimitations
{
MaxLinks = -1, // Unlimited
AllowCustomThemes = true,
AllowAnalytics = true,
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 = 3,
AllowCustomThemes = false,
AllowAnalytics = false,
AllowCustomDomain = false,
AllowMultipleDomains = false,
PrioritySupport = false,
AllowProductLinks = false,
MaxProductLinks = 0,
PlanType = "trial"
}
};
return Task.FromResult(limitations);
}
private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session)
{
var userId = session.Metadata["user_id"];
var planType = session.Metadata["plan_type"];
var subscriptionService = new SubscriptionService();
var stripeSubscription = await subscriptionService.GetAsync(session.SubscriptionId);
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
var limitations = await GetPlanLimitationsAsync(planType);
var subscription = new Models.Subscription
{
UserId = userId,
StripeSubscriptionId = session.SubscriptionId,
PlanType = planType,
Status = stripeSubscription.Status,
CurrentPeriodStart = subItem.CurrentPeriodStart,
CurrentPeriodEnd = subItem.CurrentPeriodEnd,
MaxLinks = limitations.MaxLinks,
AllowCustomThemes = limitations.AllowCustomThemes,
AllowAnalytics = limitations.AllowAnalytics,
AllowCustomDomain = limitations.AllowCustomDomain,
AllowMultipleDomains = limitations.AllowMultipleDomains,
PrioritySupport = limitations.PrioritySupport
};
await _subscriptionRepository.CreateAsync(subscription);
// Update user
var user = await _userRepository.GetByIdAsync(userId);
if (user != null)
{
user.CurrentPlan = planType;
user.SubscriptionStatus = "active";
await _userRepository.UpdateAsync(user);
}
}
private async Task HandleInvoicePaymentSucceededAsync(Invoice invoice)
{
var subscriptionId = GetSubscriptionId(invoice);
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (subscription != null)
{
subscription.Status = "active";
await _subscriptionRepository.UpdateAsync(subscription);
}
}
private async Task HandleSubscriptionUpdatedAsync(Stripe.Subscription stripeSubscription)
{
var subscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(stripeSubscription.Id);
if (subscription != null)
{
var service = new SubscriptionItemService();
var subItem = service.Get(stripeSubscription.Items.Data[0].Id);
subscription.Status = stripeSubscription.Status;
subscription.CurrentPeriodStart = subItem.CurrentPeriodStart;
subscription.CurrentPeriodEnd = subItem.CurrentPeriodEnd;
subscription.CancelAtPeriodEnd = stripeSubscription.CancelAtPeriodEnd;
await _subscriptionRepository.UpdateAsync(subscription);
// Update user status
var user = await _userRepository.GetByIdAsync(subscription.UserId);
if (user != null)
{
user.SubscriptionStatus = stripeSubscription.Status;
if (stripeSubscription.Status != "active")
{
// Quando assinatura não está ativa, usuário volta para o plano trial (gratuito)
user.CurrentPlan = "trial";
}
await _userRepository.UpdateAsync(user);
}
}
}
public async Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
return null;
var subscription = await _subscriptionRepository.GetByUserIdAsync(userId);
if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId))
return null;
try
{
var service = new SubscriptionService();
return await service.GetAsync(subscription.StripeSubscriptionId);
}
catch (StripeException)
{
return null;
}
}
public async Task<List<Invoice>> GetPaymentHistoryAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
return new List<Invoice>();
try
{
var service = new InvoiceService();
var options = new InvoiceListOptions
{
Customer = user.StripeCustomerId,
Limit = 50, // Últimas 50 faturas
Status = "paid"
};
var invoices = await service.ListAsync(options);
return invoices.Data;
}
catch (StripeException)
{
return new List<Invoice>();
}
}
public async Task<string> CreatePortalSessionAsync(string customerId, string returnUrl)
{
try
{
var options = new Stripe.BillingPortal.SessionCreateOptions
{
Customer = customerId,
ReturnUrl = returnUrl
};
var service = new Stripe.BillingPortal.SessionService();
var session = await service.CreateAsync(options);
return session.Url;
}
catch (StripeException ex)
{
throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}");
}
}
// Métodos de cancelamento com diferentes políticas
public async Task<bool> 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<bool> 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<bool> ReactivateSubscriptionAsync(string subscriptionId)
{
try
{
var service = new SubscriptionService();
var options = new SubscriptionUpdateOptions
{
CancelAtPeriodEnd = false // Reativar removendo o agendamento de cancelamento
};
await service.UpdateAsync(subscriptionId, options);
// Atualizar subscription local
var localSubscription = await _subscriptionRepository.GetByStripeSubscriptionIdAsync(subscriptionId);
if (localSubscription != null)
{
localSubscription.CancelAtPeriodEnd = false;
localSubscription.UpdatedAt = DateTime.UtcNow;
await _subscriptionRepository.UpdateAsync(localSubscription);
}
return true;
}
catch (StripeException)
{
return false;
}
}
public async Task<Refund> 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<Refund?> 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)
{
var subscriptionLineItem = invoice.Lines?.Data
.FirstOrDefault(line =>
!string.IsNullOrEmpty(line.SubscriptionId) ||
line.Subscription != null
);
string subscriptionId = null;
if (subscriptionLineItem != null)
{
// Tenta obter o ID da assinatura de duas formas diferentes
subscriptionId = subscriptionLineItem.SubscriptionId
?? subscriptionLineItem.Subscription?.Id;
}
return subscriptionId;
}
return null;
}
}