Merge pull request #227 from Moonlight-Panel/StripeIntegration
Implemented a basic stripe integration
This commit is contained in:
@@ -38,9 +38,7 @@ public class ConfigV1
|
||||
[JsonProperty("Mail")] public MailData Mail { get; set; } = new();
|
||||
|
||||
[JsonProperty("Cleanup")] public CleanupData Cleanup { get; set; } = new();
|
||||
|
||||
[JsonProperty("Subscriptions")] public SubscriptionsData Subscriptions { get; set; } = new();
|
||||
|
||||
|
||||
[JsonProperty("DiscordNotifications")] public DiscordNotificationsData DiscordNotifications { get; set; } = new();
|
||||
|
||||
[JsonProperty("Statistics")] public StatisticsData Statistics { get; set; } = new();
|
||||
@@ -50,6 +48,15 @@ public class ConfigV1
|
||||
[JsonProperty("SmartDeploy")] public SmartDeployData SmartDeploy { get; set; } = new();
|
||||
|
||||
[JsonProperty("Sentry")] public SentryData Sentry { get; set; } = new();
|
||||
|
||||
[JsonProperty("Stripe")] public StripeData Stripe { get; set; } = new();
|
||||
}
|
||||
|
||||
public class StripeData
|
||||
{
|
||||
[JsonProperty("ApiKey")]
|
||||
[Description("Put here your stripe api key if you add subscriptions. Currently the only billing option is stripe which is enabled by default and cannot be turned off. This feature is still experimental")]
|
||||
public string ApiKey { get; set; } = "";
|
||||
}
|
||||
|
||||
public class AuthData
|
||||
@@ -318,11 +325,6 @@ public class ConfigV1
|
||||
[JsonProperty("Wait")] public long Wait { get; set; } = 15;
|
||||
}
|
||||
|
||||
public class SubscriptionsData
|
||||
{
|
||||
[JsonProperty("SellPass")] public SellPassData SellPass { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SellPassData
|
||||
{
|
||||
[JsonProperty("Enable")] public bool Enable { get; set; } = false;
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
namespace Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Models.Misc;
|
||||
|
||||
namespace Moonlight.App.Database.Entities;
|
||||
|
||||
public class Subscription
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public Currency Currency { get; set; } = Currency.USD;
|
||||
public double Price { get; set; }
|
||||
public string StripeProductId { get; set; } = "";
|
||||
public string StripePriceId { get; set; } = "";
|
||||
public string LimitsJson { get; set; } = "";
|
||||
public int Duration { get; set; } = 30;
|
||||
}
|
||||
@@ -51,8 +51,8 @@ public class User
|
||||
// Subscriptions
|
||||
|
||||
public Subscription? CurrentSubscription { get; set; } = null;
|
||||
public DateTime SubscriptionSince { get; set; } = DateTime.Now;
|
||||
public int SubscriptionDuration { get; set; }
|
||||
public DateTime SubscriptionSince { get; set; } = DateTime.UtcNow;
|
||||
public DateTime SubscriptionExpires { get; set; } = DateTime.UtcNow;
|
||||
|
||||
// Ip logs
|
||||
public string RegisterIp { get; set; } = "";
|
||||
|
||||
1105
Moonlight/App/Database/Migrations/20230705171914_AddedStripeIntegration.Designer.cs
generated
Normal file
1105
Moonlight/App/Database/Migrations/20230705171914_AddedStripeIntegration.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Moonlight.App.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddedStripeIntegration : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubscriptionDuration",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "SubscriptionExpires",
|
||||
table: "Users",
|
||||
type: "datetime(6)",
|
||||
nullable: false,
|
||||
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Currency",
|
||||
table: "Subscriptions",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "Duration",
|
||||
table: "Subscriptions",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Price",
|
||||
table: "Subscriptions",
|
||||
type: "double",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripePriceId",
|
||||
table: "Subscriptions",
|
||||
type: "longtext",
|
||||
nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "StripeProductId",
|
||||
table: "Subscriptions",
|
||||
type: "longtext",
|
||||
nullable: false)
|
||||
.Annotation("MySql:CharSet", "utf8mb4");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SubscriptionExpires",
|
||||
table: "Users");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Currency",
|
||||
table: "Subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Duration",
|
||||
table: "Subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Price",
|
||||
table: "Subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripePriceId",
|
||||
table: "Subscriptions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "StripeProductId",
|
||||
table: "Subscriptions");
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "SubscriptionDuration",
|
||||
table: "Users",
|
||||
type: "int",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -663,10 +663,16 @@ namespace Moonlight.App.Database.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<int>("Currency")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<int>("Duration")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("LimitsJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
@@ -675,6 +681,17 @@ namespace Moonlight.App.Database.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<double>("Price")
|
||||
.HasColumnType("double");
|
||||
|
||||
b.Property<string>("StripePriceId")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.Property<string>("StripeProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("longtext");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Subscriptions");
|
||||
@@ -802,8 +819,8 @@ namespace Moonlight.App.Database.Migrations
|
||||
b.Property<bool>("StreamerMode")
|
||||
.HasColumnType("tinyint(1)");
|
||||
|
||||
b.Property<int>("SubscriptionDuration")
|
||||
.HasColumnType("int");
|
||||
b.Property<DateTime>("SubscriptionExpires")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
b.Property<DateTime>("SubscriptionSince")
|
||||
.HasColumnType("datetime(6)");
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Moonlight.App.Services;
|
||||
using Moonlight.App.Services.Sessions;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
|
||||
namespace Moonlight.App.Http.Controllers.Api.Moonlight;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/moonlight/billing")]
|
||||
public class BillingController : Controller
|
||||
{
|
||||
private readonly IdentityService IdentityService;
|
||||
private readonly BillingService BillingService;
|
||||
|
||||
public BillingController(
|
||||
IdentityService identityService,
|
||||
BillingService billingService)
|
||||
{
|
||||
IdentityService = identityService;
|
||||
BillingService = billingService;
|
||||
}
|
||||
|
||||
[HttpGet("cancel")]
|
||||
public async Task<ActionResult> Cancel()
|
||||
{
|
||||
var user = await IdentityService.Get();
|
||||
|
||||
if (user == null)
|
||||
return Redirect("/login");
|
||||
|
||||
return Redirect("/profile/subscriptions/close");
|
||||
}
|
||||
|
||||
[HttpGet("success")]
|
||||
public async Task<ActionResult> Success()
|
||||
{
|
||||
var user = await IdentityService.Get();
|
||||
|
||||
if (user == null)
|
||||
return Redirect("/login");
|
||||
|
||||
await BillingService.CompleteCheckout(user);
|
||||
|
||||
return Redirect("/profile/subscriptions/close");
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using Moonlight.App.Models.Misc;
|
||||
|
||||
namespace Moonlight.App.Models.Forms;
|
||||
|
||||
@@ -10,4 +11,8 @@ public class SubscriptionDataModel
|
||||
|
||||
[Required(ErrorMessage = "You need to enter a description")]
|
||||
public string Description { get; set; } = "";
|
||||
|
||||
public double Price { get; set; } = 0;
|
||||
public Currency Currency { get; set; } = Currency.USD;
|
||||
public int Duration { get; set; } = 30;
|
||||
}
|
||||
7
Moonlight/App/Models/Misc/Currency.cs
Normal file
7
Moonlight/App/Models/Misc/Currency.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Moonlight.App.Models.Misc;
|
||||
|
||||
public enum Currency
|
||||
{
|
||||
USD = 1,
|
||||
EUR = 2
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class DiscordNotificationService
|
||||
Event.On<SupportChatMessage>("supportChat.message", this, OnSupportChatMessage);
|
||||
Event.On<User>("supportChat.close", this, OnSupportChatClose);
|
||||
Event.On<User>("user.rating", this, OnUserRated);
|
||||
Event.On<User>("billing.completed", this, OnBillingCompleted);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -42,6 +43,21 @@ public class DiscordNotificationService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnBillingCompleted(User user)
|
||||
{
|
||||
await SendNotification("", builder =>
|
||||
{
|
||||
builder.Color = Color.Red;
|
||||
builder.Title = "New payment received";
|
||||
|
||||
builder.AddField("User", user.Email);
|
||||
builder.AddField("Firstname", user.FirstName);
|
||||
builder.AddField("Lastname", user.LastName);
|
||||
builder.AddField("Amount", user.CurrentSubscription!.Price);
|
||||
builder.AddField("Currency", user.CurrentSubscription!.Currency);
|
||||
});
|
||||
}
|
||||
|
||||
private async Task OnUserRated(User user)
|
||||
{
|
||||
await SendNotification("", builder =>
|
||||
|
||||
127
Moonlight/App/Services/BillingService.cs
Normal file
127
Moonlight/App/Services/BillingService.cs
Normal file
@@ -0,0 +1,127 @@
|
||||
using System.Globalization;
|
||||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Events;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services.Mail;
|
||||
using Moonlight.App.Services.Sessions;
|
||||
using Stripe.Checkout;
|
||||
using Subscription = Moonlight.App.Database.Entities.Subscription;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
public class BillingService
|
||||
{
|
||||
private readonly ConfigService ConfigService;
|
||||
private readonly SubscriptionService SubscriptionService;
|
||||
private readonly Repository<Subscription> SubscriptionRepository;
|
||||
private readonly SessionServerService SessionServerService;
|
||||
private readonly EventSystem Event;
|
||||
private readonly MailService MailService;
|
||||
|
||||
public BillingService(
|
||||
ConfigService configService,
|
||||
SubscriptionService subscriptionService,
|
||||
Repository<Subscription> subscriptionRepository,
|
||||
EventSystem eventSystem,
|
||||
SessionServerService sessionServerService,
|
||||
MailService mailService)
|
||||
{
|
||||
ConfigService = configService;
|
||||
SubscriptionService = subscriptionService;
|
||||
SubscriptionRepository = subscriptionRepository;
|
||||
Event = eventSystem;
|
||||
SessionServerService = sessionServerService;
|
||||
MailService = mailService;
|
||||
}
|
||||
|
||||
public async Task<string> StartCheckout(User user, Subscription subscription)
|
||||
{
|
||||
var appUrl = ConfigService.Get().Moonlight.AppUrl;
|
||||
var controllerUrl = appUrl + "/api/moonlight/billing";
|
||||
|
||||
var options = new SessionCreateOptions()
|
||||
{
|
||||
LineItems = new()
|
||||
{
|
||||
new()
|
||||
{
|
||||
Price = subscription.StripePriceId,
|
||||
Quantity = 1
|
||||
}
|
||||
},
|
||||
Mode = "payment",
|
||||
SuccessUrl = controllerUrl + "/success",
|
||||
CancelUrl = controllerUrl + "/cancel",
|
||||
AutomaticTax = new SessionAutomaticTaxOptions()
|
||||
{
|
||||
Enabled = true
|
||||
},
|
||||
CustomerEmail = user.Email.ToLower(),
|
||||
Metadata = new()
|
||||
{
|
||||
{
|
||||
"productId",
|
||||
subscription.StripeProductId
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var service = new SessionService();
|
||||
|
||||
var session = await service.CreateAsync(options);
|
||||
|
||||
return session.Url;
|
||||
}
|
||||
public async Task CompleteCheckout(User user)
|
||||
{
|
||||
var sessionService = new SessionService();
|
||||
|
||||
var sessionsPerUser = await sessionService.ListAsync(new SessionListOptions()
|
||||
{
|
||||
CustomerDetails = new()
|
||||
{
|
||||
Email = user.Email
|
||||
}
|
||||
});
|
||||
|
||||
var latestCompletedSession = sessionsPerUser
|
||||
.Where(x => x.Status == "complete")
|
||||
.Where(x => x.PaymentStatus == "paid")
|
||||
.MaxBy(x => x.Created);
|
||||
|
||||
if (latestCompletedSession == null)
|
||||
throw new DisplayException("No completed session found");
|
||||
|
||||
var productId = latestCompletedSession.Metadata["productId"];
|
||||
|
||||
var subscription = SubscriptionRepository
|
||||
.Get()
|
||||
.FirstOrDefault(x => x.StripeProductId == productId);
|
||||
|
||||
if (subscription == null)
|
||||
throw new DisplayException("No subscription for this product found");
|
||||
|
||||
// if (await SubscriptionService.GetActiveSubscription(user) != null)
|
||||
// {
|
||||
// return;
|
||||
// }
|
||||
|
||||
await SubscriptionService.SetActiveSubscription(user, subscription);
|
||||
|
||||
await MailService.SendMail(user, "checkoutComplete", values =>
|
||||
{
|
||||
values.Add("SubscriptionName", subscription.Name);
|
||||
values.Add("SubscriptionPrice", subscription.Price
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
values.Add("SubscriptionCurrency", subscription.Currency
|
||||
.ToString());
|
||||
values.Add("SubscriptionDuration", subscription.Duration
|
||||
.ToString(CultureInfo.InvariantCulture));
|
||||
});
|
||||
|
||||
await Event.Emit("billing.completed", user);
|
||||
|
||||
await SessionServerService.ReloadUserSessions(user);
|
||||
}
|
||||
}
|
||||
18
Moonlight/App/Services/Interop/PopupService.cs
Normal file
18
Moonlight/App/Services/Interop/PopupService.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace Moonlight.App.Services.Interop;
|
||||
|
||||
public class PopupService
|
||||
{
|
||||
private readonly IJSRuntime JsRuntime;
|
||||
|
||||
public PopupService(IJSRuntime jsRuntime)
|
||||
{
|
||||
JsRuntime = jsRuntime;
|
||||
}
|
||||
|
||||
public async Task ShowCentered(string url, string title, int width = 500, int height = 500)
|
||||
{
|
||||
await JsRuntime.InvokeVoidAsync("moonlight.popup.showCentered", url, title, width, height);
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Models.Misc;
|
||||
using Moonlight.App.Repositories;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
public class SubscriptionAdminService
|
||||
{
|
||||
private readonly SubscriptionRepository SubscriptionRepository;
|
||||
private readonly OneTimeJwtService OneTimeJwtService;
|
||||
|
||||
public SubscriptionAdminService(OneTimeJwtService oneTimeJwtService, SubscriptionRepository subscriptionRepository)
|
||||
{
|
||||
OneTimeJwtService = oneTimeJwtService;
|
||||
SubscriptionRepository = subscriptionRepository;
|
||||
}
|
||||
|
||||
public Task<SubscriptionLimit[]> GetLimits(Subscription subscription)
|
||||
{
|
||||
return Task.FromResult(
|
||||
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson)
|
||||
?? Array.Empty<SubscriptionLimit>()
|
||||
);
|
||||
}
|
||||
|
||||
public Task SaveLimits(Subscription subscription, SubscriptionLimit[] limits)
|
||||
{
|
||||
subscription.LimitsJson = JsonConvert.SerializeObject(limits);
|
||||
SubscriptionRepository.Update(subscription);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<string> GenerateCode(Subscription subscription, int duration)
|
||||
{
|
||||
return Task.FromResult(
|
||||
OneTimeJwtService.Generate(data =>
|
||||
{
|
||||
data.Add("subscription", subscription.Id.ToString());
|
||||
data.Add("duration", duration.ToString());
|
||||
}, TimeSpan.FromDays(10324))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,151 +1,200 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moonlight.App.Database.Entities;
|
||||
using Moonlight.App.Exceptions;
|
||||
using Moonlight.App.Helpers;
|
||||
using Moonlight.App.Models.Misc;
|
||||
using Moonlight.App.Repositories;
|
||||
using Moonlight.App.Services.Sessions;
|
||||
using Newtonsoft.Json;
|
||||
using Stripe;
|
||||
using File = System.IO.File;
|
||||
using Subscription = Moonlight.App.Database.Entities.Subscription;
|
||||
|
||||
namespace Moonlight.App.Services;
|
||||
|
||||
public class SubscriptionService
|
||||
{
|
||||
private readonly SubscriptionRepository SubscriptionRepository;
|
||||
private readonly OneTimeJwtService OneTimeJwtService;
|
||||
private readonly IdentityService IdentityService;
|
||||
private readonly UserRepository UserRepository;
|
||||
private readonly Repository<Subscription> SubscriptionRepository;
|
||||
private readonly Repository<User> UserRepository;
|
||||
|
||||
public SubscriptionService(
|
||||
SubscriptionRepository subscriptionRepository,
|
||||
OneTimeJwtService oneTimeJwtService,
|
||||
IdentityService identityService,
|
||||
UserRepository userRepository
|
||||
)
|
||||
Repository<Subscription> subscriptionRepository,
|
||||
Repository<User> userRepository)
|
||||
{
|
||||
SubscriptionRepository = subscriptionRepository;
|
||||
OneTimeJwtService = oneTimeJwtService;
|
||||
IdentityService = identityService;
|
||||
UserRepository = userRepository;
|
||||
}
|
||||
|
||||
public async Task<Subscription?> GetCurrent()
|
||||
public async Task<Subscription> Create(string name, string description, Currency currency, double price, int duration)
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
|
||||
if (user == null || user.CurrentSubscription == null)
|
||||
return null;
|
||||
|
||||
var subscriptionEnd = user.SubscriptionSince.ToUniversalTime().AddDays(user.SubscriptionDuration);
|
||||
|
||||
if (subscriptionEnd > DateTime.UtcNow)
|
||||
var optionsProduct = new ProductCreateOptions
|
||||
{
|
||||
return user.CurrentSubscription;
|
||||
}
|
||||
Name = name,
|
||||
Description = description,
|
||||
DefaultPriceData = new()
|
||||
{
|
||||
UnitAmount = (long)(price * 100),
|
||||
Currency = currency.ToString().ToLower()
|
||||
}
|
||||
};
|
||||
|
||||
return null;
|
||||
var productService = new ProductService();
|
||||
var product = await productService.CreateAsync(optionsProduct);
|
||||
|
||||
var subscription = new Subscription()
|
||||
{
|
||||
Name = name,
|
||||
Description = description,
|
||||
Currency = currency,
|
||||
Price = price,
|
||||
Duration = duration,
|
||||
LimitsJson = "[]",
|
||||
StripeProductId = product.Id,
|
||||
StripePriceId = product.DefaultPriceId
|
||||
};
|
||||
|
||||
return SubscriptionRepository.Add(subscription);
|
||||
}
|
||||
public async Task Update(Subscription subscription)
|
||||
{
|
||||
// Create the new price object
|
||||
|
||||
var optionsPrice = new PriceCreateOptions
|
||||
{
|
||||
UnitAmount = (long)(subscription.Price * 100),
|
||||
Currency = subscription.Currency.ToString().ToLower(),
|
||||
Product = subscription.StripeProductId
|
||||
};
|
||||
|
||||
var servicePrice = new PriceService();
|
||||
var price = await servicePrice.CreateAsync(optionsPrice);
|
||||
|
||||
// Update the product
|
||||
|
||||
var productService = new ProductService();
|
||||
var product = await productService.UpdateAsync(subscription.StripeProductId, new()
|
||||
{
|
||||
Name = subscription.Name,
|
||||
Description = subscription.Description,
|
||||
DefaultPrice = price.Id
|
||||
});
|
||||
|
||||
// Disable old price
|
||||
await servicePrice.UpdateAsync(subscription.StripePriceId, new()
|
||||
{
|
||||
Active = false
|
||||
});
|
||||
|
||||
// Update the model
|
||||
|
||||
subscription.StripeProductId = product.Id;
|
||||
subscription.StripePriceId = product.DefaultPriceId;
|
||||
|
||||
SubscriptionRepository.Update(subscription);
|
||||
}
|
||||
public async Task Delete(Subscription subscription)
|
||||
{
|
||||
var productService = new ProductService();
|
||||
await productService.DeleteAsync(subscription.StripeProductId);
|
||||
|
||||
SubscriptionRepository.Delete(subscription);
|
||||
}
|
||||
|
||||
public async Task ApplyCode(string code)
|
||||
public Task UpdateLimits(Subscription subscription, SubscriptionLimit[] limits)
|
||||
{
|
||||
var data = await OneTimeJwtService.Validate(code);
|
||||
subscription.LimitsJson = JsonConvert.SerializeObject(limits);
|
||||
SubscriptionRepository.Update(subscription);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
public Task<SubscriptionLimit[]> GetLimits(Subscription subscription)
|
||||
{
|
||||
var limits =
|
||||
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson) ?? Array.Empty<SubscriptionLimit>();
|
||||
return Task.FromResult(limits);
|
||||
}
|
||||
|
||||
if (data == null)
|
||||
throw new DisplayException("Invalid or expired subscription code");
|
||||
public async Task<Subscription?> GetActiveSubscription(User u)
|
||||
{
|
||||
var user = await EnsureData(u);
|
||||
|
||||
var id = int.Parse(data["subscription"]);
|
||||
var duration = int.Parse(data["duration"]);
|
||||
if (user.CurrentSubscription != null)
|
||||
{
|
||||
if (user.SubscriptionExpires < DateTime.UtcNow)
|
||||
{
|
||||
user.CurrentSubscription = null;
|
||||
UserRepository.Update(user);
|
||||
}
|
||||
}
|
||||
|
||||
return user.CurrentSubscription;
|
||||
}
|
||||
public async Task CancelSubscription(User u)
|
||||
{
|
||||
var user = await EnsureData(u);
|
||||
|
||||
var subscription = SubscriptionRepository
|
||||
.Get()
|
||||
.FirstOrDefault(x => x.Id == id);
|
||||
|
||||
if (subscription == null)
|
||||
throw new DisplayException("The subscription the code is associated with does not exist");
|
||||
|
||||
var user = await GetCurrentUser();
|
||||
|
||||
if (user == null)
|
||||
throw new DisplayException("Unable to determine current user");
|
||||
|
||||
user.CurrentSubscription = subscription;
|
||||
user.SubscriptionDuration = duration;
|
||||
user.CurrentSubscription = null;
|
||||
UserRepository.Update(user);
|
||||
}
|
||||
public async Task SetActiveSubscription(User u, Subscription subscription)
|
||||
{
|
||||
var user = await EnsureData(u);
|
||||
|
||||
user.SubscriptionSince = DateTime.UtcNow;
|
||||
user.SubscriptionExpires = DateTime.UtcNow.AddDays(subscription.Duration);
|
||||
user.CurrentSubscription = subscription;
|
||||
|
||||
UserRepository.Update(user);
|
||||
|
||||
await OneTimeJwtService.Revoke(code);
|
||||
}
|
||||
|
||||
public async Task Cancel()
|
||||
|
||||
public async Task<SubscriptionLimit[]> GetDefaultLimits()
|
||||
{
|
||||
if (await GetCurrent() != null)
|
||||
var defaultSubscriptionJson = "[]";
|
||||
var path = PathBuilder.File("storage", "configs", "default_subscription.json");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var user = await GetCurrentUser();
|
||||
|
||||
user.CurrentSubscription = null;
|
||||
|
||||
UserRepository.Update(user);
|
||||
defaultSubscriptionJson =
|
||||
await File.ReadAllTextAsync(path);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SubscriptionLimit> GetLimit(string identifier) // Cache, optimize sql code
|
||||
return JsonConvert.DeserializeObject<SubscriptionLimit[]>(defaultSubscriptionJson)
|
||||
?? Array.Empty<SubscriptionLimit>();
|
||||
}
|
||||
public async Task<SubscriptionLimit> GetLimit(User u, string identifier)
|
||||
{
|
||||
var subscription = await GetCurrent();
|
||||
var subscription = await GetActiveSubscription(u);
|
||||
var defaultLimits = await GetDefaultLimits();
|
||||
|
||||
if (subscription == null)
|
||||
if (subscription != null) // User has a active subscriptions
|
||||
{
|
||||
// If the default subscription limit with identifier is found, return it. if not, return empty
|
||||
return defaultLimits.FirstOrDefault(x => x.Identifier == identifier) ?? new()
|
||||
{
|
||||
Identifier = identifier,
|
||||
Amount = 0
|
||||
};
|
||||
}
|
||||
var subscriptionLimits = await GetLimits(subscription);
|
||||
|
||||
var subscriptionLimits =
|
||||
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson)
|
||||
?? Array.Empty<SubscriptionLimit>();
|
||||
var subscriptionLimit = subscriptionLimits
|
||||
.FirstOrDefault(x => x.Identifier == identifier);
|
||||
|
||||
var foundLimit = subscriptionLimits.FirstOrDefault(x => x.Identifier == identifier);
|
||||
|
||||
if (foundLimit != null)
|
||||
return foundLimit;
|
||||
if (subscriptionLimit != null) // Found subscription limit for the user's subscription
|
||||
return subscriptionLimit;
|
||||
} // If were are here, the user's subscription has no limit for this identifier, so we fallback to default
|
||||
|
||||
// If the default subscription limit with identifier is found, return it. if not, return empty
|
||||
return defaultLimits.FirstOrDefault(x => x.Identifier == identifier) ?? new()
|
||||
var defaultSubscriptionLimit = defaultLimits
|
||||
.FirstOrDefault(x => x.Identifier == identifier);
|
||||
|
||||
if (defaultSubscriptionLimit != null)
|
||||
return defaultSubscriptionLimit; // Default subscription limit found
|
||||
|
||||
return new() // No default subscription limit found
|
||||
{
|
||||
Identifier = identifier,
|
||||
Amount = 0
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<User?> GetCurrentUser()
|
||||
private Task<User> EnsureData(User u)
|
||||
{
|
||||
var user = await IdentityService.Get();
|
||||
|
||||
if (user == null)
|
||||
return null;
|
||||
|
||||
var userWithData = UserRepository
|
||||
var user = UserRepository
|
||||
.Get()
|
||||
.Include(x => x.CurrentSubscription)
|
||||
.First(x => x.Id == user.Id);
|
||||
.First(x => x.Id == u.Id);
|
||||
|
||||
return userWithData;
|
||||
}
|
||||
|
||||
private async Task<SubscriptionLimit[]> GetDefaultLimits() // Add cache and reload option
|
||||
{
|
||||
var defaultSubscriptionJson = "[]";
|
||||
|
||||
if (File.Exists(PathBuilder.File("storage", "configs", "default_subscription.json")))
|
||||
{
|
||||
defaultSubscriptionJson =
|
||||
await File.ReadAllTextAsync(PathBuilder.File("storage", "configs", "default_subscription.json"));
|
||||
}
|
||||
|
||||
return JsonConvert.DeserializeObject<SubscriptionLimit[]>(defaultSubscriptionJson) ?? Array.Empty<SubscriptionLimit>();
|
||||
return Task.FromResult(user);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.1.1-dev-00910" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.1-dev-00947" />
|
||||
<PackageReference Include="SSH.NET" Version="2020.0.2" />
|
||||
<PackageReference Include="Stripe.net" Version="41.23.0-beta.1" />
|
||||
<PackageReference Include="UAParser" Version="3.1.47" />
|
||||
<PackageReference Include="XtermBlazor" Version="1.8.1" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using BlazorDownloadFile;
|
||||
using BlazorTable;
|
||||
using CurrieTechnologies.Razor.SweetAlert2;
|
||||
using HealthChecks.UI.Client;
|
||||
using Moonlight.App.ApiClients.CloudPanel;
|
||||
using Moonlight.App.ApiClients.Daemon;
|
||||
@@ -33,6 +32,8 @@ using Moonlight.App.Services.SupportChat;
|
||||
using Sentry;
|
||||
using Serilog;
|
||||
using Serilog.Events;
|
||||
using Stripe;
|
||||
using SubscriptionService = Moonlight.App.Services.SubscriptionService;
|
||||
|
||||
namespace Moonlight
|
||||
{
|
||||
@@ -208,9 +209,9 @@ namespace Moonlight
|
||||
builder.Services.AddScoped<DynamicBackgroundService>();
|
||||
builder.Services.AddScoped<ServerAddonPluginService>();
|
||||
builder.Services.AddScoped<KeyListenerService>();
|
||||
|
||||
builder.Services.AddScoped<PopupService>();
|
||||
builder.Services.AddScoped<SubscriptionService>();
|
||||
builder.Services.AddScoped<SubscriptionAdminService>();
|
||||
builder.Services.AddScoped<BillingService>();
|
||||
|
||||
builder.Services.AddScoped<SessionClientService>();
|
||||
builder.Services.AddSingleton<SessionServerService>();
|
||||
@@ -251,6 +252,10 @@ namespace Moonlight
|
||||
builder.Services.AddBlazorContextMenu();
|
||||
builder.Services.AddBlazorDownloadFile();
|
||||
|
||||
StripeConfiguration.ApiKey = configService
|
||||
.Get()
|
||||
.Moonlight.Stripe.ApiKey;
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
@using Moonlight.App.ApiClients.Modrinth
|
||||
@using Moonlight.App.ApiClients.Wings
|
||||
@using Moonlight.App.Helpers
|
||||
@using Stripe
|
||||
@inherits ErrorBoundaryBase
|
||||
|
||||
@inject AlertService AlertService
|
||||
@@ -105,6 +106,13 @@ else
|
||||
{
|
||||
await AlertService.Error(SmartTranslateService.Translate("This function is not implemented"));
|
||||
}
|
||||
else if (exception is StripeException stripeException)
|
||||
{
|
||||
await AlertService.Error(
|
||||
SmartTranslateService.Translate("Unknown error from stripe"),
|
||||
stripeException.Message
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Warn(exception);
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
@using Moonlight.App.Repositories
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Database.Entities
|
||||
@using Mappy.Net
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject SubscriptionRepository SubscriptionRepository
|
||||
@inject SubscriptionAdminService SubscriptionAdminService
|
||||
@inject SubscriptionService SubscriptionService
|
||||
|
||||
<OnlyAdmin>
|
||||
<div class="card card-body p-10">
|
||||
@@ -31,7 +32,37 @@
|
||||
<TL>Description</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputText @bind-Value="Model.Description" class="form-control"></InputText>
|
||||
<InputTextArea @bind-Value="Model.Description" class="form-control"></InputTextArea>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Price</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputNumber @bind-Value="Model.Price" class="form-control"></InputNumber>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Currency</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<select @bind="Model.Currency" class="form-select">
|
||||
@foreach (var currency in (Currency[])Enum.GetValues(typeof(Currency)))
|
||||
{
|
||||
if (Model.Currency == currency)
|
||||
{
|
||||
<option value="@(currency)" selected="">@(currency)</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@(currency)">@(currency)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Duration</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputNumber @bind-Value="Model.Duration" class="form-control"></InputNumber>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -141,12 +172,10 @@
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
Subscription!.Name = Model.Name;
|
||||
Subscription.Description = Model.Description;
|
||||
|
||||
SubscriptionRepository.Update(Subscription);
|
||||
Subscription = Mapper.Map(Subscription, Model);
|
||||
await SubscriptionService.Update(Subscription!);
|
||||
|
||||
await SubscriptionAdminService.SaveLimits(Subscription, Limits.ToArray());
|
||||
await SubscriptionService.UpdateLimits(Subscription!, Limits.ToArray());
|
||||
|
||||
NavigationManager.NavigateTo("/admin/subscriptions");
|
||||
}
|
||||
@@ -159,10 +188,9 @@
|
||||
|
||||
if (Subscription != null)
|
||||
{
|
||||
Model.Name = Subscription.Name;
|
||||
Model.Description = Subscription.Description;
|
||||
|
||||
Limits = (await SubscriptionAdminService.GetLimits(Subscription)).ToList();
|
||||
Model = Mapper.Map<SubscriptionDataModel>(Subscription);
|
||||
|
||||
Limits = (await SubscriptionService.GetLimits(Subscription)).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,7 @@
|
||||
|
||||
@inject SmartTranslateService SmartTranslateService
|
||||
@inject SubscriptionRepository SubscriptionRepository
|
||||
|
||||
@inject SubscriptionAdminService SubscriptionAdminService
|
||||
@inject AlertService AlertService
|
||||
@inject ClipboardService ClipboardService
|
||||
@inject SubscriptionService SubscriptionService
|
||||
|
||||
<OnlyAdmin>
|
||||
<div class="card">
|
||||
@@ -34,7 +31,10 @@
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Id"))" Field="@(x => x.Id)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Name"))" Field="@(x => x.Name)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Description"))" Field="@(x => x.Description)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Manage"))" Field="@(x => x.Id)" Sortable="false" Filterable="false">
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Price"))" Field="@(x => x.Price)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Currency"))" Field="@(x => x.Currency)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="@(SmartTranslateService.Translate("Duration"))" Field="@(x => x.Duration)" Sortable="true" Filterable="true"/>
|
||||
<Column TableItem="Subscription" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false">
|
||||
<Template>
|
||||
<a href="/admin/subscriptions/edit/@(context.Id)/">
|
||||
<TL>Manage</TL>
|
||||
@@ -43,14 +43,9 @@
|
||||
</Column>
|
||||
<Column TableItem="Subscription" Title="" Field="@(x => x.Id)" Sortable="false" Filterable="false">
|
||||
<Template>
|
||||
<div class="float-end">
|
||||
<WButton Text="@(SmartTranslateService.Translate("Create code"))"
|
||||
WorkingText="@(SmartTranslateService.Translate("Working"))"
|
||||
CssClasses="btn-primary"
|
||||
OnClick="() => GenerateCode(context)">
|
||||
</WButton>
|
||||
<DeleteButton Confirm="true" OnClick="() => Delete(context)"/>
|
||||
</div>
|
||||
<DeleteButton Confirm="true"
|
||||
OnClick="() => Delete(context)">
|
||||
</DeleteButton>
|
||||
</Template>
|
||||
</Column>
|
||||
<Pager ShowPageNumber="true" ShowTotalCount="true"/>
|
||||
@@ -77,25 +72,7 @@
|
||||
|
||||
private async Task Delete(Subscription subscription)
|
||||
{
|
||||
SubscriptionRepository.Delete(subscription);
|
||||
|
||||
await SubscriptionService.Delete(subscription);
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
|
||||
private async Task GenerateCode(Subscription subscription)
|
||||
{
|
||||
var durationText = await AlertService.Text(
|
||||
SmartTranslateService.Translate("Duration"),
|
||||
SmartTranslateService.Translate("Enter duration of subscription"),
|
||||
"30"
|
||||
);
|
||||
|
||||
if (int.TryParse(durationText, out int duration))
|
||||
{
|
||||
var code = await SubscriptionAdminService.GenerateCode(subscription, duration);
|
||||
|
||||
await ClipboardService.Copy(code);
|
||||
await AlertService.Success(SmartTranslateService.Translate("Copied code to clipboard"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject SubscriptionRepository SubscriptionRepository
|
||||
@inject SubscriptionAdminService SubscriptionAdminService
|
||||
@inject SubscriptionService SubscriptionService
|
||||
|
||||
<OnlyAdmin>
|
||||
<div class="card card-body p-10">
|
||||
@@ -21,7 +21,37 @@
|
||||
<TL>Description</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputText @bind-Value="Model.Description" class="form-control"></InputText>
|
||||
<InputTextArea @bind-Value="Model.Description" class="form-control"></InputTextArea>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Price</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputNumber @bind-Value="Model.Price" class="form-control"></InputNumber>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Currency</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<select @bind="Model.Currency" class="form-select">
|
||||
@foreach (var currency in (Currency[])Enum.GetValues(typeof(Currency)))
|
||||
{
|
||||
if (Model.Currency == currency)
|
||||
{
|
||||
<option value="@(currency)" selected="">@(currency)</option>
|
||||
}
|
||||
else
|
||||
{
|
||||
<option value="@(currency)">@(currency)</option>
|
||||
}
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<label class="form-label">
|
||||
<TL>Duration</TL>
|
||||
</label>
|
||||
<div class="input-group mb-5">
|
||||
<InputNumber @bind-Value="Model.Duration" class="form-control"></InputNumber>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -124,14 +154,16 @@
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
var sub = SubscriptionRepository.Add(new()
|
||||
{
|
||||
Name = Model.Name,
|
||||
Description = Model.Description
|
||||
});
|
||||
var sub = await SubscriptionService.Create(
|
||||
Model.Name,
|
||||
Model.Description,
|
||||
Model.Currency,
|
||||
Model.Price,
|
||||
Model.Duration
|
||||
);
|
||||
|
||||
await SubscriptionService.UpdateLimits(sub, Limits.ToArray());
|
||||
|
||||
await SubscriptionAdminService.SaveLimits(sub, Limits.ToArray());
|
||||
|
||||
NavigationManager.NavigateTo("/admin/subscriptions");
|
||||
}
|
||||
}
|
||||
@@ -130,12 +130,12 @@
|
||||
Model = new();
|
||||
|
||||
await lazyLoader.SetText(SmartTranslateService.Translate("Loading your subscription"));
|
||||
Subscription = await SubscriptionService.GetCurrent();
|
||||
Subscription = await SubscriptionService.GetActiveSubscription(User);
|
||||
|
||||
AllowOrder = DomainRepository
|
||||
.Get()
|
||||
.Include(x => x.Owner)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit("domains")).Amount;
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit(User, "domains")).Amount;
|
||||
|
||||
await lazyLoader.SetText("Loading shared domains");
|
||||
SharedDomains = SharedDomainRepository.Get().ToArray();
|
||||
@@ -146,7 +146,7 @@
|
||||
if (DomainRepository
|
||||
.Get()
|
||||
.Include(x => x.Owner)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit("domains")).Amount)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit(User, "domains")).Amount)
|
||||
{
|
||||
var domain = await DomainService.Create(Model.Name, Model.SharedDomain, User);
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
@page "/profile/subscriptions"
|
||||
|
||||
@using Moonlight.Shared.Components.Navigations
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Database.Entities
|
||||
@using Moonlight.App.Helpers
|
||||
@using Moonlight.App.Services.Interop
|
||||
|
||||
@inject ConfigService ConfigService
|
||||
@inject AlertService AlertService
|
||||
@inject SubscriptionService SubscriptionService
|
||||
@inject SmartTranslateService SmartTranslateService
|
||||
|
||||
<ProfileNavigation Index="3"/>
|
||||
|
||||
<div class="card mb-3">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4 p-10">
|
||||
<img src="/assets/media/svg/subscription.svg" class="img-fluid rounded-start" alt="Subscription">
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<LazyLoader @ref="LazyLoader" Load="Load">
|
||||
@if (Subscription == null)
|
||||
{
|
||||
var config = ConfigService
|
||||
.Get()
|
||||
.Moonlight.Subscriptions.SellPass;
|
||||
|
||||
var enableSellpass = config.Enable;
|
||||
var url = config.Url;
|
||||
|
||||
<h3 class="mb-2">
|
||||
<div class="input-group mb-3">
|
||||
<input @bind="Code" type="text" class="form-control" placeholder="@(SmartTranslateService.Translate("Enter code"))">
|
||||
<WButton Text="@(SmartTranslateService.Translate("Submit"))"
|
||||
WorkingText="@(SmartTranslateService.Translate("Working"))"
|
||||
CssClasses="btn btn-primary"
|
||||
OnClick="OnSubmit">
|
||||
</WButton>
|
||||
</div>
|
||||
</h3>
|
||||
|
||||
if (enableSellpass)
|
||||
{
|
||||
<div class="d-flex justify-content-end pb-0 px-0">
|
||||
<a href="@(url)" class="btn btn-light">Buy subscription</a>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var d = User.SubscriptionSince.AddDays(User.SubscriptionDuration).ToUniversalTime();
|
||||
|
||||
<h3 class="mb-2">
|
||||
<TL>Active until</TL> @(Formatter.FormatDateOnly(d))
|
||||
</h3>
|
||||
<p class="fs-5 text-gray-600 fw-semibold">
|
||||
<TL>Current subscription</TL>: @(Subscription.Name)
|
||||
</p>
|
||||
<p class="fs-6 text-gray-600 fw-semibold">
|
||||
@(Subscription.Description)
|
||||
</p>
|
||||
<p class="fs-7 text-gray-600 fw-semibold">
|
||||
<TL>We will send you a notification upon subscription expiration</TL>
|
||||
</p>
|
||||
<div class="d-flex justify-content-end pb-0 px-0">
|
||||
<WButton Text="@(SmartTranslateService.Translate("Cancel"))"
|
||||
WorkingText="@(SmartTranslateService.Translate("Working"))"
|
||||
CssClasses="btn btn-light"
|
||||
OnClick="Cancel">
|
||||
</WButton>
|
||||
</div>
|
||||
}
|
||||
</LazyLoader>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter]
|
||||
public User User { get; set; }
|
||||
|
||||
private Subscription? Subscription;
|
||||
private LazyLoader LazyLoader;
|
||||
|
||||
private string Code = "";
|
||||
|
||||
private async Task Load(LazyLoader arg)
|
||||
{
|
||||
Subscription = await SubscriptionService.GetCurrent();
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (await AlertService.ConfirmMath())
|
||||
{
|
||||
await SubscriptionService.Cancel();
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnSubmit()
|
||||
{
|
||||
await SubscriptionService.ApplyCode(Code);
|
||||
Code = "";
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
}
|
||||
5
Moonlight/Shared/Views/Profile/Subscriptions/Close.razor
Normal file
5
Moonlight/Shared/Views/Profile/Subscriptions/Close.razor
Normal file
@@ -0,0 +1,5 @@
|
||||
@page "/profile/subscriptions/close"
|
||||
|
||||
<script suppress-error="BL9992">
|
||||
window.close();
|
||||
</script>
|
||||
170
Moonlight/Shared/Views/Profile/Subscriptions/Index.razor
Normal file
170
Moonlight/Shared/Views/Profile/Subscriptions/Index.razor
Normal file
@@ -0,0 +1,170 @@
|
||||
@page "/profile/subscriptions"
|
||||
|
||||
@using Moonlight.Shared.Components.Navigations
|
||||
@using Moonlight.App.Services
|
||||
@using Moonlight.App.Database.Entities
|
||||
@using Moonlight.App.Helpers
|
||||
@using Moonlight.App.Repositories
|
||||
@using Moonlight.App.Services.Interop
|
||||
@using Markdig
|
||||
|
||||
@inject BillingService BillingService
|
||||
@inject Repository<Subscription> SubscriptionRepository
|
||||
@inject AlertService AlertService
|
||||
@inject PopupService PopupService
|
||||
@inject SubscriptionService SubscriptionService
|
||||
@inject SmartTranslateService SmartTranslateService
|
||||
|
||||
<ProfileNavigation Index="3"/>
|
||||
|
||||
<LazyLoader @ref="LazyLoader" Load="Load">
|
||||
@if (CurrentSubscription == null)
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body p-lg-17">
|
||||
<div class="d-flex flex-column">
|
||||
<div class="mb-13 text-center">
|
||||
<h1 class="fs-1 fw-bold mb-2">
|
||||
<TL>Chose your plan</TL>
|
||||
</h1>
|
||||
<div class="text-gray-400 fw-semibold fs-3">
|
||||
<TL>Select the perfect plan for your next project</TL>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-10">
|
||||
@foreach (var subscription in Subscriptions)
|
||||
{
|
||||
<div class="col-xl-4">
|
||||
<div class="card bg-secondary border border-primary">
|
||||
<div class="card-header">
|
||||
<div class="card-title">@(subscription.Name)</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<span class="mb-2 text-primary">@(subscription.Currency)</span>
|
||||
<span class="fs-3x fw-bold text-primary">
|
||||
@(subscription.Price)
|
||||
</span>
|
||||
<span class="fs-7 fw-semibold opacity-50">
|
||||
/
|
||||
<span>
|
||||
@(subscription.Duration) <TL>Days</TL>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-3 card-text text-center">
|
||||
@{
|
||||
var html = (MarkupString)Markdown.ToHtml(subscription.Description);
|
||||
}
|
||||
|
||||
@(html)
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="text-center">
|
||||
<WButton Text="@(SmartTranslateService.Translate("Select"))"
|
||||
CssClasses="btn btn-primary"
|
||||
OnClick="() => StartCheckout(subscription)">
|
||||
</WButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
@if ((User.SubscriptionExpires - DateTime.UtcNow).TotalDays < 5)
|
||||
{
|
||||
<div class="notice d-flex bg-light-warning rounded border-warning border mb-12 p-6">
|
||||
<div class="d-flex flex-stack flex-grow-1 ">
|
||||
<div class="fw-semibold">
|
||||
<h4 class="text-gray-900 fw-bold">
|
||||
<TL>We need your attention!</TL>
|
||||
</h4>
|
||||
<div class="fs-6 text-gray-700 ">
|
||||
<TL>Your subscription expires soon</TL>
|
||||
<span>
|
||||
@(Math.Round((User.SubscriptionExpires - DateTime.UtcNow).TotalDays)) <TL>left</TL>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div class="col-lg-7">
|
||||
<h3 class="mb-2">
|
||||
<TL>Active until</TL> @(Formatter.FormatDateOnly(User.SubscriptionExpires))
|
||||
</h3>
|
||||
<p class="fs-6 text-gray-600 fw-semibold mb-6 mb-lg-15">
|
||||
<TL>We will send you a email upon subscription expiration</TL>
|
||||
</p>
|
||||
<div class="fs-5 mb-2">
|
||||
<span class="text-gray-800 fw-bold me-1">@(CurrentSubscription.Currency) @(CurrentSubscription.Price)</span>
|
||||
<span class="text-gray-600 fw-semibold">
|
||||
<TL>per</TL> @(CurrentSubscription.Duration) <TL>days</TL>
|
||||
</span>
|
||||
</div>
|
||||
<div class="fs-6 text-gray-600 fw-semibold">
|
||||
@(CurrentSubscription.Description)
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="text-end">
|
||||
<WButton Text="@(SmartTranslateService.Translate("Cancel"))"
|
||||
CssClasses="btn btn-danger"
|
||||
OnClick="Cancel">
|
||||
</WButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</LazyLoader>
|
||||
|
||||
@code
|
||||
{
|
||||
[CascadingParameter]
|
||||
public User User { get; set; }
|
||||
|
||||
private Subscription[] Subscriptions;
|
||||
private Subscription? CurrentSubscription;
|
||||
private LazyLoader LazyLoader;
|
||||
|
||||
private async Task Load(LazyLoader lazyLoader)
|
||||
{
|
||||
Subscriptions = SubscriptionRepository.Get().ToArray();
|
||||
CurrentSubscription = await SubscriptionService.GetActiveSubscription(User);
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
{
|
||||
if (await AlertService.ConfirmMath())
|
||||
{
|
||||
await SubscriptionService.CancelSubscription(User);
|
||||
await LazyLoader.Reload();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StartCheckout(Subscription subscription)
|
||||
{
|
||||
var url = await BillingService.StartCheckout(User, subscription);
|
||||
|
||||
await PopupService.ShowCentered(
|
||||
url,
|
||||
"Moonlight Checkout",
|
||||
500,
|
||||
700
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@
|
||||
Model = new();
|
||||
|
||||
await lazyLoader.SetText(SmartTranslateService.Translate("Loading your subscription"));
|
||||
Subscription = await SubscriptionService.GetCurrent();
|
||||
Subscription = await SubscriptionService.GetActiveSubscription(User);
|
||||
|
||||
await lazyLoader.SetText(SmartTranslateService.Translate("Searching for deploy node"));
|
||||
DeployNode = await SmartDeployService.GetNode();
|
||||
@@ -200,7 +200,7 @@
|
||||
|
||||
foreach (var image in images)
|
||||
{
|
||||
var limit = await SubscriptionService.GetLimit("image." + image.Id);
|
||||
var limit = await SubscriptionService.GetLimit(User, "image." + image.Id);
|
||||
|
||||
if (limit.Amount > 0)
|
||||
{
|
||||
@@ -219,7 +219,7 @@
|
||||
|
||||
private async Task OnValidSubmit()
|
||||
{
|
||||
var limit = await SubscriptionService.GetLimit("image." + Model.Image.Id);
|
||||
var limit = await SubscriptionService.GetLimit(User, "image." + Model.Image.Id);
|
||||
|
||||
if (limit.Amount > 0)
|
||||
{
|
||||
|
||||
@@ -125,7 +125,7 @@
|
||||
Model = new();
|
||||
|
||||
await lazyLoader.SetText(SmartTranslateService.Translate("Loading your subscription"));
|
||||
Subscription = await SubscriptionService.GetCurrent();
|
||||
Subscription = await SubscriptionService.GetActiveSubscription(User);
|
||||
|
||||
await lazyLoader.SetText(SmartTranslateService.Translate("Searching for deploy web host"));
|
||||
CloudPanel = await SmartDeployService.GetCloudPanel();
|
||||
@@ -133,7 +133,7 @@
|
||||
AllowOrder = WebSpaceRepository
|
||||
.Get()
|
||||
.Include(x => x.Owner)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit("websites")).Amount;
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit(User, "websites")).Amount;
|
||||
}
|
||||
|
||||
private async Task OnValidSubmit()
|
||||
@@ -141,7 +141,7 @@
|
||||
if (WebSpaceRepository
|
||||
.Get()
|
||||
.Include(x => x.Owner)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit("websites")).Amount)
|
||||
.Count(x => x.Owner.Id == User.Id) < (await SubscriptionService.GetLimit(User, "websites")).Amount)
|
||||
{
|
||||
var webSpace = await WebSpaceService.Create(Model.BaseDomain, User, CloudPanel);
|
||||
|
||||
|
||||
104
Moonlight/wwwroot/assets/js/moonlight.js
vendored
104
Moonlight/wwwroot/assets/js/moonlight.js
vendored
@@ -283,37 +283,32 @@
|
||||
}
|
||||
},
|
||||
utils: {
|
||||
scrollToElement: function (id)
|
||||
{
|
||||
scrollToElement: function (id) {
|
||||
let e = document.getElementById(id);
|
||||
e.scrollTop = e.scrollHeight;
|
||||
},
|
||||
triggerResizeEvent: function ()
|
||||
{
|
||||
triggerResizeEvent: function () {
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
},
|
||||
showNotification: function (title, text, img) {
|
||||
let notification = new Notification(title, { body: text, icon: img });
|
||||
let notification = new Notification(title, {body: text, icon: img});
|
||||
}
|
||||
},
|
||||
loading: {
|
||||
registerXterm: function()
|
||||
{
|
||||
registerXterm: function () {
|
||||
console.log("Registering xterm addons");
|
||||
|
||||
|
||||
window.XtermBlazor.registerAddon("xterm-addon-fit", new window.FitAddon.FitAddon());
|
||||
//window.XtermBlazor.registerAddon("xterm-addon-search", new window.SearchAddon.SearchAddon());
|
||||
//window.XtermBlazor.registerAddon("xterm-addon-web-links", new window.WebLinksAddon.WebLinksAddon());
|
||||
},
|
||||
loadMonaco: function ()
|
||||
{
|
||||
loadMonaco: function () {
|
||||
console.log("Loading monaco");
|
||||
|
||||
|
||||
monaco.editor.defineTheme('moonlight-theme', {
|
||||
base: 'vs-dark',
|
||||
inherit: true,
|
||||
rules: [
|
||||
],
|
||||
rules: [],
|
||||
colors: {
|
||||
'editor.background': '#000000'
|
||||
}
|
||||
@@ -321,22 +316,20 @@
|
||||
}
|
||||
},
|
||||
flashbang: {
|
||||
run: function()
|
||||
{
|
||||
run: function () {
|
||||
const light = document.getElementById("flashbang");
|
||||
light.style.boxShadow = "0 0 10000px 10000px white, 0 0 250px 10px #FFFFFF";
|
||||
light.style.animation = "flashbang 5s linear forwards";
|
||||
light.onanimationend = moonlight.flashbang.clean;
|
||||
},
|
||||
clean: function()
|
||||
{
|
||||
clean: function () {
|
||||
const light = document.getElementById("flashbang");
|
||||
light.style.animation = "";
|
||||
light.style.opacity = "0";
|
||||
}
|
||||
},
|
||||
downloads:{
|
||||
downloadStream: async function (fileName, contentStreamReference){
|
||||
downloads: {
|
||||
downloadStream: async function (fileName, contentStreamReference) {
|
||||
const arrayBuffer = await contentStreamReference.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer]);
|
||||
const url = URL.createObjectURL(blob);
|
||||
@@ -349,38 +342,31 @@
|
||||
}
|
||||
},
|
||||
keyListener: {
|
||||
register: function (dotNetObjRef)
|
||||
{
|
||||
moonlight.keyListener.listener = (event) =>
|
||||
{
|
||||
register: function (dotNetObjRef) {
|
||||
moonlight.keyListener.listener = (event) => {
|
||||
// filter here what key events should be sent to moonlight
|
||||
|
||||
if(event.code === "KeyS" && event.ctrlKey)
|
||||
{
|
||||
if (event.code === "KeyS" && event.ctrlKey) {
|
||||
event.preventDefault();
|
||||
dotNetObjRef.invokeMethodAsync('OnKeyPress', "saveShortcut");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
window.addEventListener('keydown', moonlight.keyListener.listener);
|
||||
},
|
||||
unregister: function (dotNetObjRef)
|
||||
{
|
||||
unregister: function (dotNetObjRef) {
|
||||
window.removeEventListener('keydown', moonlight.keyListener.listener);
|
||||
}
|
||||
},
|
||||
serverList: {
|
||||
init: function ()
|
||||
{
|
||||
if(moonlight.serverList.Swappable)
|
||||
{
|
||||
init: function () {
|
||||
if (moonlight.serverList.Swappable) {
|
||||
moonlight.serverList.Swappable.destroy();
|
||||
}
|
||||
|
||||
|
||||
let containers = document.querySelectorAll(".draggable-zone");
|
||||
|
||||
if (containers.length !== 0)
|
||||
{
|
||||
if (containers.length !== 0) {
|
||||
moonlight.serverList.Swappable = new Draggable.Sortable(containers, {
|
||||
draggable: ".draggable",
|
||||
handle: ".draggable .draggable-handle",
|
||||
@@ -392,30 +378,44 @@
|
||||
});
|
||||
}
|
||||
},
|
||||
getData: function ()
|
||||
{
|
||||
getData: function () {
|
||||
let groups = new Array();
|
||||
|
||||
let groupElements = document.querySelectorAll('[ml-server-group]');
|
||||
|
||||
groupElements.forEach(groupElement => {
|
||||
let group = new Object();
|
||||
group.name = groupElement.attributes.getNamedItem("ml-server-group").value;
|
||||
|
||||
let servers = new Array();
|
||||
let serverElements = groupElement.querySelectorAll("[ml-server-id]");
|
||||
|
||||
serverElements.forEach(serverElement => {
|
||||
let id = serverElement.attributes.getNamedItem("ml-server-id").value;
|
||||
|
||||
servers.push(id);
|
||||
});
|
||||
|
||||
group.servers = servers;
|
||||
groups.push(group);
|
||||
groupElements.forEach(groupElement => {
|
||||
let group = new Object();
|
||||
group.name = groupElement.attributes.getNamedItem("ml-server-group").value;
|
||||
|
||||
let servers = new Array();
|
||||
let serverElements = groupElement.querySelectorAll("[ml-server-id]");
|
||||
|
||||
serverElements.forEach(serverElement => {
|
||||
let id = serverElement.attributes.getNamedItem("ml-server-id").value;
|
||||
|
||||
servers.push(id);
|
||||
});
|
||||
|
||||
group.servers = servers;
|
||||
groups.push(group);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
},
|
||||
popup: {
|
||||
showCentered: function (url, title, w, h) {
|
||||
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : window.screenX;
|
||||
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : window.screenY;
|
||||
|
||||
const width = window.innerWidth ? window.innerWidth : document.documentElement.clientWidth ? document.documentElement.clientWidth : screen.width;
|
||||
const height = window.innerHeight ? window.innerHeight : document.documentElement.clientHeight ? document.documentElement.clientHeight : screen.height;
|
||||
|
||||
const systemZoom = width / window.screen.availWidth;
|
||||
const left = (width - w) / 2 / systemZoom + dualScreenLeft
|
||||
const top = (height - h) / 2 / systemZoom + dualScreenTop
|
||||
const newWindow = window.open(url, title,`scrollbars=yes,width=${w / systemZoom},height=${h / systemZoom},top=${top},left=${left}`)
|
||||
if (window.focus) newWindow.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user