diff --git a/DysonNetwork.Sphere/Account/Account.cs b/DysonNetwork.Sphere/Account/Account.cs index b681496..3289f4d 100644 --- a/DysonNetwork.Sphere/Account/Account.cs +++ b/DysonNetwork.Sphere/Account/Account.cs @@ -3,6 +3,7 @@ using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Storage; +using DysonNetwork.Sphere.Wallet; using Microsoft.EntityFrameworkCore; using NodaTime; using OtpNet; @@ -22,13 +23,15 @@ public class Account : ModelBase public Profile Profile { get; set; } = null!; public ICollection Contacts { get; set; } = new List(); public ICollection Badges { get; set; } = new List(); - + [JsonIgnore] public ICollection AuthFactors { get; set; } = new List(); [JsonIgnore] public ICollection Sessions { get; set; } = new List(); [JsonIgnore] public ICollection Challenges { get; set; } = new List(); [JsonIgnore] public ICollection OutgoingRelationships { get; set; } = new List(); [JsonIgnore] public ICollection IncomingRelationships { get; set; } = new List(); + + [JsonIgnore] public ICollection Subscriptions { get; set; } = new List(); } public abstract class Leveling diff --git a/DysonNetwork.Sphere/AppDatabase.cs b/DysonNetwork.Sphere/AppDatabase.cs index 279a4fb..3ba4bde 100644 --- a/DysonNetwork.Sphere/AppDatabase.cs +++ b/DysonNetwork.Sphere/AppDatabase.cs @@ -1,6 +1,7 @@ using System.Linq.Expressions; using DysonNetwork.Sphere.Permission; using DysonNetwork.Sphere.Publisher; +using DysonNetwork.Sphere.Wallet; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; using Microsoft.EntityFrameworkCore.Query; @@ -81,6 +82,9 @@ public class AppDatabase( public DbSet CustomApps { get; set; } public DbSet CustomAppSecrets { get; set; } + public DbSet WalletSubscriptions { get; set; } + public DbSet WalletCoupons { get; set; } + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { optionsBuilder.UseNpgsql( diff --git a/DysonNetwork.Sphere/Storage/FileController.cs b/DysonNetwork.Sphere/Storage/FileController.cs index 7998845..58920a0 100644 --- a/DysonNetwork.Sphere/Storage/FileController.cs +++ b/DysonNetwork.Sphere/Storage/FileController.cs @@ -36,9 +36,7 @@ public class FileController( var fileName = string.IsNullOrWhiteSpace(file.StorageId) ? file.Id : file.StorageId; if (!original && file.HasCompression) - { fileName += ".compressed"; - } if (dest.ImageProxy is not null && (file.MimeType?.StartsWith("image/") ?? false)) { diff --git a/DysonNetwork.Sphere/Wallet/Subscription.cs b/DysonNetwork.Sphere/Wallet/Subscription.cs new file mode 100644 index 0000000..2e8eb80 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/Subscription.cs @@ -0,0 +1,206 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; +using NodaTime; + +namespace DysonNetwork.Sphere.Wallet; + +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, + Paid, + Expired, + Cancelled +} + +/// +/// The subscription is for the Stellar Program in most cases. +/// The paid subscription in another word. +/// +[Index(nameof(Identifier))] +public class Subscription : 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 PaymentDetails PaymentDetails { get; set; } = null!; + public decimal BasePrice { get; set; } + public Guid? CouponId { get; set; } + public Coupon? Coupon { get; set; } + public Instant? RenewalAt { get; set; } + + public Guid AccountId { get; set; } + public Account.Account Account { get; set; } = null!; + + [NotMapped] + public bool IsAvailable + { + get + { + if (!IsActive) return false; + + var now = SystemClock.Instance.GetCurrentInstant(); + + if (BegunAt > now) return false; + if (EndedAt.HasValue && now > EndedAt.Value) return false; + if (RenewalAt.HasValue && now > RenewalAt.Value) return false; + if (Status != SubscriptionStatus.Paid) return false; + + return true; + } + } + + [NotMapped] + public decimal FinalPrice + { + get + { + if (Coupon == null) return BasePrice; + + var now = SystemClock.Instance.GetCurrentInstant(); + if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value || + Coupon.ExpiredAt.HasValue && now > 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; + } + } + + public SubscriptionReferenceObject ToReference() + { + return new SubscriptionReferenceObject + { + Id = Id, + BegunAt = BegunAt, + EndedAt = EndedAt, + Identifier = Identifier, + IsActive = IsActive, + AccountId = AccountId, + CreatedAt = CreatedAt, + UpdatedAt = UpdatedAt, + DeletedAt = DeletedAt, + }; + } +} + +public class PaymentDetails +{ + public string Currency { get; set; } = null!; + public string? OrderId { get; set; } +} + +public class SubscriptionReferenceObject : ModelBase +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Instant BegunAt { get; set; } + public Instant? EndedAt { get; set; } + [MaxLength(4096)] public string Identifier { get; set; } = null!; + public bool IsActive { get; set; } = true; + public Guid AccountId { get; set; } +} + +/// +/// A discount that can applies in purchases among the Solar Network. +/// For now, it can be used in the subscription purchase. +/// +public class Coupon : 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; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Wallet/SubscriptionService.cs b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs new file mode 100644 index 0000000..c8ac033 --- /dev/null +++ b/DysonNetwork.Sphere/Wallet/SubscriptionService.cs @@ -0,0 +1,5 @@ +namespace DysonNetwork.Sphere.Wallet; + +public class SubscriptionService(AppDatabase db) +{ +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Wallet/WalletController.cs b/DysonNetwork.Sphere/Wallet/WalletController.cs index 0375b21..1e59d28 100644 --- a/DysonNetwork.Sphere/Wallet/WalletController.cs +++ b/DysonNetwork.Sphere/Wallet/WalletController.cs @@ -54,6 +54,7 @@ public class WalletController(AppDatabase db, WalletService ws) : ControllerBase var transactions = await query .Skip(offset) .Take(take) + .OrderByDescending(t => t.CreatedAt) .ToListAsync(); Response.Headers["X-Total"] = transactionCount.ToString(); diff --git a/README.md b/README.md index ac6803a..8995a45 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,12 @@ We open sourced it here to make everything transparent and open for everyone. But it is not designed for self-hosted due to multiple reasons. -Still, you can deploy it on your own infrastructure. -But we will not providing any support for that. +1. Branding everywhere: The variables, classes and functions name are just defined to serve the Solar Network. I think you're not hope to see the Solar Network related branding on your own server. +2. Hard coded URLs: Some services might use other Solsynth LLC's services which the URL is hard coded into the code and there is no alternative to it. Which means your server need stay connected with our services and the Internet. +3. No documentation: The documentation is not available for self-hosted or guide to deploy it on your local machine. +4. No support: We don't provide any support for self-hosted. + +Still, you can deploy it on your own infrastructure if you want, we didn't disallow it. Besides, according to the APGL v3 license, if you host a modified version of the software,