[Features] Subscriptions

This commit is contained in:
nicolas.dorier
2025-08-27 12:08:58 +09:00
parent ff02c0f5d7
commit b1cba47adf
95 changed files with 9671 additions and 296 deletions

View File

@@ -58,6 +58,7 @@ dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggest
# ReSharper properties # ReSharper properties
resharper_apply_auto_detected_rules = false resharper_apply_auto_detected_rules = false
resharper_entity_framework_model_validation_unlimited_string_length_highlighting = none
resharper_autodetect_indent_settings = true resharper_autodetect_indent_settings = true
resharper_cpp_insert_final_newline = true resharper_cpp_insert_final_newline = true
resharper_csharp_insert_final_newline = true resharper_csharp_insert_final_newline = true

View File

@@ -0,0 +1,8 @@
namespace BTCPayServer.Client.Models;
public class CustomerModel
{
public string StoreId { get; set; }
public string Id { get; set; }
public string ExternalId { get; set; }
}

View File

@@ -21,7 +21,6 @@ namespace BTCPayServer.Client.Models
public JObject Metadata { get; set; } public JObject Metadata { get; set; }
public CheckoutOptions Checkout { get; set; } = new CheckoutOptions(); public CheckoutOptions Checkout { get; set; } = new CheckoutOptions();
public ReceiptOptions Receipt { get; set; } = new ReceiptOptions(); public ReceiptOptions Receipt { get; set; } = new ReceiptOptions();
public class ReceiptOptions public class ReceiptOptions
{ {
public bool? Enabled { get; set; } public bool? Enabled { get; set; }

View File

@@ -0,0 +1,10 @@
namespace BTCPayServer.Client.Models;
public class OfferingModel
{
public string Id { get; set; } = null!;
public string AppName { get; set; }
public string AppId { get; set; } = null!;
public string SuccessRedirectUrl { get; set; }
}

View File

@@ -0,0 +1,23 @@
using System;
using Newtonsoft.Json;
namespace BTCPayServer.Client.Models;
public class SubscriberModel
{
public CustomerModel Customer { get; set; }
public OfferingModel Offer { get; set; }
public SubscriptionPlanModel Plan { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? PeriodEnd { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? TrialEnd { get; set; }
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset? GracePeriodEnd { get; set; }
public bool IsActive { get; set; }
public bool IsSuspended { get; set; }
public string SuspensionReason { get; set; }
}

View File

@@ -0,0 +1,38 @@
using BTCPayServer.JsonConverters;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class SubscriptionPlanModel
{
public enum PlanStatus
{
Active,
Retired
}
public enum RecurringInterval
{
Monthly,
Quarterly,
Yearly,
Lifetime
}
public string Id { get; set; }
public string Name { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public PlanStatus Status { get; set; }
[JsonConverter(typeof(NumericStringJsonConverter))]
public decimal Price { get; set; }
public string Currency { get; set; }
[JsonConverter(typeof(StringEnumConverter))]
public RecurringInterval RecurringType { get; set; }
public int GracePeriodDays { get; set; }
public int TrialDays { get; set; }
public string Description { get; set; }
public int MemberCount { get; set; }
public bool OptimisticActivation { get; set; }
public string[] Entitlements { get; set; }
}

View File

@@ -0,0 +1,180 @@
using System;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json.Converters;
namespace BTCPayServer.Client.Models;
public class WebhookSubscriptionEvent : StoreWebhookEvent
{
public const string SubscriberCreated = nameof(SubscriberCreated);
public const string SubscriberCredited = nameof(SubscriberCredited);
public const string SubscriberCharged = nameof(SubscriberCharged);
public const string SubscriberActivated = nameof(SubscriberActivated);
public const string SubscriberPhaseChanged = nameof(SubscriberPhaseChanged);
public const string SubscriberDisabled = nameof(SubscriberDisabled);
public const string PaymentReminder = nameof(PaymentReminder);
public const string PlanStarted = nameof(PlanStarted);
public const string SubscriberNeedUpgrade = nameof(SubscriberNeedUpgrade);
public static bool IsSubscriptionTrigger(string trigger)
=> IsSubscriptionType(trigger.Substring(3));
public static bool IsSubscriptionType(string substring)
=> substring is
SubscriberCreated or
SubscriberCredited or
SubscriberCharged or
SubscriberActivated or
SubscriberPhaseChanged or
SubscriberDisabled or
PaymentReminder or
PlanStarted;
public class SubscriberEvent : WebhookSubscriptionEvent
{
public SubscriberEvent()
{
}
public SubscriberEvent(string eventType, string storeId) : base(eventType, storeId)
{
}
public SubscriberModel Subscriber { get; set; }
}
// Subscription phases carried by subscriber-related webhook events
[JsonConverter(typeof(StringEnumConverter))]
public enum SubscriptionPhase
{
Normal,
Expired,
Grace,
Trial
}
public class NewSubscriberEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public NewSubscriberEvent()
{
}
public NewSubscriberEvent(string storeId) : base(SubscriberCreated, storeId)
{
}
}
public class SubscriberCreditedEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public SubscriberCreditedEvent()
{
}
public SubscriberCreditedEvent(string storeId) : base(SubscriberCredited, storeId)
{
}
public decimal Total { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
}
public class SubscriberChargedEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public SubscriberChargedEvent()
{
}
public SubscriberChargedEvent(string storeId) : base(SubscriberCharged, storeId)
{
}
public decimal Total { get; set; }
public decimal Amount { get; set; }
public string Currency { get; set; }
}
public class SubscriberActivatedEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public SubscriberActivatedEvent()
{
}
public SubscriberActivatedEvent(string storeId) : base(SubscriberActivated, storeId)
{
}
}
public class SubscriberPhaseChangedEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public SubscriberPhaseChangedEvent()
{
}
public SubscriberPhaseChangedEvent(string storeId) : base(SubscriberPhaseChanged, storeId)
{
}
public SubscriptionPhase PreviousPhase { get; set; }
public SubscriptionPhase CurrentPhase { get; set; }
}
public class SubscriberDisabledEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public SubscriberDisabledEvent()
{
}
public SubscriberDisabledEvent(string storeId) : base(SubscriberDisabled, storeId)
{
}
}
public class PaymentReminderEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public PaymentReminderEvent()
{
}
public PaymentReminderEvent(string storeId) : base(PaymentReminder, storeId)
{
}
}
public class PlanStartedEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public PlanStartedEvent()
{
}
public PlanStartedEvent(string storeId) : base(PlanStarted, storeId)
{
}
public bool AutoRenew { get; set; }
}
public class NeedUpgradeEvent : WebhookSubscriptionEvent.SubscriberEvent
{
public NeedUpgradeEvent()
{
}
public NeedUpgradeEvent(string storeId) : base(SubscriberNeedUpgrade, storeId)
{
}
}
public WebhookSubscriptionEvent()
{
}
public WebhookSubscriptionEvent(string evtType, string storeId)
{
Type = evtType;
StoreId = storeId;
}
}

View File

@@ -40,6 +40,8 @@ namespace BTCPayServer.Client
public const string CanViewPayouts = "btcpay.store.canviewpayouts"; public const string CanViewPayouts = "btcpay.store.canviewpayouts";
public const string CanCreatePullPayments = "btcpay.store.cancreatepullpayments"; public const string CanCreatePullPayments = "btcpay.store.cancreatepullpayments";
public const string CanViewPullPayments = "btcpay.store.canviewpullpayments"; public const string CanViewPullPayments = "btcpay.store.canviewpullpayments";
public const string CanViewMembership = "btcpay.store.canviewmembership";
public const string CanModifyMembership = "btcpay.store.canmodifymembership";
public const string CanCreateNonApprovedPullPayments = "btcpay.store.cancreatenonapprovedpullpayments"; public const string CanCreateNonApprovedPullPayments = "btcpay.store.cancreatenonapprovedpullpayments";
public const string Unrestricted = "unrestricted"; public const string Unrestricted = "unrestricted";
public static IEnumerable<string> AllPolicies public static IEnumerable<string> AllPolicies
@@ -74,6 +76,8 @@ namespace BTCPayServer.Client
yield return CanArchivePullPayments; yield return CanArchivePullPayments;
yield return CanCreatePullPayments; yield return CanCreatePullPayments;
yield return CanViewPullPayments; yield return CanViewPullPayments;
yield return CanViewMembership;
yield return CanModifyMembership;
yield return CanCreateNonApprovedPullPayments; yield return CanCreateNonApprovedPullPayments;
yield return CanManageUsers; yield return CanManageUsers;
yield return CanManagePayouts; yield return CanManagePayouts;
@@ -261,6 +265,7 @@ namespace BTCPayServer.Client
Policies.CanModifyWebhooks, Policies.CanModifyWebhooks,
Policies.CanModifyPaymentRequests, Policies.CanModifyPaymentRequests,
Policies.CanManagePayouts, Policies.CanManagePayouts,
Policies.CanModifyMembership,
Policies.CanUseLightningNodeInStore); Policies.CanUseLightningNodeInStore);
PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser); PolicyHasChild(policyMap,Policies.CanManageUsers, Policies.CanCreateUser);
@@ -269,6 +274,7 @@ namespace BTCPayServer.Client
PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments); PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments);
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests); PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile); PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
PolicyHasChild(policyMap,Policies.CanModifyMembership, Policies.CanViewMembership);
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore); PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser); PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(policyMap,Policies.CanModifyServerSettings, PolicyHasChild(policyMap,Policies.CanModifyServerSettings,

View File

@@ -1,12 +1,7 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Design;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data namespace BTCPayServer.Data
{ {
@@ -20,7 +15,7 @@ namespace BTCPayServer.Data
return new ApplicationDbContext(builder.Options); return new ApplicationDbContext(builder.Options);
} }
} }
public class ApplicationDbContext : IdentityDbContext<ApplicationUser> public partial class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{ {
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options) : base(options)
@@ -64,6 +59,7 @@ namespace BTCPayServer.Data
public DbSet<PayoutProcessorData> PayoutProcessors { get; set; } public DbSet<PayoutProcessorData> PayoutProcessors { get; set; }
public DbSet<FormData> Forms { get; set; } public DbSet<FormData> Forms { get; set; }
public DbSet<PendingTransaction> PendingTransactions { get; set; } public DbSet<PendingTransaction> PendingTransactions { get; set; }
public DbSet<CustomerData> Customers { get; set; }
public DbSet<EmailRuleData> EmailRules { get; set; } public DbSet<EmailRuleData> EmailRules { get; set; }
@@ -72,6 +68,10 @@ namespace BTCPayServer.Data
base.OnModelCreating(builder); base.OnModelCreating(builder);
// some of the data models don't have OnModelCreating for now, commenting them // some of the data models don't have OnModelCreating for now, commenting them
OnSubscriptionsModelCreating(builder);
CustomerData.OnModelCreating(builder, Database);
CustomerIdentityData.OnModelCreating(builder, Database);
EmailRuleData.OnModelCreating(builder, Database); EmailRuleData.OnModelCreating(builder, Database);
ApplicationUser.OnModelCreating(builder, Database); ApplicationUser.OnModelCreating(builder, Database);
AddressInvoiceData.OnModelCreating(builder); AddressInvoiceData.OnModelCreating(builder);

View File

@@ -9,6 +9,7 @@
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<PackageReference Include="NBitcoin.Altcoins" Version="5.0.0" /> <PackageReference Include="NBitcoin.Altcoins" Version="5.0.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" /> <ProjectReference Include="..\BTCPayServer.Abstractions\BTCPayServer.Abstractions.csproj" />

View File

@@ -0,0 +1,13 @@
namespace BTCPayServer.Data;
public abstract record CustomerSelector
{
public static Id ById(string customerId) => new Id(customerId);
public static ExternalRef ByExternalRef(string externalId) => new ExternalRef(externalId);
public static Identity ByIdentity(string type, string value) => new Identity(type, value);
public static Identity ByEmail(string email) => new Identity("Email", email);
public record Id(string CustomerId) : CustomerSelector;
public record ExternalRef(string Ref) : CustomerSelector;
public record Identity(string Type, string Value) : CustomerSelector;
}

View File

@@ -3,6 +3,9 @@
using System; using System;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.IO; using System.IO;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using Dapper;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;

View File

@@ -0,0 +1,90 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data;
[Table("customers")]
public class CustomerData : BaseEntityData
{
[Key]
[Column("id")]
public string Id { get; set; } = null!;
[Required]
[Column("store_id")]
public string StoreId { get; set; } = null!;
[ForeignKey("StoreId")]
public StoreData Store { get; set; } = null!;
// Identity
[Column("external_ref")]
public string? ExternalRef { get; set; }
[Column("name")]
public string Name { get; set; } = string.Empty;
public List<CustomerIdentityData> CustomerIdentities { get; set; } = null!;
public new static string GenerateId() => ValueGenerators.WithPrefix("cust")(null, null).Next(null!) as string ?? throw new InvalidOperationException("Bug, shouldn't happen");
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<CustomerData>();
OnModelCreateBase(b, builder, databaseFacade);
b.Property(x => x.Name).HasColumnName("name").HasColumnType("TEXT")
.HasDefaultValueSql("''::TEXT");
b.HasKey(x => new { x.Id });
b.HasIndex(x => new { x.StoreId, x.ExternalRef }).IsUnique();
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("cust"));
}
public string? GetContact(string type)
=> (CustomerIdentities ?? throw ContactDataNotIncludedInEntity()).FirstOrDefault(c => c.Type == type)?.Value;
private static InvalidOperationException ContactDataNotIncludedInEntity()
=> new InvalidOperationException("Bug: Contact data not included in entity. Use .Include(x => x.Contacts) to include it.");
public class ContactSetter(CustomerData customer, string type)
{
public string Type { get; } = type;
public void Set(string? value) => customer.SetContact(Type, value);
public string? Get() => customer.GetContact(Type);
public override string ToString() => $"{Get()} ({Type})";
}
[NotMapped]
public ContactSetter Email => new ContactSetter(this, "Email");
public void SetContact(string type, string? value)
{
if (CustomerIdentities is null)
throw ContactDataNotIncludedInEntity();
if (value is null)
{
CustomerIdentities.RemoveAll(c => c.Type == type);
return;
}
var existing = CustomerIdentities.FirstOrDefault(c => c.Type == type);
if (existing != null)
{
existing.Value = value;
}
else
{
CustomerIdentities.Add(new() { CustomerId = Id, Type = type, Value = value });
}
}
public string? GetPrimaryIdentity() => Email.Get();
}

View File

@@ -0,0 +1,31 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using BTCPayServer.Data.Subscriptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data;
[Table("customers_identities")]
public class CustomerIdentityData
{
[Column("customer_id")]
public string CustomerId { get; set; }
[ForeignKey(nameof(CustomerId))]
public CustomerData Customer { get; set; }
[Required]
[Column("type")]
public string Type { get; set; }
[Required]
[Column("value")]
public string Value { get; set; }
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<CustomerIdentityData>();
b.HasKey(x=> new { x.CustomerId, x.Type });
b.HasOne(x => x.Customer).WithMany(x => x.CustomerIdentities).HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -4,6 +4,7 @@ using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using BTCPayServer.Data.Subscriptions;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -16,6 +17,11 @@ public class EmailRuleData : BaseEntityData
public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}"; public static string GetWebhookTriggerName(string webhookType) => $"WH-{webhookType}";
[Column("store_id")] [Column("store_id")]
public string? StoreId { get; set; } public string? StoreId { get; set; }
[Column("offering_id")]
public string? OfferingId { get; set; }
[ForeignKey(nameof(OfferingId))]
public OfferingData? Offering { get; set; }
[Key] [Key]
public long Id { get; set; } public long Id { get; set; }
@@ -53,6 +59,7 @@ public class EmailRuleData : BaseEntityData
var b = builder.Entity<EmailRuleData>(); var b = builder.Entity<EmailRuleData>();
BaseEntityData.OnModelCreateBase(b, builder, databaseFacade); BaseEntityData.OnModelCreateBase(b, builder, databaseFacade);
b.Property(o => o.Id).UseIdentityAlwaysColumn(); b.Property(o => o.Id).UseIdentityAlwaysColumn();
b.HasOne(o => o.Offering).WithMany().OnDelete(DeleteBehavior.Cascade);
b.HasOne(o => o.Store).WithMany().OnDelete(DeleteBehavior.Cascade); b.HasOne(o => o.Store).WithMany().OnDelete(DeleteBehavior.Cascade);
b.HasIndex(o => o.StoreId); b.HasIndex(o => o.StoreId);
} }

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
@@ -36,6 +37,11 @@ namespace BTCPayServer.Data
[Timestamp] [Timestamp]
// With this, update of InvoiceData will fail if the row was modified by another process // With this, update of InvoiceData will fail if the row was modified by another process
public uint XMin { get; set; } public uint XMin { get; set; }
public const string Processing = nameof(Processing);
public const string Settled = nameof(Settled);
public const string Invalid = nameof(Invalid);
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade) internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{ {
builder.Entity<InvoiceData>() builder.Entity<InvoiceData>()

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,45 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_entitlements")]
public class EntitlementData
{
/// <summary>
/// The internal ID of the entitlement, we only really use it in
/// SQL queries. This should not be exposed.
/// </summary>
[Required]
[Column("id")]
[Key]
public long Id { get; set; }
/// <summary>
/// The ID selected by the user, scoped at the offering level.
/// </summary>
[Required]
[Column("custom_id")]
public string CustomId { get; set; } = null!;
[Required]
[Column("offering_id")]
public string OfferingId { get; set; } = null!;
[ForeignKey(nameof(OfferingId))]
public OfferingData Offering { get; set; } = null!;
[Column("description")]
public string? Description { get; set; }
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<EntitlementData>();
b.HasKey(x => x.Id);
b.Property(x => x.Id).UseIdentityAlwaysColumn();
b.HasIndex(x => new { x.OfferingId, x.CustomId }).IsUnique();
b.HasOne(x => x.Offering).WithMany(x => x.Entitlements).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,47 @@
#nullable enable
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using AngleSharp.Html;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_offerings")]
public class OfferingData : BaseEntityData
{
[Key]
[Required]
[Column("id")]
public string Id { get; set; } = null!;
[Required]
[Column("app_id")]
public string AppId { get; set; } = null!;
[ForeignKey(nameof(AppId))]
public AppData App { get; set; } = null!;
public List<EntitlementData> Entitlements { get; set; } = null!;
public List<PlanData> Plans { get; set; } = null!;
public List<SubscriberData> Subscribers { get; set; } = null!;
[Column("success_redirect_url")]
public string? SuccessRedirectUrl { get; set; }
[Column("payment_reminder_days")]
[Required]
public int DefaultPaymentRemindersDays { get; set; }
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<OfferingData>();
OnModelCreateBase(b, builder, databaseFacade);
b.Property(x => x.DefaultPaymentRemindersDays).HasDefaultValue(3);
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("offering"));
b.HasOne(o => o.App).WithMany().OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,40 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_plan_changes")]
public class PlanChangeData
{
[Required]
[Column("plan_id")]
public string PlanId { get; set; } = null!;
[ForeignKey(nameof(PlanId))]
public PlanData Plan { get; set; } = null!;
[Required]
[Column("plan_change_id")]
public string PlanChangeId { get; set; } = null!;
[ForeignKey(nameof(PlanChangeId))]
public PlanData PlanChange { get; set; } = null!;
[Required]
[Column("type")]
public ChangeType Type { get; set; }
public enum ChangeType
{
Upgrade,
Downgrade
}
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PlanChangeData>();
b.HasKey(x => new { x.PlanId, x.PlanChangeId });
b.Property(x => x.Type).HasConversion<string>();
b.HasOne(o => o.Plan).WithMany(o => o.PlanChanges).OnDelete(DeleteBehavior.Cascade);
b.HasOne(o => o.PlanChange).WithMany().OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,145 @@
#nullable enable
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using BTCPayServer.Abstractions;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_plan_checkouts")]
public class PlanCheckoutData : BaseEntityData
{
public PlanCheckoutData()
{
}
public PlanCheckoutData(SubscriberData subscriber, PlanData? plan = null)
{
plan ??= subscriber.Plan;
NewSubscriber = false;
Subscriber = subscriber;
SubscriberId = subscriber.Id;
Plan = plan;
PlanId = plan.Id;
}
[Key]
[Column("id")]
public string Id { get; set; } = null!;
[Column("invoice_id")]
public string? InvoiceId { get; set; }
[ForeignKey(nameof(InvoiceId))]
public InvoiceData? Invoice { get; set; }
[Column("success_redirect_url")]
public string? SuccessRedirectUrl { get; set; }
[Column("is_trial")]
public bool IsTrial { get; set; }
[Required]
[Column("plan_id")]
public string PlanId { get; set; } = null!;
[ForeignKey(nameof(PlanId))]
public PlanData Plan { get; set; } = null!;
[Column("new_subscriber")]
public bool NewSubscriber { get; set; }
/// <summary>
/// Internal ID of the subscriber, do not expose outside, only use for querying.
/// </summary>
[Column("subscriber_id")]
public long? SubscriberId { get; set; }
[ForeignKey(nameof(SubscriberId))]
public SubscriberData? Subscriber { get; set; }
[Column("invoice_metadata", TypeName = "jsonb")]
public string InvoiceMetadata { get; set; } = "{}";
[Column("new_subscriber_metadata", TypeName = "jsonb")]
public string NewSubscriberMetadata { get; set; } = "{}";
[Column("test_account")]
public bool TestAccount { get; set; }
[Column("credited")]
public decimal Credited { get; set; } = 0m;
[Column("plan_started")]
public bool PlanStarted { get; set; }
[Column("refund_amount")]
public decimal? RefundAmount { get; set; }
[Column("on_pay")]
public OnPayBehavior OnPay { get; set; }
[Required]
[Column("base_url", TypeName = "text")]
public RequestBaseUrl BaseUrl { get; set; } = null!;
[Required]
[Column("expiration")]
public DateTimeOffset Expiration { get; set; }
public enum OnPayBehavior
{
/// <summary>
/// Starts the plan if payment is due, else, do not and add the funds to the credit.
/// </summary>
SoftMigration,
/// <summary>
/// Starts the plan immediately. If a payment wasn't due yet, reimburse the unused part of the period,
/// and start the plan.
/// </summary>
HardMigration
}
public string? GetRedirectUrl()
{
if (SuccessRedirectUrl is null)
return null;
// Add ?checkoutPlanId=... to the redirect URL
try { return QueryHelpers.AddQueryString(SuccessRedirectUrl, "checkoutPlanId", Id); }
catch (UriFormatException) { return null; }
}
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PlanCheckoutData>();
OnModelCreateBase(b, builder, databaseFacade);
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("plancheckout"));
b.Property(x => x.InvoiceMetadata).HasColumnName("invoice_metadata").HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb");
b.Property(x => x.NewSubscriberMetadata).HasColumnName("new_subscriber_metadata").HasColumnType("jsonb")
.HasDefaultValueSql("'{}'::jsonb");
b.Property(x => x.BaseUrl)
.HasConversion<string>(
x => x.ToString(),
x => RequestBaseUrl.FromUrl(x)
);
b.HasIndex(x => x.Expiration);
b.Property(x => x.Expiration).HasDefaultValueSql("now() + interval '1 day'");
b.Property(x => x.OnPay).HasDefaultValue(OnPayBehavior.SoftMigration).HasConversion<string>();
b.Property(x => x.IsTrial).HasDefaultValue(false);
b.HasOne(x => x.Plan).WithMany().OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Subscriber).WithMany().OnDelete(DeleteBehavior.SetNull);
b.HasOne(x => x.Invoice).WithMany().OnDelete(DeleteBehavior.SetNull);
}
[NotMapped]
public bool IsExpired => DateTimeOffset.UtcNow > Expiration;
}

View File

@@ -0,0 +1,124 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using static BTCPayServer.Data.Subscriptions.SubscriberData;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_plans")]
public class PlanData : BaseEntityData
{
[Key]
[Column("id")]
public string Id { get; set; } = null!;
public List<SubscriberData> Subscriptions { get; set; } = null!;
[Required]
[Column("offering_id")]
public string OfferingId { get; set; } = null!;
[ForeignKey(nameof(OfferingId))]
public OfferingData Offering { get; set; } = null!;
[Required]
[Column("name")]
public string Name { get; set; } = string.Empty;
[Required]
[Column("status")]
public PlanStatus Status { get; set; } = PlanStatus.Active;
[Required]
[Column("price")]
public decimal Price { get; set; }
[Required]
[Column("currency")]
public string Currency { get; set; } = string.Empty;
[Required]
[Column("recurring_type")]
public RecurringInterval RecurringType { get; set; } = RecurringInterval.Monthly;
[Required]
[Column("grace_period_days")]
public int GracePeriodDays { get; set; }
[Required]
[Column("trial_days")]
public int TrialDays { get; set; }
[Column("description")]
public string? Description { get; set; }
[Column("members_count")]
public int MemberCount { get; set; }
[Column("monthly_revenue")]
public decimal MonthlyRevenue { get; set; }
[Column("optimistic_activation")]
public bool OptimisticActivation { get; set; } = true;
[Column("renewable")]
public bool Renewable { get; set; } = true;
public List<PlanChangeData> PlanChanges { get; set; } = null!;
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PlanData>();
OnModelCreateBase(b, builder, databaseFacade);
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("plan"));
b.Property(x => x.Status).HasConversion<string>();
b.Property(x => x.OptimisticActivation).HasDefaultValue(true);
b.Property(x => x.RecurringType).HasConversion<string>();
b.Property(x => x.Renewable).HasDefaultValue(true);
b.HasOne(x => x.Offering).WithMany(x => x.Plans).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
}
public enum PlanStatus
{
Active,
Retired
}
public enum RecurringInterval
{
Monthly,
Quarterly,
Yearly,
Lifetime
}
public (DateTimeOffset? PeriodEnd, DateTimeOffset? PeriodGraceEnd) GetPeriodEnd(DateTimeOffset from)
{
if (this.RecurringType == RecurringInterval.Lifetime)
return (null, null);
var to = from.AddMonths(this.RecurringType switch
{
RecurringInterval.Monthly => 1,
RecurringInterval.Quarterly => 3,
RecurringInterval.Yearly => 12,
_ => throw new NotSupportedException(RecurringType.ToString())
});
return (to, GracePeriodDays is 0 ? null : to.AddDays(GracePeriodDays));
}
[NotMapped]
// Avoid cartesian explosion if there are lots of entitlements
public List<PlanEntitlementData> PlanEntitlements { get; set; } = null!;
public PlanEntitlementData? GetEntitlement(long entitmentId)
=> PlanEntitlements.FirstOrDefault(p => p.EntitlementId == entitmentId);
public string[] GetEntitlementIds()
=> PlanEntitlements.Select(p => p.Entitlement.CustomId).ToArray();
}

View File

@@ -0,0 +1,34 @@
#nullable enable
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_plans_entitlements")]
public class PlanEntitlementData
{
[Required]
[Column("plan_id")]
public string PlanId { get; set; } = null!;
[ForeignKey(nameof(PlanId))]
public PlanData Plan { get; set; } = null!;
[Required]
[Column("entitlement_id")]
public long EntitlementId { get; set; }
[ForeignKey(nameof(EntitlementId))]
public EntitlementData Entitlement { get; set; } = null!;
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PlanEntitlementData>();
b.HasKey(o => new { o.PlanId, o.EntitlementId });
b.HasOne(x => x.Plan).WithMany().HasForeignKey(x => x.PlanId).OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Entitlement).WithMany().HasForeignKey(x => x.EntitlementId).OnDelete(DeleteBehavior.Cascade);
}
}

View File

@@ -0,0 +1,49 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using BTCPayServer.Abstractions;
using BTCPayServer.Data.Subscriptions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_portal_sessions")]
public class PortalSessionData
{
[Required]
[Key]
[Column("id")]
public string Id { get; set; }
[Column("subscriber_id")]
public long SubscriberId { get; set; }
[ForeignKey(nameof(SubscriberId))]
public SubscriberData Subscriber { get; set; }
public StoreData GetStoreData() => Subscriber?.Offering?.App?.StoreData ?? throw new InvalidOperationException("You need to include the store in the query");
[Required]
[Column("expiration")]
public DateTimeOffset Expiration { get; set; }
[Required]
[Column("base_url", TypeName = "text")]
public RequestBaseUrl BaseUrl { get; set; }
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PortalSessionData>();
b.HasOne(x => x.Subscriber).WithMany().HasForeignKey(x => x.SubscriberId).OnDelete(DeleteBehavior.Cascade);
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
.HasValueGenerator(ValueGenerators.WithPrefix("ps"));
b.HasIndex(x => x.Expiration);
b.Property(x => x.BaseUrl)
.HasConversion<string>(
x => x.ToString(),
x => RequestBaseUrl.FromUrl(x)
);
}
}

View File

@@ -0,0 +1,38 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_subscriber_credits")]
public class SubscriberCredit
{
[Required]
[Column("subscriber_id")]
public long SubscriberId { get; set; }
[Required]
[Column("currency")]
public string Currency { get; set; } = null!;
[Required]
[Column("amount")]
public decimal Amount { get; set; }
[ForeignKey(nameof(SubscriberId))]
public SubscriberData Subscriber { get; set; } = null!;
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<SubscriberCredit>();
b.HasOne(x => x.Subscriber).WithMany(x => x.Credits).HasForeignKey(x => x.SubscriberId).OnDelete(DeleteBehavior.Cascade);
b.HasKey(x => new { x.SubscriberId, x.Currency });
// Make sure currency is always uppercase at the db level
b.Property(x => x.Currency).HasConversion(
v => v.ToUpperInvariant(),
v => v);
}
}

View File

@@ -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();
}
}

View File

@@ -0,0 +1,261 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
namespace BTCPayServer.Data.Subscriptions;
[Table("subs_subscribers")]
public class SubscriberData : BaseEntityData
{
[Key]
[Required]
[Column("id")]
public long Id { get; set; }
[Required]
[Column("offering_id")]
public string OfferingId { get; set; } = null!;
[ForeignKey(nameof(OfferingId))]
public OfferingData Offering { get; set; } = null!;
[Required]
[Column("customer_id")]
public string CustomerId { get; set; } = null!;
[ForeignKey(nameof(CustomerId))]
public CustomerData Customer { get; set; } = null!;
[Required]
[Column("plan_id")]
public string PlanId { get; set; } = null!;
[NotMapped]
public PlanData NextPlan => NewPlan ?? Plan;
[Column("new_plan_id")]
public string? NewPlanId { get; set; }
[ForeignKey(nameof(NewPlanId))]
public PlanData? NewPlan { get; set; }
[ForeignKey(nameof(PlanId))]
public PlanData Plan { get; set; } = null!;
[Column("paid_amount")]
public decimal? PaidAmount { get; set; }
public decimal GetCredit(string? currency = null)
=> Credits.FirstOrDefault(c => (currency ?? c.Currency) == Plan.Currency)?.Amount ?? 0m;
public decimal MissingCredit()
=> Math.Max(0m, NextPlan.Price - GetCredit(NextPlan.Currency));
[Required]
[Column("phase")]
public PhaseTypes Phase { get; set; } = PhaseTypes.Expired;
[Column("plan_started")]
public DateTimeOffset PlanStarted { get; set; }
[Column("period_end")]
public DateTimeOffset? PeriodEnd { get; set; }
public decimal? GetUnusedPeriodAmount() => GetUnusedPeriodAmount(DateTimeOffset.UtcNow);
public decimal? GetUnusedPeriodAmount(DateTimeOffset now)
{
if (PeriodEnd is { } pe &&
pe - now > TimeSpan.Zero &&
PaidAmount is { } pa)
{
var total = pe - PlanStarted;
var remaining = pe - now;
var unused = (decimal)(remaining.TotalMilliseconds / total.TotalMilliseconds);
return pa * unused;
}
return null;
}
[Column("optimistic_activation")]
public bool OptimisticActivation { get; set; }
[Column("trial_end")]
public DateTimeOffset? TrialEnd { get; set; }
[NotMapped]
public DateTimeOffset? NextPaymentDue => PeriodEnd ?? TrialEnd;
[Column("grace_period_end")]
public DateTimeOffset? GracePeriodEnd { get; set; }
[Column("auto_renew")]
public bool AutoRenew { get; set; } = true;
[Required]
[Column("active")]
public bool IsActive { get; set; }
[Column("payment_reminder_days")]
public int? PaymentReminderDays { get; set; }
[Required]
[Column("payment_reminded")]
public bool PaymentReminded { get; set; }
[Required]
[Column("suspended")]
public bool IsSuspended { get; set; }
public List<SubscriberCredit> Credits { get; set; } = null!;
[Column("test_account")]
public bool TestAccount { get; set; }
[Column("suspension_reason")]
public string? SuspensionReason { get; set; }
[NotMapped]
public bool CanStartNextPlan => CanStartNextPlanEx(false);
public bool CanStartNextPlanEx(bool newSubscriber) => this is
{
Phase: not PhaseTypes.Normal,
NextPlan:
{
Status: Data.Subscriptions.PlanData.PlanStatus.Active
},
IsSuspended: false
}
// If we stay on the same plan, check that the next plan is renwable
&& (newSubscriber || this.PlanId != this.NextPlan.Id || this.IsNextPlanRenewable);
[NotMapped]
public bool IsNextPlanRenewable => this.NextPlan is { Renewable: true, Status: Data.Subscriptions.PlanData.PlanStatus.Active };
public PhaseTypes GetExpectedPhase(DateTimeOffset time)
=> this switch
{
{ TrialEnd: { } te } when time < te => PhaseTypes.Trial,
{ PeriodEnd: { } pe } when time < pe => PhaseTypes.Normal,
{ GracePeriodEnd: { } gpe } when time < gpe => PhaseTypes.Grace,
{ Plan: { RecurringType: PlanData.RecurringInterval.Lifetime }, PaidAmount: not null } => PhaseTypes.Normal,
_ => PhaseTypes.Expired
};
public enum PhaseTypes
{
Trial,
Normal,
Grace,
Expired
}
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<SubscriberData>();
OnModelCreateBase(b, builder, databaseFacade);
b.Property(x => x.Id).UseIdentityAlwaysColumn();
b.Property(x => x.PaymentReminderDays);
b.Property(x => x.Phase)
.HasSentinel(PhaseTypes.Expired)
.HasDefaultValueSql("'Expired'::TEXT").HasConversion<string>();
b.Property(x => x.PlanStarted).HasDefaultValueSql("now()");
b.Property(x => x.IsActive).HasDefaultValue(false);
b.Property(x => x.IsSuspended).HasDefaultValue(false);
b.Property(x => x.AutoRenew).HasDefaultValue(true);
b.Property(x => x.PaymentReminded).HasDefaultValue(false);
b.HasIndex(c => new { c.OfferingId, c.CustomerId })
.IsUnique();
b.Property(x => x.TestAccount).HasDefaultValue(false);
b.HasOne(x => x.NewPlan).WithMany().HasForeignKey(x => x.NewPlanId).OnDelete(DeleteBehavior.SetNull);
b.HasOne(x => x.Plan).WithMany(x => x.Subscriptions).HasForeignKey(x => x.PlanId).OnDelete(DeleteBehavior.Cascade);
b.HasOne(x => x.Offering).WithMany(x => x.Subscribers).HasForeignKey(x => x.OfferingId).OnDelete(DeleteBehavior.Cascade);
}
public void StartNextPlan(DateTimeOffset now, bool trial = false)
{
var plan = NextPlan;
Plan = plan;
PlanId = plan.Id;
NewPlan = null;
NewPlanId = null;
PaymentReminded = false;
if (trial)
{
PlanStarted = now;
PeriodEnd = null;
TrialEnd = now.AddDays(plan.TrialDays);
GracePeriodEnd = null;
PaidAmount = null;
}
else
{
var currentPhase = this.GetExpectedPhase(now);
var startDate = (currentPhase, this) switch
{
(PhaseTypes.Grace, { PeriodEnd: { } pe }) => pe,
// If the user was on trial, give him for free until the end of the trial period.
(PhaseTypes.Trial, { TrialEnd: { } te }) => te,
_ => now
};
(PeriodEnd, GracePeriodEnd) = plan.GetPeriodEnd(startDate);
PlanStarted = now;
TrialEnd = null;
PaidAmount = plan.Price;
}
}
public DateTimeOffset? GetReminderDate()
{
DateTimeOffset? date = this switch
{
{ Phase: PhaseTypes.Normal or PhaseTypes.Grace, PeriodEnd: { } pe } => pe,
{ Phase: PhaseTypes.Trial, TrialEnd: { } te } => te,
_ => null
};
if (date is null)
return null;
return date - TimeSpan.FromDays(PaymentReminderDaysOrDefault);
}
[NotMapped]
public int PaymentReminderDaysOrDefault => PaymentReminderDays ?? Plan.Offering.DefaultPaymentRemindersDays;
public string ToNiceString()
=> $"{this.Customer?.GetPrimaryIdentity()} ({CustomerId})";
public NewPlanScopeDisposable NewPlanScope(PlanData checkoutPlan)
{
var original = NewPlan;
NewPlan = checkoutPlan;
return new(this, original);
}
public class NewPlanScopeDisposable(SubscriberData subscriber, PlanData? originalPlan) : IDisposable
{
public bool IsCommitted { get; private set; }
public void Commit() => IsCommitted = true;
public void Dispose()
{
if (IsCommitted)
return;
subscriber.NewPlan = originalPlan;
subscriber.NewPlanId = originalPlan?.Id;
}
}
[NotMapped]
public CustomerSelector CustomerSelector => CustomerSelector.ById(CustomerId);
}

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,558 @@
using System;
using BTCPayServer.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace BTCPayServer.Migrations
{
[DbContext(typeof(ApplicationDbContext))]
[Migration("20251028061727_subs")]
public partial class subs : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "offering_id",
table: "email_rules",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "customers",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
store_id = table.Column<string>(type: "text", nullable: false),
external_ref = table.Column<string>(type: "text", nullable: true),
name = table.Column<string>(type: "TEXT", nullable: false, defaultValueSql: "''::TEXT"),
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_customers", x => x.id);
table.ForeignKey(
name: "FK_customers_Stores_store_id",
column: x => x.store_id,
principalTable: "Stores",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_offerings",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
app_id = table.Column<string>(type: "text", nullable: false),
success_redirect_url = table.Column<string>(type: "text", nullable: true),
payment_reminder_days = table.Column<int>(type: "integer", nullable: false, defaultValue: 3),
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_subs_offerings", x => x.id);
table.ForeignKey(
name: "FK_subs_offerings_Apps_app_id",
column: x => x.app_id,
principalTable: "Apps",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "customers_identities",
columns: table => new
{
customer_id = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false),
value = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_customers_identities", x => new { x.customer_id, x.type });
table.ForeignKey(
name: "FK_customers_identities_customers_customer_id",
column: x => x.customer_id,
principalTable: "customers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_entitlements",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
custom_id = table.Column<string>(type: "text", nullable: false),
offering_id = table.Column<string>(type: "text", nullable: false),
description = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_entitlements", x => x.id);
table.ForeignKey(
name: "FK_subs_entitlements_subs_offerings_offering_id",
column: x => x.offering_id,
principalTable: "subs_offerings",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_plans",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
offering_id = table.Column<string>(type: "text", nullable: false),
name = table.Column<string>(type: "text", nullable: false),
status = table.Column<string>(type: "text", nullable: false),
price = table.Column<decimal>(type: "numeric", nullable: false),
currency = table.Column<string>(type: "text", nullable: false),
recurring_type = table.Column<string>(type: "text", nullable: false),
grace_period_days = table.Column<int>(type: "integer", nullable: false),
trial_days = table.Column<int>(type: "integer", nullable: false),
description = table.Column<string>(type: "text", nullable: true),
members_count = table.Column<int>(type: "integer", nullable: false),
monthly_revenue = table.Column<decimal>(type: "numeric", nullable: false),
optimistic_activation = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
renewable = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_subs_plans", x => x.id);
table.ForeignKey(
name: "FK_subs_plans_subs_offerings_offering_id",
column: x => x.offering_id,
principalTable: "subs_offerings",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_plan_changes",
columns: table => new
{
plan_id = table.Column<string>(type: "text", nullable: false),
plan_change_id = table.Column<string>(type: "text", nullable: false),
type = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_plan_changes", x => new { x.plan_id, x.plan_change_id });
table.ForeignKey(
name: "FK_subs_plan_changes_subs_plans_plan_change_id",
column: x => x.plan_change_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subs_plan_changes_subs_plans_plan_id",
column: x => x.plan_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_plans_entitlements",
columns: table => new
{
plan_id = table.Column<string>(type: "text", nullable: false),
entitlement_id = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_plans_entitlements", x => new { x.plan_id, x.entitlement_id });
table.ForeignKey(
name: "FK_subs_plans_entitlements_subs_entitlements_entitlement_id",
column: x => x.entitlement_id,
principalTable: "subs_entitlements",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subs_plans_entitlements_subs_plans_plan_id",
column: x => x.plan_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_subscribers",
columns: table => new
{
id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
offering_id = table.Column<string>(type: "text", nullable: false),
customer_id = table.Column<string>(type: "text", nullable: false),
plan_id = table.Column<string>(type: "text", nullable: false),
new_plan_id = table.Column<string>(type: "text", nullable: true),
paid_amount = table.Column<decimal>(type: "numeric", nullable: true),
phase = table.Column<string>(type: "text", nullable: false, defaultValueSql: "'Expired'::TEXT"),
plan_started = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now()"),
period_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
optimistic_activation = table.Column<bool>(type: "boolean", nullable: false),
trial_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
grace_period_end = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
auto_renew = table.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
active = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
payment_reminder_days = table.Column<int>(type: "integer", nullable: true),
payment_reminded = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
suspended = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
test_account = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
suspension_reason = table.Column<string>(type: "text", nullable: true),
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_subs_subscribers", x => x.id);
table.ForeignKey(
name: "FK_subs_subscribers_customers_customer_id",
column: x => x.customer_id,
principalTable: "customers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subs_subscribers_subs_offerings_offering_id",
column: x => x.offering_id,
principalTable: "subs_offerings",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subs_subscribers_subs_plans_new_plan_id",
column: x => x.new_plan_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_subs_subscribers_subs_plans_plan_id",
column: x => x.plan_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_plan_checkouts",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
invoice_id = table.Column<string>(type: "text", nullable: true),
success_redirect_url = table.Column<string>(type: "text", nullable: true),
is_trial = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false),
plan_id = table.Column<string>(type: "text", nullable: false),
new_subscriber = table.Column<bool>(type: "boolean", nullable: false),
subscriber_id = table.Column<long>(type: "bigint", nullable: true),
invoice_metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
new_subscriber_metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
test_account = table.Column<bool>(type: "boolean", nullable: false),
credited = table.Column<decimal>(type: "numeric", nullable: false),
plan_started = table.Column<bool>(type: "boolean", nullable: false),
refund_amount = table.Column<decimal>(type: "numeric", nullable: true),
on_pay = table.Column<string>(type: "text", nullable: false, defaultValue: "SoftMigration"),
base_url = table.Column<string>(type: "text", nullable: false),
expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false, defaultValueSql: "now() + interval '1 day'"),
metadata = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
additional_data = table.Column<string>(type: "jsonb", nullable: false, defaultValueSql: "'{}'::jsonb"),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_subs_plan_checkouts", x => x.id);
table.ForeignKey(
name: "FK_subs_plan_checkouts_Invoices_invoice_id",
column: x => x.invoice_id,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_subs_plan_checkouts_subs_plans_plan_id",
column: x => x.plan_id,
principalTable: "subs_plans",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subs_plan_checkouts_subs_subscribers_subscriber_id",
column: x => x.subscriber_id,
principalTable: "subs_subscribers",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "subs_portal_sessions",
columns: table => new
{
id = table.Column<string>(type: "text", nullable: false),
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
expiration = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
base_url = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_portal_sessions", x => x.id);
table.ForeignKey(
name: "FK_subs_portal_sessions_subs_subscribers_subscriber_id",
column: x => x.subscriber_id,
principalTable: "subs_subscribers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_subscriber_credits",
columns: table => new
{
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
currency = table.Column<string>(type: "text", nullable: false),
amount = table.Column<decimal>(type: "numeric", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_subscriber_credits", x => new { x.subscriber_id, x.currency });
table.ForeignKey(
name: "FK_subs_subscriber_credits_subs_subscribers_subscriber_id",
column: x => x.subscriber_id,
principalTable: "subs_subscribers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subscriber_invoices",
columns: table => new
{
invoice_id = table.Column<string>(type: "text", nullable: false),
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()")
},
constraints: table =>
{
table.PrimaryKey("PK_subscriber_invoices", x => new { x.subscriber_id, x.invoice_id });
table.ForeignKey(
name: "FK_subscriber_invoices_Invoices_invoice_id",
column: x => x.invoice_id,
principalTable: "Invoices",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_subscriber_invoices_subs_subscribers_subscriber_id",
column: x => x.subscriber_id,
principalTable: "subs_subscribers",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "subs_subscriber_credits_history",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityAlwaysColumn),
subscriber_id = table.Column<long>(type: "bigint", nullable: false),
currency = table.Column<string>(type: "text", nullable: false),
created_at = table.Column<DateTimeOffset>(type: "timestamptz", nullable: false, defaultValueSql: "now()"),
description = table.Column<string>(type: "text", nullable: false),
debit = table.Column<decimal>(type: "numeric", nullable: false),
credit = table.Column<decimal>(type: "numeric", nullable: false),
balance = table.Column<decimal>(type: "numeric", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_subs_subscriber_credits_history", x => x.Id);
table.ForeignKey(
name: "FK_subs_subscriber_credits_history_subs_subscriber_credits_sub~",
columns: x => new { x.subscriber_id, x.currency },
principalTable: "subs_subscriber_credits",
principalColumns: new[] { "subscriber_id", "currency" },
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_email_rules_offering_id",
table: "email_rules",
column: "offering_id");
migrationBuilder.CreateIndex(
name: "IX_customers_store_id_external_ref",
table: "customers",
columns: new[] { "store_id", "external_ref" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_subs_entitlements_offering_id_custom_id",
table: "subs_entitlements",
columns: new[] { "offering_id", "custom_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_subs_offerings_app_id",
table: "subs_offerings",
column: "app_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plan_changes_plan_change_id",
table: "subs_plan_changes",
column: "plan_change_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plan_checkouts_expiration",
table: "subs_plan_checkouts",
column: "expiration");
migrationBuilder.CreateIndex(
name: "IX_subs_plan_checkouts_invoice_id",
table: "subs_plan_checkouts",
column: "invoice_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plan_checkouts_plan_id",
table: "subs_plan_checkouts",
column: "plan_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plan_checkouts_subscriber_id",
table: "subs_plan_checkouts",
column: "subscriber_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plans_offering_id",
table: "subs_plans",
column: "offering_id");
migrationBuilder.CreateIndex(
name: "IX_subs_plans_entitlements_entitlement_id",
table: "subs_plans_entitlements",
column: "entitlement_id");
migrationBuilder.CreateIndex(
name: "IX_subs_portal_sessions_expiration",
table: "subs_portal_sessions",
column: "expiration");
migrationBuilder.CreateIndex(
name: "IX_subs_portal_sessions_subscriber_id",
table: "subs_portal_sessions",
column: "subscriber_id");
migrationBuilder.CreateIndex(
name: "IX_subs_subscriber_credits_history_subscriber_id_created_at",
table: "subs_subscriber_credits_history",
columns: new[] { "subscriber_id", "created_at" },
descending: new bool[0]);
migrationBuilder.CreateIndex(
name: "IX_subs_subscriber_credits_history_subscriber_id_currency",
table: "subs_subscriber_credits_history",
columns: new[] { "subscriber_id", "currency" });
migrationBuilder.CreateIndex(
name: "IX_subs_subscribers_customer_id",
table: "subs_subscribers",
column: "customer_id");
migrationBuilder.CreateIndex(
name: "IX_subs_subscribers_new_plan_id",
table: "subs_subscribers",
column: "new_plan_id");
migrationBuilder.CreateIndex(
name: "IX_subs_subscribers_offering_id_customer_id",
table: "subs_subscribers",
columns: new[] { "offering_id", "customer_id" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_subs_subscribers_plan_id",
table: "subs_subscribers",
column: "plan_id");
migrationBuilder.CreateIndex(
name: "IX_subscriber_invoices_invoice_id",
table: "subscriber_invoices",
column: "invoice_id");
migrationBuilder.CreateIndex(
name: "IX_subscriber_invoices_subscriber_id_created_at",
table: "subscriber_invoices",
columns: new[] { "subscriber_id", "created_at" });
migrationBuilder.AddForeignKey(
name: "FK_email_rules_subs_offerings_offering_id",
table: "email_rules",
column: "offering_id",
principalTable: "subs_offerings",
principalColumn: "id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_email_rules_subs_offerings_offering_id",
table: "email_rules");
migrationBuilder.DropTable(
name: "customers_identities");
migrationBuilder.DropTable(
name: "subs_plan_changes");
migrationBuilder.DropTable(
name: "subs_plan_checkouts");
migrationBuilder.DropTable(
name: "subs_plans_entitlements");
migrationBuilder.DropTable(
name: "subs_portal_sessions");
migrationBuilder.DropTable(
name: "subs_subscriber_credits_history");
migrationBuilder.DropTable(
name: "subscriber_invoices");
migrationBuilder.DropTable(
name: "subs_entitlements");
migrationBuilder.DropTable(
name: "subs_subscriber_credits");
migrationBuilder.DropTable(
name: "subs_subscribers");
migrationBuilder.DropTable(
name: "customers");
migrationBuilder.DropTable(
name: "subs_plans");
migrationBuilder.DropTable(
name: "subs_offerings");
migrationBuilder.DropIndex(
name: "IX_email_rules_offering_id",
table: "email_rules");
migrationBuilder.DropColumn(
name: "offering_id",
table: "email_rules");
}
}
}

View File

@@ -194,6 +194,77 @@ namespace BTCPayServer.Migrations
b.ToTable("AspNetUsers", (string)null); b.ToTable("AspNetUsers", (string)null);
}); });
modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("ExternalRef")
.HasColumnType("text")
.HasColumnName("external_ref");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("Name")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasColumnName("name")
.HasDefaultValueSql("''::TEXT");
b.Property<string>("StoreId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("store_id");
b.HasKey("Id");
b.HasIndex("StoreId", "ExternalRef")
.IsUnique();
b.ToTable("customers");
});
modelBuilder.Entity("BTCPayServer.Data.CustomerIdentityData", b =>
{
b.Property<string>("CustomerId")
.HasColumnType("text")
.HasColumnName("customer_id");
b.Property<string>("Type")
.HasColumnType("text")
.HasColumnName("type");
b.Property<string>("Value")
.IsRequired()
.HasColumnType("text")
.HasColumnName("value");
b.HasKey("CustomerId", "Type");
b.ToTable("customers_identities");
});
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b => modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@@ -231,6 +302,10 @@ namespace BTCPayServer.Migrations
.HasColumnName("metadata") .HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb"); .HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("OfferingId")
.HasColumnType("text")
.HasColumnName("offering_id");
b.Property<string>("StoreId") b.Property<string>("StoreId")
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("store_id"); .HasColumnName("store_id");
@@ -252,6 +327,8 @@ namespace BTCPayServer.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OfferingId");
b.HasIndex("StoreId"); b.HasIndex("StoreId");
b.ToTable("email_rules"); b.ToTable("email_rules");
@@ -948,6 +1025,591 @@ namespace BTCPayServer.Migrations
b.ToTable("Files"); b.ToTable("Files");
}); });
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.EntitlementData", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
b.Property<string>("CustomId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("custom_id");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<string>("OfferingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("offering_id");
b.HasKey("Id");
b.HasIndex("OfferingId", "CustomId")
.IsUnique();
b.ToTable("subs_entitlements");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("AppId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("app_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<int>("DefaultPaymentRemindersDays")
.ValueGeneratedOnAdd()
.HasColumnType("integer")
.HasDefaultValue(3)
.HasColumnName("payment_reminder_days");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("SuccessRedirectUrl")
.HasColumnType("text")
.HasColumnName("success_redirect_url");
b.HasKey("Id");
b.HasIndex("AppId");
b.ToTable("subs_offerings");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanChangeData", b =>
{
b.Property<string>("PlanId")
.HasColumnType("text")
.HasColumnName("plan_id");
b.Property<string>("PlanChangeId")
.HasColumnType("text")
.HasColumnName("plan_change_id");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text")
.HasColumnName("type");
b.HasKey("PlanId", "PlanChangeId");
b.HasIndex("PlanChangeId");
b.ToTable("subs_plan_changes");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanCheckoutData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("base_url");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<decimal>("Credited")
.HasColumnType("numeric")
.HasColumnName("credited");
b.Property<DateTimeOffset>("Expiration")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("expiration")
.HasDefaultValueSql("now() + interval '1 day'");
b.Property<string>("InvoiceId")
.HasColumnType("text")
.HasColumnName("invoice_id");
b.Property<string>("InvoiceMetadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("invoice_metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("IsTrial")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("is_trial");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("NewSubscriber")
.HasColumnType("boolean")
.HasColumnName("new_subscriber");
b.Property<string>("NewSubscriberMetadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("new_subscriber_metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("OnPay")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("SoftMigration")
.HasColumnName("on_pay");
b.Property<string>("PlanId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan_id");
b.Property<bool>("PlanStarted")
.HasColumnType("boolean")
.HasColumnName("plan_started");
b.Property<decimal?>("RefundAmount")
.HasColumnType("numeric")
.HasColumnName("refund_amount");
b.Property<long?>("SubscriberId")
.HasColumnType("bigint")
.HasColumnName("subscriber_id");
b.Property<string>("SuccessRedirectUrl")
.HasColumnType("text")
.HasColumnName("success_redirect_url");
b.Property<bool>("TestAccount")
.HasColumnType("boolean")
.HasColumnName("test_account");
b.HasKey("Id");
b.HasIndex("Expiration");
b.HasIndex("InvoiceId");
b.HasIndex("PlanId");
b.HasIndex("SubscriberId");
b.ToTable("subs_plan_checkouts");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<string>("Description")
.HasColumnType("text")
.HasColumnName("description");
b.Property<int>("GracePeriodDays")
.HasColumnType("integer")
.HasColumnName("grace_period_days");
b.Property<int>("MemberCount")
.HasColumnType("integer")
.HasColumnName("members_count");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<decimal>("MonthlyRevenue")
.HasColumnType("numeric")
.HasColumnName("monthly_revenue");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string>("OfferingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("offering_id");
b.Property<bool>("OptimisticActivation")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("optimistic_activation");
b.Property<decimal>("Price")
.HasColumnType("numeric")
.HasColumnName("price");
b.Property<string>("RecurringType")
.IsRequired()
.HasColumnType("text")
.HasColumnName("recurring_type");
b.Property<bool>("Renewable")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("renewable");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("text")
.HasColumnName("status");
b.Property<int>("TrialDays")
.HasColumnType("integer")
.HasColumnName("trial_days");
b.HasKey("Id");
b.HasIndex("OfferingId");
b.ToTable("subs_plans");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanEntitlementData", b =>
{
b.Property<string>("PlanId")
.HasColumnType("text")
.HasColumnName("plan_id");
b.Property<long>("EntitlementId")
.HasColumnType("bigint")
.HasColumnName("entitlement_id");
b.HasKey("PlanId", "EntitlementId");
b.HasIndex("EntitlementId");
b.ToTable("subs_plans_entitlements");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PortalSessionData", b =>
{
b.Property<string>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("id");
b.Property<string>("BaseUrl")
.IsRequired()
.HasColumnType("text")
.HasColumnName("base_url");
b.Property<DateTimeOffset>("Expiration")
.HasColumnType("timestamp with time zone")
.HasColumnName("expiration");
b.Property<long>("SubscriberId")
.HasColumnType("bigint")
.HasColumnName("subscriber_id");
b.HasKey("Id");
b.HasIndex("Expiration");
b.HasIndex("SubscriberId");
b.ToTable("subs_portal_sessions");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCredit", b =>
{
b.Property<long>("SubscriberId")
.HasColumnType("bigint")
.HasColumnName("subscriber_id");
b.Property<string>("Currency")
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.HasKey("SubscriberId", "Currency");
b.ToTable("subs_subscriber_credits");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCreditHistoryData", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
b.Property<decimal>("Balance")
.HasColumnType("numeric")
.HasColumnName("balance");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<decimal>("Credit")
.HasColumnType("numeric")
.HasColumnName("credit");
b.Property<string>("Currency")
.IsRequired()
.HasColumnType("text")
.HasColumnName("currency");
b.Property<decimal>("Debit")
.HasColumnType("numeric")
.HasColumnName("debit");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text")
.HasColumnName("description");
b.Property<long>("SubscriberId")
.HasColumnType("bigint")
.HasColumnName("subscriber_id");
b.HasKey("Id");
b.HasIndex("SubscriberId", "CreatedAt")
.IsDescending();
b.HasIndex("SubscriberId", "Currency");
b.ToTable("subs_subscriber_credits_history");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property<long>("Id"));
b.Property<string>("AdditionalData")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("additional_data")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<bool>("AutoRenew")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(true)
.HasColumnName("auto_renew");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.Property<string>("CustomerId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("customer_id");
b.Property<DateTimeOffset?>("GracePeriodEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("grace_period_end");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("active");
b.Property<bool>("IsSuspended")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("suspended");
b.Property<string>("Metadata")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("jsonb")
.HasColumnName("metadata")
.HasDefaultValueSql("'{}'::jsonb");
b.Property<string>("NewPlanId")
.HasColumnType("text")
.HasColumnName("new_plan_id");
b.Property<string>("OfferingId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("offering_id");
b.Property<bool>("OptimisticActivation")
.HasColumnType("boolean")
.HasColumnName("optimistic_activation");
b.Property<decimal?>("PaidAmount")
.HasColumnType("numeric")
.HasColumnName("paid_amount");
b.Property<bool>("PaymentReminded")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("payment_reminded");
b.Property<int?>("PaymentReminderDays")
.HasColumnType("integer")
.HasColumnName("payment_reminder_days");
b.Property<DateTimeOffset?>("PeriodEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("period_end");
b.Property<string>("Phase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasColumnName("phase")
.HasDefaultValueSql("'Expired'::TEXT");
b.Property<string>("PlanId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("plan_id");
b.Property<DateTimeOffset>("PlanStarted")
.ValueGeneratedOnAdd()
.HasColumnType("timestamp with time zone")
.HasColumnName("plan_started")
.HasDefaultValueSql("now()");
b.Property<string>("SuspensionReason")
.HasColumnType("text")
.HasColumnName("suspension_reason");
b.Property<bool>("TestAccount")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false)
.HasColumnName("test_account");
b.Property<DateTimeOffset?>("TrialEnd")
.HasColumnType("timestamp with time zone")
.HasColumnName("trial_end");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.HasIndex("NewPlanId");
b.HasIndex("PlanId");
b.HasIndex("OfferingId", "CustomerId")
.IsUnique();
b.ToTable("subs_subscribers");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberInvoiceData", b =>
{
b.Property<long>("SubscriberId")
.HasColumnType("bigint")
.HasColumnName("subscriber_id");
b.Property<string>("InvoiceId")
.HasColumnType("text")
.HasColumnName("invoice_id");
b.Property<DateTimeOffset>("CreatedAt")
.ValueGeneratedOnAdd()
.HasColumnType("timestamptz")
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
b.HasKey("SubscriberId", "InvoiceId");
b.HasIndex("InvoiceId");
b.HasIndex("SubscriberId", "CreatedAt");
b.ToTable("subscriber_invoices");
});
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b => modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
{ {
b.Property<string>("Id") b.Property<string>("Id")
@@ -1303,13 +1965,42 @@ namespace BTCPayServer.Migrations
b.Navigation("StoreData"); b.Navigation("StoreData");
}); });
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b => modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
{ {
b.HasOne("BTCPayServer.Data.StoreData", "Store") b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany() .WithMany()
.HasForeignKey("StoreId") .HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Store");
});
modelBuilder.Entity("BTCPayServer.Data.CustomerIdentityData", b =>
{
b.HasOne("BTCPayServer.Data.CustomerData", "Customer")
.WithMany("CustomerIdentities")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("BTCPayServer.Data.EmailRuleData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
.WithMany()
.HasForeignKey("OfferingId")
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
b.HasOne("BTCPayServer.Data.StoreData", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade);
b.Navigation("Offering");
b.Navigation("Store"); b.Navigation("Store");
}); });
@@ -1539,6 +2230,188 @@ namespace BTCPayServer.Migrations
b.Navigation("ApplicationUser"); b.Navigation("ApplicationUser");
}); });
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.EntitlementData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
.WithMany("Entitlements")
.HasForeignKey("OfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Offering");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
{
b.HasOne("BTCPayServer.Data.AppData", "App")
.WithMany()
.HasForeignKey("AppId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("App");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanChangeData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "PlanChange")
.WithMany()
.HasForeignKey("PlanChangeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
.WithMany("PlanChanges")
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Plan");
b.Navigation("PlanChange");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanCheckoutData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice")
.WithMany()
.HasForeignKey("InvoiceId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
.WithMany()
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
.WithMany()
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Invoice");
b.Navigation("Plan");
b.Navigation("Subscriber");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
.WithMany("Plans")
.HasForeignKey("OfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Offering");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanEntitlementData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.EntitlementData", "Entitlement")
.WithMany()
.HasForeignKey("EntitlementId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
.WithMany()
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entitlement");
b.Navigation("Plan");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PortalSessionData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
.WithMany()
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Subscriber");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCredit", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
.WithMany("Credits")
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Subscriber");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberCreditHistoryData", b =>
{
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberCredit", "SubscriberCredit")
.WithMany()
.HasForeignKey("SubscriberId", "Currency")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SubscriberCredit");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
{
b.HasOne("BTCPayServer.Data.CustomerData", "Customer")
.WithMany()
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "NewPlan")
.WithMany()
.HasForeignKey("NewPlanId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("BTCPayServer.Data.Subscriptions.OfferingData", "Offering")
.WithMany("Subscribers")
.HasForeignKey("OfferingId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.PlanData", "Plan")
.WithMany("Subscriptions")
.HasForeignKey("PlanId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Customer");
b.Navigation("NewPlan");
b.Navigation("Offering");
b.Navigation("Plan");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberInvoiceData", b =>
{
b.HasOne("BTCPayServer.Data.InvoiceData", "Invoice")
.WithMany()
.HasForeignKey("InvoiceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("BTCPayServer.Data.Subscriptions.SubscriberData", "Subscriber")
.WithMany()
.HasForeignKey("SubscriberId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Invoice");
b.Navigation("Subscriber");
});
modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b => modelBuilder.Entity("BTCPayServer.Data.U2FDevice", b =>
{ {
b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser") b.HasOne("BTCPayServer.Data.ApplicationUser", "ApplicationUser")
@@ -1683,6 +2556,11 @@ namespace BTCPayServer.Migrations
b.Navigation("UserStores"); b.Navigation("UserStores");
}); });
modelBuilder.Entity("BTCPayServer.Data.CustomerData", b =>
{
b.Navigation("CustomerIdentities");
});
modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b => modelBuilder.Entity("BTCPayServer.Data.InvoiceData", b =>
{ {
b.Navigation("AddressInvoices"); b.Navigation("AddressInvoices");
@@ -1735,6 +2613,27 @@ namespace BTCPayServer.Migrations
b.Navigation("Users"); b.Navigation("Users");
}); });
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.OfferingData", b =>
{
b.Navigation("Entitlements");
b.Navigation("Plans");
b.Navigation("Subscribers");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.PlanData", b =>
{
b.Navigation("PlanChanges");
b.Navigation("Subscriptions");
});
modelBuilder.Entity("BTCPayServer.Data.Subscriptions.SubscriberData", b =>
{
b.Navigation("Credits");
});
modelBuilder.Entity("BTCPayServer.Data.WalletData", b => modelBuilder.Entity("BTCPayServer.Data.WalletData", b =>
{ {
b.Navigation("WalletTransactions"); b.Navigation("WalletTransactions");

View File

@@ -0,0 +1,22 @@
using System;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.ValueGeneration;
using NBitcoin;
using NBitcoin.DataEncoders;
namespace BTCPayServer.Data;
public class ValueGenerators
{
class WithPrefixGen(string prefix) : ValueGenerator
{
protected override object NextValue(EntityEntry entry)
=> $"{prefix}_{Encoders.Base58.EncodeData(RandomUtils.GetBytes(13))}";
public override bool GeneratesTemporaryValues => false;
}
public static Func<IProperty, ITypeBase, ValueGenerator> WithPrefix(string prefix)
=> (_, _) => new WithPrefixGen(prefix);
}

View File

@@ -0,0 +1,35 @@
#nullable enable
using System.Threading.Tasks;
using Xunit;
namespace BTCPayServer.Tests.PMO;
public class InvoiceCheckoutPMO(PlaywrightTester s)
{
public class InvoiceAssertions
{
public string? AmountDue { get; set; }
public string? TotalFiat { get; set; }
}
public async Task AssertContent(InvoiceAssertions assertions)
{
if (assertions.AmountDue is not null)
{
var el = await s.Page.WaitForSelectorAsync("#AmountDue");
var content = await el!.TextContentAsync();
Assert.Equal(assertions.AmountDue.NormalizeWhitespaces(), content.NormalizeWhitespaces());
}
if (assertions.TotalFiat is not null)
{
await s.Page.ClickAsync("#DetailsToggle");
var el = await s.Page.WaitForSelectorAsync("#total_fiat");
var content = await el!.TextContentAsync();
Assert.Equal(assertions.TotalFiat.NormalizeWhitespaces(), content.NormalizeWhitespaces());
}
}
public async Task ClickRedirect()
=> await s.Page.ClickAsync("#StoreLink");
}

View File

@@ -15,6 +15,7 @@ using BTCPayServer.Plugins.PointOfSale;
using BTCPayServer.Plugins.PointOfSale.Controllers; using BTCPayServer.Plugins.PointOfSale.Controllers;
using BTCPayServer.Plugins.PointOfSale.Models; using BTCPayServer.Plugins.PointOfSale.Models;
using BTCPayServer.Services.Apps; using BTCPayServer.Services.Apps;
using BTCPayServer.Tests.PMO;
using BTCPayServer.Views.Stores; using BTCPayServer.Views.Stores;
using LNURL; using LNURL;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -720,12 +721,8 @@ goodies:
await s.GoToInvoiceCheckout(); await s.GoToInvoiceCheckout();
} }
private static async Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount) private static Task AssertInvoiceAmount(PlaywrightTester s, string expectedAmount)
{ => new InvoiceCheckoutPMO(s).AssertContent(new() { AmountDue = expectedAmount });
var el = await s.Page.WaitForSelectorAsync("#AmountDue");
var content = await el!.TextContentAsync();
Assert.Equal(expectedAmount.NormalizeWhitespaces(), content.NormalizeWhitespaces());
}
[Fact] [Fact]
[Trait("Playwright", "Playwright")] [Trait("Playwright", "Playwright")]

View File

@@ -569,7 +569,7 @@ namespace BTCPayServer.Tests
return (name, appId); return (name, appId);
} }
public async Task PayInvoice(bool mine = false, decimal? amount = null) public async Task PayInvoice(bool mine = false, decimal? amount = null, bool clickRedirect = false)
{ {
if (amount is not null) if (amount is not null)
{ {
@@ -585,6 +585,10 @@ namespace BTCPayServer.Tests
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync(); await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\"]").WaitForAsync();
else else
await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync(); await Page.Locator("xpath=//*[text()=\"Invoice Paid\" or text()=\"Payment Received\" or text()=\"The invoice hasn't been paid in full.\"]").WaitForAsync();
if (clickRedirect)
{
await Page.ClickAsync("#StoreLink");
}
} }
/// <summary> /// <summary>

View File

@@ -198,7 +198,7 @@ namespace BTCPayServer.Tests
public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null) public async Task<T> WaitForEvent<T>(Func<Task> action, Func<T, bool> correctEvent = null)
{ {
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously); var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var sub = PayTester.GetService<EventAggregator>().Subscribe<T>(evt => var sub = PayTester.GetService<EventAggregator>().SubscribeAny<T>(evt =>
{ {
if (correctEvent is null) if (correctEvent is null)
tcs.TrySetResult(evt); tcs.TrySetResult(evt);

View File

@@ -0,0 +1,965 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.Plugins;
using BTCPayServer.Tests.PMO;
using Microsoft.Playwright;
using NBitcoin;
using NBXplorer;
using Newtonsoft.Json.Linq;
using Xunit;
using Xunit.Abstractions;
namespace BTCPayServer.Tests;
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testOutputHelper)
{
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanChangeOfferingEmailsSettings()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
await s.CreateNewStore();
var offering = await CreateNewSubscription(s);
await offering.GoToMails();
var settings = new OfferingPMO.EmailSettingsForm()
{
PaymentRemindersDays = 7
};
await offering.SetEmailsSettings(settings);
var actual = await offering.ReadEmailsSettings();
offering.AssertEqual(settings, actual);
}
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanEditOfferingAndPlans()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
await s.CreateNewStore();
await CreateNewSubscription(s);
var offeringPMO = new OfferingPMO(s);
var editPlan = new AddEditPlanPMO(s)
{
PlanName = "Test plan",
Price = "10.00",
TrialPeriod = "7",
GracePeriod = "7",
EnableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"],
PlanChanges = [AddEditPlanPMO.PlanChangeType.Upgrade, AddEditPlanPMO.PlanChangeType.Downgrade]
};
await offeringPMO.AddPlan();
await editPlan.Save();
// Remove the other plans
for (int i = 0; i < 3; i++)
{
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Remove" }).Nth(1).ClickAsync();
await s.ConfirmDeleteModal();
await s.FindAlertMessage();
}
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync();
editPlan = new AddEditPlanPMO(s);
editPlan.PlanName = "Test plan new name";
editPlan.Price = "11.00";
editPlan.TrialPeriod = "5";
editPlan.GracePeriod = "5";
editPlan.Description = "Super cool plan";
editPlan.OptimisticActivation = true;
editPlan.EnableEntitlements = ["transaction-limit-50000", "payment-processing-1", "email-support-1"];
editPlan.DisableEntitlements = ["transaction-limit-10000", "payment-processing-0", "email-support-0"];
await editPlan.Save();
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Edit" }).ClickAsync();
var expected = editPlan;
expected.OptimisticActivation = true;
editPlan = new AddEditPlanPMO(s);
await editPlan.ReadFields();
editPlan.DisableEntitlements = null;
expected.AssertEqual(editPlan);
await s.Page.GetByTestId("offering-link").ClickAsync();
await offeringPMO.Configure();
var configureOffering = new ConfigureOfferingPMO(s)
{
Name = "New test offering 2",
SuccessRedirectUrl = "https://test.com/test",
Entitlements_0__Id = "analytics-dashboard-0-2",
Entitlements_0__ShortDescription = "Basic analytics dashboard 2",
};
await configureOffering.Fill();
// Remove "analytics-dashboard-1" which is the second item
Assert.Equal("analytics-dashboard-1", await s.Page.Locator("#Entitlements_1__Id").InputValueAsync());
await s.Page.Locator("button[name='removeIndex']").Nth(1).ClickAsync();
await s.ClickPagePrimary();
await offeringPMO.Configure();
var expectedConfigure = configureOffering;
expectedConfigure.Entitlements_1__Id = "analytics-dashboard-x";
expectedConfigure.Entitlements_1__ShortDescription = "Custom analytics & reporting";
configureOffering = new ConfigureOfferingPMO(s);
await configureOffering.ReadFields();
expectedConfigure.AssertEqual(configureOffering);
// Can we add "Support" back?
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add item" }).ClickAsync();
await s.Page.Locator("#Entitlements_14__Id").FillAsync("analytics-dashboard-1");
await s.Page.Locator("#Entitlements_14__ShortDescription").FillAsync("Advanced analytics");
await s.ClickPagePrimary();
await offeringPMO.Configure();
expectedConfigure.Entitlements_1__Id = "analytics-dashboard-1";
expectedConfigure.Entitlements_1__ShortDescription = "Advanced analytics";
configureOffering = new ConfigureOfferingPMO(s);
await configureOffering.ReadFields();
expectedConfigure.AssertEqual(configureOffering);
await s.ClickPagePrimary();
await offeringPMO.Configure();
await s.Page.GetByText("Delete this offering").ClickAsync();
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Confirm the action by typing" }).FillAsync("DELETE");
await s.Page.ClickAsync("#ConfirmContinue");
await s.FindAlertMessage(partialText: "App deleted");
await CreateNewSubscription(s);
// Change the planchanges
editPlan = await offeringPMO.Edit("Basic Plan");
await editPlan.ReadFields();
editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.Downgrade, AddEditPlanPMO.PlanChangeType.Upgrade];
await editPlan.Save();
expected = editPlan;
editPlan = await offeringPMO.Edit("Basic Plan");
await editPlan.ReadFields();
expected.AssertEqual(editPlan);
editPlan.PlanChanges = [AddEditPlanPMO.PlanChangeType.None, AddEditPlanPMO.PlanChangeType.Upgrade];
await editPlan.Save();
expected = editPlan;
editPlan = await offeringPMO.Edit("Basic Plan");
await editPlan.ReadFields();
expected.AssertEqual(editPlan);
}
private static async Task<OfferingPMO> CreateNewSubscription(PlaywrightTester s)
{
await s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscriptions" }).ClickAsync();
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Name *" }).FillAsync("New test offering");
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Create fake offering" }).ClickAsync();
return new(s);
}
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanUpgradeAndDowngrade()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
(_, string storeId) = await s.CreateNewStore();
await s.AddDerivationScheme();
var invoice = new InvoiceCheckoutPMO(s);
var offering = await CreateNewSubscription(s);
await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true);
await offering.GoToSubscribers();
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.Downgrade("Pro Plan");
await invoice.AssertContent(new()
{
TotalFiat = "$99.00"
});
}
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.ClickCallToAction();
await s.Server.WaitForEvent<SubscriptionEvent.SubscriberCredited>(async () =>
{
await s.PayInvoice(mine: true);
});
await invoice.ClickRedirect();
await s.FindAlertMessage(partialText: "The plan has been started.");
// Note that at this point, the customer has a period of 15 days + 1 month.
// This is because the trial period is 15 days, so we extend the plan.
await portal.GoTo7Days();
await portal.Downgrade("Pro Plan");
decimal totalRefunded = 0m;
// The downgrade can be paid by the current, more expensive plan.
var unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 299.0m, daysInPeriod: 15 + DaysInThisMonth());
totalRefunded += await portal.AssertRefunded(unused);
var expectedBalance = totalRefunded - 99.0m;
await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
// This time, we should have 1 month in the current period.
await portal.GoTo7Days();
var credited = await s.Server.WaitForEvent<SubscriptionEvent.SubscriberCredited>(async () =>
{
await portal.Downgrade("Basic Plan");
unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
totalRefunded += await portal.AssertRefunded(unused);
});
Assert.Equal(unused, credited.Amount);
Assert.Equal(unused + expectedBalance, credited.Total);
expectedBalance = totalRefunded - 29.0m - 99.0m;
await portal.AssertCredit("$29.00", "-$29.00", "$0.00", $"${expectedBalance:F2}");
// The balance should now be around 202.15 USD
// Now, let's try upgrade. Since we have enough money, we should be able to upgrade without invoice.
await portal.GoTo7Days();
await portal.Upgrade("Pro Plan");
unused = GetUnusedPeriodValue(usedDays: 7, planPrice: 29.0m, daysInPeriod: DaysInThisMonth());
totalRefunded += await portal.AssertRefunded(unused);
expectedBalance = totalRefunded - 29.0m - 99.0m - 99.0m;
await portal.AssertCredit(creditBalance: $"${expectedBalance:F2}");
// However, for going back to enterprise, we do not have enough.
await portal.GoTo7Days();
await portal.GoTo7Days();
await portal.GoTo7Days();
unused = GetUnusedPeriodValue(usedDays: 21, planPrice: 99.0m, daysInPeriod: DaysInThisMonth());
await s.Server.WaitForEvent<SubscriptionEvent.PlanStarted>(async () =>
{
await portal.Upgrade("Enterprise Plan");
await invoice.AssertContent(new()
{
TotalFiat = USD(299m - expectedBalance - unused)
});
await s.PayInvoice(mine: true);
});
await invoice.ClickRedirect();
totalRefunded += await portal.AssertRefunded(unused);
}
}
private static decimal GetUnusedPeriodValue(int usedDays, decimal planPrice, int daysInPeriod)
{
var unused = (double)(daysInPeriod - usedDays) / (double)daysInPeriod;
var expected = (decimal)Math.Round((double)planPrice * unused, 2);
return expected;
}
private static int DaysInThisMonth()
{
return DateTime.DaysInMonth(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month);
}
private string USD(decimal val)
=> $"${val.ToString("F2", CultureInfo.InvariantCulture)}";
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanUseNonRenewableFreePlan()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
await s.CreateNewStore();
await s.AddDerivationScheme();
var offering = await CreateNewSubscription(s);
var addPlan = await offering.AddPlan();
addPlan.Price = "0";
addPlan.PlanName = "Free Plan";
addPlan.Renewable = false;
addPlan.OptimisticActivation = true;
addPlan.PlanChanges =
[
AddEditPlanPMO.PlanChangeType.Upgrade,
AddEditPlanPMO.PlanChangeType.None,
AddEditPlanPMO.PlanChangeType.None,
];
await addPlan.Save();
await offering.NewSubscriber("Free Plan", "free@example.com", false, hasInvoice: false);
await offering.GoToSubscribers();
await using (var portal = await offering.GoToPortal("free@example.com"))
{
await portal.GoToReminder();
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Upgrade needed in 3 days");
await portal.ClickCallToAction();
await s.PayInvoice(clickRedirect: true);
await portal.AssertNoCallToAction();
await portal.AssertPlan("Basic Plan");
await portal.AssertCreditHistory([
"Upgrade to new plan 'Basic Plan'",
"Credit purchase",
"Starting plan 'Free Plan'"
]);
}
}
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanCreateSubscriberAndCircleThroughStates()
{
await using var s = CreatePlaywrightTester();
await s.StartAsync();
await s.RegisterNewUser();
(_, string storeId) = await s.CreateNewStore();
await s.AddDerivationScheme();
var offering = await CreateNewSubscription(s);
// enterprise@example.com is a trial subscriber
await offering.NewSubscriber("Enterprise Plan", "enterprise@example.com", true);
// basic@example.com is a basic plan subscriber (without optimistic activation), so he needs to wait confirmation
offering.GoToPlans();
var edit = await offering.Edit("Basic Plan");
edit.OptimisticActivation = false;
await edit.Save();
await offering.NewSubscriber("Basic Plan", "basic@example.com", false);
offering.GoToPlans();
edit = await offering.Edit("Basic Plan");
edit.OptimisticActivation = true;
await edit.Save();
// basic2@example.com is a basic plan subscriber (optimistic activation), so he is imediatly activated
await offering.NewSubscriber("Basic Plan", "basic2@example.com", false);
await offering.AssertHasSubscriber("enterprise@example.com", new()
{
Phase = SubscriberData.PhaseTypes.Trial,
Active = OfferingPMO.ActiveState.Active
});
await offering.AssertHasSubscriber("basic2@example.com",
new()
{
Phase = SubscriberData.PhaseTypes.Normal,
Active = OfferingPMO.ActiveState.Active
});
// Payment isn't yet confirmed, and no optimistic activation
await offering.AssertHasNotSubscriber("basic@example.com");
// Mark the invoice of basic2 invalid, so he should go from active to inactive
var api = await s.AsTestAccount().CreateClient();
var invoiceId = (await api.GetInvoices(storeId)).First().Id;
var waiting = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberDisabled>();
await api.MarkInvoiceStatus(storeId, invoiceId, new()
{
Status = InvoiceStatus.Invalid
});
var disabled = await waiting;
Assert.True(disabled.Subscriber.IsSuspended);
Assert.Equal("The plan has been started by an invoice which later became invalid.", disabled.Subscriber.SuspensionReason);
await s.Page.ReloadAsync(new() { WaitUntil = WaitUntilState.Commit });
await offering.AssertHasSubscriber("basic2@example.com",
new()
{
Phase = SubscriberData.PhaseTypes.Normal,
Active = OfferingPMO.ActiveState.Suspended
});
await using (var suspendedPortal = await offering.GoToPortal("basic2@example.com"))
{
await suspendedPortal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended");
}
var activating = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberActivated>();
await s.Server.GetExplorerNode("BTC").EnsureGenerateAsync(1);
var activated = await activating;
Assert.Equal("basic@example.com", activated.Subscriber.Customer.GetPrimaryIdentity());
await s.Page.ReloadAsync(new() { WaitUntil = WaitUntilState.Commit });
// Payment confirmed, this one should be active now
await offering.AssertHasSubscriber("basic@example.com",
new()
{
Phase = SubscriberData.PhaseTypes.Normal,
Active = OfferingPMO.ActiveState.Active
});
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.AssertCallToAction(PortalPMO.CallToAction.Info);
await portal.ClickCallToAction();
var changingPhase = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberPhaseChanged>();
await s.PayInvoice(mine: true, clickRedirect: true);
var changeEvent = await changingPhase;
Assert.Equal(
(SubscriberData.PhaseTypes.Normal, SubscriberData.PhaseTypes.Trial),
(changeEvent.Subscriber.Phase, changeEvent.PreviousPhase));
await s.Page.ReloadAsync();
await portal.AssertNoCallToAction();
var sendingPaymentReminder = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.PaymentReminder>();
await portal.GoToReminder();
var paymentReminder = await sendingPaymentReminder;
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days");
await portal.GoToNextPhase();
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Payment due");
var disabling = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberDisabled>();
await portal.GoToNextPhase();
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access expired");
await disabling;
await portal.AddCredit("19.00001");
var addingCredit = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberCredited>();
await s.PayInvoice(mine: true, clickRedirect: true);
var addedCredit = await addingCredit;
Assert.Equal((19.0m, 19.0m), (addedCredit.Amount, addedCredit.Total));
await s.Page.ReloadAsync();
await portal.AssertCredit("$299.00", "-$19.00", "$280.00");
addingCredit = offering.WaitEvent<SubscriptionEvent.SubscriberEvent.SubscriberCredited>();
await portal.ClickCallToAction();
await s.PayInvoice(mine: true, clickRedirect: true);
addedCredit = await addingCredit;
Assert.Equal((280.0m, 299.0m), (addedCredit.Amount, addedCredit.Total));
await s.Page.ReloadAsync();
await portal.AssertNoCallToAction();
}
await s.Page.ReloadAsync();
await offering.Suspend("enterprise@example.com", "some reason");
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.AssertCallToAction(PortalPMO.CallToAction.Danger, noticeTitle: "Access suspended",
noticeSubtitles: ["Your access to this subscription has been suspended.", "Reason: some reason"]);
}
await offering.Unsuspend("enterprise@example.com");
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.AssertNoCallToAction();
}
await offering.Charge("enterprise@example.com", 10.00001m, "-$10.00 (USD)");
await offering.Credit("enterprise@example.com", 15m, "$5.00 (USD)");
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.AssertNoCallToAction();
}
await offering.Charge("enterprise@example.com", 5m, "$0.00 (USD)");
await using (var portal = await offering.GoToPortal("enterprise@example.com"))
{
await portal.GoToReminder();
await portal.AssertCallToAction(PortalPMO.CallToAction.Warning, noticeTitle: "Payment due in 3 days");
await portal.ClickCallToAction();
await s.PayInvoice(mine: true, clickRedirect: true);
await portal.AssertNoCallToAction();
}
}
class OfferingPMO(PlaywrightTester s)
{
public Task Configure()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Configure" }).ClickAsync();
public async Task<AddEditPlanPMO> AddPlan()
{
await s.Page.GetByRole(AriaRole.Button, new() { Name = "Add Plan" }).ClickAsync();
return new AddEditPlanPMO(s);
}
public async Task NewSubscriber(string planName, string email, bool hasTrial, bool mine = false, bool? hasInvoice = null)
{
var allowTrial = await s.Page.Locator($"tr[data-plan-name='{planName}']").GetAttributeAsync("data-allow-trial") == "True";
await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .dropdown-toggle");
await s.Page.ClickAsync($"tr[data-plan-name='{planName}'] .plan-name-col a");
Assert.Equal(hasTrial, allowTrial);
if (allowTrial)
await s.Page.CheckAsync("input[name='isTrial']");
else
Assert.False(await s.Page.Locator("input[name='isTrial']").IsVisibleAsync());
await s.Page.ClickAsync("#newSubscriberModal button[name='command']");
await s.Page.FillAsync("#emailInput", email);
await s.Page.ClickAsync("button[name='command']");
if (!allowTrial && hasInvoice is not false)
{
await s.PayInvoice(mine, clickRedirect: true);
}
}
public async Task<AddEditPlanPMO> Edit(string planName)
{
await s.Page.Locator($"tr[data-plan-name='{planName}'] .edit-plan").ClickAsync();
return new(s);
}
public Task GoToSubscribers()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscribers" }).ClickAsync();
public void GoToPlans()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Plans" }).ClickAsync();
public Task GoToMails()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Mails" }).ClickAsync();
public enum ActiveState
{
Inactive,
Active,
Suspended
}
public class ExpectedSubscriber
{
public SubscriberData.PhaseTypes? Phase { get; set; }
public ActiveState? Active { get; set; }
}
public async Task AssertHasSubscriber(string subscriberEmail, ExpectedSubscriber? expected = null)
{
await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).WaitForAsync();
if (expected is not null)
{
if (expected.Phase is not null)
{
var phase = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-phase").InnerTextAsync();
Assert.Equal(expected.Phase.ToString(), phase.NormalizeWhitespaces());
}
if (expected.Active is not null)
{
var active = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .status-active").InnerTextAsync();
Assert.Equal(expected.Active.ToString(), active.NormalizeWhitespaces());
}
}
}
private static string SubscriberRowSelector(string subscriberEmail)
{
return $"tr[data-subscriber-email='{subscriberEmail}']";
}
public async Task AssertHasNotSubscriber(string subscriberEmail)
{
Assert.Equal(0, await s.Page.Locator(SubscriberRowSelector(subscriberEmail)).CountAsync());
}
public async Task<T> WaitEvent<T>()
{
using var cts = new CancellationTokenSource(5000);
var eventAggregator = s.Server.PayTester.GetService<EventAggregator>();
return await eventAggregator.WaitNext<T>(cts.Token);
}
public async Task<PortalPMO> GoToPortal(string subscriberEmail)
{
var o = s.Page.Context.WaitForPageAsync();
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .portal-link").ClickAsync();
var switching = await s.SwitchPage(o);
return new(s, switching);
}
public async Task Suspend(string subscriberEmail, string? reason = null)
{
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync();
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status a").ClickAsync();
if (reason is not null)
{
await s.Page.FillAsync("#suspensionReason", reason);
}
await s.Page.ClickAsync("#suspendSubscriberModal button[name='command']");
}
public async Task Unsuspend(string subscriberEmail)
{
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status").ClickAsync();
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-status button").ClickAsync();
}
public Task Charge(string subscriberEmail, decimal value, string? expectedNewTotal = null)
=> UpdateCredit(subscriberEmail, value, expectedNewTotal, "charge");
public async Task Credit(string subscriberEmail, decimal value, string? expectedNewTotal = null)
=> await UpdateCredit(subscriberEmail, value, expectedNewTotal, "credit");
private async Task UpdateCredit(string subscriberEmail, decimal value, string? expectedNewTotal, string action)
{
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col .dropdown-toggle").ClickAsync();
await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col a[data-action='{action}']").ClickAsync();
await s.Page.FillAsync("#updateCreditModal input[name='amount']", value.ToString(CultureInfo.InvariantCulture));
if (expectedNewTotal is not null)
await s.Page.WaitForSelectorAsync($"#updateCreditModal .after-change:has-text('{expectedNewTotal}')");
// Sometimes we submit by hitting Enter. Sometimes we submit by clicking the button.
var button = s.Page.Locator($"#updateCreditModal")
.GetByRole(AriaRole.Button, new() { Name = action == "credit" ? "Credit" : "Charge" });
if (RandomUtils.GetInt32() % 2 == 0)
{
await button.ClickAsync();
}
else
{
await button.WaitForAsync();
await s.Page.FocusAsync("#updateCreditModal input[name='amount']");
await s.Page.Keyboard.PressAsync("Enter");
}
await s.FindAlertMessage(partialText: action == "charge" ? "has been charged" : "has been credited");
var newCredit = await s.Page.Locator($"{SubscriberRowSelector(subscriberEmail)} .subscriber-credit-col").InnerTextAsync();
if (expectedNewTotal is not null)
Assert.Contains(expectedNewTotal.NormalizeWhitespaces(), newCredit.NormalizeWhitespaces());
}
public class EmailSettingsForm
{
public int? PaymentRemindersDays { get; set; }
}
public async Task SetEmailsSettings(EmailSettingsForm settings)
{
if (settings.PaymentRemindersDays is not null)
{
await s.Page.FillAsync("input[name='PaymentRemindersDays']", settings.PaymentRemindersDays.Value.ToString());
}
await s.ClickPagePrimary();
await s.FindAlertMessage();
}
public async Task<EmailSettingsForm> ReadEmailsSettings()
{
var settings = new EmailSettingsForm();
settings.PaymentRemindersDays = int.Parse(await s.Page.InputValueAsync("input[name='PaymentRemindersDays']"));
return settings;
}
public void AssertEqual(EmailSettingsForm expected, EmailSettingsForm actual)
{
Assert.Equal(expected.PaymentRemindersDays, actual.PaymentRemindersDays);
}
}
class PortalPMO(PlaywrightTester s, IAsyncDisposable disposable) : IAsyncDisposable
{
public async Task ClickCallToAction()
=> await s.Page.ClickAsync("div.alert-translucent button");
public enum CallToAction
{
Danger,
Warning,
Info
}
public async Task AssertCallToAction(CallToAction callToAction, string? noticeTitle = null, string? noticeSubtitle = null,
string[]? noticeSubtitles = null)
{
await s.Page.Locator(GetAlertSelector(callToAction)).WaitForAsync();
if (noticeTitle is not null)
Assert.Equal(noticeTitle.NormalizeWhitespaces(),
(await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-title").TextContentAsync()).NormalizeWhitespaces());
if (noticeSubtitle is not null)
Assert.Equal(noticeSubtitle.NormalizeWhitespaces(),
(await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").TextContentAsync()).NormalizeWhitespaces());
if (noticeSubtitles is not null)
{
var i = 0;
foreach (var text in await s.Page.Locator($"{GetAlertSelector(callToAction)} .notice-subtitle").AllInnerTextsAsync())
{
Assert.Equal(noticeSubtitles[i].NormalizeWhitespaces(), text.NormalizeWhitespaces());
i++;
}
Assert.Equal(i, noticeSubtitles.Length);
}
}
private static string GetAlertSelector(CallToAction callToAction) => $"div.alert-translucent.alert-{callToAction.ToString().ToLowerInvariant()}";
public async Task AssertNoCallToAction()
=> Assert.Equal(0, await s.Page.Locator($"div.alert-translucent").CountAsync());
public ValueTask DisposeAsync() => disposable.DisposeAsync();
public Task GoToNextPhase()
=> s.Page.ClickAsync("#MovePhase");
public Task GoTo7Days()
=> s.Page.ClickAsync("#Move7days");
public Task GoToReminder()
=> s.Page.ClickAsync("#MoveToReminder");
public async Task AddCredit(string credit)
{
await s.Page.ClickAsync("#add-credit");
await s.Page.FillAsync("#credit-input input", credit);
await s.Page.ClickAsync("#credit-input button");
}
public async Task AssertCredit(string? planPrice = null, string? creditApplied = null, string? nextCharge = null, string? creditBalance = null)
{
if (planPrice is not null)
Assert.Equal(planPrice.NormalizeWhitespaces(),
(await s.Page.Locator(".credit-plan-price div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
if (creditApplied is not null)
Assert.Equal(creditApplied.NormalizeWhitespaces(),
(await s.Page.Locator(".credit-applied div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
if (nextCharge is not null)
Assert.Equal(nextCharge.NormalizeWhitespaces(),
(await s.Page.Locator(".credit-next-charge div:nth-child(2)").TextContentAsync()).NormalizeWhitespaces());
if (creditBalance is not null)
Assert.Equal(creditBalance.NormalizeWhitespaces(),
(await s.Page.Locator(".credit-balance").TextContentAsync()).NormalizeWhitespaces());
}
private async Task ChangePlan(string planName, string buttonText)
{
await s.Page.ClickAsync($".changeplan-container[data-plan-name='{planName}'] a:has-text('{buttonText}')");
await s.Page.ClickAsync($"#changePlanModal button[value='migrate']:has-text('{buttonText}')");
}
public Task Downgrade(string planName) => ChangePlan(planName, "Downgrade");
public Task Upgrade(string planName) => ChangePlan(planName, "Upgrade");
public async Task<decimal> AssertRefunded(decimal refunded)
{
var text = await (await s.FindAlertMessage()).TextContentAsync();
var match = Regex.Match(text!, @"\((.*?) USD has been refunded\)");
var v = decimal.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
var diff = Math.Abs(refunded - v);
Assert.True(diff < 2.0m);
return v;
}
public async Task AssertPlan(string plan)
{
var name = await s.Page.GetByTestId("plan-name").InnerTextAsync();
Assert.Equal(plan, name);
}
public async Task AssertCreditHistory(List<string> creditLines)
{
var rows = await s.Page.QuerySelectorAllAsync(".credit-history tr td:nth-child(2)");
for (int i = 0; i < creditLines.Count; i++)
{
var txt = await rows[i].InnerTextAsync();
Assert.StartsWith(creditLines[i], txt);
}
}
}
class ConfigureOfferingPMO(PlaywrightTester tester)
{
public string? Name { get; set; }
public string? SuccessRedirectUrl { get; set; }
public string? Entitlements_0__Id { get; set; }
public string? Entitlements_0__ShortDescription { get; set; }
public string? Entitlements_1__Id { get; set; }
public string? Entitlements_1__ShortDescription { get; set; }
public async Task Fill()
{
var s = tester;
if (Name is not null)
await s.Page.Locator("#Name").FillAsync(Name);
if (SuccessRedirectUrl is not null)
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).FillAsync(SuccessRedirectUrl);
if (Entitlements_0__Id is not null)
await s.Page.Locator("#Entitlements_0__Id").FillAsync(Entitlements_0__Id);
if (Entitlements_0__ShortDescription is not null)
await s.Page.Locator("#Entitlements_0__ShortDescription").FillAsync(Entitlements_0__ShortDescription);
if (Entitlements_1__Id is not null)
await s.Page.Locator("#Entitlements_1__Id").FillAsync(Entitlements_1__Id);
if (Entitlements_1__ShortDescription is not null)
await s.Page.Locator("#Entitlements_1__ShortDescription").FillAsync(Entitlements_1__ShortDescription);
}
public async Task ReadFields()
{
var s = tester;
Name = await s.Page.Locator("#Name").InputValueAsync();
SuccessRedirectUrl = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Success redirect url" }).InputValueAsync();
Entitlements_0__Id = await s.Page.Locator("#Entitlements_0__Id").InputValueAsync();
Entitlements_0__ShortDescription = await s.Page.Locator("#Entitlements_0__ShortDescription").InputValueAsync();
Entitlements_1__Id = await s.Page.Locator("#Entitlements_1__Id").InputValueAsync();
Entitlements_1__ShortDescription = await s.Page.Locator("#Entitlements_1__ShortDescription").InputValueAsync();
}
public void AssertEqual(ConfigureOfferingPMO b)
{
Assert.Equal(Name ?? "", b.Name ?? "");
Assert.Equal(SuccessRedirectUrl ?? "", b.SuccessRedirectUrl ?? "");
Assert.Equal(Entitlements_0__Id ?? "", b.Entitlements_0__Id ?? "");
Assert.Equal(Entitlements_0__ShortDescription ?? "", b.Entitlements_0__ShortDescription ?? "");
Assert.Equal(Entitlements_1__Id ?? "", b.Entitlements_1__Id ?? "");
Assert.Equal(Entitlements_1__ShortDescription ?? "", b.Entitlements_1__ShortDescription ?? "");
}
}
class AddEditPlanPMO(PlaywrightTester tester)
{
public string? PlanName { get; set; }
public string? Price { get; set; }
public string? TrialPeriod { get; set; }
public string? GracePeriod { get; set; }
public string? Description { get; set; }
public bool? OptimisticActivation { get; set; }
public List<string>? EnableEntitlements { get; set; }
public List<string>? DisableEntitlements { get; set; }
public PlanChangeType[]? PlanChanges { get; set; }
public bool? Renewable { get; set; }
public enum PlanChangeType
{
Downgrade,
Upgrade,
None
}
public async Task Save()
{
var s = tester;
if (PlanName is not null)
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).FillAsync(PlanName);
if (Description is not null)
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).FillAsync(Description);
if (Price is not null)
await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).FillAsync(Price);
if (TrialPeriod is not null)
await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).FillAsync(TrialPeriod);
if (GracePeriod is not null)
await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).FillAsync(GracePeriod);
if (PlanChanges is not null)
{
for (var i = 0; i < PlanChanges.Length; i++)
{
await s.Page.Locator($"#PlanChanges_{i}__SelectedType").SelectOptionAsync(new[] { PlanChanges[i].ToString() });
}
}
foreach (var entitlement in EnableEntitlements ?? [])
{
await s.Page.GetByTestId($"check_{entitlement}").CheckAsync();
}
foreach (var entitlement in DisableEntitlements ?? [])
{
await s.Page.GetByTestId($"check_{entitlement}").UncheckAsync();
}
if (OptimisticActivation is not null)
await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).SetCheckedAsync(OptimisticActivation.Value);
if (Renewable is not null)
await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).SetCheckedAsync(Renewable.Value);
await s.ClickPagePrimary();
await s.FindAlertMessage();
}
public async Task ReadFields()
{
var s = tester;
PlanName = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Plan Name *" }).InputValueAsync();
Description = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Description", Exact = true }).InputValueAsync();
Price = await s.Page.GetByRole(AriaRole.Textbox, new() { Name = "Price *" }).InputValueAsync();
TrialPeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Trial Period (days)" }).InputValueAsync();
GracePeriod = await s.Page.GetByRole(AriaRole.Spinbutton, new() { Name = "Grace Period (days)" }).InputValueAsync();
foreach (var entitlement in await s.Page.QuerySelectorAllAsync(".entitlement-checkbox"))
{
var isChecked = await entitlement.IsCheckedAsync();
var id = (await entitlement.GetAttributeAsync("data-testid"))!.Substring(6);
if (isChecked)
{
EnableEntitlements ??= new();
EnableEntitlements.Add(id);
}
else
{
DisableEntitlements ??= new();
DisableEntitlements.Add(id);
}
}
OptimisticActivation = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Optimistic activation" }).IsCheckedAsync();
Renewable = await s.Page.GetByRole(AriaRole.Checkbox, new() { Name = "Renewable" }).IsCheckedAsync();
List<PlanChangeType> changes = new();
foreach (var change in await s.Page.Locator(".plan-change-select").AllAsync())
{
changes.Add(Enum.Parse<PlanChangeType>(await change.InputValueAsync()));
}
PlanChanges = changes.ToArray();
}
public void AssertEqual(AddEditPlanPMO b)
{
Assert.Equal(PlanName ?? "", b.PlanName ?? "");
Assert.Equal(Description ?? "", b.Description ?? "");
Assert.Equal(Price ?? "", b.Price ?? "");
Assert.Equal(TrialPeriod ?? "", b.TrialPeriod ?? "");
Assert.Equal(GracePeriod ?? "", b.GracePeriod ?? "");
if (EnableEntitlements is not null && b.EnableEntitlements is not null)
{
Assert.Equal(EnableEntitlements.Count, b.EnableEntitlements.Count);
var (ea, eb) = (EnableEntitlements.OrderBy(e => e).ToArray(), b.EnableEntitlements.OrderBy(e => e).ToArray());
for (int i = 0; i < EnableEntitlements.Count; i++)
Assert.Equal(ea[i], eb[i]);
}
if (DisableEntitlements is not null && b.DisableEntitlements is not null)
{
Assert.Equal(DisableEntitlements.Count, b.DisableEntitlements.Count);
var (ea, eb) = (DisableEntitlements.OrderBy(e => e).ToArray(), b.DisableEntitlements.OrderBy(e => e).ToArray());
for (int i = 0; i < DisableEntitlements.Count; i++)
Assert.Equal(ea[i], eb[i]);
}
Assert.Equal(OptimisticActivation, b.OptimisticActivation);
if (PlanChanges is not null && b.PlanChanges is not null)
{
Assert.Equal(PlanChanges.Length, b.PlanChanges.Length);
for (var i = 0; i < PlanChanges.Length; i++)
{
Assert.Equal(PlanChanges[i], b.PlanChanges[i]);
}
}
}
}
}

View File

@@ -171,14 +171,14 @@ namespace BTCPayServer.Tests
return controller; return controller;
} }
public async Task CreateStoreAsync() public async Task CreateStoreAsync(string preferredExchange = "CoinGecko")
{ {
if (UserId is null) if (UserId is null)
{ {
await RegisterAsync(); await RegisterAsync();
} }
var store = GetController<UIUserStoresController>(); var store = GetController<UIUserStoresController>();
await store.CreateStore(new CreateStoreViewModel { Name = "Test Store", PreferredExchange = "coingecko", CanEditPreferredExchange = true}); await store.CreateStore(new CreateStoreViewModel { Name = "Test Store", PreferredExchange = preferredExchange.ToLowerInvariant(), CanEditPreferredExchange = true});
StoreId = store.CreatedStoreId; StoreId = store.CreatedStoreId;
parent.Stores.Add(StoreId); parent.Stores.Add(StoreId);
} }
@@ -225,7 +225,7 @@ namespace BTCPayServer.Tests
Assert.IsType<RedirectToActionResult>(GetController<UIStoresController>().LightningSettings(lnSettingsVm).Result); Assert.IsType<RedirectToActionResult>(GetController<UIStoresController>().LightningSettings(lnSettingsVm).Result);
} }
private async Task RegisterAsync(bool isAdmin = false) public async Task RegisterAsync(bool isAdmin = false)
{ {
var account = parent.PayTester.GetController<UIAccountController>(); var account = parent.PayTester.GetController<UIAccountController>();
RegisterDetails = new RegisterViewModel() RegisterDetails = new RegisterViewModel()

View File

@@ -271,9 +271,9 @@ namespace BTCPayServer.Tests
{ {
foreach (string localizer in new[] { "ViewLocalizer", "StringLocalizer" }) foreach (string localizer in new[] { "ViewLocalizer", "StringLocalizer" })
{ {
if (txt.Contains(localizer)) if (txt.Contains(localizer, StringComparison.InvariantCultureIgnoreCase))
{ {
var matches = Regex.Matches(txt, localizer + "\\[\"(.*?)\"[\\],]"); var matches = Regex.Matches(txt, localizer + "\\[\"(.*?)\"[\\],]", RegexOptions.IgnoreCase);
foreach (Match match in matches) foreach (Match match in matches)
{ {
var k = match.Groups[1].Value; var k = match.Groups[1].Value;

View File

@@ -57,7 +57,6 @@
<PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" /> <PackageReference Include="BTCPayServer.Hwi" Version="2.0.6" />
<PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.11" /> <PackageReference Include="BTCPayServer.Lightning.All" Version="1.6.11" />
<PackageReference Include="CsvHelper" Version="32.0.3" /> <PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Fido2" Version="3.0.1" /> <PackageReference Include="Fido2" Version="3.0.1" />
<PackageReference Include="Fido2.AspNet" Version="3.0.1" /> <PackageReference Include="Fido2.AspNet" Version="3.0.1" />
<PackageReference Include="LNURL" Version="0.0.36" /> <PackageReference Include="LNURL" Version="0.0.36" />

View File

@@ -538,6 +538,10 @@ namespace BTCPayServer.Controllers
{$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "Allows viewing the selected stores' payment requests.")}, {$"{Policies.CanViewPaymentRequests}:", ("View your payment requests", "Allows viewing the selected stores' payment requests.")},
{Policies.CanViewPullPayments, ("View your pull payments", "Allows viewing pull payments on all your stores.")}, {Policies.CanViewPullPayments, ("View your pull payments", "Allows viewing pull payments on all your stores.")},
{$"{Policies.CanViewPullPayments}:", ("View selected stores' pull payments", "Allows viewing pull payments on the selected stores.")}, {$"{Policies.CanViewPullPayments}:", ("View selected stores' pull payments", "Allows viewing pull payments on the selected stores.")},
{Policies.CanViewMembership, ("View your membership", "Allows viewing membership on all your stores.")},
{$"{Policies.CanViewMembership}:", ("View your membership", "Allows viewing membership on the selected stores.")},
{Policies.CanModifyMembership, ("Modify your membership", "Allows modifying membership on all your stores.")},
{$"{Policies.CanModifyMembership}:", ("Modify your membership", "Allows modifying membership on the selected stores.")},
{Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")}, {Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")},
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")}, {$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")},
{Policies.CanArchivePullPayments, ("Archive your pull payments", "Allows deleting pull payments on all your stores.")}, {Policies.CanArchivePullPayments, ("Archive your pull payments", "Allows deleting pull payments on all your stores.")},

View File

@@ -569,7 +569,7 @@ o.GetRequiredService<IEnumerable<IPaymentLinkExtension>>().ToDictionary(o => o.P
services.AddRateProviderExchangeSharp<ExchangePoloniexAPI>(new("poloniex", "Poloniex", " https://api.poloniex.com/markets/price")); services.AddRateProviderExchangeSharp<ExchangePoloniexAPI>(new("poloniex", "Poloniex", " https://api.poloniex.com/markets/price"));
services.AddRateProviderExchangeSharp<ExchangeNDAXAPI>(new("ndax", "NDAX", "https://ndax.io/api/returnTicker")); services.AddRateProviderExchangeSharp<ExchangeNDAXAPI>(new("ndax", "NDAX", "https://ndax.io/api/returnTicker"));
services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex", "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,tWPRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0")); services.AddRateProviderExchangeSharp<ExchangeBitfinexAPI>(new("bitfinex", "Bitfinex", "https://api.bitfinex.com/v2/tickers?symbols=tBTCUSD,tLTCUSD,tLTCBTC,tETHUSD,tETHBTC,tETCBTC,tETCUSD,tRRTUSD,tRRTBTC,tZECUSD,tZECBTC,tXMRUSD,tXMRBTC,tDSHUSD,tDSHBTC,tBTCEUR,tBTCJPY,tXRPUSD,tXRPBTC,tIOTUSD,tIOTBTC,tIOTETH,tEOSUSD,tEOSBTC,tEOSETH,tSANUSD,tSANBTC,tSANETH,tOMGUSD,tOMGBTC,tOMGETH,tNEOUSD,tNEOBTC,tNEOETH,tETPUSD,tETPBTC,tETPETH,tQTMUSD,tQTMBTC,tQTMETH,tAVTUSD,tAVTBTC,tAVTETH,tEDOUSD,tEDOBTC,tEDOETH,tBTGUSD,tBTGBTC,tDATUSD,tDATBTC,tDATETH,tQSHUSD,tQSHBTC,tQSHETH,tYYWUSD,tYYWBTC,tYYWETH,tGNTUSD,tGNTBTC,tGNTETH,tSNTUSD,tSNTBTC,tSNTETH,tIOTEUR,tBATUSD,tBATBTC,tBATETH,tMNAUSD,tMNABTC,tMNAETH,tFUNUSD,tFUNBTC,tFUNETH,tZRXUSD,tZRXBTC,tZRXETH,tTNBUSD,tTNBBTC,tTNBETH,tSPKUSD,tSPKBTC,tSPKETH,tTRXUSD,tTRXBTC,tTRXETH,tRCNUSD,tRCNBTC,tRCNETH,tRLCUSD,tRLCBTC,tRLCETH,tAIDUSD,tAIDBTC,tAIDETH,tSNGUSD,tSNGBTC,tSNGETH,tREPUSD,tREPBTC,tREPETH,tELFUSD,tELFBTC,tELFETH,tNECUSD,tNECBTC,tNECETH,tBTCGBP,tETHEUR,tETHJPY,tETHGBP,tNEOEUR,tNEOJPY,tNEOGBP,tEOSEUR,tEOSJPY,tEOSGBP,tIOTJPY,tIOTGBP,tIOSUSD,tIOSBTC,tIOSETH,tAIOUSD,tAIOBTC,tAIOETH,tREQUSD,tREQBTC,tREQETH,tRDNUSD,tRDNBTC,tRDNETH,tLRCUSD,tLRCBTC,tLRCETH,tWAXUSD,tWAXBTC,tWAXETH,tDAIUSD,tDAIBTC,tDAIETH,tAGIUSD,tAGIBTC,tAGIETH,tBFTUSD,tBFTBTC,tBFTETH,tMTNUSD,tMTNBTC,tMTNETH,tODEUSD,tODEBTC,tODEETH,tANTUSD,tANTBTC,tANTETH,tDTHUSD,tDTHBTC,tDTHETH,tMITUSD,tMITBTC,tMITETH,tSTJUSD,tSTJBTC,tSTJETH,tXLMUSD,tXLMEUR,tXLMJPY,tXLMGBP,tXLMBTC,tXLMETH,tXVGUSD,tXVGEUR,tXVGJPY,tXVGGBP,tXVGBTC,tXVGETH,tBCIUSD,tBCIBTC,tMKRUSD,tMKRBTC,tMKRETH,tKNCUSD,tKNCBTC,tKNCETH,tPOAUSD,tPOABTC,tPOAETH,tEVTUSD,tLYMUSD,tLYMBTC,tLYMETH,tUTKUSD,tUTKBTC,tUTKETH,tVEEUSD,tVEEBTC,tVEEETH,tDADUSD,tDADBTC,tDADETH,tORSUSD,tORSBTC,tORSETH,tAUCUSD,tAUCBTC,tAUCETH,tPOYUSD,tPOYBTC,tPOYETH,tFSNUSD,tFSNBTC,tFSNETH,tCBTUSD,tCBTBTC,tCBTETH,tZCNUSD,tZCNBTC,tZCNETH,tSENUSD,tSENBTC,tSENETH,tNCAUSD,tNCABTC,tNCAETH,tCNDUSD,tCNDBTC,tCNDETH,tCTXUSD,tCTXBTC,tCTXETH,tPAIUSD,tPAIBTC,tSEEUSD,tSEEBTC,tSEEETH,tESSUSD,tESSBTC,tESSETH,tATMUSD,tATMBTC,tATMETH,tHOTUSD,tHOTBTC,tHOTETH,tDTAUSD,tDTABTC,tDTAETH,tIQXUSD,tIQXBTC,tIQXEOS,tWPRUSD,tWPRBTC,PRETH,tZILUSD,tZILBTC,tZILETH,tBNTUSD,tBNTBTC,tBNTETH,tABSUSD,tABSETH,tXRAUSD,tXRAETH,tMANUSD,tMANETH,tBBNUSD,tBBNETH,tNIOUSD,tNIOETH,tDGXUSD,tDGXETH,tVETUSD,tVETBTC,tVETETH,tUTNUSD,tUTNETH,tTKNUSD,tTKNETH,tGOTUSD,tGOTEUR,tGOTETH,tXTZUSD,tXTZBTC,tCNNUSD,tCNNETH,tBOXUSD,tBOXETH,tTRXEUR,tTRXGBP,tTRXJPY,tMGOUSD,tMGOETH,tRTEUSD,tRTEETH,tYGGUSD,tYGGETH,tMLNUSD,tMLNETH,tWTCUSD,tWTCETH,tCSXUSD,tCSXETH,tOMNUSD,tOMNBTC,tINTUSD,tINTETH,tDRNUSD,tDRNETH,tPNKUSD,tPNKETH,tDGBUSD,tDGBBTC,tBSVUSD,tBSVBTC,tBABUSD,tBABBTC,tWLOUSD,tWLOXLM,tVLDUSD,tVLDETH,tENJUSD,tENJETH,tONLUSD,tONLETH,tRBTUSD,tRBTBTC,tUSTUSD,tEUTEUR,tEUTUSD,tGSDUSD,tUDCUSD,tTSDUSD,tPAXUSD,tRIFUSD,tRIFBTC,tPASUSD,tPASETH,tVSYUSD,tVSYBTC,tZRXDAI,tMKRDAI,tOMGDAI,tBTTUSD,tBTTBTC,tBTCUST,tETHUST,tCLOUSD,tCLOBTC,tIMPUSD,tIMPETH,tLTCUST,tEOSUST,tBABUST,tSCRUSD,tSCRETH,tGNOUSD,tGNOETH,tGENUSD,tGENETH,tATOUSD,tATOBTC,tATOETH,tWBTUSD,tXCHUSD,tEUSUSD,tWBTETH,tXCHETH,tEUSETH,tLEOUSD,tLEOBTC,tLEOUST,tLEOEOS,tLEOETH,tASTUSD,tASTETH,tFOAUSD,tFOAETH,tUFRUSD,tUFRETH,tZBTUSD,tZBTUST,tOKBUSD,tUSKUSD,tGTXUSD,tKANUSD,tOKBUST,tOKBETH,tOKBBTC,tUSKUST,tUSKETH,tUSKBTC,tUSKEOS,tGTXUST,tKANUST,tAMPUSD,tALGUSD,tALGBTC,tALGUST,tBTCXCH,tSWMUSD,tSWMETH,tTRIUSD,tTRIETH,tLOOUSD,tLOOETH,tAMPUST,tDUSK:USD,tDUSK:BTC,tUOSUSD,tUOSBTC,tRRBUSD,tRRBUST,tDTXUSD,tDTXUST,tAMPBTC,tFTTUSD,tFTTUST,tPAXUST,tUDCUST,tTSDUST,tBTC:CNHT,tUST:CNHT,tCNH:CNHT,tCHZUSD,tCHZUST,tBTCF0:USTF0,tETHF0:USTF0"));
services.AddRateProviderExchangeSharp<ExchangeOKExAPI>(new("okex", "OKEx", "https://www.okex.com/api/futures/v3/instruments/ticker")); services.AddRateProviderExchangeSharp<ExchangeOKExAPI>(new("okex", "OKEx", "https://www.okex.com/api/futures/v3/instruments/ticker"));
services.AddRateProviderExchangeSharp<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products")); services.AddRateProviderExchangeSharp<ExchangeCoinbaseAPI>(new("coinbasepro", "Coinbase Pro", "https://api.pro.coinbase.com/products"));

View File

@@ -7,6 +7,7 @@ using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client; using BTCPayServer.Client;
using BTCPayServer.Data; using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.Views; using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.Subscriptions.Controllers;
using BTCPayServer.Services.Mails; using BTCPayServer.Services.Mails;
using Dapper; using Dapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
@@ -53,9 +54,24 @@ public class UIStoreEmailRulesController(
[HttpGet("create")] [HttpGet("create")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public IActionResult StoreEmailRulesCreate(string storeId) public IActionResult StoreEmailRulesCreate(
string storeId,
string offeringId = null,
string trigger = null,
string condition = null,
string to = null,
string redirectUrl = null)
{ {
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers)); return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(null, triggers)
{
CanChangeTrigger = trigger is null,
CanChangeCondition = offeringId is null,
Condition = condition,
Trigger = trigger,
OfferingId = offeringId,
RedirectUrl = redirectUrl,
To = to
});
} }
[HttpPost("create")] [HttpPost("create")]
@@ -64,7 +80,9 @@ public class UIStoreEmailRulesController(
{ {
await ValidateCondition(model); await ValidateCondition(model);
if (!ModelState.IsValid) if (!ModelState.IsValid)
return StoreEmailRulesCreate(storeId); return StoreEmailRulesCreate(storeId,
model.OfferingId,
model.CanChangeTrigger ? null : model.Trigger);
await using var ctx = dbContextFactory.CreateContext(); await using var ctx = dbContextFactory.CreateContext();
var c = new EmailRuleData() var c = new EmailRuleData()
@@ -74,6 +92,7 @@ public class UIStoreEmailRulesController(
Body = model.Body, Body = model.Body,
Subject = model.Subject, Subject = model.Subject,
Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition, Condition = string.IsNullOrWhiteSpace(model.Condition) ? null : model.Condition,
OfferingId = model.OfferingId,
To = model.ToAsArray() To = model.ToAsArray()
}; };
c.SetBTCPayAdditionalData(model.AdditionalData); c.SetBTCPayAdditionalData(model.AdditionalData);
@@ -81,18 +100,32 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]); this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully created"]);
return GoToStoreEmailRulesList(storeId, model);
}
private IActionResult GoToStoreEmailRulesList(string storeId, StoreEmailRuleViewModel model)
=> GoToStoreEmailRulesList(storeId, model.RedirectUrl);
private IActionResult GoToStoreEmailRulesList(string storeId, string redirectUrl)
{
if (redirectUrl != null)
return LocalRedirect(redirectUrl);
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId }); return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
} }
[HttpGet("{ruleId}/edit")] [HttpGet("{ruleId}/edit")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId) public async Task<IActionResult> StoreEmailRulesEdit(string storeId, long ruleId, string redirectUrl = null)
{ {
await using var ctx = dbContextFactory.CreateContext(); await using var ctx = dbContextFactory.CreateContext();
var r = await ctx.EmailRules.GetRule(storeId, ruleId); var r = await ctx.EmailRules.GetRule(storeId, ruleId);
if (r is null) if (r is null)
return NotFound(); return NotFound();
return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers)); return View("StoreEmailRulesManage", new StoreEmailRuleViewModel(r, triggers)
{
CanChangeTrigger = r.OfferingId is null,
CanChangeCondition = r.OfferingId is null,
RedirectUrl = redirectUrl
});
} }
[HttpPost("{ruleId}/edit")] [HttpPost("{ruleId}/edit")]
@@ -116,7 +149,7 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]); this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully updated"]);
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId }); return GoToStoreEmailRulesList(storeId, model);
} }
private async Task ValidateCondition(StoreEmailRuleViewModel model) private async Task ValidateCondition(StoreEmailRuleViewModel model)
@@ -142,7 +175,7 @@ public class UIStoreEmailRulesController(
[HttpPost("{ruleId}/delete")] [HttpPost("{ruleId}/delete")]
[Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)] [Authorize(Policy = Policies.CanModifyStoreSettings, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId) public async Task<IActionResult> StoreEmailRulesDelete(string storeId, long ruleId, string redirectUrl = null)
{ {
await using var ctx = dbContextFactory.CreateContext(); await using var ctx = dbContextFactory.CreateContext();
var r = await ctx.EmailRules.GetRule(storeId, ruleId); var r = await ctx.EmailRules.GetRule(storeId, ruleId);
@@ -152,6 +185,7 @@ public class UIStoreEmailRulesController(
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]); this.TempData.SetStatusSuccess(StringLocalizer["Email rule successfully deleted"]);
} }
return RedirectToAction(nameof(StoreEmailRulesList), new { storeId });
return GoToStoreEmailRulesList(storeId, redirectUrl);
} }
} }

View File

@@ -8,6 +8,23 @@ namespace Microsoft.AspNetCore.Mvc;
public static class EmailsUrlHelperExtensions public static class EmailsUrlHelperExtensions
{ {
public class EmailRuleParams
{
public string? OfferingId { get; set; }
public string? Trigger { get; set; }
public string? Condition { get; set; }
public string? RedirectUrl { get; set; }
public string? To { get; set; }
}
public static string CreateEmailRuleLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl,
EmailRuleParams? param = null)
=> linkGenerator.GetUriByAction(
action: nameof(UIStoreEmailRulesController.StoreEmailRulesCreate),
controller: "UIStoreEmailRules",
values: new { area = EmailsPlugin.Area, storeId, offeringId = param?.OfferingId, trigger = param?.Trigger, condition = param?.Condition, redirectUrl = param?.RedirectUrl, to = param?.To },
baseUrl);
public static string GetStoreEmailRulesLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl) public static string GetStoreEmailRulesLink(this LinkGenerator linkGenerator, string storeId, RequestBaseUrl baseUrl)
=> linkGenerator.GetUriByAction( => linkGenerator.GetUriByAction(
action: nameof(UIStoreEmailRulesController.StoreEmailRulesList), action: nameof(UIStoreEmailRulesController.StoreEmailRulesList),

View File

@@ -17,7 +17,11 @@ public interface ITriggerOwner
Task BeforeSending(EmailRuleMatchContext context); Task BeforeSending(EmailRuleMatchContext context);
} }
public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner); public record TriggerEvent(string? StoreId, string Trigger, JObject Model, ITriggerOwner? Owner)
{
public override string ToString()
=> $"Trigger event '{Trigger}'";
}
public class EmailRuleMatchContext( public class EmailRuleMatchContext(
TriggerEvent triggerEvent, TriggerEvent triggerEvent,

View File

@@ -7,7 +7,7 @@ namespace BTCPayServer.Plugins.Emails.Views;
/// </summary> /// </summary>
public class EmailTriggerViewModel public class EmailTriggerViewModel
{ {
public string Type { get; set; } public string Trigger { get; set; }
public string Description { get; set; } public string Description { get; set; }
public string SubjectExample { get; set; } public string SubjectExample { get; set; }
public string BodyExample { get; set; } public string BodyExample { get; set; }

View File

@@ -17,6 +17,7 @@ public class StoreEmailRuleViewModel
if (data is not null) if (data is not null)
{ {
Data = data; Data = data;
OfferingId = data.OfferingId;
AdditionalData = data.GetBTCPayAdditionalData() ?? new(); AdditionalData = data.GetBTCPayAdditionalData() ?? new();
Trigger = data.Trigger; Trigger = data.Trigger;
Subject = data.Subject; Subject = data.Subject;
@@ -46,6 +47,11 @@ public class StoreEmailRuleViewModel
public string To { get; set; } public string To { get; set; }
public List<EmailTriggerViewModel> Triggers { get; set; } public List<EmailTriggerViewModel> Triggers { get; set; }
public string RedirectUrl { get; set; }
public bool CanChangeTrigger { get; set; } = true;
public bool CanChangeCondition { get; set; } = true;
public string OfferingId { get; set; }
public string[] ToAsArray() public string[] ToAsArray()
=> (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries) => (To ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(t => t.Trim()) .Select(t => t.Trim())

View File

@@ -31,17 +31,38 @@
</div> </div>
<partial name="_StatusMessage" /> <partial name="_StatusMessage" />
<input type="hidden" asp-for="OfferingId"></input>
<input type="hidden" asp-for="RedirectUrl"></input>
<div class="form-group"> <div class="form-group">
<label asp-for="Trigger" class="form-label" data-required></label> <label asp-for="Trigger" class="form-label" data-required></label>
<select asp-for="Trigger" asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Type))" <input type="hidden" asp-for="CanChangeTrigger"></input>
@if (Model.CanChangeTrigger)
{
<select asp-for="Trigger"
asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Trigger))"
class="form-select email-rule-trigger" required></select> class="form-select email-rule-trigger" required></select>
<span asp-validation-for="Trigger" class="text-danger"></span> <span asp-validation-for="Trigger" class="text-danger"></span>
<div class="form-text" text-translate="true">Choose what event sends the email.</div> <div class="form-text" text-translate="true">Choose what event sends the email.</div>
}
else
{
<input type="hidden" asp-for="Trigger"></input>
<input asp-for="Trigger" class="form-control email-rule-trigger-hidden" disabled></input>
}
</div> </div>
<div class="form-group"> <div class="form-group">
<label asp-for="Condition" class="form-label"></label> <label asp-for="Condition" class="form-label"></label>
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $?(@.Invoice.Metadata.buyerName == \"john\"))"]" /> <input type="hidden" asp-for="CanChangeCondition" ></input>
@if (Model.CanChangeCondition)
{
<input asp-for="Condition" class="form-control" placeholder="@StringLocalizer["A Postgres compatible JSON Path (eg. $.Offering.Id == \"john\")"]" />
}
else
{
<input type="hidden" asp-for="Condition"></input>
<input asp-for="Condition" class="form-control" disabled></input>
}
<span asp-validation-for="Condition" class="text-danger"></span> <span asp-validation-for="Condition" class="text-danger"></span>
<div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div> <div class="form-text" text-translate="true">Only send email when the specified JSON Path exists</div>
</div> </div>
@@ -86,11 +107,12 @@
var triggers = @Safe.Json(Model.Triggers); var triggers = @Safe.Json(Model.Triggers);
var triggersByType = {}; var triggersByType = {};
for (var i = 0; i < triggers.length; i++) { for (var i = 0; i < triggers.length; i++) {
triggersByType[triggers[i].type] = triggers[i]; triggersByType[triggers[i].trigger] = triggers[i];
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
const triggerSelect = document.querySelector('.email-rule-trigger'); const triggerSelect = document.querySelector('.email-rule-trigger') ?? document.querySelector('.email-rule-trigger-hidden');
const subjectInput = document.querySelector('.email-rule-subject'); const subjectInput = document.querySelector('.email-rule-subject');
const bodyTextarea = document.querySelector('.email-rule-body'); const bodyTextarea = document.querySelector('.email-rule-body');
const placeholdersTd = document.querySelector('#placeholders'); const placeholdersTd = document.querySelector('#placeholders');
@@ -103,6 +125,7 @@
function applyTemplate() { function applyTemplate() {
const selectedTrigger = triggerSelect.value; const selectedTrigger = triggerSelect.value;
console.log(selectedTrigger);
if (triggersByType[selectedTrigger]) { if (triggersByType[selectedTrigger]) {
if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) { if (isEmptyOrDefault(subjectInput.value, 'subjectExample')) {
subjectInput.value = triggersByType[selectedTrigger].subjectExample; subjectInput.value = triggersByType[selectedTrigger].subjectExample;

View File

@@ -0,0 +1,29 @@
using System;
namespace BTCPayServer.Plugins.Subscriptions;
public class BalanceTransaction
{
public long SubscriberId { get; }
public decimal Credit { get; }
public decimal Debit { get; }
public string Description { get; }
public string Currency { get; set; }
public decimal Diff { get; }
public BalanceTransaction(long subscriberId, string currency, decimal credit, decimal debit, string description)
{
if (credit < 0)
throw new ArgumentOutOfRangeException(nameof(credit), "Credit must be positive.");
if (debit < 0)
throw new ArgumentOutOfRangeException(nameof(debit), "Debit must be positive.");
SubscriberId = subscriberId;
Credit = credit;
Debit = debit;
Description = description;
Diff = Credit - Debit;
Currency = currency;
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,604 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Constants;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.Models;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Services;
using BTCPayServer.Services.Apps;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Mails;
using BTCPayServer.Views.UIStoreMembership;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using DisplayFormatter = BTCPayServer.Services.DisplayFormatter;
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
[Authorize(Policy = Policies.CanViewMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
[AutoValidateAntiforgeryToken]
[Area(SubscriptionsPlugin.Area)]
public partial class UIOfferingController(
ApplicationDbContextFactory dbContextFactory,
IStringLocalizer stringLocalizer,
LinkGenerator linkGenerator,
EventAggregator eventAggregator,
SubscriptionHostedService subsService,
AppService appService,
BTCPayServerEnvironment env,
DisplayFormatter displayFormatter,
EmailSenderFactory emailSenderFactory,
IHtmlHelper htmlHelper,
IEnumerable<EmailTriggerViewModel> emailTriggers
) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
{
[HttpPost("stores/{storeId}/offerings/{offeringId}/new-subscriber")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> NewSubscriber(
string storeId, string offeringId,
string planId,
bool isTrial,
int linkExpiration,
string? prefilledEmail = null)
{
await using var ctx = DbContextFactory.CreateContext();
var plan = await ctx.Plans.GetPlanFromId(planId);
if (plan is null)
return NotFound();
var checkoutData = new PlanCheckoutData()
{
PlanId = planId,
IsTrial = plan.TrialDays > 0 && isTrial,
NewSubscriber = true,
TestAccount = env.CheatMode,
SuccessRedirectUrl = LinkGenerator.OfferingLink(storeId, offeringId, SubscriptionSection.Subscribers, Request.GetRequestBaseUrl()),
BaseUrl = Request.GetRequestBaseUrl(),
Expiration = DateTimeOffset.UtcNow.AddDays(linkExpiration),
};
if (prefilledEmail != null && prefilledEmail.IsValidEmail())
checkoutData.InvoiceMetadata = new InvoiceMetadata() { BuyerEmail = prefilledEmail }.ToJObject().ToString();
ctx.PlanCheckouts.Add(checkoutData);
await ctx.SaveChangesAsync();
return RedirectToPlanCheckout(checkoutData.Id);
}
[HttpGet("stores/{storeId}/offerings")]
public IActionResult CreateOffering(string storeId)
{
return View();
}
public class FormatCurrencyRequest
{
[JsonProperty("currency")]
public string? Currency { get; set; }
[JsonProperty("amount")]
public decimal Amount { get; set; }
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/format-currency")]
[IgnoreAntiforgeryToken]
public string FormatCurrency(string storeId, string offeringId, [FromBody] FormatCurrencyRequest? req)
=> displayFormatter.Currency(req?.Amount ?? 0m, req?.Currency ?? "USD", DisplayFormatter.CurrencyFormat.CodeAndSymbol);
[HttpPost("stores/{storeId}/offerings/{offeringId}/Subscribers")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> SubscriberSuspend(string storeId, string offeringId, string customerId, string? command = null,
string? suspensionReason = null, decimal? amount = null, string? description = null)
{
await using var ctx = DbContextFactory.CreateContext();
var sub = await ctx.Subscribers.GetByCustomerId(customerId, offeringId: offeringId, storeId: storeId);
if (sub is null)
return NotFound();
var subName = sub.Customer.GetPrimaryIdentity() ?? sub.CustomerId;
if (command is "unsuspend" or "suspend")
{
var canSwitch = (!sub.IsSuspended && command == "suspend") || (sub.IsSuspended && command == "unsuspend");
if (canSwitch)
{
await SubsService.ToggleSuspend(sub.Id, suspensionReason);
await ctx.Entry(sub).ReloadAsync();
var message = sub.IsSuspended
? StringLocalizer["Subscriber {0} is now suspended", subName]
: StringLocalizer["Subscriber {0} is now unsuspended", subName];
TempData.SetStatusSuccess(message);
}
}
else if (command is "toggle-test")
{
sub.TestAccount = !sub.TestAccount;
await ctx.SaveChangesAsync();
TempData.SetStatusSuccess(StringLocalizer["Subscriber {0} is now {1}", subName, sub.TestAccount ? "test" : "live"]);
}
else if (command is "credit" or "charge" && amount is > 0)
{
var message = command is "credit"
? StringLocalizer["Subscriber {0} has been credited", subName]
: StringLocalizer["Subscriber {0} has been charged", subName];
await SubsService.UpdateCredit(sub.Id, description ?? "Manual adjustment", command is "credit" ? amount.Value : -amount.Value);
TempData.SetStatusSuccess(message);
}
return GoToOffering(storeId, offeringId, SubscriptionSection.Subscribers);
}
private RedirectToActionResult GoToOffering(string storeId, string offeringId, SubscriptionSection section = SubscriptionSection.Plans)
=> RedirectToAction(nameof(Offering), new { storeId, offeringId, section = section });
[HttpPost("stores/{storeId}/offerings")]
public async Task<IActionResult> CreateOffering(string storeId, CreateOfferingViewModel vm, string? command = null)
{
if (env.CheatMode && command == "create-fake")
{
return await CreateFakeOffering(storeId, vm);
}
if (!ModelState.IsValid)
return View();
var app = new AppData()
{
Name = vm.Name,
AppType = SubscriptionsAppType.AppType,
StoreDataId = storeId
};
app.SetSettings(new SubscriptionsAppType.AppConfig());
await appService.UpdateOrCreateApp(app, sendEvents: false);
await using var ctx = DbContextFactory.CreateContext();
var o = new OfferingData()
{
AppId = app.Id,
};
ctx.Offerings.Add(o);
await ctx.SaveChangesAsync();
app.SetSettings(new SubscriptionsAppType.AppConfig()
{
OfferingId = o.Id
});
await appService.UpdateOrCreateApp(app, sendEvents: false);
eventAggregator.Publish(new AppEvent.Created(app));
this.TempData.SetStatusMessageModel(new()
{
Html = StringLocalizer["New offering created. You can now <a href='{0}' class='alert-link'>configure it.</a>",
Url.Action(nameof(ConfigureOffering), new { storeId, offeringId = o.Id })!],
Severity = StatusMessageModel.StatusSeverity.Success
});
return GoToOffering(storeId, o.Id, SubscriptionSection.Plans);
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/Mails")]
public async Task<IActionResult> SaveMailSettings(string storeId, string offeringId, SubscriptionsViewModel vm, string? addEmailRule = null)
{
await using var ctx = DbContextFactory.CreateContext();
if (addEmailRule is not null)
{
var requestBase = Request.GetRequestBaseUrl();
var link = LinkGenerator.CreateEmailRuleLink(storeId, requestBase, new()
{
OfferingId = offeringId,
Trigger = addEmailRule,
To = "{Subscriber.Email}",
Condition = $"$.Offering.Id == \"{offeringId}\"",
RedirectUrl = new Uri(LinkGenerator.OfferingLink(storeId, offeringId, SubscriptionSection.Mails, requestBase)).AbsolutePath
});
return Redirect(link);
}
else
{
if (!ModelState.IsValid)
return await Offering(storeId, offeringId, SubscriptionSection.Mails);
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
if (offering is null)
return NotFound();
var update = offering.DefaultPaymentRemindersDays != vm.PaymentRemindersDays;
if (update)
{
offering.DefaultPaymentRemindersDays = vm.PaymentRemindersDays;
await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Settings saved"]);
}
return GoToOffering(storeId, offeringId, SubscriptionSection.Mails);
}
}
[HttpGet("stores/{storeId}/offerings/{offeringId}/{section}")]
public async Task<IActionResult> Offering(string storeId, string offeringId, SubscriptionSection section = SubscriptionSection.Plans,
string? checkoutPlanId = null, string? searchTerm = null)
{
await using var ctx = DbContextFactory.CreateContext();
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
if (offering is null)
return NotFound();
var plans = await ctx.Plans
.Where(p => p.OfferingId == offeringId)
.ToListAsync();
if (checkoutPlanId is not null)
{
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
if (checkout is not null && checkout.Subscriber is { Customer: { } cust })
TempData.SetStatusSuccess(StringLocalizer["Subscriber '{0}' successfully created", cust.GetPrimaryIdentity() ?? ""]);
}
var vm = new SubscriptionsViewModel(offering) { Section = section };
vm.TotalPlans = plans.Count;
vm.TotalSubscribers = plans.Select(p => p.MemberCount).Sum();
var total = plans.Where(p => p.Currency == vm.Currency).Select(p => p.MonthlyRevenue).Sum();
vm.TotalMonthlyRevenue = displayFormatter.Currency(total, vm.Currency, DisplayFormatter.CurrencyFormat.Symbol);
vm.SelectablePlans = plans
.Where(p => p.Status == PlanData.PlanStatus.Active)
.OrderBy(p => p.Name)
.Select((p, i) => new SubscriptionsViewModel.SelectablePlan(p.Name, p.Id, p.TrialDays > 0))
.ToList();
if (section == SubscriptionSection.Plans)
{
plans = plans
.OrderBy(p => p.Status switch
{
PlanData.PlanStatus.Active => 0,
_ => 1
})
.ThenByDescending(o => o.CreatedAt)
.ToList();
vm.Plans = plans.Select(p =>
new SubscriptionsViewModel.PlanViewModel()
{
Data = p
}).ToList();
}
else if (section == SubscriptionSection.Subscribers)
{
// searchTerm
int maxMembers = 100;
var query = ctx.Subscribers
.IncludeAll()
.Where(m => m.OfferingId == offeringId);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
query = query.Where(u => (u.Customer.CustomerIdentities.Any(c => c.Value == searchTerm)) ||
u.Customer.Name.Contains(searchTerm) ||
(u.Customer.ExternalRef != null && u.Customer.ExternalRef.Contains(searchTerm)));
}
vm.SearchTerm = searchTerm;
var members = query
.OrderBy(m => m.IsActive ? 0 : 1)
.ThenBy(m => m.Plan.Name)
.ThenByDescending(m => m.CreatedAt)
.Take(maxMembers)
.ToList();
vm.Subscribers = members
.Select(v => new SubscriptionsViewModel.MemberViewModel()
{
Data = v,
}).ToList();
vm.TooMuchSubscribers = members.Count == maxMembers;
}
else if (section == SubscriptionSection.Mails)
{
var settings = await emailSenderFactory.GetSettings(storeId);
vm.EmailConfigured = settings is not null;
vm.PaymentRemindersDays = offering.DefaultPaymentRemindersDays;
vm.EmailRules = new();
var triggers = emailTriggers
.Where(t => WebhookSubscriptionEvent.IsSubscriptionTrigger(t.Trigger))
.ToDictionary(t => t.Trigger);
vm.AvailableTriggers = triggers.Values.ToList();
foreach (var emailRule in
await ctx.EmailRules
.Where(r => r.StoreId == storeId && r.OfferingId == offeringId)
.ToListAsync())
{
if (!triggers.TryGetValue(emailRule.Trigger, out var triggerViewModel))
continue;
vm.EmailRules.Add(new(emailRule)
{
TriggerViewModel = triggerViewModel
});
triggers.Remove(triggerViewModel.Trigger);
}
}
return View(nameof(Offering), vm);
}
[HttpGet("stores/{storeId}/offerings/{offeringId}/configure")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> ConfigureOffering(string storeId, string offeringId)
{
await using var ctx = DbContextFactory.CreateContext();
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
if (offering is null)
return NotFound();
return View(new ConfigureOfferingViewModel(offering));
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/configure")]
public async Task<IActionResult> ConfigureOffering(
string storeId,
string offeringId,
ConfigureOfferingViewModel vm,
string? command = null,
int? removeIndex = null)
{
await using var ctx = DbContextFactory.CreateContext();
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
if (offering is null)
return NotFound();
vm.Data = offering;
bool itemsUpdated = false;
if (command == "AddItem")
{
vm.Entitlements ??= new();
vm.Entitlements.Add(new());
itemsUpdated = true;
}
else if (removeIndex is int i)
{
vm.Entitlements.RemoveAt(i);
itemsUpdated = true;
}
if (itemsUpdated)
{
this.ModelState.Clear();
vm.Anchor = "entitlements";
}
if (!ModelState.IsValid || itemsUpdated)
return View(vm);
offering.SuccessRedirectUrl = vm.SuccessRedirectUrl;
offering.App.Name = vm.Name;
UpdateEntitlements(ctx, offering, vm);
await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Offering configuration updated"]);
return GoToOffering(storeId, offeringId);
}
private static void UpdateEntitlements(ApplicationDbContext ctx, OfferingData offering, ConfigureOfferingViewModel vm)
{
var incomingById = vm.Entitlements
.GroupBy(e => e.Id) // guard against dupes
.ToDictionary(g => g.Key, g => g.First());
var existingById = offering.Entitlements
.ToDictionary(e => e.CustomId);
var toRemove = offering.Entitlements
.Where(e => !incomingById.ContainsKey(e.CustomId))
.ToList();
foreach (var e in toRemove)
offering.Entitlements.Remove(e);
ctx.Entitlements.RemoveRange(toRemove);
foreach (var (id, vmEnt) in incomingById)
{
if (!existingById.TryGetValue(id, out var entity))
{
entity = new();
entity.CustomId = vmEnt.Id;
entity.OfferingId = offering.Id;
offering.Entitlements.Add(entity);
}
entity.Description = vmEnt.ShortDescription;
}
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/plans/{planId}/delete-plan")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> DeletePlan(string storeId, string offeringId, string planId)
{
await using var ctx = DbContextFactory.CreateContext();
var plan = await ctx.Plans.GetPlanFromId(planId, offeringId, storeId);
if (plan is null)
return NotFound();
var canDelete = !await ctx.Subscribers.Where(s => s.PlanId == planId).AnyAsync();
if (!canDelete)
{
TempData.SetStatusMessageModel(new()
{
Severity = StatusMessageModel.StatusSeverity.Error,
Html = StringLocalizer["Cannot delete plan. It is currently in use by subscribers."]
});
}
else
{
ctx.Plans.Remove(plan);
await ctx.SaveChangesAsync();
this.TempData.SetStatusSuccess(StringLocalizer["Plan deleted"]);
}
return GoToOffering(storeId, offeringId);
}
[HttpGet("stores/{storeId}/offerings/{offeringId}/add-plan")]
[HttpGet("stores/{storeId}/offerings/{offeringId}/plans/{planId}/edit")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> AddPlan(string storeId, string offeringId, string? planId = null)
{
await using var ctx = DbContextFactory.CreateContext();
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
if (offering is null)
return NotFound();
var plan = planId is not null ? await ctx.Plans.GetPlanFromId(planId, offeringId, storeId) : null;
if (plan is null && planId is not null)
return NotFound();
var vm = new AddEditPlanViewModel()
{
OfferingId = offeringId,
PlanId = planId,
OfferingName = offering.App.Name,
Currency = this.HttpContext.GetStoreData().GetStoreBlob().DefaultCurrency,
Price = plan?.Price ?? 0m,
Name = plan?.Name ?? "",
Description = plan?.Description ?? "",
RecurringType = plan?.RecurringType ?? PlanData.RecurringInterval.Monthly,
GracePeriodDays = plan?.GracePeriodDays ?? 0,
TrialDays = plan?.TrialDays ?? 0,
OptimisticActivation = plan?.OptimisticActivation ?? false,
Renewable = plan?.Renewable ?? true,
PlanChanges = offering.Plans
.Where(p => p.Id != planId && p.Status == PlanData.PlanStatus.Active)
.Select(p => new AddEditPlanViewModel.PlanChange()
{
PlanId = p.Id,
PlanName = p.Name,
SelectedType = plan?.PlanChanges
.FirstOrDefault(pc => pc.PlanChangeId == p.Id)?
.Type.ToString() ?? "None"
})
.OrderBy(p => p.PlanName)
.ToList(),
Entitlements = offering.Entitlements.OrderBy(e => e.CustomId).Select(e => new AddEditPlanViewModel.Entitlement()
{
CustomId = e.CustomId,
ShortDescription = e.Description,
Selected = plan?.GetEntitlement(e.Id) is not null
}).ToList(),
};
return View(vm);
}
[HttpPost("stores/{storeId}/offerings/{offeringId}/add-plan")]
[HttpPost("stores/{storeId}/offerings/{offeringId}/plans/{planId}/edit")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> AddPlan(string storeId, string offeringId, AddEditPlanViewModel vm, string? planId = null, string? command = null,
int? removeIndex = null)
{
if (!ModelState.IsValid)
return await AddPlan(storeId, offeringId, planId);
await using var ctx = DbContextFactory.CreateContext();
var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
// Check if the offering is part of the store
if (offering is null)
return NotFound();
var plan = planId is not null ? await ctx.Plans.GetPlanFromId(planId, offeringId, storeId) : null;
if (plan is null && planId is not null)
return NotFound();
plan ??= new PlanData();
plan.Name = vm.Name;
plan.Description = vm.Description;
plan.Price = vm.Price;
plan.Currency = vm.Currency;
plan.GracePeriodDays = vm.GracePeriodDays;
plan.TrialDays = vm.TrialDays;
plan.OptimisticActivation = vm.OptimisticActivation;
plan.Renewable = vm.Renewable;
if (planId is null)
plan.CreatedAt = DateTimeOffset.UtcNow;
plan.RecurringType = vm.RecurringType;
plan.OfferingId = vm.OfferingId;
plan.PlanEntitlements ??= new();
plan.PlanChanges ??= new();
foreach (var vmPC in vm.PlanChanges)
{
var existing = plan.PlanChanges.FirstOrDefault(pc => pc.PlanChangeId == vmPC.PlanId);
if (vmPC.SelectedType == "None" && existing is not null)
{
plan.PlanChanges.Remove(existing);
ctx.PlanChanges.Remove(existing);
}
if (vmPC.SelectedType == "None" && existing is null)
{
continue;
}
else if (existing is null)
{
var pc = new PlanChangeData();
pc.PlanId = plan.Id;
pc.PlanChangeId = vmPC.PlanId;
plan.PlanChanges.Add(pc);
ctx.PlanChanges.Add(pc);
existing = pc;
}
existing.Type = vmPC.SelectedType switch
{
"Upgrade" => PlanChangeData.ChangeType.Upgrade,
"Downgrade" => PlanChangeData.ChangeType.Downgrade,
_ => PlanChangeData.ChangeType.Downgrade
};
}
if (planId is null)
{
ctx.Plans.Add(plan);
}
await ctx.SaveChangesAsync();
eventAggregator.Publish(new SubscriptionEvent.PlanUpdated(plan));
var customIdsToIds = offering.Entitlements.ToDictionary(x => x.CustomId, x => x.Id);
var enabled = vm.Entitlements.Where(e => e.Selected).Select(e => customIdsToIds[e.CustomId]).ToArray();
await ctx.Database.GetDbConnection()
.ExecuteAsync("""
DELETE FROM subs_plans_entitlements
WHERE plan_id = @planId AND NOT (entitlement_id = ANY(@enabled));
INSERT INTO subs_plans_entitlements(plan_id, entitlement_id)
SELECT @planId, e FROM unnest(@enabled) e
ON CONFLICT DO NOTHING;
""", new { planId = plan.Id, enabled });
if (planId is null)
this.TempData.SetStatusSuccess(StringLocalizer["New plan created"]);
else
this.TempData.SetStatusSuccess(StringLocalizer["Plan edited"]);
return GoToOffering(plan.Offering.App.StoreDataId, plan.OfferingId);
}
[HttpGet("stores/{storeId}/offerings/{offeringId}/subscribers/{customerId}/create-portal")]
[Authorize(Policy = Policies.CanModifyMembership, AuthenticationSchemes = AuthenticationSchemes.Cookie)]
public async Task<IActionResult> CreatePortalSession(string storeId, string offeringId, string customerId)
{
await using var ctx = DbContextFactory.CreateContext();
var sub = await ctx.Subscribers.GetByCustomerId(customerId, offeringId: offeringId, storeId: storeId);
if (sub is null)
return NotFound();
var portal = new PortalSessionData()
{
SubscriberId = sub.Id,
Expiration = DateTimeOffset.UtcNow + TimeSpan.FromHours(1.0),
BaseUrl = Request.GetRequestBaseUrl()
};
ctx.PortalSessions.Add(portal);
await ctx.SaveChangesAsync();
return RedirectToSubscriberPortal(portal.Id);
}
}

View File

@@ -0,0 +1,138 @@
#nullable enable
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Models;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Views.UIStoreMembership;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
[AllowAnonymous]
[AutoValidateAntiforgeryToken]
[Area(SubscriptionsPlugin.Area)]
[Route("plan-checkout/{checkoutId}")]
public class UIPlanCheckoutController(
ApplicationDbContextFactory dbContextFactory,
LinkGenerator linkGenerator,
UriResolver uriResolver,
IStringLocalizer stringLocalizer,
SubscriptionHostedService subsService)
: UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
{
[HttpGet]
public async Task<IActionResult> PlanCheckout(string checkoutId)
{
await using var ctx = DbContextFactory.CreateContext();
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
var plan = checkout?.Plan;
if (plan is null || checkout is null)
return NotFound();
var prefilledEmail = GetInvoiceMetadata(checkout).BuyerEmail;
if (checkout.Subscriber is not null)
prefilledEmail = checkout.Subscriber.Customer.Email.Get();
var vm = new PlanCheckoutViewModel()
{
Id = plan.Id,
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, plan.Offering.App.StoreData.GetStoreBlob()),
StoreName = plan.Offering.App.StoreData.StoreName,
Title = plan.Name,
Data = plan,
Email = prefilledEmail,
IsPrefilled = prefilledEmail?.IsValidEmail() is true,
IsTrial = checkout.IsTrial
};
return View(vm);
}
private static InvoiceMetadata GetInvoiceMetadata(PlanCheckoutData checkout)
{
return InvoiceMetadata.FromJObject(JObject.Parse(checkout.InvoiceMetadata));
}
[HttpGet("plan-checkout/default-redirect")]
public async Task<IActionResult> PlanCheckoutDefaultRedirect(string? checkoutPlanId = null)
{
if (checkoutPlanId is null)
return NotFound();
await using var ctx = DbContextFactory.CreateContext();
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
if (checkout is null)
return NotFound();
return View(new PlanCheckoutDefaultRedirectViewModel(checkout)
{
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, checkout.Plan.Offering.App.StoreData.GetStoreBlob()),
StoreName = checkout.Plan.Offering.App.StoreData.StoreName,
});
}
[HttpPost]
public async Task<IActionResult> PlanCheckout(string checkoutId, PlanCheckoutViewModel vm,
CancellationToken cancellationToken = default)
{
await using var ctx = DbContextFactory.CreateContext();
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
if (checkout is null)
return NotFound();
var checkoutInvoice = checkout.InvoiceId is null ? null : await ctx.Invoices.FindAsync([checkout.InvoiceId], cancellationToken);
if (checkoutInvoice is not null)
{
var status = checkoutInvoice.GetInvoiceState().Status;
if (status is InvoiceStatus.Settled && checkout.GetRedirectUrl() is string url)
return Redirect(url);
if (status is not (InvoiceStatus.Expired or InvoiceStatus.Invalid))
return RedirectToInvoiceCheckout(checkoutInvoice.Id);
}
var subscriber = checkout.Subscriber;
CustomerSelector customerSelector;
if (subscriber is null)
{
var invoiceMetadata = GetInvoiceMetadata(checkout);
if (invoiceMetadata.BuyerEmail is not null)
vm.Email = invoiceMetadata.BuyerEmail;
customerSelector = CustomerSelector.ByEmail(vm.Email);
if (!vm.Email.IsValidEmail())
ModelState.AddModelError(nameof(vm.Email), "Invalid email format");
if (!ModelState.IsValid)
return await PlanCheckout(checkoutId);
if (checkout.NewSubscriber)
{
var sub = await ctx.Subscribers.GetBySelector(checkout.Plan.OfferingId, customerSelector);
if (sub is not null)
{
ModelState.AddModelError(nameof(vm.Email), "This email already has a subscription to this offering");
return await PlanCheckout(checkoutId);
}
}
if (invoiceMetadata.BuyerEmail is null)
{
invoiceMetadata.BuyerEmail = vm.Email;
checkout.InvoiceMetadata = invoiceMetadata.ToJObject().ToString();
await ctx.SaveChangesAsync(cancellationToken);
}
}
else
{
customerSelector = subscriber.CustomerSelector;
ModelState.Remove(nameof(vm.Email));
}
return await RedirectToPlanCheckoutPayment(checkoutId, customerSelector, cancellationToken);
}
}

View File

@@ -0,0 +1,270 @@
#nullable enable
using System;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Models;
using BTCPayServer.Services;
using BTCPayServer.Views.UIStoreMembership;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
[AllowAnonymous]
[AutoValidateAntiforgeryToken]
[Area(SubscriptionsPlugin.Area)]
[Route("subscriber-portal/{portalSessionId}")]
public class UISubscriberPortalController(
ApplicationDbContextFactory dbContextFactory,
LinkGenerator linkGenerator,
IStringLocalizer stringLocalizer,
SubscriptionHostedService subsService,
DisplayFormatter displayFormatter,
UriResolver uriResolver) : UISubscriptionControllerBase(dbContextFactory, linkGenerator, stringLocalizer, subsService)
{
[HttpGet]
public async Task<IActionResult> SubscriberPortal(string portalSessionId, string? checkoutPlanId = null, string? anchor = null, CancellationToken cancellationToken = default)
{
await using var ctx = DbContextFactory.CreateContext();
if (checkoutPlanId is not null)
{
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutPlanId);
var message = checkout switch
{
{ PlanStarted: true, IsTrial: true } => StringLocalizer["The trial has started."],
{
PlanStarted: false,
Invoice: { Status: Data.InvoiceData.Settled },
Plan: { OptimisticActivation: false }
} => StringLocalizer["Payment received, waiting for confirmation..."],
{ PlanStarted: true, RefundAmount: { } refund, Plan: { Currency: { } currency } }
=> StringLocalizer["The plan has been started. ({0} has been refunded)", displayFormatter.Currency(refund, currency)],
{ PlanStarted: true } => StringLocalizer["The plan has been started."],
_ => null
};
if (message is not null)
TempData.SetStatusSuccess(message);
return RedirectToAction(nameof(SubscriberPortal), new { portalSessionId });
}
var session = await ctx.PortalSessions.GetActiveById(portalSessionId);
var store = session?.GetStoreData();
if (session is null || store is null)
return NotFound();
var planChanges = session.Subscriber.Plan.PlanChanges
.Select(p => new SubscriberPortalViewModel.PlanChange(p.PlanChange)
{
ChangeType = p.Type
})
.ToList();
planChanges.Add(new(session.Subscriber.Plan)
{
Current = true
});
planChanges = planChanges.OrderBy(x => x switch
{
{ ChangeType: PlanChangeData.ChangeType.Downgrade } => 0,
{ Current: true } => 1,
_ => 2
}).ThenBy(x => x.Price).ThenBy(x => x.Name).ToList();
var curr = session.Subscriber.Plan.Currency;
var refundValue = session.Subscriber.GetUnusedPeriodAmount() ?? 0m;
var vm = new SubscriberPortalViewModel(session)
{
StoreName = store.StoreName,
StoreBranding = await StoreBrandingViewModel.CreateAsync(Request, uriResolver, store.GetStoreBlob()),
PlanChanges = planChanges,
Refund = (refundValue, displayFormatter.Currency(refundValue, curr, DisplayFormatter.CurrencyFormat.Symbol))
};
var creditHist = await ctx.SubscriberCreditHistory
.Where(s => s.SubscriberId == session.SubscriberId && s.Currency == session.Subscriber.Plan.Currency)
.OrderByDescending(s => s.CreatedAt)
.Take(50)
.ToArrayAsync(cancellationToken);
foreach (var hist in creditHist)
{
string desc = HtmlEncoder.Default.Encode(hist.Description);
desc = AddInvoiceLink(desc);
var histVm = new SubscriberPortalViewModel.BalanceTransactionViewModel
(
hist.CreatedAt,
new HtmlString(desc),
hist.Credit - hist.Debit,
hist.Balance
);
vm.Transactions.Add(histVm);
}
var credit = session.Subscriber.GetCredit();
var displayFormat = DisplayFormatter.CurrencyFormat.Symbol;
vm.MigratePopups = new();
foreach (var change in session.Subscriber.Plan.PlanChanges)
{
if (change.PlanChange.Currency != curr)
continue;
var run = change.PlanChange.Price;
run -= refundValue;
var usedCredit = Math.Min(credit, run);
usedCredit = Math.Max(usedCredit, 0);
run -= usedCredit;
var due = run;
due = Math.Max(due, 0);
var creditBalanceAdj = -usedCredit;
creditBalanceAdj += Math.Max(0, refundValue - change.PlanChange.Price);
var ajdText = creditBalanceAdj is 0m ? null : displayFormatter.Currency(creditBalanceAdj, curr, displayFormat);
ajdText = ajdText is null ? null : (creditBalanceAdj > 0 ? "+" : "") + ajdText;
var popup = new SubscriberPortalViewModel.MigratePopup()
{
Cost = displayFormatter.Currency(change.PlanChange.Price, curr, displayFormat),
UsedCredit = usedCredit is 0m ? null : "-" + displayFormatter.Currency(usedCredit, curr, displayFormat),
AmountDue = displayFormatter.Currency(due, curr, displayFormat),
CreditBalanceAdjustment = (ajdText, creditBalanceAdj),
};
vm.MigratePopups.Add(change.PlanChangeId, popup);
}
vm.Anchor = anchor;
return View(vm);
}
Regex invoiceIdRegex = new(@"\(Inv: ([^\)]*)\)", RegexOptions.Compiled);
private string AddInvoiceLink(string desc)
{
var match = invoiceIdRegex.Match(desc);
if (!match.Success)
return desc;
var invoiceId = match.Groups[1].Value;
var link = LinkGenerator.ReceiptLink(invoiceId, Request.GetRequestBaseUrl());
return invoiceIdRegex.Replace(desc, $"(Inv: <a href=\"{link}\">$1</a>)");
}
[HttpPost]
public async Task<IActionResult> SubscriberPortal(string portalSessionId, SubscriberPortalViewModel vm, string command,
string? changedPlanId = null,
CancellationToken cancellationToken = default)
{
await using var ctx = DbContextFactory.CreateContext();
var session = await ctx.PortalSessions.GetActiveById(portalSessionId);
if (session is null)
return NotFound();
if (command == "add-credit")
{
var value = vm.Credit?.InputAmount;
if (value is null || value.Value <= 0)
ModelState.AddModelError("Credit.InputAmount", StringLocalizer["Please enter a positive amount"]);
if (!ModelState.IsValid)
return await SubscriberPortal(portalSessionId, cancellationToken: cancellationToken);
try
{
var invoiceId = await SubsService.CreateCreditCheckout(session.Id, value);
if (invoiceId is not null)
{
return RedirectToInvoiceCheckout(invoiceId);
}
}
catch (BitpayHttpException ex)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Html = ex.Message.Replace("\n", "<br />", StringComparison.OrdinalIgnoreCase),
Severity = StatusMessageModel.StatusSeverity.Error,
AllowDismiss = true
});
return RedirectToPlanCheckout(portalSessionId);
}
}
else if (command is "migrate" or "pay")
{
var onPay = command == "migrate" ? PlanCheckoutData.OnPayBehavior.HardMigration : PlanCheckoutData.OnPayBehavior.SoftMigration;
if (command == "migrate" && changedPlanId is null)
{
if (session.Subscriber.Plan.PlanChanges.Count == 1)
changedPlanId = session.Subscriber.Plan.PlanChanges[0].PlanChangeId;
else
return RedirectToSubscriberPortal(portalSessionId, "plans");
}
var checkoutId = await SubsService.CreatePlanMigrationCheckout(session.Id, changedPlanId, onPay, Request.GetRequestBaseUrl());
return await RedirectToPlanCheckoutPayment(checkoutId, session.Subscriber.CustomerSelector, cancellationToken);
}
else if (command == "update-auto-renewal")
{
session.Subscriber.AutoRenew = !session.Subscriber.AutoRenew;
await ctx.SaveChangesAsync(cancellationToken);
}
return RedirectToSubscriberPortal(portalSessionId);
}
[HttpPost("~/move-time")]
public async Task<IActionResult> MoveTime(string portalSessionId, string? command = null)
{
await using var ctx = DbContextFactory.CreateContext();
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
if (portal is null || !portal.Subscriber.TestAccount)
return NotFound();
var selector = new SubscriptionHostedService.MemberSelector.Single(portal.SubscriberId);
if (command == "reminder" && portal.Subscriber.GetReminderDate() is { } reminderDate)
{
await SubsService.MoveTime(selector, reminderDate - DateTimeOffset.UtcNow);
TempData.SetStatusSuccess("Moved to reminder");
}
else if (command == "move7days")
{
await SubsService.MoveTime(selector, TimeSpan.FromDays(7.0));
TempData.SetStatusSuccess("Moved 7 days");
}
else
{
if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
{
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Normal);
TempData.SetStatusSuccess("Moved to normal phase");
}
else if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Normal)
{
if (portal.Subscriber.PeriodEnd is not null)
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Grace);
TempData.SetStatusSuccess("Moved to grace phase");
}
else if (portal.Subscriber.Phase == SubscriberData.PhaseTypes.Grace)
{
if (portal.Subscriber.PeriodEnd is not null)
await SubsService.MoveTime(portal.SubscriberId, SubscriberData.PhaseTypes.Expired);
TempData.SetStatusSuccess("Moved to expired phase");
}
}
return RedirectToSubscriberPortal(portalSessionId);
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Extensions;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Data;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Localization;
namespace BTCPayServer.Plugins.Subscriptions.Controllers;
public class UISubscriptionControllerBase(
ApplicationDbContextFactory dbContextFactory,
LinkGenerator linkGenerator,
IStringLocalizer stringLocalizer,
SubscriptionHostedService subsService) : Controller
{
public ApplicationDbContextFactory DbContextFactory { get; } = dbContextFactory;
public SubscriptionHostedService SubsService { get; } = subsService;
protected IStringLocalizer StringLocalizer => stringLocalizer;
protected LinkGenerator LinkGenerator => linkGenerator;
public RedirectResult RedirectToInvoiceCheckout(string invoiceId) => Redirect(linkGenerator.InvoiceCheckoutLink(invoiceId, Request.GetRequestBaseUrl()));
public IActionResult RedirectToSubscriberPortal(string portalId, string anchor = null)
=> RedirectToAction(nameof(UISubscriberPortalController.SubscriberPortal), "UISubscriberPortal", new { portalSessionId = portalId, anchor });
public IActionResult RedirectToPlanCheckout(string checkoutId)
=> RedirectToAction(nameof(UIPlanCheckoutController.PlanCheckout), "UIPlanCheckout", new { checkoutId });
public async Task<IActionResult> RedirectToPlanCheckoutPayment(string checkoutId, CustomerSelector customerSelector, CancellationToken cancellationToken)
{
try
{
await SubsService.ProceedToSubscribe(checkoutId, customerSelector, cancellationToken);
}
catch (InvalidOperationException) { }
catch (BitpayHttpException ex)
{
TempData.SetStatusMessageModel(new StatusMessageModel
{
Html = ex.Message.Replace("\n", "<br />", StringComparison.OrdinalIgnoreCase),
Severity = StatusMessageModel.StatusSeverity.Error,
AllowDismiss = true
});
return RedirectToPlanCheckout(checkoutId);
}
await using var ctx = DbContextFactory.CreateContext();
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
return checkout switch
{
{ PlanStarted: true } when checkout.GetRedirectUrl() is string url => Redirect(url),
{ InvoiceId: { } invoiceId } => RedirectToInvoiceCheckout(invoiceId),
_ => NotFound()
};
}
}

View File

@@ -0,0 +1,188 @@
#nullable enable
using System;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.Plugins.Webhooks;
using Newtonsoft.Json.Linq;
namespace BTCPayServer.Plugins.Subscriptions;
public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEvent.SubscriberEvent>
{
protected override async Task<JObject> GetEmailModel(WebhookTriggerContext<SubscriptionEvent.SubscriberEvent> webhookTriggerContext)
{
var evt = webhookTriggerContext.Event;
var model = await base.GetEmailModel(webhookTriggerContext);
model["Plan"] = new JObject()
{
["Id"] = evt.Subscriber.PlanId,
["Name"] = evt.Subscriber.Plan.Name ?? "",
};
model["Offering"] = new JObject()
{
["Name"] = evt.Subscriber.Offering.App.Name,
["Id"] = evt.Subscriber.Offering.Id,
["AppId"] = evt.Subscriber.Offering.AppId,
["Metadata"] = evt.Subscriber.Offering.Metadata,
};
model["Subscriber"] = new JObject()
{
["Phase"] = evt.Subscriber.Phase.ToString(),
// TODO: When the subscriber can customize the email, also check it!
["Email"] = evt.Subscriber.Customer.Email.Get()
};
model["Customer"] = new JObject()
{
["ExternalRef"] = evt.Subscriber.Customer.ExternalRef ?? "",
["Name"] = evt.Subscriber.Customer.Name ?? "",
["Metadata"] = evt.Subscriber.Customer.Metadata
};
return model;
}
protected override StoreWebhookEvent GetWebhookEvent(SubscriptionEvent.SubscriberEvent evt)
{
if (evt is null) throw new ArgumentNullException(nameof(evt));
var sub = evt.Subscriber;
var storeId = sub.Customer.StoreId;
var model = MapToSubscriberModel(sub);
switch (evt)
{
case SubscriptionEvent.NewSubscriber:
return new WebhookSubscriptionEvent.NewSubscriberEvent(storeId)
{
Subscriber = model
};
case SubscriptionEvent.SubscriberCredited credited:
return new WebhookSubscriptionEvent.SubscriberCreditedEvent(storeId)
{
Subscriber = model,
Total = credited.Total,
Amount = credited.Amount,
Currency = credited.Currency
};
case SubscriptionEvent.SubscriberDebited charged:
return new WebhookSubscriptionEvent.SubscriberChargedEvent(storeId)
{
Subscriber = model,
Total = charged.Total,
Amount = charged.Amount,
Currency = charged.Currency
};
case SubscriptionEvent.SubscriberActivated:
return new WebhookSubscriptionEvent.SubscriberActivatedEvent(storeId)
{
Subscriber = model
};
case SubscriptionEvent.SubscriberPhaseChanged phaseChanged:
return new WebhookSubscriptionEvent.SubscriberPhaseChangedEvent(storeId)
{
Subscriber = model,
PreviousPhase = MapPhase(phaseChanged.PreviousPhase),
CurrentPhase = MapPhase(sub.Phase)
};
case SubscriptionEvent.SubscriberDisabled:
return new WebhookSubscriptionEvent.SubscriberDisabledEvent(storeId)
{
Subscriber = model
};
case SubscriptionEvent.PaymentReminder:
return new WebhookSubscriptionEvent.PaymentReminderEvent(storeId)
{
Subscriber = model
};
case SubscriptionEvent.PlanStarted started:
return new WebhookSubscriptionEvent.PlanStartedEvent(storeId)
{
Subscriber = model,
AutoRenew = started.AutoRenew
};
case SubscriptionEvent.NeedUpgrade upgrade:
return new WebhookSubscriptionEvent.NeedUpgradeEvent(storeId)
{
Subscriber = model
};
default:
throw new ArgumentOutOfRangeException(nameof(evt), evt.GetType(), "Unsupported subscription event type");
}
static WebhookSubscriptionEvent.SubscriptionPhase MapPhase(SubscriberData.PhaseTypes p) =>
p switch
{
SubscriberData.PhaseTypes.Normal => WebhookSubscriptionEvent.SubscriptionPhase.Normal,
SubscriberData.PhaseTypes.Expired => WebhookSubscriptionEvent.SubscriptionPhase.Expired,
SubscriberData.PhaseTypes.Grace => WebhookSubscriptionEvent.SubscriptionPhase.Grace,
SubscriberData.PhaseTypes.Trial => WebhookSubscriptionEvent.SubscriptionPhase.Trial,
_ => WebhookSubscriptionEvent.SubscriptionPhase.Expired
};
}
private static SubscriberModel MapToSubscriberModel(SubscriberData sub)
{
if (sub is null) throw new ArgumentNullException(nameof(sub));
var customer = sub.Customer;
var offering = sub.Offering;
var plan = sub.Plan;
return new SubscriberModel
{
Customer = new CustomerModel
{
StoreId = customer.StoreId,
Id = sub.CustomerId,
ExternalId = customer.ExternalRef
},
Offer = new OfferingModel
{
Id = sub.OfferingId,
AppName = offering.App?.Name,
AppId = offering.AppId,
SuccessRedirectUrl = offering.SuccessRedirectUrl
},
Plan = new SubscriptionPlanModel
{
Id = sub.PlanId,
Name = plan.Name,
Status = plan.Status switch
{
PlanData.PlanStatus.Active => SubscriptionPlanModel.PlanStatus.Active,
PlanData.PlanStatus.Retired => SubscriptionPlanModel.PlanStatus.Retired,
_ => SubscriptionPlanModel.PlanStatus.Retired
},
Price = plan.Price,
Currency = plan.Currency,
RecurringType = plan.RecurringType switch
{
PlanData.RecurringInterval.Monthly => SubscriptionPlanModel.RecurringInterval.Monthly,
PlanData.RecurringInterval.Quarterly => SubscriptionPlanModel.RecurringInterval.Quarterly,
PlanData.RecurringInterval.Yearly => SubscriptionPlanModel.RecurringInterval.Yearly,
_ => SubscriptionPlanModel.RecurringInterval.Lifetime
},
GracePeriodDays = plan.GracePeriodDays,
TrialDays = plan.TrialDays,
Description = plan.Description,
MemberCount = plan.MemberCount,
OptimisticActivation = plan.OptimisticActivation,
Entitlements = plan.GetEntitlementIds()
},
PeriodEnd = sub.PeriodEnd,
TrialEnd = sub.TrialEnd,
GracePeriodEnd = sub.GracePeriodEnd,
IsActive = sub.IsActive,
IsSuspended = sub.IsSuspended,
SuspensionReason = sub.SuspensionReason
};
}
}

View File

@@ -0,0 +1,106 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Client.Models;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.EntityFrameworkCore;
namespace BTCPayServer.Plugins.Subscriptions;
public class SubscriptionContext(ApplicationDbContext ctx, EventAggregator aggregator, CurrencyNameTable currencyNameTable, CancellationToken cancellationToken) : IAsyncDisposable
{
List<object> _evts = new List<object>();
public CancellationToken CancellationToken { get; } = cancellationToken;
public DateTimeOffset Now { get; } = DateTimeOffset.UtcNow;
public ApplicationDbContext Context => ctx;
public void AddEvent(object evt) => _evts.Add(evt);
public IReadOnlyList<object> Events => _evts;
public decimal RoundAmount(decimal amount, string currency)
=> Math.Round(amount, currencyNameTable.GetNumberFormatInfo(currency)?.CurrencyDecimalDigits ?? 2);
public decimal GetAmountToCredit(InvoiceEntity invoice)
// If the subscriber settled more than expected, we credit the subscriber with the difference.
=> RoundAmount(invoice.Status is InvoiceStatus.Processing ? invoice.PaidAmount.Net : invoice.NetSettled, invoice.Currency);
public async Task<decimal> CreditSubscriber(SubscriberData sub, string description, decimal credit)
=> (await TryCreditDebitSubscriber(sub, description, credit, 0m, true))!.Value;
public async Task<bool> TryChargeSubscriber(SubscriberData sub, string description, decimal charge, bool force = false)
=> (await TryCreditDebitSubscriber(sub, description, 0m, charge, force)) is not null;
private static async Task<decimal?> UpdateCredit(BalanceTransaction tx, bool force, ApplicationDbContext ctx)
{
var diff = tx.Diff;
if (diff >= 0)
force = true;
var amountCondition = force ? "1=1" : "c.amount >= -@diff";
var amount = await ctx.Database.GetDbConnection()
.ExecuteScalarAsync<decimal?>($"""
WITH
up AS (
INSERT INTO subs_subscriber_credits AS c (subscriber_id, currency, amount)
VALUES (@id, @currency, @diff)
ON CONFLICT (subscriber_id, currency)
DO UPDATE
SET amount = c.amount + EXCLUDED.amount
WHERE {amountCondition}
RETURNING c.subscriber_id, c.currency, @diff AS diff, c.amount AS balance
),
hist AS (
INSERT INTO subs_subscriber_credits_history(subscriber_id, currency, description, debit, credit, balance)
SELECT
up.subscriber_id,
up.currency,
@desc,
CASE WHEN up.diff < 0 THEN ABS(up.diff) ELSE 0 END AS debit,
CASE WHEN up.diff > 0 THEN up.diff ELSE 0 END AS credit,
up.balance
FROM up
)
SELECT balance from up;
""", new { id = tx.SubscriberId, currency = tx.Currency, diff, desc = tx.Description });
return amount;
}
private static async Task ReloadCredits(SubscriberData sub, ApplicationDbContext ctx)
{
foreach (var c in sub.Credits)
ctx.Entry(c).State = EntityState.Detached;
sub.Credits.Clear();
await ctx.Entry(sub).Collection(c => c.Credits).Query().LoadAsync();
}
public async Task<decimal?> TryCreditDebitSubscriber(SubscriberData sub, string description, decimal credit, decimal charge, bool force = false)
{
charge = RoundAmount(charge, sub.Plan.Currency);
credit = RoundAmount(credit, sub.Plan.Currency);
var tx = new BalanceTransaction(sub.Id, sub.Plan.Currency, credit, charge, description);
var amount = await UpdateCredit(tx, force, ctx);
await ReloadCredits(sub, ctx);
if (amount is { } newTotal)
{
if (tx.Credit != 0)
AddEvent(new SubscriptionEvent.SubscriberCredited(sub, newTotal + tx.Debit, tx.Credit, sub.Plan.Currency));
if (tx.Debit != 0)
AddEvent(new SubscriptionEvent.SubscriberDebited(sub, newTotal, tx.Debit, sub.Plan.Currency));
}
return amount;
}
public async ValueTask DisposeAsync()
{
foreach (var evt in _evts)
aggregator.Publish(evt, evt.GetType());
await ctx.DisposeAsync();
}
}

View File

@@ -0,0 +1,69 @@
#nullable enable
using BTCPayServer.Data.Subscriptions;
namespace BTCPayServer.Events;
public class SubscriptionEvent
{
public class SubscriberEvent(SubscriberData subscriber) : SubscriptionEvent
{
public SubscriberData Subscriber { get; } = subscriber;
}
public class NewSubscriber(SubscriberData subscriber) : SubscriberEvent(subscriber)
{
public override string ToString() => $"New Subscriber {Subscriber.ToNiceString()}";
}
public class SubscriberCredited(SubscriberData subscriber, decimal total, decimal amount, string currency) : SubscriberEvent(subscriber)
{
public decimal Total { get; } = total;
public decimal Amount { get; set; } = amount;
public string Currency { get; set; } = currency;
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} credited (Amount: {Amount} {Currency}, New Total: {Total} {Currency})";
}
public class SubscriberDebited(SubscriberData subscriber, decimal total, decimal amount, string currency) : SubscriberEvent(subscriber)
{
public decimal Total { get; } = total;
public decimal Amount { get; set; } = amount;
public string Currency { get; set; } = currency;
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} debited (Amount: {Amount} {Currency}, New Total: {Total} {Currency})";
}
public class SubscriberActivated(SubscriberData subscriber) : SubscriberEvent(subscriber)
{
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} activated";
}
public class SubscriberPhaseChanged(SubscriberData subscriber, SubscriberData.PhaseTypes previousPhase) : SubscriberEvent(subscriber)
{
public SubscriberData.PhaseTypes PreviousPhase { get; set; } = previousPhase;
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} changed phase from {PreviousPhase} to {Subscriber.Phase}";
}
public class SubscriberDisabled(SubscriberData subscriber) : SubscriberEvent(subscriber)
{
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} disabled";
}
public class PaymentReminder(SubscriberData subscriber) : SubscriberEvent(subscriber)
{
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} needs reminder";
}
public class PlanUpdated(PlanData plan) : SubscriptionEvent
{
public PlanData Plan { get; set; } = plan;
}
public class NeedUpgrade(SubscriberData subscriber) : SubscriberEvent(subscriber)
{
}
public class PlanStarted(SubscriberData subscriber, PlanData previous) : SubscriberEvent(subscriber)
{
public PlanData PreviousPlan { get; set; } = previous;
public bool AutoRenew { get; set; }
public override string ToString() => $"Subscriber {Subscriber.ToNiceString()} started plan";
}
}

View File

@@ -0,0 +1,632 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BTCPayServer.Abstractions;
using BTCPayServer.Client.Models;
using BTCPayServer.Controllers;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
using BTCPayServer.Logging;
using BTCPayServer.Services;
using BTCPayServer.Services.Invoices;
using BTCPayServer.Services.Rates;
using Dapper;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using static BTCPayServer.Data.Subscriptions.SubscriberData;
// ReSharper disable MethodSupportsCancellation
namespace BTCPayServer.Plugins.Subscriptions;
public class SubscriptionHostedService(
EventAggregator eventAggregator,
ApplicationDbContextFactory applicationDbContextFactory,
SettingsRepository settingsRepository,
UIInvoiceController invoiceController,
CurrencyNameTable currencyNameTable,
LinkGenerator linkGenerator,
Logs logger) : EventHostedServiceBase(eventAggregator, logger), IPeriodicTask
{
record Poll;
public record SubscribeRequest(string CheckoutId, CustomerSelector CustomerSelector);
public Task Do(CancellationToken cancellationToken)
=> base.RunEvent(new Poll(), cancellationToken);
protected override void SubscribeToEvents()
{
this.Subscribe<InvoiceEvent>();
this.Subscribe<SubscriptionEvent.PlanUpdated>();
}
protected override async Task ProcessEvent(object evt, CancellationToken cancellationToken)
{
await using var subCtx = CreateContext(cancellationToken);
if (evt is InvoiceEvent
{
EventCode: InvoiceEventCode.Completed or
InvoiceEventCode.MarkedCompleted or
InvoiceEventCode.MarkedInvalid or
InvoiceEventCode.PaidInFull,
Invoice:
{
Status: InvoiceStatus.Settled or InvoiceStatus.Invalid or InvoiceStatus.Processing
} settledInvoice
} &&
GetCheckoutPlanIdFromInvoice(settledInvoice) is string checkoutId)
{
await ProcessSubscriptionPayment(settledInvoice, checkoutId, cancellationToken);
}
else if (evt is Poll)
{
var from = (await settingsRepository.GetSettingAsync<MembershipServerSettings>("MembershipHostedService"))?.LastUpdate;
var to = subCtx.Now;
await UpdateSubscriptionStates(subCtx, new MemberSelector.PassedDate(from, to));
if (subCtx.Events.Count != 0)
await settingsRepository.UpdateSetting(new MembershipServerSettings(to), "MembershipHostedService");
}
else if (evt is SubscriptionEvent.PlanUpdated planUpdated)
{
await UpdatePlanStats(subCtx.Context, planUpdated.Plan.Id);
}
else if (evt is SubscribeRequest subscribeRequest)
{
var ctx = subCtx.Context;
var checkout = await ctx.PlanCheckouts.GetCheckout(subscribeRequest.CheckoutId);
if (checkout?.IsExpired is not false)
throw new InvalidOperationException("Checkout not found or expired");
var redirectLink =
checkout.GetRedirectUrl() ??
checkout.Plan.Offering.SuccessRedirectUrl ??
linkGenerator.PlanCheckoutDefaultLink(checkout.BaseUrl);
if (checkout.SuccessRedirectUrl != redirectLink)
{
checkout.SuccessRedirectUrl = redirectLink;
await ctx.SaveChangesAsync();
}
if (checkout.IsTrial)
{
await StartPlanCheckoutWithoutInvoice(subCtx, checkout, subscribeRequest.CustomerSelector);
}
else
{
await CreateInvoiceForCheckout(subCtx, checkout, subscribeRequest.CustomerSelector);
}
}
else if (evt is SuspendRequest suspendRequest)
{
var ctx = subCtx.Context;
var sub = await ctx.Subscribers.FindAsync([suspendRequest.SubId], cancellationToken);
if (sub is null)
throw new InvalidOperationException("Subscriber not found");
sub.IsSuspended = suspendRequest.Suspended ?? !sub.IsSuspended;
sub.SuspensionReason = sub.IsSuspended ? suspendRequest.SuspensionReason : null;
await ctx.SaveChangesAsync();
await UpdateSubscriptionStates(subCtx, suspendRequest.SubId);
}
else if (evt is MoveTimeRequest move)
{
var ctx = subCtx.Context;
var members = await move.MemberSelector.Where(ctx.Subscribers.IncludeAll()).ToListAsync(cancellationToken);
foreach (var member in members)
{
if (member.PeriodEnd is not null)
member.PeriodEnd -= move.Period;
if (member.TrialEnd is not null)
member.TrialEnd -= move.Period;
if (member.GracePeriodEnd is not null)
member.GracePeriodEnd -= move.Period;
member.PlanStarted -= move.Period;
}
await ctx.SaveChangesAsync();
await UpdateSubscriptionStates(subCtx, move.MemberSelector);
}
}
SubscriptionContext CreateContext() => CreateContext(CancellationToken);
SubscriptionContext CreateContext(CancellationToken cancellationToken) =>
new(applicationDbContextFactory.CreateContext(), EventAggregator, currencyNameTable, cancellationToken);
public static string GetCheckoutPlanTag(string checkoutId) => $"SUBS#{checkoutId}";
public static string? GetCheckoutPlanIdFromInvoice(InvoiceEntity invoiceEntiy) => invoiceEntiy.GetInternalTags("SUBS#").FirstOrDefault();
private async Task CreateInvoiceForCheckout(SubscriptionContext subCtx, PlanCheckoutData checkout, CustomerSelector customerSelector, decimal? price = null)
{
var invoiceMetadata = JObject.Parse(checkout.InvoiceMetadata);
if (checkout.NewSubscriber)
{
invoiceMetadata["planId"] = checkout.PlanId;
invoiceMetadata["offeringId"] = checkout.Plan.OfferingId;
}
var plan = checkout.Plan;
var existingCredit = checkout.Subscriber?.GetCredit() ?? 0m;
var amount = price ?? (plan.Price - existingCredit);
if (checkout.OnPay == PlanCheckoutData.OnPayBehavior.HardMigration &&
checkout.Subscriber?.GetUnusedPeriodAmount(subCtx.Now) is decimal unusedAmount)
amount -= subCtx.RoundAmount(unusedAmount, plan.Currency);
amount = Math.Max(amount, 0);
if (amount > 0)
{
var request = await invoiceController.CreateInvoiceCoreRaw(new()
{
Currency = plan.Currency,
Amount = amount,
Checkout = new()
{
RedirectAutomatically = true,
RedirectURL = checkout.GetRedirectUrl()
},
Metadata = invoiceMetadata
}, plan.Offering.App.StoreData, checkout.BaseUrl.ToString(),
[GetCheckoutPlanTag(checkout.Id)], CancellationToken.None);
if (checkout.SubscriberId is not null)
{
subCtx.Context.SubscribersInvoices.Add(new()
{
SubscriberId = checkout.SubscriberId.Value,
InvoiceId = request.Id
});
}
checkout.InvoiceId = request.Id;
await subCtx.Context.SaveChangesAsync();
}
else
{
await StartPlanCheckoutWithoutInvoice(subCtx, checkout, customerSelector);
}
}
class MembershipServerSettings
{
public MembershipServerSettings()
{
}
public MembershipServerSettings(DateTimeOffset lastUpdate)
{
LastUpdate = lastUpdate;
}
[JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
public DateTimeOffset LastUpdate { get; set; }
}
public abstract record MemberSelector
{
public record Single(long SubscriberId) : MemberSelector
{
public override IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query)
=> query.Where(m => m.Id == SubscriberId);
}
public record PassedDate(DateTimeOffset? From, DateTimeOffset To) : MemberSelector
{
public override IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query)
=> From is null
? query.Where(q => (q.PeriodEnd < To || q.GracePeriodEnd < To || q.TrialEnd < To))
: query.Where(q =>
(q.PeriodEnd >= From && q.PeriodEnd < To) || (q.GracePeriodEnd >= From && q.GracePeriodEnd < To) ||
(q.TrialEnd >= From && q.TrialEnd < To));
}
public abstract IQueryable<SubscriberData> Where(IQueryable<SubscriberData> query);
}
private async Task UpdateSubscriptionStates(SubscriptionContext subCtx, long subscriberId)
=> await UpdateSubscriptionStates(subCtx, new MemberSelector.Single(subscriberId));
private async Task UpdateSubscriptionStates(SubscriptionContext subCtx, MemberSelector selector)
{
var (now, ctx, cancellationToken) = (subCtx.Now, subCtx.Context, subCtx.CancellationToken);
var query = ctx.Subscribers.IncludeAll();
var members = await selector.Where(query).ToListAsync(cancellationToken);
await ctx.PlanEntitlements.FetchPlanEntitlementsAsync(members.Select(m => m.Plan));
foreach (var m in members)
{
var newPhase = m.GetExpectedPhase(now);
var (prevPhase, prevActive) = (m.Phase, m.IsActive);
if (prevPhase != newPhase)
{
if (newPhase is PhaseTypes.Expired or PhaseTypes.Grace)
{
if (m is
{
CanStartNextPlan: true,
AutoRenew: true
})
{
if (await subCtx.TryChargeSubscriber(m, $"Auto renewal for plan '{m.NextPlan.Name}'", m.NextPlan.Price))
{
newPhase = PhaseTypes.Normal;
var planBefore = m.Plan;
m.StartNextPlan(now);
subCtx.AddEvent(new SubscriptionEvent.PlanStarted(m, planBefore)
{
PreviousPlan = planBefore,
AutoRenew = planBefore.Id == m.PlanId
});
}
}
else if (m is { AutoRenew: true, CanStartNextPlan: false })
{
subCtx.AddEvent(new SubscriptionEvent.NeedUpgrade(m));
}
}
if (newPhase is PhaseTypes.Expired)
{
m.PaidAmount = null;
if (m is { NewPlan: not null, NewPlanId: not null } && m.NewPlanId != m.PlanId)
{
(m.PlanId, m.Plan) = (m.NewPlanId, m.NewPlan);
(m.NewPlanId, m.NewPlan) = (null, null);
}
}
if (prevPhase != newPhase)
{
m.Phase = newPhase;
subCtx.AddEvent(new SubscriptionEvent.SubscriberPhaseChanged(m, prevPhase));
}
}
var needReminder = m.GetReminderDate() <= now &&
!m.PaymentReminded &&
m.MissingCredit() >= 0m;
if (needReminder)
{
m.PaymentReminded = true;
subCtx.AddEvent(new SubscriptionEvent.PaymentReminder(m));
}
var newActive = !m.IsSuspended && newPhase != PhaseTypes.Expired;
if (prevActive != newActive)
{
m.IsActive = newActive;
if (newActive)
subCtx.AddEvent(new SubscriptionEvent.SubscriberActivated(m));
else
subCtx.AddEvent(new SubscriptionEvent.SubscriberDisabled(m));
}
}
await ctx.SaveChangesAsync(cancellationToken);
foreach (var plan in GetActiveMemberChangedPlans(subCtx))
{
await UpdatePlanStats(ctx, plan);
}
}
private static HashSet<string> GetActiveMemberChangedPlans(SubscriptionContext subCtx)
{
HashSet<string> plansToUpdate = new();
foreach (var evt in subCtx.Events.OfType<SubscriptionEvent.SubscriberEvent>())
{
if (evt is SubscriptionEvent.PlanStarted ps)
{
plansToUpdate.Add(ps.PreviousPlan.Id);
plansToUpdate.Add(ps.Subscriber.PlanId);
}
else if (evt is SubscriptionEvent.SubscriberDisabled
or SubscriptionEvent.SubscriberActivated
or SubscriptionEvent.NewSubscriber)
{
plansToUpdate.Add(evt.Subscriber.PlanId);
}
else if (evt is SubscriptionEvent.SubscriberPhaseChanged pc)
{
var expired = pc.PreviousPhase is PhaseTypes.Expired;
var newExpired = pc.Subscriber.Phase is PhaseTypes.Expired;
if (expired != newExpired)
plansToUpdate.Add(evt.Subscriber.PlanId);
}
}
return plansToUpdate;
}
public Task ProceedToSubscribe(string checkoutId, CustomerSelector selector, CancellationToken cancellationToken)
=> RunEvent(new SubscribeRequest(checkoutId, selector), cancellationToken);
private async Task StartPlanCheckoutWithoutInvoice(SubscriptionContext subCtx, PlanCheckoutData checkout, CustomerSelector customerSelector)
{
var ctx = subCtx.Context;
var sub = checkout.Subscriber;
if (sub is null && !checkout.NewSubscriber)
throw new InvalidOperationException("Bug: Subscriber is null and not a new subscriber");
if (sub is null)
{
sub = await CreateSubscription(subCtx, checkout, false, customerSelector);
if (sub is null)
return;
}
await TryStartPlan(subCtx, checkout, sub);
await ctx.SaveChangesAsync();
await UpdateSubscriptionStates(subCtx, sub.Id);
}
private async Task ProcessSubscriptionPayment(InvoiceEntity invoice, string checkoutId, CancellationToken cancellationToken = default)
{
bool needUpdate = false;
await using var subCtx = CreateContext(cancellationToken);
var ctx = subCtx.Context;
var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
var plan = checkout?.Plan;
if (checkout is null || plan is null ||
(invoice.Status == InvoiceStatus.Processing && !plan.OptimisticActivation) ||
checkout.Plan.Offering.App.StoreDataId != invoice.StoreId)
return;
// If subscrberId isn't set, then it should be a new subscriber.
if (checkout.Subscriber is null && !checkout.NewSubscriber)
throw new InvalidOperationException("Bug: Subscriber is null and not a new subscriber");
var sub = checkout.Subscriber;
if (invoice.Status is InvoiceStatus.Settled or InvoiceStatus.Processing)
{
// We only create a new subscriber lazily when a payment has been received
if (sub is null)
{
var optimisticActivation = invoice.Status == InvoiceStatus.Processing && plan.OptimisticActivation;
sub = await CreateSubscription(subCtx, checkout, optimisticActivation, CustomerSelector.ByEmail(invoice.Metadata.BuyerEmail));
if (sub is null)
return;
ctx.SubscribersInvoices.Add(new SubscriberInvoiceData()
{
SubscriberId = sub.Id,
InvoiceId = invoice.Id
});
await ctx.SaveChangesAsync();
}
var invoiceCredit = subCtx.GetAmountToCredit(invoice);
if (checkout.Credited != invoiceCredit)
{
var diff = invoiceCredit - checkout.Credited;
if (diff > 0)
{
checkout.Credited += diff;
await subCtx.CreditSubscriber(sub, $"Credit purchase (Inv: {invoice.Id})", diff);
if (!checkout.PlanStarted)
{
await TryStartPlan(subCtx, checkout, sub);
}
}
else
{
await subCtx.TryChargeSubscriber(sub, $"Adjustement (Inv: {invoice.Id})", -diff, force: true);
checkout.Credited -= -diff;
}
}
if (invoice.Status == InvoiceStatus.Settled && sub.Plan.OptimisticActivation)
// Maybe we don't need this column
sub.OptimisticActivation = false;
needUpdate = true;
await ctx.SaveChangesAsync();
}
else if (sub is not null && invoice.Status == InvoiceStatus.Invalid &&
checkout is { PlanStarted: true, Credited: not 0m })
{
// We should probably ask the merchant before reversing the credit...
// await TryChargeSubscriber(ctx, sub, checkout.Credited, force: true);
// checkout.Credited = 0m;
sub.IsSuspended = true;
sub.SuspensionReason = "The plan has been started by an invoice which later became invalid.";
needUpdate = true;
await ctx.SaveChangesAsync();
}
if (sub is not null)
{
if (needUpdate)
await UpdateSubscriptionStates(subCtx, sub.Id);
}
}
private async Task TryStartPlan(SubscriptionContext subCtx, PlanCheckoutData checkout, SubscriberData sub)
{
if (checkout.PlanStarted)
return;
var prevPlan = sub.Plan;
var now = subCtx.Now;
using var scope = sub.NewPlanScope(checkout.Plan);
if (sub.CanStartNextPlanEx(checkout.NewSubscriber))
{
if (checkout.IsTrial || await subCtx.TryChargeSubscriber(sub, $"Starting plan '{sub.NextPlan.Name}'", sub.NextPlan.Price))
{
sub.StartNextPlan(now, checkout.IsTrial);
scope.Commit();
checkout.PlanStarted = true;
}
}
// In hard migrations, we stop the current plan by reimbursing what has
// not yet been spent. The we start the new plan.
else if (checkout.OnPay == PlanCheckoutData.OnPayBehavior.HardMigration)
{
var unusedAmount = subCtx.RoundAmount(sub.GetUnusedPeriodAmount(now) ?? 0.0m, sub.Plan.Currency);
var planChanges = await subCtx.Context.PlanChanges
.Where(p => p.PlanId == sub.Plan.Id).ToListAsync();
var planChange = planChanges
.Where(p => p.PlanChangeId == sub.NewPlan?.Id)
.Select(p => p.Type)
.FirstOrDefault();
var description = planChange switch
{
PlanChangeData.ChangeType.Upgrade => $"Upgrade to new plan '{sub.NewPlan?.Name}'",
PlanChangeData.ChangeType.Downgrade => $"Downgrade to new plan '{sub.NewPlan?.Name}'",
_ => $"Migration to plan '{sub.NewPlan?.Name}'"
};
if (checkout.IsTrial || await subCtx.TryCreditDebitSubscriber(sub, description, unusedAmount, sub.NextPlan.Price) is not null)
{
checkout.RefundAmount = unusedAmount;
sub.StartNextPlan(now, checkout.IsTrial);
scope.Commit();
checkout.PlanStarted = true;
}
}
if (checkout.PlanStarted)
subCtx.AddEvent(new SubscriptionEvent.PlanStarted(sub, prevPlan)
{
PreviousPlan = prevPlan
});
}
record MoveTimeRequest(MemberSelector MemberSelector, TimeSpan Period);
/// <summary>
/// Modify the dates of the subscriber so that he moves to the next phase.
/// </summary>
/// <param name="memberSelector"></param>
/// <param name="period"></param>
/// <returns></returns>
public Task MoveTime(MemberSelector memberSelector, TimeSpan period)
=> this.RunEvent(new MoveTimeRequest(memberSelector, period));
public async Task MoveTime(long subscriberId, PhaseTypes phase)
{
await using var ctx = applicationDbContextFactory.CreateContext();
var selector = new MemberSelector.Single(subscriberId);
var subscriber = await selector.Where(ctx.Subscribers.IncludeAll()).FirstAsync();
TimeSpan time;
if (phase == PhaseTypes.Normal)
time = subscriber.TrialEnd!.Value - DateTimeOffset.UtcNow;
else if (phase == PhaseTypes.Grace)
time = subscriber.PeriodEnd!.Value - DateTimeOffset.UtcNow;
else if (phase == PhaseTypes.Expired)
time = subscriber.GracePeriodEnd!.Value - DateTimeOffset.UtcNow;
else
throw new InvalidOperationException("Invalid phase");
await this.MoveTime(selector, time);
}
private async Task<SubscriberData?> CreateSubscription(SubscriptionContext subCtx, PlanCheckoutData checkout, bool optimisticActivation,
CustomerSelector customerSelector)
{
var ctx = subCtx.Context;
var plan = checkout.Plan;
var cust = await ctx.Customers.GetOrUpdate(checkout.Plan.Offering.App.StoreDataId, customerSelector);
(var sub, var created) =
await ctx.Subscribers.GetOrCreateByCustomerId(cust.Id, plan.OfferingId, plan.Id, optimisticActivation, checkout.TestAccount,
JObject.Parse(checkout.NewSubscriberMetadata));
if (!created || sub is null)
return null;
checkout.Subscriber = sub;
checkout.SubscriberId = sub.Id;
await ctx.SaveChangesAsync();
subCtx.AddEvent(new SubscriptionEvent.NewSubscriber(sub));
return sub;
}
private static async Task UpdatePlanStats(ApplicationDbContext ctx, string planId)
{
await ctx.Database.GetDbConnection()
.ExecuteAsync("""
WITH defaults AS (SELECT 0),
stats AS (
SELECT COUNT(1) AS subscribers_count,
SUM( CASE sp.recurring_type
WHEN 'Monthly' THEN sp.price
WHEN 'Quarterly' THEN sp.price / 3.0::numeric
WHEN 'Yearly' THEN sp.price / 12.0::numeric
WHEN 'Lifetime' THEN 0
END) AS monthly_revenue
FROM subs_subscribers ss
JOIN subs_plans sp ON ss.plan_id = sp.id
WHERE ss.plan_id = @id AND ss.active
GROUP BY ss.plan_id
)
UPDATE subs_plans AS sp
SET members_count = COALESCE(stats.subscribers_count,0),
monthly_revenue = COALESCE(stats.monthly_revenue, 0)
FROM defaults d -- forces one row
LEFT JOIN stats ON TRUE
WHERE sp.id = @id;
""", new
{
id = planId
});
}
record SuspendRequest(long SubId, string? SuspensionReason, bool? Suspended);
public Task ToggleSuspend(long subId, string? suspensionReason)
=> RunEvent(new SuspendRequest(subId, suspensionReason, null));
public Task Suspend(long subId, string? suspensionReason)
=> RunEvent(new SuspendRequest(subId, suspensionReason, true));
public async Task UpdateCredit(long subscriberId, string description, decimal update)
{
await using var subCtx = CreateContext();
var sub = await subCtx.Context.Subscribers.GetById(subscriberId);
if (sub is null) return;
if (update < 0)
await subCtx.TryChargeSubscriber(sub, description, -update, force: true);
else if (update > 0)
await subCtx.CreditSubscriber(sub, description, update);
}
public async Task<string?> CreateCreditCheckout(string portalSessionId, decimal? value)
{
await using var subCtx = CreateContext(CancellationToken);
var ctx = subCtx.Context;
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
if (portal is null)
return null;
var checkout = new PlanCheckoutData(portal.Subscriber)
{
SuccessRedirectUrl = linkGenerator.SubscriberPortalLink(portalSessionId, portal.BaseUrl),
BaseUrl = portal.BaseUrl
};
ctx.PlanCheckouts.Add(checkout);
await ctx.SaveChangesAsync();
await this.CreateInvoiceForCheckout(subCtx, checkout, portal.Subscriber.CustomerSelector, value);
return checkout.InvoiceId;
}
public async Task<string?> CreatePlanMigrationCheckout(string portalSessionId, string? planId, PlanCheckoutData.OnPayBehavior migrationType,
RequestBaseUrl requestBaseUrl)
{
await using var ctx = applicationDbContextFactory.CreateContext();
var portal = await ctx.PortalSessions.GetActiveById(portalSessionId);
var plan = planId is null ? null : await ctx.Plans.GetPlanFromId(planId);
if (portal is null)
return null;
var checkout = new PlanCheckoutData(portal.Subscriber, plan)
{
SuccessRedirectUrl = linkGenerator.SubscriberPortalLink(portalSessionId, requestBaseUrl),
OnPay = migrationType,
BaseUrl = requestBaseUrl,
};
ctx.PlanCheckouts.Add(checkout);
await ctx.SaveChangesAsync();
return checkout.Id;
}
}

View File

@@ -0,0 +1,170 @@
#nullable enable
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using BTCPayServer.Abstractions.Models;
using BTCPayServer.Client.Models;
using BTCPayServer.Configuration;
using BTCPayServer.Data;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Plugins.Subscriptions.Controllers;
using BTCPayServer.Services.Apps;
using BTCPayServer.Views.UIStoreMembership;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
namespace BTCPayServer.Plugins.Subscriptions;
public class SubscriptionsPlugin : BaseBTCPayServerPlugin
{
public const string Area = "Subscriptions";
public override string Identifier => "BTCPayServer.Plugins.Subscriptions";
public override string Name => "Subscriptions";
public override string Description => "Manage recurring payment plans and subscriptions with customizable offerings, pricing tiers, and billing cycles.";
public override void Execute(IServiceCollection services)
{
services.AddUIExtension("header-nav", "/Plugins/Subscriptions/Views/NavExtension.cshtml");
services.AddSingleton<AppBaseType, SubscriptionsAppType>();
services.AddScheduledTask<SubscriptionHostedService>(TimeSpan.FromMinutes(5));
services.AddSingleton<SubscriptionHostedService>();
services.AddSingleton<IHostedService>(s => s.GetRequiredService<SubscriptionHostedService>());
AddSubscriptionsWebhooks(services);
base.Execute(services);
}
private void AddSubscriptionsWebhooks(IServiceCollection services)
{
services.AddWebhookTriggerProvider<SubscriberWebhookProvider>();
var placeHolders = new List<EmailTriggerViewModel.PlaceHolder>()
{
new ("{Plan.Id}", "Plan ID"),
new ("{Plan.Name}", "Plan name"),
new ("{Offering.Name}", "Offering name"),
new ("{Offering.Id}", "Offering ID"),
new ("{Offering.AppId}", "Offering app ID"),
new ("{Offering.Metadata}*", "Offering metadata"),
new ("{Subscriber.Phase}", "Subscriber phase"),
new ("{Subscriber.Email}", "Subscriber email"),
new ("{Customer.ExternalRef}", "Customer external reference"),
new ("{Customer.Name}", "Customer name"),
new ("{Customer.Metadata}*", "Customer metadata")
}.AddStoresPlaceHolders();
var viewModels = new List<EmailTriggerViewModel>()
{
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberCreated,
Description = "Subscription - New subscriber",
SubjectExample = "Welcome {Customer.Name}!",
BodyExample = "Hello {Customer.Name},\n\nThank you for subscribing to our service.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberCredited,
Description = "Subscription - Subscriber credited",
SubjectExample = "Your subscription has been credited",
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been credited.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberCharged,
Description = "Subscription - Subscriber charged",
SubjectExample = "Your subscription payment has been processed",
BodyExample = "Hello {Customer.Name},\n\nYour subscription payment for {Plan.Name} has been processed.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberActivated,
Description = "Subscription - Subscriber activated",
SubjectExample = "Your subscription is now active",
BodyExample = "Hello {Customer.Name},\n\nYour subscription to {Plan.Name} is now active.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberPhaseChanged,
Description = "Subscription - Subscriber phase changed",
SubjectExample = "Your subscription phase has changed",
BodyExample = "Hello {Customer.Name},\n\nYour subscription phase has been updated to {Subscriber.Phase}.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberDisabled,
Description = "Subscription - Subscriber disabled",
SubjectExample = "Your subscription has been disabled",
BodyExample = "Hello {Customer.Name},\n\nYour subscription has been disabled.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.PaymentReminder,
Description = "Subscription - Payment reminder",
SubjectExample = "Payment reminder for your subscription",
BodyExample = "Hello {Customer.Name},\n\nThis is a reminder about your upcoming subscription payment.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.PlanStarted,
Description = "Subscription - Plan started",
SubjectExample = "Your subscription plan has started",
BodyExample = "Hello {Customer.Name},\n\nYour subscription plan {Plan.Name} has started.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
new()
{
Trigger = WebhookSubscriptionEvent.SubscriberNeedUpgrade,
Description = "Subscription - Need upgrade",
SubjectExample = "Your subscription needs to be upgraded",
BodyExample = "Hello {Customer.Name},\n\nYour subscription needs to be upgraded to continue using our service.\n\nRegards,\n{Store.Name}",
PlaceHolders = placeHolders
},
};
services.AddWebhookTriggerViewModels(viewModels);
}
}
public class SubscriptionsAppType(
LinkGenerator linkGenerator,
IOptions<BTCPayServerOptions> btcPayServerOptions) : AppBaseType(AppType)
{
public const string AppType = "Subscriptions";
public class AppConfig
{
public string OfferingId { get; set; } = null!;
}
public override Task<object?> GetInfo(AppData appData)
=> Task.FromResult<object?>(null);
public override Task<string> ConfigureLink(AppData app)
{
var config = app.GetSettings<AppConfig>();
return Task.FromResult(linkGenerator.GetPathByAction(nameof(UIOfferingController.Offering),
"UIOffering", new { storeId = app.Id, offeringId = config?.OfferingId, section = SubscriptionSection.Plans }, btcPayServerOptions.Value.RootPath)!);
}
public override Task<string> ViewLink(AppData app)
{
var config = app.GetSettings<AppConfig>();
return Task.FromResult(linkGenerator.GetPathByAction(nameof(UIOfferingController.Offering),
"UIOffering", new { storeId = app.Id, offeringId = config?.OfferingId, section = SubscriptionSection.Plans }, btcPayServerOptions.Value.RootPath)!);
}
public override Task SetDefaultSettings(AppData appData, string defaultCurrency)
{
throw new System.NotImplementedException();
}
}

View File

@@ -0,0 +1,39 @@
#nullable enable
using BTCPayServer.Abstractions;
using BTCPayServer.Plugins.Subscriptions.Controllers;
using BTCPayServer.Views.UIStoreMembership;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace BTCPayServer.Plugins.Subscriptions;
public static class UrlHelperExtensions
{
public static string PlanCheckout(this LinkGenerator urlHelper, string checkoutId, RequestBaseUrl requestBaseUrl)
=> urlHelper.GetUriByAction(
action: nameof(UIPlanCheckoutController.PlanCheckout),
values: new { area = SubscriptionsPlugin.Area, checkoutId },
controller: "UIPlanCheckout",
requestBaseUrl: requestBaseUrl);
public static string SubscriberPortalLink(this LinkGenerator urlHelper, string portalSessionId, RequestBaseUrl requestBaseUrl, string? checkoutPlanId = null)
=> urlHelper.GetUriByAction(
action: nameof(UISubscriberPortalController.SubscriberPortal),
values: new { area = SubscriptionsPlugin.Area, portalSessionId, checkoutPlanId },
controller: "UISubscriberPortal",
requestBaseUrl: requestBaseUrl);
public static string OfferingLink(this LinkGenerator urlHelper, string storeId, string offeringId, SubscriptionSection section, RequestBaseUrl requestBaseUrl)
=> urlHelper.GetUriByAction(
action: nameof(UIOfferingController.Offering),
values: new { area = SubscriptionsPlugin.Area, storeId, offeringId, section },
controller: "UIOffering",
requestBaseUrl: requestBaseUrl);
public static string PlanCheckoutDefaultLink(this LinkGenerator urlHelper, RequestBaseUrl requestBaseUrl)
=> urlHelper.GetUriByAction(
action: nameof(UIPlanCheckoutController.PlanCheckoutDefaultRedirect),
values: new { area = SubscriptionsPlugin.Area },
controller: "UIPlanCheckout",
requestBaseUrl: requestBaseUrl);
}

View File

@@ -0,0 +1,9 @@
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Views.UIStoreMembership.Components.Contact;
public class Contact : ViewComponent
{
public IViewComponentResult Invoke(string type, string value)
=> View((type, value));
}

View File

@@ -0,0 +1,17 @@
@model (string type, string value)
@if (Model.type == "Email")
{
<span class="customer-contact-@Model.type"><i class="fa fa-envelope me-2"></i><span>@Model.value</span></span>
}
else if (Model.type == "Nostr")
{
<span class="customer-contact-@Model.type">
<span class="me-1 align-baseline" style="color: mediumpurple"><vc:icon symbol="social-nostr"></vc:icon></span>
<span class="align-baseline">@Model.value</span>
</span>
}
else
{
<span>@Model.type: @Model.value</span>
}

View File

@@ -0,0 +1,55 @@
@model (bool, SubscriberData)
@{
var subscriber = Model.Item2;
var canSuspend = Model.Item1;
var (label, badge, hasDropdown) = subscriber switch
{
{ IsActive: true } => (StringLocalizer["Active"], "success", true),
{ IsActive: false, IsSuspended: true } => (StringLocalizer["Suspended"], "danger", true),
_ => (StringLocalizer["Inactive"], "danger", subscriber.IsSuspended)
};
hasDropdown = hasDropdown && canSuspend;
}
<span class="subscriber-status badge badge-translucent rounded-pill text-bg-@badge">
@if (hasDropdown)
{
<form asp-action="SubscriberSuspend"
asp-route-offeringId="@subscriber.OfferingId"
asp-route-storeId="@subscriber.Offering.App.StoreDataId"
asp-route-customerId="@subscriber.CustomerId" method="post" class="dropdown">
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span>@label</span>
</span>
<div class="dropdown-menu">
@if (subscriber.IsActive)
{
@* <button type="submit" name="command" value="suspend" class="dropdown-item lh-base" text-translate="true"> *@
@* Suspend Access *@
@* </button> *@
<a
href="#"
text-translate="true"
class="suspend-subscriber-link dropdown-item lh-base"
data-bs-toggle="modal"
data-bs-target="#suspendSubscriberModal"
data-subscriber-id="@subscriber.CustomerId"
data-subscriber-email="@subscriber.Customer.Email.Get()">
Suspend Access
</a>
}
else if (subscriber.IsSuspended)
{
<button type="submit" name="command" value="unsuspend" class="dropdown-item lh-base" text-translate="true">
Unsuspend Access
</button>
}
</div>
</form>
}
else
{
<span>@label</span>
}
</span>

View File

@@ -0,0 +1,10 @@
using BTCPayServer.Data.Subscriptions;
using Microsoft.AspNetCore.Mvc;
namespace BTCPayServer.Views.UIStoreMembership.Components.SubscriberStatus;
public class SubscriberStatus : ViewComponent
{
public IViewComponentResult Invoke(SubscriberData subscriber, bool canSuspend = false)
=> View((canSuspend, subscriber));
}

View File

@@ -0,0 +1,48 @@
@using BTCPayServer.Client
@using BTCPayServer.Components.MainNav
@using BTCPayServer.Plugins.PointOfSale
@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Services.Apps
@using BTCPayServer.Views.Apps
@using Microsoft.AspNetCore.Mvc.Razor
@model BTCPayServer.Components.MainNav.MainNavViewModel
@{
var store = Context.GetStoreData();
}
@if (store != null)
{
var appType = SubscriptionsAppType.AppType;
var apps = Model.Apps.Where(app => app.AppType == appType).ToList();
<li class="nav-item" permission="@Policies.CanModifyMembership">
<a asp-area="Subscriptions" asp- asp-controller="UIOffering" asp-action="CreateOffering" asp-route-storeId="@store.Id" class="nav-link @ViewData.ActivePageClass(AppsNavPages.Create, appType)" id="@($"StoreNav-Create{appType}")">
<vc:icon symbol="nav-reporting" />
<span text-translate="true">Subscriptions</span>
</a>
</li>
@if (apps.Any())
{
<li class="nav-item" not-permission="@Policies.CanModifyMembership" permission="@Policies.CanViewStoreSettings">
<span class="nav-link">
<vc:icon symbol="nav-reporting" />
<span text-translate="true">Subscriptions</span>
</span>
</li>
}
@foreach (var app in apps)
{
var offeringId = app.Data.GetSettings<SubscriptionsAppType.AppConfig>().OfferingId ?? "";
<li class="nav-item nav-item-sub" permission="@Policies.CanViewMembership">
<a asp-area="Subscriptions" asp-controller="UIOffering" asp-action="Offering" asp-route-storeId="@Model.Store.Id" asp-route-offeringId="@offeringId" asp-route-section="Plans" class="nav-link @ViewData.ActivePageClass(AppsNavPages.Update, @offeringId)" id="@($"StoreNav-Offering-{offeringId}")">
<span>@app.AppName</span>
</a>
</li>
<li class="nav-item nav-item-sub" not-permission="@Policies.CanViewMembership">
<a asp-area="Subscriptions" asp-controller="UIOffering" asp-action="Offering" asp-route-storeId="@Model.Store.Id" asp-route-offeringId="@offeringId" asp-route-section="Plans" class="nav-link">
<span>@app.AppName</span>
</a>
</li>
}
}

View File

@@ -0,0 +1,74 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Views.UIStoreMembership
{
public class AddEditPlanViewModel
{
public string OfferingId { get; set; }
[Required]
[Display(Name = "Plan Name")]
[StringLength(100)]
public string Name { get; set; } = string.Empty;
public string OfferingName { get; set; }
[Required]
[Display(Name = "Price")]
[Range(0, double.MaxValue)]
public decimal Price { get; set; }
[Required]
[Display(Name = "Currency")]
public string Currency { get; set; }
[Required]
[Display(Name = "Recurring Type")]
public PlanData.RecurringInterval RecurringType { get; set; } = PlanData.RecurringInterval.Monthly;
[Required]
[Display(Name = "Grace Period (days)")]
[Range(0, 3650)]
public int GracePeriodDays { get; set; }
[Required]
[Display(Name = "Trial Period (days)")]
[Range(0, 3650)]
public int TrialDays { get; set; }
[Display(Name = "Description")]
[StringLength(1000)]
public string Description { get; set; }
[Display(Name = "Optimistic activation")]
public bool OptimisticActivation { get; set; } = true;
[Display(Name = "Renewable")]
public bool Renewable { get; set; } = true;
[Display(Name = "Entitlements")]
public List<Entitlement> Entitlements { get; set; } = new();
public string Anchor { get; set; }
public string PlanId { get; set; }
public List<PlanChange> PlanChanges { get; set; } = new();
public class PlanChange
{
public string PlanId { get; set; }
public string PlanName { get; set; }
public string SelectedType { get; set; }
}
public class Entitlement
{
public string CustomId { get; set; } = string.Empty;
public string ShortDescription { get; set; } = string.Empty;
public bool Selected { get; set; }
}
}
}

View File

@@ -0,0 +1,178 @@
@model AddEditPlanViewModel
@{
string storeId = (string)this.Context.GetRouteValue("storeId");
string offeringId = (string)this.Context.GetRouteValue("offeringId");
string submitLabel = "";
if (Model.PlanId is null)
{
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Add plan"], offeringId);
submitLabel = StringLocalizer["Create"];
}
else
{
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Edit plan"], offeringId);
submitLabel = StringLocalizer["Save"];
}
}
<form method="post">
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="Offering"
asp-route-storeId="@storeId"
asp-route-offeringId="@Model.OfferingId"
asp-route-section="Plans"
data-testid="offering-link"
>@StringLocalizer["Offering ({0})", Model.OfferingName]</a>
</li>
<li class="breadcrumb-item active" aria-current="page">
@if (Model.PlanId is null)
{
<span>@StringLocalizer["Add plan"]</span>
}
else
{
<span>@StringLocalizer["Edit plan ({0})", Model.Name]</span>
}
</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<input id="page-primary" type="submit" name="command" value="@submitLabel" class="btn btn-primary" />
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="form-group">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" placeholder="e.g., Premium" />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="d-flex justify-content-between">
<div class="form-group flex-fill me-4">
<label asp-for="Price" class="form-label" data-required></label>
<input inputmode="decimal" asp-for="Price" class="form-control" />
<span asp-validation-for="Price" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="Currency" class="form-label" data-required></label>
<input asp-for="Currency" class="form-control w-auto" currency-selection />
<span asp-validation-for="Currency" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="RecurringType" class="form-label"></label>
<select asp-for="RecurringType" class="form-select" asp-items="Html.GetEnumSelectList<PlanData.RecurringInterval>()"></select>
<span asp-validation-for="RecurringType" class="text-danger"></span>
</div>
<div class="d-flex justify-content-between">
<div class="form-group flex-fill me-4">
<label asp-for="TrialDays" class="form-label"></label>
<div class="input-group">
<input asp-for="TrialDays" class="form-control" min="0" placeholder="7" />
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="TrialDays" class="text-danger"></span>
</div>
<div class="form-group flex-fill">
<label asp-for="GracePeriodDays" class="form-label"></label>
<div class="input-group">
<input asp-for="GracePeriodDays" class="form-control" min="0" placeholder="15" />
<span class="input-group-text">days</span>
</div>
<span asp-validation-for="GracePeriodDays" class="text-danger"></span>
</div>
</div>
<div class="form-group">
<label asp-for="Description" class="form-label"></label>
<textarea asp-for="Description" class="form-control" rows="3" placeholder="Brief description of the plan features..."></textarea>
<span asp-validation-for="Description" class="text-danger"></span>
</div>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="OptimisticActivation" />
<label class="form-check-label" asp-for="OptimisticActivation"></label>
</div>
</div>
<div class="form-group">
<div class="form-check">
<input class="form-check-input" asp-for="Renewable" />
<label class="form-check-label" asp-for="Renewable"></label>
</div>
</div>
@if (Model.PlanChanges?.Any() is true)
{
<section id="entitlements" class="mt-4">
<h4 class="mb-4">Plan changes</h4>
<p>Allow the subscriber to downgrade or upgrade to a different plan.</p>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<th>Plan</th>
<th>Change type</th>
</thead>
<tbody>
@for (int i = 0; i < Model.PlanChanges.Count; i++)
{
var planChange = Model.PlanChanges[i];
<tr>
<td class="align-middle">@planChange.PlanName</td>
<input type="hidden" asp-for="PlanChanges[i].PlanId"></input>
<td><select
class="form-select plan-change-select w-auto"
asp-for="PlanChanges[i].SelectedType">
<option value="Downgrade">Downgrade</option>
<option value="Upgrade">Upgrade</option>
<option value="None">None</option>
</select></td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
@if (Model.Entitlements?.Any() is true)
{
<section id="entitlements" class="mt-4">
<h4 class="mb-4" text-translate="true">Entitlements</h4>
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width:1%"></th>
<th text-translate="true">ID</th>
<th text-translate="true">Description</th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Entitlements.Count(); i++)
{
<tr>
<td>
<input asp-for="Entitlements[i].Selected" class="form-check-input entitlement-checkbox"
data-testid="check_@Model.Entitlements[i].CustomId" type="checkbox">
<input asp-for="Entitlements[i].CustomId" type="hidden">
<input asp-for="Entitlements[i].ShortDescription" type="hidden">
</td>
<td>@Model.Entitlements[i].CustomId</td>
<td>@Model.Entitlements[i].ShortDescription</td>
</tr>
}
</tbody>
</table>
</div>
</section>
}
</div>
</div>
</form>

View File

@@ -0,0 +1,139 @@

<div class="modal fade" id="updateCreditModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title charge-mode" text-translate="true">Charge user</h4>
<h4 class="modal-title credit-mode" text-translate="true">Credit user</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
</div>
<form method="post">
<div class="modal-body">
<div html-translate="true" class="charge-mode">Would you like to charge <span class="subscriber-name fw-semibold"></span>?</div>
<div html-translate="true" class="credit-mode">Would you like to credit <span class="subscriber-name fw-semibold"></span>?</div>
</div>
<div class="modal-body pt-0 pb-0">
<input name="customerId" type="hidden" />
<div class="d-flex justify-content-between credit-plan-price">
<div class="text-muted">Current credit</div>
<div class="current-credit"></div>
</div>
<div class="d-flex justify-content-between align-items-center charge-applied">
<div class="text-muted charge-mode">Charge applied</div>
<div class="text-muted credit-mode">Credit applied</div>
<div class="d-flex align-items-center justify-content-end">
<span class="charge-mode">-</span>
<span class="credit-mode">+</span>
<input type="text"
class="form-control form-control-sm amount ms-2"
name="amount"
style="text-align: right;"
autocomplete="off" />
</div>
</div>
<hr />
<div class="d-flex justify-content-between fw-semibold credit-next-charge">
<div class="charge-mode">After charge</div>
<div class="credit-mode">After Credit</div>
<div class="after-change"></div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-danger charge-mode" text-translate="true" name="command" value="charge">Charge</button>
<button type="submit" class="btn btn-success credit-mode" text-translate="true" name="command" value="credit">Credit</button>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
(function () {
const modalChargeEl = document.getElementById('updateCreditModal');
const idEl = modalChargeEl.querySelector('input[name="customerId"]');
const currentCredit = modalChargeEl.querySelector('.current-credit');
const afterChangeEl = modalChargeEl.querySelector('.after-change');
const amount = modalChargeEl.querySelector('.amount');
var creditMode = false;
var seq = 0;
var currency;
var currentCreditValue = 0;
amount.addEventListener('input', updateChargeAfter);
async function getFormattedValue(value, currency) {
const response = await fetch(`format-currency`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
amount: value,
currency: currency
})
});
return await response.text();
}
let debounceTimeout;
async function updateChargeAfter() {
const amountValue = Number(amount.value) || 0;
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.disabled = amountValue <= 0);
const remaining = creditMode ? currentCreditValue + amountValue : currentCreditValue - amountValue;
// Debounce the updates to avoid flooding the service
const DEBOUNCE_DELAY = 300; // 300ms delay
// Create a debounced update function
const debouncedUpdate = async () => {
seq++;
var thisSeq = seq;
afterChangeEl.innerText = '---';
if (thisSeq !== seq) return;
afterChangeEl.innerText = await getFormattedValue(remaining, currency);
};
if (debounceTimeout) clearTimeout(debounceTimeout);
afterChangeEl.innerText = '...';
debounceTimeout = setTimeout(debouncedUpdate, DEBOUNCE_DELAY);
}
modalChargeEl.addEventListener('show.bs.modal', function (event) {
const trigger = event.relatedTarget;
if (!trigger) return;
const tr = trigger.closest('tr');
currency = tr.getAttribute('data-currency');
currentCreditValue = Number(tr.getAttribute('data-current-credit-value'));
if (idEl) idEl.value = tr.getAttribute('data-subscriber-id');
modalChargeEl.querySelectorAll('.subscriber-name').forEach(el => el.innerText = tr.getAttribute('data-subscriber-email'));
creditMode = trigger.getAttribute('data-action') === 'credit';
const chargeModeEls = modalChargeEl.querySelectorAll('.charge-mode');
const creditModeEls = modalChargeEl.querySelectorAll('.credit-mode');
if (creditMode) {
chargeModeEls.forEach(el => el.style.display = 'none');
creditModeEls.forEach(el => el.style.display = '');
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.value = 'credit');
} else {
chargeModeEls.forEach(el => el.style.display = '');
creditModeEls.forEach(el => el.style.display = 'none');
modalChargeEl.querySelectorAll('button[name="command"]').forEach(el => el.value = 'charge');
}
currentCredit.innerText = '---';
getFormattedValue(currentCreditValue, currency).then(value => currentCredit.innerText = value);
amount.value = '';
afterChangeEl.innerText = '';
updateChargeAfter();
});
})();
});
</script>

View File

@@ -0,0 +1,121 @@

@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@using BTCPayServer.Controllers
@model ConfigureOfferingViewModel
@{
string offeringId = (string)this.Context.GetRouteValue("offeringId");
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Configure offering"], offeringId);
string storeId = (string)this.Context.GetRouteValue("storeId");
var deleteModal = new ConfirmModel(StringLocalizer["Delete offering"], StringLocalizer["This offering will be removed from this store."], StringLocalizer["Delete"])
{
ControllerName = "UIApps",
ActionName = nameof(UIAppsController.DeleteApp),
ActionValues = new { appId = Model.Data.AppId },
GenerateForm = true,
Antiforgery = true
};
}
<form method="post">
<input type="hidden" asp-for="OriginalName" />
<div class="sticky-header">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a asp-action="Offering"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-section="Plans">@StringLocalizer["Offering ({0})", Model.Name]</a>
</li>
<li class="breadcrumb-item active" aria-current="page">@ViewData["Title"]</li>
</ol>
<h2>@ViewData["Title"]</h2>
</nav>
<input id="page-primary" type="submit" value="Save" class="btn btn-primary" />
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly"></div>
}
<div class="form-group">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
<div class="form-group">
<label asp-for="SuccessRedirectUrl" class="form-label"></label>
<input asp-for="SuccessRedirectUrl" class="form-control" placeholder="https://example.com/success" />
<span asp-validation-for="SuccessRedirectUrl" class="text-danger"></span>
</div>
</div>
</div>
<section id="entitlements" class="mt-4">
<div class="d-flex align-items-center gap-4">
<h4 text-translate="true">Entitlements</h4>
<button type="submit" name="command" value="AddItem" class="btn btn-outline-secondary" id="AddPlanItem">Add item</button>
</div>
@if (Model.Entitlements?.Any() is true)
{
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 30%" text-translate="true">ID</th>
<th text-translate="true">Description</th>
<th style="width: 1%" text-translate="true"></th>
</tr>
</thead>
<tbody>
@for (int i = 0; i < Model.Entitlements.Count; i++)
{
<tr>
<td>
<input asp-for="Entitlements[i].Id" class="form-control">
<span asp-validation-for="Entitlements[i].Id" class="text-danger"></span>
</td>
<td>
<input asp-for="Entitlements[i].ShortDescription" class="form-control w-100">
<span asp-validation-for="Entitlements[i].ShortDescription" class="text-danger"></span>
</td>
<td>
<button type="submit" name="removeIndex" value="@i" class="d-inline-block btn text-danger btn-link">
<vc:icon symbol="cross" />
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<div class="d-grid d-sm-flex flex-wrap gap-3 mt-3">
<a id="DeleteApp" class="btn btn-outline-danger" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
data-description="The app <strong>@Html.Encode(Model.Name)</strong> and its settings will be permanently deleted."
data-confirm-input="@StringLocalizer["Delete"]"
permission="@Policies.CanModifyStoreSettings">Delete this offering</a>
</div>
</section>
</form>
<partial name="_Confirm" model="@deleteModal" permission="@Policies.CanModifyStoreSettings" />
@section PageFootContent {
@if (Model.Anchor != null)
{
<script>
document.getElementById(@Safe.Json(Model.Anchor)).scrollIntoView();
</script>
}
}

View File

@@ -0,0 +1,54 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Validation;
namespace BTCPayServer.Views.UIStoreMembership;
public class ConfigureOfferingViewModel
{
public ConfigureOfferingViewModel()
{
}
public ConfigureOfferingViewModel(OfferingData offeringData)
{
Name = offeringData.App.Name;
OriginalName = Name;
SuccessRedirectUrl = offeringData.SuccessRedirectUrl;
foreach (var entitlement in offeringData.Entitlements.OrderBy(b => b.CustomId))
{
Entitlements.Add(new EntitlementViewModel()
{
Id = entitlement.CustomId,
ShortDescription = entitlement.Description
});
}
Data = offeringData;
}
public OfferingData Data { get; set; }
public class EntitlementViewModel
{
[StringLength(50)]
[Required]
public string Id { get; set; }
[StringLength(500)]
public string ShortDescription { get; set; }
}
public string OriginalName { get; set; }
[Required]
[StringLength(50)]
public string Name { get; set; } = null!;
[Uri]
[StringLength(500)]
[Display(Name = "Success redirect url")]
public string SuccessRedirectUrl { get; set; }
public List<EntitlementViewModel> Entitlements { get; set; } = new();
public string Anchor { get; set; }
}

View File

@@ -0,0 +1,33 @@
@using BTCPayServer.Services
@model CreateOfferingViewModel
@{
ViewData.SetActivePage(StoreNavPages.Subscriptions, StringLocalizer["New offering"]);
}
<form method="post">
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<div>
<button cheat-mode="true" type="submit" name="command" value="create-fake" class="btn btn-outline-info" id="fake-offering-button">
Create fake offering
</button>
<a><input id="page-primary" type="submit" value="Create" class="btn btn-primary" /></a>
</div>
</div>
<partial name="_StatusMessage" />
<div class="row">
<div class="col-xl-8 col-xxl-constrain">
@if (!ViewContext.ModelState.IsValid)
{
<div asp-validation-summary="ModelOnly"></div>
}
<div class="form-group">
<label asp-for="Name" class="form-label" data-required></label>
<input asp-for="Name" class="form-control" required />
<span asp-validation-for="Name" class="text-danger"></span>
</div>
</div>
</div>
</form>

View File

@@ -0,0 +1,10 @@
using System.ComponentModel.DataAnnotations;
namespace BTCPayServer.Views.UIStoreMembership;
public class CreateOfferingViewModel
{
[Required]
[StringLength(50)]
public string Name { get; set; } = null!;
}

View File

@@ -0,0 +1,121 @@
@model List<SubscriptionsViewModel.SelectablePlan>
<div class="modal fade" id="newSubscriberModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" text-translate="true">Create a new subscriber</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
</div>
<form method="post"
asp-antiforgery="true"
action="new-subscriber">
<div class="modal-body">
<div text-translate="true">Would you like to invite a new subscriber?</div>
</div>
<div class="modal-body pt-0">
<div class="d-flex justify-content-between">
<div class="form-group flex-fill me-4">
<label for="newSubscriber-prefilledEmail" class="form-label" text-translate="true">Email</label>
<input id="newSubscriber-prefilledEmail"
name="prefilledEmail"
type="email"
autocomplete="email"
inputmode="email"
placeholder="Leave empty to let the customer configure it"
class="form-control me-2" />
</div>
<div class="form-group">
<label for="newSubscriber-linkExpiration" class="form-label" text-translate="true">Expires in</label>
<select id="newSubscriber-linkExpiration" name="linkExpiration" class="form-select w-auto">
<option value="1" text-translate="true">1 day</option>
<option value="7" text-translate="true">7 days</option>
<option value="30" text-translate="true">30 days</option>
</select>
</div>
</div>
<div class="form-group">
<label for="newSubscriber-plan" class="form-label" text-translate="true">Plan</label>
<!-- Vue-driven select -->
<select id="newSubscriber-plan"
name="planId"
class="form-select"
v-model="selectedPlanId"
:disabled="!canEditPlan">
<option :value="p.id" v-for="p in plans" :key="p.id">
{{ p.name }}
</option>
</select>
<input type="hidden" :value="selectedPlanId" name="planId" />
</div>
<!-- Trial checkbox only appears when plan supports trials -->
<div class="form-group d-flex align-items-center" v-if="showTrial">
<input id="newSubscriber-isTrial"
name="isTrial"
value="true"
type="checkbox"
class="btcpay-toggle me-3"
v-model="isTrial">
<label class="form-check-label" for="newSubscriber-isTrial" text-translate="true">Enable trial</label>
</div>
<div class="modal-footer">
<button type="submit" name="command" value="new-subscriber" class="btn btn-success" text-translate="true">Create</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
var selectablePlans = @Safe.Json(Model);
if (!selectablePlans.length) { return; }
var newSubscriberApp = new Vue({
el: '#newSubscriberModal',
data() {
return {
plans: selectablePlans,
selectedPlanId: selectablePlans[0].id,
isTrial: false,
canEditPlan: false
};
},
computed: {
selectedPlan() {
return this.plans.find(p => p.id === this.selectedPlanId) || null;
},
showTrial() {
return !!(this.selectedPlan && this.selectedPlan.hasTrial);
}
}
});
(function () {
const modalEl = document.getElementById('newSubscriberModal');
modalEl.addEventListener('show.bs.modal', function (event) {
const trigger = event.relatedTarget;
if (!trigger) return;
const tr = trigger.closest('tr');
Object.assign(newSubscriberApp.$data, newSubscriberApp.$options.data.call(this));
if (tr) {
newSubscriberApp.selectedPlanId = tr.getAttribute('data-plan-id');
newSubscriberApp.canEditPlan = false;
} else {
newSubscriberApp.canEditPlan = true;
}
});
})();
});
</script>

View File

@@ -0,0 +1,459 @@
@using BTCPayServer.Abstractions.Models
@using BTCPayServer.Client
@using BTCPayServer.Controllers
@using BTCPayServer.Plugins.Emails
@using BTCPayServer.Plugins.Subscriptions.Controllers
@using BTCPayServer.Services
@model SubscriptionsViewModel
@inject DisplayFormatter DisplayFormatter
@inject BTCPayServer.Security.ContentSecurityPolicies Csp
@{
string storeId = (string)this.Context.GetRouteValue("storeId");
string offeringId = (string)this.Context.GetRouteValue("offeringId");
ViewData.SetActivePage(AppsNavPages.Update, StringLocalizer["Subscriptions"], offeringId);
Csp.UnsafeEval();
}
@section PageHeadContent {
<style>
.card h6 {
font-weight: var(--btcpay-font-weight-semibold);
color: var(--btcpay-body-text-muted);
}
</style>
<link href="~/vendor/summernote/summernote-bs5.css" rel="stylesheet" asp-append-version="true" />
}
<div class="sticky-header">
<h2>@ViewData["Title"]</h2>
<div>
<a class="btn btn-secondary" asp-action="ConfigureOffering" asp-route-storeId="@storeId" asp-route-offeringId="@offeringId" text-translate="true">Configure</a>
</div>
</div>
<partial name="_StatusMessage" />
<div class="container-fluid px-4 py-4">
<!-- Metrics Cards -->
<div class="row">
<div class="col-md-2">
<div>
<h6 text-translate="true">Active Subscribers</h6>
<h4>@Model.TotalSubscribers</h4>
</div>
</div>
<div class="col-md-2">
<div>
<h6 text-translate="true">Monthly revenue</h6>
<h4 class="text-nowrap">@Model.TotalMonthlyRevenue</h4>
</div>
</div>
<div class="col-md-8"></div>
</div>
<!-- Navigation Tabs -->
<ul id="SectionNav" class="nav mb-4">
<a class="nav-link @(Model.Section == SubscriptionSection.Subscribers ? "active" : "")"
asp-action="Offering"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-section="@SubscriptionSection.Subscribers" text-translate="true">Subscribers</a>
<a class="nav-link @(Model.Section == SubscriptionSection.Plans ? "active" : "")"
asp-action="Offering"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-section="@SubscriptionSection.Plans" text-translate="true">Plans</a>
<a class="nav-link @(Model.Section == SubscriptionSection.Mails ? "active" : "")"
asp-action="Offering"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-section="@SubscriptionSection.Mails" text-translate="true">Mails</a>
</ul>
@if (Model.Section == SubscriptionSection.Plans)
{
<div class="d-flex justify-content-between align-items-center">
<h4>Plans</h4>
<a id="page-primary" permission="@Policies.CanModifyMembership" asp-route-storeId="@storeId" asp-route-offeringId="@offeringId" asp-action="AddPlan"
class="btn btn-primary"
role="button"
text-translate="true">Add Plan</a>
</div>
<!-- Subscription Plans Table -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th text-translate="true">Plan</th>
<th text-translate="true">API ID</th>
<th text-translate="true">Price</th>
<th text-translate="true">Recurring</th>
<th text-translate="true">Grace Period</th>
<th text-translate="true">Trial Period</th>
<th text-translate="true">Status</th>
<th text-translate="true">Active Members</th>
<th text-translate="true">Actions</th>
</tr>
</thead>
<tbody>
@if (Model.Plans.Count != 0)
{
foreach (var p in Model.Plans)
{
<tr id="plan_@p.Data.Id" class="plan-row align-middle"
data-plan-id="@p.Data.Id"
data-plan-name="@p.Data.Name"
data-allow-trial="@(p.Data.TrialDays > 0)">
<td class="fw-semibold text-nowrap plan-name-col">
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@p.Data.Name
</span>
<div class="dropdown-menu">
<a
href="#"
text-translate="true"
class="new-subscriber-link dropdown-item lh-base"
data-bs-toggle="modal"
data-bs-target="#newSubscriberModal">
Create a new subscriber
</a>
</div>
</td>
<td>
<vc:truncate-center
text="@p.Data.Id"
classes="truncate-center-id plan-row-id" />
</td>
<td class="text-nowrap">@DisplayFormatter.Currency(@p.Data.Price, @p.Data.Currency, DisplayFormatter.CurrencyFormat.CodeAndSymbol)</td>
<td>@p.Data.RecurringType</td>
<td>@p.Data.GracePeriodDays days</td>
<td>@p.Data.TrialDays days</td>
<td><span class="status-active">
@{
var (badge, name) = p.Data.Status switch
{
PlanData.PlanStatus.Retired =>
p.Data.MemberCount != 0 ? ("warning", StringLocalizer["Retiring"]) : ("danger", StringLocalizer["Retired"]),
_ => ("success", StringLocalizer["Active"])
};
}
<span class="subscriber-status badge badge-translucent rounded-pill text-bg-@badge">@name</span>
</span></td>
<td>@p.Data.MemberCount Members</td>
<td>
<div class="d-inline-flex align-items-center gap-3">
<a class="edit-plan" asp-action="AddPlan"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-planId="@p.Data.Id">Edit</a>
<a
asp-action="DeletePlan"
asp-route-storeId="@storeId"
asp-route-offeringId="@offeringId"
asp-route-planId="@p.Data.Id"
data-bs-toggle="modal" data-bs-target="#ConfirmModal"
data-description="@ViewLocalizer["This action will remove the plan <b>{0}</b>.", Html.Encode(p.Data.Name)]"
data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
</div>
</td>
</tr>
}
}
else
{
<tr>
<td colspan="8" class="text-secondary" text-translate="true">There are no subscription plans.</td>
</tr>
}
</tbody>
</table>
</div>
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove plan"], StringLocalizer["This action will remove this plan. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
}
else if (Model.Section == SubscriptionSection.Subscribers)
{
<div class="d-flex justify-content-between align-items-center">
<h4>Subscribers</h4>
@if (Model.SelectablePlans.Count != 0)
{
<a
href="#"
permission="@Policies.CanModifyMembership"
text-translate="true"
role="button"
id="page-primary"
class="new-subscriber btn btn-primary"
data-bs-toggle="modal"
data-bs-target="#newSubscriberModal">
Add subsriber
</a>
}
</div>
<form class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3 mb-4 col-xxl-8">
<input asp-for="SearchTerm" class="form-control" placeholder="@StringLocalizer["Search by email, external reference, name…"]" />
</form>
<!-- Subscription Plans Table -->
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th text-translate="true">User</th>
<th text-translate="true">Credits</th>
<th text-translate="true">Plan</th>
<th text-translate="true">Phase</th>
<th text-translate="true">Status</th>
<th text-translate="true">Actions</th>
</tr>
</thead>
<tbody>
@if (Model.Subscribers.Count != 0)
{
@foreach (var subscriber in Model.Subscribers)
{
<tr data-subscriber-email="@subscriber.Data.Customer.Email.Get()"
data-subscriber-id="@subscriber.Data.CustomerId"
data-currency="@subscriber.Data.Plan.Currency"
data-current-credit-value="@subscriber.Data.GetCredit()">
<td class="fw-semibold text-nowrap d-flex align-items-center">
<form method="post" asp-antiforgery="true" class="me-2">
@if (subscriber.Data.TestAccount)
{
<span class="badge badge-translucent rounded-pill text-bg-info">Test</span>
}
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@subscriber.Data.Customer.Email.Get()
</span>
<div class="dropdown-menu">
<input type="hidden" name="customerId" value="@subscriber.Data.CustomerId" />
<button type="submit" name="command" class="dropdown-item lh-base" value="toggle-test">
@if (subscriber.Data.TestAccount)
{
<span text-translate="true">Unmark test account</span>
}
else
{
<span text-translate="true">Mark as a test account</span>
}
</button>
</div>
</form>
</td>
<td class="fw-semibold text-nowrap subscriber-credit-col">
<span>
<span class="dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span>@DisplayFormatter.Currency(subscriber.Data.GetCredit(), subscriber.Data.Plan.Currency, DisplayFormatter.CurrencyFormat.CodeAndSymbol)</span>
</span>
<div class="dropdown-menu">
<a
href="#"
text-translate="true"
class="charge-subscriber-link dropdown-item lh-base"
data-bs-toggle="modal"
data-bs-target="#updateCreditModal"
data-action="credit">
Credit
</a>
<a
href="#"
text-translate="true"
class="charge-subscriber-link dropdown-item lh-base"
data-bs-toggle="modal"
data-bs-target="#updateCreditModal"
data-action="charge">
Charge
</a>
</div>
</span>
</td>
<td class="text-nowrap">@subscriber.Data.Plan.Name</td>
<td>
<span class="subscriber-phase">
@{
var (style, name) = subscriber.Data.Phase switch
{
SubscriberData.PhaseTypes.Normal => ("success", StringLocalizer["Normal"]),
SubscriberData.PhaseTypes.Expired => ("danger", StringLocalizer["Expired"]),
SubscriberData.PhaseTypes.Grace => ("warning", StringLocalizer["Grace"]),
SubscriberData.PhaseTypes.Trial => ("info", StringLocalizer["Trial"]),
_ => throw new NotSupportedException()
};
}
<span class="badge badge-translucent rounded-pill text-bg-@style">@name</span>
</span></td>
<td>
<span class="status-active">
<vc:subscriber-status subscriber="@subscriber.Data" can-suspend="true" />
</span>
</td>
<td>
<div class="d-inline-flex align-items-center gap-3">
<a asp-action="CreatePortalSession" asp-route-storeId="@storeId" asp-route-offeringId="@subscriber.Data.OfferingId"
asp-route-customerId="@subscriber.Data.CustomerId" class="portal-link" target="_blank">View Portal</a>
</div>
</td>
</tr>
}
if (Model.TooMuchSubscribers)
{
<tr>
<td colspan="8" class="text-secondary" text-translate="true">There are many subscribers, use search to look for them.</td>
</tr>
}
}
else
{
<td colspan="8" class="text-secondary" text-translate="true">There are no subscribers.</td>
}
</tbody>
</table>
</div>
}
else if (Model.Section == SubscriptionSection.Mails)
{
<form asp-antiforgery="true" method="post">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4 text-translate="true">Mails</h4>
<div class="d-flex justify-content-start sticky-footer">
<button id="page-primary" class="btn btn-success px-4" type="submit" text-translate="true">Save</button>
</div>
</div>
<div class="row g-4">
@if (!Model.EmailConfigured)
{
<div class="col-lg-6">
<div class="card h-100">
<div class="card-body">
<div class="d-flex align-items-center justify-content-between mb-2">
<h5 class="mb-0" text-translate="true">Email Configuration</h5>
</div>
<p class="text-muted mb-3" text-translate="true">No email address has been configured for the Server. Configure an email address
to
begin sending emails.</p>
<a
asp-area="@EmailsPlugin.Area"
asp-action="StoreEmailSettings"
asp-controller="UIStoresEmail"
asp-route-storeId="@storeId"
target="_blank"
class="btn btn-warning" text-translate="true">Configure email</a>
</div>
</div>
</div>
}
<!-- Notifications & Alerts -->
<div class="col-lg-@(Model.EmailConfigured ? "12" : "6")">
<div class="card h-100">
<div class="card-body">
<h5 class="mb-3" text-translate="true">Notifications & Alerts</h5>
<div class="form-group mb-4">
<label asp-for="PaymentRemindersDays" class="form-label" text-translate="true">Email Reminder Days Before Due</label>
<div class="input-group">
<input inputmode="number" asp-for="PaymentRemindersDays" class="form-control" style="max-width:12ch;"
min="0" />
<span class="input-group-text" text-translate="true">days</span>
</div>
<span asp-validation-for="PaymentRemindersDays" class="text-danger"></span>
</div>
</div>
</div>
</div>
</div>
<div class="card mt-4">
<div class="card-body">
<div class="d-flex align-items-center gap-4 mb-3">
<h5 class="mb-0" text-translate="true">Email rules</h5>
<span class="dropdown-toggle btn btn-outline-secondary" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">Add email rule</span>
<div class="dropdown-menu">
@foreach (var availableRule in Model.AvailableTriggers)
{
<button
type="submit"
name="addEmailRule"
value="@availableRule.Trigger"
class="new-subscriber-link dropdown-item lh-base">@StringLocalizer[availableRule.Description]</button>
}
</div>
</div>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th class="text-nowrap w-25" text-translate="true">Trigger</th>
<th class="w-75" text-translate="true">Subject</th>
<th class="actions-col" permission="@Policies.CanModifyStoreSettings"></th>
</tr>
</thead>
<tbody>
@if (Model.EmailRules.Count == 0)
{
<td colspan="2" class="text-secondary" text-translate="true">There are no email rules for this offering.</td>
}
else
{
@foreach (var rule in Model.EmailRules)
{
var thisPage = @Url.Action(nameof(UIOfferingController.Offering), new { storeId, offeringId, SubscriptionSection.Mails });
<tr>
<td>@rule.TriggerViewModel.Description</td>
<td>@rule.Data.Subject</td>
<td class="actions-col" permission="@Policies.CanModifyStoreSettings">
<div class="d-inline-flex align-items-center gap-3">
<a asp-area="@EmailsPlugin.Area"
asp-controller="UIStoreEmailRules"
asp-action="StoreEmailRulesEdit" asp-route-storeId="@storeId"
asp-route-redirectUrl="@thisPage"
asp-route-ruleId="@rule.Data.Id">Edit</a>
<a asp-area="@EmailsPlugin.Area"
asp-controller="UIStoreEmailRules"
asp-action="StoreEmailRulesDelete" asp-route-storeId="@storeId"
asp-route-redirectUrl="@thisPage"
asp-route-ruleId="@rule.Data.Id" data-bs-toggle="modal" data-bs-target="#ConfirmModal"
data-description="@ViewLocalizer["This action will remove the rule with the trigger <b>{0}</b>.", Html.Encode(rule.TriggerViewModel.Description)]"
data-confirm-input="@StringLocalizer["Delete"]" text-translate="true">Remove</a>
</div>
</td>
</tr>
}
}
</tbody>
</table>
</div>
</div>
</div>
</form>
<partial name="_Confirm" model="@(new ConfirmModel(StringLocalizer["Remove email rule"], StringLocalizer["This action will remove this rule. Are you sure?"], StringLocalizer["Delete"]))" permission="@Policies.CanModifyStoreSettings" />
}
<partial name="SuspendSubscriberModal" />
<partial name="NewSubscriberModal" model="Model.SelectablePlans" />
<partial name="ChangeCreditModal" />
</div>
@section PageFootContent {
<script>
document.addEventListener('DOMContentLoaded', function () {
$('.richtext2').summernote({
minHeight: 200,
tableClassName: 'table table-sm',
insertTableMaxSize: {
col: 5,
row: 10
},
codeviewFilter: true,
codeviewFilterRegex: new RegExp($.summernote.options.codeviewFilterRegex.source + '|<.*?( on\\w+?=.*?)>', 'gi'),
codeviewIframeWhitelistSrc: ['twitter.com', 'syndication.twitter.com']
});
});
</script>
<script src="~/vendor/vuejs/vue.min.js" asp-append-version="true"></script>
<script src="~/vendor/summernote/summernote-bs5.js" asp-append-version="true"></script>
}

View File

@@ -0,0 +1,51 @@
<div class="modal fade" id="suspendSubscriberModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" text-translate="true">Suspend Subscriber</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
</div>
<form method="post">
<div class="modal-body">
<div text-translate="true">Would you like to proceed with suspending the following user?</div>
</div>
<div class="modal-body pt-0">
<input id="suspendSubscriberId" name="customerId" type="hidden" />
<div class="form-group">
<label class="form-label" text-translate="true">Subscriber</label>
<input id="suspendSubscriberName" type="text" disabled="disabled" class="form-control" />
</div>
<div class="mb-3">
<label for="suspensionReason" class="form-label" text-translate="true">Suspension Reason</label>
<input id="suspensionReason" name="suspensionReason" type="text" placeholder="Enter the reason (optional)" class="form-control" />
</div>
<div class="modal-footer">
<button type="submit" name="command" value="suspend" class="btn btn-danger" text-translate="true">Suspend</button>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const modalEl = document.getElementById('suspendSubscriberModal');
if (!modalEl) return;
modalEl.addEventListener('show.bs.modal', function (event) {
const trigger = event.relatedTarget;
if (!trigger) return;
const email = trigger.getAttribute('data-subscriber-email');
const idEl = modalEl.querySelector('#suspendSubscriberId');
const nameEl = modalEl.querySelector('#suspendSubscriberName');
const reasonEl = modalEl.querySelector('#suspensionReason');
if (nameEl) nameEl.value = email || '';
if (reasonEl) reasonEl.value = '';
if (idEl) idEl.value = trigger.getAttribute('data-subscriber-id');
});
});
</script>

View File

@@ -0,0 +1,306 @@
@inject BTCPayServer.Services.BTCPayServerEnvironment Env
@inject DisplayFormatter DisplayFormatter
@inject LinkGenerator LinkGenerator
@using BTCPayServer.Plugins.Subscriptions
@using BTCPayServer.Services
@model PlanCheckoutViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["StoreBranding"] = Model.StoreBranding;
Layout = null;
ViewData.SetBlazorAllowed(false);
}
<!DOCTYPE html>
<html lang="en" @(Env.IsDeveloping ? " data-devenv" : "") id="PlanCheckout-@Model.Id">
<head>
<partial name="LayoutHead" />
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<style>
body {
background-color: var(--btcpay-body-bg-medium);
color: var(--btcpay-body-text);
}
.checkout-container {
min-height: 100vh;
padding: 2rem 0;
}
.subscription-details {
background: var(--btcpay-body-bg-light);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
height: fit-content;
}
.checkout-form {
background: var(--btcpay-body-bg-light);
border-radius: 12px;
padding: 2rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
position: sticky;
top: 2rem;
}
.company-logo {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.company-logo img {
width: 60px;
height: 60px;
object-fit: contain;
}
.subscription-title {
font-weight: 600;
margin-bottom: 0.5rem;
}
.subscription-description {
margin-bottom: 2rem;
}
.feature-list li {
padding: 0.5rem 0;
display: flex;
align-items: center;
color: var(--btcpay-body-text-muted);
}
.feature-list li i {
margin-right: 0.75rem;
width: 16px;
}
.feature-icon {
color: var(--btcpay-primary);
margin-right: 0.75rem;
width: 20px;
}
.price-display {
background: var(--btcpay-body-bg);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
margin: 2rem 0;
}
.price-amount {
font-size: 2.5rem;
font-weight: 700;
color: var(--btcpay-primary);
}
.price-period {
font-size: 1rem;
}
.form-control:focus {
border-color: var(--btcpay-primary);
box-shadow: 0 0 0 0.2rem rgba(5, 150, 105, 0.25);
}
.btn-checkout {
padding: 0.875rem 2rem;
width: 100%;
}
.checkout-header {
text-align: center;
margin-bottom: 2rem;
}
.checkout-title {
color: var(--btcpay-primary-text);
font-weight: 600;
margin-bottom: 0.5rem;
}
.checkout-subtitle {
font-size: 0.95rem;
}
@@media (max-width: 768px) {
.checkout-container {
padding: 1rem 0;
}
.subscription-details,
.checkout-form {
margin-bottom: 1.5rem;
padding: 1.5rem;
}
.checkout-form {
position: static;
}
}
</style>
</head>
<body>
<div class="checkout-container">
<div class="container">
<partial name="_StatusMessage" />
<div class="row">
<!-- Left Column - Subscription Details -->
<div class="col-lg-7 col-md-6">
<div class="subscription-details">
@if (Model.StoreBranding.LogoUrl is not null || Model.StoreName is not null)
{
<div class="d-flex align-items-center mb-4">
<div class="company-logo me-3">
@if (Model.StoreBranding.LogoUrl is null)
{
<img src="~/img/btcpay-logo.svg" alt="@Model.StoreName" />
}
else
{
<img src="@Model.StoreBranding.LogoUrl" alt="@Model.StoreName" />
}
</div>
<div>
<h6 class="mb-0 fw-semibold">@Model.StoreName</h6>
</div>
</div>
}
<h3 class="subscription-title d-inline-flex align-items-center gap-2">
<span>@Model.Title</span>
<a type="button" class="btn-link"
href="#"
data-bs-toggle="modal"
data-bs-target="#OpenQrModal">
<vc:icon symbol="qr-code" />
</a>
</h3>
<p class="subscription-description">@Model.Data.Description</p>
<div class="price-display">
<div
class="price-amount">@DisplayFormatter.Currency(Model.Data.Price, Model.Data.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
<div class="price-period">per month</div>
</div>
@if (Model.Data.PlanEntitlements.Count(i => !string.IsNullOrWhiteSpace(i.Entitlement.Description)) != 0)
{
<h5 class="mb-3">What's included:</h5>
<ul class="feature-list">
@foreach (var item in Model.Data.PlanEntitlements.Where(i => !string.IsNullOrWhiteSpace(i.Entitlement.Description)))
{
<li class="feature-item">
<i class="fas fa fa-check feature-icon"></i>
<span>@item.Entitlement.Description</span>
</li>
}
</ul>
}
</div>
</div>
<!-- Right Column - Checkout Form -->
<div class="col-lg-5 col-md-6">
<div class="checkout-form">
<div class="checkout-header">
<h4 class="checkout-title">Complete Your Order</h4>
<p class="checkout-subtitle">Enter your email to proceed to secure payment</p>
</div>
<form id="checkoutForm" method="post">
<div class="mb-4">
<label for="customerEmail" class="form-label fw-semibold">Email Address</label>
<input
asp-for="Email"
type="email"
class="form-control form-control-lg"
id="emailInput"
placeholder="Enter your email address"
required
disabled="@Model.IsPrefilled">
<span asp-validation-for="Email" class="text-danger"></span>
<div class="form-text">
<i class="fas fa fa-info-circle me-1"></i>
We'll send your receipt and account details here
</div>
</div>
@if (Model.IsTrial)
{
<button type="submit" name="command" value="start-trial" class="btn btn-primary btn-checkout btn-lg" id="proceedBtn">
<i class="fas fa fa-lock me-2"></i>
Proceed to free trial
</button>
}
else
{
<button type="submit" name="command" value="pay" class="btn btn-primary btn-checkout btn-lg" id="proceedBtn">
<i class="fas fa fa-lock me-2"></i>
Proceed to Secure Payment
</button>
}
</form>
<!-- Security Badges -->
<div class="security-badges">
<div class="security-badge store-footer">
<a class="store-powered-by" href="https://btcpayserver.org" target="_blank" rel="noreferrer noopener">
Powered by
<partial name="_StoreFooterLogo" />
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="OpenQrModal" tabindex="-1" aria-labelledby="ConfirmTitle" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" text-translate="true">Scan the QR code to open this page on a mobile</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body pt-0 text-center">
<component type="typeof(BTCPayServer.Blazor.QrCode)"
param-Data="@LinkGenerator.PlanCheckout(Model.Id, Context.Request.GetRequestBaseUrl())"
render-mode="Static"
/>
</div>
</div>
</div>
</div>
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
<script>
const emailInput = document.getElementById('emailInput');
const proceedBtn = document.getElementById('proceedBtn');
const checkoutForm = document.getElementById('checkoutForm');
emailInput.addEventListener('input', function () {
const isValid = this.checkValidity() && this.value.length > 0;
proceedBtn.disabled = !isValid;
});
checkoutForm.addEventListener('submit', function (e) {
const email = emailInput.value;
if (!email || !emailInput.checkValidity()) {
e.preventDefault();
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,84 @@
@model PlanCheckoutDefaultRedirectViewModel
@{
ViewData["Title"] = Model.Title;
ViewData["StoreBranding"] = Model.StoreBranding;
Layout = null;
ViewData.SetBlazorAllowed(false);
}
<!DOCTYPE html>
<html lang="en" id="PlanCheckout-@Model.Id">
<head>
<partial name="LayoutHead" />
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<style>
body {
background-color: var(--btcpay-body-bg-medium);
color: var(--btcpay-body-text);
}
.success-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
.success-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 3rem;
text-align: center;
max-width: 500px;
width: 100%;
}
.success-icon {
width: 80px;
height: 80px;
background: var(--btcpay-primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 2rem;
animation: pulse 2s infinite;
}
@@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.success-icon i {
color: white;
font-size: 2rem;
}
</style>
</head>
<body>
<div class="success-container">
<div class="success-card">
<div class="success-icon">
<i class="fa fa-check"></i>
</div>
<h1 class="success-title">Payment Successful!</h1>
<p class="success-message">
Thank you for your purchase! Your payment has been processed successfully and your subscription is now active.
</p>
<p class="text-muted">
A confirmation email has been sent to your registered email address with your subscription details.
</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,23 @@
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Models;
namespace BTCPayServer.Views.UIStoreMembership;
public class PlanCheckoutDefaultRedirectViewModel
{
public PlanCheckoutDefaultRedirectViewModel()
{
}
public PlanCheckoutDefaultRedirectViewModel(PlanCheckoutData data)
{
Data = data;
Id = data.Id;
}
public string Title { get; set; }
public string StoreName { get; set; }
public StoreBrandingViewModel StoreBranding { get; set; }
public string Id { get; set; }
public PlanCheckoutData Data { get; set; }
}

View File

@@ -0,0 +1,17 @@
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Models;
namespace BTCPayServer.Views.UIStoreMembership;
public class PlanCheckoutViewModel
{
public StoreBrandingViewModel StoreBranding { get; set; }
public string Title { get; set; }
public string StoreName { get; set; }
public string Email { get; set; }
public string Id { get; set; }
public PlanData Data { get; set; }
public bool IsPrefilled { get; set; }
public bool IsTrial { get; set; }
}

View File

@@ -0,0 +1,13 @@
@model (string ButtonClass, SubscriberData Subscriber)
<form method="post">
@if (Model.Subscriber.NextPlan.Renewable)
{
<button type="submit" name="command" value="pay" class="@Model.ButtonClass">Pay Now</button>
}
else
{
<button type="submit" name="command" value="migrate" class="@Model.ButtonClass">Upgrade</button>
}
</form>

View File

@@ -0,0 +1,723 @@
@using BTCPayServer.Client.Models
@using BTCPayServer.Services
@using BTCPayServer.Services.Invoices
@using NBXplorer
@model SubscriberPortalViewModel
@inject DisplayFormatter DisplayFormatter
@inject LinkGenerator linkGenerator;
@functions
{
int CountDays(DateTimeOffset date)
{
var days = (int)double.Ceiling((date - DateTimeOffset.UtcNow).TotalDays);
return days < 0 ? 0 : days;
}
string FormatDays(int days)
=> days is 1 or 0 ? StringLocalizer["{0} day", days] : StringLocalizer["{0} days", days];
private static string RecurringToString(PlanData.RecurringInterval type)
{
return type switch
{
PlanData.RecurringInterval.Lifetime => "Lifetime",
PlanData.RecurringInterval.Monthly => "per month",
PlanData.RecurringInterval.Quarterly => "per quarter",
PlanData.RecurringInterval.Yearly => "per year",
_ => throw new NotSupportedException()
};
}
}
@{
Layout = null;
var creditBalance = DisplayFormatter.Currency(Model.Credit.CurrentBalance, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol);
var daysRemaining = Model.Subscriber.NextPaymentDue is null ? 0 : CountDays(Model.Subscriber.NextPaymentDue.Value);
var date = Model.Subscriber.NextPaymentDue is null ? "" : Model.Subscriber.NextPaymentDue.Value.ToString("D");
var graceRemaining = Model.Subscriber.GracePeriodEnd is null ? 0 : CountDays(Model.Subscriber.GracePeriodEnd.Value);
var currency = Model.Subscriber.Plan.Currency;
SubscriberData.PhaseTypes? nextPhase = Model.Subscriber switch
{
{ Phase: SubscriberData.PhaseTypes.Trial, PeriodEnd: { } pe } => SubscriberData.PhaseTypes.Normal,
{ Phase: SubscriberData.PhaseTypes.Trial } => SubscriberData.PhaseTypes.Expired,
{ Phase: SubscriberData.PhaseTypes.Normal, GracePeriodEnd: { } gpe } => SubscriberData.PhaseTypes.Grace,
{ Phase: SubscriberData.PhaseTypes.Normal } => SubscriberData.PhaseTypes.Expired,
{ Phase: SubscriberData.PhaseTypes.Grace } => SubscriberData.PhaseTypes.Expired,
_ => null
};
}
<!DOCTYPE html>
<html lang="en">
<head>
<partial name="LayoutHead" />
<link href="~/vendor/font-awesome/css/font-awesome.min.css" asp-append-version="true" rel="stylesheet" />
<link href="~/main/site.css" asp-append-version="true" rel="stylesheet" />
<style>
:root {
--warning-bg: #fef3c7;
--warning-text: #92400e;
--danger-bg: #f8d7da;
--danger-text: #dc3545;
--info-bg: #cff4fc;
}
.company-logo {
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.company-logo img {
width: 60px;
height: 60px;
object-fit: contain;
}
.navbar {
border-bottom: 1px solid var(--btcpay-body-border-light);
padding: 1rem 0;
}
.main-content {
max-width: 1000px;
margin: 2rem auto;
padding: 0 1rem;
}
.card {
border-radius: 8px;
box-shadow: none;
margin-bottom: 1.5rem;
}
.card-header {
font-weight: 600;
padding: 1rem 1.5rem;
}
.card-body {
padding: 1.5rem;
}
.alert-translucent {
border-radius: 6px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
justify-content: space-between;
}
.alert-translucent.alert-warning {
background: var(--warning-bg);
border: 1px solid var(--btcpay-warning-border);
}
.alert-translucent.alert-info {
background: var(--info-bg);
border: 1px solid var(--btcpay-info-border);
}
.alert-translucent.alert-danger {
background: var(--danger-bg);
border: 1px solid var(--btcpay-danger-border);
}
.alert-translucent .alert-icon {
margin-right: 0.75rem;
font-size: 1.125rem;
}
.alert-translucent.alert-warning .alert-icon {
color: var(--btcpay-warning-border);
}
.alert-translucent.alert-danger .alert-icon {
color: var(--btcpay-danger-border);
}
.alert-translucent.alert-info .alert-icon {
color: var(--btcpay-info-border);
}
.alert-translucent.alert-warning .alert-text {
color: var(--warning-text);
}
.alert-translucent.alert-danger .alert-text {
color: var(--btcpay-danger);
}
.alert-translucent.alert-info .alert-text {
color: var(--btcpay-info);
}
.notice-content {
display: flex;
align-items: center;
}
.notice-title {
font-weight: 600;
margin-bottom: 0.125rem;
}
.notice-subtitle {
font-size: 0.875rem;
margin: 0;
}
.table {
margin-bottom: 0;
}
.table th {
border-top: none;
font-weight: 600;
}
.table td {
vertical-align: middle;
}
.plan-comparison {
background-color: var(--btcpay-neutral-100);
border-radius: 6px;
padding: 1rem;
margin: 1rem 0;
}
.current-plan {
border-left: 3px solid #0066cc;
padding-left: 1rem;
}
.text-muted {
color: #6c757d !important;
}
</style>
</head>
<body>
<!-- Navigation -->
<nav class="navbar btcpay-header">
<div class="container-fluid">
<div class="d-flex align-items-center">
<div class="company-logo me-3">
@if (Model.StoreBranding.LogoUrl is null)
{
<img src="~/img/btcpay-logo.svg" alt="@Model.StoreName" />
}
else
{
<img src="@Model.StoreBranding.LogoUrl" alt="@Model.StoreName" />
}
</div>
<span class="navbar-brand mb-0 h1">@Model.StoreName</span>
</div>
<div class="d-flex align-items-center gap-3">
@if (Model.Subscriber.TestAccount)
{
@if (Model.Subscriber.GetReminderDate() is var o && DateTimeOffset.UtcNow < o)
{
<form id="MoveToReminder"
asp-action="MoveTime"
asp-route-portalSessionId="@Model.Data.Id" method="post">
<button type="submit" class="btn btn-outline-info" name="command" value="reminder">Move to payment reminder</button>
</form>
}
@if (nextPhase is not null)
{
<form id="MovePhase"
asp-action="MoveTime"
asp-route-portalSessionId="@Model.Data.Id" method="post">
<button type="submit" class="btn btn-outline-info">Move to @nextPhase.Value</button>
</form>
}
<form id="Move7days"
asp-action="MoveTime"
asp-route-portalSessionId="@Model.Data.Id" method="post">
<button type="submit" class="btn btn-outline-info" name="command" value="move7days">Move to 7 days</button>
</form>
}
<span class="me-3 text-muted">@Model.Subscriber.Customer.GetPrimaryIdentity()</span>
</div>
</div>
</nav>
<div class="main-content">
<partial name="_StatusMessage" />
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">Subscription Management</h2>
@if (@Model.Subscriber.Customer.ExternalRef is not null)
{
<span class="text-muted">Customer Reference: @Model.Subscriber.Customer.ExternalRef</span>
}
</div>
<!-- Current Subscription -->
<div class="card">
<div class="card-header">
<i class="fa fa-address-book me-2"></i>Current Subscription
</div>
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h5 class="d-flex align-items-center gap-3 mb-2">
<span data-testid="plan-name">@Model.Subscriber.Plan.Name</span>
@if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
{
<span class="badge badge-translucent rounded-pill text-bg-info">Trial</span>
}
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Grace)
{
<span class="badge badge-translucent rounded-pill text-bg-warning">Grace</span>
}
</h5>
@if (Model.Subscriber.Plan.Description is not null)
{
<p class="text-muted mb-3">@Model.Subscriber.Plan.Description</p>
}
<div class="d-flex align-items-center mb-2">
<span class="me-3"><vc:subscriber-status subscriber="@Model.Subscriber" /></span>
@if (!string.IsNullOrEmpty(date))
{
@if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Normal)
{
<span class="text-muted">Next billing: @date</span>
}
else if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Trial)
{
<span class="text-muted">Trial expires: @date</span>
}
else if (Model.Subscriber.Phase is SubscriberData.PhaseTypes.Grace)
{
<span class="text-muted">The subscription has already expired and will soon be inactivated</span>
}
}
</div>
</div>
@if (Model.Subscriber.Plan.RecurringType != PlanData.RecurringInterval.Lifetime)
{
<div class="col-md-4 text-md-end">
<h4 class="mb-1">@DisplayFormatter.Currency(Model.Plan.Price, Model.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</h4>
<p class="text-muted mb-0">@RecurringToString(Model.Plan.RecurringType)</p>
</div>
}
</div>
</div>
</div>
@if (Model.Subscriber.IsSuspended)
{
<div class="alert-translucent alert-danger">
<div class="notice-content">
<i class="fa fa-warning alert-icon"></i>
<div class="alert-text">
<div class="notice-title"><span>Access suspended</span></div>
<p class="notice-subtitle">Your access to this subscription has been suspended.</p>
@if (!String.IsNullOrEmpty(Model.Subscriber.SuspensionReason))
{
<p class="notice-subtitle">Reason: @Model.Subscriber.SuspensionReason</p>
}
</div>
</div>
</div>
}
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Expired)
{
<div class="alert-translucent alert-danger">
<div class="notice-content">
<i class="fa fa-warning alert-icon"></i>
<div class="alert-text">
<div class="notice-title"><span>Access expired</span></div>
<p class="notice-subtitle">Your access to this subscription has been expired.</p>
</div>
</div>
<partial name="NextAction" model="@("btn btn-danger", Model.Subscriber)" />
</div>
}
else if (Model.Subscriber.Phase == SubscriberData.PhaseTypes.Trial)
{
<div class="alert-translucent alert-info">
<div class="notice-content">
<i class="fa fa-warning alert-icon"></i>
<div class="alert-text">
<div class="notice-title"><span>You are in trial</span></div>
<p class="notice-subtitle">You access will be revoked in <span>@FormatDays(daysRemaining)</span></p>
</div>
</div>
<partial name="NextAction" model="@("btn btn-info", Model.Subscriber)" />
</div>
}
else if (Model.Subscriber.GetReminderDate() is { } reminderDate2 &&
reminderDate2 < DateTimeOffset.UtcNow &&
!Model.Subscriber.IsNextPlanRenewable)
{
<div class="alert-translucent alert-warning">
<div class="notice-content">
<i class="fa fa-warning alert-icon"></i>
<div class="alert-text">
<div class="notice-title">Upgrade needed in <span>@FormatDays(daysRemaining)</span></div>
<div class="notice-subtitle">You access will be revoked in <span>@FormatDays(daysRemaining)</span>, you need to subscribe to a new plan.</div>
</div>
</div>
<partial name="NextAction" model="@("btn btn-warning", Model.Subscriber)" />
</div>
}
else if (Model.Subscriber.GetReminderDate() is { } reminderDate &&
reminderDate < DateTimeOffset.UtcNow &&
Model.Subscriber.MissingCredit() != 0)
{
@if (daysRemaining == 0)
{
<div class="alert-translucent alert-danger">
<div class="notice-content">
<i class="fa fa-warning alert-icon"></i>
<div class="alert-text">
<div class="notice-title">Payment due</div>
<div class="notice-subtitle">You access will be revoked in <span>@FormatDays(graceRemaining)</span></div>
</div>
</div>
<partial name="NextAction" model="@("btn btn-danger", Model.Subscriber)" />
</div>
}
else
{
<div class="alert-translucent alert-warning">
<div class="notice-content">
<i class="fa fa-clock-o alert-icon"></i>
<div class="alert-text">
<div class="notice-title">Payment due in <span>@FormatDays(daysRemaining)</span></div>
<p class="notice-subtitle">Next billing
date: @date • @DisplayFormatter.Currency(Model.Subscriber.Plan.Price, Model.Subscriber.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</p>
</div>
</div>
<partial name="NextAction" model="@("btn btn-warning", Model.Subscriber)" />
</div>
}
}
<!-- Billing Information -->
<div class="card">
<div class="card-header">
<i class="fa fa-credit-card me-2"></i>Billing Information
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="mb-2">Payment Method</h6>
<div class="d-flex justify-content-between align-items-center mb-3">
<div class="d-flex align-items-center">
<span class="badge bg-light text-dark border">Credit balance</span>
<span
class="ms-2 fw-semibold credit-balance">@creditBalance</span>
</div>
<button id="add-credit" class="btn btn-link p-0" type="button" data-bs-toggle="collapse" data-bs-target="#credit-input"
aria-expanded="false"
aria-controls="credit-input">
<i class="fa fa-credit-card"></i>
Add credit
</button>
</div>
<div class="collapse" id="credit-input">
<form method="post">
<div class="input-group">
<button type="submit" name="command" value="add-credit" class="btn btn-primary">Proceed to payment</button>
<input inputmode="decimal" class="form-control" placeholder="Enter credit amount" asp-for="Credit.InputAmount" min="0"
required />
</div>
<span asp-validation-for="Credit.InputAmount" class="text-danger"></span>
</form>
</div>
@if (Model.Subscriber.Plan.RecurringType is not PlanData.RecurringInterval.Lifetime)
{
<hr>
<div class="d-flex justify-content-between credit-plan-price">
<div class="text-muted">Plan price</div>
<div>@DisplayFormatter.Currency(Model.Subscriber.Plan.Price, Model.Subscriber.Plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
</div>
<div class="d-flex justify-content-between credit-applied">
<div class="text-muted">Credit applied</div>
<div class="text-success">
- @DisplayFormatter.Currency(Model.Credit.CreditApplied, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
</div>
<div class="d-flex justify-content-between fw-semibold credit-next-charge">
<div>Next charge on @date</div>
<div>@DisplayFormatter.Currency(Model.Credit.NextCharge, Model.Credit.Currency, DisplayFormatter.CurrencyFormat.Symbol)</div>
</div>
<form method="post" class="d-flex mt-3">
<input type="hidden" name="command" value="update-auto-renewal" />
<input type="checkbox" class="btcpay-toggle me-2" id="autoRenewal" asp-for="Subscriber.AutoRenew">
<label class="form-check-label" for="autoRenewal">Auto renewal</label>
</form>
}
</div>
@if (Model.Subscriber.Plan.RecurringType is not PlanData.RecurringInterval.Lifetime)
{
<div class="col-md-6">
<h6 class="mb-2">Notification</h6>
<p class="text-muted mb-0">
@foreach (var identity in Model.Subscriber.Customer.CustomerIdentities)
{
<vc:contact type="@identity.Type" value="@identity.Value"></vc:contact>
}
</p>
<p class="form-text mb-0">Payment reminder will be sent <span class="fw-bold">@Model.Subscriber.PaymentReminderDaysOrDefault days</span> before
expiration.</p>
</div>
}
</div>
</div>
</div>
@if (Model.Plan.PlanChanges.Any())
{
<!-- Plan Management -->
<div class="card">
<div class="card-header" id="plans">
<i class="fa fa-arrow-up me-2"></i>Plan Management
</div>
<div class="card-body">
<div class="plan-comparison">
@foreach (var batch in Model.PlanChanges.Batch(3))
{
<div class="row">
@foreach (var plan in batch)
{
if (plan.Current)
{
<div class="col-md-4" data-plan-name="@plan.Name">
<div class="current-plan">
<h6 class="mb-1 changeplan-title">@plan.Name</h6>
<p class="text-muted mb-2"><span>@DisplayFormatter.Currency(plan.Price, plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</span> <span>@RecurringToString(plan.RecurringType)</span></p>
<span class="badge bg-primary">Current</span>
</div>
</div>
}
else
{
<div class="col-md-4 changeplan-container"
data-plan-name="@plan.Name"
data-plan-id="@plan.PlanId">
<h6 class="mb-1 changeplan-title">@plan.Name</h6>
<p class="text-muted mb-2"><span>@DisplayFormatter.Currency(plan.Price, plan.Currency, DisplayFormatter.CurrencyFormat.Symbol)</span> <span>@RecurringToString(plan.RecurringType)</span></p>
<input type="hidden" name="changedPlanId" value="@plan.PlanId" />
@if (plan.ChangeType == PlanChangeData.ChangeType.Upgrade)
{
<a
href="#"
class="btn btn-outline-primary btn-sm"
data-bs-toggle="modal"
data-bs-target="#changePlanModal"
data-action="upgrade">
Upgrade
</a>
}
else
{
<a
href="#"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#changePlanModal"
data-action="downgrade">
Downgrade
</a>
}
</div>
}
}
</div>
}
</div>
</div>
</div>
}
@if (Model.Transactions.Count != 0)
{
<div class="card credit-history">
<div class="card-header">
<i class="fa fa-file me-2"></i> History
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th class="text-nowrap">Date</th>
<th>Description</th>
<th class="text-nowrap text-end">Amount</th>
<th class="text-nowrap text-end">Total Balance</th>
</tr>
</thead>
<tbody>
@foreach (var tx in Model.Transactions)
{
var color = tx.Amount >= 0 ? "text-success" : "text-danger";
<tr>
<td>@tx.Date.ToString("D")</td>
<td>@tx.Description</td>
<td class="@color text-end">
@DisplayFormatter.Currency(tx.Amount, currency, DisplayFormatter.CurrencyFormat.Symbol)
</td>
<td class="text-end">
@DisplayFormatter.Currency(tx.TotalBalance, currency, DisplayFormatter.CurrencyFormat.Symbol)
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
<div class="modal fade" id="changePlanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title upgrade-mode">Upgrade</h4>
<h4 class="modal-title downgrade-mode">Downgrade</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="@StringLocalizer["Close"]">
<vc:icon symbol="close" />
</button>
</div>
<form method="post">
<div class="modal-body">
<div html-translate="true" class="upgrade-mode">Would you like to upgrade <span class="subscriber-name fw-semibold"></span> to <b class="changePlanName"></b>?</div>
<div html-translate="true" class="downgrade-mode">Would you like to downgrade <span class="subscriber-name fw-semibold"></span> to <b class="changePlanName"></b> ?</div>
</div>
<div class="modal-body pt-0 pb-0">
<input name="changePlanId" type="hidden" />
<div id="changePlanCost" class="d-flex justify-content-between align-items-center pl">
<div class="text-muted">New plan cost</div>
<div></div>
</div>
@if (Model.Refund.Value != 0)
{
<div class="d-flex justify-content-between align-items-center pl">
<div class="text-muted">Current plan refund</div>
<div>-@Model.Refund.Display</div>
</div>
}
<div id="changePlanUsedCredits" class="d-flex justify-content-between align-items-center pl">
<div class="text-muted">Used credits</div>
<div></div>
</div>
<hr />
<div id="changePlanRemainingToPay" class="d-flex justify-content-between align-items-center pl">
<div class="text-muted">Amount due</div>
<div class="fw-bold"></div>
</div>
<div id="changePlanCreditBalance" class="d-flex justify-content-between align-items-center pl">
<div class="text-muted">Credit balance adjustment</div>
<div></div>
</div>
</div>
<div class="modal-footer">
<input type="hidden" name="changedPlanId" />
<button type="submit" class="btn btn-success upgrade-mode" text-translate="true" name="command" value="migrate">Upgrade</button>
<button type="submit" class="btn btn-danger downgrade-mode" text-translate="true" name="command" value="migrate">Downgrade</button>
</div>
</form>
</div>
</div>
</div>
</div>
<script>
var migrationPopups = @Safe.Json(Model.MigratePopups);
@if (!string.IsNullOrEmpty(Model.Anchor))
{
<text>
document.addEventListener("DOMContentLoaded", function () {
const element = document.getElementById(@Safe.Json(Model.Anchor));
if (element) {
element.scrollIntoView();
}
});
</text>
}
document.querySelector("#autoRenewal").addEventListener('change', function (e) {
e.preventDefault();
fetch(this.form.action, {
method: 'POST',
body: new FormData(this.form)
});
});
(function () {
const changePlanModal = document.getElementById('changePlanModal');
var changedPlanId = changePlanModal.querySelector('[name="changedPlanId"]');
var changePlanName = document.querySelectorAll('.changePlanName');
var changePlanCost = document.querySelector('#changePlanCost');
var changePlanUsedCredits = document.querySelector('#changePlanUsedCredits');
var changePlanCreditBalance = document.querySelector('#changePlanCreditBalance');
var changePlanUpgradeMode = document.querySelectorAll('.upgrade-mode');
var changePlanDowngradeMode = document.querySelectorAll('.downgrade-mode');
function setField(el, value) {
var div = el.children[1];
if (value === null || value === undefined) {
el.style.cssText = 'display: none !important';
div.innerText = '';
} else {
el.style.display = '';
div.innerText = value;
}
}
changePlanModal.addEventListener('show.bs.modal', function (event) {
const trigger = event.relatedTarget;
if (!trigger) return;
var container = trigger.closest('.changeplan-container');
var action = trigger.getAttribute('data-action');
var name = container.getAttribute('data-plan-name');
var id = container.getAttribute('data-plan-id');
changedPlanId.value = id;
changePlanName.forEach(el => el.innerText = name);
var m = migrationPopups[id];
setField(changePlanCost, m.cost);
setField(changePlanUsedCredits, m.usedCredit);
setField(changePlanRemainingToPay, m.amountDue);
setField(changePlanCreditBalance, m.creditBalanceAdjustment.text);
if (m.creditBalanceAdjustment.value > 0) {
changePlanCreditBalance.children[1].classList.add('text-success');
changePlanCreditBalance.children[1].classList.remove('text-danger');
} else {
changePlanCreditBalance.children[1].classList.add('text-danger');
changePlanCreditBalance.children[1].classList.remove('text-success');
}
if (action === "upgrade") {
changePlanDowngradeMode.forEach(el => el.style.display = 'none');
changePlanUpgradeMode.forEach(el => el.style.display = '');
} else {
changePlanDowngradeMode.forEach(el => el.style.display = '');
changePlanUpgradeMode.forEach(el => el.style.display = 'none');
}
});
})();
</script>
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
</body>
</html>

View File

@@ -0,0 +1,94 @@
using System;
using System.Collections.Generic;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Models;
using BTCPayServer.Plugins.Subscriptions;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
namespace BTCPayServer.Views.UIStoreMembership;
public class SubscriberPortalViewModel
{
public class CreditViewModel
{
public string Currency { get; set; }
public decimal CurrentBalance { get; set; }
public decimal CreditApplied { get; set; }
public decimal NextCharge { get; set; }
public decimal? InputAmount { get; set; }
}
public SubscriberPortalViewModel()
{
}
public SubscriberPortalViewModel(PortalSessionData data)
{
Data = data;
var credit = data.Subscriber.GetCredit();
var applied = Math.Min(credit, data.Subscriber.Plan.Price);
var nextCharge = data.Subscriber.Plan.Price - applied;
Credit = new()
{
Currency = data.Subscriber.Plan.Currency,
CurrentBalance = credit,
CreditApplied = applied,
InputAmount = nextCharge > 0 ? nextCharge : null,
NextCharge = nextCharge
};
}
public StoreBrandingViewModel StoreBranding { get; set; }
public string StoreName { get; set; }
public PortalSessionData Data { get; set; }
public (decimal Value, string Display) Refund { get; set; }
public CreditViewModel Credit { get; set; }
public record BalanceTransactionViewModel(DateTimeOffset Date, HtmlString Description, decimal Amount, decimal TotalBalance);
public List<BalanceTransactionViewModel> Transactions { get; set; } = new();
public List<PlanChange> PlanChanges { get; set; }
public class PlanChange
{
public PlanChange(PlanData plan)
{
Name = plan.Name;
PlanId = plan.Id;
RecurringType = plan.RecurringType;
Currency = plan.Currency;
Price = plan.Price;
}
public string Name { get; set; }
public bool Current { get; set; }
public PlanChangeData.ChangeType ChangeType { get; set; }
public string PlanId { get; set; }
public decimal Price { get; set; }
public string Currency { get; set; }
public PlanData.RecurringInterval RecurringType { get; set; }
}
public Dictionary<string, MigratePopup> MigratePopups { get; set; }
public class MigratePopup
{
public string Cost { get; set; }
public string UsedCredit { get; set; }
public string AmountDue { get; set; }
public (string Text, decimal Value) CreditBalanceAdjustment { get; set; }
}
[BindingBehavior(BindingBehavior.Never)]
[ValidateNever]
public SubscriberData Subscriber => Data.Subscriber;
[BindingBehavior(BindingBehavior.Never)]
[ValidateNever]
public PlanData Plan => Data.Subscriber.Plan;
public string Anchor { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace BTCPayServer.Views.UIStoreMembership;
public enum SubscriptionSection
{
Subscribers,
Plans,
Mails
}

View File

@@ -0,0 +1,60 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Plugins.Emails.Views;
using BTCPayServer.Services.Mails;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace BTCPayServer.Views.UIStoreMembership;
public class SubscriptionsViewModel
{
public SubscriptionsViewModel()
{
}
public SubscriptionsViewModel(OfferingData offeringData)
{
Currency = offeringData.App.StoreData.GetStoreBlob().DefaultCurrency;
}
public class PlanViewModel
{
public PlanData Data { get; set; }
}
public class MemberViewModel
{
public SubscriberData Data { get; set; }
}
public SubscriptionSection Section { get; set; }
public List<PlanViewModel> Plans { get; set; } = new();
public List<MemberViewModel> Subscribers { get; set; } = new();
public bool TooMuchSubscribers { get; set; }
public string Currency { get; set; }
public int TotalPlans { get; set; }
public int TotalSubscribers { get; set; }
public string TotalMonthlyRevenue { get; set; }
public record SelectablePlan(string Name, string Id, bool HasTrial);
public List<SelectablePlan> SelectablePlans { get; set; }
public bool EmailConfigured { get; set; }
public class EmailRule(EmailRuleData data)
{
public EmailTriggerViewModel TriggerViewModel { get; set; }
public EmailRuleData Data { get; set; } = data;
}
public List<EmailRule> EmailRules { get; set; }
public List<EmailTriggerViewModel> AvailableTriggers { get; set; }
public int PaymentRemindersDays { get; set; }
public string SearchTerm { get; set; }
}

View File

@@ -0,0 +1,4 @@
@using BTCPayServer.Views.Stores
@using BTCPayServer.Views.UIStoreMembership
@using BTCPayServer.Data.Subscriptions
@using BTCPayServer.Views.Apps

View File

@@ -0,0 +1,3 @@
@{
Layout = "_Layout";
}

View File

@@ -20,10 +20,10 @@ public static class WebhookExtensions
{ {
foreach(var trigger in viewModels) foreach(var trigger in viewModels)
{ {
var webhookType = trigger.Type; var webhookType = trigger.Trigger;
if (trigger.Type.StartsWith("WH-")) if (trigger.Trigger.StartsWith("WH-"))
throw new ArgumentException("Webhook type cannot start with WH-"); throw new ArgumentException("Webhook type cannot start with WH-");
trigger.Type = EmailRuleData.GetWebhookTriggerName(trigger.Type); trigger.Trigger = EmailRuleData.GetWebhookTriggerName(trigger.Trigger);
services.AddSingleton(new AvailableWebhookViewModel(webhookType, trigger.Description)); services.AddSingleton(new AvailableWebhookViewModel(webhookType, trigger.Description));
services.AddSingleton(trigger); services.AddSingleton(trigger);
} }

View File

@@ -71,7 +71,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{ {
new() new()
{ {
Type = PendingTransactionTriggerProvider.PendingTransactionCreated, Trigger = PendingTransactionTriggerProvider.PendingTransactionCreated,
Description = "Pending Transaction - Created", Description = "Pending Transaction - Created",
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created", SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Created",
BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}", BodyExample = "Review the transaction {PendingTransaction.Id} and sign it on: {PendingTransaction.Link}",
@@ -79,7 +79,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected, Trigger = PendingTransactionTriggerProvider.PendingTransactionSignatureCollected,
Description = "Pending Transaction - Signature Collected", Description = "Pending Transaction - Signature Collected",
SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}", SubjectExample = "Signature Collected for Pending Transaction {PendingTransaction.TrimmedId}",
BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ", BodyExample = "So far {PendingTransaction.SignaturesCollected} signatures collected out of {PendingTransaction.SignaturesNeeded} signatures needed. ",
@@ -87,7 +87,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = PendingTransactionTriggerProvider.PendingTransactionBroadcast, Trigger = PendingTransactionTriggerProvider.PendingTransactionBroadcast,
Description = "Pending Transaction - Broadcast", Description = "Pending Transaction - Broadcast",
SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast", SubjectExample = "Transaction {PendingTransaction.TrimmedId} has been Broadcast",
BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ", BodyExample = "Transaction is visible in mempool on: https://mempool.space/tx/{PendingTransaction.Id}. ",
@@ -95,7 +95,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = PendingTransactionTriggerProvider.PendingTransactionCancelled, Trigger = PendingTransactionTriggerProvider.PendingTransactionCancelled,
Description = "Pending Transaction - Cancelled", Description = "Pending Transaction - Cancelled",
SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled", SubjectExample = "Pending Transaction {PendingTransaction.TrimmedId} Cancelled",
BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ", BodyExample = "Transaction {PendingTransaction.Id} is cancelled and signatures are no longer being collected. ",
@@ -128,7 +128,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{ {
new() new()
{ {
Type = WebhookEventType.PaymentRequestCreated, Trigger = WebhookEventType.PaymentRequestCreated,
Description = "Payment Request - Created", Description = "Payment Request - Created",
SubjectExample = "Payment Request {PaymentRequest.Id} created", SubjectExample = "Payment Request {PaymentRequest.Id} created",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.", BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) created.",
@@ -137,7 +137,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PaymentRequestUpdated, Trigger = WebhookEventType.PaymentRequestUpdated,
Description = "Payment Request - Updated", Description = "Payment Request - Updated",
SubjectExample = "Payment Request {PaymentRequest.Id} updated", SubjectExample = "Payment Request {PaymentRequest.Id} updated",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.", BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) updated.",
@@ -146,7 +146,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PaymentRequestArchived, Trigger = WebhookEventType.PaymentRequestArchived,
Description = "Payment Request - Archived", Description = "Payment Request - Archived",
SubjectExample = "Payment Request {PaymentRequest.Id} archived", SubjectExample = "Payment Request {PaymentRequest.Id} archived",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.", BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) archived.",
@@ -155,7 +155,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PaymentRequestStatusChanged, Trigger = WebhookEventType.PaymentRequestStatusChanged,
Description = "Payment Request - Status Changed", Description = "Payment Request - Status Changed",
SubjectExample = "Payment Request {PaymentRequest.Id} status changed", SubjectExample = "Payment Request {PaymentRequest.Id} status changed",
BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.", BodyExample = "Payment Request {PaymentRequest.Id} ({PaymentRequest.Title}) status changed to {PaymentRequest.Status}.",
@@ -164,7 +164,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PaymentRequestCompleted, Trigger = WebhookEventType.PaymentRequestCompleted,
Description = "Payment Request - Completed", Description = "Payment Request - Completed",
SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed", SubjectExample = "Payment Request {PaymentRequest.Title} {PaymentRequest.ReferenceId} Completed",
BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}", BodyExample = "The total of {PaymentRequest.Amount} {PaymentRequest.Currency} has been received and Payment Request {PaymentRequest.Id} is completed.\nReview the payment request: {PaymentRequest.Link}",
@@ -190,7 +190,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{ {
new() new()
{ {
Type = WebhookEventType.PayoutCreated, Trigger = WebhookEventType.PayoutCreated,
Description = "Payout - Created", Description = "Payout - Created",
SubjectExample = "Payout {Payout.Id} created", SubjectExample = "Payout {Payout.Id} created",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.", BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) created.",
@@ -198,7 +198,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PayoutApproved, Trigger = WebhookEventType.PayoutApproved,
Description = "Payout - Approved", Description = "Payout - Approved",
SubjectExample = "Payout {Payout.Id} approved", SubjectExample = "Payout {Payout.Id} approved",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.", BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) approved.",
@@ -206,7 +206,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.PayoutUpdated, Trigger = WebhookEventType.PayoutUpdated,
Description = "Payout - Updated", Description = "Payout - Updated",
SubjectExample = "Payout {Payout.Id} updated", SubjectExample = "Payout {Payout.Id} updated",
BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.", BodyExample = "Payout {Payout.Id} (Pull Payment Id: {Payout.PullPaymentId}) updated.",
@@ -236,7 +236,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
{ {
new() new()
{ {
Type = WebhookEventType.InvoiceCreated, Trigger = WebhookEventType.InvoiceCreated,
Description = "Invoice - Created", Description = "Invoice - Created",
SubjectExample = "Invoice {Invoice.Id} created", SubjectExample = "Invoice {Invoice.Id} created",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) created.",
@@ -245,7 +245,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceReceivedPayment, Trigger = WebhookEventType.InvoiceReceivedPayment,
Description = "Invoice - Received Payment", Description = "Invoice - Received Payment",
SubjectExample = "Invoice {Invoice.Id} received payment", SubjectExample = "Invoice {Invoice.Id} received payment",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) received payment.",
@@ -254,7 +254,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceProcessing, Trigger = WebhookEventType.InvoiceProcessing,
Description = "Invoice - Is Processing", Description = "Invoice - Is Processing",
SubjectExample = "Invoice {Invoice.Id} processing", SubjectExample = "Invoice {Invoice.Id} processing",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is processing.",
@@ -263,7 +263,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceExpired, Trigger = WebhookEventType.InvoiceExpired,
Description = "Invoice - Expired", Description = "Invoice - Expired",
SubjectExample = "Invoice {Invoice.Id} expired", SubjectExample = "Invoice {Invoice.Id} expired",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired.",
@@ -272,7 +272,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceSettled, Trigger = WebhookEventType.InvoiceSettled,
Description = "Invoice - Is Settled", Description = "Invoice - Is Settled",
SubjectExample = "Invoice {Invoice.Id} settled", SubjectExample = "Invoice {Invoice.Id} settled",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) is settled.",
@@ -281,7 +281,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceInvalid, Trigger = WebhookEventType.InvoiceInvalid,
Description = "Invoice - Became Invalid", Description = "Invoice - Became Invalid",
SubjectExample = "Invoice {Invoice.Id} invalid", SubjectExample = "Invoice {Invoice.Id} invalid",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) invalid.",
@@ -290,7 +290,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoicePaymentSettled, Trigger = WebhookEventType.InvoicePaymentSettled,
Description = "Invoice - Payment Settled", Description = "Invoice - Payment Settled",
SubjectExample = "Invoice {Invoice.Id} payment settled", SubjectExample = "Invoice {Invoice.Id} payment settled",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) payment settled.",
@@ -299,7 +299,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoiceExpiredPaidPartial, Trigger = WebhookEventType.InvoiceExpiredPaidPartial,
Description = "Invoice - Expired Paid Partial", Description = "Invoice - Expired Paid Partial",
SubjectExample = "Invoice {Invoice.Id} expired with partial payment", SubjectExample = "Invoice {Invoice.Id} expired with partial payment",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) expired with partial payment. \nPlease review and take appropriate action: {Invoice.Link}",
@@ -308,7 +308,7 @@ public class WebhooksPlugin : BaseBTCPayServerPlugin
}, },
new() new()
{ {
Type = WebhookEventType.InvoicePaidAfterExpiration, Trigger = WebhookEventType.InvoicePaidAfterExpiration,
Description = "Invoice - Expired Paid Late", Description = "Invoice - Expired Paid Late",
SubjectExample = "Invoice {Invoice.Id} paid after expiration", SubjectExample = "Invoice {Invoice.Id} paid after expiration",
BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.", BodyExample = "Invoice {Invoice.Id} (Order Id: {Invoice.OrderId}) paid after expiration.",

View File

@@ -447,7 +447,8 @@ retry:
goto retry; goto retry;
} }
public async Task UpdateOrCreateApp(AppData app) public Task UpdateOrCreateApp(AppData app) => UpdateOrCreateApp(app, true);
public async Task UpdateOrCreateApp(AppData app, bool sendEvents)
{ {
await using var ctx = _ContextFactory.CreateContext(); await using var ctx = _ContextFactory.CreateContext();
var newApp = string.IsNullOrEmpty(app.Id); var newApp = string.IsNullOrEmpty(app.Id);
@@ -465,11 +466,14 @@ retry:
ctx.Entry(app).Property(data => data.AppType).IsModified = false; ctx.Entry(app).Property(data => data.AppType).IsModified = false;
} }
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
if (sendEvents)
{
if (newApp) if (newApp)
_eventAggregator.Publish(new AppEvent.Created(app)); _eventAggregator.Publish(new AppEvent.Created(app));
else else
_eventAggregator.Publish(new AppEvent.Updated(app)); _eventAggregator.Publish(new AppEvent.Updated(app));
} }
}
private static bool TryParseJson(string json, [MaybeNullWhen(false)] out JObject result) private static bool TryParseJson(string json, [MaybeNullWhen(false)] out JObject result)
{ {

View File

@@ -9,6 +9,15 @@ namespace BTCPayServer.Services.Apps
{ {
public abstract class AppBaseType public abstract class AppBaseType
{ {
public AppBaseType()
{
}
public AppBaseType(string typeName)
{
Type = typeName;
}
public string Description { get; set; } = string.Empty; public string Description { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty; public string Type { get; set; } = string.Empty;
public abstract Task<object?> GetInfo(AppData appData); public abstract Task<object?> GetInfo(AppData appData);

View File

@@ -296,6 +296,7 @@ namespace BTCPayServer.Services.Invoices
public string Currency { get; set; } public string Currency { get; set; }
[JsonConverter(typeof(PaymentMethodIdJsonConverter))] [JsonConverter(typeof(PaymentMethodIdJsonConverter))]
public PaymentMethodId DefaultPaymentMethod { get; set; } public PaymentMethodId DefaultPaymentMethod { get; set; }
[JsonExtensionData] [JsonExtensionData]
public IDictionary<string, JToken> AdditionalData { get; set; } public IDictionary<string, JToken> AdditionalData { get; set; }
@@ -763,9 +764,6 @@ namespace BTCPayServer.Services.Invoices
public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl); public RequestBaseUrl GetRequestBaseUrl() => RequestBaseUrl.FromUrl(ServerUrl);
} }
public enum InvoiceStatusLegacy
{
}
public static class InvoiceStatusLegacyExtensions public static class InvoiceStatusLegacyExtensions
{ {
public static string ToLegacyStatusString(this InvoiceStatus status) => public static string ToLegacyStatusString(this InvoiceStatus status) =>

View File

@@ -24,7 +24,7 @@
<li class="nav-item" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings"> <li class="nav-item" not-permission="@Policies.CanModifyStoreSettings" permission="@Policies.CanViewStoreSettings">
<span class="nav-link"> <span class="nav-link">
<vc:icon symbol="nav-pointofsale" /> <vc:icon symbol="nav-pointofsale" />
<span>Point of Sale</span> <span text-translate="true">Point of Sale</span>
</span> </span>
</li> </li>
} }

View File

@@ -255,7 +255,7 @@
</div> </div>
<div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat"> <div v-if="orderAmount > 0 && srvModel.orderAmountFiat" id="PaymentDetails-TotalFiat" key="TotalFiat">
<dt v-t="'total_fiat'"></dt> <dt v-t="'total_fiat'"></dt>
<dd class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd> <dd id="total_fiat" class="clipboard-button clipboard-button-hover" :data-clipboard="asNumber(srvModel.orderAmountFiat)" data-clipboard-hover="start">{{srvModel.orderAmountFiat}}</dd>
</div> </div>
<div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded"> <div v-if="srvModel.taxIncluded.value > 0 && srvModel.taxIncluded.formatted" id="PaymentDetails-TaxIncluded" key="TaxIncluded">
<dt v-t="'tax_included'"></dt> <dt v-t="'tax_included'"></dt>

View File

@@ -20,6 +20,7 @@ namespace BTCPayServer.Views.Stores
Plugins, Plugins,
Webhooks, Webhooks,
PullPayments, PullPayments,
Subscriptions,
Reporting, Reporting,
Payouts, Payouts,
PayoutProcessors, PayoutProcessors,

View File

@@ -206,7 +206,7 @@
"securitySchemes": { "securitySchemes": {
"API_Key": { "API_Key": {
"type": "apiKey", "type": "apiKey",
"description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n", "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canviewmembership`: View your membership\n* `btcpay.store.canmodifymembership`: Modify your membership\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
"name": "Authorization", "name": "Authorization",
"in": "header" "in": "header"
}, },