diff --git a/BTCPayServerPlugins.sln b/BTCPayServerPlugins.sln index 3478fb4..f43c282 100644 --- a/BTCPayServerPlugins.sln +++ b/BTCPayServerPlugins.sln @@ -59,6 +59,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.Blink" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BTCPayServer.Plugins.MicroNode", "Plugins\BTCPayServer.Plugins.MicroNode\BTCPayServer.Plugins.MicroNode.csproj", "{95626F3B-7722-4AE7-9C12-EDB1E58687E2}" 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 GlobalSection(SolutionConfigurationPlatforms) = preSolution 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-Release|Any CPU.ActiveCfg = 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 GlobalSection(NestedProjects) = preSolution {B19C9F52-DC47-466D-8B5C-2D202B7B003F} = {9E04ECE9-E304-4FF2-9CBC-83256E6C6962} diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/BTCPayServer.Plugins.Subscriptions.csproj b/Plugins/BTCPayServer.Plugins.Subscriptions/BTCPayServer.Plugins.Subscriptions.csproj new file mode 100644 index 0000000..38640b4 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/BTCPayServer.Plugins.Subscriptions.csproj @@ -0,0 +1,42 @@ + + + + net8.0 + 12 + enable + + + + + Subscriptions + Offer and manage subscriptions through BTCPay Server + 1.0.0 + true + + + + true + false + true + + + + + + StaticWebAssetsEnabled=false + false + runtime;native;build;buildTransitive;contentFiles + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/GreenfieldSubscriptionsController.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/GreenfieldSubscriptionsController.cs new file mode 100644 index 0000000..2efe4e4 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/GreenfieldSubscriptionsController.cs @@ -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 GetSubscription(string appId) + { + var app = await _appService.GetApp(appId, SubscriptionApp.AppType, includeArchived: true); + if (app == null) + { + return AppNotFound(); + } + + var ss = app.GetSettings(); + return Ok(ss); + } + + private IActionResult AppNotFound() + { + return this.CreateAPIError(404, "app-not-found", "The app with specified ID was not found"); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Subscription.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscription.cs new file mode 100644 index 0000000..58f440d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscription.cs @@ -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 Payments { get; set; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionApp.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionApp.cs new file mode 100644 index 0000000..694deb1 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionApp.cs @@ -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 _options; + public const string AppType = "Subscription"; + + public SubscriptionApp( + LinkGenerator linkGenerator, + IOptions options) + { + Description = "Subscription"; + Type = AppType; + _linkGenerator = linkGenerator; + _options = options; + } + + public override Task ConfigureLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction( + nameof(SubscriptionController.Update), + "Subscription", new {appId = app.Id}, _options.Value.RootPath)!); + } + + public override Task GetInfo(AppData appData) + { + return Task.FromResult(null); + } + + public override Task SetDefaultSettings(AppData appData, string defaultCurrency) + { + appData.SetSettings(new SubscriptionAppSettings()); + return Task.CompletedTask; + } + + public override Task ViewLink(AppData app) + { + return Task.FromResult(_linkGenerator.GetPathByAction(nameof(SubscriptionController.View), + "Subscription", new {appId = app.Id}, _options.Value.RootPath)!); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs new file mode 100644 index 0000000..f3744fe --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs @@ -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 Subscriptions { get; set; } = new(); +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs new file mode 100644 index 0000000..cf9ce4e --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs @@ -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 View(string appId) + { + var app = await _appService.GetApp(appId, SubscriptionApp.AppType, true, false); + + if (app == null) + return NotFound(); + var ss = app.GetSettings(); + ss.SubscriptionName = app.Name; + ViewData["StoreBranding"] = new StoreBrandingViewModel(app.StoreData.GetStoreBlob()); + return View(ss); + } + + [AllowAnonymous] + [HttpGet("~/plugins/subscription/{appId}/{id}")] + public async Task ViewSubscription(string appId, string id) + { + var app = await _appService.GetApp(appId, SubscriptionApp.AppType, true, false); + + if (app == null) + return NotFound(); + var ss = app.GetSettings(); + 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 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 Subscribe(string appId) + { + var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, false); + + if (app == null) + return NotFound(); + var ss = app.GetSettings(); + 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() + { + {"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 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(); + ss.SubscriptionName = app.Name; + + return View(ss); + } + + [HttpPost("~/plugins/subscription/{appId}/update")] + public async Task 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(); + 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}); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs new file mode 100644 index 0000000..28d289f --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs @@ -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; } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPlugin.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPlugin.cs new file mode 100644 index 0000000..6074a54 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPlugin.cs @@ -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(); + applicationBuilder.AddSingleton(o => o.GetRequiredService()); + applicationBuilder.AddHostedService(s => s.GetRequiredService()); + + applicationBuilder.AddSingleton(new UIExtension("Subscriptions/NavExtension", "header-nav")); + applicationBuilder.AddSingleton(); + base.Execute(applicationBuilder); + } + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs new file mode 100644 index 0000000..1868f37 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs @@ -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 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 ReactivateSubscription(string appId, string subscriptionId) + { + var tcs = new TaskCompletionSource(); + PushEvent(new SequentialExecute(async () => + { + var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true); + if (app == null) + { + return null; + } + + var settings = app.GetSettings(); + 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(); + + 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(); + 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(); + Subscribe(); + base.SubscribeToEvents(); + } + + + public record SequentialExecute(Func> Action, TaskCompletionSource 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() != "subscription" || + !prBlob.AdditionalData.TryGetValue("appId", out var subscriptionAppidToken) || + subscriptionAppidToken.Value() 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(); + var blob = paymentRequestUpdated.Data.GetBlob(); + var email = blob.Email ?? blob.FormResponse?["buyerEmail"]?.Value(); + await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestUpdated.Data.Id, email); + } + else if (!isNew) + { + await HandleUnSettledSubscription(subscriptionAppId, subscriptionIdToken.Value(), + 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(); + 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(); + + 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()), path); + if (blob.Description.Contains(url.ToString())) + return; + var subscriptionHtml = + ""; + 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 GetSupportedWebhookTypes() + { + return new Dictionary + { + {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 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; + } + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionStatus.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionStatus.cs new file mode 100644 index 0000000..2faeecf --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionStatus.cs @@ -0,0 +1,7 @@ +namespace BTCPayServer.Plugins.Subscriptions; + +public enum SubscriptionStatus +{ + Active, + Inactive +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/NavExtension.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/NavExtension.cshtml new file mode 100644 index 0000000..c7ac365 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/NavExtension.cshtml @@ -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)!; + + @foreach (var app in Model.Apps.Where(app => app.AppType == appType.Type)) + { + + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/_ViewImports.cshtml new file mode 100644 index 0000000..d897d63 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Subscriptions/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using BTCPayServer.Abstractions.Services +@inject Safe Safe +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml new file mode 100644 index 0000000..7f9b24b --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml @@ -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; +} + + +
+ +
+ + + + + + +
+
+
+ +
+ + + +
+
+
+ + + + +
+
+ + + +
+
+ +
+ + + + +
+
+ + + +
+ + +
+
+ +
+
+
+ + + +
+
+
+ + + + +@if (Model.Subscriptions?.Any() is true) +{ +
+
+
+ + + + + + + + + + + + @foreach (var sub in Model.Subscriptions) + { + + + + + + + + + + } + +
SubscriptionCreatedStatusEmail
+ + + + + + @sub.Value.Start.ToBrowserDate()@sub.Value.Status@sub.Value.Email
+ + + + + + + + @foreach (var x in sub.Value.Payments) + { + + + + + + + } +
Payment RequestPeriod StartPeriod EndSettled
+ + + + + @x.PeriodStart.ToBrowserDate()@x.PeriodEnd.ToBrowserDate()@x.Settled
+
+
+
+
+} + + +
+
+ +
+ Delete this app +
+ + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml new file mode 100644 index 0000000..e8ddda0 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml @@ -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; +} + + + + + + + + +
+ + +
+ +
@Model.DurationDays day@(Model.DurationDays>1?"s": "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
+ @if (!string.IsNullOrEmpty(Model.Description)) + { +
@Safe.Raw(Model.Description)
+ } + + + + +
+ + +
+ + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml new file mode 100644 index 0000000..b280d95 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml @@ -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; +} + + + + + + + +
+ + +
+ +
@Model.DurationDays day@(Model.DurationDays > 1 ? "s" : "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
+
+ @subscription.Status + @if (subscription.Status == SubscriptionStatus.Inactive) + { + Reactivate + } +
+ @if (!string.IsNullOrEmpty(Model.Description)) + { +
@Safe.Raw(Model.Description)
+ } + + +
+ + @if (subscription.Payments?.Any() is not true) + { +

No payments have been made yet.

+ } + else + { +
+ + + + + + + + + + @foreach (var payment in subscription.Payments) + { + var isThisPeriodActive = payment.PeriodStart <= DateTimeOffset.UtcNow && payment.PeriodEnd >= DateTimeOffset.UtcNow; + var isThisPeriodFuture = payment.PeriodStart > DateTimeOffset.UtcNow; + + + + + + + } + +
Payment Request IdPeriodSettled
+ + + + +
@payment.PeriodStart.ToBrowserDate() - @payment.PeriodEnd.ToBrowserDate()
+ @if (payment.Settled && isThisPeriodActive) + { + Active + } + @if (isThisPeriodFuture) + { + Next period + } +
+ @if (payment.Settled) + { + Settled + } + else + { + Not settled + } + +
+
+ } +
+
+ + +
+ + + + \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/_ViewImports.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/_ViewImports.cshtml new file mode 100644 index 0000000..d897d63 --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/_ViewImports.cshtml @@ -0,0 +1,5 @@ +@using BTCPayServer.Abstractions.Services +@inject Safe Safe +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, BTCPayServer +@addTagHelper *, BTCPayServer.Abstractions \ No newline at end of file diff --git a/submodules/btcpayserver b/submodules/btcpayserver index 83028b9..4ebe468 160000 --- a/submodules/btcpayserver +++ b/submodules/btcpayserver @@ -1 +1 @@ -Subproject commit 83028b9b73aedf68e4a33db2e2bd102aeb4de8b9 +Subproject commit 4ebe46830b3293b533fd85be984160112e637890