Files
btcpayserver/BTCPayServer.Data/Data/Subscriptions/SubscriberData.cs
2025-10-28 15:33:23 +09:00

262 lines
8.1 KiB
C#

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