Implemented new subscription system and basic stripe support

This commit is contained in:
Marcel Baumgartner
2023-07-06 02:12:06 +02:00
parent ab529991fd
commit c0df8ac507
26 changed files with 1899 additions and 374 deletions

View File

@@ -43,6 +43,13 @@ 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")] public string ApiKey { get; set; } = "";
}
public class CleanupData

View File

@@ -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;
}

View File

@@ -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; } = "";

File diff suppressed because it is too large Load Diff

View File

@@ -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);
}
}
}

View File

@@ -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)");

View File

@@ -0,0 +1,36 @@
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("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");
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,7 @@
namespace Moonlight.App.Models.Misc;
public enum Currency
{
USD = 1,
EUR = 2
}

View File

@@ -0,0 +1,108 @@
using Moonlight.App.Database.Entities;
using Moonlight.App.Events;
using Moonlight.App.Exceptions;
using Moonlight.App.Repositories;
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;
public BillingService(
ConfigService configService,
SubscriptionService subscriptionService,
Repository<Subscription> subscriptionRepository,
EventSystem eventSystem,
SessionServerService sessionServerService)
{
ConfigService = configService;
SubscriptionService = subscriptionService;
SubscriptionRepository = subscriptionRepository;
Event = eventSystem;
SessionServerService = sessionServerService;
}
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 SessionServerService.ReloadUserSessions(user);
}
}

View 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);
}
}

View File

@@ -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))
);
}
}

View File

@@ -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
{
Name = name,
Description = description,
DefaultPriceData = new()
{
UnitAmount = (long)(price * 100),
Currency = currency.ToString().ToLower()
}
};
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 Task UpdateLimits(Subscription subscription, SubscriptionLimit[] limits)
{
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);
}
public async Task<Subscription?> GetActiveSubscription(User u)
{
var user = await EnsureData(u);
if (user.CurrentSubscription != null)
{
if (user.SubscriptionExpires < DateTime.UtcNow)
{
user.CurrentSubscription = null;
UserRepository.Update(user);
}
}
return user.CurrentSubscription;
}
return null;
}
public async Task ApplyCode(string code)
public async Task CancelSubscription(User u)
{
var data = await OneTimeJwtService.Validate(code);
if (data == null)
throw new DisplayException("Invalid or expired subscription code");
var id = int.Parse(data["subscription"]);
var duration = int.Parse(data["duration"]);
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.SubscriptionSince = DateTime.UtcNow;
UserRepository.Update(user);
await OneTimeJwtService.Revoke(code);
}
public async Task Cancel()
{
if (await GetCurrent() != null)
{
var user = await GetCurrentUser();
var user = await EnsureData(u);
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);
}
}
public async Task<SubscriptionLimit> GetLimit(string identifier) // Cache, optimize sql code
{
var subscription = await GetCurrent();
var defaultLimits = await GetDefaultLimits();
if (subscription == null)
{
// 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 =
JsonConvert.DeserializeObject<SubscriptionLimit[]>(subscription.LimitsJson)
?? Array.Empty<SubscriptionLimit>();
var foundLimit = subscriptionLimits.FirstOrDefault(x => x.Identifier == identifier);
if (foundLimit != null)
return foundLimit;
// 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
};
}
private async Task<User?> GetCurrentUser()
{
var user = await IdentityService.Get();
if (user == null)
return null;
var userWithData = UserRepository
.Get()
.Include(x => x.CurrentSubscription)
.First(x => x.Id == user.Id);
return userWithData;
}
private async Task<SubscriptionLimit[]> GetDefaultLimits() // Add cache and reload option
public async Task<SubscriptionLimit[]> GetDefaultLimits()
{
var defaultSubscriptionJson = "[]";
var path = PathBuilder.File("storage", "configs", "default_subscription.json");
if (File.Exists(PathBuilder.File("storage", "configs", "default_subscription.json")))
if (File.Exists(path))
{
defaultSubscriptionJson =
await File.ReadAllTextAsync(PathBuilder.File("storage", "configs", "default_subscription.json"));
await File.ReadAllTextAsync(path);
}
return JsonConvert.DeserializeObject<SubscriptionLimit[]>(defaultSubscriptionJson) ?? Array.Empty<SubscriptionLimit>();
return JsonConvert.DeserializeObject<SubscriptionLimit[]>(defaultSubscriptionJson)
?? Array.Empty<SubscriptionLimit>();
}
public async Task<SubscriptionLimit> GetLimit(User u, string identifier)
{
var subscription = await GetActiveSubscription(u);
var defaultLimits = await GetDefaultLimits();
if (subscription != null) // User has a active subscriptions
{
var subscriptionLimits = await GetLimits(subscription);
var subscriptionLimit = subscriptionLimits
.FirstOrDefault(x => x.Identifier == identifier);
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
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 Task<User> EnsureData(User u)
{
var user = UserRepository
.Get()
.Include(x => x.CurrentSubscription)
.First(x => x.Id == u.Id);
return Task.FromResult(user);
}
}

View File

@@ -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>

View File

@@ -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;
@@ -32,6 +31,8 @@ using Moonlight.App.Services.SupportChat;
using Sentry;
using Serilog;
using Serilog.Events;
using Stripe;
using SubscriptionService = Moonlight.App.Services.SubscriptionService;
namespace Moonlight
{
@@ -209,9 +210,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>();
@@ -252,6 +253,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.

View File

@@ -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);

View File

@@ -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;
Subscription = Mapper.Map(Subscription, Model);
await SubscriptionService.Update(Subscription!);
SubscriptionRepository.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;
Model = Mapper.Map<SubscriptionDataModel>(Subscription);
Limits = (await SubscriptionAdminService.GetLimits(Subscription)).ToList();
Limits = (await SubscriptionService.GetLimits(Subscription)).ToList();
}
}
}

View File

@@ -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"));
}
}
}

View File

@@ -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,13 +154,15 @@
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 SubscriptionAdminService.SaveLimits(sub, Limits.ToArray());
await SubscriptionService.UpdateLimits(sub, Limits.ToArray());
NavigationManager.NavigateTo("/admin/subscriptions");
}

View File

@@ -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);

View File

@@ -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();
}
}

View File

@@ -0,0 +1,5 @@
@page "/profile/subscriptions/close"
<script suppress-error="BL9992">
window.close();
</script>

View 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
);
}
}

View File

@@ -156,7 +156,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();
@@ -166,7 +166,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)
{
@@ -185,7 +185,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)
{

View File

@@ -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);

View File

@@ -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,14 +342,11 @@
}
},
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");
}
@@ -364,23 +354,19 @@
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,8 +378,7 @@
});
}
},
getData: function ()
{
getData: function () {
let groups = new Array();
let groupElements = document.querySelectorAll('[ml-server-group]');
@@ -417,5 +402,20 @@
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();
}
}
};