What changed, and why it matters
This is a large feature commit adding a new Subscriptions system to BTCPay Server. It introduces new database tables, models, UI pages, API permissions, webhooks, and business logic for managing subscription plans, subscribers, customer identities, checkouts, and portal sessions. There is no explicit security fix or vulnerability disclosure in the commit message or diff. The change is a feature addition, not a documented security patch.
Treat this as a normal feature commit. Review the new subscription controllers and permission checks for authorization consistency, validate raw SQL helpers for injection safety, and ensure cascade delete behaviors match intended data retention policies. No immediate security patch action is indicated by the provided materials.
Security signals we found
New authorization permissions added (CanViewMembership, CanModifyMembership)
New database entities with foreign keys and cascade/delete behaviors
Raw SQL via Dapper used for customer/subscriber upserts
New webhook event types for subscription lifecycle
New customer identity and subscription checkout data models
Large feature commit with many new controllers and views
Evidence from the diff
The commit adds a complete Subscriptions plugin/feature to BTCPay Server. It includes new EF Core entities (CustomerData, CustomerIdentityData, OfferingData, PlanData, SubscriberData, PlanCheckoutData, PortalSessionData, EntitlementData, etc.), a new database migration, Dapper-based raw SQL helpers, new permissions (CanViewMembership/CanModifyMembership), webhook event types, MVC controllers, Razor views, and test coverage. The diff shows schema changes, authorization policy updates, and new checkout/subscriber lifecycle logic. No security advisory, CVE, or vendor security statement is present in the provided materials.
Changed components
BTCPayServer.Data (ApplicationDbContext, migrations, subscription entities)BTCPayServer.Client (permissions, subscription models, webhook events)BTCPayServer (subscription plugin controllers, views, services)BTCPayServer.Tests (SubscriptionTests)Database schema (customers, subs_* tables, email_rules.offering_id)Inspect captured patch +9736 / −361
diff --git a/.editorconfig b/.editorconfig
index ceed514..3e21e72 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -58,6 +58,7 @@ dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggest
# ReSharper properties
resharper_apply_auto_detected_rules = false
+resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
resharper_autodetect_indent_settings = true
resharper_cpp_insert_final_newline = true
resharper_csharp_insert_final_newline = true
diff --git a/BTCPayServer.Client/Models/CustomerModel.cs b/BTCPayServer.Client/Models/CustomerModel.cs
new file mode 100644
index 0000000..0f99927
--- /dev/null
+++ b/BTCPayServer.Client/Models/CustomerModel.cs
@@ -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; }
+}
diff --git a/BTCPayServer.Client/Models/InvoiceData.cs b/BTCPayServer.Client/Models/InvoiceData.cs
index 175638d..6e40774 100644
--- a/BTCPayServer.Client/Models/InvoiceData.cs
+++ b/BTCPayServer.Client/Models/InvoiceData.cs
@@ -21,7 +21,6 @@ namespace BTCPayServer.Client.Models
public JObject Metadata { get; set; }
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
public ReceiptOptions Receipt { get; set; } = new ReceiptOptions();
-
public class ReceiptOptions
{
public bool? Enabled { get; set; }
diff --git a/BTCPayServer.Client/Models/OfferingModel.cs b/BTCPayServer.Client/Models/OfferingModel.cs
new file mode 100644
index 0000000..85fb701
--- /dev/null
+++ b/BTCPayServer.Client/Models/OfferingModel.cs
@@ -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; }
+
+}
diff --git a/BTCPayServer.Client/Models/SubscriberModel.cs b/BTCPayServer.Client/Models/SubscriberModel.cs
new file mode 100644
index 0000000..aca3b7b
--- /dev/null
+++ b/BTCPayServer.Client/Models/SubscriberModel.cs
@@ -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; }
+}
diff --git a/BTCPayServer.Client/Models/SubscriptionPlanModel.cs b/BTCPayServer.Client/Models/SubscriptionPlanModel.cs
new file mode 100644
index 0000000..3877189
--- /dev/null
+++ b/BTCPayServer.Client/Models/SubscriptionPlanModel.cs
@@ -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; }
+}
diff --git a/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs b/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
new file mode 100644
index 0000000..009506a
--- /dev/null
+++ b/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
@@ -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;
+ }
+}
diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs
index d538207..8a1fc9f 100644
--- a/BTCPayServer.Client/Permissions.cs
+++ b/BTCPayServer.Client/Permissions.cs
@@ -40,6 +40,8 @@ namespace BTCPayServer.Client
public const string CanViewPayouts = "btcpay.store.canviewpayouts";
public const string CanCreatePullPayments = "btcpay.store.cancreatepullpayments";
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 Unrestricted = "unrestricted";
public static IEnumerable<string> AllPolicies
@@ -74,6 +76,8 @@ namespace BTCPayServer.Client
yield return CanArchivePullPayments;
yield return CanCreatePullPayments;
yield return CanViewPullPayments;
+ yield return CanViewMembership;
+ yield return CanModifyMembership;
yield return CanCreateNonApprovedPullPayments;
yield return CanManageUsers;
yield return CanManagePayouts;
@@ -249,7 +253,7 @@ namespace BTCPayServer.Client
}
public static ReadOnlyDictionary<string, HashSet<string>> PolicyMap { get; private set; }
-
+
private static ReadOnlyDictionary<string, HashSet<string>> Init()
{
@@ -261,6 +265,7 @@ namespace BTCPayServer.Client
Policies.CanModifyWebhooks,
Policies.CanModifyPaymentRequests,
Policies.CanManagePayouts,
+ Policies.CanModifyMembership,
Policies.CanUseLightningNodeInStore);
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
@@ -269,6 +274,7 @@ namespace BTCPayServer.Client
PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments);
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
+ PolicyHasChild(policyMap,Policies.CanModifyMembership, Policies.CanViewMembership);
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
diff --git a/BTCPayServer.Data/ApplicationDbContext.cs b/BTCPayServer.Data/ApplicationDbContext.cs
index 8de6923..d169a44 100644
--- a/BTCPayServer.Data/ApplicationDbContext.cs
+++ b/BTCPayServer.Data/ApplicationDbContext.cs
@@ -1,12 +1,7 @@
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.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
-using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data
{
@@ -20,7 +15,7 @@ namespace BTCPayServer.Data
return new ApplicationDbContext(builder.Options);
}
}
- public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
+ public partial class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
@@ -64,6 +59,7 @@ namespace BTCPayServer.Data
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; }
+ public DbSet<CustomerData> Customers { get; set; }
public DbSet<EmailRuleData> EmailRules { get; set; }
@@ -72,6 +68,10 @@ namespace BTCPayServer.Data
base.OnModelCreating(builder);
// 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);
ApplicationUser.OnModelCreating(builder, Database);
AddressInvoiceData.OnModelCreating(builder);
diff --git a/BTCPayServer.Data/BTCPayServer.Data.csproj b/BTCPayServer.Data/BTCPayServer.Data.csproj
index 7cc74c1..466b323 100644
--- a/BTCPayServer.Data/BTCPayServer.Data.csproj
+++ b/BTCPayServer.Data/BTCPayServer.Data.csproj
@@ -9,6 +9,7 @@
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="NBitcoin.Altcoins" Version="5.0.0" />
+ <PackageReference Include="Dapper" Version="2.1.35" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />
diff --git a/BTCPayServer.Data/CustomerSelector.cs b/BTCPayServer.Data/CustomerSelector.cs
new file mode 100644
index 0000000..9c3e7bf
--- /dev/null
+++ b/BTCPayServer.Data/CustomerSelector.cs
@@ -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;
+}
diff --git a/BTCPayServer.Data/Data/BaseEntityData.cs b/BTCPayServer.Data/Data/BaseEntityData.cs
index ba77957..805db76 100644
--- a/BTCPayServer.Data/Data/BaseEntityData.cs
+++ b/BTCPayServer.Data/Data/BaseEntityData.cs
@@ -3,6 +3,9 @@
using System;
using System.ComponentModel.DataAnnotations.Schema;
using System.IO;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
+using Dapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
diff --git a/BTCPayServer.Data/Data/CustomerData.cs b/BTCPayServer.Data/Data/CustomerData.cs
new file mode 100644
index 0000000..250c076
--- /dev/null
+++ b/BTCPayServer.Data/Data/CustomerData.cs
@@ -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();
+}
diff --git a/BTCPayServer.Data/Data/CustomerIdentityData.cs b/BTCPayServer.Data/Data/CustomerIdentityData.cs
new file mode 100644
index 0000000..890e8aa
--- /dev/null
+++ b/BTCPayServer.Data/Data/CustomerIdentityData.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/EmailRuleData.cs b/BTCPayServer.Data/Data/EmailRuleData.cs
index c874753..b0f37f0 100644
--- a/BTCPayServer.Data/Data/EmailRuleData.cs
+++ b/BTCPayServer.Data/Data/EmailRuleData.cs
@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
+using BTCPayServer.Data.Subscriptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq;
@@ -16,6 +17,11 @@ public class EmailRuleData : BaseEntityData
public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}";
[Column("store_id")]
public string? StoreId { get; set; }
+ [Column("offering_id")]
+ public string? OfferingId { get; set; }
+
+ [ForeignKey(nameof(OfferingId))]
+ public OfferingData? Offering { get; set; }
[Key]
public long Id { get; set; }
@@ -53,6 +59,7 @@ public class EmailRuleData : BaseEntityData
var b = builder.Entity<EmailRuleData>();
BaseEntityData.OnModelCreateBase(b, builder, databaseFacade);
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.HasIndex(o => o.StoreId);
}
diff --git a/BTCPayServer.Data/Data/InvoiceData.cs b/BTCPayServer.Data/Data/InvoiceData.cs
index 87768d3..e37ba69 100644
--- a/BTCPayServer.Data/Data/InvoiceData.cs
+++ b/BTCPayServer.Data/Data/InvoiceData.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq;
@@ -36,6 +37,11 @@ namespace BTCPayServer.Data
[Timestamp]
// With this, update of InvoiceData will fail if the row was modified by another process
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)
{
builder.Entity<InvoiceData>()
diff --git a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContext.Subscriptions.cs b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContext.Subscriptions.cs
new file mode 100644
index 0000000..b86c87f
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContext.Subscriptions.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
new file mode 100644
index 0000000..4a787a7
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
@@ -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();
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/EntitlementData.cs b/BTCPayServer.Data/Data/Subscriptions/EntitlementData.cs
new file mode 100644
index 0000000..94e9239
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/EntitlementData.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/OfferingData.cs b/BTCPayServer.Data/Data/Subscriptions/OfferingData.cs
new file mode 100644
index 0000000..d96c268
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/OfferingData.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
new file mode 100644
index 0000000..8b173d5
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanChangeData.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
new file mode 100644
index 0000000..f42386e
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
@@ -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;
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanData.cs
new file mode 100644
index 0000000..a69ed1f
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanData.cs
@@ -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();
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanEntitlementData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanEntitlementData.cs
new file mode 100644
index 0000000..eb875e8
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanEntitlementData.cs
@@ -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);
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
new file mode 100644
index 0000000..f987b03
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
@@ -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)
+ );
+
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberCredit.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberCredit.cs
new file mode 100644
index 0000000..34133ca
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberCredit.cs
@@ -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);
+
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberCreditHistoryData.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberCreditHistoryData.cs
new file mode 100644
index 0000000..bf30c84
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberCreditHistoryData.cs
@@ -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();
+ }
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
new file mode 100644
index 0000000..4535faf
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
@@ -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);
+}
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberInvoiceData.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberInvoiceData.cs
new file mode 100644
index 0000000..4467371
--- /dev/null
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberInvoiceData.cs
@@ -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 });
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/20251028061727_subs.cs b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
new file mode 100644
index 0000000..744cac6
--- /dev/null
+++ b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
@@ -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");
+ }
+ }
+}
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index 9c6f6eb..c0f857d 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -194,6 +194,77 @@ namespace BTCPayServer.Migrations
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 =>
{
b.Property<long>("Id")
@@ -231,6 +302,10 @@ namespace BTCPayServer.Migrations
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
+ b.Property<string>("OfferingId")
+ .HasColumnType("text")
+ .HasColumnName("offering_id");
+
b.Property<string>("StoreId")
.HasColumnType("text")
.HasColumnName("store_id");
@@ -252,6 +327,8 @@ namespace BTCPayServer.Migrations
b.HasKey("Id");
+ b.HasIndex("OfferingId");
+
b.HasIndex("StoreId");
b.ToTable("email_rules");
@@ -843,109 +920,694 @@ namespace BTCPayServer.Migrations
b.Property<string>("DerivationStrategies")
.HasColumnType("JSONB");
- b.Property<string>("DerivationStrategy")
- .HasColumnType("text");
+ b.Property<string>("DerivationStrategy")
+ .HasColumnType("text");
+
+ b.Property<int>("SpeedPolicy")
+ .HasColumnType("integer");
+
+ b.Property<string>("StoreBlob")
+ .HasColumnType("JSONB");
+
+ b.Property<byte[]>("StoreCertificate")
+ .HasColumnType("bytea");
+
+ b.Property<string>("StoreName")
+ .HasColumnType("text");
+
+ b.Property<string>("StoreWebsite")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("Stores");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
+ {
+ b.Property<string>("Id")
+ .HasColumnType("text");
+
+ b.Property<List<string>>("Permissions")
+ .HasColumnType("text[]");
+
+ b.Property<string>("Role")
+ .HasColumnType("text");
+
+ b.Property<string>("StoreDataId")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("StoreDataId", "Role")
+ .IsUnique();
+
+ b.ToTable("StoreRoles");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
+ {
+ b.Property<string>("StoreId")
+ .HasColumnType("text");
+
+ b.Property<string>("Name")
+ .HasColumnType("text");
+
+ b.Property<string>("Value")
+ .HasColumnType("JSONB");
+
+ b.HasKey("StoreId", "Name");
+
+ b.ToTable("StoreSettings");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b =>
+ {
+ b.Property<string>("StoreId")
+ .HasColumnType("text");
+
+ b.Property<string>("WebhookId")
+ .HasColumnType("character varying(25)");
+
+ b.HasKey("StoreId", "WebhookId");
+
+ b.HasIndex("StoreId")
+ .IsUnique();
+
+ b.HasIndex("WebhookId")
+ .IsUnique();
+
+ b.ToTable("StoreWebhooks");
+ });
+
+ modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
+ {
+ b.Property<string>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text");
+
+ b.Property<string>("ApplicationUserId")
+ .HasColumnType("text");
+
+ b.Property<string>("FileName")
+ .HasColumnType("text");
+
+ b.Property<string>("StorageFileName")
+ .HasColumnType("text");
+
+ b.Property<DateTime>("Timestamp")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApplicationUserId");
+
+ 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<int>("SpeedPolicy")
- .HasColumnType("integer");
+ b.Property<DateTimeOffset>("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamptz")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
- b.Property<string>("StoreBlob")
- .HasColumnType("JSONB");
+ b.Property<string>("CustomerId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("customer_id");
- b.Property<byte[]>("StoreCertificate")
- .HasColumnType("bytea");
+ b.Property<DateTimeOffset?>("GracePeriodEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("grace_period_end");
- b.Property<string>("StoreName")
- .HasColumnType("text");
+ b.Property<bool>("IsActive")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("active");
- b.Property<string>("StoreWebsite")
- .HasColumnType("text");
+ b.Property<bool>("IsSuspended")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("suspended");
- b.HasKey("Id");
+ b.Property<string>("Metadata")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("jsonb")
+ .HasColumnName("metadata")
+ .HasDefaultValueSql("'{}'::jsonb");
- b.ToTable("Stores");
- });
+ b.Property<string>("NewPlanId")
+ .HasColumnType("text")
+ .HasColumnName("new_plan_id");
- modelBuilder.Entity("BTCPayServer.Data.StoreRole", b =>
- {
- b.Property<string>("Id")
- .HasColumnType("text");
+ b.Property<string>("OfferingId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("offering_id");
- b.Property<List<string>>("Permissions")
- .HasColumnType("text[]");
+ b.Property<bool>("OptimisticActivation")
+ .HasColumnType("boolean")
+ .HasColumnName("optimistic_activation");
- b.Property<string>("Role")
- .HasColumnType("text");
+ b.Property<decimal?>("PaidAmount")
+ .HasColumnType("numeric")
+ .HasColumnName("paid_amount");
- b.Property<string>("StoreDataId")
- .HasColumnType("text");
+ b.Property<bool>("PaymentReminded")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("payment_reminded");
- b.HasKey("Id");
+ b.Property<int?>("PaymentReminderDays")
+ .HasColumnType("integer")
+ .HasColumnName("payment_reminder_days");
- b.HasIndex("StoreDataId", "Role")
- .IsUnique();
+ b.Property<DateTimeOffset?>("PeriodEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("period_end");
- b.ToTable("StoreRoles");
- });
+ b.Property<string>("Phase")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasColumnName("phase")
+ .HasDefaultValueSql("'Expired'::TEXT");
- modelBuilder.Entity("BTCPayServer.Data.StoreSettingData", b =>
- {
- b.Property<string>("StoreId")
- .HasColumnType("text");
+ b.Property<string>("PlanId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("plan_id");
- b.Property<string>("Name")
- .HasColumnType("text");
+ b.Property<DateTimeOffset>("PlanStarted")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("plan_started")
+ .HasDefaultValueSql("now()");
- b.Property<string>("Value")
- .HasColumnType("JSONB");
+ b.Property<string>("SuspensionReason")
+ .HasColumnType("text")
+ .HasColumnName("suspension_reason");
- b.HasKey("StoreId", "Name");
+ b.Property<bool>("TestAccount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(false)
+ .HasColumnName("test_account");
- b.ToTable("StoreSettings");
- });
+ b.Property<DateTimeOffset?>("TrialEnd")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("trial_end");
- modelBuilder.Entity("BTCPayServer.Data.StoreWebhookData", b =>
- {
- b.Property<string>("StoreId")
- .HasColumnType("text");
+ b.HasKey("Id");
- b.Property<string>("WebhookId")
- .HasColumnType("character varying(25)");
+ b.HasIndex("CustomerId");
- b.HasKey("StoreId", "WebhookId");
+ b.HasIndex("NewPlanId");
- b.HasIndex("StoreId")
- .IsUnique();
+ b.HasIndex("PlanId");
- b.HasIndex("WebhookId")
+ b.HasIndex("OfferingId", "CustomerId")
.IsUnique();
- b.ToTable("StoreWebhooks");
+ b.ToTable("subs_subscribers");
});
- modelBuilder.Entity("BTCPayServer.Data.StoredFile", b =>
+ modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberInvoiceData", b =>
{
- b.Property<string>("Id")
- .ValueGeneratedOnAdd()
- .HasColumnType("text");
-
- b.Property<string>("ApplicationUserId")
- .HasColumnType("text");
+ b.Property<long>("SubscriberId")
+ .HasColumnType("bigint")
+ .HasColumnName("subscriber_id");
- b.Property<string>("FileName")
- .HasColumnType("text");
+ b.Property<string>("InvoiceId")
+ .HasColumnType("text")
+ .HasColumnName("invoice_id");
- b.Property<string>("StorageFileName")
- .HasColumnType("text");
+ b.Property<DateTimeOffset>("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamptz")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("now()");
- b.Property<DateTime>("Timestamp")
- .HasColumnType("timestamp with time zone");
+ b.HasKey("SubscriberId", "InvoiceId");
- b.HasKey("Id");
+ b.HasIndex("InvoiceId");
- b.HasIndex("ApplicationUserId");
+ b.HasIndex("SubscriberId", "CreatedAt");
- b.ToTable("Files");
+ b.ToTable("subscriber_invoices");
});
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
@@ -1303,13 +1965,42 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData");
});
+ modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
+ {
+ b.HasOne("BTCPayServer.Data.StoreData", "Store")
+ .WithMany()
+ .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);
+
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
+ b.Navigation("Offering");
+
b.Navigation("Store");
});
@@ -1539,6 +2230,188 @@ namespace BTCPayServer.Migrations
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 =>
{
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
@@ -1683,6 +2556,11 @@ namespace BTCPayServer.Migrations
b.Navigation("UserStores");
});
+ modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
+ {
+ b.Navigation("CustomerIdentities");
+ });
+
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{
b.Navigation("AddressInvoices");
@@ -1735,6 +2613,27 @@ namespace BTCPayServer.Migrations
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 =>
{
b.Navigation("WalletTransactions");
diff --git a/BTCPayServer.Data/ValueGenerators.cs b/BTCPayServer.Data/ValueGenerators.cs
new file mode 100644
index 0000000..e863a32
--- /dev/null
+++ b/BTCPayServer.Data/ValueGenerators.cs
@@ -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);
+}
diff --git a/BTCPayServer.Tests/PMO/InvoiceCheckoutPMO.cs b/BTCPayServer.Tests/PMO/InvoiceCheckoutPMO.cs
new file mode 100644
index 0000000..ce42f4c
--- /dev/null
+++ b/BTCPayServer.Tests/PMO/InvoiceCheckoutPMO.cs
@@ -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");
+}
diff --git a/BTCPayServer.Tests/POSTests.cs b/BTCPayServer.Tests/POSTests.cs
index cfd182d..67f7e1b 100644
--- a/BTCPayServer.Tests/POSTests.cs
+++ b/BTCPayServer.Tests/POSTests.cs
@@ -15,6 +15,7 @@ using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps;
+using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Stores;
using LNURL;
using Microsoft.AspNetCore.Mvc;
@@ -720,12 +721,8 @@ goodies:
await s.GoToInvoiceCheckout();
}
- private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
- {
- var el = await s.Page.WaitForSelectorAsync("#AmountDue");
- var content = await el!.TextContentAsync();
- Assert.Equal(expectedAmount.NormalizeWhitespaces(), content.NormalizeWhitespaces());
- }
+ private static Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
+ => new InvoiceCheckoutPMO(s).AssertContent(new() { AmountDue = expectedAmount });
[Fact]
[Trait("Playwright", "Playwright")]
diff --git a/BTCPayServer.Tests/PlaywrightTester.cs b/BTCPayServer.Tests/PlaywrightTester.cs
index 3335a0c..bb027af 100644
--- a/BTCPayServer.Tests/PlaywrightTester.cs
+++ b/BTCPayServer.Tests/PlaywrightTester.cs
@@ -569,7 +569,7 @@ namespace BTCPayServer.Tests
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)
{
@@ -585,6 +585,10 @@ namespace BTCPayServer.Tests
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
else
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>
diff --git a/BTCPayServer.Tests/ServerTester.cs b/BTCPayServer.Tests/ServerTester.cs
index 4bb9db0..9d0bd51 100644
--- a/BTCPayServer.Tests/ServerTester.cs
+++ b/BTCPayServer.Tests/ServerTester.cs
@@ -198,7 +198,7 @@ namespace BTCPayServer.Tests
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
{
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)
tcs.TrySetResult(evt);
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
new file mode 100644
index 0000000..90a6c7f
--- /dev/null
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -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]);
+ }
+ }
+ }
+ }
+}
diff --git a/BTCPayServer.Tests/TestAccount.cs b/BTCPayServer.Tests/TestAccount.cs
index caa293e..d1f5593 100644
--- a/BTCPayServer.Tests/TestAccount.cs
+++ b/BTCPayServer.Tests/TestAccount.cs
@@ -171,14 +171,14 @@ namespace BTCPayServer.Tests
return controller;
}
- public async Task CreateStoreAsync()
+ public async Task CreateStoreAsync(string preferredExchange = "CoinGecko")
{
if (UserId is null)
{
await RegisterAsync();
}
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;
parent.Stores.Add(StoreId);
}
@@ -225,7 +225,7 @@ namespace BTCPayServer.Tests
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>();
RegisterDetails = new RegisterViewModel()
diff --git a/BTCPayServer.Tests/UtilitiesTests.cs b/BTCPayServer.Tests/UtilitiesTests.cs
index 174315f..6a06f70 100644
--- a/BTCPayServer.Tests/UtilitiesTests.cs
+++ b/BTCPayServer.Tests/UtilitiesTests.cs
@@ -271,9 +271,9 @@ namespace BTCPayServer.Tests
{
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)
{
var k = match.Groups[1].Value;
diff --git a/BTCPayServer/BTCPayServer.csproj b/BTCPayServer/BTCPayServer.csproj
index a612549..2c1f1b0 100644
--- a/BTCPayServer/BTCPayServer.csproj
+++ b/BTCPayServer/BTCPayServer.csproj
@@ -8,7 +8,7 @@
<RunAnalyzersDuringLiveAnalysis>False</RunAnalyzersDuringLiveAnalysis>
<RunAnalyzersDuringBuild>False</RunAnalyzersDuringBuild>
</PropertyGroup>
-
+
<!-- Pre-compiling views should only be done for Release builds without dotnet watch or design time build .-->
<!-- Runtime compiling is only useful for debugging with hot reload of the views -->
<PropertyGroup Condition="'$(RazorCompileOnBuild)'=='' AND ('$(Configuration)' == 'Debug' OR '$(DotNetWatchBuild)' == 'true' OR '$(DesignTimeBuild)' == 'true')">
@@ -57,7 +57,6 @@
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.11" />
<PackageReference Include="CsvHelper" Version="32.0.3" />
- <PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Fido2" Version="3.0.1" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="LNURL" Version="0.0.36" />
diff --git a/BTCPayServer/Controllers/UIManageController.APIKeys.cs b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
index d369bc1..77ba431 100644
--- a/BTCPayServer/Controllers/UIManageController.APIKeys.cs
+++ b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
@@ -538,6 +538,10 @@ namespace BTCPayServer.Controllers
{$"{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 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 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.")},
diff --git a/BTCPayServer/Hosting/BTCPayServerServices.cs b/BTCPayServer/Hosting/BTCPayServerServices.cs
index 2c22a35..7065dd9 100644
--- a/BTCPayServer/Hosting/BTCPayServerServices.cs
+++ b/BTCPayServer/Hosting/BTCPayServerServices.cs
@@ -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<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<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products"));
diff --git a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
index de73873..7f689f9 100644
--- a/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
+++ b/BTCPayServer/Plugins/Emails/Controllers/UIStoreEmailRulesController.cs
@@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.Views;
+using BTCPayServer.Plugins.Subscriptions.Controllers;
using BTCPayServer.Services.Mails;
using Dapper;
using Microsoft.AspNetCore.Authorization;
@@ -53,9 +54,24 @@ public class UIStoreEmailRulesController(
[HttpGet("create")]
[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")]
@@ -64,7 +80,9 @@ public class UIStoreEmailRulesController(
{
await ValidateCondition(model);
if (!ModelState.IsValid)
- return StoreEmailRulesCreate(storeId);
+ return StoreEmailRulesCreate(storeId,
+ model.OfferingId,
+ model.CanChangeTrigger ? null : model.Trigger);
await using var ctx = dbContextFactory.CreateContext();
var c = new EmailRuleData()
@@ -74,6 +92,7 @@ public class UIStoreEmailRulesController(
Body = model.Body,
Subject = model.Subject,
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
+ OfferingId = model.OfferingId,
To = model.ToAsArray()
};
c.SetBTCPayAdditionalData(model.AdditionalData);
@@ -81,18 +100,32 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync();
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 });
}
[HttpGet("{ruleId}/edit")]
[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();
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
if (r is null)
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")]
@@ -116,7 +149,7 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
- return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
+ return GoToStoreEmailRulesList(storeId, model);
}
private async Task ValidateCondition(StoreEmailRuleViewModel model)
@@ -142,7 +175,7 @@ public class UIStoreEmailRulesController(
[HttpPost("{ruleId}/delete")]
[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();
var r = await ctx.EmailRules.GetRule(storeId, ruleId);
@@ -152,6 +185,7 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
}
- return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
+
+ return GoToStoreEmailRulesList(storeId, redirectUrl);
}
}
diff --git a/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs b/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
index 3bcb4e8..1368c09 100644
--- a/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
+++ b/BTCPayServer/Plugins/Emails/LinkGeneratorExtensions.cs
@@ -8,6 +8,23 @@ namespace Microsoft.AspNetCore.Mvc;
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)
=> linkGenerator.GetUriByAction(
action: nameof(UIStoreEmailRulesController.StoreEmailRulesList),
diff --git a/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs b/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
index 2e8a9bc..2523753 100644
--- a/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
+++ b/BTCPayServer/Plugins/Emails/StoreEmailRuleProcessorSender.cs
@@ -17,7 +17,11 @@ public interface ITriggerOwner
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(
TriggerEvent triggerEvent,
diff --git a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
index 6d9a612..02b03a6 100644
--- a/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/EmailTriggerViewModel.cs
@@ -7,7 +7,7 @@ namespace BTCPayServer.Plugins.Emails.Views;
/// </summary>
public class EmailTriggerViewModel
{
- public string Type { get; set; }
+ public string Trigger { get; set; }
public string Description { get; set; }
public string SubjectExample { get; set; }
public string BodyExample { get; set; }
diff --git a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
index 295ca06..1fd81ec 100644
--- a/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
+++ b/BTCPayServer/Plugins/Emails/Views/StoreEmailRuleViewModel.cs
@@ -17,6 +17,7 @@ public class StoreEmailRuleViewModel
if (data is not null)
{
Data = data;
+ OfferingId = data.OfferingId;
AdditionalData = data.GetBTCPayAdditionalData() ?? new();
Trigger = data.Trigger;
Subject = data.Subject;
@@ -46,6 +47,11 @@ public class StoreEmailRuleViewModel
public string To { 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()
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim())
diff --git a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
index 42ee397..7d8475a 100644
--- a/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
+++ b/BTCPayServer/Plugins/Emails/Views/UIStoreEmailRules/StoreEmailRulesManage.cshtml
@@ -31,17 +31,38 @@
</div>
<partial name="_StatusMessage" />
+ <input type="hidden" asp-for="OfferingId"></input>
+ <input type="hidden" asp-for="RedirectUrl"></input>
<div class="form-group">
<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))"
- class="form-select email-rule-trigger" required></select>
- <span asp-validation-for="Trigger" class="text-danger"></span>
- <div class="form-text" text-translate="true">Choose what event sends the email.</div>
+ <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>
+ <span asp-validation-for="Trigger" class="text-danger"></span>
+ <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 class="form-group">
<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>
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
</div>
@@ -86,11 +107,12 @@
var triggers = @Safe.Json(Model.Triggers);
var triggersByType = {};
for (var i = 0; i < triggers.length; i++) {
- triggersByType[triggers[i].type] = triggers[i];
+ triggersByType[triggers[i].trigger] = triggers[i];
}
+
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 bodyTextarea = document.querySelector('.email-rule-body');
const placeholdersTd = document.querySelector('#placeholders');
@@ -103,6 +125,7 @@
function applyTemplate() {
const selectedTrigger = triggerSelect.value;
+ console.log(selectedTrigger);
if (triggersByType[selectedTrigger]) {
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
subjectInput.value = triggersByType[selectedTrigger].subjectExample;
diff --git a/BTCPayServer/Plugins/Subscriptions/BalanceTransaction.cs b/BTCPayServer/Plugins/Subscriptions/BalanceTransaction.cs
new file mode 100644
index 0000000..1f5773c
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/BalanceTransaction.cs
@@ -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;
+ }
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.Cheating.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.Cheating.cs
new file mode 100644
index 0000000..a35e3de
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.Cheating.cs
@@ -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;
+ }
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
new file mode 100644
index 0000000..b714b4c
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -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.DescriptionWhy this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.