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