mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
subs
This commit is contained in:
@@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Blink"
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.MicroNode", "Plugins\BTCPayServer.Plugins.MicroNode\BTCPayServer.Plugins.MicroNode.csproj", "{95626F3B-7722-4AE7-9C12-EDB1E58687E2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.MicroNode", "Plugins\BTCPayServer.Plugins.MicroNode\BTCPayServer.Plugins.MicroNode.csproj", "{95626F3B-7722-4AE7-9C12-EDB1E58687E2}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Subscriptions", "Plugins\BTCPayServer.Plugins.Subscriptions\BTCPayServer.Plugins.Subscriptions.csproj", "{994E5D32-849B-4276-82A9-2A18DBC98D39}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -283,6 +285,14 @@ Global
|
|||||||
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
|
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
|
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
|
{95626F3B-7722-4AE7-9C12-EDB1E58687E2}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Release|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{994E5D32-849B-4276-82A9-2A18DBC98D39}.Altcoins-Release|Any CPU.Build.0 = Debug|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962}
|
{B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<LangVersion>12</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- Plugin specific properties -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<Product>Subscriptions</Product>
|
||||||
|
<Description>Offer and manage subscriptions through BTCPay Server</Description>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
|
||||||
|
</PropertyGroup>
|
||||||
|
<!-- Plugin development properties -->
|
||||||
|
<PropertyGroup>
|
||||||
|
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||||
|
<PreserveCompilationContext>false</PreserveCompilationContext>
|
||||||
|
<GenerateEmbeddedFilesManifest>true</GenerateEmbeddedFilesManifest>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<!-- This will make sure that referencing BTCPayServer doesn't put any artifact in the published directory -->
|
||||||
|
<ItemDefinitionGroup>
|
||||||
|
<ProjectReference>
|
||||||
|
<Properties>StaticWebAssetsEnabled=false</Properties>
|
||||||
|
<Private>false</Private>
|
||||||
|
<ExcludeAssets>runtime;native;build;buildTransitive;contentFiles</ExcludeAssets>
|
||||||
|
</ProjectReference>
|
||||||
|
</ItemDefinitionGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<EmbeddedResource Include="Resources\**" />
|
||||||
|
<ProjectReference Include="..\..\submodules\btcpayserver\BTCPayServer\BTCPayServer.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Views\Shared\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
[EnableCors(CorsPolicies.All)]
|
||||||
|
public class GreenfieldSubscriptionsController : ControllerBase
|
||||||
|
{
|
||||||
|
private readonly AppService _appService;
|
||||||
|
|
||||||
|
public GreenfieldSubscriptionsController(AppService appService)
|
||||||
|
{
|
||||||
|
_appService = appService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("~/api/v1/apps/subscriptions/{appId}")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Greenfield)]
|
||||||
|
public async Task<IActionResult> GetSubscription(string appId)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, includeArchived: true);
|
||||||
|
if (app == null)
|
||||||
|
{
|
||||||
|
return AppNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var ss = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
return Ok(ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult AppNotFound()
|
||||||
|
{
|
||||||
|
return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found");
|
||||||
|
}
|
||||||
|
}
|
||||||
19
Plugins/BTCPayServer.Plugins.Subscriptions/Subscription.cs
Normal file
19
Plugins/BTCPayServer.Plugins.Subscriptions/Subscription.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class Subscription
|
||||||
|
|
||||||
|
|
||||||
|
{
|
||||||
|
public string Email { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public SubscriptionStatus Status { get; set; }
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset Start { get; set; }
|
||||||
|
public List<SubscriptionPaymentHistory> Payments { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Configuration;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionApp : AppBaseType
|
||||||
|
{
|
||||||
|
private readonly LinkGenerator _linkGenerator;
|
||||||
|
private readonly IOptions<BTCPayServerOptions> _options;
|
||||||
|
public const string AppType = "Subscription";
|
||||||
|
|
||||||
|
public SubscriptionApp(
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
IOptions<BTCPayServerOptions> options)
|
||||||
|
{
|
||||||
|
Description = "Subscription";
|
||||||
|
Type = AppType;
|
||||||
|
_linkGenerator = linkGenerator;
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<string> ConfigureLink(AppData app)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_linkGenerator.GetPathByAction(
|
||||||
|
nameof(SubscriptionController.Update),
|
||||||
|
"Subscription", new {appId = app.Id}, _options.Value.RootPath)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<object?> GetInfo(AppData appData)
|
||||||
|
{
|
||||||
|
return Task.FromResult<object?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
|
||||||
|
{
|
||||||
|
appData.SetSettings(new SubscriptionAppSettings());
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<string> ViewLink(AppData app)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_linkGenerator.GetPathByAction(nameof(SubscriptionController.View),
|
||||||
|
"Subscription", new {appId = app.Id}, _options.Value.RootPath)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.JsonConverters;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionAppSettings
|
||||||
|
{
|
||||||
|
[JsonIgnore] public string SubscriptionName { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public int DurationDays { get; set; }
|
||||||
|
public string? FormId { get; set; }
|
||||||
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public Dictionary<string, Subscription> Subscriptions { get; set; } = new();
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.PaymentRequests;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using PaymentRequestData = BTCPayServer.Data.PaymentRequestData;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
[Authorize(AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public class SubscriptionController : Controller
|
||||||
|
{
|
||||||
|
private readonly AppService _appService;
|
||||||
|
private readonly PaymentRequestRepository _paymentRequestRepository;
|
||||||
|
private readonly SubscriptionService _subscriptionService;
|
||||||
|
|
||||||
|
public SubscriptionController(AppService appService,
|
||||||
|
PaymentRequestRepository paymentRequestRepository, SubscriptionService subscriptionService)
|
||||||
|
{
|
||||||
|
_appService = appService;
|
||||||
|
_paymentRequestRepository = paymentRequestRepository;
|
||||||
|
_subscriptionService = subscriptionService;
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("~/plugins/subscription/{appId}")]
|
||||||
|
public async Task<IActionResult> View(string appId)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, true, false);
|
||||||
|
|
||||||
|
if (app == null)
|
||||||
|
return NotFound();
|
||||||
|
var ss = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
ss.SubscriptionName = app.Name;
|
||||||
|
ViewData["StoreBranding"] = new StoreBrandingViewModel(app.StoreData.GetStoreBlob());
|
||||||
|
return View(ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("~/plugins/subscription/{appId}/{id}")]
|
||||||
|
public async Task<IActionResult> ViewSubscription(string appId, string id)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, true, false);
|
||||||
|
|
||||||
|
if (app == null)
|
||||||
|
return NotFound();
|
||||||
|
var ss = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
ss.SubscriptionName = app.Name;
|
||||||
|
if (!ss.Subscriptions.TryGetValue(id, out _))
|
||||||
|
{
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
ViewData["StoreBranding"] = new StoreBrandingViewModel(app.StoreData.GetStoreBlob());
|
||||||
|
|
||||||
|
return View(ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("~/plugins/subscription/{appId}/{id}/reactivate")]
|
||||||
|
public async Task<IActionResult> Reactivate(string appId, string id)
|
||||||
|
{
|
||||||
|
var pr = await _subscriptionService.ReactivateSubscription(appId, id);
|
||||||
|
if (pr == null)
|
||||||
|
return NotFound();
|
||||||
|
return RedirectToAction("ViewPaymentRequest", "UIPaymentRequest", new {payReqId = pr.Id});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[HttpGet("~/plugins/subscription/{appId}/subscribe")]
|
||||||
|
public async Task<IActionResult> Subscribe(string appId)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, false);
|
||||||
|
|
||||||
|
if (app == null)
|
||||||
|
return NotFound();
|
||||||
|
var ss = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
ss.SubscriptionName = app.Name;
|
||||||
|
|
||||||
|
var pr = new PaymentRequestData()
|
||||||
|
{
|
||||||
|
StoreDataId = app.StoreDataId,
|
||||||
|
Archived = false,
|
||||||
|
Status = Client.Models.PaymentRequestData.PaymentRequestStatus.Pending
|
||||||
|
};
|
||||||
|
pr.SetBlob(new CreatePaymentRequestRequest()
|
||||||
|
{
|
||||||
|
Amount = ss.Price,
|
||||||
|
Currency = ss.Currency,
|
||||||
|
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
Description = ss.Description,
|
||||||
|
Title = ss.SubscriptionName,
|
||||||
|
FormId = ss.FormId,
|
||||||
|
AllowCustomPaymentAmounts = false,
|
||||||
|
AdditionalData = new Dictionary<string, JToken>()
|
||||||
|
{
|
||||||
|
{"source", JToken.FromObject("subscription")},
|
||||||
|
{"appId", JToken.FromObject(appId)},
|
||||||
|
{"url", HttpContext.Request.GetAbsoluteRoot()}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
|
||||||
|
|
||||||
|
return RedirectToAction("ViewPaymentRequest", "UIPaymentRequest", new {payReqId = pr.Id});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[HttpGet("~/plugins/subscription/{appId}/update")]
|
||||||
|
public async Task<IActionResult> Update(string appId)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true);
|
||||||
|
|
||||||
|
if (app == null)
|
||||||
|
return NotFound();
|
||||||
|
ViewData["archived"] = app.Archived;
|
||||||
|
var ss = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
ss.SubscriptionName = app.Name;
|
||||||
|
|
||||||
|
return View(ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("~/plugins/subscription/{appId}/update")]
|
||||||
|
public async Task<IActionResult> Update(string appId, SubscriptionAppSettings vm)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, true, true);
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(vm.Currency))
|
||||||
|
{
|
||||||
|
vm.Currency = app.StoreData.GetStoreBlob().DefaultCurrency;
|
||||||
|
ModelState.Remove(nameof(vm.Currency));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(vm.Currency))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.Currency), "Currency is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(vm.SubscriptionName))
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.SubscriptionName), "Subscription name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.Price <= 0)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.Price), "Price must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.DurationDays <= 0)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.DurationDays), "Duration must be greater than 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
ViewData["archived"] = app.Archived;
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
{
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var old = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
vm.Subscriptions = old.Subscriptions;
|
||||||
|
app.SetSettings(vm);
|
||||||
|
app.Name = vm.SubscriptionName;
|
||||||
|
await _appService.UpdateOrCreateApp(app);
|
||||||
|
TempData["SuccessMessage"] = "Subscription settings modified";
|
||||||
|
return RedirectToAction(nameof(Update), new {appId});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using System;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionPaymentHistory
|
||||||
|
{
|
||||||
|
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset PeriodStart { get; set; }
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset PeriodEnd { get; set; }
|
||||||
|
public string PaymentRequestId { get; set; }
|
||||||
|
public bool Settled { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using BTCPayServer.Abstractions.Contracts;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Abstractions.Services;
|
||||||
|
using BTCPayServer.HostedServices.Webhooks;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions
|
||||||
|
{
|
||||||
|
public class SubscriptionPlugin : BaseBTCPayServerPlugin
|
||||||
|
{
|
||||||
|
public override IBTCPayServerPlugin.PluginDependency[] Dependencies { get; } =
|
||||||
|
[
|
||||||
|
new() {Identifier = nameof(BTCPayServer), Condition = ">=1.13.0"}
|
||||||
|
];
|
||||||
|
|
||||||
|
public override void Execute(IServiceCollection applicationBuilder)
|
||||||
|
{
|
||||||
|
applicationBuilder.AddSingleton<SubscriptionService>();
|
||||||
|
applicationBuilder.AddSingleton<IWebhookProvider>(o => o.GetRequiredService<SubscriptionService>());
|
||||||
|
applicationBuilder.AddHostedService(s => s.GetRequiredService<SubscriptionService>());
|
||||||
|
|
||||||
|
applicationBuilder.AddSingleton<IUIExtension>(new UIExtension("Subscriptions/NavExtension", "header-nav"));
|
||||||
|
applicationBuilder.AddSingleton<AppBaseType, SubscriptionApp>();
|
||||||
|
base.Execute(applicationBuilder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,542 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Controllers;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using BTCPayServer.HostedServices.Webhooks;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.PaymentRequests;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using PaymentRequestData = BTCPayServer.Client.Models.PaymentRequestData;
|
||||||
|
using WebhookDeliveryData = BTCPayServer.Data.WebhookDeliveryData;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
|
||||||
|
{
|
||||||
|
private readonly AppService _appService;
|
||||||
|
private readonly PaymentRequestRepository _paymentRequestRepository;
|
||||||
|
private readonly LinkGenerator _linkGenerator;
|
||||||
|
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
|
||||||
|
private readonly WebhookSender _webhookSender;
|
||||||
|
|
||||||
|
public SubscriptionService(EventAggregator eventAggregator,
|
||||||
|
ILogger<SubscriptionService> logger,
|
||||||
|
AppService appService,
|
||||||
|
PaymentRequestRepository paymentRequestRepository,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings,
|
||||||
|
WebhookSender webhookSender) : base(eventAggregator, logger)
|
||||||
|
{
|
||||||
|
_appService = appService;
|
||||||
|
_paymentRequestRepository = paymentRequestRepository;
|
||||||
|
_linkGenerator = linkGenerator;
|
||||||
|
_btcPayNetworkJsonSerializerSettings = btcPayNetworkJsonSerializerSettings;
|
||||||
|
_webhookSender = webhookSender;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
_ = ScheduleChecks(cancellationToken);
|
||||||
|
return base.StartAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScheduleChecks(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
await CreatePaymentRequestForActiveSubscriptionCloseToEnding();
|
||||||
|
await Task.Delay(TimeSpan.FromHours(1), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Data.PaymentRequestData?> ReactivateSubscription(string appId, string subscriptionId)
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<object>();
|
||||||
|
PushEvent(new SequentialExecute(async () =>
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true);
|
||||||
|
if (app == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
if (!settings.Subscriptions.TryGetValue(subscriptionId, out var subscription))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subscription.Status == SubscriptionStatus.Active)
|
||||||
|
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var lastSettled = subscription.Payments.Where(p => p.Settled).MaxBy(history => history.PeriodEnd);
|
||||||
|
var lastPr =
|
||||||
|
await _paymentRequestRepository.FindPaymentRequest(lastSettled.PaymentRequestId, null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var lastBlob = lastPr.GetBlob();
|
||||||
|
|
||||||
|
var pr = new Data.PaymentRequestData()
|
||||||
|
{
|
||||||
|
StoreDataId = app.StoreDataId,
|
||||||
|
Status = PaymentRequestData.PaymentRequestStatus.Pending,
|
||||||
|
Created = DateTimeOffset.UtcNow, Archived = false,
|
||||||
|
};
|
||||||
|
pr.SetBlob(new PaymentRequestBaseData()
|
||||||
|
{
|
||||||
|
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
|
||||||
|
Amount = settings.Price,
|
||||||
|
Currency = settings.Currency,
|
||||||
|
StoreId = app.StoreDataId,
|
||||||
|
Title = $"{settings.SubscriptionName} Subscription Reactivation",
|
||||||
|
Description = settings.Description,
|
||||||
|
AdditionalData = lastBlob.AdditionalData
|
||||||
|
});
|
||||||
|
return await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
|
||||||
|
}, tcs));
|
||||||
|
return await tcs.Task as Data.PaymentRequestData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CreatePaymentRequestForActiveSubscriptionCloseToEnding()
|
||||||
|
{
|
||||||
|
var tcs = new TaskCompletionSource<object>();
|
||||||
|
|
||||||
|
PushEvent(new SequentialExecute(async () =>
|
||||||
|
{
|
||||||
|
var apps = await _appService.GetApps(SubscriptionApp.AppType);
|
||||||
|
apps = apps.Where(data => !data.Archived).ToList();
|
||||||
|
List<(string appId, string subscriptionId, string paymentRequestId, string email)> deliverRequests = new();
|
||||||
|
foreach (var app in apps)
|
||||||
|
{
|
||||||
|
var settings = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
settings.SubscriptionName = app.Name;
|
||||||
|
if (settings.Subscriptions?.Any() is true)
|
||||||
|
{
|
||||||
|
foreach (var subscription in settings.Subscriptions)
|
||||||
|
{
|
||||||
|
if (subscription.Value.Status == SubscriptionStatus.Active)
|
||||||
|
{
|
||||||
|
var currentPeriod = subscription.Value.Payments.FirstOrDefault(p => p.Settled &&
|
||||||
|
p.PeriodStart <= DateTimeOffset.UtcNow &&
|
||||||
|
p.PeriodEnd >= DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
var nextPeriod =
|
||||||
|
subscription.Value.Payments.FirstOrDefault(p => p.PeriodStart > DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
if (currentPeriod is null || nextPeriod is not null)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
|
||||||
|
var noticePeriod = currentPeriod.PeriodEnd - DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
var lastPr =
|
||||||
|
await _paymentRequestRepository.FindPaymentRequest(currentPeriod.PaymentRequestId, null,
|
||||||
|
CancellationToken.None);
|
||||||
|
var lastBlob = lastPr.GetBlob();
|
||||||
|
|
||||||
|
if (noticePeriod.TotalDays < Math.Min(3, settings.DurationDays))
|
||||||
|
{
|
||||||
|
var pr = new Data.PaymentRequestData()
|
||||||
|
{
|
||||||
|
StoreDataId = app.StoreDataId,
|
||||||
|
Status = PaymentRequestData.PaymentRequestStatus.Pending,
|
||||||
|
Created = DateTimeOffset.UtcNow, Archived = false
|
||||||
|
};
|
||||||
|
pr.SetBlob(new PaymentRequestBaseData()
|
||||||
|
{
|
||||||
|
ExpiryDate = currentPeriod.PeriodEnd,
|
||||||
|
Amount = settings.Price,
|
||||||
|
Currency = settings.Currency,
|
||||||
|
StoreId = app.StoreDataId,
|
||||||
|
Title = $"{settings.SubscriptionName} Subscription Renewal",
|
||||||
|
Description = settings.Description,
|
||||||
|
AdditionalData = lastBlob.AdditionalData
|
||||||
|
});
|
||||||
|
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
|
||||||
|
|
||||||
|
var newHistory = new SubscriptionPaymentHistory()
|
||||||
|
{
|
||||||
|
PaymentRequestId = pr.Id,
|
||||||
|
PeriodStart = currentPeriod.PeriodEnd,
|
||||||
|
PeriodEnd = currentPeriod.PeriodEnd.AddDays(settings.DurationDays),
|
||||||
|
Settled = false
|
||||||
|
};
|
||||||
|
subscription.Value.Payments.Add(newHistory);
|
||||||
|
|
||||||
|
deliverRequests.Add((app.Id, subscription.Key, pr.Id, subscription.Value.Email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.SetSettings(settings);
|
||||||
|
|
||||||
|
await _appService.UpdateOrCreateApp(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var deliverRequest in deliverRequests)
|
||||||
|
{
|
||||||
|
var webhooks = await _webhookSender.GetWebhooks(app.StoreDataId, SubscriptionRenewalRequested);
|
||||||
|
foreach (var webhook in webhooks)
|
||||||
|
{
|
||||||
|
_webhookSender.EnqueueDelivery(CreateSubscriptionRenewalRequestedDeliveryRequest(webhook,
|
||||||
|
app.Id, app.StoreDataId, deliverRequest.subscriptionId, null,
|
||||||
|
deliverRequest.paymentRequestId, deliverRequest.email));
|
||||||
|
}
|
||||||
|
|
||||||
|
EventAggregator.Publish(CreateSubscriptionRenewalRequestedDeliveryRequest(null, app.Id,
|
||||||
|
app.StoreDataId, deliverRequest.subscriptionId, null,
|
||||||
|
deliverRequest.paymentRequestId, deliverRequest.email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, tcs));
|
||||||
|
await tcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void SubscribeToEvents()
|
||||||
|
{
|
||||||
|
Subscribe<PaymentRequestEvent>();
|
||||||
|
Subscribe<SequentialExecute>();
|
||||||
|
base.SubscribeToEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public record SequentialExecute(Func<Task<object>> Action, TaskCompletionSource<object> TaskCompletionSource);
|
||||||
|
|
||||||
|
|
||||||
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case SequentialExecute sequentialExecute:
|
||||||
|
{
|
||||||
|
var task = await sequentialExecute.Action();
|
||||||
|
sequentialExecute.TaskCompletionSource.SetResult(task);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case PaymentRequestEvent paymentRequestUpdated
|
||||||
|
when paymentRequestUpdated.Type == PaymentRequestEvent.StatusChanged:
|
||||||
|
{
|
||||||
|
var prBlob = paymentRequestUpdated.Data.GetBlob();
|
||||||
|
if (!prBlob.AdditionalData.TryGetValue("source", out var src) ||
|
||||||
|
src.Value<string>() != "subscription" ||
|
||||||
|
!prBlob.AdditionalData.TryGetValue("appId", out var subscriptionAppidToken) ||
|
||||||
|
subscriptionAppidToken.Value<string>() is not { } subscriptionAppId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isNew = !prBlob.AdditionalData.TryGetValue("subcriptionId", out var subscriptionIdToken);
|
||||||
|
|
||||||
|
if (isNew && paymentRequestUpdated.Data.Status != PaymentRequestData.PaymentRequestStatus.Completed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (paymentRequestUpdated.Data.Status == PaymentRequestData.PaymentRequestStatus.Completed)
|
||||||
|
{
|
||||||
|
var subscriptionId = subscriptionIdToken?.Value<string>();
|
||||||
|
var blob = paymentRequestUpdated.Data.GetBlob();
|
||||||
|
var email = blob.Email ?? blob.FormResponse?["buyerEmail"]?.Value<string>();
|
||||||
|
await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestUpdated.Data.Id, email);
|
||||||
|
}
|
||||||
|
else if (!isNew)
|
||||||
|
{
|
||||||
|
await HandleUnSettledSubscription(subscriptionAppId, subscriptionIdToken.Value<string>(),
|
||||||
|
paymentRequestUpdated.Data.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await base.ProcessEvent(evt, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleUnSettledSubscription(string appId, string subscriptionId, string paymenRequestId)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true);
|
||||||
|
if (app == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
if (settings.Subscriptions.TryGetValue(subscriptionId, out var subscription))
|
||||||
|
{
|
||||||
|
var existingPayment = subscription.Payments.Find(p => p.PaymentRequestId == paymenRequestId);
|
||||||
|
if (existingPayment is not null)
|
||||||
|
existingPayment.Settled = false;
|
||||||
|
|
||||||
|
var changed = DetermineStatusOfSubscription(subscription);
|
||||||
|
|
||||||
|
app.SetSettings(settings);
|
||||||
|
await _appService.UpdateOrCreateApp(app);
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
var webhooks = await _webhookSender.GetWebhooks(app.StoreDataId, SubscriptionStatusUpdated);
|
||||||
|
foreach (var webhook in webhooks)
|
||||||
|
{
|
||||||
|
_webhookSender.EnqueueDelivery(CreateSubscriptionStatusUpdatedDeliveryRequest(webhook, app.Id,
|
||||||
|
app.StoreDataId,
|
||||||
|
subscriptionId, subscription.Status, null, subscription.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
EventAggregator.Publish(CreateSubscriptionStatusUpdatedDeliveryRequest(null, app.Id, app.StoreDataId,
|
||||||
|
subscriptionId, subscription.Status, null, subscription.Email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandlePaidSubscription(string appId, string? subscriptionId, string paymentRequestId, string? email)
|
||||||
|
{
|
||||||
|
var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true);
|
||||||
|
if (app == null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var settings = app.GetSettings<SubscriptionAppSettings>();
|
||||||
|
|
||||||
|
subscriptionId ??= Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
if (!settings.Subscriptions.TryGetValue(subscriptionId, out var subscription))
|
||||||
|
{
|
||||||
|
subscription = new Subscription()
|
||||||
|
{
|
||||||
|
Email = email,
|
||||||
|
Start = DateTimeOffset.UtcNow,
|
||||||
|
Status = SubscriptionStatus.Inactive,
|
||||||
|
Payments =
|
||||||
|
[
|
||||||
|
new SubscriptionPaymentHistory()
|
||||||
|
{
|
||||||
|
PaymentRequestId = paymentRequestId,
|
||||||
|
PeriodStart = DateTimeOffset.UtcNow,
|
||||||
|
PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays),
|
||||||
|
Settled = true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
settings.Subscriptions.Add(subscriptionId, subscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
var existingPayment = subscription.Payments.Find(p => p.PaymentRequestId == paymentRequestId);
|
||||||
|
if (existingPayment is null)
|
||||||
|
{
|
||||||
|
subscription.Payments.Add(new SubscriptionPaymentHistory()
|
||||||
|
{
|
||||||
|
PaymentRequestId = paymentRequestId,
|
||||||
|
PeriodStart = DateTimeOffset.UtcNow,
|
||||||
|
PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays),
|
||||||
|
Settled = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
existingPayment.Settled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var changed = DetermineStatusOfSubscription(subscription);
|
||||||
|
app.SetSettings(settings);
|
||||||
|
await _appService.UpdateOrCreateApp(app);
|
||||||
|
|
||||||
|
var paymentRequest =
|
||||||
|
await _paymentRequestRepository.FindPaymentRequest(paymentRequestId, null, CancellationToken.None);
|
||||||
|
var blob = paymentRequest.GetBlob();
|
||||||
|
blob.AdditionalData.TryGetValue("url", out var urlToken);
|
||||||
|
var path = _linkGenerator.GetPathByAction("ViewSubscription", "Subscription", new {appId, id = subscriptionId});
|
||||||
|
var url = new Uri(new Uri(urlToken.Value<string>()), path);
|
||||||
|
if (blob.Description.Contains(url.ToString()))
|
||||||
|
return;
|
||||||
|
var subscriptionHtml =
|
||||||
|
"<div class=\"d-flex justify-content-center mt-4\"><a class=\"btn btn-primary\" href=\"" + url +
|
||||||
|
"\">View Subscription</a></div>";
|
||||||
|
blob.Description += subscriptionHtml;
|
||||||
|
blob.AdditionalData["subscriptionHtml"] = JToken.FromObject(subscriptionHtml);
|
||||||
|
blob.AdditionalData["subscriptionUrl"] = JToken.FromObject(url);
|
||||||
|
paymentRequest.SetBlob(blob);
|
||||||
|
await _paymentRequestRepository.CreateOrUpdatePaymentRequest(paymentRequest);
|
||||||
|
if (changed)
|
||||||
|
{
|
||||||
|
var webhooks = await _webhookSender.GetWebhooks(app.StoreDataId, SubscriptionStatusUpdated);
|
||||||
|
foreach (var webhook in webhooks)
|
||||||
|
{
|
||||||
|
_webhookSender.EnqueueDelivery(CreateSubscriptionStatusUpdatedDeliveryRequest(webhook, app.Id,
|
||||||
|
app.StoreDataId,
|
||||||
|
subscriptionId, subscription.Status, url.ToString(), subscription.Email));
|
||||||
|
}
|
||||||
|
|
||||||
|
EventAggregator.Publish(CreateSubscriptionStatusUpdatedDeliveryRequest(null, app.Id, app.StoreDataId,
|
||||||
|
subscriptionId, subscription.Status, url.ToString(), subscription.Email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionWebhookDeliveryRequest CreateSubscriptionStatusUpdatedDeliveryRequest(WebhookData? webhook,
|
||||||
|
string appId, string storeId, string subscriptionId, SubscriptionStatus status, string subscriptionUrl, string email)
|
||||||
|
{
|
||||||
|
var webhookEvent = new WebhookSubscriptionEvent(SubscriptionStatusUpdated, storeId)
|
||||||
|
{
|
||||||
|
AppId = appId,
|
||||||
|
SubscriptionId = subscriptionId,
|
||||||
|
Status = status.ToString(),
|
||||||
|
Email = email
|
||||||
|
};
|
||||||
|
var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
|
||||||
|
if (delivery is not null)
|
||||||
|
{
|
||||||
|
webhookEvent.DeliveryId = delivery.Id;
|
||||||
|
webhookEvent.OriginalDeliveryId = delivery.Id;
|
||||||
|
webhookEvent.Timestamp = delivery.Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubscriptionWebhookDeliveryRequest(subscriptionUrl, webhook?.Id,
|
||||||
|
webhookEvent,
|
||||||
|
delivery,
|
||||||
|
webhook?.GetBlob(), _btcPayNetworkJsonSerializerSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionWebhookDeliveryRequest CreateSubscriptionRenewalRequestedDeliveryRequest(WebhookData? webhook,
|
||||||
|
string appId, string storeId, string subscriptionId, string subscriptionUrl,
|
||||||
|
string paymentRequestId, string email)
|
||||||
|
{
|
||||||
|
var webhookEvent = new WebhookSubscriptionEvent(SubscriptionRenewalRequested, storeId)
|
||||||
|
{
|
||||||
|
AppId = appId,
|
||||||
|
SubscriptionId = subscriptionId,
|
||||||
|
PaymentRequestId = paymentRequestId,
|
||||||
|
Email = email
|
||||||
|
};
|
||||||
|
var delivery = webhook is null ? null : WebhookExtensions.NewWebhookDelivery(webhook.Id);
|
||||||
|
if (delivery is not null)
|
||||||
|
{
|
||||||
|
webhookEvent.DeliveryId = delivery.Id;
|
||||||
|
webhookEvent.OriginalDeliveryId = delivery.Id;
|
||||||
|
webhookEvent.Timestamp = delivery.Timestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SubscriptionWebhookDeliveryRequest(subscriptionUrl, webhook?.Id,
|
||||||
|
webhookEvent,
|
||||||
|
delivery,
|
||||||
|
webhook?.GetBlob(), _btcPayNetworkJsonSerializerSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public bool DetermineStatusOfSubscription(Subscription subscription)
|
||||||
|
{
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
if (subscription.Payments.Count == 0)
|
||||||
|
{
|
||||||
|
if (subscription.Status != SubscriptionStatus.Inactive)
|
||||||
|
{
|
||||||
|
subscription.Status = SubscriptionStatus.Inactive;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var newStatus =
|
||||||
|
subscription.Payments.Any(history =>
|
||||||
|
history.Settled && history.PeriodStart <= now && history.PeriodEnd >= now)
|
||||||
|
? SubscriptionStatus.Active
|
||||||
|
: SubscriptionStatus.Inactive;
|
||||||
|
if (newStatus != subscription.Status)
|
||||||
|
{
|
||||||
|
subscription.Status = newStatus;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public const string SubscriptionStatusUpdated = "SubscriptionStatusUpdated";
|
||||||
|
public const string SubscriptionRenewalRequested = "SubscriptionRenewalRequested";
|
||||||
|
|
||||||
|
public Dictionary<string, string> GetSupportedWebhookTypes()
|
||||||
|
{
|
||||||
|
return new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{SubscriptionStatusUpdated, "A subscription status has been updated"},
|
||||||
|
{SubscriptionRenewalRequested, "A subscription has generated a payment request for renewal"}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookEvent CreateTestEvent(string type, params object[] args)
|
||||||
|
{
|
||||||
|
var storeId = args[0].ToString();
|
||||||
|
return new WebhookSubscriptionEvent(type, storeId)
|
||||||
|
{
|
||||||
|
AppId = "__test__" + Guid.NewGuid() + "__test__",
|
||||||
|
SubscriptionId = "__test__" + Guid.NewGuid() + "__test__",
|
||||||
|
Status = SubscriptionStatus.Active.ToString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public class WebhookSubscriptionEvent : StoreWebhookEvent
|
||||||
|
{
|
||||||
|
public WebhookSubscriptionEvent(string type, string storeId)
|
||||||
|
{
|
||||||
|
if (!type.StartsWith("subscription", StringComparison.InvariantCultureIgnoreCase))
|
||||||
|
throw new ArgumentException("Invalid event type", nameof(type));
|
||||||
|
Type = type;
|
||||||
|
StoreId = storeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[JsonProperty(Order = 2)] public string AppId { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty(Order = 3)] public string SubscriptionId { get; set; }
|
||||||
|
[JsonProperty(Order = 4)] public string Status { get; set; }
|
||||||
|
[JsonProperty(Order = 5)] public string PaymentRequestId { get; set; }
|
||||||
|
[JsonProperty(Order = 6)] public string Email { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriptionWebhookDeliveryRequest(
|
||||||
|
string receiptUrl,
|
||||||
|
string? webhookId,
|
||||||
|
WebhookSubscriptionEvent webhookEvent,
|
||||||
|
WebhookDeliveryData? delivery,
|
||||||
|
WebhookBlob? webhookBlob,
|
||||||
|
BTCPayNetworkJsonSerializerSettings btcPayNetworkJsonSerializerSettings)
|
||||||
|
: WebhookSender.WebhookDeliveryRequest(webhookId!, webhookEvent, delivery!, webhookBlob!)
|
||||||
|
{
|
||||||
|
public override Task<SendEmailRequest?> Interpolate(SendEmailRequest req,
|
||||||
|
UIStoresController.StoreEmailRule storeEmailRule)
|
||||||
|
{
|
||||||
|
if (storeEmailRule.CustomerEmail &&
|
||||||
|
MailboxAddressValidator.TryParse(webhookEvent.Email, out var bmb))
|
||||||
|
{
|
||||||
|
req.Email ??= string.Empty;
|
||||||
|
req.Email += $",{bmb}";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
req.Subject = Interpolate(req.Subject);
|
||||||
|
req.Body = Interpolate(req.Body);
|
||||||
|
return Task.FromResult(req)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string Interpolate(string str)
|
||||||
|
{
|
||||||
|
var res = str.Replace("{Subscription.SubscriptionId}", webhookEvent.SubscriptionId)
|
||||||
|
.Replace("{Subscription.Status}", webhookEvent.Status)
|
||||||
|
.Replace("{Subscription.PaymentRequestId}", webhookEvent.PaymentRequestId)
|
||||||
|
.Replace("{Subscription.AppId}", webhookEvent.AppId);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public enum SubscriptionStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Inactive
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
@using BTCPayServer.Client
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using BTCPayServer.Services.Apps
|
||||||
|
@using BTCPayServer
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@using BTCPayServer.Plugins.Subscriptions
|
||||||
|
@inject AppService AppService;
|
||||||
|
@model BTCPayServer.Components.MainNav.MainNavViewModel
|
||||||
|
@{
|
||||||
|
var store = Context.GetStoreData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (store != null)
|
||||||
|
{
|
||||||
|
var appType = AppService.GetAppType(SubscriptionApp.AppType)!;
|
||||||
|
<li class="nav-item" permission="@Policies.CanModifyStoreSettings">
|
||||||
|
<a asp-area="" asp-controller="UIApps" asp-action="CreateApp" asp-route-storeId="@store.Id" asp-route-appType="@appType.Type" class="nav-link @ViewData.IsActivePage(AppsNavPages.Create, appType.Type)" id="@($"StoreNav-Create{appType.Type}")">
|
||||||
|
<svg
|
||||||
|
style=" height: 15px;
|
||||||
|
margin-left: 5px;
|
||||||
|
margin-right: 5px;"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
shape-rendering="geometricPrecision"
|
||||||
|
text-rendering="geometricPrecision"
|
||||||
|
image-rendering="optimizeQuality"
|
||||||
|
fill-rule="evenodd" clip-rule="evenodd"
|
||||||
|
viewBox="0 0 512 496.61">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M42.09 42.35h38.82v51.8c0 14.4 6.7 27.13 17.51 35.98 9.13 7.49 21.28 12.13 34.26 12.13 12.99 0 25.14-4.64 34.28-12.13 10.8-8.85 17.5-21.58 17.5-35.98v-51.8h78.39v51.8c0 14.4 6.69 27.13 17.5 35.98 9.13 7.49 21.29 12.13 34.27 12.13 12.98 0 25.14-4.64 34.27-12.13 10.81-8.85 17.51-21.58 17.51-35.98v-51.8h40.52c11.56 0 22.08 4.75 29.72 12.38 7.64 7.61 12.38 18.14 12.38 29.72v144.24a157.45 157.45 0 0 0-17.98-5.73v-58.75H17.98v244.83c0 18.19 14.89 33.1 33.1 33.1h201.39a154.57 154.57 0 0 0 10.28 17.97H42.09c-11.54 0-22.06-4.73-29.7-12.36C4.74 440.08 0 429.57 0 418.03V84.44c0-11.56 4.74-22.07 12.36-29.7 7.65-7.65 18.18-12.39 29.73-12.39zm324.19 430.69c5.37 1.43 8.56 6.95 7.13 12.32-1.44 5.37-6.96 8.56-12.32 7.13a120.88 120.88 0 0 1-13.74-4.57c-25.72-10.29-46.32-28.83-59.5-51.65-15.46-26.74-20.7-59.38-12.09-91.54 8.61-32.15 29.47-57.78 56.22-73.23 26.73-15.46 59.38-20.71 91.54-12.08 11.24 3.01 21.69 7.51 31.18 13.23a120.08 120.08 0 0 1 21 16.1l-.55-5.33c-.56-5.51 3.44-10.44 8.95-11 5.51-.56 10.43 3.45 11 8.96l2.88 27.82c.1 1.02.05 2.03-.15 2.99-.42 4.99-4.53 8.99-9.66 9.15l-27.95.99c-5.52.17-10.15-4.18-10.32-9.7-.17-5.53 4.17-10.15 9.69-10.32l1.12-.04c-4.98-4.63-10.47-8.78-16.4-12.37-7.92-4.78-16.63-8.53-25.99-11.04-26.82-7.18-54.01-2.82-76.26 10.03-22.29 12.84-39.66 34.21-46.84 61.03-7.19 26.82-2.84 54.01 10.02 76.28 6.39 11.09 14.89 20.97 25.13 28.97 10.19 7.98 23.41 14.54 35.91 17.87zm21.37-160.44h20.09l-.48 12.66c8.45.53 15.83 1.37 22.16 2.54l-4.59 24.51H401.1c-3.69 0-6.15.58-7.36 1.74-1.21 1.16-1.87 3.38-1.98 6.64l9.97 1.11c12.13 1.37 20.49 4.48 25.08 9.34 4.58 4.85 6.88 11.12 6.88 18.82 0 7.7-.8 13.85-2.38 18.43-1.58 4.58-3.85 8.09-6.8 10.52-5.38 4.11-12.44 6.49-21.19 7.12l-.48 15.97h-20.41l.64-15.97c-10.02-.73-18.51-2.01-25.47-3.8l4.58-25.15c8.76 2.32 17.93 3.48 27.53 3.48 4.01 0 7.75-.21 11.23-.63v-6.65l-9.81-1.11c-12.66-1.26-21.09-4.95-25.31-11.07-3.69-5.37-5.53-12.39-5.53-21.04 0-11.38 2.34-19.66 7.04-24.83 4.69-5.17 11.3-8.34 19.85-9.49l.47-13.14zm10.79 163.84c-13.97 1.17-11.44 21.17 1.08 20.13 7.13-.34 14.89-1.52 21.82-3.25 12.86-3.22 8.01-22.79-4.91-19.55-5.95 1.45-11.88 2.3-17.99 2.67zm51.19-17.66c-10.86 7.61.68 24.13 11.55 16.52 5.89-4.05 11.85-9.12 16.82-14.25 9.13-9.13-4.81-23.69-14.47-14.04-4 4.19-9.12 8.49-13.9 11.77zm34.42-42.03c-5.4 11.87 12.83 20.48 18.43 8.16 3.08-6.97 5.14-13.44 7.06-20.79 3.17-12.65-16.32-17.82-19.6-4.69-1.74 6.23-3.25 11.36-5.89 17.32zm7.88-53.71c2.11 12.71 20.07 10.98 20.07-1.51l-.08-1.09c-1-7.42-2.51-14.26-4.77-21.42-4.29-12.87-23.3-6.18-19.19 6.16a105.17 105.17 0 0 1 3.97 17.86zm-294.54-43.78h49.42c-6.41 17.06-9.94 35.54-9.94 54.84 0 3.59.14 7.16.38 10.69h-39.86c-4.21 0-7.69-3.45-7.69-7.67v-50.18c0-4.22 3.46-7.68 7.69-7.68zm0-106.69h60.3c4.23 0 7.68 3.47 7.68 7.68v50.17c0 4.2-3.47 7.68-7.68 7.68h-60.3c-4.21 0-7.69-3.46-7.69-7.68v-50.17c0-4.23 3.46-7.68 7.69-7.68zm-121.63 0h60.3c4.23 0 7.69 3.47 7.69 7.68v50.17c0 4.2-3.48 7.68-7.69 7.68h-60.3c-4.21 0-7.68-3.46-7.68-7.68v-50.17c0-4.23 3.46-7.68 7.68-7.68zm243.25 0h60.31c3.57 0 6.58 2.48 7.44 5.78-27.59 1.04-53.33 9.25-75.43 22.81v-20.91c0-4.23 3.46-7.68 7.68-7.68zM75.76 319.26h60.3c4.23 0 7.69 3.48 7.69 7.68v50.18c0 4.2-3.48 7.67-7.69 7.67h-60.3c-4.21 0-7.68-3.45-7.68-7.67v-50.18c0-4.22 3.46-7.68 7.68-7.68zM294.3 16.66C294.3 7.47 303.39 0 314.62 0c11.24 0 20.33 7.47 20.33 16.66v77.49c0 9.19-9.09 16.66-20.33 16.66-11.23 0-20.32-7.47-20.32-16.66V16.66zm-181.94 0c0-9.19 9.09-16.66 20.32-16.66 11.24 0 20.33 7.47 20.33 16.66v77.49c0 9.19-9.09 16.66-20.33 16.66-11.23 0-20.32-7.47-20.32-16.66V16.66z"/>
|
||||||
|
</svg>
|
||||||
|
<span>@appType.Description</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@foreach (var app in Model.Apps.Where(app => app.AppType == appType.Type))
|
||||||
|
{
|
||||||
|
<li class="nav-item nav-item-sub" permission="@Policies.CanModifyStoreSettings">
|
||||||
|
<a asp-area="" asp-controller="Subscription" asp-action="Update" asp-route-appId="@app.Id" class="nav-link @ViewData.IsActivePage(AppsNavPages.Update, app.Id)" id="@($"StoreNav-App-{app.Id}")">
|
||||||
|
<span>@app.AppName</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@using BTCPayServer.Abstractions.Services
|
||||||
|
@inject Safe Safe
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@addTagHelper *, BTCPayServer
|
||||||
|
@addTagHelper *, BTCPayServer.Abstractions
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using Microsoft.AspNetCore.Routing
|
||||||
|
@using BTCPayServer
|
||||||
|
@using BTCPayServer.Abstractions.Models
|
||||||
|
@using BTCPayServer.Forms
|
||||||
|
@using BTCPayServer.Services.Apps
|
||||||
|
@using BTCPayServer.TagHelpers
|
||||||
|
@model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings
|
||||||
|
@inject FormDataService FormDataService
|
||||||
|
@{
|
||||||
|
var appId = Context.GetRouteValue("appId").ToString();
|
||||||
|
var storeId = Context.GetCurrentStoreId();
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update.ToString(), typeof(AppsNavPages).ToString(), "Update Subscription app", appId);
|
||||||
|
var checkoutFormOptions = await FormDataService.GetSelect(storeId, Model.FormId);
|
||||||
|
var archived = ViewData["Archived"] as bool? is true;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
|
||||||
|
<div class="sticky-header-setup"></div>
|
||||||
|
<div class="sticky-header d-sm-flex align-items-center justify-content-between">
|
||||||
|
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||||
|
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||||
|
<input type="submit" value="Save" name="command" class="btn btn-primary"/>
|
||||||
|
@if (archived)
|
||||||
|
{
|
||||||
|
}else if (this.ViewContext.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<a class="btn btn-secondary" target="_blank" href=" @Url.Action("View", "Subscription", new {appId})">
|
||||||
|
Subscription page
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<partial name="_StatusMessage"/>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="SubscriptionName" class="form-label" data-required>App name</label>
|
||||||
|
<input asp-for="SubscriptionName" class="form-control" required/>
|
||||||
|
<span asp-validation-for="SubscriptionName" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="form-group flex-fill me-4">
|
||||||
|
<label asp-for="Price" class="form-label" data-required></label>
|
||||||
|
<input type="number" inputmode="decimal" step="any" asp-for="Price" class="form-control" required/>
|
||||||
|
<span asp-validation-for="Price" class="text-danger"></span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Currency" class="form-label"></label>
|
||||||
|
<input asp-for="Currency" class="form-control w-auto" currency-selection/>
|
||||||
|
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="DurationDays" class="form-label" data-required></label>
|
||||||
|
<input type="number" inputmode="decimal" step="1" min="1" asp-for="DurationDays" class="form-control" required/>
|
||||||
|
<span asp-validation-for="DurationDays" class="text-danger"></span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="FormId" class="form-label"></label>
|
||||||
|
<select asp-for="FormId" class="form-select w-auto" asp-items="@checkoutFormOptions"></select>
|
||||||
|
<span asp-validation-for="FormId" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-10 col-xxl-constrain">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Description" class="form-label"></label>
|
||||||
|
<textarea asp-for="Description" class="form-control richtext"></textarea>
|
||||||
|
<span asp-validation-for="Description" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@if (Model.Subscriptions?.Any() is true)
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-10 col-xxl-constrain">
|
||||||
|
<div class="table-responsive">
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Subscription</th>
|
||||||
|
<th>Created</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Email</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var sub in Model.Subscriptions)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a asp-action="ViewSubscription"
|
||||||
|
asp-controller="Subscription"
|
||||||
|
asp-route-appId="@appId"
|
||||||
|
asp-route-id="@sub.Key">
|
||||||
|
|
||||||
|
<vc:truncate-center text="@sub.Key" padding="7" classes="truncate-center-id"/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td>@sub.Value.Start.ToBrowserDate()</td>
|
||||||
|
<td>@sub.Value.Status</td>
|
||||||
|
<td>@sub.Value.Email</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="pt-0">
|
||||||
|
<table class="table bg-light my-0">
|
||||||
|
<tr>
|
||||||
|
<th>Payment Request</th>
|
||||||
|
<th>Period Start</th>
|
||||||
|
<th>Period End</th>
|
||||||
|
<th>Settled</th>
|
||||||
|
</tr>
|
||||||
|
@foreach (var x in sub.Value.Payments)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a asp-action="ViewPaymentRequest"
|
||||||
|
asp-controller="UIPaymentRequest"
|
||||||
|
asp-route-payReqId="@x.PaymentRequestId">
|
||||||
|
|
||||||
|
<vc:truncate-center text="@x.PaymentRequestId" padding="7" classes="truncate-center-id"/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>@x.PeriodStart.ToBrowserDate()</td>
|
||||||
|
<td>@x.PeriodEnd.ToBrowserDate()</td>
|
||||||
|
<td>@x.Settled</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="d-grid d-sm-flex flex-wrap gap-3 mt-3">
|
||||||
|
<form method="post" asp-controller="UIApps" asp-action="ToggleArchive" asp-route-appId="@appId">
|
||||||
|
<button type="submit" class="w-100 btn btn-outline-secondary" id="btn-archive-toggle">
|
||||||
|
@if (archived)
|
||||||
|
{
|
||||||
|
<span class="text-nowrap">Unarchive this app</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-nowrap" data-bs-toggle="tooltip" title="Archive this app so that it does not appear in the apps list by default">Archive this app</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<a id="DeleteApp" class="btn btn-outline-danger" asp-controller="UIApps" asp-action="DeleteApp" asp-route-appId="@appId" data-bs-toggle="modal" data-bs-target="#ConfirmModal" data-description="The app and its settings will be permanently deleted." data-confirm-input="DELETE">Delete this app</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_Confirm" model="@(new ConfirmModel("Delete app", "This app will be removed from this store.", "Delete"))"/>
|
||||||
|
|
||||||
|
<partial name="_ValidationScriptsPartial"/>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@using Microsoft.AspNetCore.Routing
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Models
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@inject DisplayFormatter DisplayFormatter
|
||||||
|
@model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings
|
||||||
|
@{
|
||||||
|
var appId = Context.GetRouteValue("appId");
|
||||||
|
StoreBrandingViewModel storeBranding = (StoreBrandingViewModel) ViewData["StoreBranding"];
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<partial name="LayoutHead"/>
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body class="min-vh-100">
|
||||||
|
<div id="Subscription" class="public-page-wrap">
|
||||||
|
|
||||||
|
<partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/>
|
||||||
|
<main>
|
||||||
|
<partial name="_StatusMessage"/>
|
||||||
|
<div class="text-muted mb-4 text-center lead ">@Model.DurationDays day@(Model.DurationDays>1?"s": "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)</div>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Description))
|
||||||
|
{
|
||||||
|
<div class="subscription-description lead text-center">@Safe.Raw(Model.Description)</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="text-center w-100 mt-4"> <a asp-action="Subscribe" asp-route-appId="@appId" asp- class="btn btn-primary btn-lg m-auto">Subscribe</a></div>
|
||||||
|
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="store-footer">
|
||||||
|
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||||
|
Powered by <partial name="_StoreFooterLogo"/>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<partial name="LayoutFoot"/>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@using Microsoft.AspNetCore.Routing
|
||||||
|
@using Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@using BTCPayServer.Models
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@using BTCPayServer.Abstractions.Extensions
|
||||||
|
@using BTCPayServer.Plugins.Subscriptions
|
||||||
|
@inject DisplayFormatter DisplayFormatter
|
||||||
|
@model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings
|
||||||
|
@{
|
||||||
|
var appId = Context.GetRouteValue("appId");
|
||||||
|
var subscriptionId = Context.GetRouteValue("id") as string;
|
||||||
|
var subscription = Model.Subscriptions[subscriptionId!];
|
||||||
|
StoreBrandingViewModel storeBranding = (StoreBrandingViewModel) ViewData["StoreBranding"];
|
||||||
|
Layout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<partial name="LayoutHead"/>
|
||||||
|
</head>
|
||||||
|
<body class="min-vh-100">
|
||||||
|
<div id="Subscription" class="public-page-wrap">
|
||||||
|
|
||||||
|
<partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/>
|
||||||
|
<main>
|
||||||
|
<partial name="_StatusMessage"/>
|
||||||
|
<div class="text-muted mb-4 text-center lead ">@Model.DurationDays day@(Model.DurationDays > 1 ? "s" : "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)</div>
|
||||||
|
<div class=" mb-4 text-center lead d-flex gap-2 justify-content-center">
|
||||||
|
<span class=" fw-semibold ">@subscription.Status</span>
|
||||||
|
@if (subscription.Status == SubscriptionStatus.Inactive)
|
||||||
|
{
|
||||||
|
<a class="btn btn-link" asp-action="Reactivate" asp-controller="Subscription" asp-route-id="@subscriptionId" asp-route-appId="@appId">Reactivate</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Description))
|
||||||
|
{
|
||||||
|
<div class="subscription-description lead text-center mb-4">@Safe.Raw(Model.Description)</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<section class="tile">
|
||||||
|
|
||||||
|
@if (subscription.Payments?.Any() is not true)
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-0">No payments have been made yet.</p>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive my-0">
|
||||||
|
<table class="invoice table table-borderless">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="fw-normal text-secondary" scope="col">Payment Request Id</th>
|
||||||
|
<th class="fw-normal text-secondary">Period</th>
|
||||||
|
<th class="fw-normal text-secondary text-end">Settled</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var payment in subscription.Payments)
|
||||||
|
{
|
||||||
|
var isThisPeriodActive = payment.PeriodStart <= DateTimeOffset.UtcNow && payment.PeriodEnd >= DateTimeOffset.UtcNow;
|
||||||
|
var isThisPeriodFuture = payment.PeriodStart > DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a asp-action="ViewPaymentRequest"
|
||||||
|
asp-controller="UIPaymentRequest"
|
||||||
|
asp-route-payReqId="@payment.PaymentRequestId">
|
||||||
|
<vc:truncate-center text="@payment.PaymentRequestId" padding="7" classes="truncate-center-id"/>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="text-print-default d-flex justify-content-start gap-2">
|
||||||
|
<div> @payment.PeriodStart.ToBrowserDate() - @payment.PeriodEnd.ToBrowserDate()</div>
|
||||||
|
@if (payment.Settled && isThisPeriodActive)
|
||||||
|
{
|
||||||
|
<span class="badge badge-settled">Active</span>
|
||||||
|
}
|
||||||
|
@if (isThisPeriodFuture)
|
||||||
|
{
|
||||||
|
<span class="badge badge-processing">Next period</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-end text-print-default ">
|
||||||
|
@if (payment.Settled)
|
||||||
|
{
|
||||||
|
<span class="badge badge-settled">Settled</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge badge-invalid">Not settled</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="store-footer">
|
||||||
|
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||||
|
Powered by <partial name="_StoreFooterLogo"/>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
<partial name="LayoutFoot"/>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
@using BTCPayServer.Abstractions.Services
|
||||||
|
@inject Safe Safe
|
||||||
|
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||||
|
@addTagHelper *, BTCPayServer
|
||||||
|
@addTagHelper *, BTCPayServer.Abstractions
|
||||||
Submodule submodules/btcpayserver updated: 83028b9b73...4ebe46830b
Reference in New Issue
Block a user