582 lines
20 KiB
C#
582 lines
20 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<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;
|
|
}
|
|
} |