fix: ajustar para configuração de plano ficam só no mongondb.
All checks were successful
Deploy QR Rapido / test (push) Successful in 41s
Deploy QR Rapido / build-and-push (push) Successful in 14m6s
Deploy QR Rapido / deploy-staging (push) Has been skipped
Deploy QR Rapido / deploy-production (push) Successful in 1m55s

This commit is contained in:
Ricardo Carneiro 2025-10-19 21:48:45 -03:00
parent 59e04fedc7
commit 2edb4e1196
12 changed files with 834 additions and 203 deletions

View File

@ -29,8 +29,10 @@
"Bash(nc:*)",
"Bash(ssh:*)",
"Read(//mnt/c/vscode/**)",
"Read(//mnt/c/**)"
"Read(//mnt/c/**)",
"Bash(chmod +x /mnt/c/vscode/qrrapido/Scripts/update-plans.sh)",
"Bash(netstat -tln)"
],
"deny": []
}
}
}

View File

@ -0,0 +1,142 @@
using Microsoft.AspNetCore.Mvc;
using QRRapidoApp.Data;
using QRRapidoApp.Models;
using MongoDB.Driver;
namespace QRRapidoApp.Controllers
{
/// <summary>
/// Admin controller - ONLY accessible from localhost for security
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class AdminController : ControllerBase
{
private readonly MongoDbContext _context;
private readonly ILogger<AdminController> _logger;
public AdminController(MongoDbContext context, ILogger<AdminController> logger)
{
_context = context;
_logger = logger;
}
/// <summary>
/// Seed/Update MongoDB Plans collection
/// Only accessible from localhost (127.0.0.1 or ::1)
/// </summary>
[HttpPost("SeedPlans")]
public async Task<IActionResult> SeedPlans([FromBody] List<Plan> plans)
{
// SECURITY: Only allow from localhost
var remoteIp = HttpContext.Connection.RemoteIpAddress;
var isLocalhost = remoteIp != null &&
(remoteIp.ToString() == "127.0.0.1" ||
remoteIp.ToString() == "::1" ||
remoteIp.ToString() == "localhost");
if (!isLocalhost)
{
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
return Forbid("This endpoint is only accessible from localhost");
}
try
{
_logger.LogInformation($"SeedPlans called from localhost - Upserting {plans.Count} plans");
foreach (var plan in plans)
{
// Upsert based on interval (month/year)
var filter = Builders<Plan>.Filter.Eq(p => p.Interval, plan.Interval);
var options = new ReplaceOptions { IsUpsert = true };
await _context.Plans.ReplaceOneAsync(filter, plan, options);
_logger.LogInformation($"Upserted plan: {plan.Interval}");
}
return Ok(new {
success = true,
message = $"{plans.Count} plans seeded successfully",
plans = plans.Select(p => new {
interval = p.Interval,
priceIds = p.PricesByCountry.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.StripePriceId
)
})
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Error seeding plans");
return StatusCode(500, new { success = false, error = ex.Message });
}
}
/// <summary>
/// Get all plans from MongoDB
/// Only accessible from localhost
/// </summary>
[HttpGet("Plans")]
public async Task<IActionResult> GetPlans()
{
// SECURITY: Only allow from localhost
var remoteIp = HttpContext.Connection.RemoteIpAddress;
var isLocalhost = remoteIp != null &&
(remoteIp.ToString() == "127.0.0.1" ||
remoteIp.ToString() == "::1" ||
remoteIp.ToString() == "localhost");
if (!isLocalhost)
{
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
return Forbid("This endpoint is only accessible from localhost");
}
try
{
var plans = await _context.Plans.Find(_ => true).ToListAsync();
return Ok(new { success = true, count = plans.Count, plans });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error retrieving plans");
return StatusCode(500, new { success = false, error = ex.Message });
}
}
/// <summary>
/// Delete all plans from MongoDB
/// Only accessible from localhost
/// </summary>
[HttpDelete("Plans")]
public async Task<IActionResult> DeleteAllPlans()
{
// SECURITY: Only allow from localhost
var remoteIp = HttpContext.Connection.RemoteIpAddress;
var isLocalhost = remoteIp != null &&
(remoteIp.ToString() == "127.0.0.1" ||
remoteIp.ToString() == "::1" ||
remoteIp.ToString() == "localhost");
if (!isLocalhost)
{
_logger.LogWarning($"Unauthorized admin access attempt from {remoteIp}");
return Forbid("This endpoint is only accessible from localhost");
}
try
{
var result = await _context.Plans.DeleteManyAsync(_ => true);
_logger.LogInformation($"Deleted {result.DeletedCount} plans");
return Ok(new { success = true, deletedCount = result.DeletedCount });
}
catch (Exception ex)
{
_logger.LogError(ex, "Error deleting plans");
return StatusCode(500, new { success = false, error = ex.Message });
}
}
}
}

View File

@ -1,172 +1,172 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QRRapidoApp.Services;
using System.Security.Claims;
using System.Threading.Tasks;
using QRRapidoApp.Models.ViewModels;
using System.Linq;
namespace QRRapidoApp.Controllers
{
[Authorize]
public class PagamentoController : Controller
{
private readonly IPlanService _planService;
private readonly AdDisplayService _adDisplayService;
private readonly IUserService _userService;
private readonly StripeService _stripeService;
private readonly ILogger<PagamentoController> _logger;
private readonly List<string> languages = new List<string> { "pt-BR", "es-PY", "es" };
public PagamentoController(IPlanService planService, IUserService userService, StripeService stripeService, ILogger<PagamentoController> logger, AdDisplayService adDisplayService)
{
_planService = planService;
_userService = userService;
_stripeService = stripeService;
_logger = logger;
_adDisplayService = adDisplayService;
}
[HttpGet]
public async Task<IActionResult> SelecaoPlano()
{
var plans = await _planService.GetActivePlansAsync();
var countryCode = GetUserCountryCodeComplete(); // Implement this method based on your needs
_adDisplayService.SetViewBagAds(ViewBag);
var model = new SelecaoPlanoViewModel
{
Plans = plans,
CountryCode = countryCode
};
return View(model);
}
[HttpPost]
public async Task<IActionResult> CreateCheckout(string planId, string lang)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Json(new { success = false, error = "User not authenticated" });
}
var plan = await _planService.GetPlanByIdAsync(planId);
if (plan == null)
{
return Json(new { success = false, error = "Plan not found" });
}
var countryCode = GetUserCountryCode();
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using QRRapidoApp.Services;
using System.Security.Claims;
using System.Threading.Tasks;
using QRRapidoApp.Models.ViewModels;
using System.Linq;
namespace QRRapidoApp.Controllers
{
[Authorize]
public class PagamentoController : Controller
{
private readonly IPlanService _planService;
private readonly AdDisplayService _adDisplayService;
private readonly IUserService _userService;
private readonly StripeService _stripeService;
private readonly ILogger<PagamentoController> _logger;
private readonly List<string> languages = new List<string> { "pt-BR", "es-PY", "es" };
public PagamentoController(IPlanService planService, IUserService userService, StripeService stripeService, ILogger<PagamentoController> logger, AdDisplayService adDisplayService)
{
_planService = planService;
_userService = userService;
_stripeService = stripeService;
_logger = logger;
_adDisplayService = adDisplayService;
}
[HttpGet]
public async Task<IActionResult> SelecaoPlano()
{
var plans = await _planService.GetActivePlansAsync();
var countryCode = GetUserCountryCodeComplete(); // Implement this method based on your needs
_adDisplayService.SetViewBagAds(ViewBag);
var model = new SelecaoPlanoViewModel
{
Plans = plans,
CountryCode = countryCode
};
return View(model);
}
[HttpPost]
public async Task<IActionResult> CreateCheckout(string planId, string lang)
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
{
return Json(new { success = false, error = "User not authenticated" });
}
var plan = await _planService.GetPlanByIdAsync(planId);
if (plan == null)
{
return Json(new { success = false, error = "Plan not found" });
}
var countryCode = GetUserCountryCode();
if (countryCode != lang && languages.Contains(lang))
{
countryCode = lang;
}
var priceId = plan.PricesByCountry.ContainsKey(countryCode)
? plan.PricesByCountry[countryCode].StripePriceId
: plan.StripePriceId;
try
{
var checkoutUrl = await _stripeService.CreateCheckoutSessionAsync(userId, priceId, lang);
return Json(new { success = true, url = checkoutUrl });
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error creating checkout session for user {userId} and plan {planId}");
return Json(new { success = false, error = ex.Message });
}
}
[HttpGet]
public IActionResult Sucesso()
{
_adDisplayService.SetViewBagAds(ViewBag);
ViewBag.SuccessMessage = "Pagamento concluído com sucesso! Bem-vindo ao Premium.";
return View();
}
[HttpGet]
public async Task<IActionResult> Cancelar()
{
_adDisplayService.SetViewBagAds(ViewBag);
ViewBag.CancelMessage = "O pagamento foi cancelado. Você pode tentar novamente a qualquer momento.";
var plans = await _planService.GetActivePlansAsync();
var countryCode = GetUserCountryCode(); // Implement this method based on your needs
_adDisplayService.SetViewBagAds(ViewBag);
var model = new SelecaoPlanoViewModel
{
Plans = plans,
CountryCode = countryCode
};
return View("SelecaoPlano", model);
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> StripeWebhook()
{
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var json = await reader.ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
return BadRequest("Missing Stripe signature");
}
await _stripeService.HandleWebhookAsync(json, signature);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Stripe webhook");
return BadRequest(ex.Message);
}
}
private string GetUserCountryCode()
{
// Check current culture from URL first
}
var priceId = plan.PricesByCountry.ContainsKey(countryCode)
? plan.PricesByCountry[countryCode].StripePriceId
: plan.StripePriceId;
try
{
var checkoutUrl = await _stripeService.CreateCheckoutSessionAsync(userId, priceId, lang);
return Json(new { success = true, url = checkoutUrl });
}
catch (Exception ex)
{
_logger.LogError(ex, $"Error creating checkout session for user {userId} and plan {planId}");
return Json(new { success = false, error = ex.Message });
}
}
[HttpGet]
public IActionResult Sucesso()
{
_adDisplayService.SetViewBagAds(ViewBag);
ViewBag.SuccessMessage = "Pagamento concluído com sucesso! Bem-vindo ao Premium.";
return View();
}
[HttpGet]
public async Task<IActionResult> Cancelar()
{
_adDisplayService.SetViewBagAds(ViewBag);
ViewBag.CancelMessage = "O pagamento foi cancelado. Você pode tentar novamente a qualquer momento.";
var plans = await _planService.GetActivePlansAsync();
var countryCode = GetUserCountryCode(); // Implement this method based on your needs
_adDisplayService.SetViewBagAds(ViewBag);
var model = new SelecaoPlanoViewModel
{
Plans = plans,
CountryCode = countryCode
};
return View("SelecaoPlano", model);
}
[HttpPost]
[AllowAnonymous]
public async Task<IActionResult> StripeWebhook()
{
try
{
using var reader = new StreamReader(HttpContext.Request.Body);
var json = await reader.ReadToEndAsync();
var signature = Request.Headers["Stripe-Signature"].FirstOrDefault();
if (string.IsNullOrEmpty(signature))
{
return BadRequest("Missing Stripe signature");
}
await _stripeService.HandleWebhookAsync(json, signature);
return Ok();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing Stripe webhook");
return BadRequest(ex.Message);
}
}
private string GetUserCountryCode()
{
// Check current culture from URL first
var culture = HttpContext.Request.RouteValues["culture"]?.ToString() ??
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
var countryMap = new Dictionary<string, string>
{
{ "pt-BR", "BR" },
{ "es-PY", "PY" },
{ "es", "PY" }
};
if (!string.IsNullOrEmpty(culture) && countryMap.ContainsKey(culture))
{
return countryMap[culture];
}
// Fallback to Cloudflare header or default
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
}
private string GetUserCountryCodeComplete()
{
// Check current culture from URL first
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
var countryMap = new Dictionary<string, string>
{
{ "pt-BR", "BR" },
{ "es-PY", "PY" },
{ "es", "PY" }
};
if (!string.IsNullOrEmpty(culture) && countryMap.ContainsKey(culture))
{
return countryMap[culture];
}
// Fallback to Cloudflare header or default
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
}
private string GetUserCountryCodeComplete()
{
// Check current culture from URL first
var culture = HttpContext.Request.RouteValues["culture"]?.ToString() ??
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
if (languages.Contains(culture))
{
return culture;
}
// Fallback to Cloudflare header or default
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
}
}
}
HttpContext.Features.Get<Microsoft.AspNetCore.Localization.IRequestCultureFeature>()?.RequestCulture?.Culture?.Name;
if (languages.Contains(culture))
{
return culture;
}
// Fallback to Cloudflare header or default
return HttpContext.Request.Headers["CF-IPCountry"].FirstOrDefault() ?? "BR";
}
}
}

View File

@ -6,7 +6,7 @@
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"applicationUrl": "https://localhost:52428;http://localhost:52429"
"applicationUrl": "https://0.0.0.0:52428;http://0.0.0.0:52429"
}
}
}

272
Scripts/README-ADMIN-API.md Normal file
View File

@ -0,0 +1,272 @@
# 🔧 Admin API - Gerenciamento de Planos
Este diretório contém ferramentas para gerenciar os planos do Stripe no MongoDB via API local.
## 🔒 Segurança
**IMPORTANTE**: O endpoint `/api/Admin/*` **só funciona quando acessado de localhost** (127.0.0.1). Qualquer acesso externo retornará `403 Forbidden`.
---
## 📋 Arquivos
- **`plans.json`** - Configuração dos planos com Price IDs do Stripe
- **`update-plans.sh`** - Script bash para atualizar planos via curl
- **`seed-mongodb-plans.js`** - Script MongoDB direto (método alternativo)
---
## 🚀 Método 1: API Endpoint (Recomendado)
### Vantagens
- ✅ Não precisa de credenciais do MongoDB
- ✅ Validação automática
- ✅ Logs no sistema
- ✅ Funciona com app rodando
### Como Usar
#### 1⃣ Edite o `plans.json` com seus Price IDs
```json
{
"pricesByCountry": {
"BR": {
"stripePriceId": "price_SEU_ID_AQUI"
}
}
}
```
#### 2⃣ Inicie a aplicação
```bash
dotnet run
# ou
dotnet watch run
```
#### 3⃣ Execute o script de atualização
```bash
# Linux/Mac/WSL
cd Scripts
./update-plans.sh
# Especificar porta diferente
./update-plans.sh 5000
```
#### 4⃣ Ou use curl diretamente
```bash
# Atualizar planos
curl -k -X POST \
-H "Content-Type: application/json" \
-d @Scripts/plans.json \
https://localhost:52428/api/Admin/SeedPlans
# Listar planos
curl -k https://localhost:52428/api/Admin/Plans
# Deletar todos os planos
curl -k -X DELETE https://localhost:52428/api/Admin/Plans
```
---
## 🗄️ Método 2: Script MongoDB Direto
### Vantagens
- ✅ Funciona sem a app rodando
- ✅ Acesso direto ao MongoDB
### Desvantagens
- ❌ Precisa de credenciais do MongoDB
- ❌ Sem validação automática
### Como Usar
```bash
# Localhost
mongosh "mongodb://localhost:27017/QrRapido" < Scripts/seed-mongodb-plans.js
# Produção
mongosh "mongodb://user:pass@host:27017/QrRapido?replicaSet=rs0&authSource=admin" < Scripts/seed-mongodb-plans.js
```
---
## 📝 Endpoints Disponíveis
### POST `/api/Admin/SeedPlans`
Cria ou atualiza planos no MongoDB
**Request Body:**
```json
[
{
"interval": "month",
"stripePriceId": "price_...",
"pricesByCountry": { ... }
}
]
```
**Response:**
```json
{
"success": true,
"message": "2 plans seeded successfully",
"plans": [ ... ]
}
```
---
### GET `/api/Admin/Plans`
Lista todos os planos do MongoDB
**Response:**
```json
{
"success": true,
"count": 2,
"plans": [ ... ]
}
```
---
### DELETE `/api/Admin/Plans`
Remove todos os planos do MongoDB
**Response:**
```json
{
"success": true,
"deletedCount": 2
}
```
---
## 🛡️ Proteção de Segurança
O código verifica o IP de origem:
```csharp
var remoteIp = HttpContext.Connection.RemoteIpAddress;
var isLocalhost = remoteIp != null &&
(remoteIp.ToString() == "127.0.0.1" ||
remoteIp.ToString() == "::1" ||
remoteIp.ToString() == "localhost");
if (!isLocalhost) {
return Forbid("This endpoint is only accessible from localhost");
}
```
**Tentativas de acesso externo são logadas:**
```
[Warning] Unauthorized admin access attempt from 192.168.1.100
```
---
## 🧪 Testando
```bash
# 1. Listar planos atuais
curl -k https://localhost:52428/api/Admin/Plans | jq '.'
# 2. Deletar planos
curl -k -X DELETE https://localhost:52428/api/Admin/Plans
# 3. Criar novos planos
curl -k -X POST \
-H "Content-Type: application/json" \
-d @Scripts/plans.json \
https://localhost:52428/api/Admin/SeedPlans | jq '.'
# 4. Verificar se foram criados
curl -k https://localhost:52428/api/Admin/Plans | jq '.plans[] | {interval, priceIds: .pricesByCountry}'
```
---
## ⚠️ Troubleshooting
### Erro: "Connection refused"
- ✅ Certifique-se que a app está rodando: `dotnet run`
### Erro: "Forbidden" mesmo em localhost
- ✅ Verifique se está usando `https://localhost` (não `http://`)
- ✅ Verifique se está usando `localhost` ou `127.0.0.1` (não o IP da máquina)
### Erro: "SSL certificate problem"
- ✅ Use o flag `-k` no curl para aceitar certificados auto-assinados
---
## 📊 Estrutura do plans.json
```json
[
{
"name": {
"pt-BR": "Nome em português",
"es-PY": "Nombre en español",
"en": "Name in english"
},
"description": { ... },
"features": { ... },
"interval": "month" ou "year",
"stripePriceId": "price_default",
"pricesByCountry": {
"BR": {
"amount": 9.90,
"currency": "BRL",
"stripePriceId": "price_BR_ID"
},
"PY": {
"amount": 35000,
"currency": "PYG",
"stripePriceId": "price_PY_ID"
}
},
"isActive": true,
"displayOrder": 1,
"badge": { ... } // Opcional
}
]
```
---
## 🎯 Fluxo Recomendado
1. **Desenvolvimento (localhost)**:
```bash
# Editar plans.json com Price IDs de teste
./update-plans.sh
```
2. **Staging**:
```bash
# SSH no servidor staging
ssh user@staging-server
cd /app/qrrapido
./Scripts/update-plans.sh 5000
```
3. **Produção**:
```bash
# Opção 1: Via API (se tiver acesso SSH)
ssh user@prod-server
cd /app/qrrapido
./Scripts/update-plans.sh 5001
# Opção 2: Via MongoDB direto
mongosh "connection_string" < Scripts/seed-mongodb-plans.js
```

134
Scripts/plans.json Normal file
View File

@ -0,0 +1,134 @@
[
{
"name": {
"pt-BR": "Premium Mensal",
"es-PY": "Premium Mensual",
"en": "Premium Monthly"
},
"description": {
"pt-BR": "Acesso ilimitado a todos os recursos premium - Cobrança mensal",
"es-PY": "Acceso ilimitado a todas las funciones premium - Facturación mensual",
"en": "Unlimited access to all premium features - Monthly billing"
},
"features": {
"pt-BR": [
"QR Codes ilimitados",
"Sem anúncios",
"Customização avançada",
"Suporte para logos",
"Histórico e downloads",
"Suporte prioritário",
"Cancele a qualquer momento"
],
"es-PY": [
"Códigos QR ilimitados",
"Sin anuncios",
"Personalización avanzada",
"Soporte para logos",
"Historial y descargas",
"Soporte prioritario",
"Cancela cuando quieras"
],
"en": [
"Unlimited QR Codes",
"No ads",
"Advanced customization",
"Logo support",
"History and downloads",
"Priority support",
"Cancel anytime"
]
},
"interval": "month",
"stripePriceId": "price_1SJwebB6bFjHQirAloEqXWd6",
"pricesByCountry": {
"BR": {
"amount": 9.90,
"currency": "BRL",
"stripePriceId": "price_1SJwebB6bFjHQirAloEqXWd6"
},
"PY": {
"amount": 35000,
"currency": "PYG",
"stripePriceId": "price_1SK4Y0B6bFjHQirAaxNHxILi"
},
"US": {
"amount": 1.99,
"currency": "USD",
"stripePriceId": "price_XXXXX_monthly_us"
}
},
"isActive": true,
"displayOrder": 1
},
{
"name": {
"pt-BR": "Premium Anual",
"es-PY": "Premium Anual",
"en": "Premium Yearly"
},
"description": {
"pt-BR": "Acesso ilimitado a todos os recursos premium - Economia de 20% no plano anual!",
"es-PY": "Acceso ilimitado a todas las funciones premium - ¡Ahorra 20% con el plan anual!",
"en": "Unlimited access to all premium features - Save 20% with yearly billing!"
},
"features": {
"pt-BR": [
"QR Codes ilimitados",
"Sem anúncios",
"Customização avançada",
"Suporte para logos",
"Histórico e downloads",
"Suporte prioritário",
"💰 Economia de 20%",
"Cobrança anual única"
],
"es-PY": [
"Códigos QR ilimitados",
"Sin anuncios",
"Personalización avanzada",
"Soporte para logos",
"Historial y descargas",
"Soporte prioritario",
"💰 Ahorro del 20%",
"Facturación anual única"
],
"en": [
"Unlimited QR Codes",
"No ads",
"Advanced customization",
"Logo support",
"History and downloads",
"Priority support",
"💰 Save 20%",
"Billed annually"
]
},
"interval": "year",
"stripePriceId": "price_1SK4X7B6bFjHQirAdMtviPw4",
"pricesByCountry": {
"BR": {
"amount": 95.04,
"currency": "BRL",
"stripePriceId": "price_1SK4X7B6bFjHQirAdMtviPw4"
},
"PY": {
"amount": 336000,
"currency": "PYG",
"stripePriceId": "price_1SK4Y0B6bFjHQirAaxNHxILi"
},
"US": {
"amount": 19.10,
"currency": "USD",
"stripePriceId": "price_XXXXX_yearly_us"
}
},
"isActive": true,
"displayOrder": 2,
"badge": {
"pt-BR": "MELHOR VALOR",
"es-PY": "MEJOR VALOR",
"en": "BEST VALUE"
}
}
]

View File

@ -51,22 +51,22 @@ db.Plans.insertOne({
]
},
interval: "month",
stripePriceId: "price_XXXXX_monthly_us", // Default price (USA) - UPDATE from appsettings.json > Stripe:Plans:Monthly:US
stripePriceId: "price_1SJwebB6bFjHQirAloEqXWd6", // Default price (BR)
pricesByCountry: {
"BR": {
amount: 9.90,
currency: "BRL",
stripePriceId: "price_XXXXX_monthly_br" // UPDATE from appsettings.json > Stripe:Plans:Monthly:BR
stripePriceId: "price_1SJwebB6bFjHQirAloEqXWd6"
},
"PY": {
amount: 35000,
currency: "PYG",
stripePriceId: "price_XXXXX_monthly_py" // UPDATE from appsettings.json > Stripe:Plans:Monthly:PY
stripePriceId: "price_1SK4Y0B6bFjHQirAaxNHxILi"
},
"US": {
amount: 1.99,
currency: "USD",
stripePriceId: "price_XXXXX_monthly_us" // UPDATE from appsettings.json > Stripe:Plans:Monthly:US
stripePriceId: "price_XXXXX_monthly_us" // TODO: Update with real Stripe Price ID
}
},
isActive: true,
@ -120,22 +120,22 @@ db.Plans.insertOne({
]
},
interval: "year",
stripePriceId: "price_XXXXX_yearly_us", // Default price (USA) - UPDATE from appsettings.json > Stripe:Plans:Yearly:US
stripePriceId: "price_1SK4X7B6bFjHQirAdMtviPw4", // Default price (BR)
pricesByCountry: {
"BR": {
amount: 95.04, // 9.90 * 12 * 0.80 = Economia de 20%
currency: "BRL",
stripePriceId: "price_XXXXX_yearly_br" // UPDATE from appsettings.json > Stripe:Plans:Yearly:BR
stripePriceId: "price_1SK4X7B6bFjHQirAdMtviPw4"
},
"PY": {
amount: 336000, // 35000 * 12 * 0.80 = Economia de 20%
currency: "PYG",
stripePriceId: "price_XXXXX_yearly_py" // UPDATE from appsettings.json > Stripe:Plans:Yearly:PY
stripePriceId: "price_1SK4Y0B6bFjHQirAaxNHxILi"
},
"US": {
amount: 19.10, // 1.99 * 12 * 0.80 = Economia de 20%
currency: "USD",
stripePriceId: "price_XXXXX_yearly_us" // UPDATE from appsettings.json > Stripe:Plans:Yearly:US
stripePriceId: "price_XXXXX_yearly_us" // TODO: Update with real Stripe Price ID
}
},
isActive: true,

63
Scripts/update-plans.sh Normal file
View File

@ -0,0 +1,63 @@
#!/bin/bash
# Script to update MongoDB Plans via localhost-only API endpoint
# Usage: ./update-plans.sh [port]
PORT=${1:-52428}
API_URL="https://localhost:$PORT/api/Admin/SeedPlans"
PLANS_FILE="$(dirname "$0")/plans.json"
echo "🔧 QR Rapido - Update Plans Script"
echo "=================================="
echo ""
echo "📋 Plans file: $PLANS_FILE"
echo "🌐 API URL: $API_URL"
echo ""
# Check if plans.json exists
if [ ! -f "$PLANS_FILE" ]; then
echo "❌ Error: plans.json not found at $PLANS_FILE"
echo ""
echo "Please create plans.json with your Stripe Price IDs"
exit 1
fi
# Check if the app is running
echo "🔍 Checking if app is running on port $PORT..."
if ! curl -k -s "https://localhost:$PORT/health" > /dev/null 2>&1; then
echo "❌ Error: App not responding on https://localhost:$PORT"
echo ""
echo "Please start the app first:"
echo " dotnet run"
exit 1
fi
echo "✅ App is running"
echo ""
# Send request
echo "📤 Sending plans to API..."
response=$(curl -k -s -w "\n%{http_code}" \
-X POST \
-H "Content-Type: application/json" \
-d @"$PLANS_FILE" \
"$API_URL")
http_code=$(echo "$response" | tail -n1)
body=$(echo "$response" | sed '$d')
echo ""
if [ "$http_code" -eq 200 ]; then
echo "✅ Success! Plans updated in MongoDB"
echo ""
echo "$body" | jq '.' 2>/dev/null || echo "$body"
else
echo "❌ Error! HTTP Status: $http_code"
echo ""
echo "$body"
exit 1
fi
echo ""
echo "🎉 Done!"

View File

@ -33,6 +33,24 @@ namespace QRRapidoApp.Services
}
var customerId = user.StripeCustomerId;
var customerService = new CustomerService();
// Verify if customer exists in Stripe, create new if not
if (!string.IsNullOrEmpty(customerId))
{
try
{
// Try to retrieve the customer to verify it exists
await customerService.GetAsync(customerId);
_logger.LogInformation($"Using existing Stripe customer {customerId} for user {userId}");
}
catch (StripeException ex) when (ex.StripeError?.Code == "resource_missing")
{
_logger.LogWarning($"Stripe customer {customerId} not found, creating new one for user {userId}");
customerId = null; // Force creation of new customer
}
}
if (string.IsNullOrEmpty(customerId))
{
var customerOptions = new CustomerCreateOptions
@ -41,10 +59,10 @@ namespace QRRapidoApp.Services
Name = user.Name,
Metadata = new Dictionary<string, string> { { "app_user_id", user.Id } }
};
var customerService = new CustomerService();
var customer = await customerService.CreateAsync(customerOptions);
customerId = customer.Id;
await _userService.UpdateUserStripeCustomerIdAsync(userId, customerId);
_logger.LogInformation($"Created new Stripe customer {customerId} for user {userId}");
}
var options = new SessionCreateOptions

View File

@ -145,15 +145,9 @@
else if (User.Identity.IsAuthenticated)
{
var isPremium = await AdService.HasValidPremiumSubscription(userId);
if (isPremium)
{
<div class="alert alert-success ad-free-notice mb-3">
<i class="fas fa-crown text-warning"></i>
<span><strong>@Localizer["PremiumUserNoAds"]</strong></span>
</div>
}
else
if (!isPremium)
{
@* Only show upgrade notice for non-premium users *@
<div class="alert alert-info upgrade-notice mb-3">
<i class="fas fa-star text-warning"></i>
<span><strong>@Localizer["UpgradePremiumRemoveAds"]</strong></span>
@ -162,4 +156,5 @@ else if (User.Identity.IsAuthenticated)
</a>
</div>
}
@* Premium users: render nothing (100% invisible) *@
}

View File

@ -38,6 +38,24 @@
"GrowthRateWarningMBPerHour": 200,
"IncludeCollectionStats": true
},
"Stripe": {
"PublishableKey": "pk_test_51Rs42SB6bFjHQirAJ6kzbFCbBuAobyNbmlgpULFsInl8KRzlpclUoqOZICqvp2S51kquw3Bc04CPO9bNUEgDLDgd00XbAHT7Fh",
"SecretKey": "sk_test_51Rs42SB6bFjHQirANOUg8jgzPALbNdVWULSVRMycFRBTzE0QUGA6pkpoQaTVsCIoV3XGRgoJ7E3CA6Y67vWlM76q00QBoKW0aH",
"WebhookSecret": "whsec_667402ff1d753b181f626636d556975f2749b5fec4d1231d44f040b057fb3009",
"ProductId": "prod_TGTbombliOUYmQ",
"Plans": {
"Monthly": {
"BR": "price_1SJwebB6bFjHQirAloEqXWd6",
"PY": "price_1SK4Y0B6bFjHQirAaxNHxILi",
"US": "price_XXXXX_monthly_us"
},
"Yearly": {
"BR": "price_1SK4X7B6bFjHQirAdMtviPw4",
"PY": "price_1SK4Y0B6bFjHQirAaxNHxILi",
"US": "price_XXXXX_yearly_us"
}
}
},
"HealthChecks": {
"MongoDB": {
"TimeoutSeconds": 10,

View File

@ -21,22 +21,9 @@
}
},
"Stripe": {
"PublishableKey": "pk_test_51Rs42tBeR5IFYUsBooapyDwQTgh6CFuKbya5R3MVDTrdOUKmgiHQYipU0pgOdG5iKogH77RUYIKBJzbCt5BghUOY00xitV5KiN",
"SecretKey": "sk_test_51Rs42tBeR5IFYUsBtycRlJJcdwgoMbh8MfQIKIGelBPTQFwDcOn2iCCbw5uG6hnqlpgNAUuFgWRAUUMA8qkABKun00EIx4odDF",
"WebhookSecret": "whsec_2e828803ceb48e7865458b0cf332b68535fdff8753d26d69b1c88ea55cb0e482",
"ProductId": "prod_SnfQTxwE3i8r5L",
"Plans": {
"Monthly": {
"BR": "price_1Rs45OBeR5IFYUsBfsnOpOiv",
"PY": "price_XXXXX_monthly_py",
"US": "price_XXXXX_monthly_us"
},
"Yearly": {
"BR": "price_1Rs4AyBeR5IFYUsB8kRSNUIM",
"PY": "price_XXXXX_yearly_py",
"US": "price_XXXXX_yearly_us"
}
}
"PublishableKey": "pk_test_51Rs42SB6bFjHQirAJ6kzbFCbBuAobyNbmlgpULFsInl8KRzlpclUoqOZICqvp2S51kquw3Bc04CPO9bNUEgDLDgd00XbAHT7Fh",
"SecretKey": "sk_test_51Rs42SB6bFjHQirANOUg8jgzPALbNdVWULSVRMycFRBTzE0QUGA6pkpoQaTVsCIoV3XGRgoJ7E3CA6Y67vWlM76q00QBoKW0aH",
"WebhookSecret": "whsec_667402ff1d753b181f626636d556975f2749b5fec4d1231d44f040b057fb3009"
},
"AdSense": {
"ClientId": "ca-pub-3475956393038764",