using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; using System.Globalization; using Microsoft.EntityFrameworkCore; using NodaTime; using NodaTime.Serialization.Protobuf; namespace DysonNetwork.Shared.Models; public record class SubscriptionTypeData( string Identifier, string? GroupIdentifier, string Currency, decimal BasePrice, int? RequiredLevel = null ) { public static readonly Dictionary SubscriptionDict = new() { [SubscriptionType.Twinkle] = new SubscriptionTypeData( SubscriptionType.Twinkle, SubscriptionType.StellarProgram, WalletCurrency.SourcePoint, 0, 1 ), [SubscriptionType.Stellar] = new SubscriptionTypeData( SubscriptionType.Stellar, SubscriptionType.StellarProgram, WalletCurrency.SourcePoint, 1200, 20 ), [SubscriptionType.Nova] = new SubscriptionTypeData( SubscriptionType.Nova, SubscriptionType.StellarProgram, WalletCurrency.SourcePoint, 2400, 40 ), [SubscriptionType.Supernova] = new SubscriptionTypeData( SubscriptionType.Supernova, SubscriptionType.StellarProgram, WalletCurrency.SourcePoint, 3600, 60 ) }; public static readonly Dictionary SubscriptionHumanReadable = new() { [SubscriptionType.Twinkle] = "Stellar Program Twinkle", [SubscriptionType.Stellar] = "Stellar Program", [SubscriptionType.Nova] = "Stellar Program Nova", [SubscriptionType.Supernova] = "Stellar Program Supernova" }; } /// /// Represents a gifted subscription that can be claimed by another user. /// Support both direct gifts (to specific users) and open gifts (anyone can redeem via link/code). /// [Index(nameof(GiftCode))] [Index(nameof(GifterId))] [Index(nameof(RecipientId))] public class SnWalletGift : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); /// /// The user who purchased/gave the gift. /// public Guid GifterId { get; set; } public SnAccount Gifter { get; set; } = null!; /// /// The intended recipient. Null for open gifts that anyone can redeem. /// public Guid? RecipientId { get; set; } public SnAccount? Recipient { get; set; } /// /// Unique redemption code/link identifier for the gift. /// [MaxLength(128)] public string GiftCode { get; set; } = null!; /// /// Optional custom message from the gifter. /// [MaxLength(1000)] public string? Message { get; set; } /// /// The subscription type being gifted. /// [MaxLength(4096)] public string SubscriptionIdentifier { get; set; } = null!; /// /// The original price before any discounts. /// public decimal BasePrice { get; set; } /// /// The final price paid after discounts. /// public decimal FinalPrice { get; set; } /// /// Current status of the gift. /// public GiftStatus Status { get; set; } = GiftStatus.Created; /// /// When the gift was redeemed. Null if not yet redeemed. /// public Instant? RedeemedAt { get; set; } /// /// The user who redeemed the gift (if different from recipient). /// public Guid? RedeemerId { get; set; } public SnAccount? Redeemer { get; set; } /// /// The subscription created when the gift is redeemed. /// public SnWalletSubscription? Subscription { get; set; } /// /// When the gift expires and can no longer be redeemed. /// public Instant ExpiresAt { get; set; } /// /// Whether this gift can be redeemed by anyone (open gift) or only the specified recipient. /// public bool IsOpenGift { get; set; } /// /// Payment method used by the gifter. /// [MaxLength(4096)] public string PaymentMethod { get; set; } = null!; [Column(TypeName = "jsonb")] public SnPaymentDetails PaymentDetails { get; set; } = null!; /// /// Coupon used for the gift purchase. /// public Guid? CouponId { get; set; } public SnWalletCoupon? Coupon { get; set; } /// /// Checks if the gift can still be redeemed. /// [NotMapped] public bool IsRedeemable { get { if (Status != GiftStatus.Sent) return false; var now = SystemClock.Instance.GetCurrentInstant(); return now <= ExpiresAt; } } /// /// Checks if the gift has expired. /// [NotMapped] public bool IsExpired { get { if (Status == GiftStatus.Redeemed || Status == GiftStatus.Cancelled) return false; var now = SystemClock.Instance.GetCurrentInstant(); return now > ExpiresAt; } } // TODO: Uncomment once protobuf files are regenerated /* public Proto.Gift ToProtoValue() => new() { Id = Id.ToString(), GifterId = GifterId.ToString(), RecipientId = RecipientId?.ToString(), GiftCode = GiftCode, Message = Message, SubscriptionIdentifier = SubscriptionIdentifier, BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture), FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture), Status = (Proto.GiftStatus)Status, RedeemedAt = RedeemedAt?.ToTimestamp(), RedeemerId = RedeemerId?.ToString(), SubscriptionId = SubscriptionId?.ToString(), ExpiresAt = ExpiresAt.ToTimestamp(), IsOpenGift = IsOpenGift, PaymentMethod = PaymentMethod, PaymentDetails = PaymentDetails.ToProtoValue(), CouponId = CouponId?.ToString(), Coupon = Coupon?.ToProtoValue(), IsRedeemable = IsRedeemable, IsExpired = IsExpired, CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; public static SnWalletGift FromProtoValue(Proto.Gift proto) => new() { Id = Guid.Parse(proto.Id), GifterId = Guid.Parse(proto.GifterId), RecipientId = proto.HasRecipientId ? Guid.Parse(proto.RecipientId) : null, GiftCode = proto.GiftCode, Message = proto.Message, SubscriptionIdentifier = proto.SubscriptionIdentifier, BasePrice = decimal.Parse(proto.BasePrice), FinalPrice = decimal.Parse(proto.FinalPrice), Status = (GiftStatus)proto.Status, RedeemedAt = proto.RedeemedAt?.ToInstant(), RedeemerId = proto.HasRedeemerId ? Guid.Parse(proto.RedeemerId) : null, SubscriptionId = proto.HasSubscriptionId ? Guid.Parse(proto.SubscriptionId) : null, ExpiresAt = proto.ExpiresAt.ToInstant(), IsOpenGift = proto.IsOpenGift, PaymentMethod = proto.PaymentMethod, PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails), CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null, Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null, CreatedAt = proto.CreatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant() }; */ } public abstract class SubscriptionType { /// /// DO NOT USE THIS TYPE DIRECTLY, /// this is the prefix of all the stellar program subscriptions. /// public const string StellarProgram = "solian.stellar"; /// /// No actual usage, just tells there is a free level named twinkle. /// Applies to every registered user by default, so there is no need to create a record in db for that. /// public const string Twinkle = "solian.stellar.twinkle"; public const string Stellar = "solian.stellar.primary"; public const string Nova = "solian.stellar.nova"; public const string Supernova = "solian.stellar.supernova"; } public abstract class SubscriptionPaymentMethod { /// /// The solar points / solar dollars. /// public const string InAppWallet = "solian.wallet"; /// /// afdian.com /// aka. China patreon /// public const string Afdian = "afdian"; } public enum SubscriptionStatus { Unpaid, Active, Expired, Cancelled } public enum GiftStatus { Created = 0, Sent = 1, Redeemed = 2, Expired = 3, Cancelled = 4 } /// /// The subscription is for the Stellar Program in most cases. /// The paid subscription in another word. /// [Index(nameof(Identifier))] [Index(nameof(AccountId))] [Index(nameof(Status))] [Index(nameof(AccountId), nameof(Identifier))] [Index(nameof(AccountId), nameof(IsActive))] public class SnWalletSubscription : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); public Instant BegunAt { get; set; } public Instant? EndedAt { get; set; } /// /// The type of the subscriptions /// [MaxLength(4096)] public string Identifier { get; set; } = null!; /// /// The field is used to override the activation status of the membership. /// Might be used for refund handling and other special cases. /// /// Go see the IsAvailable field if you want to get real the status of the membership. /// public bool IsActive { get; set; } = true; /// /// Indicates is the current user got the membership for free, /// to prevent giving the same discount for the same user again. /// public bool IsFreeTrial { get; set; } public SubscriptionStatus Status { get; set; } = SubscriptionStatus.Unpaid; [MaxLength(4096)] public string PaymentMethod { get; set; } = null!; [Column(TypeName = "jsonb")] public SnPaymentDetails PaymentDetails { get; set; } = null!; public decimal BasePrice { get; set; } public Guid? CouponId { get; set; } public SnWalletCoupon? Coupon { get; set; } public Instant? RenewalAt { get; set; } public Guid AccountId { get; set; } public SnAccount Account { get; set; } = null!; /// /// If this subscription was redeemed from a gift, this references the gift record. /// public Guid? GiftId { get; set; } public SnWalletGift? Gift { get; set; } [NotMapped] public bool IsAvailable { get => IsAvailableAt(SystemClock.Instance.GetCurrentInstant()); } [NotMapped] public decimal FinalPrice { get => CalculateFinalPriceAt(SystemClock.Instance.GetCurrentInstant()); } /// /// Optimized method to check availability at a specific instant (avoids repeated SystemClock calls). /// public bool IsAvailableAt(Instant currentInstant) { if (!IsActive) return false; if (BegunAt > currentInstant) return false; if (EndedAt.HasValue && currentInstant > EndedAt.Value) return false; if (RenewalAt.HasValue && currentInstant > RenewalAt.Value) return false; if (Status != SubscriptionStatus.Active) return false; return true; } /// /// Optimized method to calculate final price at a specific instant (avoids repeated SystemClock calls). /// public decimal CalculateFinalPriceAt(Instant currentInstant) { if (IsFreeTrial) return 0; if (Coupon == null) return BasePrice; if (Coupon.AffectedAt.HasValue && currentInstant < Coupon.AffectedAt.Value || Coupon.ExpiredAt.HasValue && currentInstant > Coupon.ExpiredAt.Value) return BasePrice; if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value; if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value); return BasePrice; } /// /// Returns a reference object that contains a subset of subscription data /// suitable for client-side use, with sensitive information removed. /// public SnSubscriptionReferenceObject ToReference() { // Cache the current instant once to avoid multiple SystemClock calls var currentInstant = SystemClock.Instance.GetCurrentInstant(); return new SnSubscriptionReferenceObject { Id = Id, Identifier = Identifier, BegunAt = BegunAt, EndedAt = EndedAt, IsActive = IsActive, IsAvailable = IsAvailableAt(currentInstant), IsFreeTrial = IsFreeTrial, Status = Status, BasePrice = BasePrice, FinalPrice = CalculateFinalPriceAt(currentInstant), RenewalAt = RenewalAt, AccountId = AccountId }; } public Proto.Subscription ToProtoValue() => new() { Id = Id.ToString(), BegunAt = BegunAt.ToTimestamp(), EndedAt = EndedAt?.ToTimestamp(), Identifier = Identifier, IsActive = IsActive, IsFreeTrial = IsFreeTrial, Status = (Proto.SubscriptionStatus)Status, PaymentMethod = PaymentMethod, PaymentDetails = PaymentDetails.ToProtoValue(), BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture), CouponId = CouponId?.ToString(), Coupon = Coupon?.ToProtoValue(), RenewalAt = RenewalAt?.ToTimestamp(), AccountId = AccountId.ToString(), IsAvailable = IsAvailable, FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture), CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; public static SnWalletSubscription FromProtoValue(Proto.Subscription proto) => new() { Id = Guid.Parse(proto.Id), BegunAt = proto.BegunAt.ToInstant(), EndedAt = proto.EndedAt?.ToInstant(), Identifier = proto.Identifier, IsActive = proto.IsActive, IsFreeTrial = proto.IsFreeTrial, Status = (SubscriptionStatus)proto.Status, PaymentMethod = proto.PaymentMethod, PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails), BasePrice = decimal.Parse(proto.BasePrice), CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null, Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null, RenewalAt = proto.RenewalAt?.ToInstant(), AccountId = Guid.Parse(proto.AccountId), CreatedAt = proto.CreatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant() }; } /// /// A reference object for Subscription that contains only non-sensitive information /// suitable for client-side use. /// public class SnSubscriptionReferenceObject : ModelBase { public Guid Id { get; set; } public string Identifier { get; set; } = null!; public Instant BegunAt { get; set; } public Instant? EndedAt { get; set; } public bool IsActive { get; set; } public bool IsAvailable { get; set; } public bool IsFreeTrial { get; set; } public SubscriptionStatus Status { get; set; } public decimal BasePrice { get; set; } public decimal FinalPrice { get; set; } public Instant? RenewalAt { get; set; } public Guid AccountId { get; set; } private string? _displayName; /// /// Gets the human-readable name of the subscription type if available (cached for performance). /// [NotMapped] public string? DisplayName => _displayName ??= SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name) ? name : null; public Proto.SubscriptionReferenceObject ToProtoValue() => new() { Id = Id.ToString(), Identifier = Identifier, BegunAt = BegunAt.ToTimestamp(), EndedAt = EndedAt?.ToTimestamp(), IsActive = IsActive, IsAvailable = IsAvailable, IsFreeTrial = IsFreeTrial, Status = (Proto.SubscriptionStatus)Status, BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture), FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture), RenewalAt = RenewalAt?.ToTimestamp(), AccountId = AccountId.ToString(), DisplayName = DisplayName, CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; public static SnSubscriptionReferenceObject FromProtoValue(Proto.SubscriptionReferenceObject proto) => new() { Id = Guid.Parse(proto.Id), Identifier = proto.Identifier, BegunAt = proto.BegunAt.ToInstant(), EndedAt = proto.EndedAt?.ToInstant(), IsActive = proto.IsActive, IsAvailable = proto.IsAvailable, IsFreeTrial = proto.IsFreeTrial, Status = (SubscriptionStatus)proto.Status, BasePrice = decimal.Parse(proto.BasePrice), FinalPrice = decimal.Parse(proto.FinalPrice), RenewalAt = proto.RenewalAt?.ToInstant(), AccountId = Guid.Parse(proto.AccountId), CreatedAt = proto.CreatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant() }; } public class SnPaymentDetails { public string Currency { get; set; } = null!; public string? OrderId { get; set; } public Proto.PaymentDetails ToProtoValue() => new() { Currency = Currency, OrderId = OrderId, }; public static SnPaymentDetails FromProtoValue(Proto.PaymentDetails proto) => new() { Currency = proto.Currency, OrderId = proto.OrderId, }; } /// /// A discount that can applies in purchases among the Solar Network. /// For now, it can be used in the subscription purchase. /// public class SnWalletCoupon : ModelBase { public Guid Id { get; set; } = Guid.NewGuid(); /// /// The items that can apply this coupon. /// Leave it to null to apply to all items. /// [MaxLength(4096)] public string? Identifier { get; set; } /// /// The code that human-readable and memorizable. /// Leave it blank to use it only with the ID. /// [MaxLength(1024)] public string? Code { get; set; } public Instant? AffectedAt { get; set; } public Instant? ExpiredAt { get; set; } /// /// The amount of the discount. /// If this field and the rate field are both not null, /// the amount discount will be applied and the discount rate will be ignored. /// Formula: final price = base price - discount amount /// public decimal? DiscountAmount { get; set; } /// /// The percentage of the discount. /// If this field and the amount field are both not null, /// this field will be ignored. /// Formula: final price = base price * (1 - discount rate) /// public double? DiscountRate { get; set; } /// /// The max usage of the current coupon. /// Leave it to null to use it unlimited. /// public int? MaxUsage { get; set; } public Proto.Coupon ToProtoValue() => new() { Id = Id.ToString(), Identifier = Identifier, Code = Code, AffectedAt = AffectedAt?.ToTimestamp(), ExpiredAt = ExpiredAt?.ToTimestamp(), DiscountAmount = DiscountAmount?.ToString(), DiscountRate = DiscountRate, MaxUsage = MaxUsage, CreatedAt = CreatedAt.ToTimestamp(), UpdatedAt = UpdatedAt.ToTimestamp() }; public static SnWalletCoupon FromProtoValue(Proto.Coupon proto) => new() { Id = Guid.Parse(proto.Id), Identifier = proto.Identifier, Code = proto.Code, AffectedAt = proto.AffectedAt?.ToInstant(), ExpiredAt = proto.ExpiredAt?.ToInstant(), DiscountAmount = proto.HasDiscountAmount ? decimal.Parse(proto.DiscountAmount) : null, DiscountRate = proto.DiscountRate, MaxUsage = proto.MaxUsage, CreatedAt = proto.CreatedAt.ToInstant(), UpdatedAt = proto.UpdatedAt.ToInstant() }; }