From 6e95ddb8b12c67c777f4717afc69e56deb7575bf Mon Sep 17 00:00:00 2001 From: Kukks Date: Thu, 25 Apr 2024 11:51:43 +0200 Subject: [PATCH] fix subs --- .../AsyncExtensions.cs | 57 ++++++ .../SubscriptionAppSettings.cs | 45 ++++- .../SubscriptionController.cs | 4 +- .../SubscriptionPaymentHistory.cs | 4 +- .../SubscriptionService.cs | 170 ++++++++++++++---- .../Views/Subscription/Update.cshtml | 26 ++- .../Views/Subscription/View.cshtml | 27 ++- .../Subscription/ViewSubscription.cshtml | 16 +- 8 files changed, 296 insertions(+), 53 deletions(-) create mode 100644 Plugins/BTCPayServer.Plugins.Subscriptions/AsyncExtensions.cs diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/AsyncExtensions.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/AsyncExtensions.cs new file mode 100644 index 0000000..522161d --- /dev/null +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/AsyncExtensions.cs @@ -0,0 +1,57 @@ +using System; +using System.ComponentModel; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace BTCPayServer.Plugins.Subscriptions; + +public static class AsyncExtensions +{ + /// + /// Allows a cancellation token to be awaited. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public static CancellationTokenAwaiter GetAwaiter(this CancellationToken ct) + { + // return our special awaiter + return new CancellationTokenAwaiter + { + CancellationToken = ct + }; + } + + /// + /// The awaiter for cancellation tokens. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public struct CancellationTokenAwaiter : INotifyCompletion, ICriticalNotifyCompletion + { + public CancellationTokenAwaiter(CancellationToken cancellationToken) + { + CancellationToken = cancellationToken; + } + + internal CancellationToken CancellationToken; + + public object GetResult() + { + // this is called by compiler generated methods when the + // task has completed. Instead of returning a result, we + // just throw an exception. + if (IsCompleted) throw new OperationCanceledException(); + else throw new InvalidOperationException("The cancellation token has not yet been cancelled."); + } + + // called by compiler generated/.net internals to check + // if the task has completed. + public bool IsCompleted => CancellationToken.IsCancellationRequested; + + // The compiler will generate stuff that hooks in + // here. We hook those methods directly into the + // cancellation token. + public void OnCompleted(Action continuation) => + CancellationToken.Register(continuation); + public void UnsafeOnCompleted(Action continuation) => + CancellationToken.Register(continuation); + } +} \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs index f3744fe..2c6e7b5 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionAppSettings.cs @@ -1,6 +1,9 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using BTCPayServer.JsonConverters; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal; using Newtonsoft.Json; +using Newtonsoft.Json.Converters; namespace BTCPayServer.Plugins.Subscriptions; @@ -8,10 +11,48 @@ public class SubscriptionAppSettings { [JsonIgnore] public string SubscriptionName { get; set; } public string Description { get; set; } - public int DurationDays { get; set; } + public int Duration { get; set; } + + [JsonConverter(typeof(StringEnumConverter))] + public DurationType DurationType { 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(); + +} + +public static class SubscriptionAppSettingsExtensions +{ + + public static string GetSubscriptionHumanReadableLength(this SubscriptionAppSettings settings) + { + return settings.DurationType switch + { + DurationType.Day => $"{settings.Duration} day{(settings.Duration > 1 ? "s" : "")}", + DurationType.Month => $"{settings.Duration} month{(settings.Duration > 1 ? "s" : "")}", + _ => throw new ArgumentOutOfRangeException() + }; + } + + public static DateTimeOffset GetNextRenewalDate(this SubscriptionAppSettings settings, DateTimeOffset? lastRenewalDate) + { + if (lastRenewalDate == null) + { + return DateTimeOffset.UtcNow; + } + return settings.DurationType switch + { + DurationType.Day => lastRenewalDate.Value.AddDays(settings.Duration), + DurationType.Month => lastRenewalDate.Value.AddMonths(settings.Duration), + _ => throw new ArgumentOutOfRangeException() + }; + } +} + +public enum DurationType +{ + Day, + Month } \ No newline at end of file diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs index cf9ce4e..f1eafcd 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionController.cs @@ -159,9 +159,9 @@ public class SubscriptionController : Controller ModelState.AddModelError(nameof(vm.Price), "Price must be greater than 0"); } - if (vm.DurationDays <= 0) + if (vm.Duration <= 0) { - ModelState.AddModelError(nameof(vm.DurationDays), "Duration must be greater than 0"); + ModelState.AddModelError(nameof(vm.Duration), "Duration must be greater than 0"); } diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs index 28d289f..128ca4c 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionPaymentHistory.cs @@ -7,9 +7,9 @@ public class SubscriptionPaymentHistory { [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] - public DateTimeOffset PeriodStart { get; set; } + public DateTime PeriodStart { get; set; } [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] - public DateTimeOffset PeriodEnd { get; set; } + public DateTime 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/SubscriptionService.cs b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs index 1868f37..28c1045 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/SubscriptionService.cs @@ -28,6 +28,13 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly WebhookSender _webhookSender; + public const string PaymentRequestSubscriptionIdKey = "subscriptionId"; + public const string PaymentRequestSourceKey = "source"; + public const string PaymentRequestSourceValue = "subscription"; + public const string PaymentRequestAppId = "appId"; + + + public SubscriptionService(EventAggregator eventAggregator, ILogger logger, AppService appService, @@ -43,18 +50,31 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider _webhookSender = webhookSender; } - public override Task StartAsync(CancellationToken cancellationToken) + public override async Task StartAsync(CancellationToken cancellationToken) { - _ = ScheduleChecks(cancellationToken); - return base.StartAsync(cancellationToken); + + await base.StartAsync(cancellationToken); + _ = ScheduleChecks(); } - private async Task ScheduleChecks(CancellationToken cancellationToken) + private CancellationTokenSource _checkTcs = new(); + private async Task ScheduleChecks() { - while (!cancellationToken.IsCancellationRequested) + + while (!CancellationToken.IsCancellationRequested) { - await CreatePaymentRequestForActiveSubscriptionCloseToEnding(); - await Task.Delay(TimeSpan.FromHours(1), cancellationToken); + try + { + + await CreatePaymentRequestForActiveSubscriptionCloseToEnding(); + } + catch (Exception e) + { + Logs.PayServer.LogError(e, "Error while checking subscriptions"); + } + _checkTcs = new CancellationTokenSource(); + _checkTcs.CancelAfter(TimeSpan.FromHours(1)); + await CancellationTokenSource.CreateLinkedTokenSource(_checkTcs.Token, CancellationToken).Token; } } @@ -76,7 +96,6 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider } if (subscription.Status == SubscriptionStatus.Active) - return null; var lastSettled = subscription.Payments.Where(p => p.Settled).MaxBy(history => history.PeriodEnd); @@ -91,6 +110,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider Status = PaymentRequestData.PaymentRequestStatus.Pending, Created = DateTimeOffset.UtcNow, Archived = false, }; + var additionalData = lastBlob.AdditionalData; + additionalData[PaymentRequestSubscriptionIdKey] = JToken.FromObject(subscriptionId); pr.SetBlob(new PaymentRequestBaseData() { ExpiryDate = DateTimeOffset.UtcNow.AddDays(1), @@ -99,13 +120,16 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider StoreId = app.StoreDataId, Title = $"{settings.SubscriptionName} Subscription Reactivation", Description = settings.Description, - AdditionalData = lastBlob.AdditionalData + AdditionalData = additionalData }); return await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); }, tcs)); + + return await tcs.Task as Data.PaymentRequestData; } + private async Task CreatePaymentRequestForActiveSubscriptionCloseToEnding() { var tcs = new TaskCompletionSource(); @@ -121,14 +145,18 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider settings.SubscriptionName = app.Name; if (settings.Subscriptions?.Any() is true) { + var changedSubscriptions = new List>(); + foreach (var subscription in settings.Subscriptions) { + var changed = DetermineStatusOfSubscription(subscription.Value); if (subscription.Value.Status == SubscriptionStatus.Active) { var currentPeriod = subscription.Value.Payments.FirstOrDefault(p => p.Settled && p.PeriodStart <= DateTimeOffset.UtcNow && p.PeriodEnd >= DateTimeOffset.UtcNow); + //there should only ever be one future payment request at a time var nextPeriod = subscription.Value.Payments.FirstOrDefault(p => p.PeriodStart > DateTimeOffset.UtcNow); @@ -138,12 +166,12 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider var noticePeriod = currentPeriod.PeriodEnd - DateTimeOffset.UtcNow; - var lastPr = - await _paymentRequestRepository.FindPaymentRequest(currentPeriod.PaymentRequestId, null, - CancellationToken.None); + var lastPr = await _paymentRequestRepository.FindPaymentRequest( + currentPeriod.PaymentRequestId, null, + CancellationToken.None); var lastBlob = lastPr.GetBlob(); - if (noticePeriod.TotalDays < Math.Min(3, settings.DurationDays)) + if (noticePeriod.Days <= Math.Min(3, settings.Duration)) { var pr = new Data.PaymentRequestData() { @@ -163,22 +191,47 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider }); pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); + var start = DateOnly.FromDateTime(currentPeriod.PeriodEnd.AddDays(1)); + var end = settings.DurationType == DurationType.Day + ? start.AddDays(settings.Duration) + : start.AddMonths(settings.Duration); var newHistory = new SubscriptionPaymentHistory() { PaymentRequestId = pr.Id, - PeriodStart = currentPeriod.PeriodEnd, - PeriodEnd = currentPeriod.PeriodEnd.AddDays(settings.DurationDays), + PeriodStart = start.ToDateTime(TimeOnly.MinValue), + PeriodEnd = end.ToDateTime(TimeOnly.MinValue), Settled = false }; subscription.Value.Payments.Add(newHistory); - + deliverRequests.Add((app.Id, subscription.Key, pr.Id, subscription.Value.Email)); } } + if(changed) + changedSubscriptions.Add(subscription); } + app.SetSettings(settings); await _appService.UpdateOrCreateApp(app); + + if (changedSubscriptions.Any()) + { + var webhooks = await _webhookSender.GetWebhooks(app.StoreDataId, SubscriptionStatusUpdated); + foreach (var changedSubscription in changedSubscriptions) + { + foreach (var webhook in webhooks) + { + _webhookSender.EnqueueDelivery(CreateSubscriptionStatusUpdatedDeliveryRequest(webhook, app.Id, + app.StoreDataId, + changedSubscription.Key, changedSubscription.Value.Status, null, changedSubscription.Value.Email)); + } + + EventAggregator.Publish(CreateSubscriptionStatusUpdatedDeliveryRequest(null, app.Id, app.StoreDataId, + changedSubscription.Key, changedSubscription.Value.Status, null, changedSubscription.Value.Email)); + } + + } } foreach (var deliverRequest in deliverRequests) @@ -223,40 +276,82 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider sequentialExecute.TaskCompletionSource.SetResult(task); return; } - case PaymentRequestEvent paymentRequestUpdated - when paymentRequestUpdated.Type == PaymentRequestEvent.StatusChanged: + case PaymentRequestEvent {Type: PaymentRequestEvent.StatusChanged} paymentRequestStatusUpdated: { - var prBlob = paymentRequestUpdated.Data.GetBlob(); - if (!prBlob.AdditionalData.TryGetValue("source", out var src) || - src.Value() != "subscription" || - !prBlob.AdditionalData.TryGetValue("appId", out var subscriptionAppidToken) || + var prBlob = paymentRequestStatusUpdated.Data.GetBlob(); + if (!prBlob.AdditionalData.TryGetValue(PaymentRequestSourceKey, out var src) || + src.Value() != PaymentRequestSourceValue || + !prBlob.AdditionalData.TryGetValue(PaymentRequestAppId, out var subscriptionAppidToken) || subscriptionAppidToken.Value() is not { } subscriptionAppId) { return; } - var isNew = !prBlob.AdditionalData.TryGetValue("subcriptionId", out var subscriptionIdToken); + var isNew = !prBlob.AdditionalData.TryGetValue(PaymentRequestSubscriptionIdKey, out var subscriptionIdToken); - if (isNew && paymentRequestUpdated.Data.Status != PaymentRequestData.PaymentRequestStatus.Completed) + if (isNew && paymentRequestStatusUpdated.Data.Status != + PaymentRequestData.PaymentRequestStatus.Completed) { return; } - if (paymentRequestUpdated.Data.Status == PaymentRequestData.PaymentRequestStatus.Completed) + if (paymentRequestStatusUpdated.Data.Status == PaymentRequestData.PaymentRequestStatus.Completed) { var subscriptionId = subscriptionIdToken?.Value(); - var blob = paymentRequestUpdated.Data.GetBlob(); + var blob = paymentRequestStatusUpdated.Data.GetBlob(); var email = blob.Email ?? blob.FormResponse?["buyerEmail"]?.Value(); - await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestUpdated.Data.Id, email); + await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestStatusUpdated.Data.Id, + email); } else if (!isNew) { await HandleUnSettledSubscription(subscriptionAppId, subscriptionIdToken.Value(), - paymentRequestUpdated.Data.Id); + paymentRequestStatusUpdated.Data.Id); } + + await _checkTcs.CancelAsync(); + break; } + // case PaymentRequestEvent {Type: PaymentRequestEvent.Updated} paymentRequestEvent: + // { + // var prBlob = paymentRequestEvent.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("subscriptionId", out var subscriptionIdToken); + // if(isNew) + // return; + // + // var app = await _appService.GetApp(subscriptionAppId, SubscriptionApp.AppType, false, true); + // if (app == null) + // { + // return; + // } + // + // var settings = app.GetSettings(); + // + // var subscriptionId = subscriptionIdToken!.Value(); + // + // if (!settings.Subscriptions.TryGetValue(subscriptionId, out var subscription)) + // { + // return; + // } + // + // var payment = subscription.Payments.Find(p => p.PaymentRequestId == paymentRequestEvent.Data.Id); + // + // if (payment is null) + // { + // return; + // } + // } } await base.ProcessEvent(evt, cancellationToken); @@ -298,7 +393,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider } } - private async Task HandlePaidSubscription(string appId, string? subscriptionId, string paymentRequestId, string? 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) @@ -310,6 +406,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider subscriptionId ??= Guid.NewGuid().ToString(); + var start = DateOnly.FromDateTime(DateTimeOffset.UtcNow.DateTime); + var end = settings.DurationType == DurationType.Day? start.AddDays(settings.Duration).ToDateTime(TimeOnly.MaxValue): start.AddMonths(settings.Duration).ToDateTime(TimeOnly.MaxValue); if (!settings.Subscriptions.TryGetValue(subscriptionId, out var subscription)) { subscription = new Subscription() @@ -322,8 +420,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider new SubscriptionPaymentHistory() { PaymentRequestId = paymentRequestId, - PeriodStart = DateTimeOffset.UtcNow, - PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays), + PeriodStart = start.ToDateTime(TimeOnly.MinValue), + PeriodEnd = end, Settled = true } ] @@ -337,8 +435,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider subscription.Payments.Add(new SubscriptionPaymentHistory() { PaymentRequestId = paymentRequestId, - PeriodStart = DateTimeOffset.UtcNow, - PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays), + PeriodStart = start.ToDateTime(TimeOnly.MinValue), + PeriodEnd = end, Settled = true }); } @@ -384,10 +482,12 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider } SubscriptionWebhookDeliveryRequest CreateSubscriptionStatusUpdatedDeliveryRequest(WebhookData? webhook, - string appId, string storeId, string subscriptionId, SubscriptionStatus status, string subscriptionUrl, string email) + string appId, string storeId, string subscriptionId, SubscriptionStatus status, string subscriptionUrl, + string email) { var webhookEvent = new WebhookSubscriptionEvent(SubscriptionStatusUpdated, storeId) { + WebhookId = webhook?.Id, AppId = appId, SubscriptionId = subscriptionId, Status = status.ToString(), @@ -413,6 +513,7 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider { var webhookEvent = new WebhookSubscriptionEvent(SubscriptionRenewalRequested, storeId) { + WebhookId = webhook?.Id, AppId = appId, SubscriptionId = subscriptionId, PaymentRequestId = paymentRequestId, @@ -501,7 +602,6 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider [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( diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml index 7f9b24b..dd9792e 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/Update.cshtml @@ -4,7 +4,9 @@ @using Microsoft.AspNetCore.Routing @using BTCPayServer @using BTCPayServer.Abstractions.Models +@using BTCPayServer.Components.TruncateCenter @using BTCPayServer.Forms +@using BTCPayServer.Plugins.Subscriptions @using BTCPayServer.Services.Apps @using BTCPayServer.TagHelpers @model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings @@ -27,7 +29,8 @@ @if (archived) { - }else if (this.ViewContext.ModelState.IsValid) + } + else if (this.ViewContext.ModelState.IsValid) { Subscription page @@ -64,9 +67,18 @@
- - - + + +
+ + + +
+ +
@@ -116,7 +128,7 @@ asp-controller="Subscription" asp-route-appId="@appId" asp-route-id="@sub.Key"> - + @@ -141,8 +153,8 @@ - - + + @x.PeriodStart.ToBrowserDate() diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml index e8ddda0..3c81b2e 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/View.cshtml @@ -1,6 +1,7 @@ @using Microsoft.AspNetCore.Routing @using Microsoft.AspNetCore.Mvc.TagHelpers @using BTCPayServer.Models +@using BTCPayServer.Plugins.Subscriptions @using BTCPayServer.Services @inject DisplayFormatter DisplayFormatter @model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings @@ -15,6 +16,16 @@ +
@@ -22,14 +33,22 @@
-
@Model.DurationDays day@(Model.DurationDays>1?"s": "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
+
@Model.GetSubscriptionHumanReadableLength() subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
+ @if (!string.IsNullOrEmpty(Model.Description)) { -
@Safe.Raw(Model.Description)
+
+

Description

+
+ @Safe.Raw(Model.Description) +
+
} - - +
+ Subscribe +
+
diff --git a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml index b280d95..0e004d0 100644 --- a/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml +++ b/Plugins/BTCPayServer.Plugins.Subscriptions/Views/Subscription/ViewSubscription.cshtml @@ -18,6 +18,17 @@ + +
@@ -25,7 +36,7 @@
-
@Model.DurationDays day@(Model.DurationDays > 1 ? "s" : "") subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
+
@Model.GetSubscriptionHumanReadableLength() subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)
@subscription.Status @if (subscription.Status == SubscriptionStatus.Inactive) @@ -99,6 +110,9 @@
} +