[Greenfield] Subscription API (#7022)
What changed, and why it matters
This commit introduces a new Greenfield (REST) Subscription API to BTCPay Server, allowing stores to manage subscription offerings, plans, subscribers, credits, and checkout sessions programmatically. It also refactors existing subscription internals, adds new permissions, and makes small related fixes such as handling empty URL paths and adding a null check for invoice IDs. There is no explicit security bug in the diff, but the large new attack surface and a few design choices warrant careful review.
Treat this as a significant feature addition requiring security review rather than an immediate vulnerability. Review authorization policy enforcement on all new endpoints, validate that anonymous endpoints cannot be abused to enumerate or mutate subscriber data, ensure CustomerSelector parsing cannot be confused to access another customer's records, and confirm that UpdateCredit with AllowOverdraft and arbitrary currency is intended. Run integration tests and consider a focused audit of the new subscription API before release.
Security signals we found
New large API surface added (offerings, plans, subscribers, credits, checkouts, portal sessions)
New permissions CanManageSubscribers and CanCreditSubscribers introduced; CanModifyOfferings grants both implicitly
AllowAnonymous endpoints for plan-checkout and subscriber-portal create unauthenticated resources by ID
CustomerSelector parsing uses regex and string splitting; edge cases like 'cust_test@ggwg.com' are parsed as email identity
UpdateCredit endpoint allows arbitrary credit/charge adjustments with optional overdraft
Plan checkout creation accepts metadata JObjects and SuccessRedirectLink; redirect URL is validated with Uri.IsWellFormedUriString
GreenfieldAuthorizationHandler now reads storeId from context.Resource as fallback, changing authorization context behavior
LocalGreenfieldAuthorizationHandler removed; local Greenfield auth now flows through main handler
Evidence from the diff
The commit adds ~2,800 lines across 48 files, primarily implementing a new Greenfield API for subscriptions. Key additions include controllers (GreenfieldOfferingController), client SDK methods (BTCPayServerClient.Subscriptions.cs), models, EF Core migrations, and Swagger templates. It introduces two new store-scoped permissions: CanManageSubscribers and CanCreditSubscribers, and wires them under CanModifyOfferings. Several existing subscription workflows are refactored: credit/charge operations now use explicit Credit/Charge parameters with an AllowOverdraft flag; plan checkout creation no longer requires a pre-existing subscriber and supports new-subscriber email; ProceedToSubscribe no longer takes a CustomerSelector because the email is captured in the checkout. Minor fixes include RequestBaseUrl handling empty AbsolutePath as ‘/’, GetInvoice throwing ArgumentNullException for null invoiceId, and removal of the LocalGreenfieldAuthorizationHandler in favor of a single GreenfieldAuthorizationHandler that can accept a store ID from context.Resource.
Changed components
BTCPayServer.Client (subscription client methods and models)BTCPayServer.Data (CustomerSelector, Subscription entities, migrations)BTCPayServer/Plugins/Subscriptions (controllers, hosted service, mapper, webhooks)BTCPayServer/Security/GreenField (authorization handler)BTCPayServer/Controllers/GreenField/GreenfieldInvoiceControllerBTCPayServer/wwwroot/swagger/v1 (OpenAPI templates)Inspect captured patch +2837 / −310
diff --git a/BTCPayServer.Abstractions/RequestBaseUrl.cs b/BTCPayServer.Abstractions/RequestBaseUrl.cs
index a3c223a..76deb64 100644
--- a/BTCPayServer.Abstractions/RequestBaseUrl.cs
+++ b/BTCPayServer.Abstractions/RequestBaseUrl.cs
@@ -1,5 +1,6 @@
using System;
using System.Diagnostics.CodeAnalysis;
+using BTCPayServer.Abstractions.Extensions;
using Microsoft.AspNetCore.Http;
namespace BTCPayServer.Abstractions;
@@ -7,7 +8,7 @@ namespace BTCPayServer.Abstractions;
public record RequestBaseUrl(string Scheme, HostString Host, PathString PathBase)
{
public static RequestBaseUrl FromUrl(Uri url)
- => new RequestBaseUrl(url.Scheme, new HostString(url.Authority), new PathString(url.AbsolutePath));
+ => new RequestBaseUrl(url.Scheme, new HostString(url.Authority), new PathString(url.AbsolutePath == "" ? "/" : url.AbsolutePath));
public static RequestBaseUrl FromUrl(string url)
{
@@ -30,6 +31,9 @@ public record RequestBaseUrl(string Scheme, HostString Host, PathString PathBase
}
+ public string GetUrl(string relativePath)
+ => ToString().WithoutEndingSlash() + relativePath.WithStartingSlash();
+
public override string ToString()
=> string.Concat(
Scheme,
diff --git a/BTCPayServer.Client/BTCPayServerClient.Invoices.cs b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs
index 23e23cc..753a81b 100644
--- a/BTCPayServer.Client/BTCPayServerClient.Invoices.cs
+++ b/BTCPayServer.Client/BTCPayServerClient.Invoices.cs
@@ -43,6 +43,7 @@ public partial class BTCPayServerClient
public virtual async Task<InvoiceData> GetInvoice(string storeId, string invoiceId,
CancellationToken token = default)
{
+ if (invoiceId == null) throw new ArgumentNullException(nameof(invoiceId));
return await SendHttpRequest<InvoiceData>($"api/v1/stores/{storeId}/invoices/{invoiceId}", null, HttpMethod.Get, token);
}
public virtual async Task<InvoicePaymentMethodDataModel[]> GetInvoicePaymentMethods(string storeId, string invoiceId,
diff --git a/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs b/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs
new file mode 100644
index 0000000..a787a91
--- /dev/null
+++ b/BTCPayServer.Client/BTCPayServerClient.Subscriptions.cs
@@ -0,0 +1,58 @@
+#nullable enable
+using System;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using BTCPayServer.Client.Models;
+
+namespace BTCPayServer.Client;
+
+public partial class BTCPayServerClient
+{
+ public async Task<OfferingModel> CreateOffering(string storeId, OfferingModel offering, CancellationToken token = default)
+ => await SendHttpRequest<OfferingModel>($"api/v1/stores/{storeId}/offerings", offering, HttpMethod.Post, token);
+ public async Task<OfferingPlanModel> CreateOfferingPlan(string storeId, string offeringId, CreatePlanRequest request, CancellationToken token = default)
+ => await SendHttpRequest<OfferingPlanModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/plans", request, HttpMethod.Post, token);
+ public async Task<OfferingPlanModel> GetOfferingPlan(string storeId, string offeringId, string planId, CancellationToken token = default)
+ => await SendHttpRequest<OfferingPlanModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/plans/{planId}", null, HttpMethod.Get, token);
+
+ public async Task<OfferingModel> GetOffering(string storeId, string offeringId, CancellationToken token = default)
+ => await SendHttpRequest<OfferingModel>($"api/v1/stores/{storeId}/offerings/{offeringId}", null, HttpMethod.Get, token);
+ public async Task<OfferingModel[]> GetOfferings(string storeId, CancellationToken token = default)
+ => await SendHttpRequest<OfferingModel[]>($"api/v1/stores/{storeId}/offerings", null, HttpMethod.Get, token);
+ public async Task<PlanCheckoutModel> CreatePlanCheckout(CreatePlanCheckoutRequest request, CancellationToken token = default)
+ => await SendHttpRequest<PlanCheckoutModel>($"api/v1/plan-checkout", request, HttpMethod.Post, token);
+ public async Task<PlanCheckoutModel> GetPlanCheckout(string checkoutId, CancellationToken token = default)
+ => await SendHttpRequest<PlanCheckoutModel>($"api/v1/plan-checkout/{checkoutId}", null, HttpMethod.Get, token);
+
+ public async Task<CreditModel> GetCredit(string storeId, string offeringId, string customerSelector, string currency, CancellationToken token = default)
+ => await SendHttpRequest<CreditModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{Uri.EscapeDataString(customerSelector)}/credits/{currency}", null, HttpMethod.Get, token);
+
+ public async Task<CreditModel> UpdateCredit(string storeId, string offeringId, string customerSelector, string currency, UpdateCreditRequest request, CancellationToken token = default)
+ => await SendHttpRequest<CreditModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{Uri.EscapeDataString(customerSelector)}/credits/{currency}", request, HttpMethod.Post, token);
+
+ public async Task<PlanCheckoutModel> ProceedPlanCheckout(string checkoutId, string? email = null, CancellationToken token = default)
+ {
+ if (email is not null)
+ return await SendHttpRequest<PlanCheckoutModel>($"api/v1/plan-checkout/{checkoutId}?email={Uri.EscapeDataString(email)}", null, HttpMethod.Post, token);
+ else
+ return await SendHttpRequest<PlanCheckoutModel>($"api/v1/plan-checkout/{checkoutId}", null, HttpMethod.Post, token);
+ }
+ public async Task<SubscriberModel> GetSubscriber(string storeId, string offeringId, string customerSelector, CancellationToken token = default)
+ => await SendHttpRequest<SubscriberModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{Uri.EscapeDataString(customerSelector)}", null, HttpMethod.Get, token);
+
+ public async Task<SubscriberModel> SuspendSubscriber(string storeId, string offeringId, string customerSelector, string? reason = null,
+ CancellationToken token = default)
+ => await SendHttpRequest<SubscriberModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{Uri.EscapeDataString(customerSelector)}/suspend", new SuspendSubscriberRequest()
+ {
+ Reason = reason
+ },
+ HttpMethod.Post, token);
+ public async Task<SubscriberModel> UnsuspendSubscriber(string storeId, string offeringId, string customerSelector, CancellationToken token = default)
+ => await SendHttpRequest<SubscriberModel>($"api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/unsuspend", null, HttpMethod.Post, token);
+
+ public async Task<PortalSessionModel> CreatePortalSession(CreatePortalSessionRequest request, CancellationToken token = default)
+ => await SendHttpRequest<PortalSessionModel>($"api/v1/subscriber-portal", request, HttpMethod.Post, token);
+ public async Task<PortalSessionModel> GetPortalSession(string portalSessionId, CancellationToken token = default)
+ => await SendHttpRequest<PortalSessionModel>($"api/v1/subscriber-portal/{portalSessionId}", null, HttpMethod.Get, token);
+}
diff --git a/BTCPayServer.Client/Models/CustomerModel.cs b/BTCPayServer.Client/Models/CustomerModel.cs
index 0f99927..de8b4a6 100644
--- a/BTCPayServer.Client/Models/CustomerModel.cs
+++ b/BTCPayServer.Client/Models/CustomerModel.cs
@@ -1,8 +1,12 @@
-namespace BTCPayServer.Client.Models;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
public class CustomerModel
{
public string StoreId { get; set; }
public string Id { get; set; }
public string ExternalId { get; set; }
+ public JObject Identities { get; set; }
+ public JObject Metadata { get; set; }
}
diff --git a/BTCPayServer.Client/Models/OfferingModel.cs b/BTCPayServer.Client/Models/OfferingModel.cs
deleted file mode 100644
index 85fb701..0000000
--- a/BTCPayServer.Client/Models/OfferingModel.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace BTCPayServer.Client.Models;
-
-public class OfferingModel
-{
- public string Id { get; set; } = null!;
- public string AppName { get; set; }
- public string AppId { get; set; } = null!;
- public string SuccessRedirectUrl { get; set; }
-
-}
diff --git a/BTCPayServer.Client/Models/SubscriberModel.cs b/BTCPayServer.Client/Models/SubscriberModel.cs
deleted file mode 100644
index aca3b7b..0000000
--- a/BTCPayServer.Client/Models/SubscriberModel.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using System;
-using Newtonsoft.Json;
-
-namespace BTCPayServer.Client.Models;
-
-public class SubscriberModel
-{
- public CustomerModel Customer { get; set; }
- public OfferingModel Offer { get; set; }
- public SubscriptionPlanModel Plan { get; set; }
-
- [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
- public DateTimeOffset? PeriodEnd { get; set; }
- [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
- public DateTimeOffset? TrialEnd { get; set; }
-
- [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
- public DateTimeOffset? GracePeriodEnd { get; set; }
-
- public bool IsActive { get; set; }
- public bool IsSuspended { get; set; }
- public string SuspensionReason { get; set; }
-}
diff --git a/BTCPayServer.Client/Models/SubscriptionPlanModel.cs b/BTCPayServer.Client/Models/SubscriptionPlanModel.cs
deleted file mode 100644
index 4108819..0000000
--- a/BTCPayServer.Client/Models/SubscriptionPlanModel.cs
+++ /dev/null
@@ -1,38 +0,0 @@
-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[] Features { get; set; }
-}
diff --git a/BTCPayServer.Client/Models/Subscriptions/CreatePlanCheckoutRequest.cs b/BTCPayServer.Client/Models/Subscriptions/CreatePlanCheckoutRequest.cs
new file mode 100644
index 0000000..fc314a5
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/CreatePlanCheckoutRequest.cs
@@ -0,0 +1,33 @@
+using System;
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class CreatePlanCheckoutRequest
+{
+ public string StoreId { get; set; }
+ public string OfferingId { get; set; }
+ public string PlanId { get; set; }
+ public string CustomerSelector { get; set; }
+ [JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter.Minutes))]
+ public TimeSpan? DurationMinutes { get; set; }
+ [JsonConverter(typeof(StringEnumConverter))]
+ public OnPayBehavior? OnPayBehavior { get; set; }
+
+ public JObject NewSubscriberMetadata { get; set; }
+ public JObject InvoiceMetadata { get; set; }
+ public JObject Metadata { get; set; }
+ public bool? IsTrial { get; set; }
+ /// <summary>
+ /// The amount of credit to purchase. The amount of credit purchased will be equal to what need to be paid
+ /// to top-up the plan.
+ /// If enough credit is available, the plan will start immediately.
+ /// </summary>
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal? CreditPurchase { get; set; }
+ public string SuccessRedirectLink { get; set; }
+ public string NewSubscriberEmail { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/CreatePlanRequest.cs b/BTCPayServer.Client/Models/Subscriptions/CreatePlanRequest.cs
new file mode 100644
index 0000000..407fed8
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/CreatePlanRequest.cs
@@ -0,0 +1,22 @@
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class CreatePlanRequest
+{
+ public string Description { get; set; }
+ public string Currency { get; set; }
+ public int? GracePeriodDays { get; set; }
+ public string Name { get; set; }
+ public bool? OptimisticActivation { get; set; }
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal? Price { get; set; }
+ public bool? Renewable { get; set; }
+ public int? TrialDays { get; set; }
+ public JObject Metadata { get; set; }
+ [JsonConverter(typeof(StringEnumConverter))]
+ public OfferingPlanModel.RecurringInterval? RecurringType { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/CreatePortalSessionRequest.cs b/BTCPayServer.Client/Models/Subscriptions/CreatePortalSessionRequest.cs
new file mode 100644
index 0000000..8bf066b
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/CreatePortalSessionRequest.cs
@@ -0,0 +1,13 @@
+using System;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Client.Models;
+
+public class CreatePortalSessionRequest
+{
+ public string StoreId { get; set; }
+ public string OfferingId { get; set; }
+ public string CustomerSelector { get; set; }
+ [JsonConverter(typeof(JsonConverters.TimeSpanJsonConverter.Minutes))]
+ public TimeSpan? DurationMinutes { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/CreditModel.cs b/BTCPayServer.Client/Models/Subscriptions/CreditModel.cs
new file mode 100644
index 0000000..6660346
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/CreditModel.cs
@@ -0,0 +1,11 @@
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Client.Models;
+
+public class CreditModel
+{
+ public string Currency { get; set; }
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal Value { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/FeatureModel.cs b/BTCPayServer.Client/Models/Subscriptions/FeatureModel.cs
new file mode 100644
index 0000000..a41717c
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/FeatureModel.cs
@@ -0,0 +1,7 @@
+namespace BTCPayServer.Client.Models;
+
+public class FeatureModel
+{
+ public string Id { get; set; }
+ public string Description { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/OfferingModel.cs b/BTCPayServer.Client/Models/Subscriptions/OfferingModel.cs
new file mode 100644
index 0000000..629f9f2
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/OfferingModel.cs
@@ -0,0 +1,19 @@
+using System.Collections.Generic;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class OfferingModel
+{
+ public string Id { get; set; } = null!;
+ public string StoreId { get; set; }
+ public string AppName { get; set; }
+ public string AppId { get; set; } = null!;
+ public string SuccessRedirectUrl { get; set; }
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public List<OfferingPlanModel> Plans { get; set; }
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public List<FeatureModel> Features { get; set; }
+ public JObject Metadata { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/OfferingPlanModel.cs b/BTCPayServer.Client/Models/Subscriptions/OfferingPlanModel.cs
new file mode 100644
index 0000000..f98ef51
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/OfferingPlanModel.cs
@@ -0,0 +1,43 @@
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class OfferingPlanModel
+{
+ [JsonConverter(typeof(StringEnumConverter))]
+ public enum PlanStatus
+ {
+ Active,
+ Retired
+ }
+
+ [JsonConverter(typeof(StringEnumConverter))]
+ 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[] Features { get; set; }
+ public bool Renewable { get; set; }
+ public JObject Metadata { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/PlanCheckoutModel.cs b/BTCPayServer.Client/Models/Subscriptions/PlanCheckoutModel.cs
new file mode 100644
index 0000000..973a49c
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/PlanCheckoutModel.cs
@@ -0,0 +1,57 @@
+using System;
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+[JsonConverter(typeof(StringEnumConverter))]
+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 class PlanCheckoutModel
+{
+ public SubscriberModel Subscriber { get; set; }
+ public OfferingPlanModel Plan { get; set; }
+ public string BaseUrl { get; set; }
+ public string Id { get; set; }
+ public string InvoiceId { get; set; }
+ public string SuccessRedirectUrl { get; set; }
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset Expiration { get; set; }
+ public string RedirectUrl { get; set; }
+ public JObject InvoiceMetadata { get; set; }
+ public JObject Metadata { get; set; }
+ public bool NewSubscriber { get; set; }
+ public bool IsTrial { get; set; }
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset Created { get; set; }
+ public bool PlanStarted { get; set; }
+ public JObject NewSubscriberMetadata { get; set; }
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal? RefundAmount { get; set; }
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal CreditedByInvoice { get; set; }
+ [JsonConverter(typeof(StringEnumConverter))]
+ public OnPayBehavior OnPayBehavior { get; set; }
+ public bool IsExpired { get; set; }
+ public string Url { get; set; }
+
+ /// <summary>
+ /// The amount of credit to purchase. The amount of credit purchased will be equal to what need to be paid
+ /// to top-up the plan.
+ /// If enough credit is available, the plan will start immediately.
+ /// </summary>
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal? CreditPurchase { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/PortalSessionModel.cs b/BTCPayServer.Client/Models/Subscriptions/PortalSessionModel.cs
new file mode 100644
index 0000000..2c5ae8b
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/PortalSessionModel.cs
@@ -0,0 +1,15 @@
+using System;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Client.Models;
+
+public class PortalSessionModel
+{
+ public string BaseUrl { get; set; }
+ public string Id { get; set; }
+ public SubscriberModel Subscriber { get; set; }
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset? Expiration { get; set; }
+ public bool IsExpired { get; set; }
+ public string Url { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs b/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs
new file mode 100644
index 0000000..271bdd1
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/SubscriberModel.cs
@@ -0,0 +1,33 @@
+using System;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Client.Models;
+
+public class SubscriberModel
+{
+ [JsonConverter(typeof(NBitcoin.JsonConverters.DateTimeToUnixTimeConverter))]
+ public DateTimeOffset Created { get; set; }
+ public CustomerModel Customer { get; set; }
+ public OfferingModel Offering { get; set; }
+ public OfferingPlanModel 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; }
+ public bool AutoRenew { get; set; }
+ public JObject Metadata { get; set; }
+ public string ProcessingInvoiceId { get; set; }
+ public OfferingPlanModel NextPlan { get; set; }
+ [JsonConverter(typeof(StringEnumConverter))]
+ public SubscriptionPhase Phase { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/SubscriptionPhase.cs b/BTCPayServer.Client/Models/Subscriptions/SubscriptionPhase.cs
new file mode 100644
index 0000000..ec8ee90
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/SubscriptionPhase.cs
@@ -0,0 +1,14 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace BTCPayServer.Client.Models;
+
+// Subscription phases carried by subscriber-related webhook events
+[JsonConverter(typeof(StringEnumConverter))]
+public enum SubscriptionPhase
+{
+ Normal,
+ Expired,
+ Grace,
+ Trial
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/SuspendSubscriberRequest.cs b/BTCPayServer.Client/Models/Subscriptions/SuspendSubscriberRequest.cs
new file mode 100644
index 0000000..fc0c116
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/SuspendSubscriberRequest.cs
@@ -0,0 +1,6 @@
+namespace BTCPayServer.Client.Models;
+
+public class SuspendSubscriberRequest
+{
+ public string Reason { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/Subscriptions/UpdateCreditRequest.cs b/BTCPayServer.Client/Models/Subscriptions/UpdateCreditRequest.cs
new file mode 100644
index 0000000..eb052d3
--- /dev/null
+++ b/BTCPayServer.Client/Models/Subscriptions/UpdateCreditRequest.cs
@@ -0,0 +1,14 @@
+using BTCPayServer.JsonConverters;
+using Newtonsoft.Json;
+
+namespace BTCPayServer.Client.Models;
+
+public class UpdateCreditRequest
+{
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal Credit { get; set; }
+ [JsonConverter(typeof(NumericStringJsonConverter))]
+ public decimal Charge { get; set; }
+ public string Description { get; set; }
+ public bool AllowOverdraft { get; set; }
+}
diff --git a/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs b/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
index 009506a..b944d17 100644
--- a/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
+++ b/BTCPayServer.Client/Models/WebhookSubscriptionEvent.cs
@@ -42,16 +42,6 @@ public class WebhookSubscriptionEvent : StoreWebhookEvent
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()
diff --git a/BTCPayServer.Client/Permissions.cs b/BTCPayServer.Client/Permissions.cs
index 9f46be3..b24ec37 100644
--- a/BTCPayServer.Client/Permissions.cs
+++ b/BTCPayServer.Client/Permissions.cs
@@ -42,6 +42,8 @@ namespace BTCPayServer.Client
public const string CanViewPullPayments = "btcpay.store.canviewpullpayments";
public const string CanViewOfferings = "btcpay.store.canviewofferings";
public const string CanModifyOfferings = "btcpay.store.canmodifyofferings";
+ public const string CanManageSubscribers = "btcpay.store.canmanagesubscribers";
+ public const string CanCreditSubscribers = "btcpay.store.cancreditsubscribers";
public const string CanCreateNonApprovedPullPayments = "btcpay.store.cancreatenonapprovedpullpayments";
public const string Unrestricted = "unrestricted";
public static IEnumerable<string> AllPolicies
@@ -78,6 +80,8 @@ namespace BTCPayServer.Client
yield return CanViewPullPayments;
yield return CanViewOfferings;
yield return CanModifyOfferings;
+ yield return CanManageSubscribers;
+ yield return CanCreditSubscribers;
yield return CanCreateNonApprovedPullPayments;
yield return CanManageUsers;
yield return CanManagePayouts;
@@ -274,7 +278,7 @@ namespace BTCPayServer.Client
PolicyHasChild(policyMap, Policies.CanCreateNonApprovedPullPayments, Policies.CanViewPullPayments);
PolicyHasChild(policyMap,Policies.CanModifyPaymentRequests, Policies.CanViewPaymentRequests);
PolicyHasChild(policyMap,Policies.CanModifyProfile, Policies.CanViewProfile);
- PolicyHasChild(policyMap,Policies.CanModifyOfferings, Policies.CanViewOfferings);
+ PolicyHasChild(policyMap,Policies.CanModifyOfferings, Policies.CanViewOfferings, Policies.CanManageSubscribers, Policies.CanCreditSubscribers);
PolicyHasChild(policyMap,Policies.CanUseLightningNodeInStore, Policies.CanViewLightningInvoiceInStore, Policies.CanCreateLightningInvoiceInStore);
PolicyHasChild(policyMap,Policies.CanManageNotificationsForUser, Policies.CanViewNotificationsForUser);
PolicyHasChild(policyMap,Policies.CanModifyServerSettings,
diff --git a/BTCPayServer.Data/CustomerSelector.cs b/BTCPayServer.Data/CustomerSelector.cs
index 9c3e7bf..ed60714 100644
--- a/BTCPayServer.Data/CustomerSelector.cs
+++ b/BTCPayServer.Data/CustomerSelector.cs
@@ -1,13 +1,58 @@
-namespace BTCPayServer.Data;
+#nullable enable
+using System;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+using BTCPayServer.Data.Subscriptions;
+
+namespace BTCPayServer.Data;
public abstract record CustomerSelector
{
+ public static readonly Regex ReferenceRegex = new(@"^\[(.*)\]$");
+ public static bool TryParse(string str, [MaybeNullWhen(false)] out CustomerSelector selector)
+ {
+ ArgumentNullException.ThrowIfNull(str);
+ selector = null;
+ if (str.StartsWith(CustomerData.IdPrefix + "_") && !str.Contains("@", StringComparison.OrdinalIgnoreCase))
+ {
+ selector = ById(str);
+ return true;
+ }
+ else if (ReferenceRegex.Match(str) is { Success: true } match)
+ {
+ selector = ByExternalRef(match.Groups[1].Value);
+ return true;
+ }
+ else if (str.Split(':', 2, StringSplitOptions.RemoveEmptyEntries) is { Length: 2 } arr)
+ {
+ selector = ByIdentity(arr[0], arr[1]);
+ return true;
+ }
+ else if (str.Contains('@', StringComparison.InvariantCulture))
+ {
+ selector = ByEmail(str);
+ return true;
+ }
+ return false;
+ }
+
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;
+ public record Id(string CustomerId) : CustomerSelector
+ {
+ public override string ToString() => CustomerId;
+ }
+
+ public record ExternalRef(string Ref) : CustomerSelector
+ {
+ public override string ToString() => $"[{Ref}]";
+ }
+ public record Identity(string Type, string Value) : CustomerSelector
+ {
+ public override string ToString() => $"{Type}:{Value}";
+ }
}
diff --git a/BTCPayServer.Data/Data/CustomerData.cs b/BTCPayServer.Data/Data/CustomerData.cs
index 250c076..39466a2 100644
--- a/BTCPayServer.Data/Data/CustomerData.cs
+++ b/BTCPayServer.Data/Data/CustomerData.cs
@@ -32,7 +32,8 @@ public class CustomerData : BaseEntityData
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 const string IdPrefix = "cust";
+ public new static string GenerateId() => ValueGenerators.WithPrefix(IdPrefix)(null, null).Next(null!) as string ?? throw new InvalidOperationException("Bug, shouldn't happen");
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
@@ -45,7 +46,7 @@ public class CustomerData : BaseEntityData
b.HasIndex(x => new { x.StoreId, x.ExternalRef }).IsUnique();
b.Property(x => x.Id)
.ValueGeneratedOnAdd()
- .HasValueGenerator(ValueGenerators.WithPrefix("cust"));
+ .HasValueGenerator(ValueGenerators.WithPrefix(IdPrefix));
}
public string? GetContact(string type)
diff --git a/BTCPayServer.Data/Data/InvoiceData.cs b/BTCPayServer.Data/Data/InvoiceData.cs
index e37ba69..c606a20 100644
--- a/BTCPayServer.Data/Data/InvoiceData.cs
+++ b/BTCPayServer.Data/Data/InvoiceData.cs
@@ -41,6 +41,7 @@ namespace BTCPayServer.Data
public const string Processing = nameof(Processing);
public const string Settled = nameof(Settled);
public const string Invalid = nameof(Invalid);
+ public const string Expired = nameof(Expired);
internal static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
diff --git a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
index 75d4ca1..fed49da 100644
--- a/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/ApplicationDbContextExtensions.Subscriptions.cs
@@ -92,12 +92,7 @@ public static partial class ApplicationDbContextExtensions
public static async Task<OfferingData?> GetOfferingData(this DbSet<OfferingData> offerings, string offeringId, string? storeId = null)
{
- var offering = offerings
- .Include(o => o.Features)
- .Include(o => o.Plans)
- .Include(o => o.App)
- .ThenInclude(o => o.StoreData)
- .AsSplitQuery();
+ var offering = offerings.IncludeAll();
var o = await offering
.Where(o => o.Id == offeringId)
@@ -107,6 +102,14 @@ public static partial class ApplicationDbContextExtensions
return o;
}
+ public static IQueryable<OfferingData> IncludeAll(this DbSet<OfferingData> offerings)
+ => offerings
+ .Include(o => o.Features)
+ .Include(o => o.Plans)
+ .Include(o => o.App)
+ .ThenInclude(o => o.StoreData)
+ .AsSplitQuery();
+
public static async Task<PlanCheckoutData?> GetCheckout(this DbSet<PlanCheckoutData> checkouts, string checkoutId)
{
var checkout = await checkouts
@@ -238,10 +241,10 @@ public static partial class ApplicationDbContextExtensions
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)
+ public static async Task<SubscriberData?> GetBySelector(this DbSet<SubscriberData> subscribers, string offeringId, CustomerSelector selector, string? storeId = null)
{
var ctx = (ApplicationDbContext)subscribers.GetDbContext();
- var storeId = await ctx.Offerings
+ storeId ??= await ctx.Offerings
.Where(o => o.Id == offeringId)
.Select(o => o.App.StoreDataId)
.FirstOrDefaultAsync();
@@ -253,7 +256,7 @@ public static partial class ApplicationDbContextExtensions
: (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();
+ return await subscribers.IncludeAll().Where(s => s.OfferingId == offeringId && s.CustomerId == customerId).FirstOrDefaultAsync();
}
public static Task<CustomerData?> GetBySelector(this IQueryable<CustomerData> customers, string storeId, CustomerSelector selector)
diff --git a/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs b/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
index f42386e..d34a3c0 100644
--- a/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/PlanCheckoutData.cs
@@ -52,6 +52,9 @@ public class PlanCheckoutData : BaseEntityData
[Column("new_subscriber")]
public bool NewSubscriber { get; set; }
+ [Column("new_subscriber_email")]
+ public string? NewSubscriberEmail { get; set; }
+
/// <summary>
/// Internal ID of the subscriber, do not expose outside, only use for querying.
/// </summary>
@@ -71,7 +74,7 @@ public class PlanCheckoutData : BaseEntityData
public bool TestAccount { get; set; }
[Column("credited")]
- public decimal Credited { get; set; } = 0m;
+ public decimal CreditedByInvoice { get; set; } = 0m;
[Column("plan_started")]
public bool PlanStarted { get; set; }
@@ -90,10 +93,13 @@ public class PlanCheckoutData : BaseEntityData
[Column("expiration")]
public DateTimeOffset Expiration { get; set; }
+ [Column("credit_purchase")]
+ public decimal? CreditPurchase { get; set; }
+
public enum OnPayBehavior
{
/// <summary>
- /// Starts the plan if payment is due, else, do not and add the funds to the credit.
+ /// Starts the plan if the phase is expired or grace, else, add to credit.
/// </summary>
SoftMigration,
/// <summary>
@@ -142,4 +148,11 @@ public class PlanCheckoutData : BaseEntityData
[NotMapped]
public bool IsExpired => DateTimeOffset.UtcNow > Expiration;
+
+ public string? GetEmail()
+ {
+ if (NewSubscriber)
+ return NewSubscriberEmail;
+ return Subscriber?.Customer.Email.Get();
+ }
}
diff --git a/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
index 011d4f9..8f5f0f5 100644
--- a/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/PortalSessionData.cs
@@ -31,6 +31,9 @@ public class PortalSessionData
[Column("base_url", TypeName = "text")]
public RequestBaseUrl BaseUrl { get; set; }
+ [NotMapped]
+ public bool IsExpired => DateTimeOffset.UtcNow > Expiration;
+
public static void OnModelCreating(ModelBuilder builder, DatabaseFacade databaseFacade)
{
var b = builder.Entity<PortalSessionData>();
diff --git a/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
index 43a9d8c..ce27738 100644
--- a/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
+++ b/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
@@ -51,7 +51,7 @@ public class SubscriberData : BaseEntityData
public decimal? PaidAmount { get; set; }
public decimal GetCredit(string? currency = null)
- => Credits.FirstOrDefault(c => (currency ?? c.Currency) == Plan.Currency)?.Amount ?? 0m;
+ => Credits.FirstOrDefault(c => (currency ?? c.Currency).Equals(Plan.Currency, StringComparison.OrdinalIgnoreCase))?.Amount ?? 0m;
public decimal MissingCredit()
=> Math.Max(0m, NextPlan.Price - GetCredit(NextPlan.Currency));
diff --git a/BTCPayServer.Data/Migrations/20251028061727_subs.cs b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
index ec1c63d..2693fc4 100644
--- a/BTCPayServer.Data/Migrations/20251028061727_subs.cs
+++ b/BTCPayServer.Data/Migrations/20251028061727_subs.cs
@@ -257,12 +257,14 @@ namespace BTCPayServer.Migrations
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),
+ new_subscriber_email = table.Column<string>(type: "text", nullable: true),
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),
+ credit_purchase = table.Column<decimal>(type: "numeric", nullable: true),
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),
diff --git a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
index b50665c..092217b 100644
--- a/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
+++ b/BTCPayServer.Data/Migrations/ApplicationDbContextModelSnapshot.cs
@@ -1162,7 +1162,11 @@ namespace BTCPayServer.Migrations
.HasColumnName("created_at")
.HasDefaultValueSql("now()");
- b.Property<decimal>("Credited")
+ b.Property<decimal?>("CreditPurchase")
+ .HasColumnType("numeric")
+ .HasColumnName("credit_purchase");
+
+ b.Property<decimal>("CreditedByInvoice")
.HasColumnType("numeric")
.HasColumnName("credited");
@@ -1200,6 +1204,10 @@ namespace BTCPayServer.Migrations
.HasColumnType("boolean")
.HasColumnName("new_subscriber");
+ b.Property<string>("NewSubscriberEmail")
+ .HasColumnType("text")
+ .HasColumnName("new_subscriber_email");
+
b.Property<string>("NewSubscriberMetadata")
.IsRequired()
.ValueGeneratedOnAdd()
diff --git a/BTCPayServer.Tests/SubscriptionTests.cs b/BTCPayServer.Tests/SubscriptionTests.cs
index f97bd17..6c0ef6e 100644
--- a/BTCPayServer.Tests/SubscriptionTests.cs
+++ b/BTCPayServer.Tests/SubscriptionTests.cs
@@ -3,15 +3,21 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
+using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
+using BTCPayServer.Abstractions;
using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
using BTCPayServer.Data.Subscriptions;
using BTCPayServer.Events;
using BTCPayServer.HostedServices;
-using BTCPayServer.Plugins;
+using BTCPayServer.Plugins.Subscriptions;
using BTCPayServer.Tests.PMO;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Routing;
using Microsoft.Playwright;
using NBitcoin;
using NBXplorer;
@@ -25,7 +31,6 @@ namespace BTCPayServer.Tests;
[Collection(nameof(NonParallelizableCollectionDefinition))]
public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBase(testOutputHelper)
{
-
[Fact]
[Trait("Playwright", "Playwright")]
public async Task CanChangeOfferingEmailsSettings()
@@ -289,8 +294,228 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
return DateTime.DaysInMonth(DateTimeOffset.UtcNow.Year, DateTimeOffset.UtcNow.Month);
}
- private string USD(decimal val)
- => $"${val.ToString("F2", CultureInfo.InvariantCulture)}";
+
+ [Fact]
+ [Trait("Integration", "Integration")]
+ public async Task CanUseSubscriptionAPI()
+ {
+ using var s = CreateServerTester();
+ await s.StartAsync();
+ var user = s.NewAccount();
+ await user.RegisterAsync(true);
+ await user.CreateStoreAsync();
+
+ var client = await user.CreateClient();
+ var offering = await client.CreateOffering(user.StoreId, new OfferingModel()
+ {
+ AppName = "Test",
+ SuccessRedirectUrl = "https://google.com",
+ Features = new()
+ {
+ new() { Id = "can-access", Description = "Can access the subscription API" }
+ }
+ });
+ Assert.Equal("Test", offering.AppName);
+ Assert.Equal("https://google.com", offering.SuccessRedirectUrl);
+ Assert.Single(offering.Features);
+ Assert.Equal("can-access", offering.Features[0].Id);
+
+ var plan = await client.CreateOfferingPlan(user.StoreId, offering.Id, new()
+ {
+ Name = "NewPlan",
+ Price = 10m
+ });
+ Assert.Equal(("NewPlan", 10m, "USD"), (plan.Name, plan.Price, plan.Currency));
+ plan = await client.GetOfferingPlan(user.StoreId, offering.Id, plan.Id);
+ Assert.Equal(("NewPlan", 10m, "USD"), (plan.Name, plan.Price, plan.Currency));
+
+ offering = await client.GetOffering(offering.StoreId, offering.Id);
+ var offering2 = (await client.GetOfferings(offering.StoreId))[0];
+ Assert.Equal(offering.Id, offering2.Id);
+ Assert.Equal(offering.AppName, offering2.AppName);
+ Assert.Equal(offering.SuccessRedirectUrl, offering2.SuccessRedirectUrl);
+ Assert.Single(offering2.Features);
+ Assert.Equal("can-access", offering2.Features[0].Id);
+ Assert.Equal("can-access", offering.Features[0].Id);
+
+ var planCheckout = await client.CreatePlanCheckout(new CreatePlanCheckoutRequest()
+ {
+ StoreId = user.StoreId,
+ OfferingId = offering.Id,
+ NewSubscriberEmail = "test@gmail.com",
+ NewSubscriberMetadata = new JObject() { ["sub"] = "test" },
+ InvoiceMetadata = new JObject() { ["inv"] = "invtest" },
+ Metadata = new JObject() { ["checkout"] = "metatest" },
+ PlanId = plan.Id,
+ });
+
+ var planCheckout2 = await client.GetPlanCheckout(planCheckout.Id);
+ Assert.Equal(planCheckout.Id, planCheckout2.Id);
+ Assert.Equal("metatest", planCheckout2.Metadata["checkout"]?.ToString());
+ Assert.Null(planCheckout.InvoiceId);
+
+ await CanAccessUrl(planCheckout.Url);
+
+ await AssertEx.AssertApiError(400, "invoice-creation-error", () => client.ProceedPlanCheckout(planCheckout.Id));
+ await user.RegisterDerivationSchemeAsync("BTC", importKeysToNBX: true);
+ planCheckout = await client.ProceedPlanCheckout(planCheckout.Id);
+ var invoice = await client.GetInvoice(user.StoreId, planCheckout.InvoiceId);
+ Assert.NotNull(invoice);
+ Assert.Equal("test@gmail.com", invoice.Metadata["buyerEmail"]?.ToString());
+ Assert.Equal("invtest", invoice.Metadata["inv"]?.ToString());
+
+ planCheckout = await client.GetPlanCheckout(planCheckout.Id);
+ Assert.Equal(planCheckout.InvoiceId, invoice.Id);
+
+ var oldInvoiceId = invoice.Id;
+ planCheckout = await client.ProceedPlanCheckout(planCheckout.Id);
+ Assert.Equal(oldInvoiceId, planCheckout.InvoiceId);
+ invoice = await client.GetInvoice(user.StoreId, planCheckout.InvoiceId);
+ Assert.Null(planCheckout.Subscriber);
+
+ await s.ExplorerNode.GenerateAsync(1);
+ await user.ReceiveUTXO(Money.Coins(1.0m));
+ await s.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () =>
+ {
+ await user.PayOnChain(invoice.Id);
+ await s.ExplorerNode.GenerateAsync(1);
+ });
+
+ planCheckout = await client.GetPlanCheckout(planCheckout.Id);
+ Assert.NotNull(planCheckout.Subscriber);
+ Assert.Equal("test", planCheckout.Subscriber.Metadata["sub"]?.ToString());
+
+ var subscriber = await client.GetSubscriber(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id);
+ Assert.Equal(planCheckout.Subscriber.Customer.Id, subscriber.Customer.Id);
+ subscriber = await client.GetSubscriber(user.StoreId, offering.Id, "test@gmail.com");
+ Assert.Equal(planCheckout.Subscriber.Customer.Id, subscriber.Customer.Id);
+ subscriber = await client.GetSubscriber(user.StoreId, offering.Id, "Email:test@gmail.com");
+ Assert.Equal(planCheckout.Subscriber.Customer.Id, subscriber.Customer.Id);
+ Assert.False(planCheckout.IsExpired);
+
+ Assert.True(subscriber.IsActive);
+
+ subscriber = await client.SuspendSubscriber(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "booh");
+ Assert.False(subscriber.IsActive);
+ Assert.True(subscriber.IsSuspended);
+ Assert.Equal("booh", subscriber.SuspensionReason);
+
+ subscriber = await client.UnsuspendSubscriber(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id);
+ Assert.True(subscriber.IsActive);
+ Assert.False(subscriber.IsSuspended);
+ Assert.Null(subscriber.SuspensionReason);
+
+ var session = await client.CreatePortalSession(new()
+ {
+ StoreId = user.StoreId,
+ OfferingId = offering.Id,
+ CustomerSelector = "test@gmail.com",
+ DurationMinutes = TimeSpan.FromMinutes(3.0)
+ });
+
+ session = await client.GetPortalSession(session.Id);
+ Assert.True(session.Expiration < DateTimeOffset.UtcNow + TimeSpan.FromMinutes(5.0));
+ Assert.True(session.Expiration > DateTimeOffset.UtcNow + TimeSpan.FromMinutes(2.0));
+ Assert.NotNull(session.Subscriber);
+ Assert.False(session.IsExpired);
+
+ await CanAccessUrl(session.Url);
+
+ var result = await client.GetCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "USD");
+ Assert.Equal(0m, result.Value);
+
+ await AssertEx.AssertApiError(400, "overdraft", () => client.UpdateCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "USD",
+ new()
+ {
+ Description = "Hello",
+ Charge = 5m,
+ AllowOverdraft = false
+ }));
+ await client.UpdateCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "USD",
+ new()
+ {
+ Description = "Hello",
+ Charge = 5m,
+ AllowOverdraft = true
+ });
+ result = await client.GetCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "current");
+ Assert.Equal(-5m, result.Value);
+ await client.UpdateCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "current",
+ new()
+ {
+ Description = "Hello",
+ Credit = 2m,
+ AllowOverdraft = false
+ });
+ await client.UpdateCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "current",
+ new()
+ {
+ Description = "Hello",
+ Credit = 30m,
+ AllowOverdraft = false
+ });
+ result = await client.GetCredit(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id, "current");
+ Assert.Equal(-5m + 2m + 30m, result.Value);
+
+ await MoveToExpiration(s, offering);
+
+ subscriber = await client.GetSubscriber(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id);
+ Assert.False(subscriber.IsActive);
+
+ planCheckout = await client.CreatePlanCheckout(new()
+ {
+ StoreId = user.StoreId,
+ PlanId = plan.Id,
+ OfferingId = offering.Id,
+ CustomerSelector = "test@gmail.com"
+ });
+ await client.ProceedPlanCheckout(planCheckout.Id);
+ subscriber = await client.GetSubscriber(user.StoreId, offering.Id, planCheckout.Subscriber.Customer.Id);
+ Assert.True(subscriber.IsActive);
+ }
+
+ private static async Task MoveToExpiration(ServerTester s, OfferingModel offering)
+ {
+ var dbFactory = s.PayTester.GetService<ApplicationDbContextFactory>();
+ await using var ctx = dbFactory.CreateContext();
+ var sub = await ctx.Subscribers.GetBySelector(offering.Id, CustomerSelector.ByEmail("test@gmail.com"));
+ var subsService = s.PayTester.GetService<SubscriptionHostedService>();
+ await subsService.MoveTime(sub!.Id, SubscriberData.PhaseTypes.Expired);
+ }
+
+ private static async Task CanAccessUrl(string url)
+ {
+ using (var http = new HttpClient())
+ {
+ using var resp = await http.GetAsync(url);
+ resp.EnsureSuccessStatusCode();
+ }
+ }
+
+ [Fact]
+ [Trait("Fast", "Fast")]
+ public void CanParseSelectors()
+ {
+ CanParseSelectorCore("[test]", typeof(CustomerSelector.ExternalRef));
+ CanParseSelectorCore("[test]]", typeof(CustomerSelector.ExternalRef));
+ CanParseSelectorCore("cust_test", typeof(CustomerSelector.Id));
+ CanParseSelectorCore("Email:test@ggwg.com", typeof(CustomerSelector.Identity));
+
+ Assert.True(CustomerSelector.TryParse("test@ggwg.com", out var selector));
+ Assert.IsType<CustomerSelector.Identity>(selector);
+ Assert.Equal("Email:test@ggwg.com", selector.ToString());
+
+ Assert.True(CustomerSelector.TryParse("cust_test@ggwg.com", out selector));
+ Assert.IsType<CustomerSelector.Identity>(selector);
+ Assert.Equal("Email:cust_test@ggwg.com", selector.ToString());
+ }
+
+ private void CanParseSelectorCore(string str, Type expectedType)
+ {
+ Assert.True(CustomerSelector.TryParse(str, out var selector));
+ Assert.Equal(expectedType, selector.GetType());
+ Assert.Equal(str, selector.ToString());
+ }
[Fact]
[Trait("Playwright", "Playwright")]
@@ -367,7 +592,8 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
await edit.Save();
// basic2@example.com is a basic plan subscriber (optimistic activation), so he is immediately activated
- await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () => {
+ await s.Server.WaitForEvent<SubscriptionEvent.NewSubscriber>(async () =>
+ {
await offering.NewSubscriber("Basic Plan", "basic2@example.com", false);
});
@@ -554,8 +780,10 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
public Task GoToSubscribers()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Subscribers" }).ClickAsync();
+
public Task GoToPlans()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Plans" }).ClickAsync();
+
public Task GoToMails()
=> s.Page.GetByRole(AriaRole.Link, new() { Name = "Mails" }).ClickAsync();
@@ -679,6 +907,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
{
await s.Page.FillAsync("input[name='PaymentRemindersDays']", settings.PaymentRemindersDays.Value.ToString());
}
+
await s.ClickPagePrimary();
await s.FindAlertMessage();
}
@@ -802,6 +1031,7 @@ public class SubscriptionTests(ITestOutputHelper testOutputHelper) : UnitTestBas
{
Assert.Fail($"Expected {refunded} USD, but got {v} USD");
}
+
return v;
}
diff --git a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
index 9d95da3..29177f1 100644
--- a/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
+++ b/BTCPayServer/Controllers/GreenField/GreenfieldInvoiceController.cs
@@ -639,7 +639,8 @@ namespace BTCPayServer.Controllers.Greenfield
};
}
- private InvoiceData ToModel(InvoiceEntity entity)
+ [NonAction]
+ public InvoiceData ToModel(InvoiceEntity entity)
{
return ToModel(entity, _linkGenerator, _currencyNameTable, Request);
}
diff --git a/BTCPayServer/Controllers/UIManageController.APIKeys.cs b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
index 46b71f5..42d4908 100644
--- a/BTCPayServer/Controllers/UIManageController.APIKeys.cs
+++ b/BTCPayServer/Controllers/UIManageController.APIKeys.cs
@@ -542,6 +542,10 @@ namespace BTCPayServer.Controllers
{$"{Policies.CanViewOfferings}:", ("View your offerings", "Allows viewing offerings on the selected stores.")},
{Policies.CanModifyOfferings, ("Modify your offerings", "Allows modifying offerings on all your stores.")},
{$"{Policies.CanModifyOfferings}:", ("Modify your offerings", "Allows modifying offerings on the selected stores.")},
+ {Policies.CanManageSubscribers, ("Manage your subscribers", "Allows managing subscribers on all your stores.")},
+ {$"{Policies.CanManageSubscribers}:", ("Manage your subscribers", "Allows managing subscribers on the selected stores.")},
+ {Policies.CanCreditSubscribers, ("Credit your subscribers", "Allows crediting subscribers on all your stores.")},
+ {$"{Policies.CanCreditSubscribers}:", ("Credit your subscribers", "Allows crediting subscribers on the selected stores.")},
{Policies.CanManagePullPayments, ("Manage your pull payments", "Allows viewing, modifying, deleting and creating pull payments on all your stores.")},
{$"{Policies.CanManagePullPayments}:", ("Manage selected stores' pull payments", "Allows viewing, modifying, deleting and creating pull payments on the selected stores.")},
{Policies.CanArchivePullPayments, ("Archive your pull payments", "Allows deleting pull payments on all your stores.")},
diff --git a/BTCPayServer/ModelBinders/CustomerSelectorModelBinder.cs b/BTCPayServer/ModelBinders/CustomerSelectorModelBinder.cs
new file mode 100644
index 0000000..346fc65
--- /dev/null
+++ b/BTCPayServer/ModelBinders/CustomerSelectorModelBinder.cs
@@ -0,0 +1,30 @@
+using System.Reflection;
+using System.Threading.Tasks;
+using BTCPayServer.Data;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+
+namespace BTCPayServer.ModelBinders;
+
+public class CustomerSelectorModelBinder : IModelBinder
+{
+ public const string InvalidFormat = "Invalid customer selector. Expected formats are `cust_customerId`, `[externalRef]` or `cust@email.com`";
+ public Task BindModelAsync(ModelBindingContext bindingContext)
+ {
+ if (!typeof(CustomerSelector).GetTypeInfo().IsAssignableFrom(bindingContext.ModelType))
+ {
+ return Task.CompletedTask;
+ }
+ var val = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
+ var v = val.FirstValue;
+ if (v is null)
+ return Task.CompletedTask;
+ if (CustomerSelector.TryParse(v, out var res))
+ bindingContext.Result = ModelBindingResult.Success(res);
+ else
+ {
+ bindingContext.Result = ModelBindingResult.Failed();
+ bindingContext.ModelState.AddModelError(bindingContext.ModelName, InvalidFormat);
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs
new file mode 100644
index 0000000..e32a0e7
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/GreenfieldOfferingController.cs
@@ -0,0 +1,401 @@
+#nullable enable
+using System;
+using System.Linq;
+using System.Threading.Tasks;
+using BTCPayServer.Abstractions.Constants;
+using BTCPayServer.Abstractions.Extensions;
+using BTCPayServer.Client;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data;
+using BTCPayServer.Data.Subscriptions;
+using BTCPayServer.ModelBinders;
+using BTCPayServer.Security;
+using BTCPayServer.Services.Apps;
+using BTCPayServer.Services.Rates;
+using BTCPayServer.Views.UIStoreMembership;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace BTCPayServer.Plugins.Subscriptions.Controllers
+{
+ [ApiController]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield,
+ Policy = Policies.CanViewOfferings)]
+ [EnableCors(CorsPolicies.All)]
+ public class GreenfieldOfferingController(
+ ApplicationDbContext ctx,
+ IAuthorizationService authorizationService,
+ SubscriptionHostedService subscriptionHostedService,
+ CurrencyNameTable currencyNameTable,
+ AppService appService) : ControllerBase
+ {
+ [HttpGet("~/api/v1/stores/{storeId}/offerings/{offeringId?}")]
+ public async Task<IActionResult> GetOffering(string storeId, string? offeringId = null)
+ {
+ OfferingData[] offerings;
+ if (offeringId is null)
+ offerings = await ctx.Offerings.IncludeAll().Where(o => o.App.StoreDataId == storeId).ToArrayAsync();
+ else
+ {
+ var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
+ if (offering is null)
+ return OfferingNotFound();
+ offerings = new[] { offering };
+ }
+ await ctx.Plans.FetchPlanFeaturesAsync(offerings.SelectMany(p => p.Plans).ToArray());
+ if (offeringId is not null)
+ return Ok(Mapper.MapOffering(offerings[0]));
+ return Ok(offerings.Select(Mapper.MapOffering).ToArray());
+ }
+ [HttpPost("~/api/v1/stores/{storeId}/offerings")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanModifyOfferings)]
+ public async Task<IActionResult> CreateOffering(string storeId, [FromBody] OfferingModel request)
+ {
+ if (request?.AppName is null)
+ ModelState.AddModelError(nameof(request.AppName), "AppName is required");
+ if (!ModelState.IsValid || request?.AppName is null)
+ return this.CreateValidationError(ModelState);
+ var o = await appService.CreateOffering(storeId, request.AppName);
+ var offering = await ctx.Offerings.GetOfferingData(o.OfferingId, storeId);
+ if (offering is not null)
+ {
+ offering.SuccessRedirectUrl = request.SuccessRedirectUrl;
+ offering.Metadata = request.Metadata?.ToString() ?? "{}";
+ if (request.Features is not null)
+ {
+ UIOfferingController.UpdateFeatures(ctx, offering, new()
+ {
+ Features = request.Features.Select(f => new ConfigureOfferingViewModel.FeatureViewModel()
+ { Id = f.Id, ShortDescription = f.Description }).ToList()
+ });
+ }
+ }
+ await ctx.SaveChangesAsync();
+ ctx.ChangeTracker.Clear();
+ return await GetOffering(storeId, offering?.Id ?? "");
+ }
+
+ [HttpPost("~/api/v1/stores/{storeId}/offerings/{offeringId}/plans")]
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanModifyOfferings)]
+ public async Task<IActionResult> CreateOfferingPlan(string storeId, string offeringId, [FromBody] CreatePlanRequest request)
+ {
+ var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
+ var store = await ctx.Stores.FindAsync(storeId);
+ if (offering is null || store is null)
+ return OfferingNotFound();
+ if (request.Price < 0m)
+ ModelState.AddModelError(nameof(request.Price), "Price cannot be negative");
+ if (request?.Name is null)
+ ModelState.AddModelError(nameof(request.Name), "Name is required");
+ if (!ModelState.IsValid || request?.Name is null)
+ return this.CreateValidationError(ModelState);
+
+ var data = new PlanData()
+ {
+ OfferingId = offeringId,
+ Description = request.Description ?? "",
+ Currency = store.GetStoreBlob().DefaultCurrency ?? request.Currency,
+ GracePeriodDays = request.GracePeriodDays ?? 0,
+ TrialDays = request.TrialDays ?? 0,
+ Name = request.Name,
+ Price = request.Price ?? 0m,
+ Metadata = request.Metadata?.ToString() ?? "{}",
+ RecurringType = Mapper.Map(request.RecurringType ?? OfferingPlanModel.RecurringInterval.Monthly),
+ };
+ if (request.OptimisticActivation is {} o)
+ data.OptimisticActivation = o;
+ if (request.Renewable is {} r)
+ data.Renewable = r;
+ ctx.Plans.Add(data);
+ await ctx.SaveChangesAsync();
+ ctx.ChangeTracker.Clear();
+ return await GetOfferingPlan(storeId, offeringId, data.Id);
+ }
+
+ [HttpGet("~/api/v1/stores/{storeId}/offerings/{offeringId}/plans/{planId}")]
+ public async Task<IActionResult> GetOfferingPlan(string storeId, string offeringId, string planId)
+ {
+ var offering = await ctx.Offerings.GetOfferingData(offeringId, storeId);
+ if (offering is null)
+ return OfferingNotFound();
+ var plan = offering.Plans.FirstOrDefault(p => p.Id == planId);
+ if (plan is null)
+ return PlanNotFound();
+ await plan.EnsureFeatureLoaded(ctx);
+ return Ok(Mapper.MapPlan(plan));
+ }
+
+ [HttpGet("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}")]
+ public async Task<IActionResult> GetSubscriber(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ await subscriber.Plan.EnsureFeatureLoaded(ctx);
+ return Ok(Mapper.MapToSubscriberModel(subscriber));
+ }
+
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanManageSubscribers)]
+ [HttpGet("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/credits/{currency}")]
+ public async Task<IActionResult> GetCredit(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector,
+ string? currency)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ currency = currency == "current" ? subscriber.Plan.Currency : currency;
+ return Ok(new CreditModel()
+ {
+ Currency = currency,
+ Value = subscriber.GetCredit(currency)
+ });
+ }
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanCreditSubscribers)]
+ [HttpPost("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/credits/{currency}")]
+ public async Task<IActionResult> UpdateCredit(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector,
+ string? currency,
+ [FromBody] UpdateCreditRequest request)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ currency = currency == "current" ? subscriber.Plan.Currency : currency;
+ var newTotal = await subscriptionHostedService.UpdateCredit(new()
+ {
+ SubscriberId = subscriber.Id,
+ Currency = currency,
+ Description = request.Description,
+ Credit = request.Credit,
+ Charge = request.Charge,
+ AllowOverdraft = request.AllowOverdraft
+ });
+ if (newTotal is null)
+ return this.CreateAPIError(400, "overdraft", "The subscriber's balance would be overdrawn. Use allowOverdraft to allow this.");
+ ctx.ChangeTracker.Clear();
+ return await GetCredit(storeId, offeringId, customerSelector, currency);
+ }
+
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanManageSubscribers)]
+ [HttpPost("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/suspend")]
+ public async Task<IActionResult> SuspendSubscriber(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector,
+ [FromBody] SuspendSubscriberRequest model)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ await subscriptionHostedService.Suspend(subscriber.Id, model?.Reason);
+ ctx.ChangeTracker.Clear();
+ return await GetSubscriber(storeId, offeringId, customerSelector);
+ }
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanManageSubscribers)]
+ [HttpPost("~/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/unsuspend")]
+ public async Task<IActionResult> UnsuspendSubscriber(string storeId, string offeringId,
+ [ModelBinder<CustomerSelectorModelBinder>]
+ CustomerSelector customerSelector)
+ {
+ var subscriber = await ctx.Subscribers.GetBySelector(offeringId, customerSelector, storeId);
+ if (subscriber is null)
+ return SubscriberNotFound();
+ await subscriptionHostedService.Unsuspend(subscriber.Id);
+ ctx.ChangeTracker.Clear();
+ return await GetSubscriber(storeId, offeringId, customerSelector);
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/api/v1/plan-checkout/{checkoutId}")]
+ public async Task<IActionResult> ProceedPlanCheckout(string checkoutId, [FromQuery] string? email = null)
+ {
+ var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
+ if (checkout is null)
+ return CheckoutNotFound();
+ if (checkout.IsExpired)
+ return CheckoutExpired();
+ if (email is not null && !email.IsValidEmail())
+ ModelState.AddModelError(nameof(email), "Invalid email");
+ if (!ModelState.IsValid)
+ return this.CreateValidationError(ModelState);
+
+ if (checkout is { Invoice: { Status: not (Data.InvoiceData.Expired or Data.InvoiceData.Invalid) } })
+ return await GetPlanCheckout(checkoutId);
+
+ if (checkout is { NewSubscriber: true, NewSubscriberEmail: null } && email is {})
+ {
+ checkout.NewSubscriberEmail = email;
+ await ctx.SaveChangesAsync();
+ }
+
+ if (checkout is { NewSubscriber: true, NewSubscriberEmail: null })
+ ModelState.AddModelError(nameof(email), "You need to pass `email` as query string");
+
+ if (!ModelState.IsValid)
+ return this.CreateValidationError(ModelState);
+
+ try
+ {
+ await subscriptionHostedService.ProceedToSubscribe(checkout.Id, HttpContext.RequestAborted);
+ }
+ catch (BitpayHttpException ex)
+ {
+ return this.CreateAPIError(400, "invoice-creation-error", ex.Message);
+ }
+ ctx.ChangeTracker.Clear();
+ return await GetPlanCheckout(checkoutId);
+ }
+
+ [AllowAnonymous]
+ [HttpGet("~/api/v1/plan-checkout/{checkoutId}")]
+ public async Task<IActionResult> GetPlanCheckout(string checkoutId)
+ {
+ var checkout = await ctx.PlanCheckouts.GetCheckout(checkoutId);
+ if (checkout is null)
+ return CheckoutNotFound();
+ await ctx.Plans.FetchPlanFeaturesAsync(checkout.Plan);
+ return Ok(Mapper.MapPlanCheckout(checkout));
+ }
+
+ [Authorize(AuthenticationSchemes = AuthenticationSchemes.Greenfield, Policy = Policies.CanManageSubscribers)]
+ [HttpPost("~/api/v1/plan-checkout")]
+ public async Task<IActionResult> CreatePlanCheckout([FromBody]CreatePlanCheckoutRequest model)
+ {
+ var selector = GetSelector(nameof(model.CustomerSelector), model.CustomerSelector, false);
+ if (model.StoreId is null)
+ ModelState.AddModelError(nameof(model.StoreId), "StoreId is required");
+ if (model.OfferingId is null)
+ ModelState.AddModelError(nameof(model.OfferingId), "OfferingId is required");
+ if (model.PlanId is null)
+ ModelState.AddModelError(nameof(model.PlanId), "PlanId is required");
+ if (model.NewSubscriberEmail is not null && model.CustomerSelector is not null)
+ ModelState.AddModelError(nameof(model.NewSubscriberEmail), "If customerSelector is specified, newSubscriberEmail cannot be specified");
+ if (!await CanManageSubscribers(model.StoreId))
+ return this.CreateAPIPermissionError(Policies.CanManageSubscribers);
+ var plan = await ctx.Plans.GetPlanFromId(model.PlanId ?? "", model.OfferingId ?? "", model.StoreId ?? "");
+ if (plan is null)
+ return PlanNotFound();
+
+ if (model.CreditPurchase is not null)
+ model.CreditPurchase = RoundAmount(model.CreditPurchase.Value, plan.Currency);
+ if (model.CreditPurchase is <= 0.0m)
+ ModelState.AddModelError(nameof(model.CreditPurchase), "CreditPurchase must be greater than 0 or left null");
+ if (!ModelState.IsValid || model.PlanId is null || model.OfferingId is null)
+ return this.CreateValidationError(ModelState);
+
+ var data = new PlanCheckoutData()
+ {
+ NewSubscriber = selector is null,
+ NewSubscriberEmail = model.NewSubscriberEmail,
+ NewSubscriberMetadata = model.NewSubscriberMetadata?.ToString() ?? "{}",
+ InvoiceMetadata = model.InvoiceMetadata?.ToString() ?? "{}",
+ IsTrial = plan.TrialDays > 0 && model.IsTrial is true,
+ PlanId = plan.Id,
+ CreditPurchase = model.CreditPurchase,
+ SuccessRedirectUrl = Uri.IsWellFormedUriString(model.SuccessRedirectLink, UriKind.Absolute) ? model.SuccessRedirectLink : null,
+ Metadata = model.Metadata?.ToString() ?? "{}",
+ BaseUrl = Request.GetRequestBaseUrl()
+ };
+ if (model.OnPayBehavior is { } b)
+ data.OnPay = Mapper.Map(b);
+
+ if (selector is not null)
+ {
+ var sub = await ctx.Subscribers.GetBySelector(model.OfferingId, selector, model.StoreId);
+ if (sub is null)
+ return SubscriberNotFound();
+ data.SubscriberId = sub.Id;
+ }
+
+ if (model.DurationMinutes is { Ticks: > 0 } d)
+ data.Expiration = DateTimeOffset.UtcNow + d;
+ ctx.PlanCheckouts.Add(data);
+ await ctx.SaveChangesAsync();
+ ctx.ChangeTracker.Clear();
+ return await GetPlanCheckout(data.Id);
+ }
+
+ decimal RoundAmount(decimal amount, string currency)
+ => Math.Round(amount, currencyNameTable.GetNumberFormatInfo(currency)?.CurrencyDecimalDigits ?? 2);
+
+ private async Task<bool> CanManageSubscribers(string? storeId)
+ => (await authorizationService.AuthorizeAsync(User, storeId ?? "???", new PolicyRequirement(Policies.CanManageSubscribers))).Succeeded;
+
+ [AllowAnonymous]
+ [HttpGet("~/api/v1/subscriber-portal/{portalSessionId}")]
+ public async Task<IActionResult> GetPortalSession(string portalSessionId)
+ {
+ var session = await ctx.PortalSessions.GetById(portalSessionId);
+ if (session is null)
+ return PortalSessionNotFound();
+ await ctx.Plans.FetchPlanFeaturesAsync(session.Subscriber.Plan);
+ return Ok(Mapper.MapPortalSession(session));
+ }
+
+ [AllowAnonymous]
+ [HttpPost("~/api/v1/subscriber-portal")]
+ public async Task<IActionResult> CreatePortalSession([FromBody]CreatePortalSessionRequest model)
+ {
+ if (model.StoreId is null)
+ ModelState.AddModelError(nameof(model.StoreId), "StoreId is required");
+ if (model.OfferingId is null)
+ ModelState.AddModelError(nameof(model.OfferingId), "OfferingId is required");
+ var selector = GetSelector(nameof(model.CustomerSelector), model.CustomerSelector, false);
+ if (selector is null || model.OfferingId is null || !ModelState.IsValid || model.StoreId is null)
+ return this.CreateValidationError(ModelState);
+ if (!await CanManageSubscribers(model.StoreId))
+ return this.CreateAPIPermissionError(Policies.CanManageSubscribers);
+ var sub = await ctx.Subscribers.GetBySelector(model.OfferingId, selector, model.StoreId);
+ if (sub is null)
+ return SubscriberNotFound();
+
+ var data = new PortalSessionData()
+ {
+ SubscriberId = sub.Id,
+ BaseUrl = Request.GetRequestBaseUrl()
+ };
+ if (model.DurationMinutes is { Ticks: > 0 } d)
+ data.Expiration = DateTimeOffset.UtcNow + d;
+ ctx.PortalSessions.Add(data);
+ await ctx.SaveChangesAsync();
+ ctx.ChangeTracker.Clear();
+ return await GetPortalSession(data.Id);
+ }
+
+ private CustomerSelector? GetSelector(string fieldName, string? selectorString, bool required = true)
+ {
+ if (string.IsNullOrEmpty(selectorString))
+ {
+ if (required)
+ ModelState.AddModelError(fieldName, "CustomerSelector is required");
+ return null;
+ }
+ if (CustomerSelector.TryParse(selectorString, out var selector))
+ return selector;
+ ModelState.AddModelError(fieldName, CustomerSelectorModelBinder.InvalidFormat);
+ return null;
+ }
+
+ private IActionResult PortalSessionNotFound()
+ => this.CreateAPIError(404, "portal-session-not-found", "The portal session was not found");
+
+ private IActionResult CheckoutNotFound()
+ => this.CreateAPIError(404, "checkout-plan-not-found", "The checkout plan was not found");
+ private IActionResult CheckoutExpired()
+ => this.CreateAPIError(404, "checkout-plan-expired", "The checkout plan is expired");
+
+ private IActionResult SubscriberNotFound()
+ => this.CreateAPIError(404, "subscriber-not-found", "The subscriber was not found");
+
+ private IActionResult OfferingNotFound()
+ => this.CreateAPIError(404, "offering-not-found", "The offering was not found");
+ private IActionResult PlanNotFound()
+ => this.CreateAPIError(404, "plan-not-found", "The plan was not found");
+ }
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
index 974d427..c201326 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIOfferingController.cs
@@ -129,7 +129,15 @@ public partial class UIOfferingController(
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);
+ await SubsService.UpdateCredit(
+ new SubscriptionHostedService.UpdateCreditParameters()
+ {
+ SubscriberId = sub.Id,
+ Description = description ?? "Manual adjustment",
+ Credit = command is "credit" ? amount.Value : 0.0m,
+ Charge = command is "charge" ? amount.Value : 0.0m,
+ AllowOverdraft = true
+ });
TempData.SetStatusSuccess(message);
}
@@ -400,7 +408,7 @@ public partial class UIOfferingController(
return GoToOffering(storeId, offeringId);
}
- private static void UpdateFeatures(ApplicationDbContext ctx, OfferingData offering, ConfigureOfferingViewModel vm)
+ internal static void UpdateFeatures(ApplicationDbContext ctx, OfferingData offering, ConfigureOfferingViewModel vm)
{
var incomingById = vm.Features
.GroupBy(e => e.Id) // guard against dupes
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
index eb9bbc1..ad1585e 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UIPlanCheckoutController.cs
@@ -1,22 +1,15 @@
#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;
@@ -41,9 +34,11 @@ public class UIPlanCheckoutController(
var plan = checkout?.Plan;
if (plan is null || checkout is null)
return NotFound();
- var prefilledEmail = GetInvoiceMetadata(checkout).BuyerEmail;
+ string? prefilledEmail = null;
if (checkout.Subscriber is not null)
prefilledEmail = checkout.Subscriber.Customer.Email.Get();
+ else if (checkout.NewSubscriberEmail is not null)
+ prefilledEmail = checkout.NewSubscriberEmail;
var vm = new PlanCheckoutViewModel()
{
Id = plan.Id,
@@ -58,11 +53,6 @@ public class UIPlanCheckoutController(
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)
{
@@ -100,17 +90,21 @@ public class UIPlanCheckoutController(
}
var subscriber = checkout.Subscriber;
- CustomerSelector customerSelector;
+ CustomerSelector? customerSelector = null;
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.NewSubscriberEmail is null)
+ {
+ if (!vm.Email.IsValidEmail())
+ ModelState.AddModelError(nameof(vm.Email), "Invalid email format");
+ if (!ModelState.IsValid)
+ return await PlanCheckout(checkoutId);
+
+ checkout.NewSubscriberEmail = vm.Email;
+ await ctx.SaveChangesAsync();
+ }
+
+ customerSelector = CustomerSelector.ByEmail(checkout.NewSubscriberEmail);
if (checkout.NewSubscriber)
{
var sub = await ctx.Subscribers.GetBySelector(checkout.Plan.OfferingId, customerSelector);
@@ -120,19 +114,11 @@ public class UIPlanCheckoutController(
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);
+ return await RedirectToPlanCheckoutPayment(checkoutId, cancellationToken);
}
}
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
index 0c11fe9..2e17743 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriberPortalController.cs
@@ -214,7 +214,7 @@ public class UISubscriberPortalController(
}
var checkoutId = await SubsService.CreatePlanMigrationCheckout(session.Id, changedPlanId, onPay, Request.GetRequestBaseUrl());
- return await RedirectToPlanCheckoutPayment(checkoutId, session.Subscriber.CustomerSelector, cancellationToken);
+ return await RedirectToPlanCheckoutPayment(checkoutId, cancellationToken);
}
else if (command == "update-auto-renewal")
{
diff --git a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriptionControllerBase.cs b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriptionControllerBase.cs
index d6ad00c..fcb8da1 100644
--- a/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriptionControllerBase.cs
+++ b/BTCPayServer/Plugins/Subscriptions/Controllers/UISubscriptionControllerBase.cs
@@ -28,11 +28,11 @@ public class UISubscriptionControllerBase(
public IActionResult RedirectToPlanCheckout(string checkoutId)
=> RedirectToAction(nameof(UIPlanCheckoutController.PlanCheckout), "UIPlanCheckout", new { checkoutId });
- public async Task<IActionResult> RedirectToPlanCheckoutPayment(string checkoutId, CustomerSelector customerSelector, CancellationToken cancellationToken)
+ public async Task<IActionResult> RedirectToPlanCheckoutPayment(string checkoutId, CancellationToken cancellationToken)
{
try
{
- await SubsService.ProceedToSubscribe(checkoutId, customerSelector, cancellationToken);
+ await SubsService.ProceedToSubscribe(checkoutId, cancellationToken);
}
catch (InvalidOperationException) { }
catch (BitpayHttpException ex)
diff --git a/BTCPayServer/Plugins/Subscriptions/Mapper.cs b/BTCPayServer/Plugins/Subscriptions/Mapper.cs
new file mode 100644
index 0000000..9bcda5e
--- /dev/null
+++ b/BTCPayServer/Plugins/Subscriptions/Mapper.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Linq;
+using BTCPayServer.Client.Models;
+using BTCPayServer.Data.Subscriptions;
+using Newtonsoft.Json.Linq;
+
+namespace BTCPayServer.Plugins.Subscriptions;
+
+public static class Mapper
+{
+ public static SubscriptionPhase Map(SubscriberData.PhaseTypes p) =>
+ p switch
+ {
+ SubscriberData.PhaseTypes.Normal => SubscriptionPhase.Normal,
+ SubscriberData.PhaseTypes.Expired => SubscriptionPhase.Expired,
+ SubscriberData.PhaseTypes.Grace => SubscriptionPhase.Grace,
+ SubscriberData.PhaseTypes.Trial => SubscriptionPhase.Trial,
+ _ => SubscriptionPhase.Expired
+ };
+ public static SubscriberData.PhaseTypes Map(SubscriptionPhase subscriptionPhase)
+ => subscriptionPhase switch
+ {
+ SubscriptionPhase.Normal => SubscriberData.PhaseTypes.Normal,
+ SubscriptionPhase.Expired => SubscriberData.PhaseTypes.Expired,
+ SubscriptionPhase.Grace => SubscriberData.PhaseTypes.Grace,
+ SubscriptionPhase.Trial => SubscriberData.PhaseTypes.Trial,
+ _ => SubscriberData.PhaseTypes.Expired
+ };
+ public static OfferingPlanModel.RecurringInterval Map(PlanData plan)
+ => plan.RecurringType switch
+ {
+ PlanData.RecurringInterval.Monthly => OfferingPlanModel.RecurringInterval.Monthly,
+ PlanData.RecurringInterval.Quarterly => OfferingPlanModel.RecurringInterval.Quarterly,
+ PlanData.RecurringInterval.Yearly => OfferingPlanModel.RecurringInterval.Yearly,
+ _ => OfferingPlanModel.RecurringInterval.Lifetime
+ };
+
+ public static PlanData.RecurringInterval Map(OfferingPlanModel.RecurringInterval recurringInterval)
+ => recurringInterval switch
+ {
+ OfferingPlanModel.RecurringInterval.Monthly => PlanData.RecurringInterval.Monthly,
+ OfferingPlanModel.RecurringInterval.Quarterly => PlanData.RecurringInterval.Quarterly,
+ OfferingPlanModel.RecurringInterval.Yearly => PlanData.RecurringInterval.Yearly,
+ _ => PlanData.RecurringInterval.Lifetime
+ };
+
+ public 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,
+ Identities = customer.CustomerIdentities is {} v ? new JObject(v.Select(i => new JProperty(i.Type, i.Value))) : null,
+ Metadata = JObject.Parse(customer.Metadata)
+ },
+ Created = sub.CreatedAt,
+ Offering = MapOffering(offering),
+ Plan = MapPlan(plan),
+ PeriodEnd = sub.PeriodEnd,
+ TrialEnd = sub.TrialEnd,
+ GracePeriodEnd = sub.GracePeriodEnd,
+ IsActive = sub.IsActive,
+ IsSuspended = sub.IsSuspended,
+ Phase = Map(sub.Phase),
+ SuspensionReason = sub.SuspensionReason,
+ AutoRenew = sub.AutoRenew,
+ Metadata = JObject.Parse(sub.Metadata),
+ ProcessingInvoiceId = sub.ProcessingInvoiceId,
+ NextPlan = MapPlan(sub.NextPlan)
+ };
+ }
+
+ public static OfferingPlanModel MapPlan(PlanData plan)
+ {
+ return new OfferingPlanModel
+ {
+ Id = plan.Id,
+ Name = plan.Name,
+ Status = plan.Status switch
+ {
+ PlanData.PlanStatus.Active => OfferingPlanModel.PlanStatus.Active,
+ PlanData.PlanStatus.Retired => OfferingPlanModel.PlanStatus.Retired,
+ _ => OfferingPlanModel.PlanStatus.Retired
+ },
+ Price = plan.Price,
+ Currency = plan.Currency,
+ RecurringType = Map(plan),
+ GracePeriodDays = plan.GracePeriodDays,
+ TrialDays = plan.TrialDays,
+ Description = plan.Description,
+ MemberCount = plan.MemberCount,
+ OptimisticActivation = plan.OptimisticActivation,
+ Renewable = plan.Renewable,
+ Features = plan.GetFeatureIds(),
+ Metadata = JObject.Parse(plan.Metadata),
+ };
+ }
+
+ public static OfferingModel MapOffering(OfferingData offering)
+ => new()
+ {
+ Id = offering.Id,
+ StoreId = offering.App.StoreDataId,
+ AppName = offering.App.Name,
+ AppId = offering.AppId,
+ SuccessRedirectUrl = offering.SuccessRedirectUrl,
+ Plans = offering?.Plans is {} plans ? plans.Select(MapPlan).ToList() : null,
+ Features = offering?.Features is {} features ? features.Select(MapFeature).ToList() : null,
+ Metadata = JObject.Parse(offering.Metadata)
+ };
+
+ private static FeatureModel MapFeature(FeatureData arg)
+ => new()
+ {
+ Id = arg.CustomId,
+ Description = arg.Description
+ };
+
+ public static PlanCheckoutModel MapPlanCheckout(PlanCheckoutData checkout)
+ => new()
+ {
+ Id = checkout.Id,
+ InvoiceId = checkout.InvoiceId,
+ Subscriber = checkout.Subscriber is null ? null : MapToSubscriberModel(checkout.Subscriber),
+ Plan = MapPlan(checkout.Plan),
+ SuccessRedirectUrl = checkout.SuccessRedirectUrl,
+ Expiration = checkout.Expiration,
+ RedirectUrl = checkout.GetRedirectUrl(),
+ BaseUrl = checkout.BaseUrl.ToString(),
+ InvoiceMetadata = JObject.Parse(checkout.InvoiceMetadata),
+ Metadata = JObject.Parse(checkout.Metadata),
+ NewSubscriber = checkout.NewSubscriber,
+ IsTrial = checkout.IsTrial,
+ Created = checkout.CreatedAt,
+ PlanStarted = checkout.PlanStarted,
+ NewSubscriberMetadata = JObject.Parse(checkout.NewSubscriberMetadata),
+ RefundAmount = checkout.RefundAmount,
+ CreditedByInvoice = checkout.CreditedByInvoice,
+ OnPayBehavior = MapOnPay(checkout.OnPay),
+ IsExpired = checkout.IsExpired,
+ CreditPurchase = checkout.CreditPurchase,
+ Url = checkout.BaseUrl.GetUrl($"/plan-checkout/{checkout.Id}")
+ };
+
+ private static OnPayBehavior MapOnPay(PlanCheckoutData.OnPayBehavior behavior)
+ => behavior switch
+ {
+ PlanCheckoutData.OnPayBehavior.HardMigration => OnPayBehavior.HardMigration,
+ PlanCheckoutData.OnPayBehavior.SoftMigration => OnPayBehavior.SoftMigration,
+ _ => throw new NotSupportedException(nameof(behavior))
+ };
+ public static PlanCheckoutData.OnPayBehavior Map(OnPayBehavior onPayBehavior)
+ => onPayBehavior switch
+ {
+ OnPayBehavior.HardMigration => PlanCheckoutData.OnPayBehavior.HardMigration,
+ OnPayBehavior.SoftMigration => PlanCheckoutData.OnPayBehavior.SoftMigration,
+ _ => throw new NotSupportedException(nameof(onPayBehavior))
+ };
+
+ public static PortalSessionModel MapPortalSession(PortalSessionData session)
+ => new()
+ {
+ Id = session.Id,
+ BaseUrl = session.BaseUrl.ToString(),
+ Subscriber = MapToSubscriberModel(session.Subscriber),
+ Expiration = session.Expiration,
+ IsExpired = session.IsExpired,
+ Url = session.BaseUrl.GetUrl($"subscriber-portal/{session.Id}")
+ };
+
+
+}
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs b/BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs
index 68beb17..94b7628 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriberWebhookProvider.cs
@@ -2,7 +2,6 @@
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;
@@ -19,7 +18,7 @@ public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEven
model["Plan"] = new JObject()
{
["Id"] = evt.Subscriber.PlanId,
- ["Name"] = evt.Subscriber.Plan.Name ?? "",
+ ["Name"] = evt.Subscriber.Plan.Name,
};
model["Offering"] = new JObject()
{
@@ -37,7 +36,7 @@ public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEven
model["Customer"] = new JObject()
{
["ExternalRef"] = evt.Subscriber.Customer.ExternalRef ?? "",
- ["Name"] = evt.Subscriber.Customer.Name ?? "",
+ ["Name"] = evt.Subscriber.Customer.Name,
["Metadata"] = evt.Subscriber.Customer.Metadata
};
return model;
@@ -48,7 +47,7 @@ public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEven
if (evt is null) throw new ArgumentNullException(nameof(evt));
var sub = evt.Subscriber;
var storeId = sub.Customer.StoreId;
- var model = MapToSubscriberModel(sub);
+ var model = Mapper.MapToSubscriberModel(sub);
switch (evt)
{
@@ -86,8 +85,8 @@ public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEven
return new WebhookSubscriptionEvent.SubscriberPhaseChangedEvent(storeId)
{
Subscriber = model,
- PreviousPhase = MapPhase(phaseChanged.PreviousPhase),
- CurrentPhase = MapPhase(sub.Phase)
+ PreviousPhase = Mapper.Map(phaseChanged.PreviousPhase),
+ CurrentPhase = Mapper.Map(sub.Phase)
};
case SubscriptionEvent.SubscriberDisabled:
@@ -116,73 +115,5 @@ public class SubscriberWebhookProvider : WebhookTriggerProvider<SubscriptionEven
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,
- Features = plan.GetFeatureIds()
- },
- PeriodEnd = sub.PeriodEnd,
- TrialEnd = sub.TrialEnd,
- GracePeriodEnd = sub.GracePeriodEnd,
- IsActive = sub.IsActive,
- IsSuspended = sub.IsSuspended,
- SuspensionReason = sub.SuspensionReason
- };
}
}
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs b/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
index 55b862e..38725e9 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriptionContext.cs
@@ -34,8 +34,8 @@ public class SubscriptionContext(ApplicationDbContext ctx, EventAggregator aggre
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;
+ public async Task<bool> TryChargeSubscriber(SubscriberData sub, string description, decimal charge, bool allowOverdraft = false)
+ => (await TryCreditDebitSubscriber(sub, description, 0m, charge, allowOverdraft)) is not null;
private static async Task<decimal?> UpdateCredit(BalanceTransaction tx, bool force, ApplicationDbContext ctx)
{
@@ -84,19 +84,21 @@ public class SubscriptionContext(ApplicationDbContext ctx, EventAggregator aggre
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)
+ public async Task<decimal?> TryCreditDebitSubscriber(SubscriberData sub, string description, decimal credit, decimal charge, bool allowOverdraft = false, string? currency = null)
{
- 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);
+ currency ??= sub.Plan.Currency;
+ currency = currency.ToUpperInvariant().Trim();
+ charge = RoundAmount(charge, currency);
+ credit = RoundAmount(credit, currency);
+ var tx = new BalanceTransaction(sub.Id, currency, credit, charge, description);
+ var amount = await UpdateCredit(tx, allowOverdraft, 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));
+ AddEvent(new SubscriptionEvent.SubscriberCredited(sub, newTotal + tx.Debit, tx.Credit, currency));
if (tx.Debit != 0)
- AddEvent(new SubscriptionEvent.SubscriberDebited(sub, newTotal, tx.Debit, sub.Plan.Currency));
+ AddEvent(new SubscriptionEvent.SubscriberDebited(sub, newTotal, tx.Debit, currency));
}
return amount;
}
diff --git a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
index 2e5bdf7..1a1e71e 100644
--- a/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
+++ b/BTCPayServer/Plugins/Subscriptions/SubscriptionHostedService.cs
@@ -38,7 +38,7 @@ public class SubscriptionHostedService(
{
record Poll;
- public record SubscribeRequest(string CheckoutId, CustomerSelector CustomerSelector);
+ public record SubscribeRequest(string CheckoutId);
public Task Do(CancellationToken cancellationToken)
=> base.RunEvent(new Poll(), cancellationToken);
@@ -95,11 +95,11 @@ public class SubscriptionHostedService(
if (checkout.IsTrial)
{
- await StartPlanCheckoutWithoutInvoice(subCtx, checkout, subscribeRequest.CustomerSelector);
+ await StartPlanCheckoutWithoutInvoice(subCtx, checkout);
}
else
{
- await CreateInvoiceForCheckout(subCtx, checkout, subscribeRequest.CustomerSelector);
+ await CreateInvoiceForCheckout(subCtx, checkout);
}
}
else if (evt is SuspendRequest suspendRequest)
@@ -142,7 +142,7 @@ public class SubscriptionHostedService(
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)
+ private async Task CreateInvoiceForCheckout(SubscriptionContext subCtx, PlanCheckoutData checkout)
{
var invoiceMetadata = JObject.Parse(checkout.InvoiceMetadata);
if (checkout.NewSubscriber)
@@ -150,12 +150,12 @@ public class SubscriptionHostedService(
invoiceMetadata["planId"] = checkout.PlanId;
invoiceMetadata["offeringId"] = checkout.Plan.OfferingId;
}
- if (GetBuyerEmail(checkout, customerSelector) is string email)
+ if (checkout.GetEmail() is string email && !invoiceMetadata.ContainsKey("buyerEmail"))
invoiceMetadata["buyerEmail"] = email;
var plan = checkout.Plan;
var existingCredit = checkout.Subscriber?.GetCredit() ?? 0m;
- var amount = price ?? (plan.Price - existingCredit);
+ var amount = checkout.CreditPurchase ?? (plan.Price - existingCredit);
if (checkout.OnPay == PlanCheckoutData.OnPayBehavior.HardMigration &&
checkout.Subscriber?.GetUnusedPeriodAmount(subCtx.Now) is decimal unusedAmount)
amount -= subCtx.RoundAmount(unusedAmount, plan.Currency);
@@ -190,15 +190,10 @@ public class SubscriptionHostedService(
}
else
{
- await StartPlanCheckoutWithoutInvoice(subCtx, checkout, customerSelector);
+ await StartPlanCheckoutWithoutInvoice(subCtx, checkout);
}
}
- private static string? GetBuyerEmail(PlanCheckoutData checkout, CustomerSelector customerSelector)
- => customerSelector is CustomerSelector.Identity { Type: "Email", Value: { } email }
- ? email
- : checkout.Subscriber?.Customer.Email.Get();
-
class MembershipServerSettings
{
public MembershipServerSettings()
@@ -349,10 +344,10 @@ public class SubscriptionHostedService(
return plansToUpdate;
}
- public Task ProceedToSubscribe(string checkoutId, CustomerSelector selector, CancellationToken cancellationToken)
- => RunEvent(new SubscribeRequest(checkoutId, selector), cancellationToken);
+ public Task ProceedToSubscribe(string checkoutId, CancellationToken cancellationToken)
+ => RunEvent(new SubscribeRequest(checkoutId), cancellationToken);
- private async Task StartPlanCheckoutWithoutInvoice(SubscriptionContext subCtx, PlanCheckoutData checkout, CustomerSelector customerSelector)
+ private async Task StartPlanCheckoutWithoutInvoice(SubscriptionContext subCtx, PlanCheckoutData checkout)
{
var ctx = subCtx.Context;
var sub = checkout.Subscriber;
@@ -361,7 +356,7 @@ public class SubscriptionHostedService(
if (sub is null)
{
- sub = await CreateSubscription(subCtx, checkout, false, customerSelector);
+ sub = await CreateSubscription(subCtx, checkout, false);
if (sub is null)
return;
}
@@ -405,7 +400,7 @@ public class SubscriptionHostedService(
if (sub is null)
{
var optimisticActivation = invoice.Status == InvoiceStatus.Processing && plan.OptimisticActivation;
- sub = await CreateSubscription(subCtx, checkout, optimisticActivation, CustomerSelector.ByEmail(invoice.Metadata.BuyerEmail));
+ sub = await CreateSubscription(subCtx, checkout, optimisticActivation);
if (sub is null)
return;
@@ -418,12 +413,12 @@ public class SubscriptionHostedService(
}
var invoiceCredit = subCtx.GetAmountToCredit(invoice);
- if (checkout.Credited != invoiceCredit)
+ if (checkout.CreditedByInvoice != invoiceCredit)
{
- var diff = invoiceCredit - checkout.Credited;
+ var diff = invoiceCredit - checkout.CreditedByInvoice;
if (diff > 0)
{
- checkout.Credited += diff;
+ checkout.CreditedByInvoice += diff;
await subCtx.CreditSubscriber(sub, $"Credit purchase (Inv: {invoice.Id})", diff);
if (!checkout.PlanStarted)
@@ -433,8 +428,8 @@ public class SubscriptionHostedService(
}
else
{
- await subCtx.TryChargeSubscriber(sub, $"Adjustement (Inv: {invoice.Id})", -diff, force: true);
- checkout.Credited -= -diff;
+ await subCtx.TryChargeSubscriber(sub, $"Adjustement (Inv: {invoice.Id})", -diff, allowOverdraft: true);
+ checkout.CreditedByInvoice -= -diff;
}
}
@@ -446,7 +441,7 @@ public class SubscriptionHostedService(
await ctx.SaveChangesAsync();
}
else if (sub is not null && invoice.Status == InvoiceStatus.Invalid &&
- checkout is { PlanStarted: true, Credited: not 0m })
+ checkout is { PlanStarted: true, CreditedByInvoice: not 0m })
{
// We should probably ask the merchant before reversing the credit...
// await TryChargeSubscriber(ctx, sub, checkout.Credited, force: true);
@@ -534,19 +529,22 @@ public class SubscriptionHostedService(
else if (phase == PhaseTypes.Grace)
time = subscriber.PeriodEnd!.Value - DateTimeOffset.UtcNow;
else if (phase == PhaseTypes.Expired)
- time = subscriber.GracePeriodEnd!.Value - DateTimeOffset.UtcNow;
+ time = (subscriber.GracePeriodEnd ?? subscriber.PeriodEnd)!.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)
+ private async Task<SubscriberData?> CreateSubscription(SubscriptionContext subCtx, PlanCheckoutData checkout, bool optimisticActivation)
{
var ctx = subCtx.Context;
var plan = checkout.Plan;
- var cust = await ctx.Customers.GetOrUpdate(checkout.Plan.Offering.App.StoreDataId, customerSelector);
+
+ var email = checkout.GetEmail();
+ if (email is null)
+ return null;
+ var cust = await ctx.Customers.GetOrUpdate(checkout.Plan.Offering.App.StoreDataId, CustomerSelector.ByEmail(email));
(var sub, var created) =
await ctx.Subscribers.GetOrCreateByCustomerId(cust.Id, plan.OfferingId, plan.Id, optimisticActivation, checkout.TestAccount,
JObject.Parse(checkout.NewSubscriberMetadata));
@@ -596,16 +594,30 @@ public class SubscriptionHostedService(
public Task Suspend(long subId, string? suspensionReason)
=> RunEvent(new SuspendRequest(subId, suspensionReason, true));
+ public Task Unsuspend(long subId)
+ => RunEvent(new SuspendRequest(subId, null, false));
+
+ public class UpdateCreditParameters
+ {
+ public long SubscriberId { get; set; }
+ public string? Description { get; set; }
+ public bool AllowOverdraft { get; set; }
+ public decimal Credit { get; set; }
+ public decimal Charge { get; set; }
+ public string? Currency { get; set; }
+ }
- public async Task UpdateCredit(long subscriberId, string description, decimal update)
+ public async Task<decimal?> UpdateCredit(UpdateCreditParameters parameters)
{
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);
+ var sub = await subCtx.Context.Subscribers.GetById(parameters.SubscriberId);
+ if (sub is null) return null;
+ return await subCtx.TryCreditDebitSubscriber(sub,
+ parameters.Description ?? "No description",
+ parameters.Credit,
+ parameters.Charge,
+ parameters.AllowOverdraft,
+ parameters.Currency);
}
@@ -620,11 +632,12 @@ public class SubscriptionHostedService(
var checkout = new PlanCheckoutData(portal.Subscriber)
{
SuccessRedirectUrl = linkGenerator.SubscriberPortalLink(portalSessionId, portal.BaseUrl),
+ CreditPurchase = value,
BaseUrl = portal.BaseUrl
};
ctx.PlanCheckouts.Add(checkout);
await ctx.SaveChangesAsync();
- await this.CreateInvoiceForCheckout(subCtx, checkout, portal.Subscriber.CustomerSelector, value);
+ await this.CreateInvoiceForCheckout(subCtx, checkout);
return checkout.InvoiceId;
}
diff --git a/BTCPayServer/Security/GreenField/APIKeyExtensions.cs b/BTCPayServer/Security/GreenField/APIKeyExtensions.cs
index 820cb89..f85e7de 100644
--- a/BTCPayServer/Security/GreenField/APIKeyExtensions.cs
+++ b/BTCPayServer/Security/GreenField/APIKeyExtensions.cs
@@ -39,7 +39,6 @@ namespace BTCPayServer.Security.Greenfield
{
serviceCollection.AddSingleton<APIKeyRepository>();
serviceCollection.AddScoped<IAuthorizationHandler, GreenfieldAuthorizationHandler>();
- serviceCollection.AddScoped<IAuthorizationHandler, LocalGreenfieldAuthorizationHandler>();
return serviceCollection;
}
diff --git a/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs b/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs
index 7d01574..240282a 100644
--- a/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs
+++ b/BTCPayServer/Security/GreenField/GreenFieldAuthorizationHandler.cs
@@ -12,45 +12,6 @@ using StoreData = BTCPayServer.Data.StoreData;
namespace BTCPayServer.Security.Greenfield
{
- public class LocalGreenfieldAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
- {
- private readonly IHttpContextAccessor _httpContextAccessor;
- private readonly UserManager<ApplicationUser> _userManager;
- private readonly StoreRepository _storeRepository;
- private readonly IPluginHookService _pluginHookService;
-
- public LocalGreenfieldAuthorizationHandler(IHttpContextAccessor httpContextAccessor,
- UserManager<ApplicationUser> userManager,
- StoreRepository storeRepository,
- IPluginHookService pluginHookService)
- {
- _httpContextAccessor = httpContextAccessor;
- _userManager = userManager;
- _storeRepository = storeRepository;
- _pluginHookService = pluginHookService;
- }
- protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, PolicyRequirement requirement)
- {
- var withuser = context.User.Identity?.AuthenticationType == $"Local{GreenfieldConstants.AuthenticationType}WithUser";
- if (withuser)
- {
- var newUser = new ClaimsPrincipal(new ClaimsIdentity(context.User.Claims,
- $"{GreenfieldConstants.AuthenticationType}"));
- var newContext = new AuthorizationHandlerContext(context.Requirements, newUser, null);
- return new GreenfieldAuthorizationHandler(
- _httpContextAccessor, _userManager, _storeRepository, _pluginHookService).HandleAsync(newContext);
- }
-
- var succeed = context.User.Identity.AuthenticationType == $"Local{GreenfieldConstants.AuthenticationType}";
-
- if (succeed)
- {
- context.Succeed(requirement);
- }
- return Task.CompletedTask;
- }
- }
-
public class GreenfieldAuthorizationHandler : AuthorizationHandler<PolicyRequirement>
{
private readonly HttpContext _httpContext;
@@ -72,7 +33,7 @@ namespace BTCPayServer.Security.Greenfield
protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context,
PolicyRequirement requirement)
{
- if (context.User.Identity.AuthenticationType != GreenfieldConstants.AuthenticationType)
+ if (context.User.Identity?.AuthenticationType != GreenfieldConstants.AuthenticationType)
return;
var userid = _userManager.GetUserId(context.User);
bool success = false;
@@ -87,7 +48,7 @@ namespace BTCPayServer.Security.Greenfield
switch (policy)
{
case { } when Policies.IsStorePolicy(policy):
- var storeId = requiredUnscoped ? null : _httpContext.GetImplicitStoreId();
+ var storeId = requiredUnscoped ? null : (context.Resource as string ?? _httpContext.GetImplicitStoreId());
// Specific store action
if (storeId != null)
{
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
index 625071c..a54e551 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.invoices.json
@@ -827,8 +827,7 @@
"additionalProperties": false,
"properties": {
"id": {
- "type": "string",
- "description": "The identifier of the invoice"
+ "$ref": "#/components/schemas/InvoiceId"
},
"storeId": {
"description": "The store identifier that the invoice belongs to",
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
index b64beb8..929486f 100644
--- a/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.json
@@ -40,7 +40,7 @@
"required": true,
"description": "The invoice ID",
"schema": {
- "type": "string"
+ "$ref": "#/components/schemas/InvoiceId"
}
},
"UserIdOrEmail": {
@@ -54,6 +54,11 @@
}
},
"schemas": {
+ "BaseUrl": {
+ "type": "string",
+ "description": "Base URL of the BTCPay Server instance.",
+ "example": "https://btcpay.example.com/"
+ },
"ValidationProblemDetails": {
"type": "array",
"description": "An array of validation errors of the request",
@@ -149,6 +154,11 @@
"description": "Store ID of the item",
"example": "9CiNzKoANXxmk5ayZngSXrHTiVvvgCrwrpFQd4m2K776"
},
+ "InvoiceId": {
+ "type": "string",
+ "description": "The invoice ID",
+ "example": "HMprBnL9BTXWuPvpoKBS6e"
+ },
"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",
@@ -206,7 +216,7 @@
"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.canviewofferings`: View your offerings\n* `btcpay.store.canmodifyofferings`: Modify your offerings\n* `btcpay.store.canmanagepullpayments`: Manage your pull payments\n* `btcpay.store.canarchivepullpayments`: Archive your pull payments\n* `btcpay.store.cancreatepullpayments`: Create pull payments\n* `btcpay.store.canmanagepayouts`: Manage payouts\n* `btcpay.store.canviewpayouts`: View payouts\n* `btcpay.store.cancreatenonapprovedpullpayments`: Create non-approved pull payments\n* `btcpay.store.canuselightningnode`: Use the lightning nodes associated with your stores\n* `btcpay.store.canviewlightninginvoice`: View the lightning invoices associated with your stores\n* `btcpay.store.cancreatelightninginvoice`: Create invoices from the lightning nodes associated with your stores\n\nNote that API Keys only limits permission of a user and can never expand it. If an API Key has the permission `btcpay.server.canmodifyserversettings` but that the user account creating this API Key is not administrator, the API Key will not be able to modify the server settings.\nSome permissions may include other permissions, see [this operation](#operation/permissionsMetadata).\n",
+ "description": "BTCPay Server supports authenticating and authorizing users through an API Key that is generated by them. Send the API Key as a header value to Authorization with the format: `token {token}`. For a smoother experience, you can generate a url that redirects users to an API key creation screen.\n\n The following permissions are available to the context of the user creating the API Key:\n\n* `unrestricted`: Unrestricted access\n* `btcpay.user.candeleteuser`: Delete user\n* `btcpay.user.canviewprofile`: View your profile\n* `btcpay.user.canmodifyprofile`: Manage your profile\n* `btcpay.user.canmanagenotificationsforuser`: Manage your notifications\n* `btcpay.user.canviewnotificationsforuser`: View your notifications\n\nThe following permissions are available if the user is an administrator:\n\n* `btcpay.server.canviewusers`: View users\n* `btcpay.server.cancreateuser`: Create new users\n* `btcpay.server.canmanageusers`: Manage users\n* `btcpay.server.canmodifyserversettings`: Manage your server\n* `btcpay.server.canuseinternallightningnode`: Use the internal lightning node\n* `btcpay.server.canviewlightninginvoiceinternalnode`: View invoices from internal lightning node\n* `btcpay.server.cancreatelightninginvoiceinternalnode`: Create invoices with internal lightning node\n\nThe following permissions applies to all stores of the user, you can limit to a specific store with the following format: `btcpay.store.cancreateinvoice:6HSHAEU4iYWtjxtyRs9KyPjM9GAQp8kw2T9VWbGG1FnZ`:\n\n* `btcpay.store.canmodifystoresettings`: Modify your stores\n* `btcpay.store.webhooks.canmodifywebhooks`: Modify stores webhooks\n* `btcpay.store.canviewstoresettings`: View your stores\n* `btcpay.store.canviewreports`: View your reports\n* `btcpay.store.cancreateinvoice`: Create an invoice\n* `btcpay.store.canviewinvoices`: View invoices\n* `btcpay.store.canmodifyinvoices`: Modify invoices\n* `btcpay.store.canmodifypaymentrequests`: Modify your payment requests\n* `btcpay.store.canviewpaymentrequests`: View your payment requests\n* `btcpay.store.canviewpullpayments`: View your pull payments\n* `btcpay.store.canviewofferings`: View your offerings\n* `btcpay.store.canmodifyofferings`: Modify your offerings\n* `btcpay.store.canmanagesubscribers`: Manage your subscribers\n* `btcpay.store.cancreditsubscribers`: Credit your subscribers\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"
},
@@ -223,4 +233,4 @@
"Basic": []
}
]
-}
\ No newline at end of file
+}
diff --git a/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json b/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json
new file mode 100644
index 0000000..10973e3
--- /dev/null
+++ b/BTCPayServer/wwwroot/swagger/v1/swagger.template.subscriptions.json
@@ -0,0 +1,1417 @@
+{
+ "paths": {
+ "/api/v1/stores/{storeId}/offerings/{offeringId}": {
+ "get": {
+ "summary": "Get an offering",
+ "description": "Returns a specific offering for a store.",
+ "operationId": "GetOffering",
+ "tags": [
+ "Subscriptions"
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canviewofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Offering retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OfferingModel"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Offering not found."
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings": {
+ "get": {
+ "summary": "List offerings for a store",
+ "description": "Retrieves all offerings associated with the specified store.",
+ "operationId": "GetOfferings",
+ "tags": ["Subscriptions"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ }
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canviewofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "List of offerings retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "array",
+ "items": { "$ref": "#/components/schemas/OfferingModel" }
+ }
+ }
+ }
+ }
+ }
+ },
+ "post": {
+ "summary": "Create an offering",
+ "description": "Creates a new offering for the specified store.",
+ "operationId": "CreateOffering",
+ "tags": [
+ "Subscriptions"
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifyofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Offering data to create.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreateOfferingModel"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Offering created successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OfferingModel"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request data."
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/plans": {
+ "post": {
+ "summary": "Create an offering plan",
+ "description": "Creates a new plan for a specific offering.",
+ "operationId": "CreateOfferingPlan",
+ "tags": [
+ "Subscriptions"
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmodifyofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Plan data to create for the offering.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/CreatePlanRequest"
+ }
+ }
+ }
+ },
+ "responses": {
+ "201": {
+ "description": "Plan created successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OfferingPlanModel"
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request data."
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/plans/{planId}": {
+ "get": {
+ "summary": "Get an offering plan",
+ "description": "Returns a specific plan for a given offering.",
+ "operationId": "GetOfferingPlan",
+ "tags": [
+ "Subscriptions"
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canviewofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/PlanId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Plan retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/OfferingPlanModel"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Plan not found."
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}": {
+ "get": {
+ "summary": "Get a subscriber",
+ "description": "Retrieves a subscriber for a specific offering by customer selector.",
+ "operationId": "GetSubscriber",
+ "tags": [
+ "Subscriptions"
+ ],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canviewofferings"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Subscriber retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/SubscriberModel"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Subscriber not found."
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/credits/{currency}": {
+ "get": {
+ "summary": "Get subscriber credit balance",
+ "description": "Retrieves the credit balance for a subscriber in the specified currency.",
+ "operationId": "GetCredit",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ },
+ {
+ "$ref": "#/components/parameters/Subscriptions/Currency"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Credit retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/CreditModel" }
+ }
+ }
+ },
+ "404": { "description": "Credit record not found." }
+ }
+ },
+
+ "post": {
+ "summary": "Update subscriber credit balance",
+ "description": "Adds credit or charges credit for a subscriber in a given currency.",
+ "operationId": "UpdateCredit",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.cancreditsubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ },
+ {
+ "$ref": "#/components/parameters/Subscriptions/Currency"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Details for modifying subscriber credit.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/UpdateCreditRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Credit updated successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/CreditModel" }
+ }
+ }
+ },
+ "400": {
+ "description": "Error code: `overdraft`. The subscriber's balance would be overdrawn. Use `allowOverdraft` to allow this.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/suspend": {
+ "post": {
+ "summary": "Suspend a subscriber",
+ "description": "Suspends a subscriber for the specified offering.",
+ "operationId": "SuspendSubscriber",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Details about why the subscriber is being suspended.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/SuspendSubscriberRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Subscriber suspended successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/SubscriberModel" }
+ }
+ }
+ },
+ "400": { "description": "Invalid request data." }
+ }
+ }
+ },
+ "/api/v1/stores/{storeId}/offerings/{offeringId}/subscribers/{customerSelector}/unsuspend": {
+ "post": {
+ "summary": "Unsuspend a subscriber",
+ "description": "Removes suspension from a subscriber for the specified offering.",
+ "operationId": "UnsuspendSubscriber",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/StoreId"
+ },
+ {
+ "$ref": "#/components/parameters/OfferingId"
+ },
+ {
+ "$ref": "#/components/parameters/CustomerSelector"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Subscriber unsuspended successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/SubscriberModel" }
+ }
+ }
+ },
+ "400": { "description": "Invalid request data." }
+ }
+ }
+ },
+ "/api/v1/plan-checkout/{checkoutId}": {
+ "get": {
+ "summary": "Get a plan checkout",
+ "description": "Retrieves the details of a plan checkout session.",
+ "operationId": "GetPlanCheckout",
+ "tags": ["Subscriptions"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/PlanCheckoutId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Checkout retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PlanCheckoutModel" }
+ }
+ }
+ },
+ "404": {
+ "description": "Checkout session not found."
+ }
+ }
+ },
+ "post": {
+ "summary": "Proceed with a plan checkout",
+ "description": "Continues a plan checkout session.\n\n**Behavior:**\n- If payment is required, the checkout assigns a BTCPay Server invoice and `invoiceId` will be set.\n- If no payment is required (for example, the subscriber already has enough credit or no credit purchase is needed), the plan will start immediately and `planStarted` will be `true`.",
+ "operationId": "ProceedPlanCheckout",
+ "tags": ["Subscriptions"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/PlanCheckoutId"
+ },
+ {
+ "name": "email",
+ "in": "query",
+ "required": false,
+ "description": "Optional customer email used when proceeding with the checkout.",
+ "schema": { "type": "string" },
+ "example": "user@example.com"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Checkout processed successfully.\n\n**Behavior:**\n- If payment is required, `invoiceId` contains the BTCPay Server invoice the user must pay.\n- If no payment is required, the subscription is activated immediately and `planStarted` will be `true`.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PlanCheckoutModel" }
+ }
+ }
+ },
+ "400": {
+ "description": "Error code: `invoice-creation-error`. Error during the creation of the invoice.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "$ref": "#/components/schemas/ProblemDetails"
+ }
+ }
+ }
+ },
+ "404": {
+ "description": "Checkout session not found."
+ }
+ }
+ }
+ },
+
+ "/api/v1/plan-checkout": {
+ "post": {
+ "summary": "Create a plan checkout session",
+ "description": "Creates a checkout session for purchasing or activating a plan.",
+ "operationId": "CreatePlanCheckout",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Details required to create a checkout session.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/CreatePlanCheckoutRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Checkout created successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PlanCheckoutModel" }
+ }
+ }
+ },
+ "400": {
+ "description": "Invalid request data."
+ }
+ }
+ }
+ },
+ "/api/v1/subscriber-portal": {
+ "post": {
+ "summary": "Create a subscriber portal session",
+ "description": "Creates a portal session that allows a subscriber to manage their subscriptions, view billing details, or update account information.",
+ "operationId": "CreatePortalSession",
+ "tags": ["Subscriptions"],
+ "security": [
+ {
+ "API_Key": [
+ "btcpay.store.canmanagesubscribers"
+ ],
+ "Basic": []
+ }
+ ],
+ "requestBody": {
+ "required": true,
+ "description": "Information required to create a subscriber portal session.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/CreatePortalSessionRequest" }
+ }
+ }
+ },
+ "responses": {
+ "200": {
+ "description": "Portal session created successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PortalSessionModel" }
+ }
+ }
+ },
+ "400": { "description": "Invalid request data." }
+ }
+ }
+ },
+
+ "/api/v1/subscriber-portal/{portalSessionId}": {
+ "get": {
+ "summary": "Get a subscriber portal session",
+ "description": "Retrieves the details of an existing subscriber portal session.",
+ "operationId": "GetPortalSession",
+ "tags": ["Subscriptions"],
+ "parameters": [
+ {
+ "$ref": "#/components/parameters/PortalSessionId"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Portal session retrieved successfully.",
+ "content": {
+ "application/json": {
+ "schema": { "$ref": "#/components/schemas/PortalSessionModel" }
+ }
+ }
+ },
+ "404": { "description": "Portal session not found." }
+ }
+ }
+ }
+ },
+ "components": {
+ "parameters": {
+ "PortalSessionId": {
+ "name": "portalSessionId",
+ "in": "path",
+ "required": true,
+ "example": "ps_665afQ23ExGouiY4EZ",
+ "description": "Identifier of the portal session.",
+ "schema": { "$ref": "#/components/schemas/PortalSessionId" }
+ },
+ "PlanCheckoutId": {
+ "name": "checkoutId",
+ "in": "path",
+ "required": true,
+ "example": "plancheckout_Hpm59L1NPCMUj477Q5",
+ "description": "Unique identifier of the plan checkout session.",
+ "schema": { "$ref": "#/components/schemas/PlanCheckoutId" }
+ },
+ "Subscriptions/Currency": {
+ "name": "currency",
+ "in": "path",
+ "required": true,
+ "description": "Currency code for the credit balance. Use `current` to retrieve the current balance in the currency of the current plan.",
+ "schema": { "type": "string" },
+ "example": "current"
+ },
+ "CustomerSelector": {
+ "name": "customerSelector",
+ "in": "path",
+ "required": true,
+ "description": "Flexible customer selector. Supports: a customer ID (e.g., `cust_GUGnpx3311fkaqGk7f`), an email (e.g., `subscriber@example.com`), or a key/value identity (e.g., `Email:subscriber@example.com`).",
+ "schema": {
+ "$ref": "#/components/schemas/CustomerSelector"
+ }
+ },
+ "PlanId": {
+ "name": "planId",
+ "in": "path",
+ "required": true,
+ "description": "Plan's ID",
+ "example": "plan_KZMVyuQp3v2vFWnEUM",
+ "schema": {
+ "$ref": "#/components/schemas/PlanId"
+ }
+ },
+ "OfferingId": {
+ "name": "offeringId",
+ "in": "path",
+ "required": true,
+ "description": "Offering's ID",
+ "example": "offering_DKWhZGB6PZsgTcPwpf",
+ "schema": {
+ "$ref": "#/components/schemas/OfferingId"
+ }
+ }
+ },
+ "schemas": {
+ "PortalSessionId": {
+ "type": "string",
+ "example": "ps_665afQ23ExGouiY4EZ",
+ "description": "Identifier of the portal session."
+ },
+ "PlanCheckoutId": {
+ "type": "string",
+ "description": "Unique identifier of the plan checkout session.",
+ "example": "plancheckout_Hpm59L1NPCMUj477Q5"
+ },
+ "OnPayBehavior": {
+ "type": "string",
+ "description": "Defines how the system should behave when payment is processed during a plan checkout or migration.\n* `SoftMigration`: Starts the plan only if payment is due. If no payment is due yet, the amount is added as credit instead of starting the plan.\n* `HardMigration`: Starts the plan immediately, even if payment is not due. If the user already paid for unused time, the unused portion is refunded before starting the plan.",
+ "enum": [
+ "SoftMigration",
+ "HardMigration"
+ ],
+ "example": "SoftMigration",
+ "x-enumDescriptions": {
+ "SoftMigration": "Starts the plan only if payment is due. If no payment is due yet, the amount is added as credit instead of starting the plan.",
+ "HardMigration": "Starts the plan immediately, even if payment is not due. If the user already paid for unused time, the unused portion is refunded before starting the plan."
+ }
+ },
+ "PlanCheckoutModel": {
+ "type": "object",
+ "description": "Represents a checkout session for activating or purchasing a subscription plan.",
+ "properties": {
+ "subscriber": {
+ "$ref": "#/components/schemas/SubscriberModel",
+ "description": "Subscriber associated with the checkout."
+ },
+ "plan": {
+ "$ref": "#/components/schemas/OfferingPlanModel",
+ "description": "Plan being purchased or activated."
+ },
+ "baseUrl": {
+ "$ref": "#/components/schemas/BaseUrl"
+ },
+ "id": {
+ "$ref": "#/components/schemas/PlanCheckoutId"
+ },
+ "invoiceId": {
+ "$ref": "#/components/schemas/InvoiceId"
+ },
+ "successRedirectUrl": {
+ "type": "string",
+ "description": "URL to redirect the user after checkout success. (The `checkoutPlanId` query parameter will be added to the URL.)",
+ "example": "https://example.com/success"
+ },
+ "expiration": {
+ "type": "integer",
+ "format": "unix-time",
+ "description": "Checkout expiration timestamp.",
+ "example": 1710602000
+ },
+ "redirectUrl": {
+ "type": "string",
+ "description": "URL where the user is redirected to proceed with payment.",
+ "example": "https://pay.example.com/plancheckout_Hpm59L1NPCMUj477Q5"
+ },
+ "invoiceMetadata": {
+ "type": "object",
+ "description": "Custom metadata that will be attached to the invoice when it is created.",
+ "additionalProperties": true,
+ "example": { "segment": "gold" }
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the checkout.",
+ "additionalProperties": true,
+ "example": { "flow": "upgrade" }
+ },
+ "newSubscriber": {
+ "type": "boolean",
+ "description": "Indicates whether the checkout is for a new subscriber.",
+ "example": false
+ },
+ "isTrial": {
+ "type": "boolean",
+ "description": "Indicates if the checkout activates a trial.",
+ "example": false
+ },
+ "created": {
+ "type": "integer",
+ "format": "unix-time",
+ "description": "Timestamp when checkout was created.",
+ "example": 1710500000
+ },
+ "planStarted": {
+ "type": "boolean",
+ "description": "Indicates if the plan has already been activated.",
+ "example": false
+ },
+ "newSubscriberMetadata": {
+ "type": "object",
+ "description": "Metadata for creating a new subscriber.",
+ "additionalProperties": true,
+ "example": { "welcomeTier": "starter" }
+ },
+ "refundAmount": {
+ "type": "string",
+ "nullable": true,
+ "description": "Refund amount applied during migration.",
+ "example": "3.50"
+ },
+ "creditedByInvoice": {
+ "type": "string",
+ "description": "Amount credited due to invoice settlement.",
+ "example": "12.00"
+ },
+ "onPayBehavior": {
+ "$ref": "#/components/schemas/OnPayBehavior",
+ "description": "Defines how to apply payment behavior."
+ },
+ "isExpired": {
+ "type": "boolean",
+ "description": "Indicates whether the checkout session has expired.",
+ "example": false
+ },
+ "url": {
+ "type": "string",
+ "description": "Public URL for accessing the checkout session.",
+ "example": "https://btcpay.example.com/plan-checkout/plancheckout_Hpm59L1NPCMUj477Q5"
+ },
+ "creditPurchase": {
+ "type": "string",
+ "nullable": true,
+ "description": "Credit amount to purchase to top-up the account.",
+ "example": "10.00"
+ }
+ }
+ },
+ "CreatePlanCheckoutRequest": {
+ "type": "object",
+ "description": "Request payload for initiating a plan checkout session.",
+ "properties": {
+ "storeId": {
+ "$ref": "#/components/schemas/StoreId"
+ },
+ "offeringId": {
+ "$ref": "#/components/schemas/OfferingId"
+ },
+ "planId": {
+ "$ref": "#/components/schemas/PlanId"
+ },
+ "customerSelector": {
+ "$ref": "#/components/schemas/CustomerSelector"
+ },
+ "durationMinutes": {
+ "type": "integer",
+ "nullable": true,
+ "description": "How long the checkout session is valid, in minutes.",
+ "example": 30
+ },
+ "onPayBehavior": {
+ "$ref": "#/components/schemas/OnPayBehavior",
+ "nullable": true,
+ "default": "SoftMigration"
+ },
+ "newSubscriberMetadata": {
+ "type": "object",
+ "description": "Metadata used when creating a new subscriber.",
+ "additionalProperties": true,
+ "example": { "locale": "en-US" }
+ },
+ "invoiceMetadata": {
+ "type": "object",
+ "description": "Metadata attached to the created invoice.",
+ "additionalProperties": true,
+ "example": { "campaign": "winter-sale" }
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the checkout session.",
+ "additionalProperties": true,
+ "example": { "flow": "upgrade" }
+ },
+ "isTrial": {
+ "type": "boolean",
+ "nullable": true,
+ "description": "Indicates if the checkout starts a trial.",
+ "example": false
+ },
+ "creditPurchase": {
+ "type": "string",
+ "nullable": true,
+ "description": "Amount of credit to purchase.",
+ "example": "20.00"
+ },
+ "successRedirectLink": {
+ "type": "string",
+ "description": "URL to redirect the user after checkout success. (This default to offering's successRedirectLink, also, the `checkoutPlanId` query parameter will be added to the URL.)",
+ "example": "https://example.com/thank-you"
+ },
+ "newSubscriberEmail": {
+ "type": "string",
+ "description": "Email address for creating a new subscriber. Keep `null` to let the user choose an email address in the checkout page.",
+ "nullable": true,
+ "example": "user@example.com"
+ }
+ }
+ },
+ "SuspendSubscriberRequest": {
+ "type": "object",
+ "description": "Request payload for suspending a subscriber.",
+ "properties": {
+ "reason": {
+ "type": "string",
+ "description": "Reason for the suspension.",
+ "example": "Suspicious behavior detected"
+ }
+ }
+ },
+ "CustomerSelector": {
+ "type": "string",
+ "description": "Flexible identifier for selecting a customer. Supports: customer ID (e.g., `cust_abc123`), an email (e.g., `user@example.com`), or a key/value identity (e.g., `Email:user@example.com`).",
+ "example": "cust_GUGnpx3311fkaqGk7f"
+ },
+ "PlanId": {
+ "type": "string",
+ "description": "Plan's ID",
+ "example": "plan_KZMVyuQp3v2vFWnEUM"
+ },
+ "OfferingId": {
+ "type": "string",
+ "description": "Offering's ID",
+ "example": "offering_DKWhZGB6PZsgTcPwpf"
+ },
+ "CreateOfferingModel": {
+ "type": "object",
+ "description": "New offering data to create.",
+ "required": [
+ "appName"
+ ],
+ "properties": {
+ "appName": {
+ "type": "string",
+ "nullable": true,
+ "description": "Display name of the related application.",
+ "example": "Example App"
+ },
+ "successRedirectUrl": {
+ "type": "string",
+ "nullable": true,
+ "description": "The default URL to redirect to after a plan checkout is successful.",
+ "example": "https://example.com/success"
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the offering.",
+ "additionalProperties": true,
+ "example": {
+ "category": "saas",
+ "region": "us"
+ }
+ },
+ "features": {
+ "type": "array",
+ "nullable": true,
+ "description": "List of features included in this offering.",
+ "items": {
+ "$ref": "#/components/schemas/FeatureModel"
+ }
+ }
+ }
+ },
+ "CustomerId": {
+ "type": "string",
+ "description": "Unique identifier of the customer.",
+ "example": "cust_GUGnpx3311fkaqGk7f"
+ },
+ "CustomerModel": {
+ "type": "object",
+ "description": "Represents a customer associated with a store.",
+ "properties": {
+ "storeId": {
+ "$ref": "#/components/schemas/StoreId"
+ },
+ "id": {
+ "$ref": "#/components/schemas/CustomerId"
+ },
+ "externalId": {
+ "type": "string",
+ "description": "External system identifier for the customer.",
+ "example": "ext_4455"
+ },
+ "identities": {
+ "type": "object",
+ "description": "Identity attributes for matching and lookup (e.g., email, username).",
+ "additionalProperties": true,
+ "example": {
+ "Email": "subscriber@example.com"
+ }
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata associated with the customer.",
+ "additionalProperties": true,
+ "example": {
+ "segment": "premium",
+ "locale": "en-US"
+ }
+ }
+ }
+ },
+ "SubscriberModel": {
+ "type": "object",
+ "description": "Represents a subscriber of an offering.",
+ "properties": {
+ "created": {
+ "type": "integer",
+ "format": "unix-time",
+ "description": "Timestamp when the subscription was created.",
+ "example": 1710598234
+ },
+ "customer": {
+ "$ref": "#/components/schemas/CustomerModel",
+ "description": "Customer associated with the subscription."
+ },
+ "offering": {
+ "$ref": "#/components/schemas/OfferingModel",
+ "description": "Offering associated with the subscription."
+ },
+ "plan": {
+ "$ref": "#/components/schemas/OfferingPlanModel",
+ "description": "Current active plan of the subscriber."
+ },
+ "periodEnd": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "End of the current billing period.",
+ "example": 1713200000
+ },
+ "trialEnd": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "End of the subscriber's trial period.",
+ "example": 1711000000
+ },
+ "gracePeriodEnd": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "End of the grace period.",
+ "example": 1711500000
+ },
+ "isActive": {
+ "type": "boolean",
+ "description": "Indicates if the subscription is active. (Phase is not `Expired`, and not suspended)",
+ "example": true
+ },
+ "isSuspended": {
+ "type": "boolean",
+ "description": "Indicates if the subscription is suspended.",
+ "example": false
+ },
+ "suspensionReason": {
+ "type": "string",
+ "nullable": true,
+ "description": "Reason for suspension, if applicable.",
+ "example": "Suspicious activity detected"
+ },
+ "autoRenew": {
+ "type": "boolean",
+ "description": "Indicates if the subscription renews automatically.",
+ "example": true
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the subscription.",
+ "additionalProperties": true,
+ "example": {
+ "segment": "beta"
+ }
+ },
+ "processingInvoiceId": {
+ "type": "string",
+ "nullable": true,
+ "description": "ID of the invoice being processed.",
+ "example": "inv_88443"
+ },
+ "nextPlan": {
+ "$ref": "#/components/schemas/OfferingPlanModel",
+ "nullable": true,
+ "description": "Plan scheduled for next billing cycle."
+ },
+ "phase": {
+ "type": "string",
+ "description": "Lifecycle phase of the subscription.",
+ "enum": [
+ "Trial",
+ "Normal",
+ "Grace",
+ "Expired"
+ ],
+ "example": "Normal"
+ }
+ }
+ },
+ "OfferingModel": {
+ "allOf": [
+ {
+ "type": "object",
+ "description": "Represents an offering available in a store.",
+ "required": [
+ "appName"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/OfferingId"
+ },
+ "storeId": {
+ "$ref": "#/components/schemas/StoreId"
+ },
+ "appId": {
+ "type": "string",
+ "description": "Identifier of the related application.",
+ "example": "app-001"
+ },
+ "plans": {
+ "type": "array",
+ "nullable": true,
+ "description": "List of plans available for this offering.",
+ "items": {
+ "$ref": "#/components/schemas/OfferingPlanModel"
+ }
+ }
+ }
+ },
+ {
+ "$ref": "#/components/schemas/CreateOfferingModel"
+ }
+ ]
+ },
+ "UpdateCreditRequest": {
+ "type": "object",
+ "description": "Request payload for updating subscriber credit.",
+ "properties": {
+ "credit": {
+ "type": "string",
+ "description": "Amount of credit to add as a numeric string.",
+ "example": "25.00"
+ },
+ "charge": {
+ "type": "string",
+ "description": "Amount to deduct as a numeric string.",
+ "example": "10.00"
+ },
+ "description": {
+ "type": "string",
+ "description": "Short description explaining the credit change.",
+ "example": "Monthly reward bonus"
+ },
+ "allowOverdraft": {
+ "type": "boolean",
+ "description": "Indicates if the credit balance is allowed to go negative.",
+ "example": false
+ }
+ }
+ },
+ "CreditModel": {
+ "type": "object",
+ "description": "Represents a subscriber's credit balance in a specific currency.",
+ "properties": {
+ "currency": {
+ "type": "string",
+ "description": "Currency code of the credit balance.",
+ "example": "USD"
+ },
+ "value": {
+ "type": "string",
+ "description": "Current credit value as a numeric string.",
+ "example": "150.00"
+ }
+ }
+ },
+ "CreatePlanRequest": {
+ "type": "object",
+ "description": "Request payload for creating a new offering plan.",
+ "properties": {
+ "description": {
+ "type": "string",
+ "description": "Short description of the plan.",
+ "example": "Standard monthly subscription."
+ },
+ "currency": {
+ "type": "string",
+ "description": "Currency code for the plan price.",
+ "example": "USD"
+ },
+ "gracePeriodDays": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Number of grace period days after expiry.",
+ "example": 7
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name of the plan.",
+ "example": "Monthly Plan"
+ },
+ "optimisticActivation": {
+ "type": "boolean",
+ "nullable": true,
+ "description": "Indicates if the plan is activated before payment confirmation.",
+ "example": true
+ },
+ "price": {
+ "type": "string",
+ "nullable": true,
+ "description": "Price of the plan as a numeric string.",
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "example": "19.99"
+ },
+ "renewable": {
+ "type": "boolean",
+ "nullable": true,
+ "description": "Indicates if the plan can be renewed.",
+ "example": true
+ },
+ "trialDays": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Number of trial days before billing.",
+ "example": 14
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the plan.",
+ "additionalProperties": true,
+ "example": {
+ "tier": "standard"
+ }
+ },
+ "recurringType": {
+ "type": "string",
+ "nullable": true,
+ "description": "Recurring interval for billing.",
+ "enum": [
+ "Monthly",
+ "Quarterly",
+ "Yearly",
+ "Lifetime"
+ ],
+ "example": "Monthly"
+ }
+ }
+ },
+ "CreatePortalSessionRequest": {
+ "type": "object",
+ "description": "Request payload for creating a subscriber portal session.",
+ "properties": {
+ "storeId": {
+ "$ref": "#/components/schemas/StoreId"
+ },
+ "offeringId": {
+ "$ref": "#/components/schemas/OfferingId"
+ },
+ "customerSelector": {
+ "$ref": "#/components/schemas/CustomerSelector"
+ },
+ "durationMinutes": {
+ "type": "integer",
+ "nullable": true,
+ "description": "Duration in minutes before the portal session expires.",
+ "example": 30
+ }
+ }
+ },
+ "PortalSessionModel": {
+ "type": "object",
+ "description": "Represents a subscriber portal session used for managing subscriptions, billing, and account details.",
+ "properties": {
+ "baseUrl": {
+ "$ref": "#/components/schemas/BaseUrl"
+ },
+ "id": {
+ "$ref": "#/components/schemas/PortalSessionId"
+ },
+ "subscriber": {
+ "$ref": "#/components/schemas/SubscriberModel",
+ "description": "The subscriber associated with this portal session."
+ },
+ "expiration": {
+ "type": "integer",
+ "format": "unix-time",
+ "nullable": true,
+ "description": "Expiration timestamp for the portal session.",
+ "example": 1710602000
+ },
+ "isExpired": {
+ "type": "boolean",
+ "description": "Indicates whether the portal session is expired.",
+ "example": false
+ },
+ "url": {
+ "type": "string",
+ "description": "Public URL where the subscriber can access the portal session.",
+ "example": "https://btcpay.example.com/subscriber-portal/ps_665afQ23ExGouiY4EZ"
+ }
+ }
+ },
+ "FeatureModel": {
+ "type": "object",
+ "description": "Represents a feature that can be included in an offering or plan.",
+ "required": [
+ "id",
+ "description"
+ ],
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier of the feature.",
+ "example": "feature-analytics"
+ },
+ "description": {
+ "type": "string",
+ "description": "Short description of the feature.",
+ "example": "Access to analytics dashboard."
+ }
+ }
+ },
+ "OfferingPlanModel": {
+ "type": "object",
+ "description": "Represents a pricing plan for an offering.",
+ "required": [
+ "id",
+ "name",
+ "status",
+ "price",
+ "currency",
+ "recurringType",
+ "gracePeriodDays",
+ "trialDays",
+ "description",
+ "memberCount",
+ "optimisticActivation",
+ "features",
+ "renewable"
+ ],
+ "properties": {
+ "id": {
+ "$ref": "#/components/schemas/PlanId"
+ },
+ "name": {
+ "type": "string",
+ "description": "Display name of the plan.",
+ "example": "Starter Plan"
+ },
+ "status": {
+ "type": "string",
+ "description": "Current status of the plan.",
+ "enum": [
+ "Active",
+ "Retired"
+ ],
+ "example": "Active"
+ },
+ "price": {
+ "type": "string",
+ "description": "Price of the plan as a numeric string.",
+ "pattern": "^[0-9]+(\\.[0-9]+)?$",
+ "example": "19.99"
+ },
+ "currency": {
+ "type": "string",
+ "description": "Currency code for the price.",
+ "example": "USD"
+ },
+ "recurringType": {
+ "type": "string",
+ "description": "Recurring interval for billing.",
+ "enum": [
+ "Monthly",
+ "Quarterly",
+ "Yearly",
+ "Lifetime"
+ ],
+ "example": "Monthly"
+ },
+ "gracePeriodDays": {
+ "type": "integer",
+ "description": "Number of grace period days after expiry.",
+ "example": 7
+ },
+ "trialDays": {
+ "type": "integer",
+ "description": "Number of trial days before billing.",
+ "example": 14
+ },
+ "description": {
+ "type": "string",
+ "description": "Short description of the plan.",
+ "example": "Standard monthly subscription."
+ },
+ "memberCount": {
+ "type": "integer",
+ "description": "Maximum number of members allowed under this plan.",
+ "example": 10
+ },
+ "optimisticActivation": {
+ "type": "boolean",
+ "description": "Indicates if the plan is activated before payment confirmation.",
+ "example": true
+ },
+ "features": {
+ "type": "array",
+ "description": "List of feature identifiers included in this plan.",
+ "items": {
+ "type": "string"
+ },
+ "example": [
+ "feature-analytics",
+ "feature-support"
+ ]
+ },
+ "renewable": {
+ "type": "boolean",
+ "description": "Indicates if the plan can be renewed.",
+ "example": true
+ },
+ "metadata": {
+ "type": "object",
+ "description": "Custom metadata for the plan.",
+ "additionalProperties": true,
+ "example": {
+ "tier": "standard"
+ }
+ }
+ }
+ }
+ }
+ },
+ "tags": [
+ {
+ "name": "Subscriptions",
+ "description": "Subscription operations"
+ }
+ ]
+}
+
diff --git a/btcpayserver.sln.DotSettings b/btcpayserver.sln.DotSettings
index 4dbc81f..e3065fa 100644
--- a/btcpayserver.sln.DotSettings
+++ b/btcpayserver.sln.DotSettings
@@ -1,4 +1,5 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
+ <s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=API/@EntryIndexedValue">API</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BIP/@EntryIndexedValue">BIP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=BTC/@EntryIndexedValue">BTC</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=CPFP/@EntryIndexedValue">CPFP</s:String>
Why this scored 37/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.