395 lines
17 KiB
C#
395 lines
17 KiB
C#
using Stripe;
|
|
using Stripe.Checkout;
|
|
using QRRapidoApp.Models;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.Logging;
|
|
using System.Collections.Generic;
|
|
using System;
|
|
using System.Linq;
|
|
|
|
namespace QRRapidoApp.Services
|
|
{
|
|
public class StripeService
|
|
{
|
|
private readonly IConfiguration _config;
|
|
private readonly IUserService _userService;
|
|
private readonly ILogger<StripeService> _logger;
|
|
|
|
public StripeService(IConfiguration config, IUserService userService, ILogger<StripeService> logger)
|
|
{
|
|
_config = config;
|
|
_userService = userService;
|
|
_logger = logger;
|
|
StripeConfiguration.ApiKey = _config["Stripe:SecretKey"];
|
|
}
|
|
|
|
public async Task<string> CreateCheckoutSessionAsync(string userId, string priceId, string lang = "pt-BR")
|
|
{
|
|
// Legacy subscription method - Kept for compatibility but likely unused in Credit model
|
|
var user = await _userService.GetUserAsync(userId);
|
|
if (user == null) throw new Exception("User not found");
|
|
|
|
var options = new SessionCreateOptions
|
|
{
|
|
PaymentMethodTypes = new List<string> { "card" },
|
|
Mode = "subscription",
|
|
LineItems = new List<SessionLineItemOptions>
|
|
{
|
|
new SessionLineItemOptions { Price = priceId, Quantity = 1 }
|
|
},
|
|
Customer = user.StripeCustomerId, // Might be null, legacy logic handled creation
|
|
ClientReferenceId = userId,
|
|
SuccessUrl = $"{_config["App:BaseUrl"]}/Pagamento/Sucesso",
|
|
CancelUrl = $"{_config["App:BaseUrl"]}/Pagamento/SelecaoPlano",
|
|
};
|
|
var service = new SessionService();
|
|
var session = await service.CreateAsync(options);
|
|
return session.Url;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a Stripe Checkout Session for an API subscription.
|
|
/// Follows the same inline PriceData pattern used for credit purchases —
|
|
/// no hardcoded Price IDs or appsettings entries.
|
|
/// </summary>
|
|
public async Task<string> CreateApiSubscriptionCheckoutAsync(
|
|
string userId,
|
|
ApiPlanTier planTier,
|
|
string baseUrl)
|
|
{
|
|
var user = await _userService.GetUserAsync(userId);
|
|
if (user == null) throw new InvalidOperationException("User not found");
|
|
|
|
// Monthly prices in BRL (centavos) — no Price IDs, everything inline
|
|
var (amountCents, productName) = planTier switch
|
|
{
|
|
ApiPlanTier.Starter => (2900L, "QRRapido API — Starter (50 req/min, 10 mil/mês)"),
|
|
ApiPlanTier.Pro => (9900L, "QRRapido API — Pro (200 req/min, 100 mil/mês)"),
|
|
ApiPlanTier.Business => (29900L, "QRRapido API — Business (500 req/min, 500 mil/mês)"),
|
|
_ => throw new ArgumentException($"Plan tier '{planTier}' does not have a subscription price.")
|
|
};
|
|
|
|
var options = new SessionCreateOptions
|
|
{
|
|
PaymentMethodTypes = new List<string> { "card" },
|
|
Mode = "subscription",
|
|
LineItems = new List<SessionLineItemOptions>
|
|
{
|
|
new SessionLineItemOptions
|
|
{
|
|
PriceData = new SessionLineItemPriceDataOptions
|
|
{
|
|
Currency = "brl",
|
|
UnitAmount = amountCents,
|
|
Recurring = new SessionLineItemPriceDataRecurringOptions
|
|
{
|
|
Interval = "month"
|
|
},
|
|
ProductData = new SessionLineItemPriceDataProductDataOptions
|
|
{
|
|
Name = productName,
|
|
Description = $"Plano {ApiPlanLimits.PlanName(planTier)} — acesso à API QRRapido"
|
|
}
|
|
},
|
|
Quantity = 1
|
|
}
|
|
},
|
|
// Metadata on the Session (visible on checkout.session.completed)
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
{ "type", "api_subscription" },
|
|
{ "planTier", planTier.ToString() },
|
|
{ "userId", userId }
|
|
},
|
|
// Metadata forwarded to the Subscription object (visible on subscription events)
|
|
SubscriptionData = new SessionSubscriptionDataOptions
|
|
{
|
|
Metadata = new Dictionary<string, string>
|
|
{
|
|
{ "type", "api_subscription" },
|
|
{ "planTier", planTier.ToString() },
|
|
{ "userId", userId }
|
|
}
|
|
},
|
|
ClientReferenceId = userId,
|
|
Customer = !string.IsNullOrEmpty(user.StripeCustomerId) ? user.StripeCustomerId : null,
|
|
SuccessUrl = $"{baseUrl}/Developer?subscription=success",
|
|
CancelUrl = $"{baseUrl}/Developer/Pricing"
|
|
};
|
|
|
|
var service = new SessionService();
|
|
var session = await service.CreateAsync(options);
|
|
|
|
_logger.LogInformation(
|
|
"API subscription checkout created: user={UserId} tier={Tier} sessionId={SessionId}",
|
|
userId, planTier, session.Id);
|
|
|
|
return session.Url;
|
|
}
|
|
|
|
public async Task HandleWebhookAsync(string json, string signature)
|
|
{
|
|
var webhookSecret = _config["Stripe:WebhookSecret"];
|
|
var stripeEvent = EventUtility.ConstructEvent(json, signature, webhookSecret);
|
|
|
|
_logger.LogInformation($"Processing Stripe webhook: {stripeEvent.Type}");
|
|
|
|
switch (stripeEvent.Type)
|
|
{
|
|
case "checkout.session.completed":
|
|
if (stripeEvent.Data.Object is Session session)
|
|
{
|
|
// API subscription checkout — check metadata first
|
|
if (session.Mode == "subscription" &&
|
|
session.Metadata != null &&
|
|
session.Metadata.TryGetValue("type", out var sessionType) &&
|
|
sessionType == "api_subscription")
|
|
{
|
|
await ProcessApiSubscriptionCheckout(session);
|
|
}
|
|
// 1. Handle One-Time Payment (Credits)
|
|
else if (session.Mode == "payment" && session.PaymentStatus == "paid")
|
|
{
|
|
await ProcessCreditPayment(session);
|
|
}
|
|
// 2. Handle Subscription (Legacy QR premium)
|
|
else if (session.SubscriptionId != null)
|
|
{
|
|
var subscriptionService = new SubscriptionService();
|
|
var subscription = await subscriptionService.GetAsync(session.SubscriptionId);
|
|
var userId = session.ClientReferenceId ??
|
|
(session.Metadata != null && session.Metadata.ContainsKey("user_id") ? session.Metadata["user_id"] : null);
|
|
|
|
if (!string.IsNullOrEmpty(userId))
|
|
{
|
|
await ProcessSubscriptionActivation(userId, subscription);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "invoice.finalized":
|
|
// Legacy subscription logic
|
|
if (stripeEvent.Data.Object is Invoice invoice)
|
|
{
|
|
var subscriptionLineItem = invoice.Lines?.Data
|
|
.FirstOrDefault(line => !string.IsNullOrEmpty(line.SubscriptionId));
|
|
|
|
if (subscriptionLineItem != null)
|
|
{
|
|
var subscriptionService = new SubscriptionService();
|
|
var subscription = await subscriptionService.GetAsync(subscriptionLineItem.SubscriptionId);
|
|
var user = await _userService.GetUserByStripeCustomerIdAsync(subscription.CustomerId);
|
|
if (user != null)
|
|
{
|
|
await ProcessSubscriptionActivation(user.Id, subscription);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "customer.subscription.updated":
|
|
if (stripeEvent.Data.Object is Subscription updatedSub &&
|
|
updatedSub.Metadata != null &&
|
|
updatedSub.Metadata.TryGetValue("type", out var updType) &&
|
|
updType == "api_subscription")
|
|
{
|
|
await ProcessApiSubscriptionUpdated(updatedSub);
|
|
}
|
|
break;
|
|
|
|
case "customer.subscription.deleted":
|
|
if (stripeEvent.Data.Object is Subscription deletedSub)
|
|
{
|
|
if (deletedSub.Metadata != null &&
|
|
deletedSub.Metadata.TryGetValue("type", out var delType) &&
|
|
delType == "api_subscription")
|
|
{
|
|
// API subscription canceled — downgrade to Free
|
|
await _userService.UpdateApiSubscriptionStatusAsync(
|
|
deletedSub.Id, "canceled");
|
|
}
|
|
else
|
|
{
|
|
// Legacy QR premium subscription
|
|
await _userService.DeactivatePremiumStatus(deletedSub.Id);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case "invoice.payment_failed":
|
|
if (stripeEvent.Data.Object is Invoice failedInvoice)
|
|
{
|
|
// Get subscription ID from invoice line items (same pattern as invoice.finalized)
|
|
var failedLineItem = failedInvoice.Lines?.Data
|
|
.FirstOrDefault(line => !string.IsNullOrEmpty(line.SubscriptionId));
|
|
|
|
if (failedLineItem != null)
|
|
{
|
|
var subService = new SubscriptionService();
|
|
var failedSub = await subService.GetAsync(failedLineItem.SubscriptionId);
|
|
|
|
if (failedSub?.Metadata != null &&
|
|
failedSub.Metadata.TryGetValue("type", out var failType) &&
|
|
failType == "api_subscription")
|
|
{
|
|
await _userService.UpdateApiSubscriptionStatusAsync(
|
|
failedSub.Id, "past_due");
|
|
|
|
_logger.LogWarning(
|
|
"API subscription payment failed: subId={SubId}", failedSub.Id);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
private async Task ProcessApiSubscriptionCheckout(Session session)
|
|
{
|
|
if (session.Metadata == null ||
|
|
!session.Metadata.TryGetValue("userId", out var userId) ||
|
|
!session.Metadata.TryGetValue("planTier", out var tierStr))
|
|
{
|
|
_logger.LogWarning("API subscription checkout completed but missing metadata: sessionId={Id}", session.Id);
|
|
return;
|
|
}
|
|
|
|
if (!Enum.TryParse<ApiPlanTier>(tierStr, out var tier))
|
|
{
|
|
_logger.LogWarning("API subscription checkout has invalid planTier '{Tier}'", tierStr);
|
|
return;
|
|
}
|
|
|
|
// Same pattern as existing ProcessSubscriptionActivation: fetch SubscriptionItem for period end
|
|
var subscriptionService = new SubscriptionService();
|
|
var subscription = await subscriptionService.GetAsync(session.SubscriptionId);
|
|
|
|
var subItemService = new SubscriptionItemService();
|
|
var subItem = subItemService.Get(subscription.Items.Data[0].Id);
|
|
|
|
await _userService.ActivateApiSubscriptionAsync(
|
|
userId,
|
|
subscription.Id,
|
|
tier,
|
|
subItem.CurrentPeriodEnd,
|
|
subscription.CustomerId);
|
|
|
|
_logger.LogInformation(
|
|
"API subscription activated via checkout: user={UserId} tier={Tier} subId={SubId}",
|
|
userId, tier, subscription.Id);
|
|
}
|
|
|
|
private async Task ProcessApiSubscriptionUpdated(Subscription subscription)
|
|
{
|
|
// Re-read tier from metadata in case of a plan change (upgrade/downgrade via Stripe portal)
|
|
ApiPlanTier? newTier = null;
|
|
if (subscription.Metadata != null &&
|
|
subscription.Metadata.TryGetValue("planTier", out var tierStr) &&
|
|
Enum.TryParse<ApiPlanTier>(tierStr, out var parsedTier))
|
|
{
|
|
newTier = parsedTier;
|
|
}
|
|
|
|
var newStatus = subscription.Status switch
|
|
{
|
|
"active" => "active",
|
|
"past_due" => "past_due",
|
|
"canceled" => "canceled",
|
|
_ => subscription.Status
|
|
};
|
|
|
|
// Fetch current period end from SubscriptionItem (same pattern as existing code)
|
|
DateTime? periodEnd = null;
|
|
if (subscription.Items?.Data?.Count > 0)
|
|
{
|
|
var subItemService = new SubscriptionItemService();
|
|
var subItem = subItemService.Get(subscription.Items.Data[0].Id);
|
|
periodEnd = subItem.CurrentPeriodEnd;
|
|
}
|
|
|
|
await _userService.UpdateApiSubscriptionStatusAsync(
|
|
subscription.Id,
|
|
newStatus,
|
|
newTier,
|
|
periodEnd);
|
|
|
|
_logger.LogInformation(
|
|
"API subscription updated: subId={SubId} status={Status} tier={Tier}",
|
|
subscription.Id, newStatus, newTier);
|
|
}
|
|
|
|
private async Task ProcessCreditPayment(Session session)
|
|
{
|
|
if (session.Metadata != null &&
|
|
session.Metadata.TryGetValue("user_id", out var userId) &&
|
|
session.Metadata.TryGetValue("credits_amount", out var creditsStr) &&
|
|
int.TryParse(creditsStr, out var credits))
|
|
{
|
|
var success = await _userService.AddCreditsAsync(userId, credits);
|
|
if (success)
|
|
{
|
|
_logger.LogInformation($"✅ Credits added via Stripe: {credits} credits for user {userId}");
|
|
}
|
|
else
|
|
{
|
|
_logger.LogError($"❌ Failed to add credits for user {userId}");
|
|
}
|
|
}
|
|
else
|
|
{
|
|
_logger.LogWarning("⚠️ Payment received but missing metadata (user_id or credits_amount)");
|
|
}
|
|
}
|
|
|
|
private async Task ProcessSubscriptionActivation(string userId, Subscription subscription)
|
|
{
|
|
var service = new SubscriptionItemService();
|
|
var subItem = service.Get(subscription.Items.Data[0].Id);
|
|
|
|
var user = await _userService.GetUserAsync(userId);
|
|
if (user == null) return;
|
|
|
|
if (string.IsNullOrEmpty(user.StripeCustomerId))
|
|
{
|
|
await _userService.UpdateUserStripeCustomerIdAsync(user.Id, subscription.CustomerId);
|
|
}
|
|
|
|
await _userService.ActivatePremiumStatus(userId, subscription.Id, subItem.CurrentPeriodEnd);
|
|
}
|
|
|
|
// Helper methods for legacy support
|
|
public async Task<bool> CancelSubscriptionAsync(string subscriptionId)
|
|
{
|
|
try {
|
|
var service = new SubscriptionService();
|
|
await service.CancelAsync(subscriptionId, new SubscriptionCancelOptions());
|
|
return true;
|
|
} catch { return false; }
|
|
}
|
|
|
|
public async Task DeactivatePremiumStatus(string subscriptionId) => await _userService.DeactivatePremiumStatus(subscriptionId);
|
|
|
|
public async Task<string> GetSubscriptionStatusAsync(string subscriptionId)
|
|
{
|
|
if (string.IsNullOrEmpty(subscriptionId)) return "None";
|
|
try
|
|
{
|
|
var service = new SubscriptionService();
|
|
var subscription = await service.GetAsync(subscriptionId);
|
|
return subscription.Status;
|
|
}
|
|
catch
|
|
{
|
|
return "Unknown";
|
|
}
|
|
}
|
|
|
|
public async Task<(bool success, string message)> CancelAndRefundSubscriptionAsync(string userId)
|
|
{
|
|
// Legacy method - no longer applicable for credit system
|
|
return (false, "Sistema migrado para créditos. Entre em contato com o suporte.");
|
|
}
|
|
}
|
|
} |