This commit is contained in:
Kukks
2024-04-25 11:51:43 +02:00
parent f7cf0899ec
commit 6e95ddb8b1
8 changed files with 296 additions and 53 deletions

View File

@@ -0,0 +1,57 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
namespace BTCPayServer.Plugins.Subscriptions;
public static class AsyncExtensions
{
/// <summary>
/// Allows a cancellation token to be awaited.
/// </summary>
[EditorBrowsable(EditorBrowsableState.Never)]
public static CancellationTokenAwaiter GetAwaiter(this CancellationToken ct)
{
// return our special awaiter
return new CancellationTokenAwaiter
{
CancellationToken = ct
};
}
/// <summary>
/// The awaiter for cancellation tokens.
/// </summary>
[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);
}
}

View File

@@ -1,6 +1,9 @@
using System.Collections.Generic; using System;
using System.Collections.Generic;
using BTCPayServer.JsonConverters; using BTCPayServer.JsonConverters;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion.Internal;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Plugins.Subscriptions; namespace BTCPayServer.Plugins.Subscriptions;
@@ -8,10 +11,48 @@ public class SubscriptionAppSettings
{ {
[JsonIgnore] public string SubscriptionName { get; set; } [JsonIgnore] public string SubscriptionName { get; set; }
public string Description { 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; } public string? FormId { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))] [JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Price { get; set; } public decimal Price { get; set; }
public string Currency { get; set; } public string Currency { get; set; }
public Dictionary<string, Subscription> Subscriptions { get; set; } = new(); public Dictionary<string, Subscription> 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
} }

View File

@@ -159,9 +159,9 @@ public class SubscriptionController : Controller
ModelState.AddModelError(nameof(vm.Price), "Price must be greater than 0"); 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");
} }

View File

@@ -7,9 +7,9 @@ public class SubscriptionPaymentHistory
{ {
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset PeriodStart { get; set; } public DateTime PeriodStart { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))] [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset PeriodEnd { get; set; } public DateTime PeriodEnd { get; set; }
public string PaymentRequestId { get; set; } public string PaymentRequestId { get; set; }
public bool Settled { get; set; } public bool Settled { get; set; }
} }

View File

@@ -28,6 +28,13 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings; private readonly BTCPayNetworkJsonSerializerSettings _btcPayNetworkJsonSerializerSettings;
private readonly WebhookSender _webhookSender; 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, public SubscriptionService(EventAggregator eventAggregator,
ILogger<SubscriptionService> logger, ILogger<SubscriptionService> logger,
AppService appService, AppService appService,
@@ -43,18 +50,31 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
_webhookSender = webhookSender; _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)
{ {
try
{
await CreatePaymentRequestForActiveSubscriptionCloseToEnding(); await CreatePaymentRequestForActiveSubscriptionCloseToEnding();
await Task.Delay(TimeSpan.FromHours(1), cancellationToken); }
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) if (subscription.Status == SubscriptionStatus.Active)
return null; return null;
var lastSettled = subscription.Payments.Where(p => p.Settled).MaxBy(history => history.PeriodEnd); 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, Status = PaymentRequestData.PaymentRequestStatus.Pending,
Created = DateTimeOffset.UtcNow, Archived = false, Created = DateTimeOffset.UtcNow, Archived = false,
}; };
var additionalData = lastBlob.AdditionalData;
additionalData[PaymentRequestSubscriptionIdKey] = JToken.FromObject(subscriptionId);
pr.SetBlob(new PaymentRequestBaseData() pr.SetBlob(new PaymentRequestBaseData()
{ {
ExpiryDate = DateTimeOffset.UtcNow.AddDays(1), ExpiryDate = DateTimeOffset.UtcNow.AddDays(1),
@@ -99,13 +120,16 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
StoreId = app.StoreDataId, StoreId = app.StoreDataId,
Title = $"{settings.SubscriptionName} Subscription Reactivation", Title = $"{settings.SubscriptionName} Subscription Reactivation",
Description = settings.Description, Description = settings.Description,
AdditionalData = lastBlob.AdditionalData AdditionalData = additionalData
}); });
return await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); return await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr);
}, tcs)); }, tcs));
return await tcs.Task as Data.PaymentRequestData; return await tcs.Task as Data.PaymentRequestData;
} }
private async Task CreatePaymentRequestForActiveSubscriptionCloseToEnding() private async Task CreatePaymentRequestForActiveSubscriptionCloseToEnding()
{ {
var tcs = new TaskCompletionSource<object>(); var tcs = new TaskCompletionSource<object>();
@@ -121,14 +145,18 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
settings.SubscriptionName = app.Name; settings.SubscriptionName = app.Name;
if (settings.Subscriptions?.Any() is true) if (settings.Subscriptions?.Any() is true)
{ {
var changedSubscriptions = new List<KeyValuePair<string, Subscription>>();
foreach (var subscription in settings.Subscriptions) foreach (var subscription in settings.Subscriptions)
{ {
var changed = DetermineStatusOfSubscription(subscription.Value);
if (subscription.Value.Status == SubscriptionStatus.Active) if (subscription.Value.Status == SubscriptionStatus.Active)
{ {
var currentPeriod = subscription.Value.Payments.FirstOrDefault(p => p.Settled && var currentPeriod = subscription.Value.Payments.FirstOrDefault(p => p.Settled &&
p.PeriodStart <= DateTimeOffset.UtcNow && p.PeriodStart <= DateTimeOffset.UtcNow &&
p.PeriodEnd >= DateTimeOffset.UtcNow); p.PeriodEnd >= DateTimeOffset.UtcNow);
//there should only ever be one future payment request at a time
var nextPeriod = var nextPeriod =
subscription.Value.Payments.FirstOrDefault(p => p.PeriodStart > DateTimeOffset.UtcNow); 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 noticePeriod = currentPeriod.PeriodEnd - DateTimeOffset.UtcNow;
var lastPr = var lastPr = await _paymentRequestRepository.FindPaymentRequest(
await _paymentRequestRepository.FindPaymentRequest(currentPeriod.PaymentRequestId, null, currentPeriod.PaymentRequestId, null,
CancellationToken.None); CancellationToken.None);
var lastBlob = lastPr.GetBlob(); 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() var pr = new Data.PaymentRequestData()
{ {
@@ -163,11 +191,15 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
}); });
pr = await _paymentRequestRepository.CreateOrUpdatePaymentRequest(pr); 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() var newHistory = new SubscriptionPaymentHistory()
{ {
PaymentRequestId = pr.Id, PaymentRequestId = pr.Id,
PeriodStart = currentPeriod.PeriodEnd, PeriodStart = start.ToDateTime(TimeOnly.MinValue),
PeriodEnd = currentPeriod.PeriodEnd.AddDays(settings.DurationDays), PeriodEnd = end.ToDateTime(TimeOnly.MinValue),
Settled = false Settled = false
}; };
subscription.Value.Payments.Add(newHistory); subscription.Value.Payments.Add(newHistory);
@@ -175,10 +207,31 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
deliverRequests.Add((app.Id, subscription.Key, pr.Id, subscription.Value.Email)); deliverRequests.Add((app.Id, subscription.Key, pr.Id, subscription.Value.Email));
} }
} }
if(changed)
changedSubscriptions.Add(subscription);
} }
app.SetSettings(settings); app.SetSettings(settings);
await _appService.UpdateOrCreateApp(app); 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) foreach (var deliverRequest in deliverRequests)
@@ -223,40 +276,82 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
sequentialExecute.TaskCompletionSource.SetResult(task); sequentialExecute.TaskCompletionSource.SetResult(task);
return; return;
} }
case PaymentRequestEvent paymentRequestUpdated case PaymentRequestEvent {Type: PaymentRequestEvent.StatusChanged} paymentRequestStatusUpdated:
when paymentRequestUpdated.Type == PaymentRequestEvent.StatusChanged:
{ {
var prBlob = paymentRequestUpdated.Data.GetBlob(); var prBlob = paymentRequestStatusUpdated.Data.GetBlob();
if (!prBlob.AdditionalData.TryGetValue("source", out var src) || if (!prBlob.AdditionalData.TryGetValue(PaymentRequestSourceKey, out var src) ||
src.Value<string>() != "subscription" || src.Value<string>() != PaymentRequestSourceValue ||
!prBlob.AdditionalData.TryGetValue("appId", out var subscriptionAppidToken) || !prBlob.AdditionalData.TryGetValue(PaymentRequestAppId, out var subscriptionAppidToken) ||
subscriptionAppidToken.Value<string>() is not { } subscriptionAppId) subscriptionAppidToken.Value<string>() is not { } subscriptionAppId)
{ {
return; 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; return;
} }
if (paymentRequestUpdated.Data.Status == PaymentRequestData.PaymentRequestStatus.Completed) if (paymentRequestStatusUpdated.Data.Status == PaymentRequestData.PaymentRequestStatus.Completed)
{ {
var subscriptionId = subscriptionIdToken?.Value<string>(); var subscriptionId = subscriptionIdToken?.Value<string>();
var blob = paymentRequestUpdated.Data.GetBlob(); var blob = paymentRequestStatusUpdated.Data.GetBlob();
var email = blob.Email ?? blob.FormResponse?["buyerEmail"]?.Value<string>(); var email = blob.Email ?? blob.FormResponse?["buyerEmail"]?.Value<string>();
await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestUpdated.Data.Id, email); await HandlePaidSubscription(subscriptionAppId, subscriptionId, paymentRequestStatusUpdated.Data.Id,
email);
} }
else if (!isNew) else if (!isNew)
{ {
await HandleUnSettledSubscription(subscriptionAppId, subscriptionIdToken.Value<string>(), await HandleUnSettledSubscription(subscriptionAppId, subscriptionIdToken.Value<string>(),
paymentRequestUpdated.Data.Id); paymentRequestStatusUpdated.Data.Id);
} }
await _checkTcs.CancelAsync();
break; break;
} }
// case PaymentRequestEvent {Type: PaymentRequestEvent.Updated} paymentRequestEvent:
// {
// var prBlob = paymentRequestEvent.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("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<SubscriptionAppSettings>();
//
// var subscriptionId = subscriptionIdToken!.Value<string>();
//
// 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); 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); var app = await _appService.GetApp(appId, SubscriptionApp.AppType, false, true);
if (app == null) if (app == null)
@@ -310,6 +406,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
subscriptionId ??= Guid.NewGuid().ToString(); 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)) if (!settings.Subscriptions.TryGetValue(subscriptionId, out var subscription))
{ {
subscription = new Subscription() subscription = new Subscription()
@@ -322,8 +420,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
new SubscriptionPaymentHistory() new SubscriptionPaymentHistory()
{ {
PaymentRequestId = paymentRequestId, PaymentRequestId = paymentRequestId,
PeriodStart = DateTimeOffset.UtcNow, PeriodStart = start.ToDateTime(TimeOnly.MinValue),
PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays), PeriodEnd = end,
Settled = true Settled = true
} }
] ]
@@ -337,8 +435,8 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
subscription.Payments.Add(new SubscriptionPaymentHistory() subscription.Payments.Add(new SubscriptionPaymentHistory()
{ {
PaymentRequestId = paymentRequestId, PaymentRequestId = paymentRequestId,
PeriodStart = DateTimeOffset.UtcNow, PeriodStart = start.ToDateTime(TimeOnly.MinValue),
PeriodEnd = DateTimeOffset.UtcNow.AddDays(settings.DurationDays), PeriodEnd = end,
Settled = true Settled = true
}); });
} }
@@ -384,10 +482,12 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
} }
SubscriptionWebhookDeliveryRequest CreateSubscriptionStatusUpdatedDeliveryRequest(WebhookData? webhook, 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) var webhookEvent = new WebhookSubscriptionEvent(SubscriptionStatusUpdated, storeId)
{ {
WebhookId = webhook?.Id,
AppId = appId, AppId = appId,
SubscriptionId = subscriptionId, SubscriptionId = subscriptionId,
Status = status.ToString(), Status = status.ToString(),
@@ -413,6 +513,7 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
{ {
var webhookEvent = new WebhookSubscriptionEvent(SubscriptionRenewalRequested, storeId) var webhookEvent = new WebhookSubscriptionEvent(SubscriptionRenewalRequested, storeId)
{ {
WebhookId = webhook?.Id,
AppId = appId, AppId = appId,
SubscriptionId = subscriptionId, SubscriptionId = subscriptionId,
PaymentRequestId = paymentRequestId, PaymentRequestId = paymentRequestId,
@@ -501,7 +602,6 @@ public class SubscriptionService : EventHostedServiceBase, IWebhookProvider
[JsonProperty(Order = 4)] public string Status { get; set; } [JsonProperty(Order = 4)] public string Status { get; set; }
[JsonProperty(Order = 5)] public string PaymentRequestId { get; set; } [JsonProperty(Order = 5)] public string PaymentRequestId { get; set; }
[JsonProperty(Order = 6)] public string Email { get; set; } [JsonProperty(Order = 6)] public string Email { get; set; }
} }
public class SubscriptionWebhookDeliveryRequest( public class SubscriptionWebhookDeliveryRequest(

View File

@@ -4,7 +4,9 @@
@using Microsoft.AspNetCore.Routing @using Microsoft.AspNetCore.Routing
@using BTCPayServer @using BTCPayServer
@using BTCPayServer.Abstractions.Models @using BTCPayServer.Abstractions.Models
@using BTCPayServer.Components.TruncateCenter
@using BTCPayServer.Forms @using BTCPayServer.Forms
@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Services.Apps @using BTCPayServer.Services.Apps
@using BTCPayServer.TagHelpers @using BTCPayServer.TagHelpers
@model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings @model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings
@@ -27,7 +29,8 @@
<input type="submit" value="Save" name="command" class="btn btn-primary"/> <input type="submit" value="Save" name="command" class="btn btn-primary"/>
@if (archived) @if (archived)
{ {
}else if (this.ViewContext.ModelState.IsValid) }
else if (this.ViewContext.ModelState.IsValid)
{ {
<a class="btn btn-secondary" target="_blank" href=" @Url.Action("View", "Subscription", new {appId})"> <a class="btn btn-secondary" target="_blank" href=" @Url.Action("View", "Subscription", new {appId})">
Subscription page Subscription page
@@ -64,9 +67,18 @@
</div> </div>
<div class="form-group"> <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/> <label asp-for="Duration" class="form-label" data-required>
<span asp-validation-for="DurationDays" class="text-danger"></span> Duration
</label>
<div class="d-flex align-items-center">
<input type="number" inputmode="decimal" step="1" min="1" asp-for="Duration" placeholder="Duration" class="form-control" required/>
<select class="form-select w-auto" asp-for="DurationType" asp-items="@Html.GetEnumSelectList<DurationType>()">
</select>
</div>
<span asp-validation-for="DurationType" class="text-danger"></span>
<span asp-validation-for="Duration" class="text-danger"></span>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -1,6 +1,7 @@
@using Microsoft.AspNetCore.Routing @using Microsoft.AspNetCore.Routing
@using Microsoft.AspNetCore.Mvc.TagHelpers @using Microsoft.AspNetCore.Mvc.TagHelpers
@using BTCPayServer.Models @using BTCPayServer.Models
@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Services @using BTCPayServer.Services
@inject DisplayFormatter DisplayFormatter @inject DisplayFormatter DisplayFormatter
@model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings @model BTCPayServer.Plugins.Subscriptions.SubscriptionAppSettings
@@ -15,6 +16,16 @@
<head> <head>
<partial name="LayoutHead"/> <partial name="LayoutHead"/>
<style>
#Subscription{
--wrap-max-width: 720px;
}
#InvoiceDescription > :last-child { margin-bottom: 0; }
@@media print {
thead { display: table-row-group; }
}
</style>
</head> </head>
<body class="min-vh-100"> <body class="min-vh-100">
<div id="Subscription" class="public-page-wrap"> <div id="Subscription" class="public-page-wrap">
@@ -22,13 +33,21 @@
<partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/> <partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/>
<main> <main>
<partial name="_StatusMessage"/> <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="text-muted mb-4 text-center lead ">@Model.GetSubscriptionHumanReadableLength() subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)</div>
@if (!string.IsNullOrEmpty(Model.Description)) @if (!string.IsNullOrEmpty(Model.Description))
{ {
<div class="subscription-description lead text-center">@Safe.Raw(Model.Description)</div> <section class="tile">
<h2 class="h4 mb-3">Description</h2>
<div id="InvoiceDescription" class="subscription-description">
@Safe.Raw(Model.Description)
</div>
</section>
} }
<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> <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> </main>

View File

@@ -18,6 +18,17 @@
<html lang="en"> <html lang="en">
<head> <head>
<partial name="LayoutHead"/> <partial name="LayoutHead"/>
<style>
#Subscription{
--wrap-max-width: 720px;
}
#InvoiceDescription > :last-child { margin-bottom: 0; }
@@media print {
thead { display: table-row-group; }
}
</style>
</head> </head>
<body class="min-vh-100"> <body class="min-vh-100">
<div id="Subscription" class="public-page-wrap"> <div id="Subscription" class="public-page-wrap">
@@ -25,7 +36,7 @@
<partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/> <partial name="_StoreHeader" model="(Model.SubscriptionName, storeBranding)"/>
<main> <main>
<partial name="_StatusMessage"/> <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="text-muted mb-4 text-center lead ">@Model.GetSubscriptionHumanReadableLength() subscription for @DisplayFormatter.Currency(Model.Price, Model.Currency)</div>
<div class=" mb-4 text-center lead d-flex gap-2 justify-content-center"> <div class=" mb-4 text-center lead d-flex gap-2 justify-content-center">
<span class=" fw-semibold ">@subscription.Status</span> <span class=" fw-semibold ">@subscription.Status</span>
@if (subscription.Status == SubscriptionStatus.Inactive) @if (subscription.Status == SubscriptionStatus.Inactive)
@@ -99,6 +110,9 @@
</div> </div>
} }
</section> </section>
<div class="d-flex justify-content-center mt-4 ">
<a asp-action="View" asp-route-appId="@appId.ToString()" class="btn btn-secondary rounded-pill">Return to subscription</a>
</div>
</main> </main>
<footer class="store-footer"> <footer class="store-footer">