This commit is contained in:
Kukks
2024-04-11 15:10:57 +02:00
parent 71cb4c3f82
commit 215edd5b0a
18 changed files with 1352 additions and 1 deletions

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
namespace BTCPayServer.Plugins.Subscriptions;
public enum SubscriptionStatus
{
Active,
Inactive
}

View File

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

View File

@@ -0,0 +1,5 @@
@using BTCPayServer.Abstractions.Services
@inject Safe Safe
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer
@addTagHelper *, BTCPayServer.Abstractions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
@using BTCPayServer.Abstractions.Services
@inject Safe Safe
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, BTCPayServer
@addTagHelper *, BTCPayServer.Abstractions