mirror of
https://github.com/aljazceru/btcpayserver.git
synced 2026-02-12 01:34:34 +01:00
[Features] Subscriptions
This commit is contained in:
@@ -0,0 +1,261 @@
|
||||
#nullable enable
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using BTCPayServer.Data.Subscriptions;
|
||||
using Dapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Query;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace BTCPayServer.Data;
|
||||
|
||||
public static partial class ApplicationDbContextExtensions
|
||||
{
|
||||
public static async Task<PlanData?> GetPlanFromId(this DbSet<PlanData> plans, string planId, string? offeringId = null, string? storeId = null)
|
||||
{
|
||||
var plan = await plans
|
||||
.Include(o => o.Offering).ThenInclude(o => o.App).ThenInclude(o => o.StoreData)
|
||||
.Include(o => o.PlanChanges).ThenInclude(o => o.PlanChange)
|
||||
.Where(p => p.Id == planId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (offeringId is not null && plan?.OfferingId != offeringId)
|
||||
return null;
|
||||
if (storeId is not null && plan?.Offering.App.StoreDataId != storeId)
|
||||
return null;
|
||||
if (plan is not null)
|
||||
await FetchPlanEntitlementsAsync(plans, plan);
|
||||
return plan;
|
||||
}
|
||||
|
||||
public static async Task FetchPlanEntitlementsAsync<T>(this DbSet<T> ctx, IEnumerable<PlanData> plans) where T : class
|
||||
{
|
||||
var planIds = plans.Select(p => p.Id).Distinct().ToArray();
|
||||
var result = await ctx.GetDbConnection()
|
||||
.QueryAsync<(
|
||||
string Id,
|
||||
long[] EIds,
|
||||
string[] ECIds,
|
||||
string[] EDesc,
|
||||
string[] EName)>
|
||||
(
|
||||
"""
|
||||
SELECT pId,
|
||||
array_agg(spe.entitlement_id),
|
||||
array_agg(se.custom_id),
|
||||
array_agg(se.description)
|
||||
FROM unnest(@planIds) pId
|
||||
JOIN subs_plans_entitlements spe ON spe.plan_id = pId
|
||||
JOIN subs_entitlements se ON se.id = spe.entitlement_id
|
||||
GROUP BY 1
|
||||
""", new { planIds }
|
||||
);
|
||||
var res = result.ToDictionary(x => x.Id, x => x);
|
||||
foreach (var plan in plans)
|
||||
{
|
||||
if (plan.PlanEntitlements is not null)
|
||||
continue;
|
||||
plan.PlanEntitlements = new();
|
||||
if (res.TryGetValue(plan.Id, out var r))
|
||||
{
|
||||
for (int i = 0; i < r.ECIds.Length; i++)
|
||||
{
|
||||
var pe = new PlanEntitlementData();
|
||||
pe.Plan = plan;
|
||||
pe.PlanId = plan.Id;
|
||||
pe.EntitlementId = r.EIds[i];
|
||||
pe.Entitlement = new()
|
||||
{
|
||||
Id = r.EIds[i],
|
||||
CustomId = r.ECIds[i],
|
||||
Description = r.EDesc[i],
|
||||
};
|
||||
plan.PlanEntitlements.Add(pe);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public static Task FetchPlanEntitlementsAsync<T>(this DbSet<T> ctx, PlanData plan) where T : class
|
||||
=> FetchPlanEntitlementsAsync(ctx, new[] { plan });
|
||||
|
||||
|
||||
public static async Task<OfferingData?> GetOfferingData(this DbSet<OfferingData> offerings, string offeringId, string? storeId = null)
|
||||
{
|
||||
var offering = offerings
|
||||
.Include(o => o.Entitlements)
|
||||
.Include(o => o.Plans)
|
||||
.Include(o => o.App)
|
||||
.ThenInclude(o => o.StoreData);
|
||||
|
||||
var o = await offering
|
||||
.Where(o => o.Id == offeringId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (storeId != null && o?.App.StoreDataId != storeId)
|
||||
return null;
|
||||
return o;
|
||||
}
|
||||
|
||||
public static async Task<PlanCheckoutData?> GetCheckout(this DbSet<PlanCheckoutData> checkouts, string checkoutId)
|
||||
{
|
||||
var checkout = await checkouts
|
||||
.Include(x => x.Plan).ThenInclude(x => x.Offering).ThenInclude(x => x.App).ThenInclude(x => x.StoreData)
|
||||
.Include(x => x.Invoice)
|
||||
.Include(x => x.Subscriber).ThenInclude(x => x!.Customer).ThenInclude(x => x.CustomerIdentities)
|
||||
.Include(x => x.Subscriber).ThenInclude(x => x!.Credits)
|
||||
.Include(x => x.Subscriber).ThenInclude(x => x!.Plan)
|
||||
.Where(c => c.Id == checkoutId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (checkout is not null)
|
||||
await FetchPlanEntitlementsAsync(checkouts, checkout.Plan);
|
||||
return checkout;
|
||||
}
|
||||
|
||||
public static async Task<(SubscriberData?, bool Created)> GetOrCreateByCustomerId(this DbSet<SubscriberData> subs, string custId, string offeringId, string planId, bool? optimisticActivation, bool testAccount, JObject? newMemberMetadata = null)
|
||||
{
|
||||
var member = await subs.GetByCustomerId(custId, offeringId);
|
||||
if (member != null)
|
||||
return member.PlanId == planId ? (member, false) : (null, false);
|
||||
var membId = await subs.GetDbConnection().ExecuteScalarAsync<long?>
|
||||
("""
|
||||
INSERT INTO subs_subscribers (customer_id, offering_id, plan_id, optimistic_activation, plan_started, test_account, metadata) VALUES (@custId, @offeringId, @planId, @optimisticActivation, @now, @testAccount, @metadata::JSONB)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
""", new { custId, planId, offeringId, now = DateTimeOffset.UtcNow, optimisticActivation = optimisticActivation ?? false, testAccount, metadata = newMemberMetadata?.ToString() ?? "{}" });
|
||||
|
||||
member = membId is null ? null : await subs.GetById(membId.Value);
|
||||
return member?.PlanId == planId ? (member, true) : (null, false);
|
||||
}
|
||||
|
||||
public static Task<PortalSessionData?> GetActiveById(this IQueryable<PortalSessionData> sessions, string sessionId)
|
||||
=> sessions.IncludeAll()
|
||||
.Where(s => s.Id == sessionId && DateTimeOffset.UtcNow < s.Expiration).FirstOrDefaultAsync();
|
||||
public static Task<PortalSessionData?> GetById(this IQueryable<PortalSessionData> sessions, string sessionId)
|
||||
=> sessions.IncludeAll()
|
||||
.Where(s => s.Id == sessionId).FirstOrDefaultAsync();
|
||||
|
||||
public static IIncludableQueryable<PortalSessionData, StoreData> IncludeAll(this IQueryable<PortalSessionData> sessions)
|
||||
=> sessions
|
||||
.Include(s => s.Subscriber).ThenInclude(s => s.Customer).ThenInclude(s => s.CustomerIdentities)
|
||||
.Include(s => s.Subscriber).ThenInclude(s => s.Credits)
|
||||
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.PlanChanges).ThenInclude(s => s.PlanChange)
|
||||
.Include(s => s.Subscriber).ThenInclude(s => s.Plan).ThenInclude(s => s.Offering).ThenInclude(s => s.App).ThenInclude(s => s.StoreData);
|
||||
|
||||
public static async Task<SubscriberData?> GetByCustomerId(this DbSet<SubscriberData> dbSet, string custId, string offeringId, string? planId = null,
|
||||
string? storeId = null)
|
||||
{
|
||||
var result = await dbSet.IncludeAll()
|
||||
.Where(c => c.CustomerId == custId && c.OfferingId == offeringId).FirstOrDefaultAsync();
|
||||
|
||||
if ((result is null) ||
|
||||
(storeId != null && result.Plan?.Offering?.App?.StoreDataId != storeId) ||
|
||||
(planId != null && result.PlanId != planId))
|
||||
return null;
|
||||
await FetchPlanEntitlementsAsync(dbSet, result.Plan);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static IIncludableQueryable<SubscriberData, List<SubscriberCredit>> IncludeAll(this IQueryable<SubscriberData> subscribers)
|
||||
=> subscribers
|
||||
.Include(p => p.NewPlan)
|
||||
.Include(p => p.Plan).ThenInclude(p => p.Offering).ThenInclude(p => p.App)
|
||||
.Include(m => m.Customer).ThenInclude(c => c.CustomerIdentities)
|
||||
.Include(s => s.Credits);
|
||||
|
||||
public static async Task<SubscriberData?> GetById(this DbSet<SubscriberData> subscribers, long id)
|
||||
{
|
||||
var sub = await subscribers.IncludeAll().Where(s => s.Id == id).FirstOrDefaultAsync();
|
||||
if (sub != null)
|
||||
await FetchPlanEntitlementsAsync(subscribers, sub.Plan);
|
||||
return sub;
|
||||
}
|
||||
|
||||
public static async Task<CustomerData> GetOrUpdate(this DbSet<CustomerData> dbSet, string storeId, CustomerSelector selector)
|
||||
{
|
||||
var cust = await GetBySelector(dbSet, storeId, selector);
|
||||
if (cust != null)
|
||||
return cust;
|
||||
string? custId;
|
||||
if (selector is CustomerSelector.Id { CustomerId: {} id })
|
||||
{
|
||||
custId = await dbSet.GetDbConnection()
|
||||
.ExecuteScalarAsync<string>
|
||||
("""
|
||||
INSERT INTO customers (id, store_id) VALUES (@id, @storeId)
|
||||
ON CONFLICT DO NOTHING
|
||||
RETURNING id
|
||||
""", new { id, storeId });
|
||||
}
|
||||
else if (selector is CustomerSelector.ExternalRef { Ref: {} externalRef })
|
||||
{
|
||||
custId = await dbSet.GetDbConnection()
|
||||
.ExecuteScalarAsync<string>
|
||||
("""
|
||||
INSERT INTO customers (id, external_ref, store_id) VALUES (@id, @externalRef, @storeId)
|
||||
ON CONFLICT (store_id, external_ref) DO NOTHING
|
||||
RETURNING id
|
||||
""", new { id = CustomerData.GenerateId(), externalRef, storeId });
|
||||
}
|
||||
else if (selector is CustomerSelector.Identity { Type: { } type, Value: { } value })
|
||||
{
|
||||
custId = await dbSet.GetDbConnection()
|
||||
.ExecuteScalarAsync<string>
|
||||
("""
|
||||
WITH ins_cust AS (
|
||||
INSERT INTO customers (id, store_id) VALUES (@id, @storeId)
|
||||
RETURNING id),
|
||||
ins_identity AS (
|
||||
INSERT INTO customers_identities (customer_id, type, value)
|
||||
SELECT id, @type, @value
|
||||
FROM ins_cust
|
||||
RETURNING customer_id
|
||||
)
|
||||
SELECT customer_id FROM ins_identity;
|
||||
""", new { id = CustomerData.GenerateId(), storeId, type, value });
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new NotSupportedException(selector.ToString());
|
||||
}
|
||||
|
||||
return
|
||||
(custId is null ?
|
||||
await GetBySelector(dbSet, storeId, selector) :
|
||||
await GetById(dbSet, storeId, custId)) ?? throw new InvalidOperationException("Customer not found");
|
||||
}
|
||||
|
||||
private static Task<CustomerData?> GetById(this IQueryable<CustomerData> customers, string storeId, string custId)
|
||||
=> GetBySelector(customers, storeId, CustomerSelector.ById(custId));
|
||||
|
||||
public static async Task<SubscriberData?> GetBySelector(this DbSet<SubscriberData> subscribers, string offeringId, CustomerSelector selector)
|
||||
{
|
||||
var ctx = (ApplicationDbContext)subscribers.GetDbContext();
|
||||
var storeId = await ctx.Offerings
|
||||
.Where(o => o.Id == offeringId)
|
||||
.Select(o => o.App.StoreDataId)
|
||||
.FirstOrDefaultAsync();
|
||||
if (storeId is null)
|
||||
return null;
|
||||
|
||||
string? customerId = null;
|
||||
customerId = selector is CustomerSelector.Id { CustomerId: {} id } ? id
|
||||
: (await ctx.Customers.GetBySelector(storeId, selector))?.Id;
|
||||
if (customerId is null)
|
||||
return null;
|
||||
return await subscribers.Where(s => s.OfferingId == offeringId && s.CustomerId == customerId).FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
public static Task<CustomerData?> GetBySelector(this IQueryable<CustomerData> customers, string storeId, CustomerSelector selector)
|
||||
{
|
||||
customers = customers.Where(c => c.StoreId == storeId);
|
||||
return (selector switch
|
||||
{
|
||||
CustomerSelector.Id { CustomerId: {} id } => customers.Where(c => c.Id == id),
|
||||
CustomerSelector.ExternalRef { Ref: {} externalRef } => customers.Where(c => c.ExternalRef == externalRef),
|
||||
CustomerSelector.Identity { Type: { } type, Value: { } value } => customers.Where(c => c.CustomerIdentities.Any(cust => cust.Type == type && cust.Value == value)),
|
||||
_ => throw new NotSupportedException()
|
||||
}).FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user