♻️ Subscription overhaul

This commit is contained in:
2026-03-14 19:27:23 +08:00
parent 5b2a3021ac
commit 8bec801508
21 changed files with 2051 additions and 344 deletions

View File

@@ -45,7 +45,7 @@ public class QuotaService(
var basedQuota = 1L;
if (perkSubscription != null)
{
var privilege = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perkSubscription.Identifier);
var privilege = perkSubscription.PerkLevel;
basedQuota = privilege switch
{
1 => 5L,

View File

@@ -256,7 +256,7 @@ public class FileUploadController(
var privilege = currentUser.PerkSubscription is null
? 0
: PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier);
: currentUser.PerkSubscription.PerkLevel;
if (privilege < pool.PolicyConfig.RequirePrivilege)
{

View File

@@ -122,9 +122,7 @@ public class ThoughtController(
return BadRequest("Sorry, SN-chan currently does not support requests with files attached.");
if (serviceInfo.PerkLevel > 0 && !currentUser.IsSuperuser)
if (currentUser.PerkSubscription is null ||
PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(currentUser.PerkSubscription.Identifier) <
serviceInfo.PerkLevel)
if (currentUser.PerkLevel < serviceInfo.PerkLevel)
return StatusCode(403, "Not enough perk level");
var kernel = service.GetSnChanKernel();
@@ -669,4 +667,4 @@ public class ThoughtController(
}
}
#pragma warning restore SKEXP0050
#pragma warning restore SKEXP0050

View File

@@ -181,7 +181,7 @@ public class AccountServiceGrpc(
var perk = SnWalletSubscription.FromProtoValue(subscription).ToReference();
account.PerkSubscription = perk;
account.PerkLevel = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perk.Identifier);
account.PerkLevel = perk.PerkLevel;
}
catch (Exception ex)
{
@@ -209,7 +209,7 @@ public class AccountServiceGrpc(
if (subscriptionMap.TryGetValue(account.Id, out var perk))
{
account.PerkSubscription = perk;
account.PerkLevel = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perk.Identifier);
account.PerkLevel = perk.PerkLevel;
}
else
{

View File

@@ -486,7 +486,7 @@ public class AuthService(
var perk = SnWalletSubscription.FromProtoValue(subscription).ToReference();
account.PerkSubscription = perk;
account.PerkLevel = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(perk.Identifier);
account.PerkLevel = perk.PerkLevel;
}
catch (Exception ex)
{

View File

@@ -19,11 +19,11 @@ public class ExperienceService(AppDatabase db, RemoteSubscriptionService subscri
var perkSubscription = await subscriptions.GetPerkSubscription(accountId);
if (perkSubscription is not null)
{
record.BonusMultiplier = perkSubscription.Identifier switch
record.BonusMultiplier = perkSubscription.PerkLevel switch
{
SubscriptionType.Stellar => 1.5,
SubscriptionType.Nova => 2,
SubscriptionType.Supernova => 2.5,
1 => 1.5,
2 => 2,
3 => 2.5,
_ => 1
};
if (record.Delta >= 0)

View File

@@ -273,6 +273,11 @@ public abstract class SubscriptionPaymentMethod
/// paddle.com
/// </summary>
public const string Paddle = "paddle";
/// <summary>
/// Internal gift redemption marker.
/// </summary>
public const string Gift = "gift";
}
public enum SubscriptionStatus
@@ -313,6 +318,10 @@ public class SnWalletSubscription : ModelBase
[MaxLength(4096)]
public string Identifier { get; set; } = null!;
[MaxLength(4096)] public string? GroupIdentifier { get; set; }
[MaxLength(4096)] public string? DisplayName { get; set; }
public int PerkLevel { get; set; }
/// <summary>
/// The field is used to override the activation status of the membership.
/// Might be used for refund handling and other special cases.
@@ -398,6 +407,9 @@ public class SnWalletSubscription : ModelBase
{
Id = Id,
Identifier = Identifier,
GroupIdentifier = GroupIdentifier,
DisplayName = DisplayName,
PerkLevel = PerkLevel,
BegunAt = BegunAt,
EndedAt = EndedAt,
IsActive = IsActive,
@@ -419,6 +431,9 @@ public class SnWalletSubscription : ModelBase
BegunAt = BegunAt.ToTimestamp(),
EndedAt = EndedAt?.ToTimestamp(),
Identifier = Identifier,
GroupIdentifier = GroupIdentifier ?? string.Empty,
DisplayName = DisplayName ?? string.Empty,
PerkLevel = PerkLevel,
IsActive = IsActive,
IsFreeTrial = IsFreeTrial,
Status = (DySubscriptionStatus)Status,
@@ -452,6 +467,9 @@ public class SnWalletSubscription : ModelBase
BegunAt = proto.BegunAt.ToInstant(),
EndedAt = proto.EndedAt?.ToInstant(),
Identifier = proto.Identifier,
GroupIdentifier = string.IsNullOrWhiteSpace(proto.GroupIdentifier) ? null : proto.GroupIdentifier,
DisplayName = string.IsNullOrWhiteSpace(proto.DisplayName) ? null : proto.DisplayName,
PerkLevel = proto.PerkLevel,
IsActive = proto.IsActive,
IsFreeTrial = proto.IsFreeTrial,
Status = (SubscriptionStatus)proto.Status,
@@ -475,6 +493,9 @@ public class SnSubscriptionReferenceObject : ModelBase
{
public Guid Id { get; set; }
public string Identifier { get; set; } = null!;
public string? GroupIdentifier { get; set; }
public string? DisplayName { get; set; }
public int PerkLevel { get; set; }
public Instant BegunAt { get; set; }
public Instant? EndedAt { get; set; }
public bool IsActive { get; set; }
@@ -489,13 +510,13 @@ public class SnSubscriptionReferenceObject : ModelBase
/// <summary>
/// Gets the human-readable name of the subscription type if available (cached for performance).
/// </summary>
[NotMapped]
public string? DisplayName => field ??= SubscriptionTypeData.SubscriptionHumanReadable.GetValueOrDefault(Identifier);
public DySubscriptionReferenceObject ToProtoValue() => new()
{
Id = Id.ToString(),
Identifier = Identifier,
GroupIdentifier = GroupIdentifier ?? string.Empty,
DisplayName = DisplayName ?? string.Empty,
PerkLevel = PerkLevel,
BegunAt = BegunAt.ToTimestamp(),
EndedAt = EndedAt?.ToTimestamp(),
IsActive = IsActive,
@@ -506,7 +527,6 @@ public class SnSubscriptionReferenceObject : ModelBase
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
RenewalAt = RenewalAt?.ToTimestamp(),
AccountId = AccountId.ToString(),
DisplayName = DisplayName,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
@@ -515,6 +535,9 @@ public class SnSubscriptionReferenceObject : ModelBase
{
Id = Guid.Parse(proto.Id),
Identifier = proto.Identifier,
GroupIdentifier = string.IsNullOrWhiteSpace(proto.GroupIdentifier) ? null : proto.GroupIdentifier,
DisplayName = string.IsNullOrWhiteSpace(proto.DisplayName) ? null : proto.DisplayName,
PerkLevel = proto.PerkLevel,
BegunAt = proto.BegunAt.ToInstant(),
EndedAt = proto.EndedAt?.ToInstant(),
IsActive = proto.IsActive,

View File

@@ -0,0 +1,126 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Shared.Models;
[Index(nameof(Identifier), IsUnique = true)]
public class SnWalletSubscriptionDefinition : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[MaxLength(4096)] public string Identifier { get; set; } = null!;
[MaxLength(4096)] public string? GroupIdentifier { get; set; }
[MaxLength(4096)] public string DisplayName { get; set; } = null!;
[MaxLength(128)] public string Currency { get; set; } = null!;
public decimal BasePrice { get; set; }
public int PerkLevel { get; set; }
public int? MinimumAccountLevel { get; set; }
public decimal? ExperienceMultiplier { get; set; }
public int? GoldenPointReward { get; set; }
[Column(TypeName = "jsonb")] public SubscriptionPaymentPolicy PaymentPolicy { get; set; } = new();
[Column(TypeName = "jsonb")] public SubscriptionGiftPolicy? GiftPolicy { get; set; }
[Column(TypeName = "jsonb")] public Dictionary<string, List<string>> ProviderMappings { get; set; } = new();
public bool IsPaymentMethodAllowed(string paymentMethod)
{
if (string.IsNullOrWhiteSpace(paymentMethod)) return false;
var normalized = paymentMethod.Trim().ToLowerInvariant();
if (PaymentPolicy.AllowedMethods.Count > 0 &&
!PaymentPolicy.AllowedMethods.Any(m => string.Equals(m, normalized, StringComparison.OrdinalIgnoreCase)))
return false;
if (normalized == SubscriptionPaymentMethod.InAppWallet)
return PaymentPolicy.AllowInternalWallet;
if (normalized == SubscriptionPaymentMethod.Gift)
return PaymentPolicy.AllowedMethods.Count == 0 ||
PaymentPolicy.AllowedMethods.Any(m => string.Equals(m, normalized, StringComparison.OrdinalIgnoreCase));
return PaymentPolicy.AllowExternal;
}
}
public class SnWalletSubscriptionCatalogSettings : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
[Column(TypeName = "jsonb")] public SubscriptionGiftPolicy GiftPolicyDefaults { get; set; } = new();
}
public class SubscriptionPaymentPolicy
{
public bool AllowInternalWallet { get; set; } = true;
public bool AllowExternal { get; set; } = true;
public bool AllowInternalWalletRenewal { get; set; } = false;
public List<string> AllowedMethods { get; set; } = [];
}
public class SubscriptionGiftPolicy
{
public bool AllowPurchase { get; set; } = true;
public int? MinimumAccountLevel { get; set; }
public bool AllowPerkSubscriptionBypass { get; set; } = true;
public int? RollingPurchaseLimit { get; set; }
public int? RollingWindowDays { get; set; }
public int? GiftDurationDays { get; set; }
public int? SubscriptionDurationDays { get; set; }
public SubscriptionGiftPolicy Merge(SubscriptionGiftPolicy? overrides)
{
if (overrides is null) return this.Clone();
return new SubscriptionGiftPolicy
{
AllowPurchase = overrides.AllowPurchase,
MinimumAccountLevel = overrides.MinimumAccountLevel ?? MinimumAccountLevel,
AllowPerkSubscriptionBypass = overrides.AllowPerkSubscriptionBypass,
RollingPurchaseLimit = overrides.RollingPurchaseLimit ?? RollingPurchaseLimit,
RollingWindowDays = overrides.RollingWindowDays ?? RollingWindowDays,
GiftDurationDays = overrides.GiftDurationDays ?? GiftDurationDays,
SubscriptionDurationDays = overrides.SubscriptionDurationDays ?? SubscriptionDurationDays
};
}
}
public static class SubscriptionCatalogPolicyExtensions
{
public static SubscriptionGiftPolicy Clone(this SubscriptionGiftPolicy policy) => new()
{
AllowPurchase = policy.AllowPurchase,
MinimumAccountLevel = policy.MinimumAccountLevel,
AllowPerkSubscriptionBypass = policy.AllowPerkSubscriptionBypass,
RollingPurchaseLimit = policy.RollingPurchaseLimit,
RollingWindowDays = policy.RollingWindowDays,
GiftDurationDays = policy.GiftDurationDays,
SubscriptionDurationDays = policy.SubscriptionDurationDays
};
}
public class SubscriptionCatalogSeedOptions
{
public SubscriptionCatalogSeedSettings Settings { get; set; } = new();
public List<SubscriptionCatalogSeedDefinition> Definitions { get; set; } = [];
}
public class SubscriptionCatalogSeedSettings
{
public SubscriptionGiftPolicy GiftPolicyDefaults { get; set; } = new();
}
public class SubscriptionCatalogSeedDefinition
{
public string Identifier { get; set; } = null!;
public string? GroupIdentifier { get; set; }
public string DisplayName { get; set; } = null!;
public string Currency { get; set; } = null!;
public decimal BasePrice { get; set; }
public int PerkLevel { get; set; }
public int? MinimumAccountLevel { get; set; }
public decimal? ExperienceMultiplier { get; set; }
public int? GoldenPointReward { get; set; }
public SubscriptionPaymentPolicy PaymentPolicy { get; set; } = new();
public SubscriptionGiftPolicy? GiftPolicy { get; set; }
public Dictionary<string, List<string>> ProviderMappings { get; set; } = new();
}

View File

@@ -1880,6 +1880,9 @@ namespace DysonNetwork.Shared.Proto {
begunAt_ = other.begunAt_ != null ? other.begunAt_.Clone() : null;
endedAt_ = other.endedAt_ != null ? other.endedAt_.Clone() : null;
identifier_ = other.identifier_;
groupIdentifier_ = other.groupIdentifier_;
displayName_ = other.displayName_;
perkLevel_ = other.perkLevel_;
isActive_ = other.isActive_;
isFreeTrial_ = other.isFreeTrial_;
status_ = other.status_;
@@ -1951,6 +1954,42 @@ namespace DysonNetwork.Shared.Proto {
}
}
/// <summary>Field number for the "group_identifier" field.</summary>
public const int GroupIdentifierFieldNumber = 19;
private string groupIdentifier_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string GroupIdentifier {
get { return groupIdentifier_; }
set {
groupIdentifier_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "display_name" field.</summary>
public const int DisplayNameFieldNumber = 20;
private string displayName_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string DisplayName {
get { return displayName_; }
set {
displayName_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "perk_level" field.</summary>
public const int PerkLevelFieldNumber = 21;
private int perkLevel_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int PerkLevel {
get { return perkLevel_; }
set {
perkLevel_ = value;
}
}
/// <summary>Field number for the "is_active" field.</summary>
public const int IsActiveFieldNumber = 5;
private bool isActive_;
@@ -2152,6 +2191,9 @@ namespace DysonNetwork.Shared.Proto {
if (!object.Equals(BegunAt, other.BegunAt)) return false;
if (!object.Equals(EndedAt, other.EndedAt)) return false;
if (Identifier != other.Identifier) return false;
if (GroupIdentifier != other.GroupIdentifier) return false;
if (DisplayName != other.DisplayName) return false;
if (PerkLevel != other.PerkLevel) return false;
if (IsActive != other.IsActive) return false;
if (IsFreeTrial != other.IsFreeTrial) return false;
if (Status != other.Status) return false;
@@ -2177,6 +2219,9 @@ namespace DysonNetwork.Shared.Proto {
if (begunAt_ != null) hash ^= BegunAt.GetHashCode();
if (endedAt_ != null) hash ^= EndedAt.GetHashCode();
if (Identifier.Length != 0) hash ^= Identifier.GetHashCode();
if (GroupIdentifier.Length != 0) hash ^= GroupIdentifier.GetHashCode();
if (DisplayName.Length != 0) hash ^= DisplayName.GetHashCode();
if (PerkLevel != 0) hash ^= PerkLevel.GetHashCode();
if (IsActive != false) hash ^= IsActive.GetHashCode();
if (IsFreeTrial != false) hash ^= IsFreeTrial.GetHashCode();
if (Status != global::DysonNetwork.Shared.Proto.DySubscriptionStatus.Unspecified) hash ^= Status.GetHashCode();
@@ -2225,6 +2270,18 @@ namespace DysonNetwork.Shared.Proto {
output.WriteRawTag(34);
output.WriteString(Identifier);
}
if (GroupIdentifier.Length != 0) {
output.WriteRawTag(154, 1);
output.WriteString(GroupIdentifier);
}
if (DisplayName.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(DisplayName);
}
if (PerkLevel != 0) {
output.WriteRawTag(168, 1);
output.WriteInt32(PerkLevel);
}
if (IsActive != false) {
output.WriteRawTag(40);
output.WriteBool(IsActive);
@@ -2307,6 +2364,18 @@ namespace DysonNetwork.Shared.Proto {
output.WriteRawTag(34);
output.WriteString(Identifier);
}
if (GroupIdentifier.Length != 0) {
output.WriteRawTag(154, 1);
output.WriteString(GroupIdentifier);
}
if (DisplayName.Length != 0) {
output.WriteRawTag(162, 1);
output.WriteString(DisplayName);
}
if (PerkLevel != 0) {
output.WriteRawTag(168, 1);
output.WriteInt32(PerkLevel);
}
if (IsActive != false) {
output.WriteRawTag(40);
output.WriteBool(IsActive);
@@ -2385,6 +2454,15 @@ namespace DysonNetwork.Shared.Proto {
if (Identifier.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(Identifier);
}
if (GroupIdentifier.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(GroupIdentifier);
}
if (DisplayName.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(DisplayName);
}
if (PerkLevel != 0) {
size += 2 + pb::CodedOutputStream.ComputeInt32Size(PerkLevel);
}
if (IsActive != false) {
size += 1 + 1;
}
@@ -2457,6 +2535,15 @@ namespace DysonNetwork.Shared.Proto {
if (other.Identifier.Length != 0) {
Identifier = other.Identifier;
}
if (other.GroupIdentifier.Length != 0) {
GroupIdentifier = other.GroupIdentifier;
}
if (other.DisplayName.Length != 0) {
DisplayName = other.DisplayName;
}
if (other.PerkLevel != 0) {
PerkLevel = other.PerkLevel;
}
if (other.IsActive != false) {
IsActive = other.IsActive;
}
@@ -2555,6 +2642,18 @@ namespace DysonNetwork.Shared.Proto {
Identifier = input.ReadString();
break;
}
case 154: {
GroupIdentifier = input.ReadString();
break;
}
case 162: {
DisplayName = input.ReadString();
break;
}
case 168: {
PerkLevel = input.ReadInt32();
break;
}
case 40: {
IsActive = input.ReadBool();
break;
@@ -2667,6 +2766,18 @@ namespace DysonNetwork.Shared.Proto {
Identifier = input.ReadString();
break;
}
case 154: {
GroupIdentifier = input.ReadString();
break;
}
case 162: {
DisplayName = input.ReadString();
break;
}
case 168: {
PerkLevel = input.ReadInt32();
break;
}
case 40: {
IsActive = input.ReadBool();
break;
@@ -2782,6 +2893,8 @@ namespace DysonNetwork.Shared.Proto {
public DySubscriptionReferenceObject(DySubscriptionReferenceObject other) : this() {
id_ = other.id_;
identifier_ = other.identifier_;
groupIdentifier_ = other.groupIdentifier_;
perkLevel_ = other.perkLevel_;
begunAt_ = other.begunAt_ != null ? other.begunAt_.Clone() : null;
endedAt_ = other.endedAt_ != null ? other.endedAt_.Clone() : null;
isActive_ = other.isActive_;
@@ -2828,6 +2941,30 @@ namespace DysonNetwork.Shared.Proto {
}
}
/// <summary>Field number for the "group_identifier" field.</summary>
public const int GroupIdentifierFieldNumber = 16;
private string groupIdentifier_ = "";
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public string GroupIdentifier {
get { return groupIdentifier_; }
set {
groupIdentifier_ = pb::ProtoPreconditions.CheckNotNull(value, "value");
}
}
/// <summary>Field number for the "perk_level" field.</summary>
public const int PerkLevelFieldNumber = 17;
private int perkLevel_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public int PerkLevel {
get { return perkLevel_; }
set {
perkLevel_ = value;
}
}
/// <summary>Field number for the "begun_at" field.</summary>
public const int BegunAtFieldNumber = 3;
private global::Google.Protobuf.WellKnownTypes.Timestamp begunAt_;
@@ -3015,6 +3152,8 @@ namespace DysonNetwork.Shared.Proto {
}
if (Id != other.Id) return false;
if (Identifier != other.Identifier) return false;
if (GroupIdentifier != other.GroupIdentifier) return false;
if (PerkLevel != other.PerkLevel) return false;
if (!object.Equals(BegunAt, other.BegunAt)) return false;
if (!object.Equals(EndedAt, other.EndedAt)) return false;
if (IsActive != other.IsActive) return false;
@@ -3037,6 +3176,8 @@ namespace DysonNetwork.Shared.Proto {
int hash = 1;
if (Id.Length != 0) hash ^= Id.GetHashCode();
if (Identifier.Length != 0) hash ^= Identifier.GetHashCode();
if (GroupIdentifier.Length != 0) hash ^= GroupIdentifier.GetHashCode();
if (PerkLevel != 0) hash ^= PerkLevel.GetHashCode();
if (begunAt_ != null) hash ^= BegunAt.GetHashCode();
if (endedAt_ != null) hash ^= EndedAt.GetHashCode();
if (IsActive != false) hash ^= IsActive.GetHashCode();
@@ -3076,6 +3217,14 @@ namespace DysonNetwork.Shared.Proto {
output.WriteRawTag(18);
output.WriteString(Identifier);
}
if (GroupIdentifier.Length != 0) {
output.WriteRawTag(130, 1);
output.WriteString(GroupIdentifier);
}
if (PerkLevel != 0) {
output.WriteRawTag(136, 1);
output.WriteInt32(PerkLevel);
}
if (begunAt_ != null) {
output.WriteRawTag(26);
output.WriteMessage(BegunAt);
@@ -3146,6 +3295,14 @@ namespace DysonNetwork.Shared.Proto {
output.WriteRawTag(18);
output.WriteString(Identifier);
}
if (GroupIdentifier.Length != 0) {
output.WriteRawTag(130, 1);
output.WriteString(GroupIdentifier);
}
if (PerkLevel != 0) {
output.WriteRawTag(136, 1);
output.WriteInt32(PerkLevel);
}
if (begunAt_ != null) {
output.WriteRawTag(26);
output.WriteMessage(BegunAt);
@@ -3214,6 +3371,12 @@ namespace DysonNetwork.Shared.Proto {
if (Identifier.Length != 0) {
size += 1 + pb::CodedOutputStream.ComputeStringSize(Identifier);
}
if (GroupIdentifier.Length != 0) {
size += 2 + pb::CodedOutputStream.ComputeStringSize(GroupIdentifier);
}
if (PerkLevel != 0) {
size += 2 + pb::CodedOutputStream.ComputeInt32Size(PerkLevel);
}
if (begunAt_ != null) {
size += 1 + pb::CodedOutputStream.ComputeMessageSize(BegunAt);
}
@@ -3271,6 +3434,12 @@ namespace DysonNetwork.Shared.Proto {
if (other.Identifier.Length != 0) {
Identifier = other.Identifier;
}
if (other.GroupIdentifier.Length != 0) {
GroupIdentifier = other.GroupIdentifier;
}
if (other.PerkLevel != 0) {
PerkLevel = other.PerkLevel;
}
if (other.begunAt_ != null) {
if (begunAt_ == null) {
BegunAt = new global::Google.Protobuf.WellKnownTypes.Timestamp();
@@ -3352,6 +3521,14 @@ namespace DysonNetwork.Shared.Proto {
Identifier = input.ReadString();
break;
}
case 130: {
GroupIdentifier = input.ReadString();
break;
}
case 136: {
PerkLevel = input.ReadInt32();
break;
}
case 26: {
if (begunAt_ == null) {
BegunAt = new global::Google.Protobuf.WellKnownTypes.Timestamp();
@@ -3446,6 +3623,14 @@ namespace DysonNetwork.Shared.Proto {
Identifier = input.ReadString();
break;
}
case 130: {
GroupIdentifier = input.ReadString();
break;
}
case 136: {
PerkLevel = input.ReadInt32();
break;
}
case 26: {
if (begunAt_ == null) {
BegunAt = new global::Google.Protobuf.WellKnownTypes.Timestamp();

View File

@@ -22,6 +22,8 @@ public class AppDatabase(
public DbSet<SnWalletFund> WalletFunds { get; set; } = null!;
public DbSet<SnWalletFundRecipient> WalletFundRecipients { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletSubscriptionDefinition> WalletSubscriptionDefinitions { get; set; } = null!;
public DbSet<SnWalletSubscriptionCatalogSettings> WalletSubscriptionCatalogSettings { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;

View File

@@ -0,0 +1,956 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using DysonNetwork.Wallet;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DysonNetwork.Wallet.Migrations
{
[DbContext(typeof(AppDatabase))]
[Migration("20260314112018_AddSubscriptionCatalog")]
partial class AddSubscriptionCatalog
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.2")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLottery", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant?>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<int>("DrawStatus")
.HasColumnType("integer")
.HasColumnName("draw_status");
b.PrimitiveCollection<string>("MatchedRegionOneNumbers")
.HasColumnType("jsonb")
.HasColumnName("matched_region_one_numbers");
b.Property<int?>("MatchedRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("matched_region_two_number");
b.Property<int>("Multiplier")
.HasColumnType("integer")
.HasColumnName("multiplier");
b.PrimitiveCollection<string>("RegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("region_one_numbers");
b.Property<int>("RegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("region_two_number");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_lotteries");
b.ToTable("lotteries", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnLotteryRecord", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("DrawDate")
.HasColumnType("timestamp with time zone")
.HasColumnName("draw_date");
b.Property<long>("TotalPrizeAmount")
.HasColumnType("bigint")
.HasColumnName("total_prize_amount");
b.Property<int>("TotalPrizesAwarded")
.HasColumnType("integer")
.HasColumnName("total_prizes_awarded");
b.Property<int>("TotalTickets")
.HasColumnType("integer")
.HasColumnName("total_tickets");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.PrimitiveCollection<string>("WinningRegionOneNumbers")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("winning_region_one_numbers");
b.Property<int>("WinningRegionTwoNumber")
.HasColumnType("integer")
.HasColumnName("winning_region_two_number");
b.HasKey("Id")
.HasName("pk_lottery_records");
b.ToTable("lottery_records", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallets");
b.ToTable("wallets", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletCoupon", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant?>("AffectedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("affected_at");
b.Property<string>("Code")
.HasMaxLength(1024)
.HasColumnType("character varying(1024)")
.HasColumnName("code");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<decimal?>("DiscountAmount")
.HasColumnType("numeric")
.HasColumnName("discount_amount");
b.Property<double?>("DiscountRate")
.HasColumnType("double precision")
.HasColumnName("discount_rate");
b.Property<Instant?>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<string>("Identifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("identifier");
b.Property<int?>("MaxUsage")
.HasColumnType("integer")
.HasColumnName("max_usage");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_coupons");
b.ToTable("wallet_coupons", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<int>("AmountOfSplits")
.HasColumnType("integer")
.HasColumnName("amount_of_splits");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Guid>("CreatorAccountId")
.HasColumnType("uuid")
.HasColumnName("creator_account_id");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<bool>("IsOpen")
.HasColumnType("boolean")
.HasColumnName("is_open");
b.Property<string>("Message")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("message");
b.Property<decimal>("RemainingAmount")
.HasColumnType("numeric")
.HasColumnName("remaining_amount");
b.Property<int>("SplitType")
.HasColumnType("integer")
.HasColumnName("split_type");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<decimal>("TotalAmount")
.HasColumnType("numeric")
.HasColumnName("total_amount");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_funds");
b.ToTable("wallet_funds", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid>("FundId")
.HasColumnType("uuid")
.HasColumnName("fund_id");
b.Property<bool>("IsReceived")
.HasColumnType("boolean")
.HasColumnName("is_received");
b.Property<Instant?>("ReceivedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("received_at");
b.Property<Guid>("RecipientAccountId")
.HasColumnType("uuid")
.HasColumnName("recipient_account_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_fund_recipients");
b.HasIndex("FundId")
.HasDatabaseName("ix_wallet_fund_recipients_fund_id");
b.ToTable("wallet_fund_recipients", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("BasePrice")
.HasColumnType("numeric")
.HasColumnName("base_price");
b.Property<Guid?>("CouponId")
.HasColumnType("uuid")
.HasColumnName("coupon_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<decimal>("FinalPrice")
.HasColumnType("numeric")
.HasColumnName("final_price");
b.Property<string>("GiftCode")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("gift_code");
b.Property<Guid>("GifterId")
.HasColumnType("uuid")
.HasColumnName("gifter_id");
b.Property<bool>("IsOpenGift")
.HasColumnType("boolean")
.HasColumnName("is_open_gift");
b.Property<string>("Message")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)")
.HasColumnName("message");
b.Property<SnPaymentDetails>("PaymentDetails")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payment_details");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("payment_method");
b.Property<Guid?>("RecipientId")
.HasColumnType("uuid")
.HasColumnName("recipient_id");
b.Property<Instant?>("RedeemedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("redeemed_at");
b.Property<Guid?>("RedeemerId")
.HasColumnType("uuid")
.HasColumnName("redeemer_id");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Guid?>("SubscriptionId")
.HasColumnType("uuid")
.HasColumnName("subscription_id");
b.Property<string>("SubscriptionIdentifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("subscription_identifier");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_gifts");
b.HasIndex("CouponId")
.HasDatabaseName("ix_wallet_gifts_coupon_id");
b.HasIndex("GiftCode")
.HasDatabaseName("ix_wallet_gifts_gift_code");
b.HasIndex("GifterId")
.HasDatabaseName("ix_wallet_gifts_gifter_id");
b.HasIndex("RecipientId")
.HasDatabaseName("ix_wallet_gifts_recipient_id");
b.HasIndex("SubscriptionId")
.IsUnique()
.HasDatabaseName("ix_wallet_gifts_subscription_id");
b.ToTable("wallet_gifts", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<string>("AppIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("app_identifier");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("ExpiredAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expired_at");
b.Property<Dictionary<string, object>>("Meta")
.HasColumnType("jsonb")
.HasColumnName("meta");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid")
.HasColumnName("payee_wallet_id");
b.Property<string>("ProductIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("product_identifier");
b.Property<string>("Remarks")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("remarks");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Guid?>("TransactionId")
.HasColumnType("uuid")
.HasColumnName("transaction_id");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_payment_orders");
b.HasIndex("PayeeWalletId")
.HasDatabaseName("ix_payment_orders_payee_wallet_id");
b.HasIndex("TransactionId")
.HasDatabaseName("ix_payment_orders_transaction_id");
b.ToTable("payment_orders", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletPocket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.Property<Guid>("WalletId")
.HasColumnType("uuid")
.HasColumnName("wallet_id");
b.HasKey("Id")
.HasName("pk_wallet_pockets");
b.HasIndex("WalletId")
.HasDatabaseName("ix_wallet_pockets_wallet_id");
b.ToTable("wallet_pockets", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Guid>("AccountId")
.HasColumnType("uuid")
.HasColumnName("account_id");
b.Property<decimal>("BasePrice")
.HasColumnType("numeric")
.HasColumnName("base_price");
b.Property<Instant>("BegunAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("begun_at");
b.Property<Guid?>("CouponId")
.HasColumnType("uuid")
.HasColumnName("coupon_id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DisplayName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("display_name");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<string>("GroupIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("group_identifier");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("identifier");
b.Property<bool>("IsActive")
.HasColumnType("boolean")
.HasColumnName("is_active");
b.Property<bool>("IsFreeTrial")
.HasColumnType("boolean")
.HasColumnName("is_free_trial");
b.Property<SnPaymentDetails>("PaymentDetails")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payment_details");
b.Property<string>("PaymentMethod")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("payment_method");
b.Property<int>("PerkLevel")
.HasColumnType("integer")
.HasColumnName("perk_level");
b.Property<Instant?>("RenewalAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("renewal_at");
b.Property<int>("Status")
.HasColumnType("integer")
.HasColumnName("status");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_subscriptions");
b.HasIndex("AccountId")
.HasDatabaseName("ix_wallet_subscriptions_account_id");
b.HasIndex("CouponId")
.HasDatabaseName("ix_wallet_subscriptions_coupon_id");
b.HasIndex("Identifier")
.HasDatabaseName("ix_wallet_subscriptions_identifier");
b.HasIndex("Status")
.HasDatabaseName("ix_wallet_subscriptions_status");
b.HasIndex("AccountId", "Identifier")
.HasDatabaseName("ix_wallet_subscriptions_account_id_identifier");
b.HasIndex("AccountId", "IsActive")
.HasDatabaseName("ix_wallet_subscriptions_account_id_is_active");
b.ToTable("wallet_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscriptionCatalogSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<SubscriptionGiftPolicy>("GiftPolicyDefaults")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("gift_policy_defaults");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_subscription_catalog_settings");
b.ToTable("wallet_subscription_catalog_settings", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscriptionDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("BasePrice")
.HasColumnType("numeric")
.HasColumnName("base_price");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("display_name");
b.Property<decimal?>("ExperienceMultiplier")
.HasColumnType("numeric")
.HasColumnName("experience_multiplier");
b.Property<SubscriptionGiftPolicy>("GiftPolicy")
.HasColumnType("jsonb")
.HasColumnName("gift_policy");
b.Property<int?>("GoldenPointReward")
.HasColumnType("integer")
.HasColumnName("golden_point_reward");
b.Property<string>("GroupIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("group_identifier");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("identifier");
b.Property<int?>("MinimumAccountLevel")
.HasColumnType("integer")
.HasColumnName("minimum_account_level");
b.Property<SubscriptionPaymentPolicy>("PaymentPolicy")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payment_policy");
b.Property<int>("PerkLevel")
.HasColumnType("integer")
.HasColumnName("perk_level");
b.Property<Dictionary<string, List<string>>>("ProviderMappings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("provider_mappings");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_subscription_definitions");
b.HasIndex("Identifier")
.IsUnique()
.HasDatabaseName("ix_wallet_subscription_definitions_identifier");
b.ToTable("wallet_subscription_definitions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("Amount")
.HasColumnType("numeric")
.HasColumnName("amount");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<Guid?>("PayeeWalletId")
.HasColumnType("uuid")
.HasColumnName("payee_wallet_id");
b.Property<Guid?>("PayerWalletId")
.HasColumnType("uuid")
.HasColumnName("payer_wallet_id");
b.Property<string>("Remarks")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("remarks");
b.Property<int>("Type")
.HasColumnType("integer")
.HasColumnName("type");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_payment_transactions");
b.HasIndex("PayeeWalletId")
.HasDatabaseName("ix_payment_transactions_payee_wallet_id");
b.HasIndex("PayerWalletId")
.HasDatabaseName("ix_payment_transactions_payer_wallet_id");
b.ToTable("payment_transactions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFundRecipient", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletFund", "Fund")
.WithMany("Recipients")
.HasForeignKey("FundId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_fund_recipients_wallet_funds_fund_id");
b.Navigation("Fund");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletGift", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
.WithMany()
.HasForeignKey("CouponId")
.HasConstraintName("fk_wallet_gifts_wallet_coupons_coupon_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletSubscription", "Subscription")
.WithOne("Gift")
.HasForeignKey("DysonNetwork.Shared.Models.SnWalletGift", "SubscriptionId")
.HasConstraintName("fk_wallet_gifts_wallet_subscriptions_subscription_id");
b.Navigation("Coupon");
b.Navigation("Subscription");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletOrder", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWallet", "PayeeWallet")
.WithMany()
.HasForeignKey("PayeeWalletId")
.HasConstraintName("fk_payment_orders_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Shared.Models.SnWalletTransaction", "Transaction")
.WithMany()
.HasForeignKey("TransactionId")
.HasConstraintName("fk_payment_orders_payment_transactions_transaction_id");
b.Navigation("PayeeWallet");
b.Navigation("Transaction");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletPocket", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWallet", "Wallet")
.WithMany("Pockets")
.HasForeignKey("WalletId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("fk_wallet_pockets_wallets_wallet_id");
b.Navigation("Wallet");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWalletCoupon", "Coupon")
.WithMany()
.HasForeignKey("CouponId")
.HasConstraintName("fk_wallet_subscriptions_wallet_coupons_coupon_id");
b.Navigation("Coupon");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
{
b.HasOne("DysonNetwork.Shared.Models.SnWallet", "PayeeWallet")
.WithMany()
.HasForeignKey("PayeeWalletId")
.HasConstraintName("fk_payment_transactions_wallets_payee_wallet_id");
b.HasOne("DysonNetwork.Shared.Models.SnWallet", "PayerWallet")
.WithMany()
.HasForeignKey("PayerWalletId")
.HasConstraintName("fk_payment_transactions_wallets_payer_wallet_id");
b.Navigation("PayeeWallet");
b.Navigation("PayerWallet");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWallet", b =>
{
b.Navigation("Pockets");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletFund", b =>
{
b.Navigation("Recipients");
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscription", b =>
{
b.Navigation("Gift");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,108 @@
using System;
using System.Collections.Generic;
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace DysonNetwork.Wallet.Migrations
{
/// <inheritdoc />
public partial class AddSubscriptionCatalog : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "display_name",
table: "wallet_subscriptions",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "group_identifier",
table: "wallet_subscriptions",
type: "character varying(4096)",
maxLength: 4096,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "perk_level",
table: "wallet_subscriptions",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "wallet_subscription_catalog_settings",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
gift_policy_defaults = table.Column<SubscriptionGiftPolicy>(type: "jsonb", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_subscription_catalog_settings", x => x.id);
});
migrationBuilder.CreateTable(
name: "wallet_subscription_definitions",
columns: table => new
{
id = table.Column<Guid>(type: "uuid", nullable: false),
identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
group_identifier = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: true),
display_name = table.Column<string>(type: "character varying(4096)", maxLength: 4096, nullable: false),
currency = table.Column<string>(type: "character varying(128)", maxLength: 128, nullable: false),
base_price = table.Column<decimal>(type: "numeric", nullable: false),
perk_level = table.Column<int>(type: "integer", nullable: false),
minimum_account_level = table.Column<int>(type: "integer", nullable: true),
experience_multiplier = table.Column<decimal>(type: "numeric", nullable: true),
golden_point_reward = table.Column<int>(type: "integer", nullable: true),
payment_policy = table.Column<SubscriptionPaymentPolicy>(type: "jsonb", nullable: false),
gift_policy = table.Column<SubscriptionGiftPolicy>(type: "jsonb", nullable: true),
provider_mappings = table.Column<Dictionary<string, List<string>>>(type: "jsonb", nullable: false),
created_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
updated_at = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
deleted_at = table.Column<Instant>(type: "timestamp with time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("pk_wallet_subscription_definitions", x => x.id);
});
migrationBuilder.CreateIndex(
name: "ix_wallet_subscription_definitions_identifier",
table: "wallet_subscription_definitions",
column: "identifier",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "wallet_subscription_catalog_settings");
migrationBuilder.DropTable(
name: "wallet_subscription_definitions");
migrationBuilder.DropColumn(
name: "display_name",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "group_identifier",
table: "wallet_subscriptions");
migrationBuilder.DropColumn(
name: "perk_level",
table: "wallet_subscriptions");
}
}
}

View File

@@ -595,10 +595,20 @@ namespace DysonNetwork.Wallet.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DisplayName")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("display_name");
b.Property<Instant?>("EndedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("ended_at");
b.Property<string>("GroupIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("group_identifier");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(4096)
@@ -624,6 +634,10 @@ namespace DysonNetwork.Wallet.Migrations
.HasColumnType("character varying(4096)")
.HasColumnName("payment_method");
b.Property<int>("PerkLevel")
.HasColumnType("integer")
.HasColumnName("perk_level");
b.Property<Instant?>("RenewalAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("renewal_at");
@@ -660,6 +674,122 @@ namespace DysonNetwork.Wallet.Migrations
b.ToTable("wallet_subscriptions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscriptionCatalogSettings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<SubscriptionGiftPolicy>("GiftPolicyDefaults")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("gift_policy_defaults");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_subscription_catalog_settings");
b.ToTable("wallet_subscription_catalog_settings", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletSubscriptionDefinition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid")
.HasColumnName("id");
b.Property<decimal>("BasePrice")
.HasColumnType("numeric")
.HasColumnName("base_price");
b.Property<Instant>("CreatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("created_at");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)")
.HasColumnName("currency");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("display_name");
b.Property<decimal?>("ExperienceMultiplier")
.HasColumnType("numeric")
.HasColumnName("experience_multiplier");
b.Property<SubscriptionGiftPolicy>("GiftPolicy")
.HasColumnType("jsonb")
.HasColumnName("gift_policy");
b.Property<int?>("GoldenPointReward")
.HasColumnType("integer")
.HasColumnName("golden_point_reward");
b.Property<string>("GroupIdentifier")
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("group_identifier");
b.Property<string>("Identifier")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("character varying(4096)")
.HasColumnName("identifier");
b.Property<int?>("MinimumAccountLevel")
.HasColumnType("integer")
.HasColumnName("minimum_account_level");
b.Property<SubscriptionPaymentPolicy>("PaymentPolicy")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("payment_policy");
b.Property<int>("PerkLevel")
.HasColumnType("integer")
.HasColumnName("perk_level");
b.Property<Dictionary<string, List<string>>>("ProviderMappings")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("provider_mappings");
b.Property<Instant>("UpdatedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("updated_at");
b.HasKey("Id")
.HasName("pk_wallet_subscription_definitions");
b.HasIndex("Identifier")
.IsUnique()
.HasDatabaseName("ix_wallet_subscription_definitions_identifier");
b.ToTable("wallet_subscription_definitions", (string)null);
});
modelBuilder.Entity("DysonNetwork.Shared.Models.SnWalletTransaction", b =>
{
b.Property<Guid>("Id")

View File

@@ -0,0 +1,118 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
namespace DysonNetwork.Wallet.Payment;
public class SubscriptionCatalogService(
AppDatabase db,
IConfiguration configuration,
ILogger<SubscriptionCatalogService> logger
)
{
private readonly AppDatabase _db = db;
private readonly IConfiguration _configuration = configuration;
private readonly ILogger<SubscriptionCatalogService> _logger = logger;
public async Task EnsureSeededAsync(CancellationToken cancellationToken = default)
{
var options = _configuration.GetSection("Payment:SubscriptionCatalog").Get<SubscriptionCatalogSeedOptions>();
if (options is null) return;
var existingSettings = await _db.WalletSubscriptionCatalogSettings.FirstOrDefaultAsync(cancellationToken);
if (existingSettings is null)
{
_db.WalletSubscriptionCatalogSettings.Add(new SnWalletSubscriptionCatalogSettings
{
GiftPolicyDefaults = options.Settings.GiftPolicyDefaults.Clone()
});
}
var identifiers = options.Definitions.Select(x => x.Identifier).ToList();
var existingDefinitions = await _db.WalletSubscriptionDefinitions
.Where(x => identifiers.Contains(x.Identifier))
.Select(x => x.Identifier)
.ToListAsync(cancellationToken);
foreach (var definition in options.Definitions.Where(x => !existingDefinitions.Contains(x.Identifier)))
{
_db.WalletSubscriptionDefinitions.Add(new SnWalletSubscriptionDefinition
{
Identifier = definition.Identifier,
GroupIdentifier = definition.GroupIdentifier,
DisplayName = definition.DisplayName,
Currency = definition.Currency,
BasePrice = definition.BasePrice,
PerkLevel = definition.PerkLevel,
MinimumAccountLevel = definition.MinimumAccountLevel,
ExperienceMultiplier = definition.ExperienceMultiplier,
GoldenPointReward = definition.GoldenPointReward,
PaymentPolicy = new SubscriptionPaymentPolicy
{
AllowInternalWallet = definition.PaymentPolicy.AllowInternalWallet,
AllowExternal = definition.PaymentPolicy.AllowExternal,
AllowInternalWalletRenewal = definition.PaymentPolicy.AllowInternalWalletRenewal,
AllowedMethods = definition.PaymentPolicy.AllowedMethods.ToList()
},
GiftPolicy = definition.GiftPolicy is null ? null : new SubscriptionGiftPolicy
{
AllowPurchase = definition.GiftPolicy.AllowPurchase,
MinimumAccountLevel = definition.GiftPolicy.MinimumAccountLevel,
AllowPerkSubscriptionBypass = definition.GiftPolicy.AllowPerkSubscriptionBypass,
RollingPurchaseLimit = definition.GiftPolicy.RollingPurchaseLimit,
RollingWindowDays = definition.GiftPolicy.RollingWindowDays,
GiftDurationDays = definition.GiftPolicy.GiftDurationDays,
SubscriptionDurationDays = definition.GiftPolicy.SubscriptionDurationDays
},
ProviderMappings = definition.ProviderMappings.ToDictionary(
kv => kv.Key,
kv => kv.Value.ToList(),
StringComparer.OrdinalIgnoreCase
)
});
}
if (_db.ChangeTracker.HasChanges())
{
await _db.SaveChangesAsync(cancellationToken);
_logger.LogInformation("Seeded subscription catalog definitions from configuration.");
}
}
public Task<SnWalletSubscriptionDefinition?> GetDefinitionAsync(string identifier, CancellationToken cancellationToken = default)
{
return _db.WalletSubscriptionDefinitions.FirstOrDefaultAsync(x => x.Identifier == identifier, cancellationToken);
}
public async Task<SnWalletSubscriptionDefinition?> ResolveDefinitionAsync(
string provider,
string externalId,
CancellationToken cancellationToken = default
)
{
var definitions = await _db.WalletSubscriptionDefinitions.ToListAsync(cancellationToken);
return definitions.FirstOrDefault(def =>
def.ProviderMappings.TryGetValue(provider, out var mapped) &&
mapped.Any(id => string.Equals(id, externalId, StringComparison.OrdinalIgnoreCase)));
}
public async Task<List<string>> GetGroupIdentifiersAsync(string? groupIdentifier, string fallbackIdentifier, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(groupIdentifier))
return [fallbackIdentifier];
return await _db.WalletSubscriptionDefinitions
.Where(x => x.GroupIdentifier == groupIdentifier)
.Select(x => x.Identifier)
.ToListAsync(cancellationToken);
}
public async Task<SubscriptionGiftPolicy> GetGiftPolicyAsync(
SnWalletSubscriptionDefinition definition,
CancellationToken cancellationToken = default
)
{
var settings = await _db.WalletSubscriptionCatalogSettings.FirstOrDefaultAsync(cancellationToken);
var defaults = settings?.GiftPolicyDefaults.Clone() ?? new SubscriptionGiftPolicy();
return defaults.Merge(definition.GiftPolicy);
}
}

View File

@@ -124,32 +124,7 @@ public class SubscriptionGiftController(
}
else
{
// Check if user already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo != null)
{
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription =
await subscriptions.GetSubscriptionAsync(Guid.Parse(currentUser.Id), subscriptionsInGroup);
if (existingSubscription is not null)
{
error = "You already have an active subscription of this type.";
}
else
{
canRedeem = true;
}
}
canRedeem = true;
}
return new GiftCheckResponse
@@ -183,8 +158,6 @@ public class SubscriptionGiftController(
public int? SubscriptionDurationDays { get; set; } = 30; // Subscription lasts 30 days when redeemed
}
const int MinimumAccountLevel = 60;
/// <summary>
/// Purchases a gift subscription.
/// </summary>
@@ -194,12 +167,6 @@ public class SubscriptionGiftController(
{
if (HttpContext.Items["CurrentUser"] is not DyAccount currentUser) return Unauthorized();
if (currentUser.Profile.Level < MinimumAccountLevel)
{
if (currentUser.PerkSubscription is null)
return StatusCode(403, "Account level must be at least 60 or a member of the Stellar Program to purchase a gift.");
}
Duration? giftDuration = null;
if (request.GiftDurationDays.HasValue)
giftDuration = Duration.FromDays(request.GiftDurationDays.Value);

View File

@@ -7,6 +7,7 @@ namespace DysonNetwork.Wallet.Payment;
public class SubscriptionRenewalJob(
AppDatabase db,
SubscriptionCatalogService catalog,
SubscriptionService subscriptionService,
PaymentService paymentService,
WalletService walletService,
@@ -26,6 +27,23 @@ public class SubscriptionRenewalJob(
var processedCount = 0;
var renewedCount = 0;
var failedCount = 0;
var activatedCount = 0;
var queuedSubscriptions = await db.WalletSubscriptions
.Where(s => s.IsActive)
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => s.BegunAt <= now)
.Where(s => s.EndedAt == null || s.EndedAt > now)
.Where(s => s.RenewalAt == null || s.RenewalAt > now)
.Where(s => s.CreatedAt != s.UpdatedAt)
.Take(batchSize)
.ToListAsync();
foreach (var subscription in queuedSubscriptions.Where(s => s.BegunAt > s.CreatedAt))
{
activatedCount++;
await subscriptionService.UpdateExpiredSubscriptionsAsync();
}
// Find subscriptions that need renewal (due for renewal and are still active)
var subscriptionsToRenew = await db.WalletSubscriptions
@@ -33,6 +51,7 @@ public class SubscriptionRenewalJob(
.Where(s => s.Status == SubscriptionStatus.Active) // Only paid subscriptions
.Where(s => s.IsActive) // Only active subscriptions
.Where(s => !s.IsFreeTrial) // Exclude free trials
.Where(s => s.BegunAt <= now)
.OrderBy(s => s.RenewalAt) // Process oldest first
.Take(batchSize)
.Include(s => s.Coupon) // Include coupon information
@@ -50,6 +69,14 @@ public class SubscriptionRenewalJob(
"Processing renewal for subscription {SubscriptionId} (Identifier: {Identifier}) for account {AccountId}",
subscription.Id, subscription.Identifier, subscription.AccountId);
var definition = await catalog.GetDefinitionAsync(subscription.Identifier, context.CancellationToken);
if (definition is null)
{
logger.LogWarning("Subscription definition missing for {Identifier}", subscription.Identifier);
failedCount++;
continue;
}
if (subscription.RenewalAt is null)
{
logger.LogWarning(
@@ -64,26 +91,25 @@ public class SubscriptionRenewalJob(
// Calculate next cycle duration based on current cycle
var currentCycle = subscription.EndedAt!.Value - subscription.BegunAt;
// Create an order for the renewal payment
var order = await paymentService.CreateOrderAsync(
null,
WalletCurrency.GoldenPoint,
subscription.FinalPrice,
appIdentifier: "internal",
productIdentifier: subscription.Identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
["subscription_identifier"] = subscription.Identifier,
["is_renewal"] = true
}
);
// Try to process the payment automatically
if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet)
if (subscription.PaymentMethod == SubscriptionPaymentMethod.InAppWallet &&
definition.PaymentPolicy.AllowInternalWalletRenewal)
{
try
{
var order = await paymentService.CreateOrderAsync(
null,
definition.Currency,
subscription.FinalPrice,
appIdentifier: "internal",
productIdentifier: subscription.Identifier,
meta: new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
["subscription_identifier"] = subscription.Identifier,
["is_renewal"] = true
}
);
var wallet = await walletService.GetAccountWalletAsync(subscription.AccountId);
if (wallet is null) continue;
@@ -111,10 +137,11 @@ public class SubscriptionRenewalJob(
}
else
{
// For other payment methods, mark as pending payment
logger.LogInformation("Subscription {SubscriptionId} requires manual payment via {PaymentMethod}",
subscription.Id, subscription.PaymentMethod);
failedCount++;
logger.LogInformation(
"Skipping renewal for subscription {SubscriptionId}; provider {PaymentMethod} must extend via webhook or renewal is disabled",
subscription.Id,
subscription.PaymentMethod
);
}
}
catch (Exception ex)
@@ -133,7 +160,7 @@ public class SubscriptionRenewalJob(
}
logger.LogInformation(
"Completed subscription renewal job. Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
processedCount, renewedCount, failedCount);
"Completed subscription renewal job. Activated: {ActivatedCount}, Processed: {ProcessedCount}, Renewed: {RenewedCount}, Failed: {FailedCount}",
activatedCount, processedCount, renewedCount, failedCount);
}
}

View File

@@ -23,10 +23,10 @@ namespace DysonNetwork.Wallet.Payment;
public class SubscriptionService(
AppDatabase db,
PaymentService payment,
SubscriptionCatalogService catalog,
DyProfileService.DyProfileServiceClient accounts,
DyRingService.DyRingServiceClient pusher,
ILocalizationService localizer,
IConfiguration configuration,
ICacheService cache,
ILogger<SubscriptionService> logger
)
@@ -43,27 +43,21 @@ public class SubscriptionService(
bool noop = false
)
{
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(identifier, out var template)
? template
: null;
if (subscriptionInfo is null)
var definition = await catalog.GetDefinitionAsync(identifier);
if (definition is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [identifier];
if (!definition.IsPaymentMethodAllowed(paymentMethod))
throw new InvalidOperationException($"Payment method {paymentMethod} is not allowed for subscription {identifier}.");
if (definition.MinimumAccountLevel.HasValue && account.Profile.Level < definition.MinimumAccountLevel.Value)
throw new InvalidOperationException(
$"Account level must be at least {definition.MinimumAccountLevel.Value} to purchase {identifier}."
);
cycleDuration ??= Duration.FromDays(30);
var accountId = Guid.Parse(account.Id);
var existingSubscription = await GetSubscriptionAsync(accountId, subscriptionsInGroup);
if (existingSubscription is not null && !noop)
throw new InvalidOperationException($"Active subscription with identifier {identifier} already exists.");
if (existingSubscription is not null)
var existingSubscription = await GetSubscriptionAsync(accountId, identifier);
if (existingSubscription is not null && noop)
return existingSubscription;
// Batch database queries for coupon and free trial check
@@ -95,15 +89,20 @@ public class SubscriptionService(
BegunAt = now,
EndedAt = now.Plus(cycleDuration.Value),
Identifier = identifier,
GroupIdentifier = definition.GroupIdentifier,
DisplayName = definition.DisplayName,
PerkLevel = definition.PerkLevel,
IsActive = true,
IsFreeTrial = isFreeTrial,
Status = Shared.Models.SubscriptionStatus.Unpaid,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
BasePrice = subscriptionInfo.BasePrice,
BasePrice = definition.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
RenewalAt = (isFreeTrial || !isAutoRenewal) ? null : now.Plus(cycleDuration.Value),
RenewalAt = (isFreeTrial || !isAutoRenewal || !definition.PaymentPolicy.AllowInternalWalletRenewal)
? null
: now.Plus(cycleDuration.Value),
AccountId = accountId,
};
@@ -115,35 +114,14 @@ public class SubscriptionService(
public async Task<SnWalletSubscription> CreateSubscriptionFromOrder(ISubscriptionOrder order)
{
var cfgSection = configuration.GetSection("Payment:Subscriptions");
var provider = order.Provider;
if (string.IsNullOrWhiteSpace(order.SubscriptionId))
throw new InvalidOperationException("Subscription identifier was missing from the payment payload.");
var currency = "irl";
var subscriptionIdentifier = order.SubscriptionId;
switch (provider)
{
case SubscriptionPaymentMethod.Afdian:
// Get the Afdian section first, then bind it to a dictionary
var afdianPlans = cfgSection.GetSection("Afdian").Get<Dictionary<string, string>>();
logger.LogInformation("Afdian plans configuration: {Plans}", JsonSerializer.Serialize(afdianPlans));
if (afdianPlans != null && afdianPlans.TryGetValue(subscriptionIdentifier, out var planName))
subscriptionIdentifier = planName;
currency = "cny";
break;
case SubscriptionPaymentMethod.Paddle:
var paddlePlans = cfgSection.GetSection("Paddle").Get<Dictionary<string, string>>();
logger.LogInformation("Paddle plans configuration: {Plans}", JsonSerializer.Serialize(paddlePlans));
if (paddlePlans != null && paddlePlans.TryGetValue(subscriptionIdentifier, out var paddlePlanName))
subscriptionIdentifier = paddlePlanName;
currency = "usd";
break;
}
var subscriptionTemplate = SubscriptionTypeData
.SubscriptionDict.GetValueOrDefault(subscriptionIdentifier);
if (subscriptionTemplate is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$"Subscription {subscriptionIdentifier} was not found.");
var definition = await ResolveDefinitionForOrderAsync(order);
if (definition is null)
throw new ArgumentOutOfRangeException(nameof(order.SubscriptionId),
$"Subscription mapping {order.SubscriptionId} was not found for provider {provider}.");
SnAccount? account = null;
if (!string.IsNullOrEmpty(provider))
@@ -165,58 +143,18 @@ public class SubscriptionService(
if (account is null)
throw new InvalidOperationException($"Account was not found with identifier {order.AccountId}");
if (string.IsNullOrWhiteSpace(order.SubscriptionId))
throw new InvalidOperationException("Subscription identifier was missing from the payment payload.");
var cycleDuration = order.Duration;
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
throw new InvalidOperationException(
$"Active subscription with identifier {subscriptionIdentifier} already exists.");
if (existingSubscription?.PaymentDetails.OrderId == order.Id)
return existingSubscription;
if (existingSubscription is not null)
{
// Same provider, but different order, renew the subscription
existingSubscription.PaymentDetails.OrderId = order.Id;
existingSubscription.EndedAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.RenewalAt = order.BegunAt.Plus(cycleDuration);
existingSubscription.Status = SubscriptionStatus.Active;
db.Update(existingSubscription);
await db.SaveChangesAsync();
return existingSubscription;
}
var subscription = new SnWalletSubscription
{
BegunAt = order.BegunAt,
EndedAt = order.BegunAt.Plus(cycleDuration),
IsActive = true,
Status = SubscriptionStatus.Active,
Identifier = subscriptionIdentifier,
PaymentMethod = provider,
PaymentDetails = new SnPaymentDetails
return await ApplyPaidSubscriptionAsync(
account.Id,
definition,
provider,
new SnPaymentDetails
{
Currency = currency,
Currency = definition.Currency,
OrderId = order.Id,
},
BasePrice = subscriptionTemplate.BasePrice,
RenewalAt = order.BegunAt.Plus(cycleDuration),
AccountId = account.Id,
};
db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
await HandleSponsorCurrencyUpdateAsync(subscription);
await HandleSponsorBadgeSubscriptionAsync(subscription);
return subscription;
order.BegunAt,
order.Duration
);
}
/// <summary>
@@ -268,16 +206,12 @@ public class SubscriptionService(
.OrderByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is null) throw new InvalidOperationException("No matching subscription found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(subscription.Identifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
var definition = await catalog.GetDefinitionAsync(subscription.Identifier);
if (definition is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
definition.Currency,
subscription.FinalPrice,
appIdentifier: "internal",
productIdentifier: identifier,
@@ -304,16 +238,12 @@ public class SubscriptionService(
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null) throw new InvalidOperationException("No matching gift found.");
var subscriptionInfo = SubscriptionTypeData.SubscriptionDict
.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null) throw new InvalidOperationException("No matching subscription found.");
var definition = await catalog.GetDefinitionAsync(gift.SubscriptionIdentifier);
if (definition is null) throw new InvalidOperationException("No matching subscription found.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
definition.Currency,
gift.FinalPrice,
appIdentifier: "gift",
productIdentifier: gift.SubscriptionIdentifier,
@@ -341,26 +271,25 @@ public class SubscriptionService(
.FirstOrDefaultAsync();
if (subscription is null)
throw new InvalidOperationException("Invalid order.");
var definition = await catalog.GetDefinitionAsync(subscription.Identifier)
?? throw new InvalidOperationException("Invalid order.");
var cycleDuration = subscription.EndedAt.HasValue
? subscription.EndedAt.Value - subscription.BegunAt
: Duration.FromDays(30);
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
{
// Calculate original cycle duration and extend from the current ended date
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
subscription.RenewalAt = subscription.RenewalAt.HasValue
? subscription.RenewalAt.Value.Plus(originalCycle)
: subscription.EndedAt.Value.Plus(originalCycle);
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
}
subscription.Status = Shared.Models.SubscriptionStatus.Active;
db.Update(subscription);
await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
return subscription;
return await ApplyPaidSubscriptionAsync(
subscription.AccountId,
definition,
subscription.PaymentMethod,
new SnPaymentDetails
{
Currency = definition.Currency,
OrderId = order.Id.ToString()
},
SystemClock.Instance.GetCurrentInstant(),
cycleDuration,
placeholderSubscription: subscription
);
}
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
@@ -430,12 +359,152 @@ public class SubscriptionService(
return expiredSubscriptions.Count;
}
private async Task<SnWalletSubscriptionDefinition?> ResolveDefinitionForOrderAsync(ISubscriptionOrder order)
{
var direct = await catalog.GetDefinitionAsync(order.SubscriptionId);
if (direct is not null) return direct;
return await catalog.ResolveDefinitionAsync(order.Provider, order.SubscriptionId);
}
private async Task EnforceGiftPurchaseLimitAsync(
Guid gifterId,
string subscriptionIdentifier,
SubscriptionGiftPolicy giftPolicy,
Instant now
)
{
if (!giftPolicy.RollingPurchaseLimit.HasValue || giftPolicy.RollingPurchaseLimit <= 0)
return;
var windowStart = now.Minus(Duration.FromDays(giftPolicy.RollingWindowDays ?? 30));
var currentCount = await db.WalletGifts
.Where(g => g.GifterId == gifterId)
.Where(g => g.SubscriptionIdentifier == subscriptionIdentifier)
.Where(g => g.CreatedAt >= windowStart)
.CountAsync();
if (currentCount >= giftPolicy.RollingPurchaseLimit.Value)
throw new InvalidOperationException("Gift purchase limit reached for this subscription.");
}
private async Task<SnWalletSubscription> ApplyPaidSubscriptionAsync(
Guid accountId,
SnWalletSubscriptionDefinition definition,
string paymentMethod,
SnPaymentDetails paymentDetails,
Instant effectiveFrom,
Duration cycleDuration,
SnWalletSubscription? placeholderSubscription = null,
Guid? couponId = null,
SnWalletCoupon? coupon = null
)
{
var groupIdentifiers = await catalog.GetGroupIdentifiersAsync(definition.GroupIdentifier, definition.Identifier);
var activeOrQueued = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId)
.Where(s => s.IsActive)
.Where(s => s.Status == SubscriptionStatus.Active)
.Where(s => groupIdentifiers.Contains(s.Identifier))
.Where(s => placeholderSubscription == null || s.Id != placeholderSubscription.Id)
.OrderBy(s => s.BegunAt)
.ToListAsync();
var sameIdentifierTail = activeOrQueued
.Where(s => s.Identifier == definition.Identifier)
.OrderByDescending(s => s.EndedAt ?? s.BegunAt)
.FirstOrDefault();
if (sameIdentifierTail?.PaymentDetails.OrderId == paymentDetails.OrderId)
return sameIdentifierTail;
if (sameIdentifierTail is not null)
{
var extensionBase = sameIdentifierTail.EndedAt.HasValue && sameIdentifierTail.EndedAt.Value > effectiveFrom
? sameIdentifierTail.EndedAt.Value
: effectiveFrom;
sameIdentifierTail.EndedAt = extensionBase.Plus(cycleDuration);
sameIdentifierTail.PaymentMethod = paymentMethod;
sameIdentifierTail.PaymentDetails = paymentDetails;
sameIdentifierTail.CouponId = couponId ?? sameIdentifierTail.CouponId;
sameIdentifierTail.Coupon = coupon ?? sameIdentifierTail.Coupon;
sameIdentifierTail.BasePrice = definition.BasePrice;
sameIdentifierTail.GroupIdentifier = definition.GroupIdentifier;
sameIdentifierTail.DisplayName = definition.DisplayName;
sameIdentifierTail.PerkLevel = definition.PerkLevel;
sameIdentifierTail.Status = SubscriptionStatus.Active;
sameIdentifierTail.RenewalAt = definition.PaymentPolicy.AllowInternalWalletRenewal
? sameIdentifierTail.EndedAt
: null;
if (placeholderSubscription is not null && placeholderSubscription.Id != sameIdentifierTail.Id)
{
placeholderSubscription.IsActive = false;
placeholderSubscription.Status = SubscriptionStatus.Cancelled;
db.WalletSubscriptions.Update(placeholderSubscription);
}
db.WalletSubscriptions.Update(sameIdentifierTail);
await db.SaveChangesAsync();
await InvalidateSubscriptionCaches(accountId, definition.Identifier);
return sameIdentifierTail;
}
var groupTail = activeOrQueued
.OrderByDescending(s => s.EndedAt ?? s.BegunAt)
.FirstOrDefault();
var begunAt = groupTail?.EndedAt is { } endedAt && endedAt > effectiveFrom
? endedAt
: effectiveFrom;
var subscription = placeholderSubscription ?? new SnWalletSubscription();
subscription.BegunAt = begunAt;
subscription.EndedAt = begunAt.Plus(cycleDuration);
subscription.Identifier = definition.Identifier;
subscription.GroupIdentifier = definition.GroupIdentifier;
subscription.DisplayName = definition.DisplayName;
subscription.PerkLevel = definition.PerkLevel;
subscription.IsActive = true;
subscription.IsFreeTrial = false;
subscription.Status = SubscriptionStatus.Active;
subscription.PaymentMethod = paymentMethod;
subscription.PaymentDetails = paymentDetails;
subscription.BasePrice = definition.BasePrice;
subscription.CouponId = couponId;
subscription.Coupon = coupon;
subscription.RenewalAt = definition.PaymentPolicy.AllowInternalWalletRenewal
? subscription.EndedAt
: null;
subscription.AccountId = accountId;
if (placeholderSubscription is null)
db.WalletSubscriptions.Add(subscription);
else
db.WalletSubscriptions.Update(subscription);
await db.SaveChangesAsync();
await InvalidateSubscriptionCaches(accountId, definition.Identifier);
if (begunAt <= SystemClock.Instance.GetCurrentInstant())
{
await NotifySubscriptionBegun(subscription);
await HandleSponsorCurrencyUpdateAsync(subscription);
await HandleSponsorBadgeSubscriptionAsync(subscription);
}
return subscription;
}
private async Task InvalidateSubscriptionCaches(Guid accountId, string identifier)
{
await cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{accountId}:{identifier}");
await cache.RemoveAsync($"{SubscriptionPerkCacheKeyPrefix}{accountId}");
}
private async Task NotifySubscriptionBegun(SnWalletSubscription subscription)
{
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var humanReadableName = subscription.DisplayName ?? subscription.Identifier;
var duration = subscription.EndedAt is not null
? subscription.EndedAt.Value.Minus(subscription.BegunAt).Days.ToString()
: "infinite";
@@ -496,9 +565,6 @@ public class SubscriptionService(
private const string SubscriptionPerkCacheKeyPrefix = "subscription:perk:";
private static readonly List<string> PerkIdentifiers =
[SubscriptionType.Stellar, SubscriptionType.Nova, SubscriptionType.Supernova];
public async Task<SnWalletSubscription?> GetPerkSubscriptionAsync(Guid accountId)
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
@@ -513,10 +579,11 @@ public class SubscriptionService(
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
var subscription = await db.WalletSubscriptions
.Where(s => s.AccountId == accountId && PerkIdentifiers.Contains(s.Identifier))
.Where(s => s.AccountId == accountId && s.PerkLevel > 0)
.Where(s => s.Status == Shared.Models.SubscriptionStatus.Active)
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.OrderByDescending(s => s.PerkLevel)
.ThenByDescending(s => s.BegunAt)
.FirstOrDefaultAsync();
if (subscription is { IsAvailable: false }) subscription = null;
@@ -556,10 +623,11 @@ public class SubscriptionService(
var now = SystemClock.Instance.GetCurrentInstant();
var subscriptions = await db.WalletSubscriptions
.Where(s => missingAccountIds.Contains(s.AccountId))
.Where(s => PerkIdentifiers.Contains(s.Identifier))
.Where(s => s.PerkLevel > 0)
.Where(s => s.Status == Shared.Models.SubscriptionStatus.Active)
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.OrderByDescending(s => s.PerkLevel)
.ThenByDescending(s => s.BegunAt)
.ToListAsync();
// Group by account and select latest available subscription
@@ -607,11 +675,25 @@ public class SubscriptionService(
Duration? cycleDuration = null)
{
// Validate subscription exists
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.GetValueOrDefault(subscriptionIdentifier);
if (subscriptionInfo is null)
var definition = await catalog.GetDefinitionAsync(subscriptionIdentifier);
if (definition is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
if (!definition.IsPaymentMethodAllowed(paymentMethod))
throw new InvalidOperationException(
$"Payment method {paymentMethod} is not allowed for gift purchase of {subscriptionIdentifier}."
);
var giftPolicy = await catalog.GetGiftPolicyAsync(definition);
if (!giftPolicy.AllowPurchase)
throw new InvalidOperationException("Gift purchase is disabled for this subscription.");
if (giftPolicy.MinimumAccountLevel.HasValue && gifter.Profile.Level < giftPolicy.MinimumAccountLevel.Value)
{
var canBypass = giftPolicy.AllowPerkSubscriptionBypass && gifter.PerkLevel > 0;
if (!canBypass)
throw new InvalidOperationException(
$"Account level must be at least {giftPolicy.MinimumAccountLevel.Value} to purchase this gift."
);
}
// Check if recipient account exists (if specified)
DyAccount? recipient = null;
@@ -636,10 +718,11 @@ public class SubscriptionService(
throw new InvalidOperationException($"Coupon {coupon} was not found.");
// Set defaults
giftDuration ??= Duration.FromDays(30); // Gift expires in 30 days
cycleDuration ??= Duration.FromDays(30); // Subscription lasts 30 days once redeemed
giftDuration ??= Duration.FromDays(giftPolicy.GiftDurationDays ?? 30);
cycleDuration ??= Duration.FromDays(giftPolicy.SubscriptionDurationDays ?? 30);
var now = SystemClock.Instance.GetCurrentInstant();
await EnforceGiftPurchaseLimitAsync(Guid.Parse(gifter.Id), subscriptionIdentifier, giftPolicy, now);
// Generate unique gift code
var giftCode = await GenerateUniqueGiftCodeAsync();
@@ -647,7 +730,7 @@ public class SubscriptionService(
// Calculate final price (with potential coupon discount)
var tempSubscription = new SnWalletSubscription
{
BasePrice = subscriptionInfo.BasePrice,
BasePrice = definition.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
BegunAt = now // Need for price calculation
@@ -662,7 +745,7 @@ public class SubscriptionService(
GiftCode = giftCode,
Message = message,
SubscriptionIdentifier = subscriptionIdentifier,
BasePrice = subscriptionInfo.BasePrice,
BasePrice = definition.BasePrice,
FinalPrice = finalPrice,
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
ExpiresAt = now.Plus(giftDuration.Value),
@@ -714,118 +797,35 @@ public class SubscriptionService(
if (!gift.IsOpenGift && gift.RecipientId != redeemerId)
throw new InvalidOperationException("This gift is not intended for you.");
// Check if redeemer already has this subscription type
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(gift.SubscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
var definition = await catalog.GetDefinitionAsync(gift.SubscriptionIdentifier);
if (definition is null)
throw new InvalidOperationException("Invalid gift subscription type.");
var sameTypeSubscription = await GetSubscriptionAsync(redeemerId, gift.SubscriptionIdentifier);
if (sameTypeSubscription is not null)
{
// Extend existing subscription
var subscriptionDuration = Duration.FromDays(28);
if (sameTypeSubscription.EndedAt.HasValue && sameTypeSubscription.EndedAt.Value > now)
var giftPolicy = await catalog.GetGiftPolicyAsync(definition);
var cycleDuration = Duration.FromDays(giftPolicy.SubscriptionDurationDays ?? 30);
var subscription = await ApplyPaidSubscriptionAsync(
redeemerId,
definition,
SubscriptionPaymentMethod.Gift,
new SnPaymentDetails
{
sameTypeSubscription.EndedAt = sameTypeSubscription.EndedAt.Value.Plus(subscriptionDuration);
}
else
{
sameTypeSubscription.EndedAt = now.Plus(subscriptionDuration);
}
if (sameTypeSubscription.RenewalAt.HasValue)
{
sameTypeSubscription.RenewalAt = sameTypeSubscription.RenewalAt.Value.Plus(subscriptionDuration);
}
// Update gift status and link
gift.Status = Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemerId;
gift.SubscriptionId = sameTypeSubscription.Id;
gift.UpdatedAt = now;
await using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Update(sameTypeSubscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
if (gift.GifterId == redeemerId) return (gift, sameTypeSubscription);
await NotifyGiftClaimedByRecipient(gift, sameTypeSubscription, gift.GifterId, redeemer);
return (gift, sameTypeSubscription);
}
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
.Select(s => s.Value.Identifier)
.ToArray()
: [gift.SubscriptionIdentifier];
var existingSubscription = await GetSubscriptionAsync(redeemerId, subscriptionsInGroup);
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// We do not check account level requirement, since it is a gift
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(28);
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration),
Identifier = gift.SubscriptionIdentifier,
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = "gift", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
Currency = definition.Currency,
OrderId = gift.Id.ToString()
},
BasePrice = gift.BasePrice,
CouponId = gift.CouponId,
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemerId,
};
now,
cycleDuration,
couponId: gift.CouponId,
coupon: gift.Coupon
);
// Update the gift status
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemerId;
gift.Subscription = subscription;
gift.SubscriptionId = subscription.Id;
gift.UpdatedAt = now;
// Save both gift and subscription
using var createTransaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await createTransaction.CommitAsync();
}
catch
{
await createTransaction.RollbackAsync();
throw;
}
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
// Send notification to gifter if different from redeemer
if (gift.GifterId == redeemerId) return (gift, subscription);
@@ -937,10 +937,7 @@ public class SubscriptionService(
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, Guid gifterId,
DyAccount redeemer)
{
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var humanReadableName = subscription.DisplayName ?? subscription.Identifier;
var locale = System.Globalization.CultureInfo.CurrentUICulture.Name;
var notification = new DyPushNotification
@@ -969,7 +966,10 @@ public class SubscriptionService(
private async Task HandleSponsorCurrencyUpdateAsync(SnWalletSubscription subscription)
{
var amount = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(subscription.Identifier) * 10;
var amount = subscription.PerkLevel > 0
? subscription.PerkLevel * 10
: (await catalog.GetDefinitionAsync(subscription.Identifier))?.GoldenPointReward ?? 0;
if (amount <= 0) return;
try
{
@@ -1009,7 +1009,7 @@ public class SubscriptionService(
.OrderByDescending(b => b.ActivatedAt)
.ToList();
var newLevel = PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(subscription.Identifier);
var newLevel = subscription.PerkLevel;
if (sponsorBadges.Count > 0)
{
// Increment the level from the existing badge

View File

@@ -2,6 +2,7 @@ using DysonNetwork.Shared.Auth;
using DysonNetwork.Shared.Networking;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Wallet;
using DysonNetwork.Wallet.Payment;
using DysonNetwork.Wallet.Startup;
using Microsoft.EntityFrameworkCore;
@@ -38,6 +39,8 @@ using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
await db.Database.MigrateAsync();
var subscriptionCatalog = scope.ServiceProvider.GetRequiredService<SubscriptionCatalogService>();
await subscriptionCatalog.EnsureSeededAsync();
}
// Configure application middleware pipeline
@@ -48,4 +51,4 @@ app.ConfigureGrpcServices();
app.UseSwaggerManifest("DysonNetwork.Wallet");
app.Run();
app.Run();

View File

@@ -101,6 +101,7 @@ public static class ServiceCollectionExtensions
// Register Wallet services
services.AddScoped<WalletService>();
services.AddScoped<PaymentService>();
services.AddScoped<SubscriptionCatalogService>();
services.AddScoped<SubscriptionService>();
services.AddScoped<AfdianPaymentHandler>();
services.AddScoped<PaddlePaymentHandler>();
@@ -125,10 +126,7 @@ public static class ServiceCollectionExtensions
return;
// Handle subscription orders
if (
evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram) &&
evt.Meta.TryGetValue("gift_id", out _)
)
if (evt.Meta.TryGetValue("gift_id", out _))
{
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
@@ -146,7 +144,7 @@ public static class ServiceCollectionExtensions
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
}
else if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
else if (evt.Meta.TryGetValue("subscription_id", out _))
{
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);

View File

@@ -39,17 +39,83 @@
"WebhookSecret": "pdl_ntfset_xxx"
}
},
"Subscriptions": {
"Afdian": {
"7d17aae23c9611f0b5705254001e7c00": "solian.stellar.primary",
"7dfae4743c9611f0b3a55254001e7c00": "solian.stellar.nova",
"141713ee3d6211f085b352540025c377": "solian.stellar.supernova"
"SubscriptionCatalog": {
"Settings": {
"GiftPolicyDefaults": {
"AllowPurchase": true,
"MinimumAccountLevel": 60,
"AllowPerkSubscriptionBypass": true,
"RollingPurchaseLimit": 5,
"RollingWindowDays": 30,
"GiftDurationDays": 30,
"SubscriptionDurationDays": 30
}
},
"Paddle": {
"pri_your_monthly_plan": "solian.stellar.primary",
"pri_your_nova_plan": "solian.stellar.nova",
"pri_your_supernova_plan": "solian.stellar.supernova"
}
"Definitions": [
{
"Identifier": "solian.stellar.primary",
"GroupIdentifier": "solian.stellar",
"DisplayName": "Stellar Program",
"Currency": "points",
"BasePrice": 1200,
"PerkLevel": 1,
"MinimumAccountLevel": 20,
"ExperienceMultiplier": 1.5,
"GoldenPointReward": 10,
"PaymentPolicy": {
"AllowInternalWallet": true,
"AllowExternal": true,
"AllowInternalWalletRenewal": true,
"AllowedMethods": [ "solian.wallet", "afdian", "paddle", "gift" ]
},
"ProviderMappings": {
"Afdian": [ "7d17aae23c9611f0b5705254001e7c00" ],
"Paddle": [ "pri_your_monthly_plan", "pro_stellar" ]
}
},
{
"Identifier": "solian.stellar.nova",
"GroupIdentifier": "solian.stellar",
"DisplayName": "Stellar Program Nova",
"Currency": "points",
"BasePrice": 2400,
"PerkLevel": 2,
"MinimumAccountLevel": 40,
"ExperienceMultiplier": 2.0,
"GoldenPointReward": 20,
"PaymentPolicy": {
"AllowInternalWallet": true,
"AllowExternal": true,
"AllowInternalWalletRenewal": true,
"AllowedMethods": [ "solian.wallet", "afdian", "paddle", "gift" ]
},
"ProviderMappings": {
"Afdian": [ "7dfae4743c9611f0b3a55254001e7c00" ],
"Paddle": [ "pri_your_nova_plan", "pro_nova" ]
}
},
{
"Identifier": "solian.stellar.supernova",
"GroupIdentifier": "solian.stellar",
"DisplayName": "Stellar Program Supernova",
"Currency": "points",
"BasePrice": 3600,
"PerkLevel": 3,
"MinimumAccountLevel": 60,
"ExperienceMultiplier": 2.5,
"GoldenPointReward": 30,
"PaymentPolicy": {
"AllowInternalWallet": true,
"AllowExternal": true,
"AllowInternalWalletRenewal": true,
"AllowedMethods": [ "solian.wallet", "afdian", "paddle", "gift" ]
},
"ProviderMappings": {
"Afdian": [ "141713ee3d6211f085b352540025c377" ],
"Paddle": [ "pri_your_supernova_plan", "pro_supernova" ]
}
}
]
}
},
"Cache": {

View File

@@ -18,7 +18,7 @@ public class PublicationSiteService(
{
var account = await remoteAccounts.GetAccount(accountId);
var perkLevel = account.PerkSubscription is not null
? PerkSubscriptionPrivilege.GetPrivilegeFromIdentifier(account.PerkSubscription.Identifier)
? account.PerkSubscription.PerkLevel
: 0;
var sites = await db.PublicationSites