mirror of
https://github.com/aljazceru/BTCPayServerPlugins.git
synced 2025-12-17 07:34:24 +01:00
fix subs
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
await CreatePaymentRequestForActiveSubscriptionCloseToEnding();
|
try
|
||||||
await Task.Delay(TimeSpan.FromHours(1), cancellationToken);
|
{
|
||||||
|
|
||||||
|
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)
|
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(
|
||||||
|
|||||||
@@ -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">
|
||||||
@@ -142,7 +154,7 @@
|
|||||||
asp-controller="UIPaymentRequest"
|
asp-controller="UIPaymentRequest"
|
||||||
asp-route-payReqId="@x.PaymentRequestId">
|
asp-route-payReqId="@x.PaymentRequestId">
|
||||||
|
|
||||||
<vc:truncate-center text="@x.PaymentRequestId" padding="7" classes="truncate-center-id"/>
|
<vc:truncate-center text="@x.PaymentRequestId" padding="7" classes="truncate-center-id"/>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>@x.PeriodStart.ToBrowserDate()</td>
|
<td>@x.PeriodStart.ToBrowserDate()</td>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user