[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>
class="form-select email-rule-trigger" required></select> @if (Model.CanChangeTrigger)
<span asp-validation-for="Trigger" class="text-danger"></span> {
<div class="form-text" text-translate="true">Choose what event sends the email.</div> <select asp-for="Trigger"
asp-items="@Model.Triggers.Select(t => new SelectListItem(StringLocalizer[t.Description], t.Trigger))"
class="form-select email-rule-trigger" required></select>
<span asp-validation-for="Trigger" class="text-danger"></span>
<div class="form-text" text-translate="true">Choose what event sends the email.</div>
}
else
{
<input type="hidden" asp-for="Trigger"></input>
<input asp-for="Trigger" class="form-control email-rule-trigger-hidden" disabled></input>
}
</div> </div>
<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,10 +466,13 @@ 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 (newApp) if (sendEvents)
_eventAggregator.Publish(new AppEvent.Created(app)); {
else if (newApp)
_eventAggregator.Publish(new AppEvent.Updated(app)); _eventAggregator.Publish(new AppEvent.Created(app));
else
_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

@@ -1,226 +1,226 @@
{ {
"openapi": "3.0.0", "openapi": "3.0.0",
"info": { "info": {
"title": "BTCPay Greenfield API", "title": "BTCPay Greenfield API",
"version": "v1", "version": "v1",
"description": "# Introduction\n\nThe BTCPay Server Greenfield API is a REST API. Our API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs.\n\n# Authentication\n\nYou can authenticate either via Basic Auth or an API key. It's recommended to use an API key for better security. You can create an API key in the BTCPay Server UI under `Account` -> `Manage Account` -> `API keys`. You can restrict the API key for one or multiple stores and for specific permissions. For testing purposes, you can give it the 'Unrestricted access' permission. On production you should limit the permissions to the actual endpoints you use, you can see the required permission on the API docs at the top of each endpoint under `AUTHORIZATIONS`.\n\nIf you want to simplify the process of creating API keys for your users, you can use the [Authorization endpoint](https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Authorization) to predefine permissions and redirect your users to the BTCPay Server Authorization UI. You can find more information about this on the [API Authorization Flow docs](https://docs.btcpayserver.org/BTCPayServer/greenfield-authorization/) page.\n\n# Usage examples\n\nUse **Basic Auth** to read store information with cURL:\n```bash\nBTCPAY_INSTANCE=\"https://mainnet.demo.btcpayserver.org\"\nUSER=\"MyTestUser@gmail.com\"\nPASSWORD=\"notverysecurepassword\"\nPERMISSION=\"btcpay.store.canmodifystoresettings\"\nBODY=\"$(echo \"{}\" | jq --arg \"a\" \"$PERMISSION\" '. + {permissions:[$a]}')\"\n\nAPI_KEY=\"$(curl -s \\\n -H \"Content-Type: application/json\" \\\n --user \"$USER:$PASSWORD\" \\\n -X POST \\\n -d \"$BODY\" \\\n \"$BTCPAY_INSTANCE/api/v1/api-keys\" | jq -r .apiKey)\"\n```\n\n\nUse an **API key** to read store information with cURL:\n```bash\nSTORE_ID=\"yourStoreId\"\n\ncurl -s \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: token $API_KEY\" \\\n -X GET \\\n \"$BTCPAY_INSTANCE/api/v1/stores/$STORE_ID\"\n```\n\nYou can find more examples on our docs for different programming languages:\n- [cURL](https://docs.btcpayserver.org/Development/GreenFieldExample/)\n- [Javascript/Node.Js](https://docs.btcpayserver.org/Development/GreenFieldExample-NodeJS/)\n- [PHP](https://docs.btcpayserver.org/Development/GreenFieldExample-PHP/)\n\n", "description": "# Introduction\n\nThe BTCPay Server Greenfield API is a REST API. Our API has predictable resource-oriented URLs, accepts form-encoded request bodies, returns JSON-encoded responses, and uses standard HTTP response codes, authentication, and verbs.\n\n# Authentication\n\nYou can authenticate either via Basic Auth or an API key. It's recommended to use an API key for better security. You can create an API key in the BTCPay Server UI under `Account` -> `Manage Account` -> `API keys`. You can restrict the API key for one or multiple stores and for specific permissions. For testing purposes, you can give it the 'Unrestricted access' permission. On production you should limit the permissions to the actual endpoints you use, you can see the required permission on the API docs at the top of each endpoint under `AUTHORIZATIONS`.\n\nIf you want to simplify the process of creating API keys for your users, you can use the [Authorization endpoint](https://docs.btcpayserver.org/API/Greenfield/v1/#tag/Authorization) to predefine permissions and redirect your users to the BTCPay Server Authorization UI. You can find more information about this on the [API Authorization Flow docs](https://docs.btcpayserver.org/BTCPayServer/greenfield-authorization/) page.\n\n# Usage examples\n\nUse **Basic Auth** to read store information with cURL:\n```bash\nBTCPAY_INSTANCE=\"https://mainnet.demo.btcpayserver.org\"\nUSER=\"MyTestUser@gmail.com\"\nPASSWORD=\"notverysecurepassword\"\nPERMISSION=\"btcpay.store.canmodifystoresettings\"\nBODY=\"$(echo \"{}\" | jq --arg \"a\" \"$PERMISSION\" '. + {permissions:[$a]}')\"\n\nAPI_KEY=\"$(curl -s \\\n -H \"Content-Type: application/json\" \\\n --user \"$USER:$PASSWORD\" \\\n -X POST \\\n -d \"$BODY\" \\\n \"$BTCPAY_INSTANCE/api/v1/api-keys\" | jq -r .apiKey)\"\n```\n\n\nUse an **API key** to read store information with cURL:\n```bash\nSTORE_ID=\"yourStoreId\"\n\ncurl -s \\\n -H \"Content-Type: application/json\" \\\n -H \"Authorization: token $API_KEY\" \\\n -X GET \\\n \"$BTCPAY_INSTANCE/api/v1/stores/$STORE_ID\"\n```\n\nYou can find more examples on our docs for different programming languages:\n- [cURL](https://docs.btcpayserver.org/Development/GreenFieldExample/)\n- [Javascript/Node.Js](https://docs.btcpayserver.org/Development/GreenFieldExample-NodeJS/)\n- [PHP](https://docs.btcpayserver.org/Development/GreenFieldExample-PHP/)\n\n",
"contact": { "contact": {
"name": "BTCPay Server", "name": "BTCPay Server",
"url": "https://btcpayserver.org" "url": "https://btcpayserver.org"
},
"license": {
"name": "MIT",
"url": "https://github.com/btcpayserver/btcpayserver/blob/master/LICENSE"
}
}, },
"servers": [ "license": {
{ "name": "MIT",
"url": "/", "url": "https://github.com/btcpayserver/btcpayserver/blob/master/LICENSE"
"description": "BTCPay Server Greenfield API" }
},
"servers": [
{
"url": "/",
"description": "BTCPay Server Greenfield API"
}
],
"externalDocs": {
"description": "Check out our examples on how to use the API",
"url": "https://docs.btcpayserver.org/Development/GreenFieldExample/"
},
"components": {
"parameters": {
"StoreId": {
"name": "storeId",
"in": "path",
"required": true,
"description": "The store ID",
"schema": {
"$ref": "#/components/schemas/StoreId"
} }
], },
"externalDocs": { "InvoiceId": {
"description": "Check out our examples on how to use the API", "name": "invoiceId",
"url": "https://docs.btcpayserver.org/Development/GreenFieldExample/" "in": "path",
"required": true,
"description": "The invoice ID",
"schema": {
"type": "string"
}
},
"UserIdOrEmail": {
"name": "idOrEmail",
"in": "path",
"required": true,
"description": "The user's id or email",
"schema": {
"type": "string"
}
}
}, },
"components": { "schemas": {
"parameters": { "ValidationProblemDetails": {
"StoreId": { "type": "array",
"name": "storeId", "description": "An array of validation errors of the request",
"in": "path", "items": {
"required": true, "type": "object",
"description": "The store ID", "description": "A specific validation error on a json property",
"schema": { "properties": {
"$ref": "#/components/schemas/StoreId" "path": {
} "type": "string",
"nullable": false,
"description": "The json path of the property which failed validation"
}, },
"InvoiceId": { "message": {
"name": "invoiceId", "type": "string",
"in": "path", "nullable": false,
"required": true, "description": "User friendly error message about the validation"
"description": "The invoice ID",
"schema": {
"type": "string"
}
},
"UserIdOrEmail": {
"name": "idOrEmail",
"in": "path",
"required": true,
"description": "The user's id or email",
"schema": {
"type": "string"
}
}
},
"schemas": {
"ValidationProblemDetails": {
"type": "array",
"description": "An array of validation errors of the request",
"items": {
"type": "object",
"description": "A specific validation error on a json property",
"properties": {
"path": {
"type": "string",
"nullable": false,
"description": "The json path of the property which failed validation"
},
"message": {
"type": "string",
"nullable": false,
"description": "User friendly error message about the validation"
}
}
}
},
"ProblemDetails": {
"type": "object",
"description": "Description of an error happening during processing of the request",
"properties": {
"code": {
"type": "string",
"nullable": false,
"description": "An error code describing the error"
},
"message": {
"type": "string",
"nullable": false,
"description": "User friendly error message about the error"
}
}
},
"UnixTimestamp": {
"type": "number",
"format": "int32",
"example": 1592312018,
"description": "A unix timestamp in seconds"
},
"SpeedPolicy": {
"type": "string",
"description": "This is a risk mitigation parameter for the merchant to configure how they want to fulfill orders depending on the number of block confirmations for the transaction made by the consumer on the selected cryptocurrency.\n`\"HighSpeed\"`: 0 confirmations (1 confirmation if RBF enabled in transaction) \n`\"MediumSpeed\"`: 1 confirmation \n`\"LowMediumSpeed\"`: 2 confirmations \n`\"LowSpeed\"`: 6 confirmations\n",
"x-enumNames": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
],
"enum": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
]
},
"TimeSpan": {
"type": "number",
"format": "int32",
"example": 90
},
"TimeSpanSeconds": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "seconds",
"description": "A span of times in seconds"
},
"TimeSpanDays": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "days",
"description": "A span of times in days"
},
"TimeSpanMinutes": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "minutes",
"description": "A span of times in minutes"
},
"StoreId": {
"type": "string",
"description": "Store ID of the item",
"example": "9CiNzKoANXxmk5ayZngSXrHTiVvvgCrwrpFQd4m2K776"
},
"PaymentMethodId": {
"type": "string",
"description": "Payment method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning \n- `\"BTC-LNURL\"`: LNURL",
"example": "BTC-CHAIN"
},
"PayoutMethodId": {
"type": "string",
"description": "Payout method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning",
"example": "BTC-LN"
},
"HistogramData": {
"type": "object",
"description": "Histogram data for wallet balances over time",
"properties": {
"type": {
"type": "string",
"description": "The timespan of the histogram data",
"x-enumNames": [
"Week",
"Month",
"Year"
],
"enum": [
"Week",
"Month",
"Year"
],
"default": "Week"
},
"balance": {
"type": "string",
"format": "decimal",
"description": "The current wallet balance"
},
"series": {
"type": "array",
"description": "An array of historic balances of the wallet",
"items": {
"type": "string",
"format": "decimal",
"description": "The balance of the wallet at a specific time"
}
},
"labels": {
"type": "array",
"description": "An array of timestamps associated with the series data",
"items": {
"type": "integer",
"description": "UNIX timestamp of the balance snapshot"
}
}
}
}
},
"securitySchemes": {
"API_Key": {
"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",
"name": "Authorization",
"in": "header"
},
"Basic": {
"type": "http",
"description": "BTCPay Server supports authenticating and authorizing users through the Basic HTTP authentication scheme. Send the user and password encoded in base64 with the format `Basic {base64(username:password)}`. Using this authentication method implicitly provides you with the `unrestricted` permission",
"scheme": "Basic"
} }
}
} }
},
"ProblemDetails": {
"type": "object",
"description": "Description of an error happening during processing of the request",
"properties": {
"code": {
"type": "string",
"nullable": false,
"description": "An error code describing the error"
},
"message": {
"type": "string",
"nullable": false,
"description": "User friendly error message about the error"
}
}
},
"UnixTimestamp": {
"type": "number",
"format": "int32",
"example": 1592312018,
"description": "A unix timestamp in seconds"
},
"SpeedPolicy": {
"type": "string",
"description": "This is a risk mitigation parameter for the merchant to configure how they want to fulfill orders depending on the number of block confirmations for the transaction made by the consumer on the selected cryptocurrency.\n`\"HighSpeed\"`: 0 confirmations (1 confirmation if RBF enabled in transaction) \n`\"MediumSpeed\"`: 1 confirmation \n`\"LowMediumSpeed\"`: 2 confirmations \n`\"LowSpeed\"`: 6 confirmations\n",
"x-enumNames": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
],
"enum": [
"HighSpeed",
"MediumSpeed",
"LowSpeed",
"LowMediumSpeed"
]
},
"TimeSpan": {
"type": "number",
"format": "int32",
"example": 90
},
"TimeSpanSeconds": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "seconds",
"description": "A span of times in seconds"
},
"TimeSpanDays": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "days",
"description": "A span of times in days"
},
"TimeSpanMinutes": {
"allOf": [
{
"$ref": "#/components/schemas/TimeSpan"
}
],
"format": "minutes",
"description": "A span of times in minutes"
},
"StoreId": {
"type": "string",
"description": "Store ID of the item",
"example": "9CiNzKoANXxmk5ayZngSXrHTiVvvgCrwrpFQd4m2K776"
},
"PaymentMethodId": {
"type": "string",
"description": "Payment method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning \n- `\"BTC-LNURL\"`: LNURL",
"example": "BTC-CHAIN"
},
"PayoutMethodId": {
"type": "string",
"description": "Payout method IDs. Available payment method IDs for Bitcoin are: \n- `\"BTC-CHAIN\"`: Onchain \n-`\"BTC-LN\"`: Lightning",
"example": "BTC-LN"
},
"HistogramData": {
"type": "object",
"description": "Histogram data for wallet balances over time",
"properties": {
"type": {
"type": "string",
"description": "The timespan of the histogram data",
"x-enumNames": [
"Week",
"Month",
"Year"
],
"enum": [
"Week",
"Month",
"Year"
],
"default": "Week"
},
"balance": {
"type": "string",
"format": "decimal",
"description": "The current wallet balance"
},
"series": {
"type": "array",
"description": "An array of historic balances of the wallet",
"items": {
"type": "string",
"format": "decimal",
"description": "The balance of the wallet at a specific time"
}
},
"labels": {
"type": "array",
"description": "An array of timestamps associated with the series data",
"items": {
"type": "integer",
"description": "UNIX timestamp of the balance snapshot"
}
}
}
}
}, },
"security": [ "securitySchemes": {
{ "API_Key": {
"API_Key": [], "type": "apiKey",
"Basic": [] "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",
] "in": "header"
},
"Basic": {
"type": "http",
"description": "BTCPay Server supports authenticating and authorizing users through the Basic HTTP authentication scheme. Send the user and password encoded in base64 with the format `Basic {base64(username:password)}`. Using this authentication method implicitly provides you with the `unrestricted` permission",
"scheme": "Basic"
}
}
},
"security": [
{
"API_Key": [],
"Basic": []
}
]
} }