mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2025-12-18 14:34:23 +01:00
[Features] Subscriptions
This commit is contained in:
@@ -58,6 +58,7 @@ dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggest
|
|||||||
|
|
||||||
# ReSharper properties
|
# ReSharper properties
|
||||||
resharper_apply_auto_detected_rules = false
|
resharper_apply_auto_detected_rules = false
|
||||||
|
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
|
||||||
resharper_autodetect_indent_settings = true
|
resharper_autodetect_indent_settings = true
|
||||||
resharper_cpp_insert_final_newline = true
|
resharper_cpp_insert_final_newline = true
|
||||||
resharper_csharp_insert_final_newline = true
|
resharper_csharp_insert_final_newline = true
|
||||||
|
|||||||
8
BTCPayServer.Client/Models/CustomerModel.cs
Normal file
8
BTCPayServer.Client/Models/CustomerModel.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
public class CustomerModel
|
||||||
|
{
|
||||||
|
public string StoreId { get; set; }
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string ExternalId { get; set; }
|
||||||
|
}
|
||||||
@@ -21,7 +21,6 @@ namespace BTCPayServer.Client.Models
|
|||||||
public JObject Metadata { get; set; }
|
public JObject Metadata { get; set; }
|
||||||
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
|
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
|
||||||
public ReceiptOptions Receipt { get; set; } = new ReceiptOptions();
|
public ReceiptOptions Receipt { get; set; } = new ReceiptOptions();
|
||||||
|
|
||||||
public class ReceiptOptions
|
public class ReceiptOptions
|
||||||
{
|
{
|
||||||
public bool? Enabled { get; set; }
|
public bool? Enabled { get; set; }
|
||||||
|
|||||||
10
BTCPayServer.Client/Models/OfferingModel.cs
Normal file
10
BTCPayServer.Client/Models/OfferingModel.cs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
public class OfferingModel
|
||||||
|
{
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
public string AppName { get; set; }
|
||||||
|
public string AppId { get; set; } = null!;
|
||||||
|
public string SuccessRedirectUrl { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
23
BTCPayServer.Client/Models/SubscriberModel.cs
Normal file
23
BTCPayServer.Client/Models/SubscriberModel.cs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
using System;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
public class SubscriberModel
|
||||||
|
{
|
||||||
|
public CustomerModel Customer { get; set; }
|
||||||
|
public OfferingModel Offer { get; set; }
|
||||||
|
public SubscriptionPlanModel Plan { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset? PeriodEnd { get; set; }
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset? TrialEnd { get; set; }
|
||||||
|
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset? GracePeriodEnd { get; set; }
|
||||||
|
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
public bool IsSuspended { get; set; }
|
||||||
|
public string SuspensionReason { get; set; }
|
||||||
|
}
|
||||||
38
BTCPayServer.Client/Models/SubscriptionPlanModel.cs
Normal file
38
BTCPayServer.Client/Models/SubscriptionPlanModel.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
using BTCPayServer.JsonConverters;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
public class SubscriptionPlanModel
|
||||||
|
{
|
||||||
|
public enum PlanStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Retired
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RecurringInterval
|
||||||
|
{
|
||||||
|
Monthly,
|
||||||
|
Quarterly,
|
||||||
|
Yearly,
|
||||||
|
Lifetime
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Id { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public PlanStatus Status { get; set; }
|
||||||
|
[JsonConverter(typeof(NumericStringJsonConverter))]
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public RecurringInterval RecurringType { get; set; }
|
||||||
|
public int GracePeriodDays { get; set; }
|
||||||
|
public int TrialDays { get; set; }
|
||||||
|
public string Description { get; set; }
|
||||||
|
public int MemberCount { get; set; }
|
||||||
|
public bool OptimisticActivation { get; set; }
|
||||||
|
public string[] Entitlements { get; set; }
|
||||||
|
}
|
||||||
180
BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
Normal file
180
BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
using System;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Client.Models;
|
||||||
|
|
||||||
|
public class WebhookSubscriptionEvent : StoreWebhookEvent
|
||||||
|
{
|
||||||
|
public const string SubscriberCreated = nameof(SubscriberCreated);
|
||||||
|
public const string SubscriberCredited = nameof(SubscriberCredited);
|
||||||
|
public const string SubscriberCharged = nameof(SubscriberCharged);
|
||||||
|
public const string SubscriberActivated = nameof(SubscriberActivated);
|
||||||
|
public const string SubscriberPhaseChanged = nameof(SubscriberPhaseChanged);
|
||||||
|
public const string SubscriberDisabled = nameof(SubscriberDisabled);
|
||||||
|
public const string PaymentReminder = nameof(PaymentReminder);
|
||||||
|
public const string PlanStarted = nameof(PlanStarted);
|
||||||
|
public const string SubscriberNeedUpgrade = nameof(SubscriberNeedUpgrade);
|
||||||
|
|
||||||
|
public static bool IsSubscriptionTrigger(string trigger)
|
||||||
|
=> IsSubscriptionType(trigger.Substring(3));
|
||||||
|
public static bool IsSubscriptionType(string substring)
|
||||||
|
=> substring is
|
||||||
|
SubscriberCreated or
|
||||||
|
SubscriberCredited or
|
||||||
|
SubscriberCharged or
|
||||||
|
SubscriberActivated or
|
||||||
|
SubscriberPhaseChanged or
|
||||||
|
SubscriberDisabled or
|
||||||
|
PaymentReminder or
|
||||||
|
PlanStarted;
|
||||||
|
public class SubscriberEvent : WebhookSubscriptionEvent
|
||||||
|
{
|
||||||
|
public SubscriberEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberEvent(string eventType, string storeId) : base(eventType, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberModel Subscriber { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscription phases carried by subscriber-related webhook events
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public enum SubscriptionPhase
|
||||||
|
{
|
||||||
|
Normal,
|
||||||
|
Expired,
|
||||||
|
Grace,
|
||||||
|
Trial
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NewSubscriberEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public NewSubscriberEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NewSubscriberEvent(string storeId) : base(SubscriberCreated, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberCreditedEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public SubscriberCreditedEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberCreditedEvent(string storeId) : base(SubscriberCredited, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class SubscriberChargedEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public SubscriberChargedEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberChargedEvent(string storeId) : base(SubscriberCharged, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public decimal Total { get; set; }
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class SubscriberActivatedEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public SubscriberActivatedEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberActivatedEvent(string storeId) : base(SubscriberActivated, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public class SubscriberPhaseChangedEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public SubscriberPhaseChangedEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberPhaseChangedEvent(string storeId) : base(SubscriberPhaseChanged, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionPhase PreviousPhase { get; set; }
|
||||||
|
public SubscriptionPhase CurrentPhase { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberDisabledEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public SubscriberDisabledEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberDisabledEvent(string storeId) : base(SubscriberDisabled, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PaymentReminderEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public PaymentReminderEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PaymentReminderEvent(string storeId) : base(PaymentReminder, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlanStartedEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public PlanStartedEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanStartedEvent(string storeId) : base(PlanStarted, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AutoRenew { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NeedUpgradeEvent : WebhookSubscriptionEvent.SubscriberEvent
|
||||||
|
{
|
||||||
|
public NeedUpgradeEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NeedUpgradeEvent(string storeId) : base(SubscriberNeedUpgrade, storeId)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public WebhookSubscriptionEvent()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public WebhookSubscriptionEvent(string evtType, string storeId)
|
||||||
|
{
|
||||||
|
Type = evtType;
|
||||||
|
StoreId = storeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,8 @@ namespace BTCPayServer.Client
|
|||||||
public const string CanViewPayouts = "btcpay.store.canviewpayouts";
|
public const string CanViewPayouts = "btcpay.store.canviewpayouts";
|
||||||
public const string CanCreatePullPayments = "btcpay.store.cancreatepullpayments";
|
public const string CanCreatePullPayments = "btcpay.store.cancreatepullpayments";
|
||||||
public const string CanViewPullPayments = "btcpay.store.canviewpullpayments";
|
public const string CanViewPullPayments = "btcpay.store.canviewpullpayments";
|
||||||
|
public const string CanViewMembership = "btcpay.store.canviewmembership";
|
||||||
|
public const string CanModifyMembership = "btcpay.store.canmodifymembership";
|
||||||
public const string CanCreateNonApprovedPullPayments = "btcpay.store.cancreatenonapprovedpullpayments";
|
public const string CanCreateNonApprovedPullPayments = "btcpay.store.cancreatenonapprovedpullpayments";
|
||||||
public const string Unrestricted = "unrestricted";
|
public const string Unrestricted = "unrestricted";
|
||||||
public static IEnumerable<string> AllPolicies
|
public static IEnumerable<string> AllPolicies
|
||||||
@@ -74,6 +76,8 @@ namespace BTCPayServer.Client
|
|||||||
yield return CanArchivePullPayments;
|
yield return CanArchivePullPayments;
|
||||||
yield return CanCreatePullPayments;
|
yield return CanCreatePullPayments;
|
||||||
yield return CanViewPullPayments;
|
yield return CanViewPullPayments;
|
||||||
|
yield return CanViewMembership;
|
||||||
|
yield return CanModifyMembership;
|
||||||
yield return CanCreateNonApprovedPullPayments;
|
yield return CanCreateNonApprovedPullPayments;
|
||||||
yield return CanManageUsers;
|
yield return CanManageUsers;
|
||||||
yield return CanManagePayouts;
|
yield return CanManagePayouts;
|
||||||
@@ -261,6 +265,7 @@ namespace BTCPayServer.Client
|
|||||||
Policies.CanModifyWebhooks,
|
Policies.CanModifyWebhooks,
|
||||||
Policies.CanModifyPaymentRequests,
|
Policies.CanModifyPaymentRequests,
|
||||||
Policies.CanManagePayouts,
|
Policies.CanManagePayouts,
|
||||||
|
Policies.CanModifyMembership,
|
||||||
Policies.CanUseLightningNodeInStore);
|
Policies.CanUseLightningNodeInStore);
|
||||||
|
|
||||||
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
|
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
|
||||||
@@ -269,6 +274,7 @@ namespace BTCPayServer.Client
|
|||||||
PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments);
|
PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments);
|
||||||
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
|
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
|
||||||
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
|
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
|
||||||
|
PolicyHasChild(policyMap,Policies.CanModifyMembership, Policies.CanViewMembership);
|
||||||
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
|
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
|
||||||
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
|
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
|
||||||
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
|
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Design;
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
|
|
||||||
namespace BTCPayServer.Data
|
namespace BTCPayServer.Data
|
||||||
{
|
{
|
||||||
@@ -20,7 +15,7 @@ namespace BTCPayServer.Data
|
|||||||
return new ApplicationDbContext(builder.Options);
|
return new ApplicationDbContext(builder.Options);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
public partial class ApplicationDbContext : IdentityDbContext<ApplicationUser>
|
||||||
{
|
{
|
||||||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||||||
: base(options)
|
: base(options)
|
||||||
@@ -64,6 +59,7 @@ namespace BTCPayServer.Data
|
|||||||
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
|
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
|
||||||
public DbSet<FormData> Forms { get; set; }
|
public DbSet<FormData> Forms { get; set; }
|
||||||
public DbSet<PendingTransaction> PendingTransactions { get; set; }
|
public DbSet<PendingTransaction> PendingTransactions { get; set; }
|
||||||
|
public DbSet<CustomerData> Customers { get; set; }
|
||||||
|
|
||||||
public DbSet<EmailRuleData> EmailRules { get; set; }
|
public DbSet<EmailRuleData> EmailRules { get; set; }
|
||||||
|
|
||||||
@@ -72,6 +68,10 @@ namespace BTCPayServer.Data
|
|||||||
base.OnModelCreating(builder);
|
base.OnModelCreating(builder);
|
||||||
|
|
||||||
// some of the data models don't have OnModelCreating for now, commenting them
|
// some of the data models don't have OnModelCreating for now, commenting them
|
||||||
|
|
||||||
|
OnSubscriptionsModelCreating(builder);
|
||||||
|
CustomerData.OnModelCreating(builder, Database);
|
||||||
|
CustomerIdentityData.OnModelCreating(builder, Database);
|
||||||
EmailRuleData.OnModelCreating(builder, Database);
|
EmailRuleData.OnModelCreating(builder, Database);
|
||||||
ApplicationUser.OnModelCreating(builder, Database);
|
ApplicationUser.OnModelCreating(builder, Database);
|
||||||
AddressInvoiceData.OnModelCreating(builder);
|
AddressInvoiceData.OnModelCreating(builder);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||||
<PackageReference Include="NBitcoin.Altcoins" Version="5.0.0" />
|
<PackageReference Include="NBitcoin.Altcoins" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Dapper" Version="2.1.35" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
|
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
|
||||||
|
|||||||
13
BTCPayServer.Data/CustomerSelector.cs
Normal file
13
BTCPayServer.Data/CustomerSelector.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public abstract record CustomerSelector
|
||||||
|
{
|
||||||
|
public static Id ById(string customerId) => new Id(customerId);
|
||||||
|
public static ExternalRef ByExternalRef(string externalId) => new ExternalRef(externalId);
|
||||||
|
public static Identity ByIdentity(string type, string value) => new Identity(type, value);
|
||||||
|
public static Identity ByEmail(string email) => new Identity("Email", email);
|
||||||
|
|
||||||
|
public record Id(string CustomerId) : CustomerSelector;
|
||||||
|
public record ExternalRef(string Ref) : CustomerSelector;
|
||||||
|
public record Identity(string Type, string Value) : CustomerSelector;
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
|
using Dapper;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
|||||||
90
BTCPayServer.Data/Data/CustomerData.cs
Normal file
90
BTCPayServer.Data/Data/CustomerData.cs
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
[Table("customers")]
|
||||||
|
public class CustomerData : BaseEntityData
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("store_id")]
|
||||||
|
public string StoreId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey("StoreId")]
|
||||||
|
public StoreData Store { get; set; } = null!;
|
||||||
|
|
||||||
|
// Identity
|
||||||
|
[Column("external_ref")]
|
||||||
|
public string? ExternalRef { get; set; }
|
||||||
|
|
||||||
|
[Column("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public List<CustomerIdentityData> CustomerIdentities { get; set; } = null!;
|
||||||
|
|
||||||
|
public new static string GenerateId() => ValueGenerators.WithPrefix("cust")(null, null).Next(null!) as string ?? throw new InvalidOperationException("Bug, shouldn't happen");
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<CustomerData>();
|
||||||
|
OnModelCreateBase(b, builder, databaseFacade);
|
||||||
|
b.Property(x => x.Name).HasColumnName("name").HasColumnType("TEXT")
|
||||||
|
.HasDefaultValueSql("''::TEXT");
|
||||||
|
|
||||||
|
b.HasKey(x => new { x.Id });
|
||||||
|
b.HasIndex(x => new { x.StoreId, x.ExternalRef }).IsUnique();
|
||||||
|
b.Property(x => x.Id)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasValueGenerator(ValueGenerators.WithPrefix("cust"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetContact(string type)
|
||||||
|
=> (CustomerIdentities ?? throw ContactDataNotIncludedInEntity()).FirstOrDefault(c => c.Type == type)?.Value;
|
||||||
|
|
||||||
|
private static InvalidOperationException ContactDataNotIncludedInEntity()
|
||||||
|
=> new InvalidOperationException("Bug: Contact data not included in entity. Use .Include(x => x.Contacts) to include it.");
|
||||||
|
|
||||||
|
public class ContactSetter(CustomerData customer, string type)
|
||||||
|
{
|
||||||
|
public string Type { get; } = type;
|
||||||
|
public void Set(string? value) => customer.SetContact(Type, value);
|
||||||
|
public string? Get() => customer.GetContact(Type);
|
||||||
|
public override string ToString() => $"{Get()} ({Type})";
|
||||||
|
}
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public ContactSetter Email => new ContactSetter(this, "Email");
|
||||||
|
|
||||||
|
public void SetContact(string type, string? value)
|
||||||
|
{
|
||||||
|
if (CustomerIdentities is null)
|
||||||
|
throw ContactDataNotIncludedInEntity();
|
||||||
|
if (value is null)
|
||||||
|
{
|
||||||
|
CustomerIdentities.RemoveAll(c => c.Type == type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = CustomerIdentities.FirstOrDefault(c => c.Type == type);
|
||||||
|
if (existing != null)
|
||||||
|
{
|
||||||
|
existing.Value = value;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
CustomerIdentities.Add(new() { CustomerId = Id, Type = type, Value = value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetPrimaryIdentity() => Email.Get();
|
||||||
|
}
|
||||||
31
BTCPayServer.Data/Data/CustomerIdentityData.cs
Normal file
31
BTCPayServer.Data/Data/CustomerIdentityData.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
[Table("customers_identities")]
|
||||||
|
public class CustomerIdentityData
|
||||||
|
{
|
||||||
|
[Column("customer_id")]
|
||||||
|
public string CustomerId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(CustomerId))]
|
||||||
|
public CustomerData Customer { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("type")]
|
||||||
|
public string Type { get; set; }
|
||||||
|
[Required]
|
||||||
|
[Column("value")]
|
||||||
|
public string Value { get; set; }
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<CustomerIdentityData>();
|
||||||
|
b.HasKey(x=> new { x.CustomerId, x.Type });
|
||||||
|
b.HasOne(x => x.Customer).WithMany(x => x.CustomerIdentities).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -16,6 +17,11 @@ public class EmailRuleData : BaseEntityData
|
|||||||
public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}";
|
public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}";
|
||||||
[Column("store_id")]
|
[Column("store_id")]
|
||||||
public string? StoreId { get; set; }
|
public string? StoreId { get; set; }
|
||||||
|
[Column("offering_id")]
|
||||||
|
public string? OfferingId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OfferingId))]
|
||||||
|
public OfferingData? Offering { get; set; }
|
||||||
|
|
||||||
[Key]
|
[Key]
|
||||||
public long Id { get; set; }
|
public long Id { get; set; }
|
||||||
@@ -53,6 +59,7 @@ public class EmailRuleData : BaseEntityData
|
|||||||
var b = builder.Entity<EmailRuleData>();
|
var b = builder.Entity<EmailRuleData>();
|
||||||
BaseEntityData.OnModelCreateBase(b, builder, databaseFacade);
|
BaseEntityData.OnModelCreateBase(b, builder, databaseFacade);
|
||||||
b.Property(o => o.Id).UseIdentityAlwaysColumn();
|
b.Property(o => o.Id).UseIdentityAlwaysColumn();
|
||||||
|
b.HasOne(o => o.Offering).WithMany().OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasOne(o => o.Store).WithMany().OnDelete(DeleteBehavior.Cascade);
|
b.HasOne(o => o.Store).WithMany().OnDelete(DeleteBehavior.Cascade);
|
||||||
b.HasIndex(o => o.StoreId);
|
b.HasIndex(o => o.StoreId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
@@ -36,6 +37,11 @@ namespace BTCPayServer.Data
|
|||||||
[Timestamp]
|
[Timestamp]
|
||||||
// With this, update of InvoiceData will fail if the row was modified by another process
|
// With this, update of InvoiceData will fail if the row was modified by another process
|
||||||
public uint XMin { get; set; }
|
public uint XMin { get; set; }
|
||||||
|
|
||||||
|
public const string Processing = nameof(Processing);
|
||||||
|
public const string Settled = nameof(Settled);
|
||||||
|
public const string Invalid = nameof(Invalid);
|
||||||
|
|
||||||
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
{
|
{
|
||||||
builder.Entity<InvoiceData>()
|
builder.Entity<InvoiceData>()
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public partial class ApplicationDbContext
|
||||||
|
{
|
||||||
|
public DbSet<EntitlementData> Entitlements { get; set; }
|
||||||
|
public DbSet<PlanEntitlementData> PlanEntitlements { get; set; }
|
||||||
|
public DbSet<OfferingData> Offerings { get; set; }
|
||||||
|
public DbSet<SubscriberData> Subscribers { get; set; }
|
||||||
|
public DbSet<SubscriberCredit> Credits { get; set; }
|
||||||
|
public DbSet<PlanData> Plans { get; set; }
|
||||||
|
public DbSet<PlanChangeData> PlanChanges { get; set; }
|
||||||
|
public DbSet<PlanCheckoutData> PlanCheckouts { get; set; }
|
||||||
|
public DbSet<SubscriberInvoiceData> SubscribersInvoices { get; set; }
|
||||||
|
|
||||||
|
public DbSet<PortalSessionData> PortalSessions { get; set; }
|
||||||
|
|
||||||
|
public DbSet<SubscriberCreditHistoryData> SubscriberCreditHistory { get; set; }
|
||||||
|
|
||||||
|
void OnSubscriptionsModelCreating(ModelBuilder builder)
|
||||||
|
{
|
||||||
|
SubscriberCreditHistoryData.OnModelCreating(builder, Database);
|
||||||
|
PlanChangeData.OnModelCreating(builder, Database);
|
||||||
|
PortalSessionData.OnModelCreating(builder, Database);
|
||||||
|
PlanCheckoutData.OnModelCreating(builder, Database);
|
||||||
|
EntitlementData.OnModelCreating(builder, Database);
|
||||||
|
PlanEntitlementData.OnModelCreating(builder, Database);
|
||||||
|
OfferingData.OnModelCreating(builder, Database);
|
||||||
|
SubscriberData.OnModelCreating(builder, Database);
|
||||||
|
SubscriberInvoiceData.OnModelCreating(builder, Database);
|
||||||
|
SubscriberCredit.OnModelCreating(builder, Database);
|
||||||
|
PlanData.OnModelCreating(builder, Database);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Query;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public static partial class ApplicationDbContextExtensions
|
||||||
|
{
|
||||||
|
public static async Task<PlanData?> GetPlanFromId(this DbSet<PlanData> plans, string planId, string? offeringId = null, string? storeId = null)
|
||||||
|
{
|
||||||
|
var plan = await plans
|
||||||
|
.Include(o => o.Offering).ThenInclude(o => o.App).ThenInclude(o => o.StoreData)
|
||||||
|
.Include(o => o.PlanChanges).ThenInclude(o => o.PlanChange)
|
||||||
|
.Where(p => p.Id == planId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (offeringId is not null && plan?.OfferingId != offeringId)
|
||||||
|
return null;
|
||||||
|
if (storeId is not null && plan?.Offering.App.StoreDataId != storeId)
|
||||||
|
return null;
|
||||||
|
if (plan is not null)
|
||||||
|
await FetchPlanEntitlementsAsync(plans, plan);
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task FetchPlanEntitlementsAsync<T>(this DbSet<T> ctx, IEnumerable<PlanData> plans) where T : class
|
||||||
|
{
|
||||||
|
var planIds = plans.Select(p => p.Id).Distinct().ToArray();
|
||||||
|
var result = await ctx.GetDbConnection()
|
||||||
|
.QueryAsync<(
|
||||||
|
string Id,
|
||||||
|
long[] EIds,
|
||||||
|
string[] ECIds,
|
||||||
|
string[] EDesc,
|
||||||
|
string[] EName)>
|
||||||
|
(
|
||||||
|
"""
|
||||||
|
SELECT pId,
|
||||||
|
array_agg(spe.entitlement_id),
|
||||||
|
array_agg(se.custom_id),
|
||||||
|
array_agg(se.description)
|
||||||
|
FROM unnest(@planIds) pId
|
||||||
|
JOIN subs_plans_entitlements spe ON spe.plan_id = pId
|
||||||
|
JOIN subs_entitlements se ON se.id = spe.entitlement_id
|
||||||
|
GROUP BY 1
|
||||||
|
""", new { planIds }
|
||||||
|
);
|
||||||
|
var res = result.ToDictionary(x => x.Id, x => x);
|
||||||
|
foreach (var plan in plans)
|
||||||
|
{
|
||||||
|
if (plan.PlanEntitlements is not null)
|
||||||
|
continue;
|
||||||
|
plan.PlanEntitlements = new();
|
||||||
|
if (res.TryGetValue(plan.Id, out var r))
|
||||||
|
{
|
||||||
|
for (int i = 0; i < r.ECIds.Length; i++)
|
||||||
|
{
|
||||||
|
var pe = new PlanEntitlementData();
|
||||||
|
pe.Plan = plan;
|
||||||
|
pe.PlanId = plan.Id;
|
||||||
|
pe.EntitlementId = r.EIds[i];
|
||||||
|
pe.Entitlement = new()
|
||||||
|
{
|
||||||
|
Id = r.EIds[i],
|
||||||
|
CustomId = r.ECIds[i],
|
||||||
|
Description = r.EDesc[i],
|
||||||
|
};
|
||||||
|
plan.PlanEntitlements.Add(pe);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static Task FetchPlanEntitlementsAsync<T>(this DbSet<T> ctx, PlanData plan) where T : class
|
||||||
|
=> FetchPlanEntitlementsAsync(ctx, new[] { plan });
|
||||||
|
|
||||||
|
|
||||||
|
public static async Task<OfferingData?> GetOfferingData(this DbSet<OfferingData> offerings, string offeringId, string? storeId = null)
|
||||||
|
{
|
||||||
|
var offering = offerings
|
||||||
|
.Include(o => o.Entitlements)
|
||||||
|
.Include(o => o.Plans)
|
||||||
|
.Include(o => o.App)
|
||||||
|
.ThenInclude(o => o.StoreData);
|
||||||
|
|
||||||
|
var o = await offering
|
||||||
|
.Where(o => o.Id == offeringId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (storeId != null && o?.App.StoreDataId != storeId)
|
||||||
|
return null;
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<PlanCheckoutData?> GetCheckout(this DbSet<PlanCheckoutData> checkouts, string checkoutId)
|
||||||
|
{
|
||||||
|
var checkout = await checkouts
|
||||||
|
.Include(x => x.Plan).ThenInclude(x => x.Offering).ThenInclude(x => x.App).ThenInclude(x => x.StoreData)
|
||||||
|
.Include(x => x.Invoice)
|
||||||
|
.Include(x => x.Subscriber).ThenInclude(x => x!.Customer).ThenInclude(x => x.CustomerIdentities)
|
||||||
|
.Include(x => x.Subscriber).ThenInclude(x => x!.Credits)
|
||||||
|
.Include(x => x.Subscriber).ThenInclude(x => x!.Plan)
|
||||||
|
.Where(c => c.Id == checkoutId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (checkout is not null)
|
||||||
|
await FetchPlanEntitlementsAsync(checkouts, checkout.Plan);
|
||||||
|
return checkout;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<(SubscriberData?, bool Created)> GetOrCreateByCustomerId(this DbSet<SubscriberData> subs, string custId, string offeringId, string planId, bool? optimisticActivation, bool testAccount, JObject? newMemberMetadata = null)
|
||||||
|
{
|
||||||
|
var member = await subs.GetByCustomerId(custId, offeringId);
|
||||||
|
if (member != null)
|
||||||
|
return member.PlanId == planId ? (member, false) : (null, false);
|
||||||
|
var membId = await subs.GetDbConnection().ExecuteScalarAsync<long?>
|
||||||
|
("""
|
||||||
|
INSERT INTO subs_subscribers (customer_id, offering_id, plan_id, optimistic_activation, plan_started, test_account, metadata) VALUES (@custId, @offeringId, @planId, @optimisticActivation, @now, @testAccount, @metadata::JSONB)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""", new { custId, planId, offeringId, now = DateTimeOffset.UtcNow, optimisticActivation = optimisticActivation ?? false, testAccount, metadata = newMemberMetadata?.ToString() ?? "{}" });
|
||||||
|
|
||||||
|
member = membId is null ? null : await subs.GetById(membId.Value);
|
||||||
|
return member?.PlanId == planId ? (member, true) : (null, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<PortalSessionData?> GetActiveById(this IQueryable<PortalSessionData> sessions, string sessionId)
|
||||||
|
=> sessions.IncludeAll()
|
||||||
|
.Where(s => s.Id == sessionId && DateTimeOffset.UtcNow < s.Expiration).FirstOrDefaultAsync();
|
||||||
|
public static Task<PortalSessionData?> GetById(this IQueryable<PortalSessionData> sessions, string sessionId)
|
||||||
|
=> sessions.IncludeAll()
|
||||||
|
.Where(s => s.Id == sessionId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
public static IIncludableQueryable<PortalSessionData, StoreData> IncludeAll(this IQueryable<PortalSessionData> sessions)
|
||||||
|
=> sessions
|
||||||
|
.Include(s => s.Subscriber).ThenInclude(s => s.Customer).ThenInclude(s => s.CustomerIdentities)
|
||||||
|
.Include(s => s.Subscriber).ThenInclude(s => s.Credits)
|
||||||
|
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.PlanChanges).ThenInclude(s => s.PlanChange)
|
||||||
|
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.Offering).ThenInclude(s => s.App).ThenInclude(s => s.StoreData);
|
||||||
|
|
||||||
|
public static async Task<SubscriberData?> GetByCustomerId(this DbSet<SubscriberData> dbSet, string custId, string offeringId, string? planId = null,
|
||||||
|
string? storeId = null)
|
||||||
|
{
|
||||||
|
var result = await dbSet.IncludeAll()
|
||||||
|
.Where(c => c.CustomerId == custId && c.OfferingId == offeringId).FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if ((result is null) ||
|
||||||
|
(storeId != null && result.Plan?.Offering?.App?.StoreDataId != storeId) ||
|
||||||
|
(planId != null && result.PlanId != planId))
|
||||||
|
return null;
|
||||||
|
await FetchPlanEntitlementsAsync(dbSet, result.Plan);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static IIncludableQueryable<SubscriberData, List<SubscriberCredit>> IncludeAll(this IQueryable<SubscriberData> subscribers)
|
||||||
|
=> subscribers
|
||||||
|
.Include(p => p.NewPlan)
|
||||||
|
.Include(p => p.Plan).ThenInclude(p => p.Offering).ThenInclude(p => p.App)
|
||||||
|
.Include(m => m.Customer).ThenInclude(c => c.CustomerIdentities)
|
||||||
|
.Include(s => s.Credits);
|
||||||
|
|
||||||
|
public static async Task<SubscriberData?> GetById(this DbSet<SubscriberData> subscribers, long id)
|
||||||
|
{
|
||||||
|
var sub = await subscribers.IncludeAll().Where(s => s.Id == id).FirstOrDefaultAsync();
|
||||||
|
if (sub != null)
|
||||||
|
await FetchPlanEntitlementsAsync(subscribers, sub.Plan);
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<CustomerData> GetOrUpdate(this DbSet<CustomerData> dbSet, string storeId, CustomerSelector selector)
|
||||||
|
{
|
||||||
|
var cust = await GetBySelector(dbSet, storeId, selector);
|
||||||
|
if (cust != null)
|
||||||
|
return cust;
|
||||||
|
string? custId;
|
||||||
|
if (selector is CustomerSelector.Id { CustomerId: {} id })
|
||||||
|
{
|
||||||
|
custId = await dbSet.GetDbConnection()
|
||||||
|
.ExecuteScalarAsync<string>
|
||||||
|
("""
|
||||||
|
INSERT INTO customers (id, store_id) VALUES (@id, @storeId)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""", new { id, storeId });
|
||||||
|
}
|
||||||
|
else if (selector is CustomerSelector.ExternalRef { Ref: {} externalRef })
|
||||||
|
{
|
||||||
|
custId = await dbSet.GetDbConnection()
|
||||||
|
.ExecuteScalarAsync<string>
|
||||||
|
("""
|
||||||
|
INSERT INTO customers (id, external_ref, store_id) VALUES (@id, @externalRef, @storeId)
|
||||||
|
ON CONFLICT (store_id, external_ref) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
|
""", new { id = CustomerData.GenerateId(), externalRef, storeId });
|
||||||
|
}
|
||||||
|
else if (selector is CustomerSelector.Identity { Type: { } type, Value: { } value })
|
||||||
|
{
|
||||||
|
custId = await dbSet.GetDbConnection()
|
||||||
|
.ExecuteScalarAsync<string>
|
||||||
|
("""
|
||||||
|
WITH ins_cust AS (
|
||||||
|
INSERT INTO customers (id, store_id) VALUES (@id, @storeId)
|
||||||
|
RETURNING id),
|
||||||
|
ins_identity AS (
|
||||||
|
INSERT INTO customers_identities (customer_id, type, value)
|
||||||
|
SELECT id, @type, @value
|
||||||
|
FROM ins_cust
|
||||||
|
RETURNING customer_id
|
||||||
|
)
|
||||||
|
SELECT customer_id FROM ins_identity;
|
||||||
|
""", new { id = CustomerData.GenerateId(), storeId, type, value });
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new NotSupportedException(selector.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
(custId is null ?
|
||||||
|
await GetBySelector(dbSet, storeId, selector) :
|
||||||
|
await GetById(dbSet, storeId, custId)) ?? throw new InvalidOperationException("Customer not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Task<CustomerData?> GetById(this IQueryable<CustomerData> customers, string storeId, string custId)
|
||||||
|
=> GetBySelector(customers, storeId, CustomerSelector.ById(custId));
|
||||||
|
|
||||||
|
public static async Task<SubscriberData?> GetBySelector(this DbSet<SubscriberData> subscribers, string offeringId, CustomerSelector selector)
|
||||||
|
{
|
||||||
|
var ctx = (ApplicationDbContext)subscribers.GetDbContext();
|
||||||
|
var storeId = await ctx.Offerings
|
||||||
|
.Where(o => o.Id == offeringId)
|
||||||
|
.Select(o => o.App.StoreDataId)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
if (storeId is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
string? customerId = null;
|
||||||
|
customerId = selector is CustomerSelector.Id { CustomerId: {} id } ? id
|
||||||
|
: (await ctx.Customers.GetBySelector(storeId, selector))?.Id;
|
||||||
|
if (customerId is null)
|
||||||
|
return null;
|
||||||
|
return await subscribers.Where(s => s.OfferingId == offeringId && s.CustomerId == customerId).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task<CustomerData?> GetBySelector(this IQueryable<CustomerData> customers, string storeId, CustomerSelector selector)
|
||||||
|
{
|
||||||
|
customers = customers.Where(c => c.StoreId == storeId);
|
||||||
|
return (selector switch
|
||||||
|
{
|
||||||
|
CustomerSelector.Id { CustomerId: {} id } => customers.Where(c => c.Id == id),
|
||||||
|
CustomerSelector.ExternalRef { Ref: {} externalRef } => customers.Where(c => c.ExternalRef == externalRef),
|
||||||
|
CustomerSelector.Identity { Type: { } type, Value: { } value } => customers.Where(c => c.CustomerIdentities.Any(cust => cust.Type == type && cust.Value == value)),
|
||||||
|
_ => throw new NotSupportedException()
|
||||||
|
}).FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
45
BTCPayServer.Data/Data/Subscriptions/EntitlementData.cs
Normal file
45
BTCPayServer.Data/Data/Subscriptions/EntitlementData.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_entitlements")]
|
||||||
|
public class EntitlementData
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The internal ID of the entitlement, we only really use it in
|
||||||
|
/// SQL queries. This should not be exposed.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[Column("id")]
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The ID selected by the user, scoped at the offering level.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[Column("custom_id")]
|
||||||
|
public string CustomId { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Column("offering_id")]
|
||||||
|
public string OfferingId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OfferingId))]
|
||||||
|
public OfferingData Offering { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<EntitlementData>();
|
||||||
|
b.HasKey(x => x.Id);
|
||||||
|
b.Property(x => x.Id).UseIdentityAlwaysColumn();
|
||||||
|
b.HasIndex(x => new { x.OfferingId, x.CustomId }).IsUnique();
|
||||||
|
b.HasOne(x => x.Offering).WithMany(x => x.Entitlements).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
47
BTCPayServer.Data/Data/Subscriptions/OfferingData.cs
Normal file
47
BTCPayServer.Data/Data/Subscriptions/OfferingData.cs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using AngleSharp.Html;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_offerings")]
|
||||||
|
public class OfferingData : BaseEntityData
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Required]
|
||||||
|
[Column("id")]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("app_id")]
|
||||||
|
public string AppId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(AppId))]
|
||||||
|
public AppData App { get; set; } = null!;
|
||||||
|
|
||||||
|
public List<EntitlementData> Entitlements { get; set; } = null!;
|
||||||
|
public List<PlanData> Plans { get; set; } = null!;
|
||||||
|
public List<SubscriberData> Subscribers { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("success_redirect_url")]
|
||||||
|
public string? SuccessRedirectUrl { get; set; }
|
||||||
|
|
||||||
|
[Column("payment_reminder_days")]
|
||||||
|
[Required]
|
||||||
|
public int DefaultPaymentRemindersDays { get; set; }
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<OfferingData>();
|
||||||
|
OnModelCreateBase(b, builder, databaseFacade);
|
||||||
|
b.Property(x => x.DefaultPaymentRemindersDays).HasDefaultValue(3);
|
||||||
|
b.Property(x => x.Id)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasValueGenerator(ValueGenerators.WithPrefix("offering"));
|
||||||
|
b.HasOne(o => o.App).WithMany().OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
40
BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
Normal file
40
BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_plan_changes")]
|
||||||
|
public class PlanChangeData
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Column("plan_id")]
|
||||||
|
public string PlanId { get; set; } = null!;
|
||||||
|
[ForeignKey(nameof(PlanId))]
|
||||||
|
public PlanData Plan { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("plan_change_id")]
|
||||||
|
public string PlanChangeId { get; set; } = null!;
|
||||||
|
[ForeignKey(nameof(PlanChangeId))]
|
||||||
|
public PlanData PlanChange { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("type")]
|
||||||
|
public ChangeType Type { get; set; }
|
||||||
|
public enum ChangeType
|
||||||
|
{
|
||||||
|
Upgrade,
|
||||||
|
Downgrade
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<PlanChangeData>();
|
||||||
|
b.HasKey(x => new { x.PlanId, x.PlanChangeId });
|
||||||
|
b.Property(x => x.Type).HasConversion<string>();
|
||||||
|
b.HasOne(o => o.Plan).WithMany(o => o.PlanChanges).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne(o => o.PlanChange).WithMany().OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
Normal file
145
BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_plan_checkouts")]
|
||||||
|
public class PlanCheckoutData : BaseEntityData
|
||||||
|
{
|
||||||
|
public PlanCheckoutData()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanCheckoutData(SubscriberData subscriber, PlanData? plan = null)
|
||||||
|
{
|
||||||
|
plan ??= subscriber.Plan;
|
||||||
|
NewSubscriber = false;
|
||||||
|
Subscriber = subscriber;
|
||||||
|
SubscriberId = subscriber.Id;
|
||||||
|
Plan = plan;
|
||||||
|
PlanId = plan.Id;
|
||||||
|
}
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("invoice_id")]
|
||||||
|
public string? InvoiceId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(InvoiceId))]
|
||||||
|
public InvoiceData? Invoice { get; set; }
|
||||||
|
|
||||||
|
[Column("success_redirect_url")]
|
||||||
|
public string? SuccessRedirectUrl { get; set; }
|
||||||
|
|
||||||
|
[Column("is_trial")]
|
||||||
|
public bool IsTrial { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("plan_id")]
|
||||||
|
public string PlanId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(PlanId))]
|
||||||
|
public PlanData Plan { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("new_subscriber")]
|
||||||
|
public bool NewSubscriber { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal ID of the subscriber, do not expose outside, only use for querying.
|
||||||
|
/// </summary>
|
||||||
|
[Column("subscriber_id")]
|
||||||
|
public long? SubscriberId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SubscriberId))]
|
||||||
|
public SubscriberData? Subscriber { get; set; }
|
||||||
|
|
||||||
|
[Column("invoice_metadata", TypeName = "jsonb")]
|
||||||
|
public string InvoiceMetadata { get; set; } = "{}";
|
||||||
|
|
||||||
|
[Column("new_subscriber_metadata", TypeName = "jsonb")]
|
||||||
|
public string NewSubscriberMetadata { get; set; } = "{}";
|
||||||
|
|
||||||
|
[Column("test_account")]
|
||||||
|
public bool TestAccount { get; set; }
|
||||||
|
|
||||||
|
[Column("credited")]
|
||||||
|
public decimal Credited { get; set; } = 0m;
|
||||||
|
|
||||||
|
[Column("plan_started")]
|
||||||
|
public bool PlanStarted { get; set; }
|
||||||
|
|
||||||
|
[Column("refund_amount")]
|
||||||
|
public decimal? RefundAmount { get; set; }
|
||||||
|
|
||||||
|
[Column("on_pay")]
|
||||||
|
public OnPayBehavior OnPay { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("base_url", TypeName = "text")]
|
||||||
|
public RequestBaseUrl BaseUrl { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("expiration")]
|
||||||
|
public DateTimeOffset Expiration { get; set; }
|
||||||
|
|
||||||
|
public enum OnPayBehavior
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the plan if payment is due, else, do not and add the funds to the credit.
|
||||||
|
/// </summary>
|
||||||
|
SoftMigration,
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the plan immediately. If a payment wasn't due yet, reimburse the unused part of the period,
|
||||||
|
/// and start the plan.
|
||||||
|
/// </summary>
|
||||||
|
HardMigration
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? GetRedirectUrl()
|
||||||
|
{
|
||||||
|
if (SuccessRedirectUrl is null)
|
||||||
|
return null;
|
||||||
|
// Add ?checkoutPlanId=... to the redirect URL
|
||||||
|
try { return QueryHelpers.AddQueryString(SuccessRedirectUrl, "checkoutPlanId", Id); }
|
||||||
|
catch (UriFormatException) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<PlanCheckoutData>();
|
||||||
|
OnModelCreateBase(b, builder, databaseFacade);
|
||||||
|
b.Property(x => x.Id)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasValueGenerator(ValueGenerators.WithPrefix("plancheckout"));
|
||||||
|
|
||||||
|
b.Property(x => x.InvoiceMetadata).HasColumnName("invoice_metadata").HasColumnType("jsonb")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
b.Property(x => x.NewSubscriberMetadata).HasColumnName("new_subscriber_metadata").HasColumnType("jsonb")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property(x => x.BaseUrl)
|
||||||
|
.HasConversion<string>(
|
||||||
|
x => x.ToString(),
|
||||||
|
x => RequestBaseUrl.FromUrl(x)
|
||||||
|
);
|
||||||
|
|
||||||
|
b.HasIndex(x => x.Expiration);
|
||||||
|
b.Property(x => x.Expiration).HasDefaultValueSql("now() + interval '1 day'");
|
||||||
|
b.Property(x => x.OnPay).HasDefaultValue(OnPayBehavior.SoftMigration).HasConversion<string>();
|
||||||
|
b.Property(x => x.IsTrial).HasDefaultValue(false);
|
||||||
|
b.HasOne(x => x.Plan).WithMany().OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne(x => x.Subscriber).WithMany().OnDelete(DeleteBehavior.SetNull);
|
||||||
|
b.HasOne(x => x.Invoice).WithMany().OnDelete(DeleteBehavior.SetNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public bool IsExpired => DateTimeOffset.UtcNow > Expiration;
|
||||||
|
}
|
||||||
124
BTCPayServer.Data/Data/Subscriptions/PlanData.cs
Normal file
124
BTCPayServer.Data/Data/Subscriptions/PlanData.cs
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using static BTCPayServer.Data.Subscriptions.SubscriberData;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
|
||||||
|
[Table("subs_plans")]
|
||||||
|
public class PlanData : BaseEntityData
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
public string Id { get; set; } = null!;
|
||||||
|
|
||||||
|
public List<SubscriberData> Subscriptions { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("offering_id")]
|
||||||
|
public string OfferingId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OfferingId))]
|
||||||
|
public OfferingData Offering { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("name")]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("status")]
|
||||||
|
public PlanStatus Status { get; set; } = PlanStatus.Active;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("price")]
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("currency")]
|
||||||
|
public string Currency { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("recurring_type")]
|
||||||
|
public RecurringInterval RecurringType { get; set; } = RecurringInterval.Monthly;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("grace_period_days")]
|
||||||
|
public int GracePeriodDays { get; set; }
|
||||||
|
[Required]
|
||||||
|
[Column("trial_days")]
|
||||||
|
public int TrialDays { get; set; }
|
||||||
|
|
||||||
|
[Column("description")]
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
[Column("members_count")]
|
||||||
|
public int MemberCount { get; set; }
|
||||||
|
|
||||||
|
[Column("monthly_revenue")]
|
||||||
|
public decimal MonthlyRevenue { get; set; }
|
||||||
|
|
||||||
|
[Column("optimistic_activation")]
|
||||||
|
public bool OptimisticActivation { get; set; } = true;
|
||||||
|
|
||||||
|
[Column("renewable")]
|
||||||
|
public bool Renewable { get; set; } = true;
|
||||||
|
|
||||||
|
public List<PlanChangeData> PlanChanges { get; set; } = null!;
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<PlanData>();
|
||||||
|
OnModelCreateBase(b, builder, databaseFacade);
|
||||||
|
b.Property(x => x.Id)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasValueGenerator(ValueGenerators.WithPrefix("plan"));
|
||||||
|
b.Property(x => x.Status).HasConversion<string>();
|
||||||
|
b.Property(x => x.OptimisticActivation).HasDefaultValue(true);
|
||||||
|
b.Property(x => x.RecurringType).HasConversion<string>();
|
||||||
|
b.Property(x => x.Renewable).HasDefaultValue(true);
|
||||||
|
b.HasOne(x => x.Offering).WithMany(x => x.Plans).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
public enum PlanStatus
|
||||||
|
{
|
||||||
|
Active,
|
||||||
|
Retired
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RecurringInterval
|
||||||
|
{
|
||||||
|
Monthly,
|
||||||
|
Quarterly,
|
||||||
|
Yearly,
|
||||||
|
Lifetime
|
||||||
|
}
|
||||||
|
|
||||||
|
public (DateTimeOffset? PeriodEnd, DateTimeOffset? PeriodGraceEnd) GetPeriodEnd(DateTimeOffset from)
|
||||||
|
{
|
||||||
|
if (this.RecurringType == RecurringInterval.Lifetime)
|
||||||
|
return (null, null);
|
||||||
|
var to = from.AddMonths(this.RecurringType switch
|
||||||
|
{
|
||||||
|
RecurringInterval.Monthly => 1,
|
||||||
|
RecurringInterval.Quarterly => 3,
|
||||||
|
RecurringInterval.Yearly => 12,
|
||||||
|
_ => throw new NotSupportedException(RecurringType.ToString())
|
||||||
|
});
|
||||||
|
return (to, GracePeriodDays is 0 ? null : to.AddDays(GracePeriodDays));
|
||||||
|
}
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
// Avoid cartesian explosion if there are lots of entitlements
|
||||||
|
public List<PlanEntitlementData> PlanEntitlements { get; set; } = null!;
|
||||||
|
|
||||||
|
public PlanEntitlementData? GetEntitlement(long entitmentId)
|
||||||
|
=> PlanEntitlements.FirstOrDefault(p => p.EntitlementId == entitmentId);
|
||||||
|
public string[] GetEntitlementIds()
|
||||||
|
=> PlanEntitlements.Select(p => p.Entitlement.CustomId).ToArray();
|
||||||
|
}
|
||||||
34
BTCPayServer.Data/Data/Subscriptions/PlanEntitlementData.cs
Normal file
34
BTCPayServer.Data/Data/Subscriptions/PlanEntitlementData.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_plans_entitlements")]
|
||||||
|
public class PlanEntitlementData
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Column("plan_id")]
|
||||||
|
public string PlanId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(PlanId))]
|
||||||
|
public PlanData Plan { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("entitlement_id")]
|
||||||
|
public long EntitlementId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(EntitlementId))]
|
||||||
|
public EntitlementData Entitlement { get; set; } = null!;
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<PlanEntitlementData>();
|
||||||
|
b.HasKey(o => new { o.PlanId, o.EntitlementId });
|
||||||
|
b.HasOne(x => x.Plan).WithMany().HasForeignKey(x => x.PlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne(x => x.Entitlement).WithMany().HasForeignKey(x => x.EntitlementId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
Normal file
49
BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_portal_sessions")]
|
||||||
|
public class PortalSessionData
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Key]
|
||||||
|
[Column("id")]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[Column("subscriber_id")]
|
||||||
|
public long SubscriberId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SubscriberId))]
|
||||||
|
public SubscriberData Subscriber { get; set; }
|
||||||
|
|
||||||
|
public StoreData GetStoreData() => Subscriber?.Offering?.App?.StoreData ?? throw new InvalidOperationException("You need to include the store in the query");
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("expiration")]
|
||||||
|
public DateTimeOffset Expiration { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("base_url", TypeName = "text")]
|
||||||
|
public RequestBaseUrl BaseUrl { get; set; }
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<PortalSessionData>();
|
||||||
|
b.HasOne(x => x.Subscriber).WithMany().HasForeignKey(x => x.SubscriberId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Property(x => x.Id)
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasValueGenerator(ValueGenerators.WithPrefix("ps"));
|
||||||
|
b.HasIndex(x => x.Expiration);
|
||||||
|
b.Property(x => x.BaseUrl)
|
||||||
|
.HasConversion<string>(
|
||||||
|
x => x.ToString(),
|
||||||
|
x => RequestBaseUrl.FromUrl(x)
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
38
BTCPayServer.Data/Data/Subscriptions/SubscriberCredit.cs
Normal file
38
BTCPayServer.Data/Data/Subscriptions/SubscriberCredit.cs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_subscriber_credits")]
|
||||||
|
public class SubscriberCredit
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[Column("subscriber_id")]
|
||||||
|
public long SubscriberId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("currency")]
|
||||||
|
public string Currency { get; set; } = null!;
|
||||||
|
[Required]
|
||||||
|
[Column("amount")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SubscriberId))]
|
||||||
|
public SubscriberData Subscriber { get; set; } = null!;
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<SubscriberCredit>();
|
||||||
|
b.HasOne(x => x.Subscriber).WithMany(x => x.Credits).HasForeignKey(x => x.SubscriberId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasKey(x => new { x.SubscriberId, x.Currency });
|
||||||
|
|
||||||
|
// Make sure currency is always uppercase at the db level
|
||||||
|
b.Property(x => x.Currency).HasConversion(
|
||||||
|
v => v.ToUpperInvariant(),
|
||||||
|
v => v);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_subscriber_credits_history")]
|
||||||
|
public class SubscriberCreditHistoryData
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[Column("subscriber_id")]
|
||||||
|
public long SubscriberId { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("currency")]
|
||||||
|
public string Currency { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("created_at", TypeName = "timestamptz")]
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[Column("description")]
|
||||||
|
public string Description { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("debit")]
|
||||||
|
public decimal Debit { get; set; }
|
||||||
|
|
||||||
|
[Column("credit")]
|
||||||
|
public decimal Credit { get; set; }
|
||||||
|
|
||||||
|
[Column("balance")]
|
||||||
|
public decimal Balance { get; set; }
|
||||||
|
|
||||||
|
public SubscriberCredit SubscriberCredit { get; set; } = null!;
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<SubscriberCreditHistoryData>();
|
||||||
|
b.Property(o => o.Id).UseIdentityAlwaysColumn();
|
||||||
|
b.Property(o => o.CreatedAt).HasDefaultValueSql("now()");
|
||||||
|
b.HasOne(o => o.SubscriberCredit).WithMany()
|
||||||
|
.HasForeignKey(o => new { o.SubscriberId, o.Currency })
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasIndex(o => new { o.SubscriberId, CreatedDate = o.CreatedAt }).IsDescending();
|
||||||
|
}
|
||||||
|
}
|
||||||
261
BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
Normal file
261
BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using System.Linq;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subs_subscribers")]
|
||||||
|
public class SubscriberData : BaseEntityData
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[Required]
|
||||||
|
[Column("id")]
|
||||||
|
public long Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("offering_id")]
|
||||||
|
public string OfferingId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(OfferingId))]
|
||||||
|
public OfferingData Offering { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("customer_id")]
|
||||||
|
public string CustomerId { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(CustomerId))]
|
||||||
|
public CustomerData Customer { get; set; } = null!;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("plan_id")]
|
||||||
|
public string PlanId { get; set; } = null!;
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public PlanData NextPlan => NewPlan ?? Plan;
|
||||||
|
|
||||||
|
[Column("new_plan_id")]
|
||||||
|
public string? NewPlanId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(NewPlanId))]
|
||||||
|
public PlanData? NewPlan { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(PlanId))]
|
||||||
|
public PlanData Plan { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("paid_amount")]
|
||||||
|
public decimal? PaidAmount { get; set; }
|
||||||
|
|
||||||
|
public decimal GetCredit(string? currency = null)
|
||||||
|
=> Credits.FirstOrDefault(c => (currency ?? c.Currency) == Plan.Currency)?.Amount ?? 0m;
|
||||||
|
|
||||||
|
public decimal MissingCredit()
|
||||||
|
=> Math.Max(0m, NextPlan.Price - GetCredit(NextPlan.Currency));
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("phase")]
|
||||||
|
public PhaseTypes Phase { get; set; } = PhaseTypes.Expired;
|
||||||
|
|
||||||
|
[Column("plan_started")]
|
||||||
|
public DateTimeOffset PlanStarted { get; set; }
|
||||||
|
|
||||||
|
[Column("period_end")]
|
||||||
|
public DateTimeOffset? PeriodEnd { get; set; }
|
||||||
|
|
||||||
|
public decimal? GetUnusedPeriodAmount() => GetUnusedPeriodAmount(DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
public decimal? GetUnusedPeriodAmount(DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (PeriodEnd is { } pe &&
|
||||||
|
pe - now > TimeSpan.Zero &&
|
||||||
|
PaidAmount is { } pa)
|
||||||
|
{
|
||||||
|
var total = pe - PlanStarted;
|
||||||
|
var remaining = pe - now;
|
||||||
|
var unused = (decimal)(remaining.TotalMilliseconds / total.TotalMilliseconds);
|
||||||
|
return pa * unused;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Column("optimistic_activation")]
|
||||||
|
public bool OptimisticActivation { get; set; }
|
||||||
|
|
||||||
|
[Column("trial_end")]
|
||||||
|
public DateTimeOffset? TrialEnd { get; set; }
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public DateTimeOffset? NextPaymentDue => PeriodEnd ?? TrialEnd;
|
||||||
|
|
||||||
|
[Column("grace_period_end")]
|
||||||
|
public DateTimeOffset? GracePeriodEnd { get; set; }
|
||||||
|
|
||||||
|
[Column("auto_renew")]
|
||||||
|
public bool AutoRenew { get; set; } = true;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("active")]
|
||||||
|
public bool IsActive { get; set; }
|
||||||
|
|
||||||
|
[Column("payment_reminder_days")]
|
||||||
|
public int? PaymentReminderDays { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("payment_reminded")]
|
||||||
|
public bool PaymentReminded { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Column("suspended")]
|
||||||
|
public bool IsSuspended { get; set; }
|
||||||
|
|
||||||
|
public List<SubscriberCredit> Credits { get; set; } = null!;
|
||||||
|
|
||||||
|
[Column("test_account")]
|
||||||
|
public bool TestAccount { get; set; }
|
||||||
|
|
||||||
|
[Column("suspension_reason")]
|
||||||
|
public string? SuspensionReason { get; set; }
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public bool CanStartNextPlan => CanStartNextPlanEx(false);
|
||||||
|
|
||||||
|
public bool CanStartNextPlanEx(bool newSubscriber) => this is
|
||||||
|
{
|
||||||
|
Phase: not PhaseTypes.Normal,
|
||||||
|
NextPlan:
|
||||||
|
{
|
||||||
|
Status: Data.Subscriptions.PlanData.PlanStatus.Active
|
||||||
|
},
|
||||||
|
IsSuspended: false
|
||||||
|
}
|
||||||
|
// If we stay on the same plan, check that the next plan is renwable
|
||||||
|
&& (newSubscriber || this.PlanId != this.NextPlan.Id || this.IsNextPlanRenewable);
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public bool IsNextPlanRenewable => this.NextPlan is { Renewable: true, Status: Data.Subscriptions.PlanData.PlanStatus.Active };
|
||||||
|
|
||||||
|
public PhaseTypes GetExpectedPhase(DateTimeOffset time)
|
||||||
|
=> this switch
|
||||||
|
{
|
||||||
|
{ TrialEnd: { } te } when time < te => PhaseTypes.Trial,
|
||||||
|
{ PeriodEnd: { } pe } when time < pe => PhaseTypes.Normal,
|
||||||
|
{ GracePeriodEnd: { } gpe } when time < gpe => PhaseTypes.Grace,
|
||||||
|
{ Plan: { RecurringType: PlanData.RecurringInterval.Lifetime }, PaidAmount: not null } => PhaseTypes.Normal,
|
||||||
|
_ => PhaseTypes.Expired
|
||||||
|
};
|
||||||
|
|
||||||
|
public enum PhaseTypes
|
||||||
|
{
|
||||||
|
Trial,
|
||||||
|
Normal,
|
||||||
|
Grace,
|
||||||
|
Expired
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<SubscriberData>();
|
||||||
|
OnModelCreateBase(b, builder, databaseFacade);
|
||||||
|
b.Property(x => x.Id).UseIdentityAlwaysColumn();
|
||||||
|
b.Property(x => x.PaymentReminderDays);
|
||||||
|
b.Property(x => x.Phase)
|
||||||
|
.HasSentinel(PhaseTypes.Expired)
|
||||||
|
.HasDefaultValueSql("'Expired'::TEXT").HasConversion<string>();
|
||||||
|
b.Property(x => x.PlanStarted).HasDefaultValueSql("now()");
|
||||||
|
b.Property(x => x.IsActive).HasDefaultValue(false);
|
||||||
|
b.Property(x => x.IsSuspended).HasDefaultValue(false);
|
||||||
|
b.Property(x => x.AutoRenew).HasDefaultValue(true);
|
||||||
|
b.Property(x => x.PaymentReminded).HasDefaultValue(false);
|
||||||
|
b.HasIndex(c => new { c.OfferingId, c.CustomerId })
|
||||||
|
.IsUnique();
|
||||||
|
b.Property(x => x.TestAccount).HasDefaultValue(false);
|
||||||
|
b.HasOne(x => x.NewPlan).WithMany().HasForeignKey(x => x.NewPlanId).OnDelete(DeleteBehavior.SetNull);
|
||||||
|
b.HasOne(x => x.Plan).WithMany(x => x.Subscriptions).HasForeignKey(x => x.PlanId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne(x => x.Offering).WithMany(x => x.Subscribers).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void StartNextPlan(DateTimeOffset now, bool trial = false)
|
||||||
|
{
|
||||||
|
var plan = NextPlan;
|
||||||
|
Plan = plan;
|
||||||
|
PlanId = plan.Id;
|
||||||
|
|
||||||
|
NewPlan = null;
|
||||||
|
NewPlanId = null;
|
||||||
|
PaymentReminded = false;
|
||||||
|
|
||||||
|
if (trial)
|
||||||
|
{
|
||||||
|
PlanStarted = now;
|
||||||
|
PeriodEnd = null;
|
||||||
|
TrialEnd = now.AddDays(plan.TrialDays);
|
||||||
|
GracePeriodEnd = null;
|
||||||
|
PaidAmount = null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var currentPhase = this.GetExpectedPhase(now);
|
||||||
|
var startDate = (currentPhase, this) switch
|
||||||
|
{
|
||||||
|
(PhaseTypes.Grace, { PeriodEnd: { } pe }) => pe,
|
||||||
|
// If the user was on trial, give him for free until the end of the trial period.
|
||||||
|
(PhaseTypes.Trial, { TrialEnd: { } te }) => te,
|
||||||
|
_ => now
|
||||||
|
};
|
||||||
|
|
||||||
|
(PeriodEnd, GracePeriodEnd) = plan.GetPeriodEnd(startDate);
|
||||||
|
PlanStarted = now;
|
||||||
|
TrialEnd = null;
|
||||||
|
PaidAmount = plan.Price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public DateTimeOffset? GetReminderDate()
|
||||||
|
{
|
||||||
|
DateTimeOffset? date = this switch
|
||||||
|
{
|
||||||
|
{ Phase: PhaseTypes.Normal or PhaseTypes.Grace, PeriodEnd: { } pe } => pe,
|
||||||
|
{ Phase: PhaseTypes.Trial, TrialEnd: { } te } => te,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (date is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return date - TimeSpan.FromDays(PaymentReminderDaysOrDefault);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
[NotMapped]
|
||||||
|
public int PaymentReminderDaysOrDefault => PaymentReminderDays ?? Plan.Offering.DefaultPaymentRemindersDays;
|
||||||
|
|
||||||
|
public string ToNiceString()
|
||||||
|
=> $"{this.Customer?.GetPrimaryIdentity()} ({CustomerId})";
|
||||||
|
|
||||||
|
public NewPlanScopeDisposable NewPlanScope(PlanData checkoutPlan)
|
||||||
|
{
|
||||||
|
var original = NewPlan;
|
||||||
|
NewPlan = checkoutPlan;
|
||||||
|
return new(this, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NewPlanScopeDisposable(SubscriberData subscriber, PlanData? originalPlan) : IDisposable
|
||||||
|
{
|
||||||
|
public bool IsCommitted { get; private set; }
|
||||||
|
public void Commit() => IsCommitted = true;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (IsCommitted)
|
||||||
|
return;
|
||||||
|
subscriber.NewPlan = originalPlan;
|
||||||
|
subscriber.NewPlanId = originalPlan?.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
[NotMapped]
|
||||||
|
public CustomerSelector CustomerSelector => CustomerSelector.ById(CustomerId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using System;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
[Table("subscriber_invoices")]
|
||||||
|
public class SubscriberInvoiceData
|
||||||
|
{
|
||||||
|
[Column("invoice_id")]
|
||||||
|
[Required]
|
||||||
|
public string InvoiceId { get; set; }
|
||||||
|
[Column("subscriber_id")]
|
||||||
|
[Required]
|
||||||
|
public long SubscriberId { get; set; }
|
||||||
|
|
||||||
|
[Column("created_at", TypeName = "timestamptz")]
|
||||||
|
[Required]
|
||||||
|
public DateTimeOffset CreatedAt { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(InvoiceId))]
|
||||||
|
public InvoiceData Invoice { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(SubscriberId))]
|
||||||
|
public SubscriberData Subscriber { get; set; }
|
||||||
|
|
||||||
|
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
|
||||||
|
{
|
||||||
|
var b = builder.Entity<SubscriberInvoiceData>();
|
||||||
|
b.HasKey(o => new { o.SubscriberId, o.InvoiceId });
|
||||||
|
b.HasOne(o => o.Subscriber).WithMany().HasForeignKey(o => o.SubscriberId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.HasOne(o => o.Invoice).WithMany().HasForeignKey(o => o.InvoiceId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
b.Property(x => x.CreatedAt).HasColumnName("created_at").HasDefaultValueSql("now()").ValueGeneratedOnAdd();
|
||||||
|
b.HasIndex(x => new { x.SubscriberId, x.CreatedAt });
|
||||||
|
}
|
||||||
|
}
|
||||||
558
BTCPayServer.Data/Migrations/20251028061727_subs.cs
Normal file
558
BTCPayServer.Data/Migrations/20251028061727_subs.cs
Normal file
@@ -0,0 +1,558 @@
|
|||||||
|
using System;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace BTCPayServer.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ApplicationDbContext))]
|
||||||
|
[Migration("20251028061727_subs")]
|
||||||
|
public partial class subs : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "offering_id",
|
||||||
|
table: "email_rules",
|
||||||
|
type: "text",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "customers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
store_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
external_ref = table.Column<string>(type: "text", nullable: true),
|
||||||
|
name = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "''::TEXT"),
|
||||||
|
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_customers", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_customers_Stores_store_id",
|
||||||
|
column: x => x.store_id,
|
||||||
|
principalTable: "Stores",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_offerings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
app_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
success_redirect_url = table.Column<string>(type: "text", nullable: true),
|
||||||
|
payment_reminder_days = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
|
||||||
|
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_offerings", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_offerings_Apps_app_id",
|
||||||
|
column: x => x.app_id,
|
||||||
|
principalTable: "Apps",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "customers_identities",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
customer_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
type = table.Column<string>(type: "text", nullable: false),
|
||||||
|
value = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_customers_identities", x => new { x.customer_id, x.type });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_customers_identities_customers_customer_id",
|
||||||
|
column: x => x.customer_id,
|
||||||
|
principalTable: "customers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_entitlements",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
|
||||||
|
custom_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
offering_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
description = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_entitlements", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_entitlements_subs_offerings_offering_id",
|
||||||
|
column: x => x.offering_id,
|
||||||
|
principalTable: "subs_offerings",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_plans",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
offering_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
status = table.Column<string>(type: "text", nullable: false),
|
||||||
|
price = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "text", nullable: false),
|
||||||
|
recurring_type = table.Column<string>(type: "text", nullable: false),
|
||||||
|
grace_period_days = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
trial_days = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
description = table.Column<string>(type: "text", nullable: true),
|
||||||
|
members_count = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
monthly_revenue = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
optimistic_activation = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
renewable = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_plans", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plans_subs_offerings_offering_id",
|
||||||
|
column: x => x.offering_id,
|
||||||
|
principalTable: "subs_offerings",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_plan_changes",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
plan_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
plan_change_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
type = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_plan_changes", x => new { x.plan_id, x.plan_change_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plan_changes_subs_plans_plan_change_id",
|
||||||
|
column: x => x.plan_change_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plan_changes_subs_plans_plan_id",
|
||||||
|
column: x => x.plan_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_plans_entitlements",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
plan_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
entitlement_id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_plans_entitlements", x => new { x.plan_id, x.entitlement_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plans_entitlements_subs_entitlements_entitlement_id",
|
||||||
|
column: x => x.entitlement_id,
|
||||||
|
principalTable: "subs_entitlements",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plans_entitlements_subs_plans_plan_id",
|
||||||
|
column: x => x.plan_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_subscribers",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
|
||||||
|
offering_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
customer_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
plan_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
new_plan_id = table.Column<string>(type: "text", nullable: true),
|
||||||
|
paid_amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
phase = table.Column<string>(type: "text", nullable: false, defaultValueSql: "'Expired'::TEXT"),
|
||||||
|
plan_started = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
|
||||||
|
period_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
optimistic_activation = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
trial_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
grace_period_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
auto_renew = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
|
||||||
|
active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
payment_reminder_days = table.Column<int>(type: "integer", nullable: true),
|
||||||
|
payment_reminded = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
suspended = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
test_account = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
suspension_reason = table.Column<string>(type: "text", nullable: true),
|
||||||
|
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_subscribers", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscribers_customers_customer_id",
|
||||||
|
column: x => x.customer_id,
|
||||||
|
principalTable: "customers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscribers_subs_offerings_offering_id",
|
||||||
|
column: x => x.offering_id,
|
||||||
|
principalTable: "subs_offerings",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscribers_subs_plans_new_plan_id",
|
||||||
|
column: x => x.new_plan_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscribers_subs_plans_plan_id",
|
||||||
|
column: x => x.plan_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_plan_checkouts",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
invoice_id = table.Column<string>(type: "text", nullable: true),
|
||||||
|
success_redirect_url = table.Column<string>(type: "text", nullable: true),
|
||||||
|
is_trial = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
|
||||||
|
plan_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
new_subscriber = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
subscriber_id = table.Column<long>(type: "bigint", nullable: true),
|
||||||
|
invoice_metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
new_subscriber_metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
test_account = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
credited = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
plan_started = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
refund_amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||||
|
on_pay = table.Column<string>(type: "text", nullable: false, defaultValue: "SoftMigration"),
|
||||||
|
base_url = table.Column<string>(type: "text", nullable: false),
|
||||||
|
expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() + interval '1 day'"),
|
||||||
|
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_plan_checkouts", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plan_checkouts_Invoices_invoice_id",
|
||||||
|
column: x => x.invoice_id,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plan_checkouts_subs_plans_plan_id",
|
||||||
|
column: x => x.plan_id,
|
||||||
|
principalTable: "subs_plans",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_plan_checkouts_subs_subscribers_subscriber_id",
|
||||||
|
column: x => x.subscriber_id,
|
||||||
|
principalTable: "subs_subscribers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.SetNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_portal_sessions",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
base_url = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_portal_sessions", x => x.id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_portal_sessions_subs_subscribers_subscriber_id",
|
||||||
|
column: x => x.subscriber_id,
|
||||||
|
principalTable: "subs_subscribers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_subscriber_credits",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "text", nullable: false),
|
||||||
|
amount = table.Column<decimal>(type: "numeric", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_subscriber_credits", x => new { x.subscriber_id, x.currency });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscriber_credits_subs_subscribers_subscriber_id",
|
||||||
|
column: x => x.subscriber_id,
|
||||||
|
principalTable: "subs_subscribers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subscriber_invoices",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
invoice_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subscriber_invoices", x => new { x.subscriber_id, x.invoice_id });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subscriber_invoices_Invoices_invoice_id",
|
||||||
|
column: x => x.invoice_id,
|
||||||
|
principalTable: "Invoices",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subscriber_invoices_subs_subscribers_subscriber_id",
|
||||||
|
column: x => x.subscriber_id,
|
||||||
|
principalTable: "subs_subscribers",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "subs_subscriber_credits_history",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<long>(type: "bigint", nullable: false)
|
||||||
|
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
|
||||||
|
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
currency = table.Column<string>(type: "text", nullable: false),
|
||||||
|
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
|
||||||
|
description = table.Column<string>(type: "text", nullable: false),
|
||||||
|
debit = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
credit = table.Column<decimal>(type: "numeric", nullable: false),
|
||||||
|
balance = table.Column<decimal>(type: "numeric", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_subs_subscriber_credits_history", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_subs_subscriber_credits_history_subs_subscriber_credits_sub~",
|
||||||
|
columns: x => new { x.subscriber_id, x.currency },
|
||||||
|
principalTable: "subs_subscriber_credits",
|
||||||
|
principalColumns: new[] { "subscriber_id", "currency" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_email_rules_offering_id",
|
||||||
|
table: "email_rules",
|
||||||
|
column: "offering_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_customers_store_id_external_ref",
|
||||||
|
table: "customers",
|
||||||
|
columns: new[] { "store_id", "external_ref" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_entitlements_offering_id_custom_id",
|
||||||
|
table: "subs_entitlements",
|
||||||
|
columns: new[] { "offering_id", "custom_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_offerings_app_id",
|
||||||
|
table: "subs_offerings",
|
||||||
|
column: "app_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plan_changes_plan_change_id",
|
||||||
|
table: "subs_plan_changes",
|
||||||
|
column: "plan_change_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plan_checkouts_expiration",
|
||||||
|
table: "subs_plan_checkouts",
|
||||||
|
column: "expiration");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plan_checkouts_invoice_id",
|
||||||
|
table: "subs_plan_checkouts",
|
||||||
|
column: "invoice_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plan_checkouts_plan_id",
|
||||||
|
table: "subs_plan_checkouts",
|
||||||
|
column: "plan_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plan_checkouts_subscriber_id",
|
||||||
|
table: "subs_plan_checkouts",
|
||||||
|
column: "subscriber_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plans_offering_id",
|
||||||
|
table: "subs_plans",
|
||||||
|
column: "offering_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_plans_entitlements_entitlement_id",
|
||||||
|
table: "subs_plans_entitlements",
|
||||||
|
column: "entitlement_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_portal_sessions_expiration",
|
||||||
|
table: "subs_portal_sessions",
|
||||||
|
column: "expiration");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_portal_sessions_subscriber_id",
|
||||||
|
table: "subs_portal_sessions",
|
||||||
|
column: "subscriber_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscriber_credits_history_subscriber_id_created_at",
|
||||||
|
table: "subs_subscriber_credits_history",
|
||||||
|
columns: new[] { "subscriber_id", "created_at" },
|
||||||
|
descending: new bool[0]);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscriber_credits_history_subscriber_id_currency",
|
||||||
|
table: "subs_subscriber_credits_history",
|
||||||
|
columns: new[] { "subscriber_id", "currency" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscribers_customer_id",
|
||||||
|
table: "subs_subscribers",
|
||||||
|
column: "customer_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscribers_new_plan_id",
|
||||||
|
table: "subs_subscribers",
|
||||||
|
column: "new_plan_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscribers_offering_id_customer_id",
|
||||||
|
table: "subs_subscribers",
|
||||||
|
columns: new[] { "offering_id", "customer_id" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subs_subscribers_plan_id",
|
||||||
|
table: "subs_subscribers",
|
||||||
|
column: "plan_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subscriber_invoices_invoice_id",
|
||||||
|
table: "subscriber_invoices",
|
||||||
|
column: "invoice_id");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_subscriber_invoices_subscriber_id_created_at",
|
||||||
|
table: "subscriber_invoices",
|
||||||
|
columns: new[] { "subscriber_id", "created_at" });
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_email_rules_subs_offerings_offering_id",
|
||||||
|
table: "email_rules",
|
||||||
|
column: "offering_id",
|
||||||
|
principalTable: "subs_offerings",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_email_rules_subs_offerings_offering_id",
|
||||||
|
table: "email_rules");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "customers_identities");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_plan_changes");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_plan_checkouts");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_plans_entitlements");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_portal_sessions");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_subscriber_credits_history");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subscriber_invoices");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_entitlements");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_subscriber_credits");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_subscribers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "customers");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_plans");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "subs_offerings");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_email_rules_offering_id",
|
||||||
|
table: "email_rules");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "offering_id",
|
||||||
|
table: "email_rules");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -194,6 +194,77 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("AspNetUsers", (string)null);
|
b.ToTable("AspNetUsers", (string)null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AdditionalData")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("additional_data")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalRef")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("external_ref");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name")
|
||||||
|
.HasDefaultValueSql("''::TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("StoreId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("store_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StoreId", "ExternalRef")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("customers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.CustomerIdentityData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("CustomerId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("customer_id");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.Property<string>("Value")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("value");
|
||||||
|
|
||||||
|
b.HasKey("CustomerId", "Type");
|
||||||
|
|
||||||
|
b.ToTable("customers_identities");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
@@ -231,6 +302,10 @@ namespace BTCPayServer.Migrations
|
|||||||
.HasColumnName("metadata")
|
.HasColumnName("metadata")
|
||||||
.HasDefaultValueSql("'{}'::jsonb");
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("OfferingId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("offering_id");
|
||||||
|
|
||||||
b.Property<string>("StoreId")
|
b.Property<string>("StoreId")
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("store_id");
|
.HasColumnName("store_id");
|
||||||
@@ -252,6 +327,8 @@ namespace BTCPayServer.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OfferingId");
|
||||||
|
|
||||||
b.HasIndex("StoreId");
|
b.HasIndex("StoreId");
|
||||||
|
|
||||||
b.ToTable("email_rules");
|
b.ToTable("email_rules");
|
||||||
@@ -948,6 +1025,591 @@ namespace BTCPayServer.Migrations
|
|||||||
b.ToTable("Files");
|
b.ToTable("Files");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.EntitlementData", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("CustomId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("custom_id");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<string>("OfferingId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("offering_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OfferingId", "CustomId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("subs_entitlements");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AdditionalData")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("additional_data")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("AppId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("app_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultPaymentRemindersDays")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasDefaultValue(3)
|
||||||
|
.HasColumnName("payment_reminder_days");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("SuccessRedirectUrl")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("success_redirect_url");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("AppId");
|
||||||
|
|
||||||
|
b.ToTable("subs_offerings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanChangeData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("PlanId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("plan_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanChangeId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("plan_change_id");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("type");
|
||||||
|
|
||||||
|
b.HasKey("PlanId", "PlanChangeId");
|
||||||
|
|
||||||
|
b.HasIndex("PlanChangeId");
|
||||||
|
|
||||||
|
b.ToTable("subs_plan_changes");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanCheckoutData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AdditionalData")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("additional_data")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("base_url");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<decimal>("Credited")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("credited");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Expiration")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expiration")
|
||||||
|
.HasDefaultValueSql("now() + interval '1 day'");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("invoice_id");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceMetadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("invoice_metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<bool>("IsTrial")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_trial");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<bool>("NewSubscriber")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("new_subscriber");
|
||||||
|
|
||||||
|
b.Property<string>("NewSubscriberMetadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("new_subscriber_metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("OnPay")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasDefaultValue("SoftMigration")
|
||||||
|
.HasColumnName("on_pay");
|
||||||
|
|
||||||
|
b.Property<string>("PlanId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("plan_id");
|
||||||
|
|
||||||
|
b.Property<bool>("PlanStarted")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("plan_started");
|
||||||
|
|
||||||
|
b.Property<decimal?>("RefundAmount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("refund_amount");
|
||||||
|
|
||||||
|
b.Property<long?>("SubscriberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("subscriber_id");
|
||||||
|
|
||||||
|
b.Property<string>("SuccessRedirectUrl")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("success_redirect_url");
|
||||||
|
|
||||||
|
b.Property<bool>("TestAccount")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("test_account");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Expiration");
|
||||||
|
|
||||||
|
b.HasIndex("InvoiceId");
|
||||||
|
|
||||||
|
b.HasIndex("PlanId");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId");
|
||||||
|
|
||||||
|
b.ToTable("subs_plan_checkouts");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AdditionalData")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("additional_data")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<int>("GracePeriodDays")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("grace_period_days");
|
||||||
|
|
||||||
|
b.Property<int>("MemberCount")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("members_count");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<decimal>("MonthlyRevenue")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("monthly_revenue");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<string>("OfferingId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("offering_id");
|
||||||
|
|
||||||
|
b.Property<bool>("OptimisticActivation")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("optimistic_activation");
|
||||||
|
|
||||||
|
b.Property<decimal>("Price")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("price");
|
||||||
|
|
||||||
|
b.Property<string>("RecurringType")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("recurring_type");
|
||||||
|
|
||||||
|
b.Property<bool>("Renewable")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("renewable");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<int>("TrialDays")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("trial_days");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("OfferingId");
|
||||||
|
|
||||||
|
b.ToTable("subs_plans");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanEntitlementData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("PlanId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("plan_id");
|
||||||
|
|
||||||
|
b.Property<long>("EntitlementId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("entitlement_id");
|
||||||
|
|
||||||
|
b.HasKey("PlanId", "EntitlementId");
|
||||||
|
|
||||||
|
b.HasIndex("EntitlementId");
|
||||||
|
|
||||||
|
b.ToTable("subs_plans_entitlements");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PortalSessionData", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("BaseUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("base_url");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("Expiration")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("expiration");
|
||||||
|
|
||||||
|
b.Property<long>("SubscriberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("subscriber_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Expiration");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId");
|
||||||
|
|
||||||
|
b.ToTable("subs_portal_sessions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCredit", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("SubscriberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("subscriber_id");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<decimal>("Amount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("amount");
|
||||||
|
|
||||||
|
b.HasKey("SubscriberId", "Currency");
|
||||||
|
|
||||||
|
b.ToTable("subs_subscriber_credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCreditHistoryData", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<decimal>("Balance")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("balance");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<decimal>("Credit")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("credit");
|
||||||
|
|
||||||
|
b.Property<string>("Currency")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("currency");
|
||||||
|
|
||||||
|
b.Property<decimal>("Debit")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("debit");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<long>("SubscriberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("subscriber_id");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId", "CreatedAt")
|
||||||
|
.IsDescending();
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId", "Currency");
|
||||||
|
|
||||||
|
b.ToTable("subs_subscriber_credits_history");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
|
||||||
|
|
||||||
|
b.Property<string>("AdditionalData")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("additional_data")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<bool>("AutoRenew")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("auto_renew");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("CustomerId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("customer_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("GracePeriodEnd")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("grace_period_end");
|
||||||
|
|
||||||
|
b.Property<bool>("IsActive")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("active");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSuspended")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("suspended");
|
||||||
|
|
||||||
|
b.Property<string>("Metadata")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("jsonb")
|
||||||
|
.HasColumnName("metadata")
|
||||||
|
.HasDefaultValueSql("'{}'::jsonb");
|
||||||
|
|
||||||
|
b.Property<string>("NewPlanId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("new_plan_id");
|
||||||
|
|
||||||
|
b.Property<string>("OfferingId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("offering_id");
|
||||||
|
|
||||||
|
b.Property<bool>("OptimisticActivation")
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasColumnName("optimistic_activation");
|
||||||
|
|
||||||
|
b.Property<decimal?>("PaidAmount")
|
||||||
|
.HasColumnType("numeric")
|
||||||
|
.HasColumnName("paid_amount");
|
||||||
|
|
||||||
|
b.Property<bool>("PaymentReminded")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("payment_reminded");
|
||||||
|
|
||||||
|
b.Property<int?>("PaymentReminderDays")
|
||||||
|
.HasColumnType("integer")
|
||||||
|
.HasColumnName("payment_reminder_days");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("PeriodEnd")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("period_end");
|
||||||
|
|
||||||
|
b.Property<string>("Phase")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("phase")
|
||||||
|
.HasDefaultValueSql("'Expired'::TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("PlanId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("plan_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("PlanStarted")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("plan_started")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.Property<string>("SuspensionReason")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("suspension_reason");
|
||||||
|
|
||||||
|
b.Property<bool>("TestAccount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("boolean")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("test_account");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("TrialEnd")
|
||||||
|
.HasColumnType("timestamp with time zone")
|
||||||
|
.HasColumnName("trial_end");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("CustomerId");
|
||||||
|
|
||||||
|
b.HasIndex("NewPlanId");
|
||||||
|
|
||||||
|
b.HasIndex("PlanId");
|
||||||
|
|
||||||
|
b.HasIndex("OfferingId", "CustomerId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("subs_subscribers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberInvoiceData", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("SubscriberId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("subscriber_id");
|
||||||
|
|
||||||
|
b.Property<string>("InvoiceId")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("invoice_id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("timestamptz")
|
||||||
|
.HasColumnName("created_at")
|
||||||
|
.HasDefaultValueSql("now()");
|
||||||
|
|
||||||
|
b.HasKey("SubscriberId", "InvoiceId");
|
||||||
|
|
||||||
|
b.HasIndex("InvoiceId");
|
||||||
|
|
||||||
|
b.HasIndex("SubscriberId", "CreatedAt");
|
||||||
|
|
||||||
|
b.ToTable("subscriber_invoices");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
||||||
{
|
{
|
||||||
b.Property<string>("Id")
|
b.Property<string>("Id")
|
||||||
@@ -1303,13 +1965,42 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("StoreData");
|
b.Navigation("StoreData");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("StoreId")
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Store");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.CustomerIdentityData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.CustomerData", "Customer")
|
||||||
|
.WithMany("CustomerIdentities")
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("OfferingId")
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.StoreData", "Store")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("StoreId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
b.Navigation("Offering");
|
||||||
|
|
||||||
b.Navigation("Store");
|
b.Navigation("Store");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1539,6 +2230,188 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("ApplicationUser");
|
b.Navigation("ApplicationUser");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.EntitlementData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
|
||||||
|
.WithMany("Entitlements")
|
||||||
|
.HasForeignKey("OfferingId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Offering");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.AppData", "App")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AppId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("App");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanChangeData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "PlanChange")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PlanChangeId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
|
||||||
|
.WithMany("PlanChanges")
|
||||||
|
.HasForeignKey("PlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Plan");
|
||||||
|
|
||||||
|
b.Navigation("PlanChange");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanCheckoutData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InvoiceId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubscriberId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.Navigation("Invoice");
|
||||||
|
|
||||||
|
b.Navigation("Plan");
|
||||||
|
|
||||||
|
b.Navigation("Subscriber");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
|
||||||
|
.WithMany("Plans")
|
||||||
|
.HasForeignKey("OfferingId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Offering");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanEntitlementData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.EntitlementData", "Entitlement")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("EntitlementId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("PlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Entitlement");
|
||||||
|
|
||||||
|
b.Navigation("Plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PortalSessionData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubscriberId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Subscriber");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCredit", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
|
||||||
|
.WithMany("Credits")
|
||||||
|
.HasForeignKey("SubscriberId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Subscriber");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCreditHistoryData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberCredit", "SubscriberCredit")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubscriberId", "Currency")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("SubscriberCredit");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.CustomerData", "Customer")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("CustomerId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "NewPlan")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("NewPlanId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
|
||||||
|
.WithMany("Subscribers")
|
||||||
|
.HasForeignKey("OfferingId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
|
||||||
|
.WithMany("Subscriptions")
|
||||||
|
.HasForeignKey("PlanId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Customer");
|
||||||
|
|
||||||
|
b.Navigation("NewPlan");
|
||||||
|
|
||||||
|
b.Navigation("Offering");
|
||||||
|
|
||||||
|
b.Navigation("Plan");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberInvoiceData", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("InvoiceId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("SubscriberId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Invoice");
|
||||||
|
|
||||||
|
b.Navigation("Subscriber");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
|
||||||
@@ -1683,6 +2556,11 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("UserStores");
|
b.Navigation("UserStores");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("CustomerIdentities");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("AddressInvoices");
|
b.Navigation("AddressInvoices");
|
||||||
@@ -1735,6 +2613,27 @@ namespace BTCPayServer.Migrations
|
|||||||
b.Navigation("Users");
|
b.Navigation("Users");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Entitlements");
|
||||||
|
|
||||||
|
b.Navigation("Plans");
|
||||||
|
|
||||||
|
b.Navigation("Subscribers");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("PlanChanges");
|
||||||
|
|
||||||
|
b.Navigation("Subscriptions");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Credits");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
|
modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("WalletTransactions");
|
b.Navigation("WalletTransactions");
|
||||||
|
|||||||
22
BTCPayServer.Data/ValueGenerators.cs
Normal file
22
BTCPayServer.Data/ValueGenerators.cs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
using Microsoft.EntityFrameworkCore.Metadata;
|
||||||
|
using Microsoft.EntityFrameworkCore.ValueGeneration;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBitcoin.DataEncoders;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Data;
|
||||||
|
|
||||||
|
public class ValueGenerators
|
||||||
|
{
|
||||||
|
class WithPrefixGen(string prefix) : ValueGenerator
|
||||||
|
{
|
||||||
|
protected override object NextValue(EntityEntry entry)
|
||||||
|
=> $"{prefix}_{Encoders.Base58.EncodeData(RandomUtils.GetBytes(13))}";
|
||||||
|
|
||||||
|
public override bool GeneratesTemporaryValues => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Func<IProperty, ITypeBase, ValueGenerator> WithPrefix(string prefix)
|
||||||
|
=> (_, _) => new WithPrefixGen(prefix);
|
||||||
|
}
|
||||||
35
BTCPayServer.Tests/PMO/InvoiceCheckoutPMO.cs
Normal file
35
BTCPayServer.Tests/PMO/InvoiceCheckoutPMO.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Tests.PMO;
|
||||||
|
|
||||||
|
public class InvoiceCheckoutPMO(PlaywrightTester s)
|
||||||
|
{
|
||||||
|
public class InvoiceAssertions
|
||||||
|
{
|
||||||
|
public string? AmountDue { get; set; }
|
||||||
|
public string? TotalFiat { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertContent(InvoiceAssertions assertions)
|
||||||
|
{
|
||||||
|
if (assertions.AmountDue is not null)
|
||||||
|
{
|
||||||
|
var el = await s.Page.WaitForSelectorAsync("#AmountDue");
|
||||||
|
var content = await el!.TextContentAsync();
|
||||||
|
Assert.Equal(assertions.AmountDue.NormalizeWhitespaces(), content.NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assertions.TotalFiat is not null)
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync("#DetailsToggle");
|
||||||
|
var el = await s.Page.WaitForSelectorAsync("#total_fiat");
|
||||||
|
var content = await el!.TextContentAsync();
|
||||||
|
Assert.Equal(assertions.TotalFiat.NormalizeWhitespaces(), content.NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ClickRedirect()
|
||||||
|
=> await s.Page.ClickAsync("#StoreLink");
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ using BTCPayServer.Plugins.PointOfSale;
|
|||||||
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
using BTCPayServer.Plugins.PointOfSale.Controllers;
|
||||||
using BTCPayServer.Plugins.PointOfSale.Models;
|
using BTCPayServer.Plugins.PointOfSale.Models;
|
||||||
using BTCPayServer.Services.Apps;
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Tests.PMO;
|
||||||
using BTCPayServer.Views.Stores;
|
using BTCPayServer.Views.Stores;
|
||||||
using LNURL;
|
using LNURL;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -720,12 +721,8 @@ goodies:
|
|||||||
await s.GoToInvoiceCheckout();
|
await s.GoToInvoiceCheckout();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
|
private static Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
|
||||||
{
|
=> new InvoiceCheckoutPMO(s).AssertContent(new() { AmountDue = expectedAmount });
|
||||||
var el = await s.Page.WaitForSelectorAsync("#AmountDue");
|
|
||||||
var content = await el!.TextContentAsync();
|
|
||||||
Assert.Equal(expectedAmount.NormalizeWhitespaces(), content.NormalizeWhitespaces());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
[Trait("Playwright", "Playwright")]
|
[Trait("Playwright", "Playwright")]
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ namespace BTCPayServer.Tests
|
|||||||
return (name, appId);
|
return (name, appId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task PayInvoice(bool mine = false, decimal? amount = null)
|
public async Task PayInvoice(bool mine = false, decimal? amount = null, bool clickRedirect = false)
|
||||||
{
|
{
|
||||||
if (amount is not null)
|
if (amount is not null)
|
||||||
{
|
{
|
||||||
@@ -585,6 +585,10 @@ namespace BTCPayServer.Tests
|
|||||||
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
|
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
|
||||||
else
|
else
|
||||||
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync();
|
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync();
|
||||||
|
if (clickRedirect)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#StoreLink");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ namespace BTCPayServer.Tests
|
|||||||
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
|
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
|
||||||
{
|
{
|
||||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt =>
|
var sub = PayTester.GetService<EventAggregator>().SubscribeAny<T>(evt =>
|
||||||
{
|
{
|
||||||
if (correctEvent is null)
|
if (correctEvent is null)
|
||||||
tcs.TrySetResult(evt);
|
tcs.TrySetResult(evt);
|
||||||
|
|||||||
965
BTCPayServer.Tests/SubscriptionTests.cs
Normal file
965
BTCPayServer.Tests/SubscriptionTests.cs
Normal file
@@ -0,0 +1,965 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.Plugins;
|
||||||
|
using BTCPayServer.Tests.PMO;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
using NBitcoin;
|
||||||
|
using NBXplorer;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Tests;
|
||||||
|
|
||||||
|
[Collection(nameof(NonParallelizableCollectionDefinition))]
|
||||||
|
public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testOutputHelper)
|
||||||
|
{
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Playwright", "Playwright")]
|
||||||
|
public async Task CanChangeOfferingEmailsSettings()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser();
|
||||||
|
await s.CreateNewStore();
|
||||||
|
|
||||||
|
var offering = await CreateNewSubscription(s);
|
||||||
|
await offering.GoToMails();
|
||||||
|
|
||||||
|
var settings = new OfferingPMO.EmailSettingsForm()
|
||||||
|
{
|
||||||
|
PaymentRemindersDays = 7
|
||||||
|
};
|
||||||
|
await offering.SetEmailsSettings(settings);
|
||||||
|
var actual = await offering.ReadEmailsSettings();
|
||||||
|
offering.AssertEqual(settings, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Playwright", "Playwright")]
|
||||||
|
public async Task CanEditOfferingAndPlans()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser();
|
||||||
|
await s.CreateNewStore();
|
||||||
|
|
||||||
|
await CreateNewSubscription(s);
|
||||||
|
|
||||||
|
var offeringPMO = new OfferingPMO(s);
|
||||||
|
var editPlan = new AddEditPlanPMO(s)
|
||||||
|
{
|
||||||
|
PlanName = "Test plan",
|
||||||
|
Price = "10.00",
|
||||||
|
TrialPeriod = "7",
|
||||||
|
GracePeriod = "7",
|
||||||
|
EnableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"],
|
||||||
|
PlanChanges = [AddEditPlanPMO.PlanChangeType.Upgrade, AddEditPlanPMO.PlanChangeType.Downgrade]
|
||||||
|
};
|
||||||
|
await offeringPMO.AddPlan();
|
||||||
|
await editPlan.Save();
|
||||||
|
|
||||||
|
// Remove the other plans
|
||||||
|
for (int i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" }).Nth(1).ClickAsync();
|
||||||
|
await s.ConfirmDeleteModal();
|
||||||
|
await s.FindAlertMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync();
|
||||||
|
|
||||||
|
editPlan = new AddEditPlanPMO(s);
|
||||||
|
editPlan.PlanName = "Test plan new name";
|
||||||
|
editPlan.Price = "11.00";
|
||||||
|
editPlan.TrialPeriod = "5";
|
||||||
|
editPlan.GracePeriod = "5";
|
||||||
|
editPlan.Description = "Super cool plan";
|
||||||
|
editPlan.OptimisticActivation = true;
|
||||||
|
editPlan.EnableEntitlements = ["transaction-limit-50000", "payment-processing-1", "email-support-1"];
|
||||||
|
editPlan.DisableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"];
|
||||||
|
await editPlan.Save();
|
||||||
|
|
||||||
|
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync();
|
||||||
|
|
||||||
|
var expected = editPlan;
|
||||||
|
expected.OptimisticActivation = true;
|
||||||
|
|
||||||
|
editPlan = new AddEditPlanPMO(s);
|
||||||
|
await editPlan.ReadFields();
|
||||||
|
editPlan.DisableEntitlements = null;
|
||||||
|
expected.AssertEqual(editPlan);
|
||||||
|
await s.Page.GetByTestId("offering-link").ClickAsync();
|
||||||
|
await offeringPMO.Configure();
|
||||||
|
|
||||||
|
var configureOffering = new ConfigureOfferingPMO(s)
|
||||||
|
{
|
||||||
|
Name = "New test offering 2",
|
||||||
|
SuccessRedirectUrl = "https://test.com/test",
|
||||||
|
Entitlements_0__Id = "analytics-dashboard-0-2",
|
||||||
|
Entitlements_0__ShortDescription = "Basic analytics dashboard 2",
|
||||||
|
};
|
||||||
|
await configureOffering.Fill();
|
||||||
|
|
||||||
|
// Remove "analytics-dashboard-1" which is the second item
|
||||||
|
Assert.Equal("analytics-dashboard-1", await s.Page.Locator("#Entitlements_1__Id").InputValueAsync());
|
||||||
|
await s.Page.Locator("button[name='removeIndex']").Nth(1).ClickAsync();
|
||||||
|
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
await offeringPMO.Configure();
|
||||||
|
|
||||||
|
var expectedConfigure = configureOffering;
|
||||||
|
expectedConfigure.Entitlements_1__Id = "analytics-dashboard-x";
|
||||||
|
expectedConfigure.Entitlements_1__ShortDescription = "Custom analytics & reporting";
|
||||||
|
configureOffering = new ConfigureOfferingPMO(s);
|
||||||
|
await configureOffering.ReadFields();
|
||||||
|
expectedConfigure.AssertEqual(configureOffering);
|
||||||
|
|
||||||
|
// Can we add "Support" back?
|
||||||
|
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add item" }).ClickAsync();
|
||||||
|
await s.Page.Locator("#Entitlements_14__Id").FillAsync("analytics-dashboard-1");
|
||||||
|
await s.Page.Locator("#Entitlements_14__ShortDescription").FillAsync("Advanced analytics");
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
await offeringPMO.Configure();
|
||||||
|
|
||||||
|
expectedConfigure.Entitlements_1__Id = "analytics-dashboard-1";
|
||||||
|
expectedConfigure.Entitlements_1__ShortDescription = "Advanced analytics";
|
||||||
|
configureOffering = new ConfigureOfferingPMO(s);
|
||||||
|
await configureOffering.ReadFields();
|
||||||
|
expectedConfigure.AssertEqual(configureOffering);
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
|
||||||
|
await offeringPMO.Configure();
|
||||||
|
await s.Page.GetByText("Delete this offering").ClickAsync();
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Confirm the action by typing" }).FillAsync("DELETE");
|
||||||
|
await s.Page.ClickAsync("#ConfirmContinue");
|
||||||
|
await s.FindAlertMessage(partialText: "App deleted");
|
||||||
|
|
||||||
|
|
||||||
|
await CreateNewSubscription(s);
|
||||||
|
|
||||||
|
// Change the planchanges
|
||||||
|
editPlan = await offeringPMO.Edit("Basic Plan");
|
||||||
|
await editPlan.ReadFields();
|
||||||
|
editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.Downgrade, AddEditPlanPMO.PlanChangeType.Upgrade];
|
||||||
|
await editPlan.Save();
|
||||||
|
|
||||||
|
expected = editPlan;
|
||||||
|
editPlan = await offeringPMO.Edit("Basic Plan");
|
||||||
|
await editPlan.ReadFields();
|
||||||
|
expected.AssertEqual(editPlan);
|
||||||
|
editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.None, AddEditPlanPMO.PlanChangeType.Upgrade];
|
||||||
|
await editPlan.Save();
|
||||||
|
|
||||||
|
expected = editPlan;
|
||||||
|
editPlan = await offeringPMO.Edit("Basic Plan");
|
||||||
|
await editPlan.ReadFields();
|
||||||
|
expected.AssertEqual(editPlan);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<OfferingPMO> CreateNewSubscription(PlaywrightTester s)
|
||||||
|
{
|
||||||
|
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscriptions" }).ClickAsync();
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Name *" }).FillAsync("New test offering");
|
||||||
|
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Create fake offering" }).ClickAsync();
|
||||||
|
return new(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Playwright", "Playwright")]
|
||||||
|
public async Task CanUpgradeAndDowngrade()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser();
|
||||||
|
(_, string storeId) = await s.CreateNewStore();
|
||||||
|
await s.AddDerivationScheme();
|
||||||
|
|
||||||
|
var invoice = new InvoiceCheckoutPMO(s);
|
||||||
|
var offering = await CreateNewSubscription(s);
|
||||||
|
await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true);
|
||||||
|
await offering.GoToSubscribers();
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.Downgrade("Pro Plan");
|
||||||
|
await invoice.AssertContent(new()
|
||||||
|
{
|
||||||
|
TotalFiat = "$99.00"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.ClickCallToAction();
|
||||||
|
await s.Server.WaitForEvent<SubscriptionEvent.SubscriberCredited>(async () =>
|
||||||
|
{
|
||||||
|
await s.PayInvoice(mine: true);
|
||||||
|
});
|
||||||
|
await invoice.ClickRedirect();
|
||||||
|
await s.FindAlertMessage(partialText: "The plan has been started.");
|
||||||
|
// Note that at this point, the customer has a period of 15 days + 1 month.
|
||||||
|
// This is because the trial period is 15 days, so we extend the plan.
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
await portal.Downgrade("Pro Plan");
|
||||||
|
|
||||||
|
decimal totalRefunded = 0m;
|
||||||
|
// The downgrade can be paid by the current, more expensive plan.
|
||||||
|
var unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 299.0m, daysInPeriod: 15 + DaysInThisMonth());
|
||||||
|
totalRefunded += await portal.AssertRefunded(unused);
|
||||||
|
var expectedBalance = totalRefunded - 99.0m;
|
||||||
|
await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
|
||||||
|
|
||||||
|
// This time, we should have 1 month in the current period.
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
|
||||||
|
var credited = await s.Server.WaitForEvent<SubscriptionEvent.SubscriberCredited>(async () =>
|
||||||
|
{
|
||||||
|
await portal.Downgrade("Basic Plan");
|
||||||
|
unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
|
||||||
|
totalRefunded += await portal.AssertRefunded(unused);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(unused, credited.Amount);
|
||||||
|
Assert.Equal(unused + expectedBalance, credited.Total);
|
||||||
|
|
||||||
|
|
||||||
|
expectedBalance = totalRefunded - 29.0m - 99.0m;
|
||||||
|
await portal.AssertCredit("$29.00", "-$29.00", "$0.00", $"${expectedBalance:F2}");
|
||||||
|
// The balance should now be around 202.15 USD
|
||||||
|
|
||||||
|
// Now, let's try upgrade. Since we have enough money, we should be able to upgrade without invoice.
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
await portal.Upgrade("Pro Plan");
|
||||||
|
unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 29.0m, daysInPeriod: DaysInThisMonth());
|
||||||
|
totalRefunded += await portal.AssertRefunded(unused);
|
||||||
|
expectedBalance = totalRefunded - 29.0m - 99.0m - 99.0m;
|
||||||
|
await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
|
||||||
|
|
||||||
|
// However, for going back to enterprise, we do not have enough.
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
await portal.GoTo7Days();
|
||||||
|
unused = GetUnusedPeriodValue(usedDays: 21, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
|
||||||
|
await s.Server.WaitForEvent<SubscriptionEvent.PlanStarted>(async () =>
|
||||||
|
{
|
||||||
|
await portal.Upgrade("Enterprise Plan");
|
||||||
|
await invoice.AssertContent(new()
|
||||||
|
{
|
||||||
|
TotalFiat = USD(299m - expectedBalance - unused)
|
||||||
|
});
|
||||||
|
await s.PayInvoice(mine: true);
|
||||||
|
});
|
||||||
|
await invoice.ClickRedirect();
|
||||||
|
totalRefunded += await portal.AssertRefunded(unused);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static decimal GetUnusedPeriodValue(int usedDays, decimal planPrice, int daysInPeriod)
|
||||||
|
{
|
||||||
|
var unused = (double)(daysInPeriod - usedDays) / (double)daysInPeriod;
|
||||||
|
var expected = (decimal)Math.Round((double)planPrice * unused, 2);
|
||||||
|
return expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DaysInThisMonth()
|
||||||
|
{
|
||||||
|
return DateTime.DaysInMonth(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month);
|
||||||
|
}
|
||||||
|
|
||||||
|
private string USD(decimal val)
|
||||||
|
=> $"${val.ToString("F2", CultureInfo.InvariantCulture)}";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Playwright", "Playwright")]
|
||||||
|
public async Task CanUseNonRenewableFreePlan()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser();
|
||||||
|
await s.CreateNewStore();
|
||||||
|
await s.AddDerivationScheme();
|
||||||
|
|
||||||
|
var offering = await CreateNewSubscription(s);
|
||||||
|
var addPlan = await offering.AddPlan();
|
||||||
|
addPlan.Price = "0";
|
||||||
|
addPlan.PlanName = "Free Plan";
|
||||||
|
addPlan.Renewable = false;
|
||||||
|
addPlan.OptimisticActivation = true;
|
||||||
|
addPlan.PlanChanges =
|
||||||
|
[
|
||||||
|
AddEditPlanPMO.PlanChangeType.Upgrade,
|
||||||
|
AddEditPlanPMO.PlanChangeType.None,
|
||||||
|
AddEditPlanPMO.PlanChangeType.None,
|
||||||
|
];
|
||||||
|
await addPlan.Save();
|
||||||
|
|
||||||
|
await offering.NewSubscriber("Free Plan", "free@example.com", false, hasInvoice: false);
|
||||||
|
await offering.GoToSubscribers();
|
||||||
|
await using (var portal = await offering.GoToPortal("free@example.com"))
|
||||||
|
{
|
||||||
|
await portal.GoToReminder();
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Upgrade needed in 3 days");
|
||||||
|
await portal.ClickCallToAction();
|
||||||
|
await s.PayInvoice(clickRedirect: true);
|
||||||
|
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
await portal.AssertPlan("Basic Plan");
|
||||||
|
|
||||||
|
await portal.AssertCreditHistory([
|
||||||
|
"Upgrade to new plan 'Basic Plan'",
|
||||||
|
"Credit purchase",
|
||||||
|
"Starting plan 'Free Plan'"
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
[Trait("Playwright", "Playwright")]
|
||||||
|
public async Task CanCreateSubscriberAndCircleThroughStates()
|
||||||
|
{
|
||||||
|
await using var s = CreatePlaywrightTester();
|
||||||
|
await s.StartAsync();
|
||||||
|
await s.RegisterNewUser();
|
||||||
|
(_, string storeId) = await s.CreateNewStore();
|
||||||
|
await s.AddDerivationScheme();
|
||||||
|
|
||||||
|
var offering = await CreateNewSubscription(s);
|
||||||
|
|
||||||
|
// enterprise@example.com is a trial subscriber
|
||||||
|
await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true);
|
||||||
|
|
||||||
|
// basic@example.com is a basic plan subscriber (without optimistic activation), so he needs to wait confirmation
|
||||||
|
offering.GoToPlans();
|
||||||
|
var edit = await offering.Edit("Basic Plan");
|
||||||
|
edit.OptimisticActivation = false;
|
||||||
|
await edit.Save();
|
||||||
|
|
||||||
|
await offering.NewSubscriber("Basic Plan", "basic@example.com", false);
|
||||||
|
offering.GoToPlans();
|
||||||
|
edit = await offering.Edit("Basic Plan");
|
||||||
|
edit.OptimisticActivation = true;
|
||||||
|
await edit.Save();
|
||||||
|
|
||||||
|
// basic2@example.com is a basic plan subscriber (optimistic activation), so he is imediatly activated
|
||||||
|
await offering.NewSubscriber("Basic Plan", "basic2@example.com", false);
|
||||||
|
|
||||||
|
await offering.AssertHasSubscriber("enterprise@example.com", new()
|
||||||
|
{
|
||||||
|
Phase = SubscriberData.PhaseTypes.Trial,
|
||||||
|
Active = OfferingPMO.ActiveState.Active
|
||||||
|
});
|
||||||
|
await offering.AssertHasSubscriber("basic2@example.com",
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Phase = SubscriberData.PhaseTypes.Normal,
|
||||||
|
Active = OfferingPMO.ActiveState.Active
|
||||||
|
});
|
||||||
|
// Payment isn't yet confirmed, and no optimistic activation
|
||||||
|
await offering.AssertHasNotSubscriber("basic@example.com");
|
||||||
|
|
||||||
|
// Mark the invoice of basic2 invalid, so he should go from active to inactive
|
||||||
|
var api = await s.AsTestAccount().CreateClient();
|
||||||
|
var invoiceId = (await api.GetInvoices(storeId)).First().Id;
|
||||||
|
|
||||||
|
var waiting = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberDisabled>();
|
||||||
|
await api.MarkInvoiceStatus(storeId, invoiceId, new()
|
||||||
|
{
|
||||||
|
Status = InvoiceStatus.Invalid
|
||||||
|
});
|
||||||
|
var disabled = await waiting;
|
||||||
|
Assert.True(disabled.Subscriber.IsSuspended);
|
||||||
|
Assert.Equal("The plan has been started by an invoice which later became invalid.", disabled.Subscriber.SuspensionReason);
|
||||||
|
|
||||||
|
await s.Page.ReloadAsync(new() { WaitUntil = WaitUntilState.Commit });
|
||||||
|
|
||||||
|
await offering.AssertHasSubscriber("basic2@example.com",
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Phase = SubscriberData.PhaseTypes.Normal,
|
||||||
|
Active = OfferingPMO.ActiveState.Suspended
|
||||||
|
});
|
||||||
|
|
||||||
|
await using (var suspendedPortal = await offering.GoToPortal("basic2@example.com"))
|
||||||
|
{
|
||||||
|
await suspendedPortal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended");
|
||||||
|
}
|
||||||
|
|
||||||
|
var activating = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberActivated>();
|
||||||
|
await s.Server.GetExplorerNode("BTC").EnsureGenerateAsync(1);
|
||||||
|
var activated = await activating;
|
||||||
|
Assert.Equal("basic@example.com", activated.Subscriber.Customer.GetPrimaryIdentity());
|
||||||
|
|
||||||
|
await s.Page.ReloadAsync(new() { WaitUntil = WaitUntilState.Commit });
|
||||||
|
|
||||||
|
// Payment confirmed, this one should be active now
|
||||||
|
await offering.AssertHasSubscriber("basic@example.com",
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Phase = SubscriberData.PhaseTypes.Normal,
|
||||||
|
Active = OfferingPMO.ActiveState.Active
|
||||||
|
});
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Info);
|
||||||
|
await portal.ClickCallToAction();
|
||||||
|
var changingPhase = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberPhaseChanged>();
|
||||||
|
await s.PayInvoice(mine: true, clickRedirect: true);
|
||||||
|
var changeEvent = await changingPhase;
|
||||||
|
Assert.Equal(
|
||||||
|
(SubscriberData.PhaseTypes.Normal, SubscriberData.PhaseTypes.Trial),
|
||||||
|
(changeEvent.Subscriber.Phase, changeEvent.PreviousPhase));
|
||||||
|
await s.Page.ReloadAsync();
|
||||||
|
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
|
||||||
|
var sendingPaymentReminder = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.PaymentReminder>();
|
||||||
|
await portal.GoToReminder();
|
||||||
|
var paymentReminder = await sendingPaymentReminder;
|
||||||
|
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days");
|
||||||
|
await portal.GoToNextPhase();
|
||||||
|
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Payment due");
|
||||||
|
|
||||||
|
var disabling = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberDisabled>();
|
||||||
|
await portal.GoToNextPhase();
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access expired");
|
||||||
|
await disabling;
|
||||||
|
|
||||||
|
await portal.AddCredit("19.00001");
|
||||||
|
var addingCredit = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberCredited>();
|
||||||
|
await s.PayInvoice(mine: true, clickRedirect: true);
|
||||||
|
var addedCredit = await addingCredit;
|
||||||
|
Assert.Equal((19.0m, 19.0m), (addedCredit.Amount, addedCredit.Total));
|
||||||
|
|
||||||
|
await s.Page.ReloadAsync();
|
||||||
|
await portal.AssertCredit("$299.00", "-$19.00", "$280.00");
|
||||||
|
|
||||||
|
addingCredit = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberCredited>();
|
||||||
|
await portal.ClickCallToAction();
|
||||||
|
await s.PayInvoice(mine: true, clickRedirect: true);
|
||||||
|
addedCredit = await addingCredit;
|
||||||
|
Assert.Equal((280.0m, 299.0m), (addedCredit.Amount, addedCredit.Total));
|
||||||
|
await s.Page.ReloadAsync();
|
||||||
|
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.Page.ReloadAsync();
|
||||||
|
await offering.Suspend("enterprise@example.com", "some reason");
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended",
|
||||||
|
noticeSubtitles: ["Your access to this subscription has been suspended.", "Reason: some reason"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await offering.Unsuspend("enterprise@example.com");
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
await offering.Charge("enterprise@example.com", 10.00001m, "-$10.00 (USD)");
|
||||||
|
await offering.Credit("enterprise@example.com", 15m, "$5.00 (USD)");
|
||||||
|
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
await offering.Charge("enterprise@example.com", 5m, "$0.00 (USD)");
|
||||||
|
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
|
||||||
|
{
|
||||||
|
await portal.GoToReminder();
|
||||||
|
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days");
|
||||||
|
await portal.ClickCallToAction();
|
||||||
|
await s.PayInvoice(mine: true, clickRedirect: true);
|
||||||
|
await portal.AssertNoCallToAction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OfferingPMO(PlaywrightTester s)
|
||||||
|
{
|
||||||
|
public Task Configure()
|
||||||
|
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Configure" }).ClickAsync();
|
||||||
|
|
||||||
|
public async Task<AddEditPlanPMO> AddPlan()
|
||||||
|
{
|
||||||
|
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add Plan" }).ClickAsync();
|
||||||
|
return new AddEditPlanPMO(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task NewSubscriber(string planName, string email, bool hasTrial, bool mine = false, bool? hasInvoice = null)
|
||||||
|
{
|
||||||
|
var allowTrial = await s.Page.Locator($"tr[data-plan-name='{planName}']").GetAttributeAsync("data-allow-trial") == "True";
|
||||||
|
await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .dropdown-toggle");
|
||||||
|
await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .plan-name-col a");
|
||||||
|
Assert.Equal(hasTrial, allowTrial);
|
||||||
|
if (allowTrial)
|
||||||
|
await s.Page.CheckAsync("input[name='isTrial']");
|
||||||
|
else
|
||||||
|
Assert.False(await s.Page.Locator("input[name='isTrial']").IsVisibleAsync());
|
||||||
|
await s.Page.ClickAsync("#newSubscriberModal button[name='command']");
|
||||||
|
await s.Page.FillAsync("#emailInput", email);
|
||||||
|
await s.Page.ClickAsync("button[name='command']");
|
||||||
|
if (!allowTrial && hasInvoice is not false)
|
||||||
|
{
|
||||||
|
await s.PayInvoice(mine, clickRedirect: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<AddEditPlanPMO> Edit(string planName)
|
||||||
|
{
|
||||||
|
await s.Page.Locator($"tr[data-plan-name='{planName}'] .edit-plan").ClickAsync();
|
||||||
|
return new(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task GoToSubscribers()
|
||||||
|
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscribers" }).ClickAsync();
|
||||||
|
public void GoToPlans()
|
||||||
|
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Plans" }).ClickAsync();
|
||||||
|
public Task GoToMails()
|
||||||
|
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Mails" }).ClickAsync();
|
||||||
|
|
||||||
|
public enum ActiveState
|
||||||
|
{
|
||||||
|
Inactive,
|
||||||
|
Active,
|
||||||
|
Suspended
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ExpectedSubscriber
|
||||||
|
{
|
||||||
|
public SubscriberData.PhaseTypes? Phase { get; set; }
|
||||||
|
public ActiveState? Active { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertHasSubscriber(string subscriberEmail, ExpectedSubscriber? expected = null)
|
||||||
|
{
|
||||||
|
await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).WaitForAsync();
|
||||||
|
if (expected is not null)
|
||||||
|
{
|
||||||
|
if (expected.Phase is not null)
|
||||||
|
{
|
||||||
|
var phase = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-phase").InnerTextAsync();
|
||||||
|
Assert.Equal(expected.Phase.ToString(), phase.NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expected.Active is not null)
|
||||||
|
{
|
||||||
|
var active = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .status-active").InnerTextAsync();
|
||||||
|
Assert.Equal(expected.Active.ToString(), active.NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SubscriberRowSelector(string subscriberEmail)
|
||||||
|
{
|
||||||
|
return $"tr[data-subscriber-email='{subscriberEmail}']";
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertHasNotSubscriber(string subscriberEmail)
|
||||||
|
{
|
||||||
|
Assert.Equal(0, await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<T> WaitEvent<T>()
|
||||||
|
{
|
||||||
|
using var cts = new CancellationTokenSource(5000);
|
||||||
|
var eventAggregator = s.Server.PayTester.GetService<EventAggregator>();
|
||||||
|
return await eventAggregator.WaitNext<T>(cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<PortalPMO> GoToPortal(string subscriberEmail)
|
||||||
|
{
|
||||||
|
var o = s.Page.Context.WaitForPageAsync();
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .portal-link").ClickAsync();
|
||||||
|
var switching = await s.SwitchPage(o);
|
||||||
|
return new(s, switching);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Suspend(string subscriberEmail, string? reason = null)
|
||||||
|
{
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync();
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status a").ClickAsync();
|
||||||
|
if (reason is not null)
|
||||||
|
{
|
||||||
|
await s.Page.FillAsync("#suspensionReason", reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.Page.ClickAsync("#suspendSubscriberModal button[name='command']");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Unsuspend(string subscriberEmail)
|
||||||
|
{
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync();
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status button").ClickAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Charge(string subscriberEmail, decimal value, string? expectedNewTotal = null)
|
||||||
|
=> UpdateCredit(subscriberEmail, value, expectedNewTotal, "charge");
|
||||||
|
|
||||||
|
public async Task Credit(string subscriberEmail, decimal value, string? expectedNewTotal = null)
|
||||||
|
=> await UpdateCredit(subscriberEmail, value, expectedNewTotal, "credit");
|
||||||
|
|
||||||
|
private async Task UpdateCredit(string subscriberEmail, decimal value, string? expectedNewTotal, string action)
|
||||||
|
{
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col .dropdown-toggle").ClickAsync();
|
||||||
|
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col a[data-action='{action}']").ClickAsync();
|
||||||
|
await s.Page.FillAsync("#updateCreditModal input[name='amount']", value.ToString(CultureInfo.InvariantCulture));
|
||||||
|
if (expectedNewTotal is not null)
|
||||||
|
await s.Page.WaitForSelectorAsync($"#updateCreditModal .after-change:has-text('{expectedNewTotal}')");
|
||||||
|
|
||||||
|
// Sometimes we submit by hitting Enter. Sometimes we submit by clicking the button.
|
||||||
|
var button = s.Page.Locator($"#updateCreditModal")
|
||||||
|
.GetByRole(AriaRole.Button, new() { Name = action == "credit" ? "Credit" : "Charge" });
|
||||||
|
if (RandomUtils.GetInt32() % 2 == 0)
|
||||||
|
{
|
||||||
|
await button.ClickAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await button.WaitForAsync();
|
||||||
|
await s.Page.FocusAsync("#updateCreditModal input[name='amount']");
|
||||||
|
await s.Page.Keyboard.PressAsync("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
|
await s.FindAlertMessage(partialText: action == "charge" ? "has been charged" : "has been credited");
|
||||||
|
var newCredit = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col").InnerTextAsync();
|
||||||
|
if (expectedNewTotal is not null)
|
||||||
|
Assert.Contains(expectedNewTotal.NormalizeWhitespaces(), newCredit.NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EmailSettingsForm
|
||||||
|
{
|
||||||
|
public int? PaymentRemindersDays { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SetEmailsSettings(EmailSettingsForm settings)
|
||||||
|
{
|
||||||
|
if (settings.PaymentRemindersDays is not null)
|
||||||
|
{
|
||||||
|
await s.Page.FillAsync("input[name='PaymentRemindersDays']", settings.PaymentRemindersDays.Value.ToString());
|
||||||
|
}
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
await s.FindAlertMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<EmailSettingsForm> ReadEmailsSettings()
|
||||||
|
{
|
||||||
|
var settings = new EmailSettingsForm();
|
||||||
|
settings.PaymentRemindersDays = int.Parse(await s.Page.InputValueAsync("input[name='PaymentRemindersDays']"));
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssertEqual(EmailSettingsForm expected, EmailSettingsForm actual)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected.PaymentRemindersDays, actual.PaymentRemindersDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PortalPMO(PlaywrightTester s, IAsyncDisposable disposable) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public async Task ClickCallToAction()
|
||||||
|
=> await s.Page.ClickAsync("div.alert-translucent button");
|
||||||
|
|
||||||
|
public enum CallToAction
|
||||||
|
{
|
||||||
|
Danger,
|
||||||
|
Warning,
|
||||||
|
Info
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertCallToAction(CallToAction callToAction, string? noticeTitle = null, string? noticeSubtitle = null,
|
||||||
|
string[]? noticeSubtitles = null)
|
||||||
|
{
|
||||||
|
await s.Page.Locator(GetAlertSelector(callToAction)).WaitForAsync();
|
||||||
|
if (noticeTitle is not null)
|
||||||
|
Assert.Equal(noticeTitle.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-title").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
if (noticeSubtitle is not null)
|
||||||
|
Assert.Equal(noticeSubtitle.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
if (noticeSubtitles is not null)
|
||||||
|
{
|
||||||
|
var i = 0;
|
||||||
|
foreach (var text in await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").AllInnerTextsAsync())
|
||||||
|
{
|
||||||
|
Assert.Equal(noticeSubtitles[i].NormalizeWhitespaces(), text.NormalizeWhitespaces());
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(i, noticeSubtitles.Length);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetAlertSelector(CallToAction callToAction) => $"div.alert-translucent.alert-{callToAction.ToString().ToLowerInvariant()}";
|
||||||
|
|
||||||
|
public async Task AssertNoCallToAction()
|
||||||
|
=> Assert.Equal(0, await s.Page.Locator($"div.alert-translucent").CountAsync());
|
||||||
|
|
||||||
|
|
||||||
|
public ValueTask DisposeAsync() => disposable.DisposeAsync();
|
||||||
|
|
||||||
|
public Task GoToNextPhase()
|
||||||
|
=> s.Page.ClickAsync("#MovePhase");
|
||||||
|
|
||||||
|
public Task GoTo7Days()
|
||||||
|
=> s.Page.ClickAsync("#Move7days");
|
||||||
|
|
||||||
|
public Task GoToReminder()
|
||||||
|
=> s.Page.ClickAsync("#MoveToReminder");
|
||||||
|
|
||||||
|
public async Task AddCredit(string credit)
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync("#add-credit");
|
||||||
|
await s.Page.FillAsync("#credit-input input", credit);
|
||||||
|
await s.Page.ClickAsync("#credit-input button");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertCredit(string? planPrice = null, string? creditApplied = null, string? nextCharge = null, string? creditBalance = null)
|
||||||
|
{
|
||||||
|
if (planPrice is not null)
|
||||||
|
Assert.Equal(planPrice.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator(".credit-plan-price div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
if (creditApplied is not null)
|
||||||
|
Assert.Equal(creditApplied.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator(".credit-applied div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
if (nextCharge is not null)
|
||||||
|
Assert.Equal(nextCharge.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator(".credit-next-charge div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
if (creditBalance is not null)
|
||||||
|
Assert.Equal(creditBalance.NormalizeWhitespaces(),
|
||||||
|
(await s.Page.Locator(".credit-balance").TextContentAsync()).NormalizeWhitespaces());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ChangePlan(string planName, string buttonText)
|
||||||
|
{
|
||||||
|
await s.Page.ClickAsync($".changeplan-container[data-plan-name='{planName}'] a:has-text('{buttonText}')");
|
||||||
|
await s.Page.ClickAsync($"#changePlanModal button[value='migrate']:has-text('{buttonText}')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Downgrade(string planName) => ChangePlan(planName, "Downgrade");
|
||||||
|
|
||||||
|
public Task Upgrade(string planName) => ChangePlan(planName, "Upgrade");
|
||||||
|
|
||||||
|
public async Task<decimal> AssertRefunded(decimal refunded)
|
||||||
|
{
|
||||||
|
var text = await (await s.FindAlertMessage()).TextContentAsync();
|
||||||
|
var match = Regex.Match(text!, @"\((.*?) USD has been refunded\)");
|
||||||
|
var v = decimal.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||||
|
var diff = Math.Abs(refunded - v);
|
||||||
|
Assert.True(diff < 2.0m);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertPlan(string plan)
|
||||||
|
{
|
||||||
|
var name = await s.Page.GetByTestId("plan-name").InnerTextAsync();
|
||||||
|
Assert.Equal(plan, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AssertCreditHistory(List<string> creditLines)
|
||||||
|
{
|
||||||
|
var rows = await s.Page.QuerySelectorAllAsync(".credit-history tr td:nth-child(2)");
|
||||||
|
for (int i = 0; i < creditLines.Count; i++)
|
||||||
|
{
|
||||||
|
var txt = await rows[i].InnerTextAsync();
|
||||||
|
Assert.StartsWith(creditLines[i], txt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ConfigureOfferingPMO(PlaywrightTester tester)
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? SuccessRedirectUrl { get; set; }
|
||||||
|
|
||||||
|
public string? Entitlements_0__Id { get; set; }
|
||||||
|
public string? Entitlements_0__ShortDescription { get; set; }
|
||||||
|
public string? Entitlements_1__Id { get; set; }
|
||||||
|
public string? Entitlements_1__ShortDescription { get; set; }
|
||||||
|
|
||||||
|
public async Task Fill()
|
||||||
|
{
|
||||||
|
var s = tester;
|
||||||
|
if (Name is not null)
|
||||||
|
await s.Page.Locator("#Name").FillAsync(Name);
|
||||||
|
if (SuccessRedirectUrl is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).FillAsync(SuccessRedirectUrl);
|
||||||
|
if (Entitlements_0__Id is not null)
|
||||||
|
await s.Page.Locator("#Entitlements_0__Id").FillAsync(Entitlements_0__Id);
|
||||||
|
if (Entitlements_0__ShortDescription is not null)
|
||||||
|
await s.Page.Locator("#Entitlements_0__ShortDescription").FillAsync(Entitlements_0__ShortDescription);
|
||||||
|
if (Entitlements_1__Id is not null)
|
||||||
|
await s.Page.Locator("#Entitlements_1__Id").FillAsync(Entitlements_1__Id);
|
||||||
|
if (Entitlements_1__ShortDescription is not null)
|
||||||
|
await s.Page.Locator("#Entitlements_1__ShortDescription").FillAsync(Entitlements_1__ShortDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReadFields()
|
||||||
|
{
|
||||||
|
var s = tester;
|
||||||
|
Name = await s.Page.Locator("#Name").InputValueAsync();
|
||||||
|
SuccessRedirectUrl = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).InputValueAsync();
|
||||||
|
Entitlements_0__Id = await s.Page.Locator("#Entitlements_0__Id").InputValueAsync();
|
||||||
|
Entitlements_0__ShortDescription = await s.Page.Locator("#Entitlements_0__ShortDescription").InputValueAsync();
|
||||||
|
Entitlements_1__Id = await s.Page.Locator("#Entitlements_1__Id").InputValueAsync();
|
||||||
|
Entitlements_1__ShortDescription = await s.Page.Locator("#Entitlements_1__ShortDescription").InputValueAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssertEqual(ConfigureOfferingPMO b)
|
||||||
|
{
|
||||||
|
Assert.Equal(Name ?? "", b.Name ?? "");
|
||||||
|
Assert.Equal(SuccessRedirectUrl ?? "", b.SuccessRedirectUrl ?? "");
|
||||||
|
Assert.Equal(Entitlements_0__Id ?? "", b.Entitlements_0__Id ?? "");
|
||||||
|
Assert.Equal(Entitlements_0__ShortDescription ?? "", b.Entitlements_0__ShortDescription ?? "");
|
||||||
|
Assert.Equal(Entitlements_1__Id ?? "", b.Entitlements_1__Id ?? "");
|
||||||
|
Assert.Equal(Entitlements_1__ShortDescription ?? "", b.Entitlements_1__ShortDescription ?? "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddEditPlanPMO(PlaywrightTester tester)
|
||||||
|
{
|
||||||
|
public string? PlanName { get; set; }
|
||||||
|
public string? Price { get; set; }
|
||||||
|
public string? TrialPeriod { get; set; }
|
||||||
|
public string? GracePeriod { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
public bool? OptimisticActivation { get; set; }
|
||||||
|
|
||||||
|
public List<string>? EnableEntitlements { get; set; }
|
||||||
|
public List<string>? DisableEntitlements { get; set; }
|
||||||
|
public PlanChangeType[]? PlanChanges { get; set; }
|
||||||
|
public bool? Renewable { get; set; }
|
||||||
|
|
||||||
|
public enum PlanChangeType
|
||||||
|
{
|
||||||
|
Downgrade,
|
||||||
|
Upgrade,
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Save()
|
||||||
|
{
|
||||||
|
var s = tester;
|
||||||
|
if (PlanName is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).FillAsync(PlanName);
|
||||||
|
if (Description is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).FillAsync(Description);
|
||||||
|
if (Price is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).FillAsync(Price);
|
||||||
|
if (TrialPeriod is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).FillAsync(TrialPeriod);
|
||||||
|
if (GracePeriod is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).FillAsync(GracePeriod);
|
||||||
|
|
||||||
|
if (PlanChanges is not null)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < PlanChanges.Length; i++)
|
||||||
|
{
|
||||||
|
await s.Page.Locator($"#PlanChanges_{i}__SelectedType").SelectOptionAsync(new[] { PlanChanges[i].ToString() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entitlement in EnableEntitlements ?? [])
|
||||||
|
{
|
||||||
|
await s.Page.GetByTestId($"check_{entitlement}").CheckAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var entitlement in DisableEntitlements ?? [])
|
||||||
|
{
|
||||||
|
await s.Page.GetByTestId($"check_{entitlement}").UncheckAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OptimisticActivation is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).SetCheckedAsync(OptimisticActivation.Value);
|
||||||
|
if (Renewable is not null)
|
||||||
|
await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).SetCheckedAsync(Renewable.Value);
|
||||||
|
await s.ClickPagePrimary();
|
||||||
|
await s.FindAlertMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReadFields()
|
||||||
|
{
|
||||||
|
var s = tester;
|
||||||
|
PlanName = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).InputValueAsync();
|
||||||
|
Description = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).InputValueAsync();
|
||||||
|
Price = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).InputValueAsync();
|
||||||
|
TrialPeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).InputValueAsync();
|
||||||
|
GracePeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).InputValueAsync();
|
||||||
|
|
||||||
|
foreach (var entitlement in await s.Page.QuerySelectorAllAsync(".entitlement-checkbox"))
|
||||||
|
{
|
||||||
|
var isChecked = await entitlement.IsCheckedAsync();
|
||||||
|
var id = (await entitlement.GetAttributeAsync("data-testid"))!.Substring(6);
|
||||||
|
if (isChecked)
|
||||||
|
{
|
||||||
|
EnableEntitlements ??= new();
|
||||||
|
EnableEntitlements.Add(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DisableEntitlements ??= new();
|
||||||
|
DisableEntitlements.Add(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OptimisticActivation = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).IsCheckedAsync();
|
||||||
|
Renewable = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).IsCheckedAsync();
|
||||||
|
List<PlanChangeType> changes = new();
|
||||||
|
foreach (var change in await s.Page.Locator(".plan-change-select").AllAsync())
|
||||||
|
{
|
||||||
|
changes.Add(Enum.Parse<PlanChangeType>(await change.InputValueAsync()));
|
||||||
|
}
|
||||||
|
|
||||||
|
PlanChanges = changes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AssertEqual(AddEditPlanPMO b)
|
||||||
|
{
|
||||||
|
Assert.Equal(PlanName ?? "", b.PlanName ?? "");
|
||||||
|
Assert.Equal(Description ?? "", b.Description ?? "");
|
||||||
|
Assert.Equal(Price ?? "", b.Price ?? "");
|
||||||
|
Assert.Equal(TrialPeriod ?? "", b.TrialPeriod ?? "");
|
||||||
|
Assert.Equal(GracePeriod ?? "", b.GracePeriod ?? "");
|
||||||
|
|
||||||
|
if (EnableEntitlements is not null && b.EnableEntitlements is not null)
|
||||||
|
{
|
||||||
|
Assert.Equal(EnableEntitlements.Count, b.EnableEntitlements.Count);
|
||||||
|
|
||||||
|
var (ea, eb) = (EnableEntitlements.OrderBy(e => e).ToArray(), b.EnableEntitlements.OrderBy(e => e).ToArray());
|
||||||
|
for (int i = 0; i < EnableEntitlements.Count; i++)
|
||||||
|
Assert.Equal(ea[i], eb[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DisableEntitlements is not null && b.DisableEntitlements is not null)
|
||||||
|
{
|
||||||
|
Assert.Equal(DisableEntitlements.Count, b.DisableEntitlements.Count);
|
||||||
|
var (ea, eb) = (DisableEntitlements.OrderBy(e => e).ToArray(), b.DisableEntitlements.OrderBy(e => e).ToArray());
|
||||||
|
for (int i = 0; i < DisableEntitlements.Count; i++)
|
||||||
|
Assert.Equal(ea[i], eb[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.Equal(OptimisticActivation, b.OptimisticActivation);
|
||||||
|
if (PlanChanges is not null && b.PlanChanges is not null)
|
||||||
|
{
|
||||||
|
Assert.Equal(PlanChanges.Length, b.PlanChanges.Length);
|
||||||
|
for (var i = 0; i < PlanChanges.Length; i++)
|
||||||
|
{
|
||||||
|
Assert.Equal(PlanChanges[i], b.PlanChanges[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,14 +171,14 @@ namespace BTCPayServer.Tests
|
|||||||
return controller;
|
return controller;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task CreateStoreAsync()
|
public async Task CreateStoreAsync(string preferredExchange = "CoinGecko")
|
||||||
{
|
{
|
||||||
if (UserId is null)
|
if (UserId is null)
|
||||||
{
|
{
|
||||||
await RegisterAsync();
|
await RegisterAsync();
|
||||||
}
|
}
|
||||||
var store = GetController<UIUserStoresController>();
|
var store = GetController<UIUserStoresController>();
|
||||||
await store.CreateStore(new CreateStoreViewModel { Name = "Test Store", PreferredExchange = "coingecko", CanEditPreferredExchange = true});
|
await store.CreateStore(new CreateStoreViewModel { Name = "Test Store", PreferredExchange = preferredExchange.ToLowerInvariant(), CanEditPreferredExchange = true});
|
||||||
StoreId = store.CreatedStoreId;
|
StoreId = store.CreatedStoreId;
|
||||||
parent.Stores.Add(StoreId);
|
parent.Stores.Add(StoreId);
|
||||||
}
|
}
|
||||||
@@ -225,7 +225,7 @@ namespace BTCPayServer.Tests
|
|||||||
Assert.IsType<RedirectToActionResult>(GetController<UIStoresController>().LightningSettings(lnSettingsVm).Result);
|
Assert.IsType<RedirectToActionResult>(GetController<UIStoresController>().LightningSettings(lnSettingsVm).Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RegisterAsync(bool isAdmin = false)
|
public async Task RegisterAsync(bool isAdmin = false)
|
||||||
{
|
{
|
||||||
var account = parent.PayTester.GetController<UIAccountController>();
|
var account = parent.PayTester.GetController<UIAccountController>();
|
||||||
RegisterDetails = new RegisterViewModel()
|
RegisterDetails = new RegisterViewModel()
|
||||||
|
|||||||
@@ -271,9 +271,9 @@ namespace BTCPayServer.Tests
|
|||||||
{
|
{
|
||||||
foreach (string localizer in new[] { "ViewLocalizer", "StringLocalizer" })
|
foreach (string localizer in new[] { "ViewLocalizer", "StringLocalizer" })
|
||||||
{
|
{
|
||||||
if (txt.Contains(localizer))
|
if (txt.Contains(localizer, StringComparison.InvariantCultureIgnoreCase))
|
||||||
{
|
{
|
||||||
var matches = Regex.Matches(txt, localizer + "\\[\"(.*?)\"[\\],]");
|
var matches = Regex.Matches(txt, localizer + "\\[\"(.*?)\"[\\],]", RegexOptions.IgnoreCase);
|
||||||
foreach (Match match in matches)
|
foreach (Match match in matches)
|
||||||
{
|
{
|
||||||
var k = match.Groups[1].Value;
|
var k = match.Groups[1].Value;
|
||||||
|
|||||||
@@ -57,7 +57,6 @@
|
|||||||
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" />
|
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" />
|
||||||
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.11" />
|
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.11" />
|
||||||
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
<PackageReference Include="CsvHelper" Version="32.0.3" />
|
||||||
<PackageReference Include="Dapper" Version="2.1.35" />
|
|
||||||
<PackageReference Include="Fido2" Version="3.0.1" />
|
<PackageReference Include="Fido2" Version="3.0.1" />
|
||||||
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
|
||||||
<PackageReference Include="LNURL" Version="0.0.36" />
|
<PackageReference Include="LNURL" Version="0.0.36" />
|
||||||
|
|||||||
@@ -538,6 +538,10 @@ namespace BTCPayServer.Controllers
|
|||||||
{$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "Allows viewing the selected stores' payment requests.")},
|
{$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "Allows viewing the selected stores' payment requests.")},
|
||||||
{Policies.CanViewPullPayments, ("View your pull payments", "Allows viewing pull payments on all your stores.")},
|
{Policies.CanViewPullPayments, ("View your pull payments", "Allows viewing pull payments on all your stores.")},
|
||||||
{$"{Policies.CanViewPullPayments}:", ("View selected stores' pull payments", "Allows viewing pull payments on the selected stores.")},
|
{$"{Policies.CanViewPullPayments}:", ("View selected stores' pull payments", "Allows viewing pull payments on the selected stores.")},
|
||||||
|
{Policies.CanViewMembership, ("View your membership", "Allows viewing membership on all your stores.")},
|
||||||
|
{$"{Policies.CanViewMembership}:", ("View your membership", "Allows viewing membership on the selected stores.")},
|
||||||
|
{Policies.CanModifyMembership, ("Modify your membership", "Allows modifying membership on all your stores.")},
|
||||||
|
{$"{Policies.CanModifyMembership}:", ("Modify your membership", "Allows modifying membership on the selected stores.")},
|
||||||
{Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")},
|
{Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")},
|
||||||
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")},
|
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")},
|
||||||
{Policies.CanArchivePullPayments, ("Archive your pull payments", "Allows deleting pull payments on all your stores.")},
|
{Policies.CanArchivePullPayments, ("Archive your pull payments", "Allows deleting pull payments on all your stores.")},
|
||||||
|
|||||||
@@ -569,7 +569,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
|
|||||||
services.AddRateProviderExchangeSharp<ExchangePoloniexAPI>(new("poloniex", "Poloniex", " https://api.poloniex.com/markets/price"));
|
services.AddRateProviderExchangeSharp<ExchangePoloniexAPI>(new("poloniex", "Poloniex", " https://api.poloniex.com/markets/price"));
|
||||||
services.AddRateProviderExchangeSharp<ExchangeNDAXAPI>(new("ndax", "NDAX", "https://ndax.io/api/returnTicker"));
|
services.AddRateProviderExchangeSharp<ExchangeNDAXAPI>(new("ndax", "NDAX", "https://ndax.io/api/returnTicker"));
|
||||||
|
|
||||||
services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex", "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,tWPRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0"));
|
services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex", "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,PRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0"));
|
||||||
services.AddRateProviderExchangeSharp<ExchangeOKExAPI>(new("okex", "OKEx", "https://www.okex.com/api/futures/v3/instruments/ticker"));
|
services.AddRateProviderExchangeSharp<ExchangeOKExAPI>(new("okex", "OKEx", "https://www.okex.com/api/futures/v3/instruments/ticker"));
|
||||||
services.AddRateProviderExchangeSharp<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products"));
|
services.AddRateProviderExchangeSharp<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products"));
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Models;
|
|||||||
using BTCPayServer.Client;
|
using BTCPayServer.Client;
|
||||||
using BTCPayServer.Data;
|
using BTCPayServer.Data;
|
||||||
using BTCPayServer.Plugins.Emails.Views;
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
using BTCPayServer.Services.Mails;
|
using BTCPayServer.Services.Mails;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
@@ -53,9 +54,24 @@ public class UIStoreEmailRulesController(
|
|||||||
|
|
||||||
[HttpGet("create")]
|
[HttpGet("create")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public IActionResult StoreEmailRulesCreate(string storeId)
|
public IActionResult StoreEmailRulesCreate(
|
||||||
|
string storeId,
|
||||||
|
string offeringId = null,
|
||||||
|
string trigger = null,
|
||||||
|
string condition = null,
|
||||||
|
string to = null,
|
||||||
|
string redirectUrl = null)
|
||||||
{
|
{
|
||||||
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers));
|
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers)
|
||||||
|
{
|
||||||
|
CanChangeTrigger = trigger is null,
|
||||||
|
CanChangeCondition = offeringId is null,
|
||||||
|
Condition = condition,
|
||||||
|
Trigger = trigger,
|
||||||
|
OfferingId = offeringId,
|
||||||
|
RedirectUrl = redirectUrl,
|
||||||
|
To = to
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("create")]
|
[HttpPost("create")]
|
||||||
@@ -64,7 +80,9 @@ public class UIStoreEmailRulesController(
|
|||||||
{
|
{
|
||||||
await ValidateCondition(model);
|
await ValidateCondition(model);
|
||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return StoreEmailRulesCreate(storeId);
|
return StoreEmailRulesCreate(storeId,
|
||||||
|
model.OfferingId,
|
||||||
|
model.CanChangeTrigger ? null : model.Trigger);
|
||||||
|
|
||||||
await using var ctx = dbContextFactory.CreateContext();
|
await using var ctx = dbContextFactory.CreateContext();
|
||||||
var c = new EmailRuleData()
|
var c = new EmailRuleData()
|
||||||
@@ -74,6 +92,7 @@ public class UIStoreEmailRulesController(
|
|||||||
Body = model.Body,
|
Body = model.Body,
|
||||||
Subject = model.Subject,
|
Subject = model.Subject,
|
||||||
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
|
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
|
||||||
|
OfferingId = model.OfferingId,
|
||||||
To = model.ToAsArray()
|
To = model.ToAsArray()
|
||||||
};
|
};
|
||||||
c.SetBTCPayAdditionalData(model.AdditionalData);
|
c.SetBTCPayAdditionalData(model.AdditionalData);
|
||||||
@@ -81,18 +100,32 @@ public class UIStoreEmailRulesController(
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
|
||||||
|
return GoToStoreEmailRulesList(storeId, model);
|
||||||
|
}
|
||||||
|
|
||||||
|
private IActionResult GoToStoreEmailRulesList(string storeId, StoreEmailRuleViewModel model)
|
||||||
|
=> GoToStoreEmailRulesList(storeId, model.RedirectUrl);
|
||||||
|
private IActionResult GoToStoreEmailRulesList(string storeId, string redirectUrl)
|
||||||
|
{
|
||||||
|
if (redirectUrl != null)
|
||||||
|
return LocalRedirect(redirectUrl);
|
||||||
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
|
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{ruleId}/edit")]
|
[HttpGet("{ruleId}/edit")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId)
|
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
|
||||||
{
|
{
|
||||||
await using var ctx = dbContextFactory.CreateContext();
|
await using var ctx = dbContextFactory.CreateContext();
|
||||||
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
||||||
if (r is null)
|
if (r is null)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers));
|
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers)
|
||||||
|
{
|
||||||
|
CanChangeTrigger = r.OfferingId is null,
|
||||||
|
CanChangeCondition = r.OfferingId is null,
|
||||||
|
RedirectUrl = redirectUrl
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{ruleId}/edit")]
|
[HttpPost("{ruleId}/edit")]
|
||||||
@@ -116,7 +149,7 @@ public class UIStoreEmailRulesController(
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
|
||||||
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
|
return GoToStoreEmailRulesList(storeId, model);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateCondition(StoreEmailRuleViewModel model)
|
private async Task ValidateCondition(StoreEmailRuleViewModel model)
|
||||||
@@ -142,7 +175,7 @@ public class UIStoreEmailRulesController(
|
|||||||
|
|
||||||
[HttpPost("{ruleId}/delete")]
|
[HttpPost("{ruleId}/delete")]
|
||||||
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId)
|
public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
|
||||||
{
|
{
|
||||||
await using var ctx = dbContextFactory.CreateContext();
|
await using var ctx = dbContextFactory.CreateContext();
|
||||||
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
|
||||||
@@ -152,6 +185,7 @@ public class UIStoreEmailRulesController(
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
|
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
|
||||||
}
|
}
|
||||||
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
|
|
||||||
|
return GoToStoreEmailRulesList(storeId, redirectUrl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,23 @@ namespace Microsoft.AspNetCore.Mvc;
|
|||||||
|
|
||||||
public static class EmailsUrlHelperExtensions
|
public static class EmailsUrlHelperExtensions
|
||||||
{
|
{
|
||||||
|
public class EmailRuleParams
|
||||||
|
{
|
||||||
|
public string? OfferingId { get; set; }
|
||||||
|
public string? Trigger { get; set; }
|
||||||
|
public string? Condition { get; set; }
|
||||||
|
public string? RedirectUrl { get; set; }
|
||||||
|
public string? To { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CreateEmailRuleLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl,
|
||||||
|
EmailRuleParams? param = null)
|
||||||
|
=> linkGenerator.GetUriByAction(
|
||||||
|
action: nameof(UIStoreEmailRulesController.StoreEmailRulesCreate),
|
||||||
|
controller: "UIStoreEmailRules",
|
||||||
|
values: new { area = EmailsPlugin.Area, storeId, offeringId = param?.OfferingId, trigger = param?.Trigger, condition = param?.Condition, redirectUrl = param?.RedirectUrl, to = param?.To },
|
||||||
|
baseUrl);
|
||||||
|
|
||||||
public static string GetStoreEmailRulesLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl)
|
public static string GetStoreEmailRulesLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl)
|
||||||
=> linkGenerator.GetUriByAction(
|
=> linkGenerator.GetUriByAction(
|
||||||
action: nameof(UIStoreEmailRulesController.StoreEmailRulesList),
|
action: nameof(UIStoreEmailRulesController.StoreEmailRulesList),
|
||||||
|
|||||||
@@ -17,7 +17,11 @@ public interface ITriggerOwner
|
|||||||
Task BeforeSending(EmailRuleMatchContext context);
|
Task BeforeSending(EmailRuleMatchContext context);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner);
|
public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner)
|
||||||
|
{
|
||||||
|
public override string ToString()
|
||||||
|
=> $"Trigger event '{Trigger}'";
|
||||||
|
}
|
||||||
|
|
||||||
public class EmailRuleMatchContext(
|
public class EmailRuleMatchContext(
|
||||||
TriggerEvent triggerEvent,
|
TriggerEvent triggerEvent,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ namespace BTCPayServer.Plugins.Emails.Views;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class EmailTriggerViewModel
|
public class EmailTriggerViewModel
|
||||||
{
|
{
|
||||||
public string Type { get; set; }
|
public string Trigger { get; set; }
|
||||||
public string Description { get; set; }
|
public string Description { get; set; }
|
||||||
public string SubjectExample { get; set; }
|
public string SubjectExample { get; set; }
|
||||||
public string BodyExample { get; set; }
|
public string BodyExample { get; set; }
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class StoreEmailRuleViewModel
|
|||||||
if (data is not null)
|
if (data is not null)
|
||||||
{
|
{
|
||||||
Data = data;
|
Data = data;
|
||||||
|
OfferingId = data.OfferingId;
|
||||||
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
|
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
|
||||||
Trigger = data.Trigger;
|
Trigger = data.Trigger;
|
||||||
Subject = data.Subject;
|
Subject = data.Subject;
|
||||||
@@ -46,6 +47,11 @@ public class StoreEmailRuleViewModel
|
|||||||
public string To { get; set; }
|
public string To { get; set; }
|
||||||
|
|
||||||
public List<EmailTriggerViewModel> Triggers { get; set; }
|
public List<EmailTriggerViewModel> Triggers { get; set; }
|
||||||
|
public string RedirectUrl { get; set; }
|
||||||
|
public bool CanChangeTrigger { get; set; } = true;
|
||||||
|
public bool CanChangeCondition { get; set; } = true;
|
||||||
|
public string OfferingId { get; set; }
|
||||||
|
|
||||||
public string[] ToAsArray()
|
public string[] ToAsArray()
|
||||||
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
|
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||||
.Select(t => t.Trim())
|
.Select(t => t.Trim())
|
||||||
|
|||||||
@@ -31,17 +31,38 @@
|
|||||||
</div>
|
</div>
|
||||||
<partial name="_StatusMessage" />
|
<partial name="_StatusMessage" />
|
||||||
|
|
||||||
|
<input type="hidden" asp-for="OfferingId"></input>
|
||||||
|
<input type="hidden" asp-for="RedirectUrl"></input>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Trigger" class="form-label" data-required></label>
|
<label asp-for="Trigger" class="form-label" data-required></label>
|
||||||
<select asp-for="Trigger" asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Type))"
|
<input type="hidden" asp-for="CanChangeTrigger"></input>
|
||||||
|
@if (Model.CanChangeTrigger)
|
||||||
|
{
|
||||||
|
<select asp-for="Trigger"
|
||||||
|
asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Trigger))"
|
||||||
class="form-select email-rule-trigger" required></select>
|
class="form-select email-rule-trigger" required></select>
|
||||||
<span asp-validation-for="Trigger" class="text-danger"></span>
|
<span asp-validation-for="Trigger" class="text-danger"></span>
|
||||||
<div class="form-text" text-translate="true">Choose what event sends the email.</div>
|
<div class="form-text" text-translate="true">Choose what event sends the email.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="hidden" asp-for="Trigger"></input>
|
||||||
|
<input asp-for="Trigger" class="form-control email-rule-trigger-hidden" disabled></input>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label asp-for="Condition" class="form-label"></label>
|
<label asp-for="Condition" class="form-label"></label>
|
||||||
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $?(@.Invoice.Metadata.buyerName == \"john\"))"]" />
|
<input type="hidden" asp-for="CanChangeCondition" ></input>
|
||||||
|
@if (Model.CanChangeCondition)
|
||||||
|
{
|
||||||
|
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Offering.Id == \"john\")"]" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<input type="hidden" asp-for="Condition"></input>
|
||||||
|
<input asp-for="Condition" class="form-control" disabled></input>
|
||||||
|
}
|
||||||
<span asp-validation-for="Condition" class="text-danger"></span>
|
<span asp-validation-for="Condition" class="text-danger"></span>
|
||||||
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
|
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -86,11 +107,12 @@
|
|||||||
var triggers = @Safe.Json(Model.Triggers);
|
var triggers = @Safe.Json(Model.Triggers);
|
||||||
var triggersByType = {};
|
var triggersByType = {};
|
||||||
for (var i = 0; i < triggers.length; i++) {
|
for (var i = 0; i < triggers.length; i++) {
|
||||||
triggersByType[triggers[i].type] = triggers[i];
|
triggersByType[triggers[i].trigger] = triggers[i];
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
|
||||||
const triggerSelect = document.querySelector('.email-rule-trigger');
|
const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
|
||||||
const subjectInput = document.querySelector('.email-rule-subject');
|
const subjectInput = document.querySelector('.email-rule-subject');
|
||||||
const bodyTextarea = document.querySelector('.email-rule-body');
|
const bodyTextarea = document.querySelector('.email-rule-body');
|
||||||
const placeholdersTd = document.querySelector('#placeholders');
|
const placeholdersTd = document.querySelector('#placeholders');
|
||||||
@@ -103,6 +125,7 @@
|
|||||||
|
|
||||||
function applyTemplate() {
|
function applyTemplate() {
|
||||||
const selectedTrigger = triggerSelect.value;
|
const selectedTrigger = triggerSelect.value;
|
||||||
|
console.log(selectedTrigger);
|
||||||
if (triggersByType[selectedTrigger]) {
|
if (triggersByType[selectedTrigger]) {
|
||||||
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
|
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
|
||||||
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
|
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
|
||||||
|
|||||||
29
BTCPayServer/Plugins/Subscriptions/BalanceTransaction.cs
Normal file
29
BTCPayServer/Plugins/Subscriptions/BalanceTransaction.cs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class BalanceTransaction
|
||||||
|
{
|
||||||
|
public long SubscriberId { get; }
|
||||||
|
public decimal Credit { get; }
|
||||||
|
public decimal Debit { get; }
|
||||||
|
public string Description { get; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public decimal Diff { get; }
|
||||||
|
|
||||||
|
public BalanceTransaction(long subscriberId, string currency, decimal credit, decimal debit, string description)
|
||||||
|
{
|
||||||
|
if (credit < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(credit), "Credit must be positive.");
|
||||||
|
|
||||||
|
if (debit < 0)
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(debit), "Debit must be positive.");
|
||||||
|
|
||||||
|
SubscriberId = subscriberId;
|
||||||
|
Credit = credit;
|
||||||
|
Debit = debit;
|
||||||
|
Description = description;
|
||||||
|
Diff = Credit - Debit;
|
||||||
|
Currency = currency;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Filters;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
|
||||||
|
public partial class UIOfferingController
|
||||||
|
{
|
||||||
|
private async Task<IActionResult> CreateFakeOffering(string storeId, CreateOfferingViewModel vm)
|
||||||
|
{
|
||||||
|
ModelState.Clear();
|
||||||
|
var redirect = (RedirectToActionResult)await CreateOffering(storeId, vm);
|
||||||
|
var offeringId = (string)redirect.RouteValues!["offeringId"]!;
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings
|
||||||
|
.Include(o => o.Plans)
|
||||||
|
.Include(o => o.App)
|
||||||
|
.Where(o => o.Id == offeringId)
|
||||||
|
.FirstAsync();
|
||||||
|
offering.App.Name = vm.Name ?? "PayFlow Pro";
|
||||||
|
foreach (var e in new[]
|
||||||
|
{
|
||||||
|
("Up to 10,000 transactions/month", "transaction-limit-10000"),
|
||||||
|
("Up to 50,000 transactions/month", "transaction-limit-50000"),
|
||||||
|
("Unlimited transactions", "transaction-limit-x"),
|
||||||
|
|
||||||
|
("Basic payment processing", "payment-processing-0"),
|
||||||
|
("Advanced payment processing", "payment-processing-1"),
|
||||||
|
("Enterprise payment processing", "payment-processing-x"),
|
||||||
|
|
||||||
|
("Email support", "email-support-0"),
|
||||||
|
("Priority Email support", "email-support-1"),
|
||||||
|
("24/7 dedicated support", "email-support-x"),
|
||||||
|
|
||||||
|
|
||||||
|
("Standard security features", "security-features-0"),
|
||||||
|
("Enhanced security suite", "security-features-1"),
|
||||||
|
("Enterprise security suite", "security-features-x"),
|
||||||
|
|
||||||
|
("Basic analytics dashboard", "analytics-dashboard-0"),
|
||||||
|
("Advanced analytics", "analytics-dashboard-1"),
|
||||||
|
("Custom analytics & reporting", "analytics-dashboard-x"),
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ctx.Entitlements.Add(new()
|
||||||
|
{
|
||||||
|
OfferingId = offering.Id,
|
||||||
|
Description = e.Item1,
|
||||||
|
CustomId = e.Item2
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
var entitlements = await ctx.Entitlements.Where(c => c.OfferingId == offeringId).ToDictionaryAsync(x => x.CustomId);
|
||||||
|
|
||||||
|
var p = ctx.Plans.Add(new()
|
||||||
|
{
|
||||||
|
Name = "Basic Plan",
|
||||||
|
Description = "Perfect for small businesses getting started",
|
||||||
|
Price = 29.0m,
|
||||||
|
Currency = "USD",
|
||||||
|
TrialDays = 0,
|
||||||
|
OfferingId = offering.Id,
|
||||||
|
Status = PlanData.PlanStatus.Active
|
||||||
|
});
|
||||||
|
var basicPlan = p;
|
||||||
|
|
||||||
|
foreach (var e in new[]
|
||||||
|
{
|
||||||
|
"transaction-limit-10000",
|
||||||
|
"payment-processing-0",
|
||||||
|
"email-support-0",
|
||||||
|
"security-features-0",
|
||||||
|
"analytics-dashboard-0"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ctx.PlanEntitlements.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = p.Entity.Id,
|
||||||
|
EntitlementId = entitlements[e].Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
p = ctx.Plans.Add(new()
|
||||||
|
{
|
||||||
|
Name = "Pro Plan",
|
||||||
|
Description = "Great for growing businesses",
|
||||||
|
Price = 99.0m,
|
||||||
|
Currency = "USD",
|
||||||
|
TrialDays = 14,
|
||||||
|
OfferingId = offering.Id,
|
||||||
|
Status = PlanData.PlanStatus.Active
|
||||||
|
});
|
||||||
|
var proPlan = p;
|
||||||
|
|
||||||
|
foreach (var e in new[]
|
||||||
|
{
|
||||||
|
"transaction-limit-50000",
|
||||||
|
"payment-processing-1",
|
||||||
|
"email-support-1",
|
||||||
|
"security-features-1",
|
||||||
|
"analytics-dashboard-1"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ctx.PlanEntitlements.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = p.Entity.Id,
|
||||||
|
EntitlementId = entitlements[e].Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
p = ctx.Plans.Add(new()
|
||||||
|
{
|
||||||
|
Name = "Enterprise Plan",
|
||||||
|
Description = "For large scale operations",
|
||||||
|
Price = 299.0m,
|
||||||
|
Currency = "USD",
|
||||||
|
TrialDays = 15,
|
||||||
|
GracePeriodDays = 15,
|
||||||
|
OfferingId = offering.Id,
|
||||||
|
Status = PlanData.PlanStatus.Active
|
||||||
|
});
|
||||||
|
var enterprisePlan = p;
|
||||||
|
|
||||||
|
foreach (var e in new[]
|
||||||
|
{
|
||||||
|
"transaction-limit-x",
|
||||||
|
"payment-processing-x",
|
||||||
|
"email-support-x",
|
||||||
|
"security-features-x",
|
||||||
|
"analytics-dashboard-x"
|
||||||
|
})
|
||||||
|
{
|
||||||
|
ctx.PlanEntitlements.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = p.Entity.Id,
|
||||||
|
EntitlementId = entitlements[e].Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = basicPlan.Entity.Id,
|
||||||
|
PlanChangeId = proPlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Upgrade
|
||||||
|
});
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = basicPlan.Entity.Id,
|
||||||
|
PlanChangeId = enterprisePlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Upgrade
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = proPlan.Entity.Id,
|
||||||
|
PlanChangeId = basicPlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Downgrade
|
||||||
|
});
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = proPlan.Entity.Id,
|
||||||
|
PlanChangeId = enterprisePlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Upgrade
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = enterprisePlan.Entity.Id,
|
||||||
|
PlanChangeId = basicPlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Downgrade
|
||||||
|
});
|
||||||
|
ctx.PlanChanges.Add(new()
|
||||||
|
{
|
||||||
|
PlanId = enterprisePlan.Entity.Id,
|
||||||
|
PlanChangeId = proPlan.Entity.Id,
|
||||||
|
Type = PlanChangeData.ChangeType.Downgrade
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return redirect;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Constants;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using DisplayFormatter = BTCPayServer.Services.DisplayFormatter;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
|
||||||
|
[Authorize(Policy = Policies.CanViewMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
[Area(SubscriptionsPlugin.Area)]
|
||||||
|
public partial class UIOfferingController(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
IStringLocalizer stringLocalizer,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
EventAggregator eventAggregator,
|
||||||
|
SubscriptionHostedService subsService,
|
||||||
|
AppService appService,
|
||||||
|
BTCPayServerEnvironment env,
|
||||||
|
DisplayFormatter displayFormatter,
|
||||||
|
EmailSenderFactory emailSenderFactory,
|
||||||
|
IHtmlHelper htmlHelper,
|
||||||
|
IEnumerable<EmailTriggerViewModel> emailTriggers
|
||||||
|
) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
|
||||||
|
{
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/new-subscriber")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> NewSubscriber(
|
||||||
|
string storeId, string offeringId,
|
||||||
|
string planId,
|
||||||
|
bool isTrial,
|
||||||
|
int linkExpiration,
|
||||||
|
string? prefilledEmail = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var plan = await ctx.Plans.GetPlanFromId(planId);
|
||||||
|
if (plan is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var checkoutData = new PlanCheckoutData()
|
||||||
|
{
|
||||||
|
PlanId = planId,
|
||||||
|
IsTrial = plan.TrialDays > 0 && isTrial,
|
||||||
|
NewSubscriber = true,
|
||||||
|
TestAccount = env.CheatMode,
|
||||||
|
SuccessRedirectUrl = LinkGenerator.OfferingLink(storeId, offeringId, SubscriptionSection.Subscribers, Request.GetRequestBaseUrl()),
|
||||||
|
BaseUrl = Request.GetRequestBaseUrl(),
|
||||||
|
Expiration = DateTimeOffset.UtcNow.AddDays(linkExpiration),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (prefilledEmail != null && prefilledEmail.IsValidEmail())
|
||||||
|
checkoutData.InvoiceMetadata = new InvoiceMetadata() { BuyerEmail = prefilledEmail }.ToJObject().ToString();
|
||||||
|
ctx.PlanCheckouts.Add(checkoutData);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return RedirectToPlanCheckout(checkoutData.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stores/{storeId}/offerings")]
|
||||||
|
public IActionResult CreateOffering(string storeId)
|
||||||
|
{
|
||||||
|
return View();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FormatCurrencyRequest
|
||||||
|
{
|
||||||
|
[JsonProperty("currency")]
|
||||||
|
public string? Currency { get; set; }
|
||||||
|
|
||||||
|
[JsonProperty("amount")]
|
||||||
|
public decimal Amount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/format-currency")]
|
||||||
|
[IgnoreAntiforgeryToken]
|
||||||
|
public string FormatCurrency(string storeId, string offeringId, [FromBody] FormatCurrencyRequest? req)
|
||||||
|
=> displayFormatter.Currency(req?.Amount ?? 0m, req?.Currency ?? "USD", DisplayFormatter.CurrencyFormat.CodeAndSymbol);
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/Subscribers")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> SubscriberSuspend(string storeId, string offeringId, string customerId, string? command = null,
|
||||||
|
string? suspensionReason = null, decimal? amount = null, string? description = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var sub = await ctx.Subscribers.GetByCustomerId(customerId, offeringId: offeringId, storeId: storeId);
|
||||||
|
if (sub is null)
|
||||||
|
return NotFound();
|
||||||
|
var subName = sub.Customer.GetPrimaryIdentity() ?? sub.CustomerId;
|
||||||
|
if (command is "unsuspend" or "suspend")
|
||||||
|
{
|
||||||
|
var canSwitch = (!sub.IsSuspended && command == "suspend") || (sub.IsSuspended && command == "unsuspend");
|
||||||
|
if (canSwitch)
|
||||||
|
{
|
||||||
|
await SubsService.ToggleSuspend(sub.Id, suspensionReason);
|
||||||
|
await ctx.Entry(sub).ReloadAsync();
|
||||||
|
var message = sub.IsSuspended
|
||||||
|
? StringLocalizer["Subscriber {0} is now suspended", subName]
|
||||||
|
: StringLocalizer["Subscriber {0} is now unsuspended", subName];
|
||||||
|
TempData.SetStatusSuccess(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (command is "toggle-test")
|
||||||
|
{
|
||||||
|
sub.TestAccount = !sub.TestAccount;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
TempData.SetStatusSuccess(StringLocalizer["Subscriber {0} is now {1}", subName, sub.TestAccount ? "test" : "live"]);
|
||||||
|
}
|
||||||
|
else if (command is "credit" or "charge" && amount is > 0)
|
||||||
|
{
|
||||||
|
var message = command is "credit"
|
||||||
|
? StringLocalizer["Subscriber {0} has been credited", subName]
|
||||||
|
: StringLocalizer["Subscriber {0} has been charged", subName];
|
||||||
|
await SubsService.UpdateCredit(sub.Id, description ?? "Manual adjustment", command is "credit" ? amount.Value : -amount.Value);
|
||||||
|
TempData.SetStatusSuccess(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
|
||||||
|
}
|
||||||
|
|
||||||
|
private RedirectToActionResult GoToOffering(string storeId, string offeringId, SubscriptionSection section = SubscriptionSection.Plans)
|
||||||
|
=> RedirectToAction(nameof(Offering), new { storeId, offeringId, section = section });
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings")]
|
||||||
|
public async Task<IActionResult> CreateOffering(string storeId, CreateOfferingViewModel vm, string? command = null)
|
||||||
|
{
|
||||||
|
if (env.CheatMode && command == "create-fake")
|
||||||
|
{
|
||||||
|
return await CreateFakeOffering(storeId, vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return View();
|
||||||
|
|
||||||
|
var app = new AppData()
|
||||||
|
{
|
||||||
|
Name = vm.Name,
|
||||||
|
AppType = SubscriptionsAppType.AppType,
|
||||||
|
StoreDataId = storeId
|
||||||
|
};
|
||||||
|
app.SetSettings(new SubscriptionsAppType.AppConfig());
|
||||||
|
await appService.UpdateOrCreateApp(app, sendEvents: false);
|
||||||
|
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var o = new OfferingData()
|
||||||
|
{
|
||||||
|
AppId = app.Id,
|
||||||
|
};
|
||||||
|
ctx.Offerings.Add(o);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
app.SetSettings(new SubscriptionsAppType.AppConfig()
|
||||||
|
{
|
||||||
|
OfferingId = o.Id
|
||||||
|
});
|
||||||
|
await appService.UpdateOrCreateApp(app, sendEvents: false);
|
||||||
|
eventAggregator.Publish(new AppEvent.Created(app));
|
||||||
|
this.TempData.SetStatusMessageModel(new()
|
||||||
|
{
|
||||||
|
Html = StringLocalizer["New offering created. You can now <a href='{0}' class='alert-link'>configure it.</a>",
|
||||||
|
Url.Action(nameof(ConfigureOffering), new { storeId, offeringId = o.Id })!],
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Success
|
||||||
|
});
|
||||||
|
return GoToOffering(storeId, o.Id, SubscriptionSection.Plans);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/Mails")]
|
||||||
|
public async Task<IActionResult> SaveMailSettings(string storeId, string offeringId, SubscriptionsViewModel vm, string? addEmailRule = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
if (addEmailRule is not null)
|
||||||
|
{
|
||||||
|
var requestBase = Request.GetRequestBaseUrl();
|
||||||
|
var link = LinkGenerator.CreateEmailRuleLink(storeId, requestBase, new()
|
||||||
|
{
|
||||||
|
OfferingId = offeringId,
|
||||||
|
Trigger = addEmailRule,
|
||||||
|
To = "{Subscriber.Email}",
|
||||||
|
Condition = $"$.Offering.Id == \"{offeringId}\"",
|
||||||
|
RedirectUrl = new Uri(LinkGenerator.OfferingLink(storeId, offeringId, SubscriptionSection.Mails, requestBase)).AbsolutePath
|
||||||
|
});
|
||||||
|
return Redirect(link);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await Offering(storeId, offeringId, SubscriptionSection.Mails);
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var update = offering.DefaultPaymentRemindersDays != vm.PaymentRemindersDays;
|
||||||
|
if (update)
|
||||||
|
{
|
||||||
|
offering.DefaultPaymentRemindersDays = vm.PaymentRemindersDays;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Settings saved"]);
|
||||||
|
}
|
||||||
|
return GoToOffering(storeId, offeringId, SubscriptionSection.Mails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stores/{storeId}/offerings/{offeringId}/{section}")]
|
||||||
|
public async Task<IActionResult> Offering(string storeId, string offeringId, SubscriptionSection section = SubscriptionSection.Plans,
|
||||||
|
string? checkoutPlanId = null, string? searchTerm = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
var plans = await ctx.Plans
|
||||||
|
.Where(p => p.OfferingId == offeringId)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (checkoutPlanId is not null)
|
||||||
|
{
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
|
||||||
|
if (checkout is not null && checkout.Subscriber is { Customer: { } cust })
|
||||||
|
TempData.SetStatusSuccess(StringLocalizer["Subscriber '{0}' successfully created", cust.GetPrimaryIdentity() ?? ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = new SubscriptionsViewModel(offering) { Section = section };
|
||||||
|
vm.TotalPlans = plans.Count;
|
||||||
|
vm.TotalSubscribers = plans.Select(p => p.MemberCount).Sum();
|
||||||
|
var total = plans.Where(p => p.Currency == vm.Currency).Select(p => p.MonthlyRevenue).Sum();
|
||||||
|
vm.TotalMonthlyRevenue = displayFormatter.Currency(total, vm.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
|
||||||
|
vm.SelectablePlans = plans
|
||||||
|
.Where(p => p.Status == PlanData.PlanStatus.Active)
|
||||||
|
.OrderBy(p => p.Name)
|
||||||
|
.Select((p, i) => new SubscriptionsViewModel.SelectablePlan(p.Name, p.Id, p.TrialDays > 0))
|
||||||
|
.ToList();
|
||||||
|
if (section == SubscriptionSection.Plans)
|
||||||
|
{
|
||||||
|
plans = plans
|
||||||
|
.OrderBy(p => p.Status switch
|
||||||
|
{
|
||||||
|
PlanData.PlanStatus.Active => 0,
|
||||||
|
_ => 1
|
||||||
|
})
|
||||||
|
.ThenByDescending(o => o.CreatedAt)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
vm.Plans = plans.Select(p =>
|
||||||
|
new SubscriptionsViewModel.PlanViewModel()
|
||||||
|
{
|
||||||
|
Data = p
|
||||||
|
}).ToList();
|
||||||
|
}
|
||||||
|
else if (section == SubscriptionSection.Subscribers)
|
||||||
|
{
|
||||||
|
// searchTerm
|
||||||
|
int maxMembers = 100;
|
||||||
|
var query = ctx.Subscribers
|
||||||
|
.IncludeAll()
|
||||||
|
.Where(m => m.OfferingId == offeringId);
|
||||||
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
|
{
|
||||||
|
query = query.Where(u => (u.Customer.CustomerIdentities.Any(c => c.Value == searchTerm)) ||
|
||||||
|
u.Customer.Name.Contains(searchTerm) ||
|
||||||
|
(u.Customer.ExternalRef != null && u.Customer.ExternalRef.Contains(searchTerm)));
|
||||||
|
}
|
||||||
|
vm.SearchTerm = searchTerm;
|
||||||
|
var members = query
|
||||||
|
.OrderBy(m => m.IsActive ? 0 : 1)
|
||||||
|
.ThenBy(m => m.Plan.Name)
|
||||||
|
.ThenByDescending(m => m.CreatedAt)
|
||||||
|
.Take(maxMembers)
|
||||||
|
.ToList();
|
||||||
|
vm.Subscribers = members
|
||||||
|
.Select(v => new SubscriptionsViewModel.MemberViewModel()
|
||||||
|
{
|
||||||
|
Data = v,
|
||||||
|
}).ToList();
|
||||||
|
vm.TooMuchSubscribers = members.Count == maxMembers;
|
||||||
|
}
|
||||||
|
else if (section == SubscriptionSection.Mails)
|
||||||
|
{
|
||||||
|
var settings = await emailSenderFactory.GetSettings(storeId);
|
||||||
|
vm.EmailConfigured = settings is not null;
|
||||||
|
vm.PaymentRemindersDays = offering.DefaultPaymentRemindersDays;
|
||||||
|
vm.EmailRules = new();
|
||||||
|
|
||||||
|
var triggers = emailTriggers
|
||||||
|
.Where(t => WebhookSubscriptionEvent.IsSubscriptionTrigger(t.Trigger))
|
||||||
|
.ToDictionary(t => t.Trigger);
|
||||||
|
vm.AvailableTriggers = triggers.Values.ToList();
|
||||||
|
foreach (var emailRule in
|
||||||
|
await ctx.EmailRules
|
||||||
|
.Where(r => r.StoreId == storeId && r.OfferingId == offeringId)
|
||||||
|
.ToListAsync())
|
||||||
|
{
|
||||||
|
if (!triggers.TryGetValue(emailRule.Trigger, out var triggerViewModel))
|
||||||
|
continue;
|
||||||
|
vm.EmailRules.Add(new(emailRule)
|
||||||
|
{
|
||||||
|
TriggerViewModel = triggerViewModel
|
||||||
|
});
|
||||||
|
triggers.Remove(triggerViewModel.Trigger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return View(nameof(Offering), vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stores/{storeId}/offerings/{offeringId}/configure")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> ConfigureOffering(string storeId, string offeringId)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
return View(new ConfigureOfferingViewModel(offering));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/configure")]
|
||||||
|
public async Task<IActionResult> ConfigureOffering(
|
||||||
|
string storeId,
|
||||||
|
string offeringId,
|
||||||
|
ConfigureOfferingViewModel vm,
|
||||||
|
string? command = null,
|
||||||
|
int? removeIndex = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
vm.Data = offering;
|
||||||
|
bool itemsUpdated = false;
|
||||||
|
if (command == "AddItem")
|
||||||
|
{
|
||||||
|
vm.Entitlements ??= new();
|
||||||
|
vm.Entitlements.Add(new());
|
||||||
|
itemsUpdated = true;
|
||||||
|
}
|
||||||
|
else if (removeIndex is int i)
|
||||||
|
{
|
||||||
|
vm.Entitlements.RemoveAt(i);
|
||||||
|
itemsUpdated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemsUpdated)
|
||||||
|
{
|
||||||
|
this.ModelState.Clear();
|
||||||
|
vm.Anchor = "entitlements";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ModelState.IsValid || itemsUpdated)
|
||||||
|
return View(vm);
|
||||||
|
|
||||||
|
offering.SuccessRedirectUrl = vm.SuccessRedirectUrl;
|
||||||
|
offering.App.Name = vm.Name;
|
||||||
|
|
||||||
|
UpdateEntitlements(ctx, offering, vm);
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Offering configuration updated"]);
|
||||||
|
return GoToOffering(storeId, offeringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void UpdateEntitlements(ApplicationDbContext ctx, OfferingData offering, ConfigureOfferingViewModel vm)
|
||||||
|
{
|
||||||
|
var incomingById = vm.Entitlements
|
||||||
|
.GroupBy(e => e.Id) // guard against dupes
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var existingById = offering.Entitlements
|
||||||
|
.ToDictionary(e => e.CustomId);
|
||||||
|
|
||||||
|
|
||||||
|
var toRemove = offering.Entitlements
|
||||||
|
.Where(e => !incomingById.ContainsKey(e.CustomId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var e in toRemove)
|
||||||
|
offering.Entitlements.Remove(e);
|
||||||
|
|
||||||
|
ctx.Entitlements.RemoveRange(toRemove);
|
||||||
|
|
||||||
|
foreach (var (id, vmEnt) in incomingById)
|
||||||
|
{
|
||||||
|
if (!existingById.TryGetValue(id, out var entity))
|
||||||
|
{
|
||||||
|
entity = new();
|
||||||
|
entity.CustomId = vmEnt.Id;
|
||||||
|
entity.OfferingId = offering.Id;
|
||||||
|
offering.Entitlements.Add(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
entity.Description = vmEnt.ShortDescription;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/plans/{planId}/delete-plan")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> DeletePlan(string storeId, string offeringId, string planId)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var plan = await ctx.Plans.GetPlanFromId(planId, offeringId, storeId);
|
||||||
|
if (plan is null)
|
||||||
|
return NotFound();
|
||||||
|
var canDelete = !await ctx.Subscribers.Where(s => s.PlanId == planId).AnyAsync();
|
||||||
|
if (!canDelete)
|
||||||
|
{
|
||||||
|
TempData.SetStatusMessageModel(new()
|
||||||
|
{
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||||
|
Html = StringLocalizer["Cannot delete plan. It is currently in use by subscribers."]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ctx.Plans.Remove(plan);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Plan deleted"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return GoToOffering(storeId, offeringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stores/{storeId}/offerings/{offeringId}/add-plan")]
|
||||||
|
[HttpGet("stores/{storeId}/offerings/{offeringId}/plans/{planId}/edit")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> AddPlan(string storeId, string offeringId, string? planId = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var plan = planId is not null ? await ctx.Plans.GetPlanFromId(planId, offeringId, storeId) : null;
|
||||||
|
if (plan is null && planId is not null)
|
||||||
|
return NotFound();
|
||||||
|
var vm = new AddEditPlanViewModel()
|
||||||
|
{
|
||||||
|
OfferingId = offeringId,
|
||||||
|
PlanId = planId,
|
||||||
|
OfferingName = offering.App.Name,
|
||||||
|
Currency = this.HttpContext.GetStoreData().GetStoreBlob().DefaultCurrency,
|
||||||
|
Price = plan?.Price ?? 0m,
|
||||||
|
Name = plan?.Name ?? "",
|
||||||
|
Description = plan?.Description ?? "",
|
||||||
|
RecurringType = plan?.RecurringType ?? PlanData.RecurringInterval.Monthly,
|
||||||
|
GracePeriodDays = plan?.GracePeriodDays ?? 0,
|
||||||
|
TrialDays = plan?.TrialDays ?? 0,
|
||||||
|
OptimisticActivation = plan?.OptimisticActivation ?? false,
|
||||||
|
Renewable = plan?.Renewable ?? true,
|
||||||
|
PlanChanges = offering.Plans
|
||||||
|
.Where(p => p.Id != planId && p.Status == PlanData.PlanStatus.Active)
|
||||||
|
.Select(p => new AddEditPlanViewModel.PlanChange()
|
||||||
|
{
|
||||||
|
PlanId = p.Id,
|
||||||
|
PlanName = p.Name,
|
||||||
|
SelectedType = plan?.PlanChanges
|
||||||
|
.FirstOrDefault(pc => pc.PlanChangeId == p.Id)?
|
||||||
|
.Type.ToString() ?? "None"
|
||||||
|
})
|
||||||
|
.OrderBy(p => p.PlanName)
|
||||||
|
.ToList(),
|
||||||
|
Entitlements = offering.Entitlements.OrderBy(e => e.CustomId).Select(e => new AddEditPlanViewModel.Entitlement()
|
||||||
|
{
|
||||||
|
CustomId = e.CustomId,
|
||||||
|
ShortDescription = e.Description,
|
||||||
|
Selected = plan?.GetEntitlement(e.Id) is not null
|
||||||
|
}).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/add-plan")]
|
||||||
|
[HttpPost("stores/{storeId}/offerings/{offeringId}/plans/{planId}/edit")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> AddPlan(string storeId, string offeringId, AddEditPlanViewModel vm, string? planId = null, string? command = null,
|
||||||
|
int? removeIndex = null)
|
||||||
|
{
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await AddPlan(storeId, offeringId, planId);
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
|
||||||
|
// Check if the offering is part of the store
|
||||||
|
if (offering is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var plan = planId is not null ? await ctx.Plans.GetPlanFromId(planId, offeringId, storeId) : null;
|
||||||
|
if (plan is null && planId is not null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
plan ??= new PlanData();
|
||||||
|
plan.Name = vm.Name;
|
||||||
|
plan.Description = vm.Description;
|
||||||
|
plan.Price = vm.Price;
|
||||||
|
plan.Currency = vm.Currency;
|
||||||
|
plan.GracePeriodDays = vm.GracePeriodDays;
|
||||||
|
plan.TrialDays = vm.TrialDays;
|
||||||
|
plan.OptimisticActivation = vm.OptimisticActivation;
|
||||||
|
plan.Renewable = vm.Renewable;
|
||||||
|
if (planId is null)
|
||||||
|
plan.CreatedAt = DateTimeOffset.UtcNow;
|
||||||
|
plan.RecurringType = vm.RecurringType;
|
||||||
|
plan.OfferingId = vm.OfferingId;
|
||||||
|
plan.PlanEntitlements ??= new();
|
||||||
|
plan.PlanChanges ??= new();
|
||||||
|
|
||||||
|
foreach (var vmPC in vm.PlanChanges)
|
||||||
|
{
|
||||||
|
var existing = plan.PlanChanges.FirstOrDefault(pc => pc.PlanChangeId == vmPC.PlanId);
|
||||||
|
if (vmPC.SelectedType == "None" && existing is not null)
|
||||||
|
{
|
||||||
|
plan.PlanChanges.Remove(existing);
|
||||||
|
ctx.PlanChanges.Remove(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vmPC.SelectedType == "None" && existing is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else if (existing is null)
|
||||||
|
{
|
||||||
|
var pc = new PlanChangeData();
|
||||||
|
pc.PlanId = plan.Id;
|
||||||
|
pc.PlanChangeId = vmPC.PlanId;
|
||||||
|
plan.PlanChanges.Add(pc);
|
||||||
|
ctx.PlanChanges.Add(pc);
|
||||||
|
existing = pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Type = vmPC.SelectedType switch
|
||||||
|
{
|
||||||
|
"Upgrade" => PlanChangeData.ChangeType.Upgrade,
|
||||||
|
"Downgrade" => PlanChangeData.ChangeType.Downgrade,
|
||||||
|
_ => PlanChangeData.ChangeType.Downgrade
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (planId is null)
|
||||||
|
{
|
||||||
|
ctx.Plans.Add(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
eventAggregator.Publish(new SubscriptionEvent.PlanUpdated(plan));
|
||||||
|
|
||||||
|
var customIdsToIds = offering.Entitlements.ToDictionary(x => x.CustomId, x => x.Id);
|
||||||
|
var enabled = vm.Entitlements.Where(e => e.Selected).Select(e => customIdsToIds[e.CustomId]).ToArray();
|
||||||
|
await ctx.Database.GetDbConnection()
|
||||||
|
.ExecuteAsync("""
|
||||||
|
DELETE FROM subs_plans_entitlements
|
||||||
|
WHERE plan_id = @planId AND NOT (entitlement_id = ANY(@enabled));
|
||||||
|
INSERT INTO subs_plans_entitlements(plan_id, entitlement_id)
|
||||||
|
SELECT @planId, e FROM unnest(@enabled) e
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
""", new { planId = plan.Id, enabled });
|
||||||
|
|
||||||
|
if (planId is null)
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["New plan created"]);
|
||||||
|
else
|
||||||
|
this.TempData.SetStatusSuccess(StringLocalizer["Plan edited"]);
|
||||||
|
return GoToOffering(plan.Offering.App.StoreDataId, plan.OfferingId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("stores/{storeId}/offerings/{offeringId}/subscribers/{customerId}/create-portal")]
|
||||||
|
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
|
||||||
|
public async Task<IActionResult> CreatePortalSession(string storeId, string offeringId, string customerId)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var sub = await ctx.Subscribers.GetByCustomerId(customerId, offeringId: offeringId, storeId: storeId);
|
||||||
|
if (sub is null)
|
||||||
|
return NotFound();
|
||||||
|
var portal = new PortalSessionData()
|
||||||
|
{
|
||||||
|
SubscriberId = sub.Id,
|
||||||
|
Expiration = DateTimeOffset.UtcNow + TimeSpan.FromHours(1.0),
|
||||||
|
BaseUrl = Request.GetRequestBaseUrl()
|
||||||
|
};
|
||||||
|
ctx.PortalSessions.Add(portal);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return RedirectToSubscriberPortal(portal.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
#nullable enable
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
[Area(SubscriptionsPlugin.Area)]
|
||||||
|
[Route("plan-checkout/{checkoutId}")]
|
||||||
|
public class UIPlanCheckoutController(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
UriResolver uriResolver,
|
||||||
|
IStringLocalizer stringLocalizer,
|
||||||
|
SubscriptionHostedService subsService)
|
||||||
|
: UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> PlanCheckout(string checkoutId)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
|
||||||
|
var plan = checkout?.Plan;
|
||||||
|
if (plan is null || checkout is null)
|
||||||
|
return NotFound();
|
||||||
|
var prefilledEmail = GetInvoiceMetadata(checkout).BuyerEmail;
|
||||||
|
if (checkout.Subscriber is not null)
|
||||||
|
prefilledEmail = checkout.Subscriber.Customer.Email.Get();
|
||||||
|
var vm = new PlanCheckoutViewModel()
|
||||||
|
{
|
||||||
|
Id = plan.Id,
|
||||||
|
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, plan.Offering.App.StoreData.GetStoreBlob()),
|
||||||
|
StoreName = plan.Offering.App.StoreData.StoreName,
|
||||||
|
Title = plan.Name,
|
||||||
|
Data = plan,
|
||||||
|
Email = prefilledEmail,
|
||||||
|
IsPrefilled = prefilledEmail?.IsValidEmail() is true,
|
||||||
|
IsTrial = checkout.IsTrial
|
||||||
|
};
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static InvoiceMetadata GetInvoiceMetadata(PlanCheckoutData checkout)
|
||||||
|
{
|
||||||
|
return InvoiceMetadata.FromJObject(JObject.Parse(checkout.InvoiceMetadata));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("plan-checkout/default-redirect")]
|
||||||
|
public async Task<IActionResult> PlanCheckoutDefaultRedirect(string? checkoutPlanId = null)
|
||||||
|
{
|
||||||
|
if (checkoutPlanId is null)
|
||||||
|
return NotFound();
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
|
||||||
|
if (checkout is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
return View(new PlanCheckoutDefaultRedirectViewModel(checkout)
|
||||||
|
{
|
||||||
|
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, checkout.Plan.Offering.App.StoreData.GetStoreBlob()),
|
||||||
|
StoreName = checkout.Plan.Offering.App.StoreData.StoreName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> PlanCheckout(string checkoutId, PlanCheckoutViewModel vm,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
|
||||||
|
if (checkout is null)
|
||||||
|
return NotFound();
|
||||||
|
var checkoutInvoice = checkout.InvoiceId is null ? null : await ctx.Invoices.FindAsync([checkout.InvoiceId], cancellationToken);
|
||||||
|
if (checkoutInvoice is not null)
|
||||||
|
{
|
||||||
|
var status = checkoutInvoice.GetInvoiceState().Status;
|
||||||
|
|
||||||
|
if (status is InvoiceStatus.Settled && checkout.GetRedirectUrl() is string url)
|
||||||
|
return Redirect(url);
|
||||||
|
if (status is not (InvoiceStatus.Expired or InvoiceStatus.Invalid))
|
||||||
|
return RedirectToInvoiceCheckout(checkoutInvoice.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscriber = checkout.Subscriber;
|
||||||
|
CustomerSelector customerSelector;
|
||||||
|
if (subscriber is null)
|
||||||
|
{
|
||||||
|
var invoiceMetadata = GetInvoiceMetadata(checkout);
|
||||||
|
if (invoiceMetadata.BuyerEmail is not null)
|
||||||
|
vm.Email = invoiceMetadata.BuyerEmail;
|
||||||
|
customerSelector = CustomerSelector.ByEmail(vm.Email);
|
||||||
|
if (!vm.Email.IsValidEmail())
|
||||||
|
ModelState.AddModelError(nameof(vm.Email), "Invalid email format");
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await PlanCheckout(checkoutId);
|
||||||
|
if (checkout.NewSubscriber)
|
||||||
|
{
|
||||||
|
var sub = await ctx.Subscribers.GetBySelector(checkout.Plan.OfferingId, customerSelector);
|
||||||
|
if (sub is not null)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(nameof(vm.Email), "This email already has a subscription to this offering");
|
||||||
|
return await PlanCheckout(checkoutId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoiceMetadata.BuyerEmail is null)
|
||||||
|
{
|
||||||
|
invoiceMetadata.BuyerEmail = vm.Email;
|
||||||
|
checkout.InvoiceMetadata = invoiceMetadata.ToJObject().ToString();
|
||||||
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
customerSelector = subscriber.CustomerSelector;
|
||||||
|
ModelState.Remove(nameof(vm.Email));
|
||||||
|
}
|
||||||
|
return await RedirectToPlanCheckoutPayment(checkoutId, customerSelector, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Html;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
|
||||||
|
[AllowAnonymous]
|
||||||
|
[AutoValidateAntiforgeryToken]
|
||||||
|
[Area(SubscriptionsPlugin.Area)]
|
||||||
|
[Route("subscriber-portal/{portalSessionId}")]
|
||||||
|
public class UISubscriberPortalController(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
IStringLocalizer stringLocalizer,
|
||||||
|
SubscriptionHostedService subsService,
|
||||||
|
DisplayFormatter displayFormatter,
|
||||||
|
UriResolver uriResolver) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
|
||||||
|
{
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<IActionResult> SubscriberPortal(string portalSessionId, string? checkoutPlanId = null, string? anchor = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
if (checkoutPlanId is not null)
|
||||||
|
{
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
|
||||||
|
|
||||||
|
var message = checkout switch
|
||||||
|
{
|
||||||
|
{ PlanStarted: true, IsTrial: true } => StringLocalizer["The trial has started."],
|
||||||
|
{
|
||||||
|
PlanStarted: false,
|
||||||
|
Invoice: { Status: Data.InvoiceData.Settled },
|
||||||
|
Plan: { OptimisticActivation: false }
|
||||||
|
} => StringLocalizer["Payment received, waiting for confirmation..."],
|
||||||
|
{ PlanStarted: true, RefundAmount: { } refund, Plan: { Currency: { } currency } }
|
||||||
|
=> StringLocalizer["The plan has been started. ({0} has been refunded)", displayFormatter.Currency(refund, currency)],
|
||||||
|
{ PlanStarted: true } => StringLocalizer["The plan has been started."],
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message is not null)
|
||||||
|
TempData.SetStatusSuccess(message);
|
||||||
|
return RedirectToAction(nameof(SubscriberPortal), new { portalSessionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var session = await ctx.PortalSessions.GetActiveById(portalSessionId);
|
||||||
|
var store = session?.GetStoreData();
|
||||||
|
if (session is null || store is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var planChanges = session.Subscriber.Plan.PlanChanges
|
||||||
|
.Select(p => new SubscriberPortalViewModel.PlanChange(p.PlanChange)
|
||||||
|
{
|
||||||
|
ChangeType = p.Type
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
planChanges.Add(new(session.Subscriber.Plan)
|
||||||
|
{
|
||||||
|
Current = true
|
||||||
|
});
|
||||||
|
planChanges = planChanges.OrderBy(x => x switch
|
||||||
|
{
|
||||||
|
{ ChangeType: PlanChangeData.ChangeType.Downgrade } => 0,
|
||||||
|
{ Current: true } => 1,
|
||||||
|
_ => 2
|
||||||
|
}).ThenBy(x => x.Price).ThenBy(x => x.Name).ToList();
|
||||||
|
|
||||||
|
var curr = session.Subscriber.Plan.Currency;
|
||||||
|
var refundValue = session.Subscriber.GetUnusedPeriodAmount() ?? 0m;
|
||||||
|
|
||||||
|
var vm = new SubscriberPortalViewModel(session)
|
||||||
|
{
|
||||||
|
StoreName = store.StoreName,
|
||||||
|
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, store.GetStoreBlob()),
|
||||||
|
PlanChanges = planChanges,
|
||||||
|
Refund = (refundValue, displayFormatter.Currency(refundValue, curr, DisplayFormatter.CurrencyFormat.Symbol))
|
||||||
|
};
|
||||||
|
var creditHist = await ctx.SubscriberCreditHistory
|
||||||
|
.Where(s => s.SubscriberId == session.SubscriberId && s.Currency == session.Subscriber.Plan.Currency)
|
||||||
|
.OrderByDescending(s => s.CreatedAt)
|
||||||
|
.Take(50)
|
||||||
|
.ToArrayAsync(cancellationToken);
|
||||||
|
foreach (var hist in creditHist)
|
||||||
|
{
|
||||||
|
string desc = HtmlEncoder.Default.Encode(hist.Description);
|
||||||
|
desc = AddInvoiceLink(desc);
|
||||||
|
var histVm = new SubscriberPortalViewModel.BalanceTransactionViewModel
|
||||||
|
(
|
||||||
|
hist.CreatedAt,
|
||||||
|
new HtmlString(desc),
|
||||||
|
hist.Credit - hist.Debit,
|
||||||
|
hist.Balance
|
||||||
|
);
|
||||||
|
vm.Transactions.Add(histVm);
|
||||||
|
}
|
||||||
|
|
||||||
|
var credit = session.Subscriber.GetCredit();
|
||||||
|
var displayFormat = DisplayFormatter.CurrencyFormat.Symbol;
|
||||||
|
vm.MigratePopups = new();
|
||||||
|
foreach (var change in session.Subscriber.Plan.PlanChanges)
|
||||||
|
{
|
||||||
|
if (change.PlanChange.Currency != curr)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
var run = change.PlanChange.Price;
|
||||||
|
run -= refundValue;
|
||||||
|
var usedCredit = Math.Min(credit, run);
|
||||||
|
usedCredit = Math.Max(usedCredit, 0);
|
||||||
|
run -= usedCredit;
|
||||||
|
|
||||||
|
var due = run;
|
||||||
|
due = Math.Max(due, 0);
|
||||||
|
|
||||||
|
var creditBalanceAdj = -usedCredit;
|
||||||
|
creditBalanceAdj += Math.Max(0, refundValue - change.PlanChange.Price);
|
||||||
|
|
||||||
|
|
||||||
|
var ajdText = creditBalanceAdj is 0m ? null : displayFormatter.Currency(creditBalanceAdj, curr, displayFormat);
|
||||||
|
ajdText = ajdText is null ? null : (creditBalanceAdj > 0 ? "+" : "") + ajdText;
|
||||||
|
|
||||||
|
var popup = new SubscriberPortalViewModel.MigratePopup()
|
||||||
|
{
|
||||||
|
Cost = displayFormatter.Currency(change.PlanChange.Price, curr, displayFormat),
|
||||||
|
UsedCredit = usedCredit is 0m ? null : "-" + displayFormatter.Currency(usedCredit, curr, displayFormat),
|
||||||
|
AmountDue = displayFormatter.Currency(due, curr, displayFormat),
|
||||||
|
CreditBalanceAdjustment = (ajdText, creditBalanceAdj),
|
||||||
|
};
|
||||||
|
|
||||||
|
vm.MigratePopups.Add(change.PlanChangeId, popup);
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.Anchor = anchor;
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
Regex invoiceIdRegex = new(@"\(Inv: ([^\)]*)\)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
private string AddInvoiceLink(string desc)
|
||||||
|
{
|
||||||
|
var match = invoiceIdRegex.Match(desc);
|
||||||
|
if (!match.Success)
|
||||||
|
return desc;
|
||||||
|
var invoiceId = match.Groups[1].Value;
|
||||||
|
var link = LinkGenerator.ReceiptLink(invoiceId, Request.GetRequestBaseUrl());
|
||||||
|
return invoiceIdRegex.Replace(desc, $"(Inv: <a href=\"{link}\">$1</a>)");
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost]
|
||||||
|
public async Task<IActionResult> SubscriberPortal(string portalSessionId, SubscriberPortalViewModel vm, string command,
|
||||||
|
string? changedPlanId = null,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var session = await ctx.PortalSessions.GetActiveById(portalSessionId);
|
||||||
|
if (session is null)
|
||||||
|
return NotFound();
|
||||||
|
if (command == "add-credit")
|
||||||
|
{
|
||||||
|
var value = vm.Credit?.InputAmount;
|
||||||
|
if (value is null || value.Value <= 0)
|
||||||
|
ModelState.AddModelError("Credit.InputAmount", StringLocalizer["Please enter a positive amount"]);
|
||||||
|
if (!ModelState.IsValid)
|
||||||
|
return await SubscriberPortal(portalSessionId, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var invoiceId = await SubsService.CreateCreditCheckout(session.Id, value);
|
||||||
|
if (invoiceId is not null)
|
||||||
|
{
|
||||||
|
return RedirectToInvoiceCheckout(invoiceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (BitpayHttpException ex)
|
||||||
|
{
|
||||||
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
|
{
|
||||||
|
Html = ex.Message.Replace("\n", "<br />", StringComparison.OrdinalIgnoreCase),
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||||
|
AllowDismiss = true
|
||||||
|
});
|
||||||
|
return RedirectToPlanCheckout(portalSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (command is "migrate" or "pay")
|
||||||
|
{
|
||||||
|
var onPay = command == "migrate" ? PlanCheckoutData.OnPayBehavior.HardMigration : PlanCheckoutData.OnPayBehavior.SoftMigration;
|
||||||
|
if (command == "migrate" && changedPlanId is null)
|
||||||
|
{
|
||||||
|
if (session.Subscriber.Plan.PlanChanges.Count == 1)
|
||||||
|
changedPlanId = session.Subscriber.Plan.PlanChanges[0].PlanChangeId;
|
||||||
|
else
|
||||||
|
return RedirectToSubscriberPortal(portalSessionId, "plans");
|
||||||
|
}
|
||||||
|
|
||||||
|
var checkoutId = await SubsService.CreatePlanMigrationCheckout(session.Id, changedPlanId, onPay, Request.GetRequestBaseUrl());
|
||||||
|
return await RedirectToPlanCheckoutPayment(checkoutId, session.Subscriber.CustomerSelector, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (command == "update-auto-renewal")
|
||||||
|
{
|
||||||
|
session.Subscriber.AutoRenew = !session.Subscriber.AutoRenew;
|
||||||
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToSubscriberPortal(portalSessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("~/move-time")]
|
||||||
|
public async Task<IActionResult> MoveTime(string portalSessionId, string? command = null)
|
||||||
|
{
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
|
||||||
|
if (portal is null || !portal.Subscriber.TestAccount)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
|
||||||
|
var selector = new SubscriptionHostedService.MemberSelector.Single(portal.SubscriberId);
|
||||||
|
if (command == "reminder" && portal.Subscriber.GetReminderDate() is { } reminderDate)
|
||||||
|
{
|
||||||
|
await SubsService.MoveTime(selector, reminderDate - DateTimeOffset.UtcNow);
|
||||||
|
TempData.SetStatusSuccess("Moved to reminder");
|
||||||
|
}
|
||||||
|
|
||||||
|
else if (command == "move7days")
|
||||||
|
{
|
||||||
|
await SubsService.MoveTime(selector, TimeSpan.FromDays(7.0));
|
||||||
|
TempData.SetStatusSuccess("Moved 7 days");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
|
||||||
|
{
|
||||||
|
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Normal);
|
||||||
|
TempData.SetStatusSuccess("Moved to normal phase");
|
||||||
|
}
|
||||||
|
else if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Normal)
|
||||||
|
{
|
||||||
|
if (portal.Subscriber.PeriodEnd is not null)
|
||||||
|
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Grace);
|
||||||
|
TempData.SetStatusSuccess("Moved to grace phase");
|
||||||
|
}
|
||||||
|
else if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Grace)
|
||||||
|
{
|
||||||
|
if (portal.Subscriber.PeriodEnd is not null)
|
||||||
|
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Expired);
|
||||||
|
TempData.SetStatusSuccess("Moved to expired phase");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return RedirectToSubscriberPortal(portalSessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Extensions;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.Localization;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
|
||||||
|
public class UISubscriptionControllerBase(
|
||||||
|
ApplicationDbContextFactory dbContextFactory,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
IStringLocalizer stringLocalizer,
|
||||||
|
SubscriptionHostedService subsService) : Controller
|
||||||
|
{
|
||||||
|
public ApplicationDbContextFactory DbContextFactory { get; } = dbContextFactory;
|
||||||
|
public SubscriptionHostedService SubsService { get; } = subsService;
|
||||||
|
protected IStringLocalizer StringLocalizer => stringLocalizer;
|
||||||
|
protected LinkGenerator LinkGenerator => linkGenerator;
|
||||||
|
public RedirectResult RedirectToInvoiceCheckout(string invoiceId) => Redirect(linkGenerator.InvoiceCheckoutLink(invoiceId, Request.GetRequestBaseUrl()));
|
||||||
|
|
||||||
|
public IActionResult RedirectToSubscriberPortal(string portalId, string anchor = null)
|
||||||
|
=> RedirectToAction(nameof(UISubscriberPortalController.SubscriberPortal), "UISubscriberPortal", new { portalSessionId = portalId, anchor });
|
||||||
|
|
||||||
|
public IActionResult RedirectToPlanCheckout(string checkoutId)
|
||||||
|
=> RedirectToAction(nameof(UIPlanCheckoutController.PlanCheckout), "UIPlanCheckout", new { checkoutId });
|
||||||
|
|
||||||
|
public async Task<IActionResult> RedirectToPlanCheckoutPayment(string checkoutId, CustomerSelector customerSelector, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SubsService.ProceedToSubscribe(checkoutId, customerSelector, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException) { }
|
||||||
|
catch (BitpayHttpException ex)
|
||||||
|
{
|
||||||
|
TempData.SetStatusMessageModel(new StatusMessageModel
|
||||||
|
{
|
||||||
|
Html = ex.Message.Replace("\n", "<br />", StringComparison.OrdinalIgnoreCase),
|
||||||
|
Severity = StatusMessageModel.StatusSeverity.Error,
|
||||||
|
AllowDismiss = true
|
||||||
|
});
|
||||||
|
return RedirectToPlanCheckout(checkoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx = DbContextFactory.CreateContext();
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
|
||||||
|
return checkout switch
|
||||||
|
{
|
||||||
|
{ PlanStarted: true } when checkout.GetRedirectUrl() is string url => Redirect(url),
|
||||||
|
{ InvoiceId: { } invoiceId } => RedirectToInvoiceCheckout(invoiceId),
|
||||||
|
_ => NotFound()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
188
BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs
Normal file
188
BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.Plugins.Webhooks;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEvent.SubscriberEvent>
|
||||||
|
{
|
||||||
|
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<SubscriptionEvent.SubscriberEvent> webhookTriggerContext)
|
||||||
|
{
|
||||||
|
var evt = webhookTriggerContext.Event;
|
||||||
|
var model = await base.GetEmailModel(webhookTriggerContext);
|
||||||
|
model["Plan"] = new JObject()
|
||||||
|
{
|
||||||
|
["Id"] = evt.Subscriber.PlanId,
|
||||||
|
["Name"] = evt.Subscriber.Plan.Name ?? "",
|
||||||
|
};
|
||||||
|
model["Offering"] = new JObject()
|
||||||
|
{
|
||||||
|
["Name"] = evt.Subscriber.Offering.App.Name,
|
||||||
|
["Id"] = evt.Subscriber.Offering.Id,
|
||||||
|
["AppId"] = evt.Subscriber.Offering.AppId,
|
||||||
|
["Metadata"] = evt.Subscriber.Offering.Metadata,
|
||||||
|
};
|
||||||
|
model["Subscriber"] = new JObject()
|
||||||
|
{
|
||||||
|
["Phase"] = evt.Subscriber.Phase.ToString(),
|
||||||
|
// TODO: When the subscriber can customize the email, also check it!
|
||||||
|
["Email"] = evt.Subscriber.Customer.Email.Get()
|
||||||
|
};
|
||||||
|
model["Customer"] = new JObject()
|
||||||
|
{
|
||||||
|
["ExternalRef"] = evt.Subscriber.Customer.ExternalRef ?? "",
|
||||||
|
["Name"] = evt.Subscriber.Customer.Name ?? "",
|
||||||
|
["Metadata"] = evt.Subscriber.Customer.Metadata
|
||||||
|
};
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override StoreWebhookEvent GetWebhookEvent(SubscriptionEvent.SubscriberEvent evt)
|
||||||
|
{
|
||||||
|
if (evt is null) throw new ArgumentNullException(nameof(evt));
|
||||||
|
var sub = evt.Subscriber;
|
||||||
|
var storeId = sub.Customer.StoreId;
|
||||||
|
var model = MapToSubscriberModel(sub);
|
||||||
|
|
||||||
|
switch (evt)
|
||||||
|
{
|
||||||
|
case SubscriptionEvent.NewSubscriber:
|
||||||
|
return new WebhookSubscriptionEvent.NewSubscriberEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.SubscriberCredited credited:
|
||||||
|
return new WebhookSubscriptionEvent.SubscriberCreditedEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model,
|
||||||
|
Total = credited.Total,
|
||||||
|
Amount = credited.Amount,
|
||||||
|
Currency = credited.Currency
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.SubscriberDebited charged:
|
||||||
|
return new WebhookSubscriptionEvent.SubscriberChargedEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model,
|
||||||
|
Total = charged.Total,
|
||||||
|
Amount = charged.Amount,
|
||||||
|
Currency = charged.Currency
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.SubscriberActivated:
|
||||||
|
return new WebhookSubscriptionEvent.SubscriberActivatedEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.SubscriberPhaseChanged phaseChanged:
|
||||||
|
return new WebhookSubscriptionEvent.SubscriberPhaseChangedEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model,
|
||||||
|
PreviousPhase = MapPhase(phaseChanged.PreviousPhase),
|
||||||
|
CurrentPhase = MapPhase(sub.Phase)
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.SubscriberDisabled:
|
||||||
|
return new WebhookSubscriptionEvent.SubscriberDisabledEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.PaymentReminder:
|
||||||
|
return new WebhookSubscriptionEvent.PaymentReminderEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model
|
||||||
|
};
|
||||||
|
|
||||||
|
case SubscriptionEvent.PlanStarted started:
|
||||||
|
return new WebhookSubscriptionEvent.PlanStartedEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model,
|
||||||
|
AutoRenew = started.AutoRenew
|
||||||
|
};
|
||||||
|
case SubscriptionEvent.NeedUpgrade upgrade:
|
||||||
|
return new WebhookSubscriptionEvent.NeedUpgradeEvent(storeId)
|
||||||
|
{
|
||||||
|
Subscriber = model
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(evt), evt.GetType(), "Unsupported subscription event type");
|
||||||
|
}
|
||||||
|
|
||||||
|
static WebhookSubscriptionEvent.SubscriptionPhase MapPhase(SubscriberData.PhaseTypes p) =>
|
||||||
|
p switch
|
||||||
|
{
|
||||||
|
SubscriberData.PhaseTypes.Normal => WebhookSubscriptionEvent.SubscriptionPhase.Normal,
|
||||||
|
SubscriberData.PhaseTypes.Expired => WebhookSubscriptionEvent.SubscriptionPhase.Expired,
|
||||||
|
SubscriberData.PhaseTypes.Grace => WebhookSubscriptionEvent.SubscriptionPhase.Grace,
|
||||||
|
SubscriberData.PhaseTypes.Trial => WebhookSubscriptionEvent.SubscriptionPhase.Trial,
|
||||||
|
_ => WebhookSubscriptionEvent.SubscriptionPhase.Expired
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SubscriberModel MapToSubscriberModel(SubscriberData sub)
|
||||||
|
{
|
||||||
|
if (sub is null) throw new ArgumentNullException(nameof(sub));
|
||||||
|
|
||||||
|
var customer = sub.Customer;
|
||||||
|
var offering = sub.Offering;
|
||||||
|
var plan = sub.Plan;
|
||||||
|
|
||||||
|
return new SubscriberModel
|
||||||
|
{
|
||||||
|
Customer = new CustomerModel
|
||||||
|
{
|
||||||
|
StoreId = customer.StoreId,
|
||||||
|
Id = sub.CustomerId,
|
||||||
|
ExternalId = customer.ExternalRef
|
||||||
|
},
|
||||||
|
Offer = new OfferingModel
|
||||||
|
{
|
||||||
|
Id = sub.OfferingId,
|
||||||
|
AppName = offering.App?.Name,
|
||||||
|
AppId = offering.AppId,
|
||||||
|
SuccessRedirectUrl = offering.SuccessRedirectUrl
|
||||||
|
},
|
||||||
|
Plan = new SubscriptionPlanModel
|
||||||
|
{
|
||||||
|
Id = sub.PlanId,
|
||||||
|
Name = plan.Name,
|
||||||
|
Status = plan.Status switch
|
||||||
|
{
|
||||||
|
PlanData.PlanStatus.Active => SubscriptionPlanModel.PlanStatus.Active,
|
||||||
|
PlanData.PlanStatus.Retired => SubscriptionPlanModel.PlanStatus.Retired,
|
||||||
|
_ => SubscriptionPlanModel.PlanStatus.Retired
|
||||||
|
},
|
||||||
|
Price = plan.Price,
|
||||||
|
Currency = plan.Currency,
|
||||||
|
RecurringType = plan.RecurringType switch
|
||||||
|
{
|
||||||
|
PlanData.RecurringInterval.Monthly => SubscriptionPlanModel.RecurringInterval.Monthly,
|
||||||
|
PlanData.RecurringInterval.Quarterly => SubscriptionPlanModel.RecurringInterval.Quarterly,
|
||||||
|
PlanData.RecurringInterval.Yearly => SubscriptionPlanModel.RecurringInterval.Yearly,
|
||||||
|
_ => SubscriptionPlanModel.RecurringInterval.Lifetime
|
||||||
|
},
|
||||||
|
GracePeriodDays = plan.GracePeriodDays,
|
||||||
|
TrialDays = plan.TrialDays,
|
||||||
|
Description = plan.Description,
|
||||||
|
MemberCount = plan.MemberCount,
|
||||||
|
OptimisticActivation = plan.OptimisticActivation,
|
||||||
|
Entitlements = plan.GetEntitlementIds()
|
||||||
|
},
|
||||||
|
PeriodEnd = sub.PeriodEnd,
|
||||||
|
TrialEnd = sub.TrialEnd,
|
||||||
|
GracePeriodEnd = sub.GracePeriodEnd,
|
||||||
|
IsActive = sub.IsActive,
|
||||||
|
IsSuspended = sub.IsSuspended,
|
||||||
|
SuspensionReason = sub.SuspensionReason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
106
BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
Normal file
106
BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Rates;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionContext(ApplicationDbContext ctx, EventAggregator aggregator, CurrencyNameTable currencyNameTable, CancellationToken cancellationToken) : IAsyncDisposable
|
||||||
|
{
|
||||||
|
List<object> _evts = new List<object>();
|
||||||
|
public CancellationToken CancellationToken { get; } = cancellationToken;
|
||||||
|
public DateTimeOffset Now { get; } = DateTimeOffset.UtcNow;
|
||||||
|
public ApplicationDbContext Context => ctx;
|
||||||
|
public void AddEvent(object evt) => _evts.Add(evt);
|
||||||
|
public IReadOnlyList<object> Events => _evts;
|
||||||
|
|
||||||
|
public decimal RoundAmount(decimal amount, string currency)
|
||||||
|
=> Math.Round(amount, currencyNameTable.GetNumberFormatInfo(currency)?.CurrencyDecimalDigits ?? 2);
|
||||||
|
|
||||||
|
|
||||||
|
public decimal GetAmountToCredit(InvoiceEntity invoice)
|
||||||
|
// If the subscriber settled more than expected, we credit the subscriber with the difference.
|
||||||
|
=> RoundAmount(invoice.Status is InvoiceStatus.Processing ? invoice.PaidAmount.Net : invoice.NetSettled, invoice.Currency);
|
||||||
|
|
||||||
|
public async Task<decimal> CreditSubscriber(SubscriberData sub, string description, decimal credit)
|
||||||
|
=> (await TryCreditDebitSubscriber(sub, description, credit, 0m, true))!.Value;
|
||||||
|
|
||||||
|
public async Task<bool> TryChargeSubscriber(SubscriberData sub, string description, decimal charge, bool force = false)
|
||||||
|
=> (await TryCreditDebitSubscriber(sub, description, 0m, charge, force)) is not null;
|
||||||
|
|
||||||
|
private static async Task<decimal?> UpdateCredit(BalanceTransaction tx, bool force, ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
var diff = tx.Diff;
|
||||||
|
if (diff >= 0)
|
||||||
|
force = true;
|
||||||
|
var amountCondition = force ? "1=1" : "c.amount >= -@diff";
|
||||||
|
|
||||||
|
var amount = await ctx.Database.GetDbConnection()
|
||||||
|
.ExecuteScalarAsync<decimal?>($"""
|
||||||
|
WITH
|
||||||
|
up AS (
|
||||||
|
INSERT INTO subs_subscriber_credits AS c (subscriber_id, currency, amount)
|
||||||
|
VALUES (@id, @currency, @diff)
|
||||||
|
ON CONFLICT (subscriber_id, currency)
|
||||||
|
DO UPDATE
|
||||||
|
SET amount = c.amount + EXCLUDED.amount
|
||||||
|
WHERE {amountCondition}
|
||||||
|
RETURNING c.subscriber_id, c.currency, @diff AS diff, c.amount AS balance
|
||||||
|
),
|
||||||
|
hist AS (
|
||||||
|
INSERT INTO subs_subscriber_credits_history(subscriber_id, currency, description, debit, credit, balance)
|
||||||
|
SELECT
|
||||||
|
up.subscriber_id,
|
||||||
|
up.currency,
|
||||||
|
@desc,
|
||||||
|
CASE WHEN up.diff < 0 THEN ABS(up.diff) ELSE 0 END AS debit,
|
||||||
|
CASE WHEN up.diff > 0 THEN up.diff ELSE 0 END AS credit,
|
||||||
|
up.balance
|
||||||
|
FROM up
|
||||||
|
)
|
||||||
|
SELECT balance from up;
|
||||||
|
""", new { id = tx.SubscriberId, currency = tx.Currency, diff, desc = tx.Description });
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task ReloadCredits(SubscriberData sub, ApplicationDbContext ctx)
|
||||||
|
{
|
||||||
|
foreach (var c in sub.Credits)
|
||||||
|
ctx.Entry(c).State = EntityState.Detached;
|
||||||
|
sub.Credits.Clear();
|
||||||
|
await ctx.Entry(sub).Collection(c => c.Credits).Query().LoadAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<decimal?> TryCreditDebitSubscriber(SubscriberData sub, string description, decimal credit, decimal charge, bool force = false)
|
||||||
|
{
|
||||||
|
charge = RoundAmount(charge, sub.Plan.Currency);
|
||||||
|
credit = RoundAmount(credit, sub.Plan.Currency);
|
||||||
|
var tx = new BalanceTransaction(sub.Id, sub.Plan.Currency, credit, charge, description);
|
||||||
|
var amount = await UpdateCredit(tx, force, ctx);
|
||||||
|
await ReloadCredits(sub, ctx);
|
||||||
|
if (amount is { } newTotal)
|
||||||
|
{
|
||||||
|
if (tx.Credit != 0)
|
||||||
|
AddEvent(new SubscriptionEvent.SubscriberCredited(sub, newTotal + tx.Debit, tx.Credit, sub.Plan.Currency));
|
||||||
|
if (tx.Debit != 0)
|
||||||
|
AddEvent(new SubscriptionEvent.SubscriberDebited(sub, newTotal, tx.Debit, sub.Plan.Currency));
|
||||||
|
}
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (var evt in _evts)
|
||||||
|
aggregator.Publish(evt, evt.GetType());
|
||||||
|
await ctx.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
69
BTCPayServer/Plugins/Subscriptions/SubscriptionEvent.cs
Normal file
69
BTCPayServer/Plugins/Subscriptions/SubscriptionEvent.cs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
#nullable enable
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Events;
|
||||||
|
|
||||||
|
public class SubscriptionEvent
|
||||||
|
{
|
||||||
|
public class SubscriberEvent(SubscriberData subscriber) : SubscriptionEvent
|
||||||
|
{
|
||||||
|
public SubscriberData Subscriber { get; } = subscriber;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NewSubscriber(SubscriberData subscriber) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"New Subscriber {Subscriber.ToNiceString()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberCredited(SubscriberData subscriber, decimal total, decimal amount, string currency) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public decimal Total { get; } = total;
|
||||||
|
public decimal Amount { get; set; } = amount;
|
||||||
|
public string Currency { get; set; } = currency;
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} credited (Amount: {Amount} {Currency}, New Total: {Total} {Currency})";
|
||||||
|
}
|
||||||
|
public class SubscriberDebited(SubscriberData subscriber, decimal total, decimal amount, string currency) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public decimal Total { get; } = total;
|
||||||
|
public decimal Amount { get; set; } = amount;
|
||||||
|
public string Currency { get; set; } = currency;
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} debited (Amount: {Amount} {Currency}, New Total: {Total} {Currency})";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberActivated(SubscriberData subscriber) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} activated";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberPhaseChanged(SubscriberData subscriber, SubscriberData.PhaseTypes previousPhase) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public SubscriberData.PhaseTypes PreviousPhase { get; set; } = previousPhase;
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} changed phase from {PreviousPhase} to {Subscriber.Phase}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriberDisabled(SubscriberData subscriber) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PaymentReminder(SubscriberData subscriber) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} needs reminder";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlanUpdated(PlanData plan) : SubscriptionEvent
|
||||||
|
{
|
||||||
|
public PlanData Plan { get; set; } = plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NeedUpgrade(SubscriberData subscriber) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlanStarted(SubscriberData subscriber, PlanData previous) : SubscriberEvent(subscriber)
|
||||||
|
{
|
||||||
|
public PlanData PreviousPlan { get; set; } = previous;
|
||||||
|
public bool AutoRenew { get; set; }
|
||||||
|
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} started plan";
|
||||||
|
}
|
||||||
|
}
|
||||||
632
BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
Normal file
632
BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
Normal file
@@ -0,0 +1,632 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Controllers;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Events;
|
||||||
|
using BTCPayServer.HostedServices;
|
||||||
|
using BTCPayServer.Logging;
|
||||||
|
using BTCPayServer.Services;
|
||||||
|
using BTCPayServer.Services.Invoices;
|
||||||
|
using BTCPayServer.Services.Rates;
|
||||||
|
using Dapper;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
using static BTCPayServer.Data.Subscriptions.SubscriberData;
|
||||||
|
|
||||||
|
// ReSharper disable MethodSupportsCancellation
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionHostedService(
|
||||||
|
EventAggregator eventAggregator,
|
||||||
|
ApplicationDbContextFactory applicationDbContextFactory,
|
||||||
|
SettingsRepository settingsRepository,
|
||||||
|
UIInvoiceController invoiceController,
|
||||||
|
CurrencyNameTable currencyNameTable,
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
Logs logger) : EventHostedServiceBase(eventAggregator, logger), IPeriodicTask
|
||||||
|
{
|
||||||
|
record Poll;
|
||||||
|
|
||||||
|
public record SubscribeRequest(string CheckoutId, CustomerSelector CustomerSelector);
|
||||||
|
|
||||||
|
public Task Do(CancellationToken cancellationToken)
|
||||||
|
=> base.RunEvent(new Poll(), cancellationToken);
|
||||||
|
|
||||||
|
protected override void SubscribeToEvents()
|
||||||
|
{
|
||||||
|
this.Subscribe<InvoiceEvent>();
|
||||||
|
this.Subscribe<SubscriptionEvent.PlanUpdated>();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await using var subCtx = CreateContext(cancellationToken);
|
||||||
|
if (evt is InvoiceEvent
|
||||||
|
{
|
||||||
|
EventCode: InvoiceEventCode.Completed or
|
||||||
|
InvoiceEventCode.MarkedCompleted or
|
||||||
|
InvoiceEventCode.MarkedInvalid or
|
||||||
|
InvoiceEventCode.PaidInFull,
|
||||||
|
Invoice:
|
||||||
|
{
|
||||||
|
Status: InvoiceStatus.Settled or InvoiceStatus.Invalid or InvoiceStatus.Processing
|
||||||
|
} settledInvoice
|
||||||
|
} &&
|
||||||
|
GetCheckoutPlanIdFromInvoice(settledInvoice) is string checkoutId)
|
||||||
|
{
|
||||||
|
await ProcessSubscriptionPayment(settledInvoice, checkoutId, cancellationToken);
|
||||||
|
}
|
||||||
|
else if (evt is Poll)
|
||||||
|
{
|
||||||
|
var from = (await settingsRepository.GetSettingAsync<MembershipServerSettings>("MembershipHostedService"))?.LastUpdate;
|
||||||
|
var to = subCtx.Now;
|
||||||
|
await UpdateSubscriptionStates(subCtx, new MemberSelector.PassedDate(from, to));
|
||||||
|
if (subCtx.Events.Count != 0)
|
||||||
|
await settingsRepository.UpdateSetting(new MembershipServerSettings(to), "MembershipHostedService");
|
||||||
|
}
|
||||||
|
else if (evt is SubscriptionEvent.PlanUpdated planUpdated)
|
||||||
|
{
|
||||||
|
await UpdatePlanStats(subCtx.Context, planUpdated.Plan.Id);
|
||||||
|
}
|
||||||
|
else if (evt is SubscribeRequest subscribeRequest)
|
||||||
|
{
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(subscribeRequest.CheckoutId);
|
||||||
|
if (checkout?.IsExpired is not false)
|
||||||
|
throw new InvalidOperationException("Checkout not found or expired");
|
||||||
|
var redirectLink =
|
||||||
|
checkout.GetRedirectUrl() ??
|
||||||
|
checkout.Plan.Offering.SuccessRedirectUrl ??
|
||||||
|
linkGenerator.PlanCheckoutDefaultLink(checkout.BaseUrl);
|
||||||
|
if (checkout.SuccessRedirectUrl != redirectLink)
|
||||||
|
{
|
||||||
|
checkout.SuccessRedirectUrl = redirectLink;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkout.IsTrial)
|
||||||
|
{
|
||||||
|
await StartPlanCheckoutWithoutInvoice(subCtx, checkout, subscribeRequest.CustomerSelector);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await CreateInvoiceForCheckout(subCtx, checkout, subscribeRequest.CustomerSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (evt is SuspendRequest suspendRequest)
|
||||||
|
{
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var sub = await ctx.Subscribers.FindAsync([suspendRequest.SubId], cancellationToken);
|
||||||
|
if (sub is null)
|
||||||
|
throw new InvalidOperationException("Subscriber not found");
|
||||||
|
sub.IsSuspended = suspendRequest.Suspended ?? !sub.IsSuspended;
|
||||||
|
sub.SuspensionReason = sub.IsSuspended ? suspendRequest.SuspensionReason : null;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
await UpdateSubscriptionStates(subCtx, suspendRequest.SubId);
|
||||||
|
}
|
||||||
|
else if (evt is MoveTimeRequest move)
|
||||||
|
{
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var members = await move.MemberSelector.Where(ctx.Subscribers.IncludeAll()).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var member in members)
|
||||||
|
{
|
||||||
|
if (member.PeriodEnd is not null)
|
||||||
|
member.PeriodEnd -= move.Period;
|
||||||
|
if (member.TrialEnd is not null)
|
||||||
|
member.TrialEnd -= move.Period;
|
||||||
|
if (member.GracePeriodEnd is not null)
|
||||||
|
member.GracePeriodEnd -= move.Period;
|
||||||
|
member.PlanStarted -= move.Period;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
await UpdateSubscriptionStates(subCtx, move.MemberSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubscriptionContext CreateContext() => CreateContext(CancellationToken);
|
||||||
|
|
||||||
|
SubscriptionContext CreateContext(CancellationToken cancellationToken) =>
|
||||||
|
new(applicationDbContextFactory.CreateContext(), EventAggregator, currencyNameTable, cancellationToken);
|
||||||
|
|
||||||
|
public static string GetCheckoutPlanTag(string checkoutId) => $"SUBS#{checkoutId}";
|
||||||
|
public static string? GetCheckoutPlanIdFromInvoice(InvoiceEntity invoiceEntiy) => invoiceEntiy.GetInternalTags("SUBS#").FirstOrDefault();
|
||||||
|
|
||||||
|
private async Task CreateInvoiceForCheckout(SubscriptionContext subCtx, PlanCheckoutData checkout, CustomerSelector customerSelector, decimal? price = null)
|
||||||
|
{
|
||||||
|
var invoiceMetadata = JObject.Parse(checkout.InvoiceMetadata);
|
||||||
|
if (checkout.NewSubscriber)
|
||||||
|
{
|
||||||
|
invoiceMetadata["planId"] = checkout.PlanId;
|
||||||
|
invoiceMetadata["offeringId"] = checkout.Plan.OfferingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
var plan = checkout.Plan;
|
||||||
|
var existingCredit = checkout.Subscriber?.GetCredit() ?? 0m;
|
||||||
|
var amount = price ?? (plan.Price - existingCredit);
|
||||||
|
if (checkout.OnPay == PlanCheckoutData.OnPayBehavior.HardMigration &&
|
||||||
|
checkout.Subscriber?.GetUnusedPeriodAmount(subCtx.Now) is decimal unusedAmount)
|
||||||
|
amount -= subCtx.RoundAmount(unusedAmount, plan.Currency);
|
||||||
|
amount = Math.Max(amount, 0);
|
||||||
|
|
||||||
|
if (amount > 0)
|
||||||
|
{
|
||||||
|
var request = await invoiceController.CreateInvoiceCoreRaw(new()
|
||||||
|
{
|
||||||
|
Currency = plan.Currency,
|
||||||
|
Amount = amount,
|
||||||
|
Checkout = new()
|
||||||
|
{
|
||||||
|
RedirectAutomatically = true,
|
||||||
|
RedirectURL = checkout.GetRedirectUrl()
|
||||||
|
},
|
||||||
|
Metadata = invoiceMetadata
|
||||||
|
}, plan.Offering.App.StoreData, checkout.BaseUrl.ToString(),
|
||||||
|
[GetCheckoutPlanTag(checkout.Id)], CancellationToken.None);
|
||||||
|
if (checkout.SubscriberId is not null)
|
||||||
|
{
|
||||||
|
subCtx.Context.SubscribersInvoices.Add(new()
|
||||||
|
{
|
||||||
|
SubscriberId = checkout.SubscriberId.Value,
|
||||||
|
InvoiceId = request.Id
|
||||||
|
});
|
||||||
|
}
|
||||||
|
checkout.InvoiceId = request.Id;
|
||||||
|
await subCtx.Context.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await StartPlanCheckoutWithoutInvoice(subCtx, checkout, customerSelector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MembershipServerSettings
|
||||||
|
{
|
||||||
|
public MembershipServerSettings()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public MembershipServerSettings(DateTimeOffset lastUpdate)
|
||||||
|
{
|
||||||
|
LastUpdate = lastUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
|
||||||
|
public DateTimeOffset LastUpdate { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract record MemberSelector
|
||||||
|
{
|
||||||
|
public record Single(long SubscriberId) : MemberSelector
|
||||||
|
{
|
||||||
|
public override IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query)
|
||||||
|
=> query.Where(m => m.Id == SubscriberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PassedDate(DateTimeOffset? From, DateTimeOffset To) : MemberSelector
|
||||||
|
{
|
||||||
|
public override IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query)
|
||||||
|
=> From is null
|
||||||
|
? query.Where(q => (q.PeriodEnd < To || q.GracePeriodEnd < To || q.TrialEnd < To))
|
||||||
|
: query.Where(q =>
|
||||||
|
(q.PeriodEnd >= From && q.PeriodEnd < To) || (q.GracePeriodEnd >= From && q.GracePeriodEnd < To) ||
|
||||||
|
(q.TrialEnd >= From && q.TrialEnd < To));
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateSubscriptionStates(SubscriptionContext subCtx, long subscriberId)
|
||||||
|
=> await UpdateSubscriptionStates(subCtx, new MemberSelector.Single(subscriberId));
|
||||||
|
|
||||||
|
private async Task UpdateSubscriptionStates(SubscriptionContext subCtx, MemberSelector selector)
|
||||||
|
{
|
||||||
|
var (now, ctx, cancellationToken) = (subCtx.Now, subCtx.Context, subCtx.CancellationToken);
|
||||||
|
var query = ctx.Subscribers.IncludeAll();
|
||||||
|
var members = await selector.Where(query).ToListAsync(cancellationToken);
|
||||||
|
await ctx.PlanEntitlements.FetchPlanEntitlementsAsync(members.Select(m => m.Plan));
|
||||||
|
foreach (var m in members)
|
||||||
|
{
|
||||||
|
var newPhase = m.GetExpectedPhase(now);
|
||||||
|
var (prevPhase, prevActive) = (m.Phase, m.IsActive);
|
||||||
|
if (prevPhase != newPhase)
|
||||||
|
{
|
||||||
|
if (newPhase is PhaseTypes.Expired or PhaseTypes.Grace)
|
||||||
|
{
|
||||||
|
if (m is
|
||||||
|
{
|
||||||
|
CanStartNextPlan: true,
|
||||||
|
AutoRenew: true
|
||||||
|
})
|
||||||
|
{
|
||||||
|
if (await subCtx.TryChargeSubscriber(m, $"Auto renewal for plan '{m.NextPlan.Name}'", m.NextPlan.Price))
|
||||||
|
{
|
||||||
|
newPhase = PhaseTypes.Normal;
|
||||||
|
var planBefore = m.Plan;
|
||||||
|
m.StartNextPlan(now);
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.PlanStarted(m, planBefore)
|
||||||
|
{
|
||||||
|
PreviousPlan = planBefore,
|
||||||
|
AutoRenew = planBefore.Id == m.PlanId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (m is { AutoRenew: true, CanStartNextPlan: false })
|
||||||
|
{
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.NeedUpgrade(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newPhase is PhaseTypes.Expired)
|
||||||
|
{
|
||||||
|
m.PaidAmount = null;
|
||||||
|
if (m is { NewPlan: not null, NewPlanId: not null } && m.NewPlanId != m.PlanId)
|
||||||
|
{
|
||||||
|
(m.PlanId, m.Plan) = (m.NewPlanId, m.NewPlan);
|
||||||
|
(m.NewPlanId, m.NewPlan) = (null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevPhase != newPhase)
|
||||||
|
{
|
||||||
|
m.Phase = newPhase;
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.SubscriberPhaseChanged(m, prevPhase));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var needReminder = m.GetReminderDate() <= now &&
|
||||||
|
!m.PaymentReminded &&
|
||||||
|
m.MissingCredit() >= 0m;
|
||||||
|
if (needReminder)
|
||||||
|
{
|
||||||
|
m.PaymentReminded = true;
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.PaymentReminder(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
var newActive = !m.IsSuspended && newPhase != PhaseTypes.Expired;
|
||||||
|
|
||||||
|
if (prevActive != newActive)
|
||||||
|
{
|
||||||
|
m.IsActive = newActive;
|
||||||
|
if (newActive)
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.SubscriberActivated(m));
|
||||||
|
else
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.SubscriberDisabled(m));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var plan in GetActiveMemberChangedPlans(subCtx))
|
||||||
|
{
|
||||||
|
await UpdatePlanStats(ctx, plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HashSet<string> GetActiveMemberChangedPlans(SubscriptionContext subCtx)
|
||||||
|
{
|
||||||
|
HashSet<string> plansToUpdate = new();
|
||||||
|
foreach (var evt in subCtx.Events.OfType<SubscriptionEvent.SubscriberEvent>())
|
||||||
|
{
|
||||||
|
if (evt is SubscriptionEvent.PlanStarted ps)
|
||||||
|
{
|
||||||
|
plansToUpdate.Add(ps.PreviousPlan.Id);
|
||||||
|
plansToUpdate.Add(ps.Subscriber.PlanId);
|
||||||
|
}
|
||||||
|
else if (evt is SubscriptionEvent.SubscriberDisabled
|
||||||
|
or SubscriptionEvent.SubscriberActivated
|
||||||
|
or SubscriptionEvent.NewSubscriber)
|
||||||
|
{
|
||||||
|
plansToUpdate.Add(evt.Subscriber.PlanId);
|
||||||
|
}
|
||||||
|
else if (evt is SubscriptionEvent.SubscriberPhaseChanged pc)
|
||||||
|
{
|
||||||
|
var expired = pc.PreviousPhase is PhaseTypes.Expired;
|
||||||
|
var newExpired = pc.Subscriber.Phase is PhaseTypes.Expired;
|
||||||
|
if (expired != newExpired)
|
||||||
|
plansToUpdate.Add(evt.Subscriber.PlanId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plansToUpdate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task ProceedToSubscribe(string checkoutId, CustomerSelector selector, CancellationToken cancellationToken)
|
||||||
|
=> RunEvent(new SubscribeRequest(checkoutId, selector), cancellationToken);
|
||||||
|
|
||||||
|
private async Task StartPlanCheckoutWithoutInvoice(SubscriptionContext subCtx, PlanCheckoutData checkout, CustomerSelector customerSelector)
|
||||||
|
{
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var sub = checkout.Subscriber;
|
||||||
|
if (sub is null && !checkout.NewSubscriber)
|
||||||
|
throw new InvalidOperationException("Bug: Subscriber is null and not a new subscriber");
|
||||||
|
|
||||||
|
if (sub is null)
|
||||||
|
{
|
||||||
|
sub = await CreateSubscription(subCtx, checkout, false, customerSelector);
|
||||||
|
if (sub is null)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await TryStartPlan(subCtx, checkout, sub);
|
||||||
|
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
await UpdateSubscriptionStates(subCtx, sub.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessSubscriptionPayment(InvoiceEntity invoice, string checkoutId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
bool needUpdate = false;
|
||||||
|
await using var subCtx = CreateContext(cancellationToken);
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
|
||||||
|
var plan = checkout?.Plan;
|
||||||
|
if (checkout is null || plan is null ||
|
||||||
|
(invoice.Status == InvoiceStatus.Processing && !plan.OptimisticActivation) ||
|
||||||
|
checkout.Plan.Offering.App.StoreDataId != invoice.StoreId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// If subscrberId isn't set, then it should be a new subscriber.
|
||||||
|
if (checkout.Subscriber is null && !checkout.NewSubscriber)
|
||||||
|
throw new InvalidOperationException("Bug: Subscriber is null and not a new subscriber");
|
||||||
|
|
||||||
|
var sub = checkout.Subscriber;
|
||||||
|
|
||||||
|
if (invoice.Status is InvoiceStatus.Settled or InvoiceStatus.Processing)
|
||||||
|
{
|
||||||
|
// We only create a new subscriber lazily when a payment has been received
|
||||||
|
if (sub is null)
|
||||||
|
{
|
||||||
|
var optimisticActivation = invoice.Status == InvoiceStatus.Processing && plan.OptimisticActivation;
|
||||||
|
sub = await CreateSubscription(subCtx, checkout, optimisticActivation, CustomerSelector.ByEmail(invoice.Metadata.BuyerEmail));
|
||||||
|
if (sub is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
ctx.SubscribersInvoices.Add(new SubscriberInvoiceData()
|
||||||
|
{
|
||||||
|
SubscriberId = sub.Id,
|
||||||
|
InvoiceId = invoice.Id
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var invoiceCredit = subCtx.GetAmountToCredit(invoice);
|
||||||
|
if (checkout.Credited != invoiceCredit)
|
||||||
|
{
|
||||||
|
var diff = invoiceCredit - checkout.Credited;
|
||||||
|
if (diff > 0)
|
||||||
|
{
|
||||||
|
checkout.Credited += diff;
|
||||||
|
await subCtx.CreditSubscriber(sub, $"Credit purchase (Inv: {invoice.Id})", diff);
|
||||||
|
|
||||||
|
if (!checkout.PlanStarted)
|
||||||
|
{
|
||||||
|
await TryStartPlan(subCtx, checkout, sub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await subCtx.TryChargeSubscriber(sub, $"Adjustement (Inv: {invoice.Id})", -diff, force: true);
|
||||||
|
checkout.Credited -= -diff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.Status == InvoiceStatus.Settled && sub.Plan.OptimisticActivation)
|
||||||
|
// Maybe we don't need this column
|
||||||
|
sub.OptimisticActivation = false;
|
||||||
|
|
||||||
|
needUpdate = true;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
else if (sub is not null && invoice.Status == InvoiceStatus.Invalid &&
|
||||||
|
checkout is { PlanStarted: true, Credited: not 0m })
|
||||||
|
{
|
||||||
|
// We should probably ask the merchant before reversing the credit...
|
||||||
|
// await TryChargeSubscriber(ctx, sub, checkout.Credited, force: true);
|
||||||
|
// checkout.Credited = 0m;
|
||||||
|
sub.IsSuspended = true;
|
||||||
|
sub.SuspensionReason = "The plan has been started by an invoice which later became invalid.";
|
||||||
|
needUpdate = true;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub is not null)
|
||||||
|
{
|
||||||
|
if (needUpdate)
|
||||||
|
await UpdateSubscriptionStates(subCtx, sub.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TryStartPlan(SubscriptionContext subCtx, PlanCheckoutData checkout, SubscriberData sub)
|
||||||
|
{
|
||||||
|
if (checkout.PlanStarted)
|
||||||
|
return;
|
||||||
|
var prevPlan = sub.Plan;
|
||||||
|
var now = subCtx.Now;
|
||||||
|
using var scope = sub.NewPlanScope(checkout.Plan);
|
||||||
|
if (sub.CanStartNextPlanEx(checkout.NewSubscriber))
|
||||||
|
{
|
||||||
|
if (checkout.IsTrial || await subCtx.TryChargeSubscriber(sub, $"Starting plan '{sub.NextPlan.Name}'", sub.NextPlan.Price))
|
||||||
|
{
|
||||||
|
sub.StartNextPlan(now, checkout.IsTrial);
|
||||||
|
scope.Commit();
|
||||||
|
checkout.PlanStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// In hard migrations, we stop the current plan by reimbursing what has
|
||||||
|
// not yet been spent. The we start the new plan.
|
||||||
|
else if (checkout.OnPay == PlanCheckoutData.OnPayBehavior.HardMigration)
|
||||||
|
{
|
||||||
|
var unusedAmount = subCtx.RoundAmount(sub.GetUnusedPeriodAmount(now) ?? 0.0m, sub.Plan.Currency);
|
||||||
|
var planChanges = await subCtx.Context.PlanChanges
|
||||||
|
.Where(p => p.PlanId == sub.Plan.Id).ToListAsync();
|
||||||
|
var planChange = planChanges
|
||||||
|
.Where(p => p.PlanChangeId == sub.NewPlan?.Id)
|
||||||
|
.Select(p => p.Type)
|
||||||
|
.FirstOrDefault();
|
||||||
|
var description = planChange switch
|
||||||
|
{
|
||||||
|
PlanChangeData.ChangeType.Upgrade => $"Upgrade to new plan '{sub.NewPlan?.Name}'",
|
||||||
|
PlanChangeData.ChangeType.Downgrade => $"Downgrade to new plan '{sub.NewPlan?.Name}'",
|
||||||
|
_ => $"Migration to plan '{sub.NewPlan?.Name}'"
|
||||||
|
};
|
||||||
|
if (checkout.IsTrial || await subCtx.TryCreditDebitSubscriber(sub, description, unusedAmount, sub.NextPlan.Price) is not null)
|
||||||
|
{
|
||||||
|
checkout.RefundAmount = unusedAmount;
|
||||||
|
sub.StartNextPlan(now, checkout.IsTrial);
|
||||||
|
scope.Commit();
|
||||||
|
checkout.PlanStarted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (checkout.PlanStarted)
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.PlanStarted(sub, prevPlan)
|
||||||
|
{
|
||||||
|
PreviousPlan = prevPlan
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
record MoveTimeRequest(MemberSelector MemberSelector, TimeSpan Period);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Modify the dates of the subscriber so that he moves to the next phase.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="memberSelector"></param>
|
||||||
|
/// <param name="period"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
public Task MoveTime(MemberSelector memberSelector, TimeSpan period)
|
||||||
|
=> this.RunEvent(new MoveTimeRequest(memberSelector, period));
|
||||||
|
|
||||||
|
public async Task MoveTime(long subscriberId, PhaseTypes phase)
|
||||||
|
{
|
||||||
|
await using var ctx = applicationDbContextFactory.CreateContext();
|
||||||
|
var selector = new MemberSelector.Single(subscriberId);
|
||||||
|
var subscriber = await selector.Where(ctx.Subscribers.IncludeAll()).FirstAsync();
|
||||||
|
TimeSpan time;
|
||||||
|
if (phase == PhaseTypes.Normal)
|
||||||
|
time = subscriber.TrialEnd!.Value - DateTimeOffset.UtcNow;
|
||||||
|
else if (phase == PhaseTypes.Grace)
|
||||||
|
time = subscriber.PeriodEnd!.Value - DateTimeOffset.UtcNow;
|
||||||
|
else if (phase == PhaseTypes.Expired)
|
||||||
|
time = subscriber.GracePeriodEnd!.Value - DateTimeOffset.UtcNow;
|
||||||
|
else
|
||||||
|
throw new InvalidOperationException("Invalid phase");
|
||||||
|
|
||||||
|
await this.MoveTime(selector, time);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SubscriberData?> CreateSubscription(SubscriptionContext subCtx, PlanCheckoutData checkout, bool optimisticActivation,
|
||||||
|
CustomerSelector customerSelector)
|
||||||
|
{
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var plan = checkout.Plan;
|
||||||
|
var cust = await ctx.Customers.GetOrUpdate(checkout.Plan.Offering.App.StoreDataId, customerSelector);
|
||||||
|
(var sub, var created) =
|
||||||
|
await ctx.Subscribers.GetOrCreateByCustomerId(cust.Id, plan.OfferingId, plan.Id, optimisticActivation, checkout.TestAccount,
|
||||||
|
JObject.Parse(checkout.NewSubscriberMetadata));
|
||||||
|
if (!created || sub is null)
|
||||||
|
return null;
|
||||||
|
checkout.Subscriber = sub;
|
||||||
|
checkout.SubscriberId = sub.Id;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
subCtx.AddEvent(new SubscriptionEvent.NewSubscriber(sub));
|
||||||
|
return sub;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task UpdatePlanStats(ApplicationDbContext ctx, string planId)
|
||||||
|
{
|
||||||
|
await ctx.Database.GetDbConnection()
|
||||||
|
.ExecuteAsync("""
|
||||||
|
WITH defaults AS (SELECT 0),
|
||||||
|
stats AS (
|
||||||
|
SELECT COUNT(1) AS subscribers_count,
|
||||||
|
SUM( CASE sp.recurring_type
|
||||||
|
WHEN 'Monthly' THEN sp.price
|
||||||
|
WHEN 'Quarterly' THEN sp.price / 3.0::numeric
|
||||||
|
WHEN 'Yearly' THEN sp.price / 12.0::numeric
|
||||||
|
WHEN 'Lifetime' THEN 0
|
||||||
|
END) AS monthly_revenue
|
||||||
|
FROM subs_subscribers ss
|
||||||
|
JOIN subs_plans sp ON ss.plan_id = sp.id
|
||||||
|
WHERE ss.plan_id = @id AND ss.active
|
||||||
|
GROUP BY ss.plan_id
|
||||||
|
)
|
||||||
|
UPDATE subs_plans AS sp
|
||||||
|
SET members_count = COALESCE(stats.subscribers_count,0),
|
||||||
|
monthly_revenue = COALESCE(stats.monthly_revenue, 0)
|
||||||
|
FROM defaults d -- forces one row
|
||||||
|
LEFT JOIN stats ON TRUE
|
||||||
|
WHERE sp.id = @id;
|
||||||
|
""", new
|
||||||
|
{
|
||||||
|
id = planId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
record SuspendRequest(long SubId, string? SuspensionReason, bool? Suspended);
|
||||||
|
|
||||||
|
public Task ToggleSuspend(long subId, string? suspensionReason)
|
||||||
|
=> RunEvent(new SuspendRequest(subId, suspensionReason, null));
|
||||||
|
|
||||||
|
public Task Suspend(long subId, string? suspensionReason)
|
||||||
|
=> RunEvent(new SuspendRequest(subId, suspensionReason, true));
|
||||||
|
|
||||||
|
public async Task UpdateCredit(long subscriberId, string description, decimal update)
|
||||||
|
{
|
||||||
|
await using var subCtx = CreateContext();
|
||||||
|
var sub = await subCtx.Context.Subscribers.GetById(subscriberId);
|
||||||
|
if (sub is null) return;
|
||||||
|
if (update < 0)
|
||||||
|
await subCtx.TryChargeSubscriber(sub, description, -update, force: true);
|
||||||
|
else if (update > 0)
|
||||||
|
await subCtx.CreditSubscriber(sub, description, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public async Task<string?> CreateCreditCheckout(string portalSessionId, decimal? value)
|
||||||
|
{
|
||||||
|
await using var subCtx = CreateContext(CancellationToken);
|
||||||
|
var ctx = subCtx.Context;
|
||||||
|
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
|
||||||
|
if (portal is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var checkout = new PlanCheckoutData(portal.Subscriber)
|
||||||
|
{
|
||||||
|
SuccessRedirectUrl = linkGenerator.SubscriberPortalLink(portalSessionId, portal.BaseUrl),
|
||||||
|
BaseUrl = portal.BaseUrl
|
||||||
|
};
|
||||||
|
ctx.PlanCheckouts.Add(checkout);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
await this.CreateInvoiceForCheckout(subCtx, checkout, portal.Subscriber.CustomerSelector, value);
|
||||||
|
return checkout.InvoiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<string?> CreatePlanMigrationCheckout(string portalSessionId, string? planId, PlanCheckoutData.OnPayBehavior migrationType,
|
||||||
|
RequestBaseUrl requestBaseUrl)
|
||||||
|
{
|
||||||
|
await using var ctx = applicationDbContextFactory.CreateContext();
|
||||||
|
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
|
||||||
|
var plan = planId is null ? null : await ctx.Plans.GetPlanFromId(planId);
|
||||||
|
if (portal is null)
|
||||||
|
return null;
|
||||||
|
var checkout = new PlanCheckoutData(portal.Subscriber, plan)
|
||||||
|
{
|
||||||
|
SuccessRedirectUrl = linkGenerator.SubscriberPortalLink(portalSessionId, requestBaseUrl),
|
||||||
|
OnPay = migrationType,
|
||||||
|
BaseUrl = requestBaseUrl,
|
||||||
|
};
|
||||||
|
ctx.PlanCheckouts.Add(checkout);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
return checkout.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
170
BTCPayServer/Plugins/Subscriptions/SubscriptionsPlugin.cs
Normal file
170
BTCPayServer/Plugins/Subscriptions/SubscriptionsPlugin.cs
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
#nullable enable
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using BTCPayServer.Abstractions.Models;
|
||||||
|
using BTCPayServer.Client.Models;
|
||||||
|
using BTCPayServer.Configuration;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
using BTCPayServer.Services.Apps;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public class SubscriptionsPlugin : BaseBTCPayServerPlugin
|
||||||
|
{
|
||||||
|
public const string Area = "Subscriptions";
|
||||||
|
public override string Identifier => "BTCPayServer.Plugins.Subscriptions";
|
||||||
|
public override string Name => "Subscriptions";
|
||||||
|
public override string Description => "Manage recurring payment plans and subscriptions with customizable offerings, pricing tiers, and billing cycles.";
|
||||||
|
|
||||||
|
public override void Execute(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddUIExtension("header-nav", "/Plugins/Subscriptions/Views/NavExtension.cshtml");
|
||||||
|
services.AddSingleton<AppBaseType, SubscriptionsAppType>();
|
||||||
|
services.AddScheduledTask<SubscriptionHostedService>(TimeSpan.FromMinutes(5));
|
||||||
|
services.AddSingleton<SubscriptionHostedService>();
|
||||||
|
services.AddSingleton<IHostedService>(s => s.GetRequiredService<SubscriptionHostedService>());
|
||||||
|
|
||||||
|
AddSubscriptionsWebhooks(services);
|
||||||
|
|
||||||
|
base.Execute(services);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddSubscriptionsWebhooks(IServiceCollection services)
|
||||||
|
{
|
||||||
|
services.AddWebhookTriggerProvider<SubscriberWebhookProvider>();
|
||||||
|
|
||||||
|
var placeHolders = new List<EmailTriggerViewModel.PlaceHolder>()
|
||||||
|
{
|
||||||
|
new ("{Plan.Id}", "Plan ID"),
|
||||||
|
new ("{Plan.Name}", "Plan name"),
|
||||||
|
new ("{Offering.Name}", "Offering name"),
|
||||||
|
new ("{Offering.Id}", "Offering ID"),
|
||||||
|
new ("{Offering.AppId}", "Offering app ID"),
|
||||||
|
new ("{Offering.Metadata}*", "Offering metadata"),
|
||||||
|
new ("{Subscriber.Phase}", "Subscriber phase"),
|
||||||
|
new ("{Subscriber.Email}", "Subscriber email"),
|
||||||
|
new ("{Customer.ExternalRef}", "Customer external reference"),
|
||||||
|
new ("{Customer.Name}", "Customer name"),
|
||||||
|
new ("{Customer.Metadata}*", "Customer metadata")
|
||||||
|
|
||||||
|
}.AddStoresPlaceHolders();
|
||||||
|
|
||||||
|
var viewModels = new List<EmailTriggerViewModel>()
|
||||||
|
{
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberCreated,
|
||||||
|
Description = "Subscription - New subscriber",
|
||||||
|
SubjectExample = "Welcome {Customer.Name}!",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberCredited,
|
||||||
|
Description = "Subscription - Subscriber credited",
|
||||||
|
SubjectExample = "Your subscription has been credited",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberCharged,
|
||||||
|
Description = "Subscription - Subscriber charged",
|
||||||
|
SubjectExample = "Your subscription payment has been processed",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberActivated,
|
||||||
|
Description = "Subscription - Subscriber activated",
|
||||||
|
SubjectExample = "Your subscription is now active",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberPhaseChanged,
|
||||||
|
Description = "Subscription - Subscriber phase changed",
|
||||||
|
SubjectExample = "Your subscription phase has changed",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberDisabled,
|
||||||
|
Description = "Subscription - Subscriber disabled",
|
||||||
|
SubjectExample = "Your subscription has been disabled",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.PaymentReminder,
|
||||||
|
Description = "Subscription - Payment reminder",
|
||||||
|
SubjectExample = "Payment reminder for your subscription",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.PlanStarted,
|
||||||
|
Description = "Subscription - Plan started",
|
||||||
|
SubjectExample = "Your subscription plan has started",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
new()
|
||||||
|
{
|
||||||
|
Trigger = WebhookSubscriptionEvent.SubscriberNeedUpgrade,
|
||||||
|
Description = "Subscription - Need upgrade",
|
||||||
|
SubjectExample = "Your subscription needs to be upgraded",
|
||||||
|
BodyExample = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}",
|
||||||
|
PlaceHolders = placeHolders
|
||||||
|
},
|
||||||
|
};
|
||||||
|
services.AddWebhookTriggerViewModels(viewModels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SubscriptionsAppType(
|
||||||
|
LinkGenerator linkGenerator,
|
||||||
|
IOptions<BTCPayServerOptions> btcPayServerOptions) : AppBaseType(AppType)
|
||||||
|
{
|
||||||
|
public const string AppType = "Subscriptions";
|
||||||
|
public class AppConfig
|
||||||
|
{
|
||||||
|
public string OfferingId { get; set; } = null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<object?> GetInfo(AppData appData)
|
||||||
|
=> Task.FromResult<object?>(null);
|
||||||
|
|
||||||
|
public override Task<string> ConfigureLink(AppData app)
|
||||||
|
{
|
||||||
|
var config = app.GetSettings<AppConfig>();
|
||||||
|
return Task.FromResult(linkGenerator.GetPathByAction(nameof(UIOfferingController.Offering),
|
||||||
|
"UIOffering", new { storeId = app.Id, offeringId = config?.OfferingId, section = SubscriptionSection.Plans }, btcPayServerOptions.Value.RootPath)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<string> ViewLink(AppData app)
|
||||||
|
{
|
||||||
|
var config = app.GetSettings<AppConfig>();
|
||||||
|
return Task.FromResult(linkGenerator.GetPathByAction(nameof(UIOfferingController.Offering),
|
||||||
|
"UIOffering", new { storeId = app.Id, offeringId = config?.OfferingId, section = SubscriptionSection.Plans }, btcPayServerOptions.Value.RootPath)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
|
||||||
|
{
|
||||||
|
throw new System.NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#nullable enable
|
||||||
|
using BTCPayServer.Abstractions;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions.Controllers;
|
||||||
|
using BTCPayServer.Views.UIStoreMembership;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Plugins.Subscriptions;
|
||||||
|
|
||||||
|
public static class UrlHelperExtensions
|
||||||
|
{
|
||||||
|
public static string PlanCheckout(this LinkGenerator urlHelper, string checkoutId, RequestBaseUrl requestBaseUrl)
|
||||||
|
=> urlHelper.GetUriByAction(
|
||||||
|
action: nameof(UIPlanCheckoutController.PlanCheckout),
|
||||||
|
values: new { area = SubscriptionsPlugin.Area, checkoutId },
|
||||||
|
controller: "UIPlanCheckout",
|
||||||
|
requestBaseUrl: requestBaseUrl);
|
||||||
|
|
||||||
|
public static string SubscriberPortalLink(this LinkGenerator urlHelper, string portalSessionId, RequestBaseUrl requestBaseUrl, string? checkoutPlanId = null)
|
||||||
|
=> urlHelper.GetUriByAction(
|
||||||
|
action: nameof(UISubscriberPortalController.SubscriberPortal),
|
||||||
|
values: new { area = SubscriptionsPlugin.Area, portalSessionId, checkoutPlanId },
|
||||||
|
controller: "UISubscriberPortal",
|
||||||
|
requestBaseUrl: requestBaseUrl);
|
||||||
|
|
||||||
|
public static string OfferingLink(this LinkGenerator urlHelper, string storeId, string offeringId, SubscriptionSection section, RequestBaseUrl requestBaseUrl)
|
||||||
|
=> urlHelper.GetUriByAction(
|
||||||
|
action: nameof(UIOfferingController.Offering),
|
||||||
|
values: new { area = SubscriptionsPlugin.Area, storeId, offeringId, section },
|
||||||
|
controller: "UIOffering",
|
||||||
|
requestBaseUrl: requestBaseUrl);
|
||||||
|
|
||||||
|
public static string PlanCheckoutDefaultLink(this LinkGenerator urlHelper, RequestBaseUrl requestBaseUrl)
|
||||||
|
=> urlHelper.GetUriByAction(
|
||||||
|
action: nameof(UIPlanCheckoutController.PlanCheckoutDefaultRedirect),
|
||||||
|
values: new { area = SubscriptionsPlugin.Area },
|
||||||
|
controller: "UIPlanCheckout",
|
||||||
|
requestBaseUrl: requestBaseUrl);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership.Components.Contact;
|
||||||
|
|
||||||
|
public class Contact : ViewComponent
|
||||||
|
{
|
||||||
|
public IViewComponentResult Invoke(string type, string value)
|
||||||
|
=> View((type, value));
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
@model (string type, string value)
|
||||||
|
|
||||||
|
@if (Model.type == "Email")
|
||||||
|
{
|
||||||
|
<span class="customer-contact-@Model.type"><i class="fa fa-envelope me-2"></i><span>@Model.value</span></span>
|
||||||
|
}
|
||||||
|
else if (Model.type == "Nostr")
|
||||||
|
{
|
||||||
|
<span class="customer-contact-@Model.type">
|
||||||
|
<span class="me-1 align-baseline" style="color: mediumpurple"><vc:icon symbol="social-nostr"></vc:icon></span>
|
||||||
|
<span class="align-baseline">@Model.value</span>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@Model.type: @Model.value</span>
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@model (bool, SubscriberData)
|
||||||
|
|
||||||
|
@{
|
||||||
|
var subscriber = Model.Item2;
|
||||||
|
var canSuspend = Model.Item1;
|
||||||
|
var (label, badge, hasDropdown) = subscriber switch
|
||||||
|
{
|
||||||
|
{ IsActive: true } => (StringLocalizer["Active"], "success", true),
|
||||||
|
{ IsActive: false, IsSuspended: true } => (StringLocalizer["Suspended"], "danger", true),
|
||||||
|
_ => (StringLocalizer["Inactive"], "danger", subscriber.IsSuspended)
|
||||||
|
};
|
||||||
|
hasDropdown = hasDropdown && canSuspend;
|
||||||
|
}
|
||||||
|
<span class="subscriber-status badge badge-translucent rounded-pill text-bg-@badge">
|
||||||
|
@if (hasDropdown)
|
||||||
|
{
|
||||||
|
<form asp-action="SubscriberSuspend"
|
||||||
|
asp-route-offeringId="@subscriber.OfferingId"
|
||||||
|
asp-route-storeId="@subscriber.Offering.App.StoreDataId"
|
||||||
|
asp-route-customerId="@subscriber.CustomerId" method="post" class="dropdown">
|
||||||
|
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<span>@label</span>
|
||||||
|
</span>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
@if (subscriber.IsActive)
|
||||||
|
{
|
||||||
|
@* <button type="submit" name="command" value="suspend" class="dropdown-item lh-base" text-translate="true"> *@
|
||||||
|
@* Suspend Access *@
|
||||||
|
@* </button> *@
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
text-translate="true"
|
||||||
|
class="suspend-subscriber-link dropdown-item lh-base"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#suspendSubscriberModal"
|
||||||
|
data-subscriber-id="@subscriber.CustomerId"
|
||||||
|
data-subscriber-email="@subscriber.Customer.Email.Get()">
|
||||||
|
Suspend Access
|
||||||
|
</a>
|
||||||
|
|
||||||
|
}
|
||||||
|
else if (subscriber.IsSuspended)
|
||||||
|
{
|
||||||
|
<button type="submit" name="command" value="unsuspend" class="dropdown-item lh-base" text-translate="true">
|
||||||
|
Unsuspend Access
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@label</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership.Components.SubscriberStatus;
|
||||||
|
|
||||||
|
public class SubscriberStatus : ViewComponent
|
||||||
|
{
|
||||||
|
public IViewComponentResult Invoke(SubscriberData subscriber, bool canSuspend = false)
|
||||||
|
=> View((canSuspend, subscriber));
|
||||||
|
}
|
||||||
48
BTCPayServer/Plugins/Subscriptions/Views/NavExtension.cshtml
Normal file
48
BTCPayServer/Plugins/Subscriptions/Views/NavExtension.cshtml
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
@using BTCPayServer.Client
|
||||||
|
@using BTCPayServer.Components.MainNav
|
||||||
|
@using BTCPayServer.Plugins.PointOfSale
|
||||||
|
@using BTCPayServer.Plugins.Subscriptions
|
||||||
|
@using BTCPayServer.Services.Apps
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
|
@using Microsoft.AspNetCore.Mvc.Razor
|
||||||
|
@model BTCPayServer.Components.MainNav.MainNavViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
var store = Context.GetStoreData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (store != null)
|
||||||
|
{
|
||||||
|
var appType = SubscriptionsAppType.AppType;
|
||||||
|
var apps = Model.Apps.Where(app => app.AppType == appType).ToList();
|
||||||
|
<li class="nav-item" permission="@Policies.CanModifyMembership">
|
||||||
|
<a asp-area="Subscriptions" asp- asp-controller="UIOffering" asp-action="CreateOffering" asp-route-storeId="@store.Id" class="nav-link @ViewData.ActivePageClass(AppsNavPages.Create, appType)" id="@($"StoreNav-Create{appType}")">
|
||||||
|
<vc:icon symbol="nav-reporting" />
|
||||||
|
<span text-translate="true">Subscriptions</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
@if (apps.Any())
|
||||||
|
{
|
||||||
|
<li class="nav-item" not-permission="@Policies.CanModifyMembership" permission="@Policies.CanViewStoreSettings">
|
||||||
|
<span class="nav-link">
|
||||||
|
<vc:icon symbol="nav-reporting" />
|
||||||
|
<span text-translate="true">Subscriptions</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@foreach (var app in apps)
|
||||||
|
{
|
||||||
|
var offeringId = app.Data.GetSettings<SubscriptionsAppType.AppConfig>().OfferingId ?? "";
|
||||||
|
|
||||||
|
<li class="nav-item nav-item-sub" permission="@Policies.CanViewMembership">
|
||||||
|
<a asp-area="Subscriptions" asp-controller="UIOffering" asp-action="Offering" asp-route-storeId="@Model.Store.Id" asp-route-offeringId="@offeringId" asp-route-section="Plans" class="nav-link @ViewData.ActivePageClass(AppsNavPages.Update, @offeringId)" id="@($"StoreNav-Offering-{offeringId}")">
|
||||||
|
<span>@app.AppName</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item nav-item-sub" not-permission="@Policies.CanViewMembership">
|
||||||
|
<a asp-area="Subscriptions" asp-controller="UIOffering" asp-action="Offering" asp-route-storeId="@Model.Store.Id" asp-route-offeringId="@offeringId" asp-route-section="Plans" class="nav-link">
|
||||||
|
<span>@app.AppName</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership
|
||||||
|
{
|
||||||
|
public class AddEditPlanViewModel
|
||||||
|
{
|
||||||
|
public string OfferingId { get; set; }
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Plan Name")]
|
||||||
|
[StringLength(100)]
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
public string OfferingName { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Price")]
|
||||||
|
[Range(0, double.MaxValue)]
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Currency")]
|
||||||
|
public string Currency { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Recurring Type")]
|
||||||
|
public PlanData.RecurringInterval RecurringType { get; set; } = PlanData.RecurringInterval.Monthly;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Grace Period (days)")]
|
||||||
|
[Range(0, 3650)]
|
||||||
|
public int GracePeriodDays { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[Display(Name = "Trial Period (days)")]
|
||||||
|
[Range(0, 3650)]
|
||||||
|
public int TrialDays { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Description")]
|
||||||
|
[StringLength(1000)]
|
||||||
|
public string Description { get; set; }
|
||||||
|
|
||||||
|
[Display(Name = "Optimistic activation")]
|
||||||
|
public bool OptimisticActivation { get; set; } = true;
|
||||||
|
[Display(Name = "Renewable")]
|
||||||
|
public bool Renewable { get; set; } = true;
|
||||||
|
|
||||||
|
[Display(Name = "Entitlements")]
|
||||||
|
public List<Entitlement> Entitlements { get; set; } = new();
|
||||||
|
|
||||||
|
public string Anchor { get; set; }
|
||||||
|
public string PlanId { get; set; }
|
||||||
|
|
||||||
|
public List<PlanChange> PlanChanges { get; set; } = new();
|
||||||
|
public class PlanChange
|
||||||
|
{
|
||||||
|
public string PlanId { get; set; }
|
||||||
|
public string PlanName { get; set; }
|
||||||
|
public string SelectedType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class Entitlement
|
||||||
|
{
|
||||||
|
public string CustomId { get; set; } = string.Empty;
|
||||||
|
public string ShortDescription { get; set; } = string.Empty;
|
||||||
|
public bool Selected { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
@model AddEditPlanViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
string storeId = (string)this.Context.GetRouteValue("storeId");
|
||||||
|
string offeringId = (string)this.Context.GetRouteValue("offeringId");
|
||||||
|
string submitLabel = "";
|
||||||
|
if (Model.PlanId is null)
|
||||||
|
{
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Add plan"], offeringId);
|
||||||
|
submitLabel = StringLocalizer["Create"];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Edit plan"], offeringId);
|
||||||
|
submitLabel = StringLocalizer["Save"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="sticky-header">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a asp-action="Offering"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@Model.OfferingId"
|
||||||
|
asp-route-section="Plans"
|
||||||
|
data-testid="offering-link"
|
||||||
|
>@StringLocalizer["Offering ({0})", Model.OfferingName]</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">
|
||||||
|
@if (Model.PlanId is null)
|
||||||
|
{
|
||||||
|
<span>@StringLocalizer["Add plan"]</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span>@StringLocalizer["Edit plan ({0})", Model.Name]</span>
|
||||||
|
}
|
||||||
|
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<h2>@ViewData["Title"]</h2>
|
||||||
|
</nav>
|
||||||
|
<input id="page-primary" type="submit" name="command" value="@submitLabel" class="btn btn-primary" />
|
||||||
|
</div>
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Name" class="form-label" data-required></label>
|
||||||
|
<input asp-for="Name" class="form-control" placeholder="e.g., Premium" />
|
||||||
|
<span asp-validation-for="Name" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="form-group flex-fill me-4">
|
||||||
|
<label asp-for="Price" class="form-label" data-required></label>
|
||||||
|
<input inputmode="decimal" asp-for="Price" class="form-control" />
|
||||||
|
<span asp-validation-for="Price" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Currency" class="form-label" data-required></label>
|
||||||
|
<input asp-for="Currency" class="form-control w-auto" currency-selection />
|
||||||
|
<span asp-validation-for="Currency" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="RecurringType" class="form-label"></label>
|
||||||
|
<select asp-for="RecurringType" class="form-select" asp-items="Html.GetEnumSelectList<PlanData.RecurringInterval>()"></select>
|
||||||
|
<span asp-validation-for="RecurringType" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="form-group flex-fill me-4">
|
||||||
|
<label asp-for="TrialDays" class="form-label"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input asp-for="TrialDays" class="form-control" min="0" placeholder="7" />
|
||||||
|
<span class="input-group-text">days</span>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="TrialDays" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group flex-fill">
|
||||||
|
<label asp-for="GracePeriodDays" class="form-label"></label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input asp-for="GracePeriodDays" class="form-control" min="0" placeholder="15" />
|
||||||
|
<span class="input-group-text">days</span>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="GracePeriodDays" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Description" class="form-label"></label>
|
||||||
|
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Brief description of the plan features..."></textarea>
|
||||||
|
<span asp-validation-for="Description" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" asp-for="OptimisticActivation" />
|
||||||
|
<label class="form-check-label" asp-for="OptimisticActivation"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" asp-for="Renewable" />
|
||||||
|
<label class="form-check-label" asp-for="Renewable"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.PlanChanges?.Any() is true)
|
||||||
|
{
|
||||||
|
<section id="entitlements" class="mt-4">
|
||||||
|
<h4 class="mb-4">Plan changes</h4>
|
||||||
|
<p>Allow the subscriber to downgrade or upgrade to a different plan.</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<th>Plan</th>
|
||||||
|
<th>Change type</th>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (int i = 0; i < Model.PlanChanges.Count; i++)
|
||||||
|
{
|
||||||
|
var planChange = Model.PlanChanges[i];
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">@planChange.PlanName</td>
|
||||||
|
<input type="hidden" asp-for="PlanChanges[i].PlanId"></input>
|
||||||
|
<td><select
|
||||||
|
class="form-select plan-change-select w-auto"
|
||||||
|
asp-for="PlanChanges[i].SelectedType">
|
||||||
|
<option value="Downgrade">Downgrade</option>
|
||||||
|
<option value="Upgrade">Upgrade</option>
|
||||||
|
<option value="None">None</option>
|
||||||
|
</select></td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Entitlements?.Any() is true)
|
||||||
|
{
|
||||||
|
<section id="entitlements" class="mt-4">
|
||||||
|
<h4 class="mb-4" text-translate="true">Entitlements</h4>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:1%"></th>
|
||||||
|
<th text-translate="true">ID</th>
|
||||||
|
<th text-translate="true">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (int i = 0; i < Model.Entitlements.Count(); i++)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input asp-for="Entitlements[i].Selected" class="form-check-input entitlement-checkbox"
|
||||||
|
data-testid="check_@Model.Entitlements[i].CustomId" type="checkbox">
|
||||||
|
<input asp-for="Entitlements[i].CustomId" type="hidden">
|
||||||
|
<input asp-for="Entitlements[i].ShortDescription" type="hidden">
|
||||||
|
</td>
|
||||||
|
<td>@Model.Entitlements[i].CustomId</td>
|
||||||
|
<td>@Model.Entitlements[i].ShortDescription</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
|
||||||
|
<div class="modal fade" id="updateCreditModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title charge-mode" text-translate="true">Charge user</h4>
|
||||||
|
<h4 class="modal-title credit-mode" text-translate="true">Credit user</h4>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div html-translate="true" class="charge-mode">Would you like to charge <span class="subscriber-name fw-semibold"></span>?</div>
|
||||||
|
<div html-translate="true" class="credit-mode">Would you like to credit <span class="subscriber-name fw-semibold"></span>?</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-0 pb-0">
|
||||||
|
<input name="customerId" type="hidden" />
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between credit-plan-price">
|
||||||
|
<div class="text-muted">Current credit</div>
|
||||||
|
<div class="current-credit"></div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center charge-applied">
|
||||||
|
<div class="text-muted charge-mode">Charge applied</div>
|
||||||
|
<div class="text-muted credit-mode">Credit applied</div>
|
||||||
|
<div class="d-flex align-items-center justify-content-end">
|
||||||
|
<span class="charge-mode">-</span>
|
||||||
|
<span class="credit-mode">+</span>
|
||||||
|
<input type="text"
|
||||||
|
class="form-control form-control-sm amount ms-2"
|
||||||
|
name="amount"
|
||||||
|
style="text-align: right;"
|
||||||
|
autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div class="d-flex justify-content-between fw-semibold credit-next-charge">
|
||||||
|
<div class="charge-mode">After charge</div>
|
||||||
|
<div class="credit-mode">After Credit</div>
|
||||||
|
<div class="after-change"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" class="btn btn-danger charge-mode" text-translate="true" name="command" value="charge">Charge</button>
|
||||||
|
<button type="submit" class="btn btn-success credit-mode" text-translate="true" name="command" value="credit">Credit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
(function () {
|
||||||
|
const modalChargeEl = document.getElementById('updateCreditModal');
|
||||||
|
const idEl = modalChargeEl.querySelector('input[name="customerId"]');
|
||||||
|
const currentCredit = modalChargeEl.querySelector('.current-credit');
|
||||||
|
const afterChangeEl = modalChargeEl.querySelector('.after-change');
|
||||||
|
const amount = modalChargeEl.querySelector('.amount');
|
||||||
|
var creditMode = false;
|
||||||
|
|
||||||
|
var seq = 0;
|
||||||
|
var currency;
|
||||||
|
var currentCreditValue = 0;
|
||||||
|
amount.addEventListener('input', updateChargeAfter);
|
||||||
|
|
||||||
|
async function getFormattedValue(value, currency) {
|
||||||
|
const response = await fetch(`format-currency`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
amount: value,
|
||||||
|
currency: currency
|
||||||
|
})
|
||||||
|
});
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
let debounceTimeout;
|
||||||
|
|
||||||
|
async function updateChargeAfter() {
|
||||||
|
const amountValue = Number(amount.value) || 0;
|
||||||
|
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.disabled = amountValue <= 0);
|
||||||
|
|
||||||
|
const remaining = creditMode ? currentCreditValue + amountValue : currentCreditValue - amountValue;
|
||||||
|
|
||||||
|
// Debounce the updates to avoid flooding the service
|
||||||
|
const DEBOUNCE_DELAY = 300; // 300ms delay
|
||||||
|
|
||||||
|
// Create a debounced update function
|
||||||
|
const debouncedUpdate = async () => {
|
||||||
|
seq++;
|
||||||
|
var thisSeq = seq;
|
||||||
|
afterChangeEl.innerText = '---';
|
||||||
|
if (thisSeq !== seq) return;
|
||||||
|
afterChangeEl.innerText = await getFormattedValue(remaining, currency);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (debounceTimeout) clearTimeout(debounceTimeout);
|
||||||
|
afterChangeEl.innerText = '...';
|
||||||
|
debounceTimeout = setTimeout(debouncedUpdate, DEBOUNCE_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
modalChargeEl.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const trigger = event.relatedTarget;
|
||||||
|
if (!trigger) return;
|
||||||
|
|
||||||
|
const tr = trigger.closest('tr');
|
||||||
|
currency = tr.getAttribute('data-currency');
|
||||||
|
currentCreditValue = Number(tr.getAttribute('data-current-credit-value'));
|
||||||
|
if (idEl) idEl.value = tr.getAttribute('data-subscriber-id');
|
||||||
|
|
||||||
|
modalChargeEl.querySelectorAll('.subscriber-name').forEach(el => el.innerText = tr.getAttribute('data-subscriber-email'));
|
||||||
|
|
||||||
|
creditMode = trigger.getAttribute('data-action') === 'credit';
|
||||||
|
const chargeModeEls = modalChargeEl.querySelectorAll('.charge-mode');
|
||||||
|
const creditModeEls = modalChargeEl.querySelectorAll('.credit-mode');
|
||||||
|
if (creditMode) {
|
||||||
|
chargeModeEls.forEach(el => el.style.display = 'none');
|
||||||
|
creditModeEls.forEach(el => el.style.display = '');
|
||||||
|
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.value = 'credit');
|
||||||
|
} else {
|
||||||
|
chargeModeEls.forEach(el => el.style.display = '');
|
||||||
|
creditModeEls.forEach(el => el.style.display = 'none');
|
||||||
|
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.value = 'charge');
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCredit.innerText = '---';
|
||||||
|
getFormattedValue(currentCreditValue, currency).then(value => currentCredit.innerText = value);
|
||||||
|
amount.value = '';
|
||||||
|
afterChangeEl.innerText = '';
|
||||||
|
updateChargeAfter();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
|
||||||
|
@using BTCPayServer.Abstractions.Models
|
||||||
|
@using BTCPayServer.Client
|
||||||
|
@using BTCPayServer.Controllers
|
||||||
|
@model ConfigureOfferingViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
string offeringId = (string)this.Context.GetRouteValue("offeringId");
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Configure offering"], offeringId);
|
||||||
|
string storeId = (string)this.Context.GetRouteValue("storeId");
|
||||||
|
var deleteModal = new ConfirmModel(StringLocalizer["Delete offering"], StringLocalizer["This offering will be removed from this store."], StringLocalizer["Delete"])
|
||||||
|
{
|
||||||
|
ControllerName = "UIApps",
|
||||||
|
ActionName = nameof(UIAppsController.DeleteApp),
|
||||||
|
ActionValues = new { appId = Model.Data.AppId },
|
||||||
|
GenerateForm = true,
|
||||||
|
Antiforgery = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<input type="hidden" asp-for="OriginalName" />
|
||||||
|
<div class="sticky-header">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item">
|
||||||
|
<a asp-action="Offering"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-section="Plans">@StringLocalizer["Offering ({0})", Model.Name]</a>
|
||||||
|
</li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
|
||||||
|
</ol>
|
||||||
|
<h2>@ViewData["Title"]</h2>
|
||||||
|
</nav>
|
||||||
|
<input id="page-primary" type="submit" value="Save" class="btn btn-primary" />
|
||||||
|
</div>
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
@if (!ViewContext.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div asp-validation-summary="ModelOnly"></div>
|
||||||
|
}
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Name" class="form-label" data-required></label>
|
||||||
|
<input asp-for="Name" class="form-control" required />
|
||||||
|
<span asp-validation-for="Name" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="SuccessRedirectUrl" class="form-label"></label>
|
||||||
|
<input asp-for="SuccessRedirectUrl" class="form-control" placeholder="https://example.com/success" />
|
||||||
|
<span asp-validation-for="SuccessRedirectUrl" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<section id="entitlements" class="mt-4">
|
||||||
|
<div class="d-flex align-items-center gap-4">
|
||||||
|
<h4 text-translate="true">Entitlements</h4>
|
||||||
|
<button type="submit" name="command" value="AddItem" class="btn btn-outline-secondary" id="AddPlanItem">Add item</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Entitlements?.Any() is true)
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 30%" text-translate="true">ID</th>
|
||||||
|
<th text-translate="true">Description</th>
|
||||||
|
<th style="width: 1%" text-translate="true"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (int i = 0; i < Model.Entitlements.Count; i++)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<input asp-for="Entitlements[i].Id" class="form-control">
|
||||||
|
<span asp-validation-for="Entitlements[i].Id" class="text-danger"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input asp-for="Entitlements[i].ShortDescription" class="form-control w-100">
|
||||||
|
<span asp-validation-for="Entitlements[i].ShortDescription" class="text-danger"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" name="removeIndex" value="@i" class="d-inline-block btn text-danger btn-link">
|
||||||
|
<vc:icon symbol="cross" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-grid d-sm-flex flex-wrap gap-3 mt-3">
|
||||||
|
<a id="DeleteApp" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
|
||||||
|
data-description="The app <strong>@Html.Encode(Model.Name)</strong> and its settings will be permanently deleted."
|
||||||
|
data-confirm-input="@StringLocalizer["Delete"]"
|
||||||
|
permission="@Policies.CanModifyStoreSettings">Delete this offering</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</form>
|
||||||
|
<partial name="_Confirm" model="@deleteModal" permission="@Policies.CanModifyStoreSettings" />
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
@if (Model.Anchor != null)
|
||||||
|
{
|
||||||
|
<script>
|
||||||
|
document.getElementById(@Safe.Json(Model.Anchor)).scrollIntoView();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Linq;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Validation;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class ConfigureOfferingViewModel
|
||||||
|
{
|
||||||
|
public ConfigureOfferingViewModel()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public ConfigureOfferingViewModel(OfferingData offeringData)
|
||||||
|
{
|
||||||
|
Name = offeringData.App.Name;
|
||||||
|
OriginalName = Name;
|
||||||
|
SuccessRedirectUrl = offeringData.SuccessRedirectUrl;
|
||||||
|
foreach (var entitlement in offeringData.Entitlements.OrderBy(b => b.CustomId))
|
||||||
|
{
|
||||||
|
Entitlements.Add(new EntitlementViewModel()
|
||||||
|
{
|
||||||
|
Id = entitlement.CustomId,
|
||||||
|
ShortDescription = entitlement.Description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Data = offeringData;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OfferingData Data { get; set; }
|
||||||
|
public class EntitlementViewModel
|
||||||
|
{
|
||||||
|
[StringLength(50)]
|
||||||
|
[Required]
|
||||||
|
public string Id { get; set; }
|
||||||
|
[StringLength(500)]
|
||||||
|
public string ShortDescription { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public string OriginalName { get; set; }
|
||||||
|
[Required]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
|
||||||
|
[Uri]
|
||||||
|
[StringLength(500)]
|
||||||
|
[Display(Name = "Success redirect url")]
|
||||||
|
public string SuccessRedirectUrl { get; set; }
|
||||||
|
|
||||||
|
public List<EntitlementViewModel> Entitlements { get; set; } = new();
|
||||||
|
public string Anchor { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
@using BTCPayServer.Services
|
||||||
|
@model CreateOfferingViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData.SetActivePage(StoreNavPages.Subscriptions, StringLocalizer["New offering"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
<div class="sticky-header">
|
||||||
|
<h2>@ViewData["Title"]</h2>
|
||||||
|
<div>
|
||||||
|
<button cheat-mode="true" type="submit" name="command" value="create-fake" class="btn btn-outline-info" id="fake-offering-button">
|
||||||
|
Create fake offering
|
||||||
|
</button>
|
||||||
|
<a><input id="page-primary" type="submit" value="Create" class="btn btn-primary" /></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xl-8 col-xxl-constrain">
|
||||||
|
@if (!ViewContext.ModelState.IsValid)
|
||||||
|
{
|
||||||
|
<div asp-validation-summary="ModelOnly"></div>
|
||||||
|
}
|
||||||
|
<div class="form-group">
|
||||||
|
<label asp-for="Name" class="form-label" data-required></label>
|
||||||
|
<input asp-for="Name" class="form-control" required />
|
||||||
|
<span asp-validation-for="Name" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class CreateOfferingViewModel
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
[StringLength(50)]
|
||||||
|
public string Name { get; set; } = null!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
@model List<SubscriptionsViewModel.SelectablePlan>
|
||||||
|
<div class="modal fade" id="newSubscriberModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" text-translate="true">Create a new subscriber</h4>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
asp-antiforgery="true"
|
||||||
|
action="new-subscriber">
|
||||||
|
|
||||||
|
<div class="modal-body">
|
||||||
|
<div text-translate="true">Would you like to invite a new subscriber?</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body pt-0">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div class="form-group flex-fill me-4">
|
||||||
|
<label for="newSubscriber-prefilledEmail" class="form-label" text-translate="true">Email</label>
|
||||||
|
<input id="newSubscriber-prefilledEmail"
|
||||||
|
name="prefilledEmail"
|
||||||
|
type="email"
|
||||||
|
autocomplete="email"
|
||||||
|
inputmode="email"
|
||||||
|
placeholder="Leave empty to let the customer configure it"
|
||||||
|
class="form-control me-2" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newSubscriber-linkExpiration" class="form-label" text-translate="true">Expires in</label>
|
||||||
|
<select id="newSubscriber-linkExpiration" name="linkExpiration" class="form-select w-auto">
|
||||||
|
<option value="1" text-translate="true">1 day</option>
|
||||||
|
<option value="7" text-translate="true">7 days</option>
|
||||||
|
<option value="30" text-translate="true">30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="newSubscriber-plan" class="form-label" text-translate="true">Plan</label>
|
||||||
|
|
||||||
|
<!-- Vue-driven select -->
|
||||||
|
<select id="newSubscriber-plan"
|
||||||
|
name="planId"
|
||||||
|
class="form-select"
|
||||||
|
v-model="selectedPlanId"
|
||||||
|
:disabled="!canEditPlan">
|
||||||
|
<option :value="p.id" v-for="p in plans" :key="p.id">
|
||||||
|
{{ p.name }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<input type="hidden" :value="selectedPlanId" name="planId" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trial checkbox only appears when plan supports trials -->
|
||||||
|
<div class="form-group d-flex align-items-center" v-if="showTrial">
|
||||||
|
<input id="newSubscriber-isTrial"
|
||||||
|
name="isTrial"
|
||||||
|
value="true"
|
||||||
|
type="checkbox"
|
||||||
|
class="btcpay-toggle me-3"
|
||||||
|
v-model="isTrial">
|
||||||
|
<label class="form-check-label" for="newSubscriber-isTrial" text-translate="true">Enable trial</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" name="command" value="new-subscriber" class="btn btn-success" text-translate="true">Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
var selectablePlans = @Safe.Json(Model);
|
||||||
|
if (!selectablePlans.length) { return; }
|
||||||
|
var newSubscriberApp = new Vue({
|
||||||
|
el: '#newSubscriberModal',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
plans: selectablePlans,
|
||||||
|
selectedPlanId: selectablePlans[0].id,
|
||||||
|
isTrial: false,
|
||||||
|
canEditPlan: false
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selectedPlan() {
|
||||||
|
return this.plans.find(p => p.id === this.selectedPlanId) || null;
|
||||||
|
},
|
||||||
|
showTrial() {
|
||||||
|
return !!(this.selectedPlan && this.selectedPlan.hasTrial);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const modalEl = document.getElementById('newSubscriberModal');
|
||||||
|
modalEl.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const trigger = event.relatedTarget;
|
||||||
|
if (!trigger) return;
|
||||||
|
const tr = trigger.closest('tr');
|
||||||
|
|
||||||
|
Object.assign(newSubscriberApp.$data, newSubscriberApp.$options.data.call(this));
|
||||||
|
|
||||||
|
|
||||||
|
if (tr) {
|
||||||
|
newSubscriberApp.selectedPlanId = tr.getAttribute('data-plan-id');
|
||||||
|
newSubscriberApp.canEditPlan = false;
|
||||||
|
} else {
|
||||||
|
newSubscriberApp.canEditPlan = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
@using BTCPayServer.Abstractions.Models
|
||||||
|
@using BTCPayServer.Client
|
||||||
|
@using BTCPayServer.Controllers
|
||||||
|
@using BTCPayServer.Plugins.Emails
|
||||||
|
@using BTCPayServer.Plugins.Subscriptions.Controllers
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@model SubscriptionsViewModel
|
||||||
|
@inject DisplayFormatter DisplayFormatter
|
||||||
|
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
|
||||||
|
|
||||||
|
@{
|
||||||
|
string storeId = (string)this.Context.GetRouteValue("storeId");
|
||||||
|
string offeringId = (string)this.Context.GetRouteValue("offeringId");
|
||||||
|
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Subscriptions"], offeringId);
|
||||||
|
Csp.UnsafeEval();
|
||||||
|
}
|
||||||
|
|
||||||
|
@section PageHeadContent {
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.card h6 {
|
||||||
|
font-weight: var(--btcpay-font-weight-semibold);
|
||||||
|
color: var(--btcpay-body-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="sticky-header">
|
||||||
|
<h2>@ViewData["Title"]</h2>
|
||||||
|
<div>
|
||||||
|
<a class="btn btn-secondary" asp-action="ConfigureOffering" asp-route-storeId="@storeId" asp-route-offeringId="@offeringId" text-translate="true">Configure</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
|
||||||
|
<div class="container-fluid px-4 py-4">
|
||||||
|
<!-- Metrics Cards -->
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div>
|
||||||
|
<h6 text-translate="true">Active Subscribers</h6>
|
||||||
|
<h4>@Model.TotalSubscribers</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<div>
|
||||||
|
<h6 text-translate="true">Monthly revenue</h6>
|
||||||
|
<h4 class="text-nowrap">@Model.TotalMonthlyRevenue</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation Tabs -->
|
||||||
|
<ul id="SectionNav" class="nav mb-4">
|
||||||
|
<a class="nav-link @(Model.Section == SubscriptionSection.Subscribers ? "active" : "")"
|
||||||
|
asp-action="Offering"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-section="@SubscriptionSection.Subscribers" text-translate="true">Subscribers</a>
|
||||||
|
<a class="nav-link @(Model.Section == SubscriptionSection.Plans ? "active" : "")"
|
||||||
|
asp-action="Offering"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-section="@SubscriptionSection.Plans" text-translate="true">Plans</a>
|
||||||
|
<a class="nav-link @(Model.Section == SubscriptionSection.Mails ? "active" : "")"
|
||||||
|
asp-action="Offering"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-section="@SubscriptionSection.Mails" text-translate="true">Mails</a>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
@if (Model.Section == SubscriptionSection.Plans)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h4>Plans</h4>
|
||||||
|
<a id="page-primary" permission="@Policies.CanModifyMembership" asp-route-storeId="@storeId" asp-route-offeringId="@offeringId" asp-action="AddPlan"
|
||||||
|
class="btn btn-primary"
|
||||||
|
role="button"
|
||||||
|
text-translate="true">Add Plan</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subscription Plans Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th text-translate="true">Plan</th>
|
||||||
|
<th text-translate="true">API ID</th>
|
||||||
|
<th text-translate="true">Price</th>
|
||||||
|
<th text-translate="true">Recurring</th>
|
||||||
|
<th text-translate="true">Grace Period</th>
|
||||||
|
<th text-translate="true">Trial Period</th>
|
||||||
|
<th text-translate="true">Status</th>
|
||||||
|
<th text-translate="true">Active Members</th>
|
||||||
|
<th text-translate="true">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.Plans.Count != 0)
|
||||||
|
{
|
||||||
|
foreach (var p in Model.Plans)
|
||||||
|
{
|
||||||
|
<tr id="plan_@p.Data.Id" class="plan-row align-middle"
|
||||||
|
data-plan-id="@p.Data.Id"
|
||||||
|
data-plan-name="@p.Data.Name"
|
||||||
|
data-allow-trial="@(p.Data.TrialDays > 0)">
|
||||||
|
<td class="fw-semibold text-nowrap plan-name-col">
|
||||||
|
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
@p.Data.Name
|
||||||
|
</span>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
text-translate="true"
|
||||||
|
class="new-subscriber-link dropdown-item lh-base"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#newSubscriberModal">
|
||||||
|
Create a new subscriber
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<vc:truncate-center
|
||||||
|
text="@p.Data.Id"
|
||||||
|
classes="truncate-center-id plan-row-id" />
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">@DisplayFormatter.Currency(@p.Data.Price, @p.Data.Currency, DisplayFormatter.CurrencyFormat.CodeAndSymbol)</td>
|
||||||
|
<td>@p.Data.RecurringType</td>
|
||||||
|
<td>@p.Data.GracePeriodDays days</td>
|
||||||
|
<td>@p.Data.TrialDays days</td>
|
||||||
|
<td><span class="status-active">
|
||||||
|
@{
|
||||||
|
var (badge, name) = p.Data.Status switch
|
||||||
|
{
|
||||||
|
PlanData.PlanStatus.Retired =>
|
||||||
|
p.Data.MemberCount != 0 ? ("warning", StringLocalizer["Retiring"]) : ("danger", StringLocalizer["Retired"]),
|
||||||
|
_ => ("success", StringLocalizer["Active"])
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<span class="subscriber-status badge badge-translucent rounded-pill text-bg-@badge">@name</span>
|
||||||
|
</span></td>
|
||||||
|
<td>@p.Data.MemberCount Members</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-inline-flex align-items-center gap-3">
|
||||||
|
<a class="edit-plan" asp-action="AddPlan"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-planId="@p.Data.Id">Edit</a>
|
||||||
|
<a
|
||||||
|
asp-action="DeletePlan"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
asp-route-offeringId="@offeringId"
|
||||||
|
asp-route-planId="@p.Data.Id"
|
||||||
|
data-bs-toggle="modal" data-bs-target="#ConfirmModal"
|
||||||
|
data-description="@ViewLocalizer["This action will remove the plan <b>{0}</b>.", Html.Encode(p.Data.Name)]"
|
||||||
|
data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-secondary" text-translate="true">There are no subscription plans.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove plan"], StringLocalizer["This action will remove this plan. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
|
||||||
|
}
|
||||||
|
else if (Model.Section == SubscriptionSection.Subscribers)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<h4>Subscribers</h4>
|
||||||
|
@if (Model.SelectablePlans.Count != 0)
|
||||||
|
{
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
permission="@Policies.CanModifyMembership"
|
||||||
|
text-translate="true"
|
||||||
|
role="button"
|
||||||
|
id="page-primary"
|
||||||
|
class="new-subscriber btn btn-primary"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#newSubscriberModal">
|
||||||
|
Add subsriber
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8">
|
||||||
|
<input asp-for="SearchTerm" class="form-control" placeholder="@StringLocalizer["Search by email, external reference, name…"]" />
|
||||||
|
</form>
|
||||||
|
<!-- Subscription Plans Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th text-translate="true">User</th>
|
||||||
|
<th text-translate="true">Credits</th>
|
||||||
|
<th text-translate="true">Plan</th>
|
||||||
|
<th text-translate="true">Phase</th>
|
||||||
|
<th text-translate="true">Status</th>
|
||||||
|
<th text-translate="true">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.Subscribers.Count != 0)
|
||||||
|
{
|
||||||
|
@foreach (var subscriber in Model.Subscribers)
|
||||||
|
{
|
||||||
|
<tr data-subscriber-email="@subscriber.Data.Customer.Email.Get()"
|
||||||
|
data-subscriber-id="@subscriber.Data.CustomerId"
|
||||||
|
data-currency="@subscriber.Data.Plan.Currency"
|
||||||
|
data-current-credit-value="@subscriber.Data.GetCredit()">
|
||||||
|
<td class="fw-semibold text-nowrap d-flex align-items-center">
|
||||||
|
<form method="post" asp-antiforgery="true" class="me-2">
|
||||||
|
@if (subscriber.Data.TestAccount)
|
||||||
|
{
|
||||||
|
<span class="badge badge-translucent rounded-pill text-bg-info">Test</span>
|
||||||
|
}
|
||||||
|
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
@subscriber.Data.Customer.Email.Get()
|
||||||
|
</span>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<input type="hidden" name="customerId" value="@subscriber.Data.CustomerId" />
|
||||||
|
<button type="submit" name="command" class="dropdown-item lh-base" value="toggle-test">
|
||||||
|
@if (subscriber.Data.TestAccount)
|
||||||
|
{
|
||||||
|
<span text-translate="true">Unmark test account</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span text-translate="true">Mark as a test account</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="fw-semibold text-nowrap subscriber-credit-col">
|
||||||
|
<span>
|
||||||
|
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||||
|
<span>@DisplayFormatter.Currency(subscriber.Data.GetCredit(), subscriber.Data.Plan.Currency, DisplayFormatter.CurrencyFormat.CodeAndSymbol)</span>
|
||||||
|
</span>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
text-translate="true"
|
||||||
|
class="charge-subscriber-link dropdown-item lh-base"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#updateCreditModal"
|
||||||
|
data-action="credit">
|
||||||
|
Credit
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
text-translate="true"
|
||||||
|
class="charge-subscriber-link dropdown-item lh-base"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#updateCreditModal"
|
||||||
|
data-action="charge">
|
||||||
|
Charge
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap">@subscriber.Data.Plan.Name</td>
|
||||||
|
<td>
|
||||||
|
<span class="subscriber-phase">
|
||||||
|
@{
|
||||||
|
var (style, name) = subscriber.Data.Phase switch
|
||||||
|
{
|
||||||
|
SubscriberData.PhaseTypes.Normal => ("success", StringLocalizer["Normal"]),
|
||||||
|
SubscriberData.PhaseTypes.Expired => ("danger", StringLocalizer["Expired"]),
|
||||||
|
SubscriberData.PhaseTypes.Grace => ("warning", StringLocalizer["Grace"]),
|
||||||
|
SubscriberData.PhaseTypes.Trial => ("info", StringLocalizer["Trial"]),
|
||||||
|
_ => throw new NotSupportedException()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
<span class="badge badge-translucent rounded-pill text-bg-@style">@name</span>
|
||||||
|
</span></td>
|
||||||
|
<td>
|
||||||
|
|
||||||
|
<span class="status-active">
|
||||||
|
<vc:subscriber-status subscriber="@subscriber.Data" can-suspend="true" />
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-inline-flex align-items-center gap-3">
|
||||||
|
<a asp-action="CreatePortalSession" asp-route-storeId="@storeId" asp-route-offeringId="@subscriber.Data.OfferingId"
|
||||||
|
asp-route-customerId="@subscriber.Data.CustomerId" class="portal-link" target="_blank">View Portal</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Model.TooMuchSubscribers)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td colspan="8" class="text-secondary" text-translate="true">There are many subscribers, use search to look for them.</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<td colspan="8" class="text-secondary" text-translate="true">There are no subscribers.</td>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model.Section == SubscriptionSection.Mails)
|
||||||
|
{
|
||||||
|
<form asp-antiforgery="true" method="post">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4 text-translate="true">Mails</h4>
|
||||||
|
<div class="d-flex justify-content-start sticky-footer">
|
||||||
|
<button id="page-primary" class="btn btn-success px-4" type="submit" text-translate="true">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
@if (!Model.EmailConfigured)
|
||||||
|
{
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
|
<h5 class="mb-0" text-translate="true">Email Configuration</h5>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-muted mb-3" text-translate="true">No email address has been configured for the Server. Configure an email address
|
||||||
|
to
|
||||||
|
begin sending emails.</p>
|
||||||
|
<a
|
||||||
|
asp-area="@EmailsPlugin.Area"
|
||||||
|
asp-action="StoreEmailSettings"
|
||||||
|
asp-controller="UIStoresEmail"
|
||||||
|
asp-route-storeId="@storeId"
|
||||||
|
target="_blank"
|
||||||
|
class="btn btn-warning" text-translate="true">Configure email</a>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Notifications & Alerts -->
|
||||||
|
<div class="col-lg-@(Model.EmailConfigured ? "12" : "6")">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="mb-3" text-translate="true">Notifications & Alerts</h5>
|
||||||
|
<div class="form-group mb-4">
|
||||||
|
<label asp-for="PaymentRemindersDays" class="form-label" text-translate="true">Email Reminder Days Before Due</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input inputmode="number" asp-for="PaymentRemindersDays" class="form-control" style="max-width:12ch;"
|
||||||
|
min="0" />
|
||||||
|
<span class="input-group-text" text-translate="true">days</span>
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="PaymentRemindersDays" class="text-danger"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex align-items-center gap-4 mb-3">
|
||||||
|
<h5 class="mb-0" text-translate="true">Email rules</h5>
|
||||||
|
<span class="dropdown-toggle btn btn-outline-secondary" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Add email rule</span>
|
||||||
|
<div class="dropdown-menu">
|
||||||
|
@foreach (var availableRule in Model.AvailableTriggers)
|
||||||
|
{
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
name="addEmailRule"
|
||||||
|
value="@availableRule.Trigger"
|
||||||
|
class="new-subscriber-link dropdown-item lh-base">@StringLocalizer[availableRule.Description]</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap w-25" text-translate="true">Trigger</th>
|
||||||
|
<th class="w-75" text-translate="true">Subject</th>
|
||||||
|
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@if (Model.EmailRules.Count == 0)
|
||||||
|
{
|
||||||
|
<td colspan="2" class="text-secondary" text-translate="true">There are no email rules for this offering.</td>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var rule in Model.EmailRules)
|
||||||
|
{
|
||||||
|
var thisPage = @Url.Action(nameof(UIOfferingController.Offering), new { storeId, offeringId, SubscriptionSection.Mails });
|
||||||
|
<tr>
|
||||||
|
<td>@rule.TriggerViewModel.Description</td>
|
||||||
|
<td>@rule.Data.Subject</td>
|
||||||
|
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
|
||||||
|
<div class="d-inline-flex align-items-center gap-3">
|
||||||
|
<a asp-area="@EmailsPlugin.Area"
|
||||||
|
asp-controller="UIStoreEmailRules"
|
||||||
|
asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId"
|
||||||
|
asp-route-redirectUrl="@thisPage"
|
||||||
|
asp-route-ruleId="@rule.Data.Id">Edit</a>
|
||||||
|
<a asp-area="@EmailsPlugin.Area"
|
||||||
|
asp-controller="UIStoreEmailRules"
|
||||||
|
asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId"
|
||||||
|
asp-route-redirectUrl="@thisPage"
|
||||||
|
asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
|
||||||
|
data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.TriggerViewModel.Description)]"
|
||||||
|
data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
|
||||||
|
}
|
||||||
|
<partial name="SuspendSubscriberModal" />
|
||||||
|
<partial name="NewSubscriberModal" model="Model.SelectablePlans" />
|
||||||
|
<partial name="ChangeCreditModal" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section PageFootContent {
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
$('.richtext2').summernote({
|
||||||
|
minHeight: 200,
|
||||||
|
tableClassName: 'table table-sm',
|
||||||
|
insertTableMaxSize: {
|
||||||
|
col: 5,
|
||||||
|
row: 10
|
||||||
|
},
|
||||||
|
codeviewFilter: true,
|
||||||
|
codeviewFilterRegex: new RegExp($.summernote.options.codeviewFilterRegex.source + '|<.*?( on\\w+?=.*?)>', 'gi'),
|
||||||
|
codeviewIframeWhitelistSrc: ['twitter.com', 'syndication.twitter.com']
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
|
||||||
|
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<div class="modal fade" id="suspendSubscriberModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" text-translate="true">Suspend Subscriber</h4>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div text-translate="true">Would you like to proceed with suspending the following user?</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-0">
|
||||||
|
<input id="suspendSubscriberId" name="customerId" type="hidden" />
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" text-translate="true">Subscriber</label>
|
||||||
|
<input id="suspendSubscriberName" type="text" disabled="disabled" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="suspensionReason" class="form-label" text-translate="true">Suspension Reason</label>
|
||||||
|
<input id="suspensionReason" name="suspensionReason" type="text" placeholder="Enter the reason (optional)" class="form-control" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="submit" name="command" value="suspend" class="btn btn-danger" text-translate="true">Suspend</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const modalEl = document.getElementById('suspendSubscriberModal');
|
||||||
|
if (!modalEl) return;
|
||||||
|
modalEl.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const trigger = event.relatedTarget;
|
||||||
|
if (!trigger) return;
|
||||||
|
const email = trigger.getAttribute('data-subscriber-email');
|
||||||
|
|
||||||
|
const idEl = modalEl.querySelector('#suspendSubscriberId');
|
||||||
|
const nameEl = modalEl.querySelector('#suspendSubscriberName');
|
||||||
|
const reasonEl = modalEl.querySelector('#suspensionReason');
|
||||||
|
|
||||||
|
if (nameEl) nameEl.value = email || '';
|
||||||
|
if (reasonEl) reasonEl.value = '';
|
||||||
|
if (idEl) idEl.value = trigger.getAttribute('data-subscriber-id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
|
||||||
|
@inject DisplayFormatter DisplayFormatter
|
||||||
|
@inject LinkGenerator LinkGenerator
|
||||||
|
@using BTCPayServer.Plugins.Subscriptions
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@model PlanCheckoutViewModel
|
||||||
|
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Title;
|
||||||
|
ViewData["StoreBranding"] = Model.StoreBranding;
|
||||||
|
Layout = null;
|
||||||
|
ViewData.SetBlazorAllowed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "") id="PlanCheckout-@Model.Id">
|
||||||
|
<head>
|
||||||
|
<partial name="LayoutHead" />
|
||||||
|
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: var(--btcpay-body-bg-medium);
|
||||||
|
color: var(--btcpay-body-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-details {
|
||||||
|
background: var(--btcpay-body-bg-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
height: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-form {
|
||||||
|
background: var(--btcpay-body-bg-light);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
position: sticky;
|
||||||
|
top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-logo img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.subscription-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-description {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--btcpay-body-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-list li i {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
width: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
color: var(--btcpay-primary);
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
width: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-display {
|
||||||
|
background: var(--btcpay-body-bg);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-amount {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--btcpay-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-period {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control:focus {
|
||||||
|
border-color: var(--btcpay-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(5, 150, 105, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-checkout {
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-title {
|
||||||
|
color: var(--btcpay-primary-text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-subtitle {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@media (max-width: 768px) {
|
||||||
|
.checkout-container {
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-details,
|
||||||
|
.checkout-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkout-form {
|
||||||
|
position: static;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="checkout-container">
|
||||||
|
<div class="container">
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<div class="row">
|
||||||
|
<!-- Left Column - Subscription Details -->
|
||||||
|
<div class="col-lg-7 col-md-6">
|
||||||
|
<div class="subscription-details">
|
||||||
|
@if (Model.StoreBranding.LogoUrl is not null || Model.StoreName is not null)
|
||||||
|
{
|
||||||
|
<div class="d-flex align-items-center mb-4">
|
||||||
|
|
||||||
|
<div class="company-logo me-3">
|
||||||
|
@if (Model.StoreBranding.LogoUrl is null)
|
||||||
|
{
|
||||||
|
<img src="~/img/btcpay-logo.svg" alt="@Model.StoreName" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<img src="@Model.StoreBranding.LogoUrl" alt="@Model.StoreName" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-0 fw-semibold">@Model.StoreName</h6>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<h3 class="subscription-title d-inline-flex align-items-center gap-2">
|
||||||
|
<span>@Model.Title</span>
|
||||||
|
<a type="button" class="btn-link"
|
||||||
|
href="#"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#OpenQrModal">
|
||||||
|
<vc:icon symbol="qr-code" />
|
||||||
|
</a>
|
||||||
|
</h3>
|
||||||
|
<p class="subscription-description">@Model.Data.Description</p>
|
||||||
|
|
||||||
|
<div class="price-display">
|
||||||
|
<div
|
||||||
|
class="price-amount">@DisplayFormatter.Currency(Model.Data.Price, Model.Data.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
|
||||||
|
<div class="price-period">per month</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Data.PlanEntitlements.Count(i => !string.IsNullOrWhiteSpace(i.Entitlement.Description)) != 0)
|
||||||
|
{
|
||||||
|
<h5 class="mb-3">What's included:</h5>
|
||||||
|
<ul class="feature-list">
|
||||||
|
@foreach (var item in Model.Data.PlanEntitlements.Where(i => !string.IsNullOrWhiteSpace(i.Entitlement.Description)))
|
||||||
|
{
|
||||||
|
<li class="feature-item">
|
||||||
|
<i class="fas fa fa-check feature-icon"></i>
|
||||||
|
<span>@item.Entitlement.Description</span>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column - Checkout Form -->
|
||||||
|
<div class="col-lg-5 col-md-6">
|
||||||
|
<div class="checkout-form">
|
||||||
|
<div class="checkout-header">
|
||||||
|
<h4 class="checkout-title">Complete Your Order</h4>
|
||||||
|
<p class="checkout-subtitle">Enter your email to proceed to secure payment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="checkoutForm" method="post">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="customerEmail" class="form-label fw-semibold">Email Address</label>
|
||||||
|
<input
|
||||||
|
asp-for="Email"
|
||||||
|
type="email"
|
||||||
|
class="form-control form-control-lg"
|
||||||
|
id="emailInput"
|
||||||
|
placeholder="Enter your email address"
|
||||||
|
required
|
||||||
|
disabled="@Model.IsPrefilled">
|
||||||
|
<span asp-validation-for="Email" class="text-danger"></span>
|
||||||
|
<div class="form-text">
|
||||||
|
<i class="fas fa fa-info-circle me-1"></i>
|
||||||
|
We'll send your receipt and account details here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.IsTrial)
|
||||||
|
{
|
||||||
|
<button type="submit" name="command" value="start-trial" class="btn btn-primary btn-checkout btn-lg" id="proceedBtn">
|
||||||
|
<i class="fas fa fa-lock me-2"></i>
|
||||||
|
Proceed to free trial
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="submit" name="command" value="pay" class="btn btn-primary btn-checkout btn-lg" id="proceedBtn">
|
||||||
|
<i class="fas fa fa-lock me-2"></i>
|
||||||
|
Proceed to Secure Payment
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
<!-- Security Badges -->
|
||||||
|
<div class="security-badges">
|
||||||
|
<div class="security-badge store-footer">
|
||||||
|
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
|
||||||
|
Powered by
|
||||||
|
<partial name="_StoreFooterLogo" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="OpenQrModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog" role="document">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" text-translate="true">Scan the QR code to open this page on a mobile</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-0 text-center">
|
||||||
|
<component type="typeof(BTCPayServer.Blazor.QrCode)"
|
||||||
|
param-Data="@LinkGenerator.PlanCheckout(Model.Id, Context.Request.GetRequestBaseUrl())"
|
||||||
|
render-mode="Static"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
|
||||||
|
<script>
|
||||||
|
const emailInput = document.getElementById('emailInput');
|
||||||
|
const proceedBtn = document.getElementById('proceedBtn');
|
||||||
|
const checkoutForm = document.getElementById('checkoutForm');
|
||||||
|
|
||||||
|
emailInput.addEventListener('input', function () {
|
||||||
|
const isValid = this.checkValidity() && this.value.length > 0;
|
||||||
|
proceedBtn.disabled = !isValid;
|
||||||
|
});
|
||||||
|
|
||||||
|
checkoutForm.addEventListener('submit', function (e) {
|
||||||
|
const email = emailInput.value;
|
||||||
|
if (!email || !emailInput.checkValidity()) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
@model PlanCheckoutDefaultRedirectViewModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = Model.Title;
|
||||||
|
ViewData["StoreBranding"] = Model.StoreBranding;
|
||||||
|
Layout = null;
|
||||||
|
ViewData.SetBlazorAllowed(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" id="PlanCheckout-@Model.Id">
|
||||||
|
<head>
|
||||||
|
<partial name="LayoutHead" />
|
||||||
|
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: var(--btcpay-body-bg-medium);
|
||||||
|
color: var(--btcpay-body-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-container {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 3rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 500px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
background: var(--btcpay-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@@keyframes pulse {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.success-icon i {
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="success-container">
|
||||||
|
<div class="success-card">
|
||||||
|
<div class="success-icon">
|
||||||
|
<i class="fa fa-check"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="success-title">Payment Successful!</h1>
|
||||||
|
|
||||||
|
<p class="success-message">
|
||||||
|
Thank you for your purchase! Your payment has been processed successfully and your subscription is now active.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-muted">
|
||||||
|
A confirmation email has been sent to your registered email address with your subscription details.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class PlanCheckoutDefaultRedirectViewModel
|
||||||
|
{
|
||||||
|
public PlanCheckoutDefaultRedirectViewModel()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlanCheckoutDefaultRedirectViewModel(PlanCheckoutData data)
|
||||||
|
{
|
||||||
|
Data = data;
|
||||||
|
Id = data.Id;
|
||||||
|
}
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string StoreName { get; set; }
|
||||||
|
public StoreBrandingViewModel StoreBranding { get; set; }
|
||||||
|
public string Id { get; set; }
|
||||||
|
public PlanCheckoutData Data { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class PlanCheckoutViewModel
|
||||||
|
{
|
||||||
|
public StoreBrandingViewModel StoreBranding { get; set; }
|
||||||
|
public string Title { get; set; }
|
||||||
|
public string StoreName { get; set; }
|
||||||
|
public string Email { get; set; }
|
||||||
|
public string Id { get; set; }
|
||||||
|
public PlanData Data { get; set; }
|
||||||
|
public bool IsPrefilled { get; set; }
|
||||||
|
public bool IsTrial { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
@model (string ButtonClass, SubscriberData Subscriber)
|
||||||
|
|
||||||
|
<form method="post">
|
||||||
|
@if (Model.Subscriber.NextPlan.Renewable)
|
||||||
|
{
|
||||||
|
<button type="submit" name="command" value="pay" class="@Model.ButtonClass">Pay Now</button>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<button type="submit" name="command" value="migrate" class="@Model.ButtonClass">Upgrade</button>
|
||||||
|
}
|
||||||
|
</form>
|
||||||
|
|
||||||
@@ -0,0 +1,723 @@
|
|||||||
|
@using BTCPayServer.Client.Models
|
||||||
|
@using BTCPayServer.Services
|
||||||
|
@using BTCPayServer.Services.Invoices
|
||||||
|
@using NBXplorer
|
||||||
|
@model SubscriberPortalViewModel
|
||||||
|
@inject DisplayFormatter DisplayFormatter
|
||||||
|
@inject LinkGenerator linkGenerator;
|
||||||
|
|
||||||
|
@functions
|
||||||
|
{
|
||||||
|
int CountDays(DateTimeOffset date)
|
||||||
|
{
|
||||||
|
var days = (int)double.Ceiling((date - DateTimeOffset.UtcNow).TotalDays);
|
||||||
|
return days < 0 ? 0 : days;
|
||||||
|
}
|
||||||
|
|
||||||
|
string FormatDays(int days)
|
||||||
|
=> days is 1 or 0 ? StringLocalizer["{0} day", days] : StringLocalizer["{0} days", days];
|
||||||
|
|
||||||
|
private static string RecurringToString(PlanData.RecurringInterval type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
PlanData.RecurringInterval.Lifetime => "Lifetime",
|
||||||
|
PlanData.RecurringInterval.Monthly => "per month",
|
||||||
|
PlanData.RecurringInterval.Quarterly => "per quarter",
|
||||||
|
PlanData.RecurringInterval.Yearly => "per year",
|
||||||
|
_ => throw new NotSupportedException()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@{
|
||||||
|
Layout = null;
|
||||||
|
var creditBalance = DisplayFormatter.Currency(Model.Credit.CurrentBalance, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol);
|
||||||
|
var daysRemaining = Model.Subscriber.NextPaymentDue is null ? 0 : CountDays(Model.Subscriber.NextPaymentDue.Value);
|
||||||
|
var date = Model.Subscriber.NextPaymentDue is null ? "" : Model.Subscriber.NextPaymentDue.Value.ToString("D");
|
||||||
|
|
||||||
|
var graceRemaining = Model.Subscriber.GracePeriodEnd is null ? 0 : CountDays(Model.Subscriber.GracePeriodEnd.Value);
|
||||||
|
var currency = Model.Subscriber.Plan.Currency;
|
||||||
|
SubscriberData.PhaseTypes? nextPhase = Model.Subscriber switch
|
||||||
|
{
|
||||||
|
{ Phase: SubscriberData.PhaseTypes.Trial, PeriodEnd: { } pe } => SubscriberData.PhaseTypes.Normal,
|
||||||
|
{ Phase: SubscriberData.PhaseTypes.Trial } => SubscriberData.PhaseTypes.Expired,
|
||||||
|
{ Phase: SubscriberData.PhaseTypes.Normal, GracePeriodEnd: { } gpe } => SubscriberData.PhaseTypes.Grace,
|
||||||
|
{ Phase: SubscriberData.PhaseTypes.Normal } => SubscriberData.PhaseTypes.Expired,
|
||||||
|
{ Phase: SubscriberData.PhaseTypes.Grace } => SubscriberData.PhaseTypes.Expired,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<partial name="LayoutHead" />
|
||||||
|
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--warning-bg: #fef3c7;
|
||||||
|
--warning-text: #92400e;
|
||||||
|
--danger-bg: #f8d7da;
|
||||||
|
--danger-text: #dc3545;
|
||||||
|
--info-bg: #cff4fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.company-logo img {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
border-bottom: 1px solid var(--btcpay-body-border-light);
|
||||||
|
padding: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: none;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent {
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-warning {
|
||||||
|
background: var(--warning-bg);
|
||||||
|
border: 1px solid var(--btcpay-warning-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-info {
|
||||||
|
background: var(--info-bg);
|
||||||
|
border: 1px solid var(--btcpay-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-danger {
|
||||||
|
background: var(--danger-bg);
|
||||||
|
border: 1px solid var(--btcpay-danger-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent .alert-icon {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-warning .alert-icon {
|
||||||
|
color: var(--btcpay-warning-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-danger .alert-icon {
|
||||||
|
color: var(--btcpay-danger-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-info .alert-icon {
|
||||||
|
color: var(--btcpay-info-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-warning .alert-text {
|
||||||
|
color: var(--warning-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-danger .alert-text {
|
||||||
|
color: var(--btcpay-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-translucent.alert-info .alert-text {
|
||||||
|
color: var(--btcpay-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.notice-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-subtitle {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
border-top: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-comparison {
|
||||||
|
background-color: var(--btcpay-neutral-100);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin: 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-plan {
|
||||||
|
border-left: 3px solid #0066cc;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
color: #6c757d !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="navbar btcpay-header">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<div class="company-logo me-3">
|
||||||
|
@if (Model.StoreBranding.LogoUrl is null)
|
||||||
|
{
|
||||||
|
<img src="~/img/btcpay-logo.svg" alt="@Model.StoreName" />
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<img src="@Model.StoreBranding.LogoUrl" alt="@Model.StoreName" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="navbar-brand mb-0 h1">@Model.StoreName</span>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
@if (Model.Subscriber.TestAccount)
|
||||||
|
{
|
||||||
|
@if (Model.Subscriber.GetReminderDate() is var o && DateTimeOffset.UtcNow < o)
|
||||||
|
{
|
||||||
|
<form id="MoveToReminder"
|
||||||
|
asp-action="MoveTime"
|
||||||
|
asp-route-portalSessionId="@Model.Data.Id" method="post">
|
||||||
|
<button type="submit" class="btn btn-outline-info" name="command" value="reminder">Move to payment reminder</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (nextPhase is not null)
|
||||||
|
{
|
||||||
|
<form id="MovePhase"
|
||||||
|
asp-action="MoveTime"
|
||||||
|
asp-route-portalSessionId="@Model.Data.Id" method="post">
|
||||||
|
<button type="submit" class="btn btn-outline-info">Move to @nextPhase.Value</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<form id="Move7days"
|
||||||
|
asp-action="MoveTime"
|
||||||
|
asp-route-portalSessionId="@Model.Data.Id" method="post">
|
||||||
|
<button type="submit" class="btn btn-outline-info" name="command" value="move7days">Move to 7 days</button>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
<span class="me-3 text-muted">@Model.Subscriber.Customer.GetPrimaryIdentity()</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="main-content">
|
||||||
|
<partial name="_StatusMessage" />
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h2 class="mb-0">Subscription Management</h2>
|
||||||
|
@if (@Model.Subscriber.Customer.ExternalRef is not null)
|
||||||
|
{
|
||||||
|
<span class="text-muted">Customer Reference: @Model.Subscriber.Customer.ExternalRef</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Subscription -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa fa-address-book me-2"></i>Current Subscription
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<h5 class="d-flex align-items-center gap-3 mb-2">
|
||||||
|
<span data-testid="plan-name">@Model.Subscriber.Plan.Name</span>
|
||||||
|
@if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
|
||||||
|
{
|
||||||
|
<span class="badge badge-translucent rounded-pill text-bg-info">Trial</span>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Grace)
|
||||||
|
{
|
||||||
|
<span class="badge badge-translucent rounded-pill text-bg-warning">Grace</span>
|
||||||
|
}
|
||||||
|
</h5>
|
||||||
|
@if (Model.Subscriber.Plan.Description is not null)
|
||||||
|
{
|
||||||
|
<p class="text-muted mb-3">@Model.Subscriber.Plan.Description</p>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center mb-2">
|
||||||
|
<span class="me-3"><vc:subscriber-status subscriber="@Model.Subscriber" /></span>
|
||||||
|
@if (!string.IsNullOrEmpty(date))
|
||||||
|
{
|
||||||
|
@if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Normal)
|
||||||
|
{
|
||||||
|
<span class="text-muted">Next billing: @date</span>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Trial)
|
||||||
|
{
|
||||||
|
<span class="text-muted">Trial expires: @date</span>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Grace)
|
||||||
|
{
|
||||||
|
<span class="text-muted">The subscription has already expired and will soon be inactivated</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (Model.Subscriber.Plan.RecurringType != PlanData.RecurringInterval.Lifetime)
|
||||||
|
{
|
||||||
|
<div class="col-md-4 text-md-end">
|
||||||
|
<h4 class="mb-1">@DisplayFormatter.Currency(Model.Plan.Price, Model.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</h4>
|
||||||
|
<p class="text-muted mb-0">@RecurringToString(Model.Plan.RecurringType)</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Subscriber.IsSuspended)
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-danger">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-warning alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title"><span>Access suspended</span></div>
|
||||||
|
<p class="notice-subtitle">Your access to this subscription has been suspended.</p>
|
||||||
|
@if (!String.IsNullOrEmpty(Model.Subscriber.SuspensionReason))
|
||||||
|
{
|
||||||
|
<p class="notice-subtitle">Reason: @Model.Subscriber.SuspensionReason</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Expired)
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-danger">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-warning alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title"><span>Access expired</span></div>
|
||||||
|
<p class="notice-subtitle">Your access to this subscription has been expired.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="NextAction" model="@("btn btn-danger", Model.Subscriber)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-info">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-warning alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title"><span>You are in trial</span></div>
|
||||||
|
<p class="notice-subtitle">You access will be revoked in <span>@FormatDays(daysRemaining)</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="NextAction" model="@("btn btn-info", Model.Subscriber)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.GetReminderDate() is { } reminderDate2 &&
|
||||||
|
reminderDate2 < DateTimeOffset.UtcNow &&
|
||||||
|
!Model.Subscriber.IsNextPlanRenewable)
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-warning">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-warning alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title">Upgrade needed in <span>@FormatDays(daysRemaining)</span></div>
|
||||||
|
<div class="notice-subtitle">You access will be revoked in <span>@FormatDays(daysRemaining)</span>, you need to subscribe to a new plan.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="NextAction" model="@("btn btn-warning", Model.Subscriber)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else if (Model.Subscriber.GetReminderDate() is { } reminderDate &&
|
||||||
|
reminderDate < DateTimeOffset.UtcNow &&
|
||||||
|
Model.Subscriber.MissingCredit() != 0)
|
||||||
|
{
|
||||||
|
@if (daysRemaining == 0)
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-danger">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-warning alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title">Payment due</div>
|
||||||
|
<div class="notice-subtitle">You access will be revoked in <span>@FormatDays(graceRemaining)</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="NextAction" model="@("btn btn-danger", Model.Subscriber)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="alert-translucent alert-warning">
|
||||||
|
<div class="notice-content">
|
||||||
|
<i class="fa fa-clock-o alert-icon"></i>
|
||||||
|
<div class="alert-text">
|
||||||
|
<div class="notice-title">Payment due in <span>@FormatDays(daysRemaining)</span></div>
|
||||||
|
<p class="notice-subtitle">Next billing
|
||||||
|
date: @date • @DisplayFormatter.Currency(Model.Subscriber.Plan.Price, Model.Subscriber.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<partial name="NextAction" model="@("btn btn-warning", Model.Subscriber)" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<!-- Billing Information -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa fa-credit-card me-2"></i>Billing Information
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-2">Payment Method</h6>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge bg-light text-dark border">Credit balance</span>
|
||||||
|
<span
|
||||||
|
class="ms-2 fw-semibold credit-balance">@creditBalance</span>
|
||||||
|
</div>
|
||||||
|
<button id="add-credit" class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#credit-input"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="credit-input">
|
||||||
|
<i class="fa fa-credit-card"></i>
|
||||||
|
Add credit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="collapse" id="credit-input">
|
||||||
|
<form method="post">
|
||||||
|
<div class="input-group">
|
||||||
|
<button type="submit" name="command" value="add-credit" class="btn btn-primary">Proceed to payment</button>
|
||||||
|
<input inputmode="decimal" class="form-control" placeholder="Enter credit amount" asp-for="Credit.InputAmount" min="0"
|
||||||
|
required />
|
||||||
|
</div>
|
||||||
|
<span asp-validation-for="Credit.InputAmount" class="text-danger"></span>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
@if (Model.Subscriber.Plan.RecurringType is not PlanData.RecurringInterval.Lifetime)
|
||||||
|
{
|
||||||
|
<hr>
|
||||||
|
<div class="d-flex justify-content-between credit-plan-price">
|
||||||
|
<div class="text-muted">Plan price</div>
|
||||||
|
<div>@DisplayFormatter.Currency(Model.Subscriber.Plan.Price, Model.Subscriber.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between credit-applied">
|
||||||
|
<div class="text-muted">Credit applied</div>
|
||||||
|
<div class="text-success">
|
||||||
|
- @DisplayFormatter.Currency(Model.Credit.CreditApplied, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between fw-semibold credit-next-charge">
|
||||||
|
<div>Next charge on @date</div>
|
||||||
|
<div>@DisplayFormatter.Currency(Model.Credit.NextCharge, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
|
||||||
|
</div>
|
||||||
|
<form method="post" class="d-flex mt-3">
|
||||||
|
<input type="hidden" name="command" value="update-auto-renewal" />
|
||||||
|
<input type="checkbox" class="btcpay-toggle me-2" id="autoRenewal" asp-for="Subscriber.AutoRenew">
|
||||||
|
<label class="form-check-label" for="autoRenewal">Auto renewal</label>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (Model.Subscriber.Plan.RecurringType is not PlanData.RecurringInterval.Lifetime)
|
||||||
|
{
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h6 class="mb-2">Notification</h6>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
@foreach (var identity in Model.Subscriber.Customer.CustomerIdentities)
|
||||||
|
{
|
||||||
|
<vc:contact type="@identity.Type" value="@identity.Value"></vc:contact>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p class="form-text mb-0">Payment reminder will be sent <span class="fw-bold">@Model.Subscriber.PaymentReminderDaysOrDefault days</span> before
|
||||||
|
expiration.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Plan.PlanChanges.Any())
|
||||||
|
{
|
||||||
|
<!-- Plan Management -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header" id="plans">
|
||||||
|
<i class="fa fa-arrow-up me-2"></i>Plan Management
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="plan-comparison">
|
||||||
|
@foreach (var batch in Model.PlanChanges.Batch(3))
|
||||||
|
{
|
||||||
|
<div class="row">
|
||||||
|
@foreach (var plan in batch)
|
||||||
|
{
|
||||||
|
if (plan.Current)
|
||||||
|
{
|
||||||
|
<div class="col-md-4" data-plan-name="@plan.Name">
|
||||||
|
<div class="current-plan">
|
||||||
|
<h6 class="mb-1 changeplan-title">@plan.Name</h6>
|
||||||
|
<p class="text-muted mb-2"><span>@DisplayFormatter.Currency(plan.Price, plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</span> <span>@RecurringToString(plan.RecurringType)</span></p>
|
||||||
|
<span class="badge bg-primary">Current</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="col-md-4 changeplan-container"
|
||||||
|
data-plan-name="@plan.Name"
|
||||||
|
data-plan-id="@plan.PlanId">
|
||||||
|
<h6 class="mb-1 changeplan-title">@plan.Name</h6>
|
||||||
|
<p class="text-muted mb-2"><span>@DisplayFormatter.Currency(plan.Price, plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</span> <span>@RecurringToString(plan.RecurringType)</span></p>
|
||||||
|
|
||||||
|
<input type="hidden" name="changedPlanId" value="@plan.PlanId" />
|
||||||
|
@if (plan.ChangeType == PlanChangeData.ChangeType.Upgrade)
|
||||||
|
{
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-outline-primary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#changePlanModal"
|
||||||
|
data-action="upgrade">
|
||||||
|
Upgrade
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#changePlanModal"
|
||||||
|
data-action="downgrade">
|
||||||
|
Downgrade
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (Model.Transactions.Count != 0)
|
||||||
|
{
|
||||||
|
<div class="card credit-history">
|
||||||
|
<div class="card-header">
|
||||||
|
<i class="fa fa-file me-2"></i> History
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="text-nowrap">Date</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="text-nowrap text-end">Amount</th>
|
||||||
|
<th class="text-nowrap text-end">Total Balance</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var tx in Model.Transactions)
|
||||||
|
{
|
||||||
|
var color = tx.Amount >= 0 ? "text-success" : "text-danger";
|
||||||
|
<tr>
|
||||||
|
<td>@tx.Date.ToString("D")</td>
|
||||||
|
<td>@tx.Description</td>
|
||||||
|
<td class="@color text-end">
|
||||||
|
@DisplayFormatter.Currency(tx.Amount, currency, DisplayFormatter.CurrencyFormat.Symbol)
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
@DisplayFormatter.Currency(tx.TotalBalance, currency, DisplayFormatter.CurrencyFormat.Symbol)
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modal fade" id="changePlanModal" tabindex="-1" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title upgrade-mode">Upgrade</h4>
|
||||||
|
<h4 class="modal-title downgrade-mode">Downgrade</h4>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
|
||||||
|
<vc:icon symbol="close" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form method="post">
|
||||||
|
<div class="modal-body">
|
||||||
|
<div html-translate="true" class="upgrade-mode">Would you like to upgrade <span class="subscriber-name fw-semibold"></span> to <b class="changePlanName"></b>?</div>
|
||||||
|
<div html-translate="true" class="downgrade-mode">Would you like to downgrade <span class="subscriber-name fw-semibold"></span> to <b class="changePlanName"></b> ?</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body pt-0 pb-0">
|
||||||
|
<input name="changePlanId" type="hidden" />
|
||||||
|
|
||||||
|
<div id="changePlanCost" class="d-flex justify-content-between align-items-center pl">
|
||||||
|
<div class="text-muted">New plan cost</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (Model.Refund.Value != 0)
|
||||||
|
{
|
||||||
|
<div class="d-flex justify-content-between align-items-center pl">
|
||||||
|
<div class="text-muted">Current plan refund</div>
|
||||||
|
<div>-@Model.Refund.Display</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div id="changePlanUsedCredits" class="d-flex justify-content-between align-items-center pl">
|
||||||
|
<div class="text-muted">Used credits</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
<hr />
|
||||||
|
<div id="changePlanRemainingToPay" class="d-flex justify-content-between align-items-center pl">
|
||||||
|
<div class="text-muted">Amount due</div>
|
||||||
|
<div class="fw-bold"></div>
|
||||||
|
</div>
|
||||||
|
<div id="changePlanCreditBalance" class="d-flex justify-content-between align-items-center pl">
|
||||||
|
<div class="text-muted">Credit balance adjustment</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<input type="hidden" name="changedPlanId" />
|
||||||
|
<button type="submit" class="btn btn-success upgrade-mode" text-translate="true" name="command" value="migrate">Upgrade</button>
|
||||||
|
<button type="submit" class="btn btn-danger downgrade-mode" text-translate="true" name="command" value="migrate">Downgrade</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
var migrationPopups = @Safe.Json(Model.MigratePopups);
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(Model.Anchor))
|
||||||
|
{
|
||||||
|
<text>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
const element = document.getElementById(@Safe.Json(Model.Anchor));
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</text>
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#autoRenewal").addEventListener('change', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
fetch(this.form.action, {
|
||||||
|
method: 'POST',
|
||||||
|
body: new FormData(this.form)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const changePlanModal = document.getElementById('changePlanModal');
|
||||||
|
var changedPlanId = changePlanModal.querySelector('[name="changedPlanId"]');
|
||||||
|
var changePlanName = document.querySelectorAll('.changePlanName');
|
||||||
|
var changePlanCost = document.querySelector('#changePlanCost');
|
||||||
|
var changePlanUsedCredits = document.querySelector('#changePlanUsedCredits');
|
||||||
|
var changePlanCreditBalance = document.querySelector('#changePlanCreditBalance');
|
||||||
|
var changePlanUpgradeMode = document.querySelectorAll('.upgrade-mode');
|
||||||
|
var changePlanDowngradeMode = document.querySelectorAll('.downgrade-mode');
|
||||||
|
|
||||||
|
function setField(el, value) {
|
||||||
|
var div = el.children[1];
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
el.style.cssText = 'display: none !important';
|
||||||
|
div.innerText = '';
|
||||||
|
} else {
|
||||||
|
el.style.display = '';
|
||||||
|
div.innerText = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changePlanModal.addEventListener('show.bs.modal', function (event) {
|
||||||
|
const trigger = event.relatedTarget;
|
||||||
|
if (!trigger) return;
|
||||||
|
var container = trigger.closest('.changeplan-container');
|
||||||
|
var action = trigger.getAttribute('data-action');
|
||||||
|
var name = container.getAttribute('data-plan-name');
|
||||||
|
var id = container.getAttribute('data-plan-id');
|
||||||
|
changedPlanId.value = id;
|
||||||
|
changePlanName.forEach(el => el.innerText = name);
|
||||||
|
|
||||||
|
var m = migrationPopups[id];
|
||||||
|
setField(changePlanCost, m.cost);
|
||||||
|
setField(changePlanUsedCredits, m.usedCredit);
|
||||||
|
setField(changePlanRemainingToPay, m.amountDue);
|
||||||
|
setField(changePlanCreditBalance, m.creditBalanceAdjustment.text);
|
||||||
|
if (m.creditBalanceAdjustment.value > 0) {
|
||||||
|
changePlanCreditBalance.children[1].classList.add('text-success');
|
||||||
|
changePlanCreditBalance.children[1].classList.remove('text-danger');
|
||||||
|
} else {
|
||||||
|
changePlanCreditBalance.children[1].classList.add('text-danger');
|
||||||
|
changePlanCreditBalance.children[1].classList.remove('text-success');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "upgrade") {
|
||||||
|
changePlanDowngradeMode.forEach(el => el.style.display = 'none');
|
||||||
|
changePlanUpgradeMode.forEach(el => el.style.display = '');
|
||||||
|
} else {
|
||||||
|
changePlanDowngradeMode.forEach(el => el.style.display = '');
|
||||||
|
changePlanUpgradeMode.forEach(el => el.style.display = 'none');
|
||||||
|
}
|
||||||
|
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Models;
|
||||||
|
using BTCPayServer.Plugins.Subscriptions;
|
||||||
|
using Microsoft.AspNetCore.Html;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class SubscriberPortalViewModel
|
||||||
|
{
|
||||||
|
public class CreditViewModel
|
||||||
|
{
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public decimal CurrentBalance { get; set; }
|
||||||
|
public decimal CreditApplied { get; set; }
|
||||||
|
public decimal NextCharge { get; set; }
|
||||||
|
public decimal? InputAmount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberPortalViewModel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriberPortalViewModel(PortalSessionData data)
|
||||||
|
{
|
||||||
|
Data = data;
|
||||||
|
var credit = data.Subscriber.GetCredit();
|
||||||
|
var applied = Math.Min(credit, data.Subscriber.Plan.Price);
|
||||||
|
var nextCharge = data.Subscriber.Plan.Price - applied;
|
||||||
|
Credit = new()
|
||||||
|
{
|
||||||
|
Currency = data.Subscriber.Plan.Currency,
|
||||||
|
CurrentBalance = credit,
|
||||||
|
CreditApplied = applied,
|
||||||
|
InputAmount = nextCharge > 0 ? nextCharge : null,
|
||||||
|
NextCharge = nextCharge
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public StoreBrandingViewModel StoreBranding { get; set; }
|
||||||
|
public string StoreName { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
public PortalSessionData Data { get; set; }
|
||||||
|
public (decimal Value, string Display) Refund { get; set; }
|
||||||
|
public CreditViewModel Credit { get; set; }
|
||||||
|
|
||||||
|
public record BalanceTransactionViewModel(DateTimeOffset Date, HtmlString Description, decimal Amount, decimal TotalBalance);
|
||||||
|
|
||||||
|
public List<BalanceTransactionViewModel> Transactions { get; set; } = new();
|
||||||
|
public List<PlanChange> PlanChanges { get; set; }
|
||||||
|
|
||||||
|
public class PlanChange
|
||||||
|
{
|
||||||
|
public PlanChange(PlanData plan)
|
||||||
|
{
|
||||||
|
Name = plan.Name;
|
||||||
|
PlanId = plan.Id;
|
||||||
|
RecurringType = plan.RecurringType;
|
||||||
|
Currency = plan.Currency;
|
||||||
|
Price = plan.Price;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
public bool Current { get; set; }
|
||||||
|
public PlanChangeData.ChangeType ChangeType { get; set; }
|
||||||
|
public string PlanId { get; set; }
|
||||||
|
public decimal Price { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
public PlanData.RecurringInterval RecurringType { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public Dictionary<string, MigratePopup> MigratePopups { get; set; }
|
||||||
|
public class MigratePopup
|
||||||
|
{
|
||||||
|
public string Cost { get; set; }
|
||||||
|
public string UsedCredit { get; set; }
|
||||||
|
public string AmountDue { get; set; }
|
||||||
|
public (string Text, decimal Value) CreditBalanceAdjustment { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[BindingBehavior(BindingBehavior.Never)]
|
||||||
|
[ValidateNever]
|
||||||
|
public SubscriberData Subscriber => Data.Subscriber;
|
||||||
|
|
||||||
|
[BindingBehavior(BindingBehavior.Never)]
|
||||||
|
[ValidateNever]
|
||||||
|
public PlanData Plan => Data.Subscriber.Plan;
|
||||||
|
|
||||||
|
public string Anchor { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public enum SubscriptionSection
|
||||||
|
{
|
||||||
|
Subscribers,
|
||||||
|
Plans,
|
||||||
|
Mails
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Globalization;
|
||||||
|
using BTCPayServer.Data;
|
||||||
|
using BTCPayServer.Data.Subscriptions;
|
||||||
|
using BTCPayServer.Plugins.Emails.Views;
|
||||||
|
using BTCPayServer.Services.Mails;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Razor;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
|
||||||
|
namespace BTCPayServer.Views.UIStoreMembership;
|
||||||
|
|
||||||
|
public class SubscriptionsViewModel
|
||||||
|
{
|
||||||
|
public SubscriptionsViewModel()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionsViewModel(OfferingData offeringData)
|
||||||
|
{
|
||||||
|
Currency = offeringData.App.StoreData.GetStoreBlob().DefaultCurrency;
|
||||||
|
}
|
||||||
|
public class PlanViewModel
|
||||||
|
{
|
||||||
|
public PlanData Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MemberViewModel
|
||||||
|
{
|
||||||
|
public SubscriberData Data { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public SubscriptionSection Section { get; set; }
|
||||||
|
public List<PlanViewModel> Plans { get; set; } = new();
|
||||||
|
public List<MemberViewModel> Subscribers { get; set; } = new();
|
||||||
|
public bool TooMuchSubscribers { get; set; }
|
||||||
|
public string Currency { get; set; }
|
||||||
|
|
||||||
|
public int TotalPlans { get; set; }
|
||||||
|
|
||||||
|
public int TotalSubscribers { get; set; }
|
||||||
|
public string TotalMonthlyRevenue { get; set; }
|
||||||
|
public record SelectablePlan(string Name, string Id, bool HasTrial);
|
||||||
|
public List<SelectablePlan> SelectablePlans { get; set; }
|
||||||
|
|
||||||
|
public bool EmailConfigured { get; set; }
|
||||||
|
|
||||||
|
public class EmailRule(EmailRuleData data)
|
||||||
|
{
|
||||||
|
public EmailTriggerViewModel TriggerViewModel { get; set; }
|
||||||
|
public EmailRuleData Data { get; set; } = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<EmailRule> EmailRules { get; set; }
|
||||||
|
public List<EmailTriggerViewModel> AvailableTriggers { get; set; }
|
||||||
|
public int PaymentRemindersDays { get; set; }
|
||||||
|
public string SearchTerm { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
@using BTCPayServer.Views.Stores
|
||||||
|
@using BTCPayServer.Views.UIStoreMembership
|
||||||
|
@using BTCPayServer.Data.Subscriptions
|
||||||
|
@using BTCPayServer.Views.Apps
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
@@ -20,10 +20,10 @@ public static class WebhookExtensions
|
|||||||
{
|
{
|
||||||
foreach(var trigger in viewModels)
|
foreach(var trigger in viewModels)
|
||||||
{
|
{
|
||||||
var webhookType = trigger.Type;
|
var webhookType = trigger.Trigger;
|
||||||
if (trigger.Type.StartsWith("WH-"))
|
if (trigger.Trigger.StartsWith("WH-"))
|
||||||
throw new ArgumentException("Webhook type cannot start with WH-");
|
throw new ArgumentException("Webhook type cannot start with WH-");
|
||||||
trigger.Type = EmailRuleData.GetWebhookTriggerName(trigger.Type);
|
trigger.Trigger = EmailRuleData.GetWebhookTriggerName(trigger.Trigger);
|
||||||
services.AddSingleton(new AvailableWebhookViewModel(webhookType, trigger.Description));
|
services.AddSingleton(new AvailableWebhookViewModel(webhookType, trigger.Description));
|
||||||
services.AddSingleton(trigger);
|
services.AddSingleton(trigger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = PendingTransactionTriggerProvider.PendingTransactionCreated,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionCreated,
|
||||||
Description = "Pending Transaction - Created",
|
Description = "Pending Transaction - Created",
|
||||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
|
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
|
||||||
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
|
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
|
||||||
@@ -79,7 +79,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
|
||||||
Description = "Pending Transaction - Signature Collected",
|
Description = "Pending Transaction - Signature Collected",
|
||||||
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
|
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
|
||||||
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
|
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
|
||||||
@@ -87,7 +87,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
|
||||||
Description = "Pending Transaction - Broadcast",
|
Description = "Pending Transaction - Broadcast",
|
||||||
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
|
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
|
||||||
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
|
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
|
||||||
@@ -95,7 +95,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = PendingTransactionTriggerProvider.PendingTransactionCancelled,
|
Trigger = PendingTransactionTriggerProvider.PendingTransactionCancelled,
|
||||||
Description = "Pending Transaction - Cancelled",
|
Description = "Pending Transaction - Cancelled",
|
||||||
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
|
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
|
||||||
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
|
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
|
||||||
@@ -128,7 +128,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PaymentRequestCreated,
|
Trigger = WebhookEventType.PaymentRequestCreated,
|
||||||
Description = "Payment Request - Created",
|
Description = "Payment Request - Created",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} created",
|
SubjectExample = "Payment Request {PaymentRequest.Id} created",
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
|
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
|
||||||
@@ -137,7 +137,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PaymentRequestUpdated,
|
Trigger = WebhookEventType.PaymentRequestUpdated,
|
||||||
Description = "Payment Request - Updated",
|
Description = "Payment Request - Updated",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} updated",
|
SubjectExample = "Payment Request {PaymentRequest.Id} updated",
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
|
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
|
||||||
@@ -146,7 +146,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PaymentRequestArchived,
|
Trigger = WebhookEventType.PaymentRequestArchived,
|
||||||
Description = "Payment Request - Archived",
|
Description = "Payment Request - Archived",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} archived",
|
SubjectExample = "Payment Request {PaymentRequest.Id} archived",
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
|
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
|
||||||
@@ -155,7 +155,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PaymentRequestStatusChanged,
|
Trigger = WebhookEventType.PaymentRequestStatusChanged,
|
||||||
Description = "Payment Request - Status Changed",
|
Description = "Payment Request - Status Changed",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
|
SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
|
||||||
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
|
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
|
||||||
@@ -164,7 +164,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PaymentRequestCompleted,
|
Trigger = WebhookEventType.PaymentRequestCompleted,
|
||||||
Description = "Payment Request - Completed",
|
Description = "Payment Request - Completed",
|
||||||
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
|
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
|
||||||
BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
|
BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
|
||||||
@@ -190,7 +190,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PayoutCreated,
|
Trigger = WebhookEventType.PayoutCreated,
|
||||||
Description = "Payout - Created",
|
Description = "Payout - Created",
|
||||||
SubjectExample = "Payout {Payout.Id} created",
|
SubjectExample = "Payout {Payout.Id} created",
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
|
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
|
||||||
@@ -198,7 +198,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PayoutApproved,
|
Trigger = WebhookEventType.PayoutApproved,
|
||||||
Description = "Payout - Approved",
|
Description = "Payout - Approved",
|
||||||
SubjectExample = "Payout {Payout.Id} approved",
|
SubjectExample = "Payout {Payout.Id} approved",
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
|
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
|
||||||
@@ -206,7 +206,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.PayoutUpdated,
|
Trigger = WebhookEventType.PayoutUpdated,
|
||||||
Description = "Payout - Updated",
|
Description = "Payout - Updated",
|
||||||
SubjectExample = "Payout {Payout.Id} updated",
|
SubjectExample = "Payout {Payout.Id} updated",
|
||||||
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
|
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
|
||||||
@@ -236,7 +236,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
{
|
{
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceCreated,
|
Trigger = WebhookEventType.InvoiceCreated,
|
||||||
Description = "Invoice - Created",
|
Description = "Invoice - Created",
|
||||||
SubjectExample = "Invoice {Invoice.Id} created",
|
SubjectExample = "Invoice {Invoice.Id} created",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
|
||||||
@@ -245,7 +245,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceReceivedPayment,
|
Trigger = WebhookEventType.InvoiceReceivedPayment,
|
||||||
Description = "Invoice - Received Payment",
|
Description = "Invoice - Received Payment",
|
||||||
SubjectExample = "Invoice {Invoice.Id} received payment",
|
SubjectExample = "Invoice {Invoice.Id} received payment",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
|
||||||
@@ -254,7 +254,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceProcessing,
|
Trigger = WebhookEventType.InvoiceProcessing,
|
||||||
Description = "Invoice - Is Processing",
|
Description = "Invoice - Is Processing",
|
||||||
SubjectExample = "Invoice {Invoice.Id} processing",
|
SubjectExample = "Invoice {Invoice.Id} processing",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
|
||||||
@@ -263,7 +263,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceExpired,
|
Trigger = WebhookEventType.InvoiceExpired,
|
||||||
Description = "Invoice - Expired",
|
Description = "Invoice - Expired",
|
||||||
SubjectExample = "Invoice {Invoice.Id} expired",
|
SubjectExample = "Invoice {Invoice.Id} expired",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
|
||||||
@@ -272,7 +272,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceSettled,
|
Trigger = WebhookEventType.InvoiceSettled,
|
||||||
Description = "Invoice - Is Settled",
|
Description = "Invoice - Is Settled",
|
||||||
SubjectExample = "Invoice {Invoice.Id} settled",
|
SubjectExample = "Invoice {Invoice.Id} settled",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
|
||||||
@@ -281,7 +281,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceInvalid,
|
Trigger = WebhookEventType.InvoiceInvalid,
|
||||||
Description = "Invoice - Became Invalid",
|
Description = "Invoice - Became Invalid",
|
||||||
SubjectExample = "Invoice {Invoice.Id} invalid",
|
SubjectExample = "Invoice {Invoice.Id} invalid",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
|
||||||
@@ -290,7 +290,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoicePaymentSettled,
|
Trigger = WebhookEventType.InvoicePaymentSettled,
|
||||||
Description = "Invoice - Payment Settled",
|
Description = "Invoice - Payment Settled",
|
||||||
SubjectExample = "Invoice {Invoice.Id} payment settled",
|
SubjectExample = "Invoice {Invoice.Id} payment settled",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
|
||||||
@@ -299,7 +299,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoiceExpiredPaidPartial,
|
Trigger = WebhookEventType.InvoiceExpiredPaidPartial,
|
||||||
Description = "Invoice - Expired Paid Partial",
|
Description = "Invoice - Expired Paid Partial",
|
||||||
SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
|
SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
|
||||||
@@ -308,7 +308,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
|
|||||||
},
|
},
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Type = WebhookEventType.InvoicePaidAfterExpiration,
|
Trigger = WebhookEventType.InvoicePaidAfterExpiration,
|
||||||
Description = "Invoice - Expired Paid Late",
|
Description = "Invoice - Expired Paid Late",
|
||||||
SubjectExample = "Invoice {Invoice.Id} paid after expiration",
|
SubjectExample = "Invoice {Invoice.Id} paid after expiration",
|
||||||
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
|
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",
|
||||||
|
|||||||
@@ -447,7 +447,8 @@ retry:
|
|||||||
goto retry;
|
goto retry;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UpdateOrCreateApp(AppData app)
|
public Task UpdateOrCreateApp(AppData app) => UpdateOrCreateApp(app, true);
|
||||||
|
public async Task UpdateOrCreateApp(AppData app, bool sendEvents)
|
||||||
{
|
{
|
||||||
await using var ctx = _ContextFactory.CreateContext();
|
await using var ctx = _ContextFactory.CreateContext();
|
||||||
var newApp = string.IsNullOrEmpty(app.Id);
|
var newApp = string.IsNullOrEmpty(app.Id);
|
||||||
@@ -465,11 +466,14 @@ retry:
|
|||||||
ctx.Entry(app).Property(data => data.AppType).IsModified = false;
|
ctx.Entry(app).Property(data => data.AppType).IsModified = false;
|
||||||
}
|
}
|
||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
|
if (sendEvents)
|
||||||
|
{
|
||||||
if (newApp)
|
if (newApp)
|
||||||
_eventAggregator.Publish(new AppEvent.Created(app));
|
_eventAggregator.Publish(new AppEvent.Created(app));
|
||||||
else
|
else
|
||||||
_eventAggregator.Publish(new AppEvent.Updated(app));
|
_eventAggregator.Publish(new AppEvent.Updated(app));
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static bool TryParseJson(string json, [MaybeNullWhen(false)] out JObject result)
|
private static bool TryParseJson(string json, [MaybeNullWhen(false)] out JObject result)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,6 +9,15 @@ namespace BTCPayServer.Services.Apps
|
|||||||
{
|
{
|
||||||
public abstract class AppBaseType
|
public abstract class AppBaseType
|
||||||
{
|
{
|
||||||
|
public AppBaseType()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public AppBaseType(string typeName)
|
||||||
|
{
|
||||||
|
Type = typeName;
|
||||||
|
}
|
||||||
public string Description { get; set; } = string.Empty;
|
public string Description { get; set; } = string.Empty;
|
||||||
public string Type { get; set; } = string.Empty;
|
public string Type { get; set; } = string.Empty;
|
||||||
public abstract Task<object?> GetInfo(AppData appData);
|
public abstract Task<object?> GetInfo(AppData appData);
|
||||||
|
|||||||
@@ -296,6 +296,7 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
public string Currency { get; set; }
|
public string Currency { get; set; }
|
||||||
[JsonConverter(typeof(PaymentMethodIdJsonConverter))]
|
[JsonConverter(typeof(PaymentMethodIdJsonConverter))]
|
||||||
public PaymentMethodId DefaultPaymentMethod { get; set; }
|
public PaymentMethodId DefaultPaymentMethod { get; set; }
|
||||||
|
|
||||||
[JsonExtensionData]
|
[JsonExtensionData]
|
||||||
public IDictionary<string, JToken> AdditionalData { get; set; }
|
public IDictionary<string, JToken> AdditionalData { get; set; }
|
||||||
|
|
||||||
@@ -763,9 +764,6 @@ namespace BTCPayServer.Services.Invoices
|
|||||||
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
|
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum InvoiceStatusLegacy
|
|
||||||
{
|
|
||||||
}
|
|
||||||
public static class InvoiceStatusLegacyExtensions
|
public static class InvoiceStatusLegacyExtensions
|
||||||
{
|
{
|
||||||
public static string ToLegacyStatusString(this InvoiceStatus status) =>
|
public static string ToLegacyStatusString(this InvoiceStatus status) =>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<li class="nav-item" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
|
<li class="nav-item" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
|
||||||
<span class="nav-link">
|
<span class="nav-link">
|
||||||
<vc:icon symbol="nav-pointofsale" />
|
<vc:icon symbol="nav-pointofsale" />
|
||||||
<span>Point of Sale</span>
|
<span text-translate="true">Point of Sale</span>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
|
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
|
||||||
<dt v-t="'total_fiat'"></dt>
|
<dt v-t="'total_fiat'"></dt>
|
||||||
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd>
|
<dd id="total_fiat" class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded">
|
<div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded">
|
||||||
<dt v-t="'tax_included'"></dt>
|
<dt v-t="'tax_included'"></dt>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace BTCPayServer.Views.Stores
|
|||||||
Plugins,
|
Plugins,
|
||||||
Webhooks,
|
Webhooks,
|
||||||
PullPayments,
|
PullPayments,
|
||||||
|
Subscriptions,
|
||||||
Reporting,
|
Reporting,
|
||||||
Payouts,
|
Payouts,
|
||||||
PayoutProcessors,
|
PayoutProcessors,
|
||||||
|
|||||||
@@ -206,7 +206,7 @@
|
|||||||
"securitySchemes": {
|
"securitySchemes": {
|
||||||
"API_Key": {
|
"API_Key": {
|
||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
|
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canviewmembership`: View your membership\n* `btcpay.store.canmodifymembership`: Modify your membership\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
|
||||||
"name": "Authorization",
|
"name": "Authorization",
|
||||||
"in": "header"
|
"in": "header"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user