Subscription gifts

This commit is contained in:
2025-10-03 14:36:27 +08:00
parent a93b633e84
commit fa24f14c05
18 changed files with 5273 additions and 537 deletions

View File

@@ -4,9 +4,6 @@ var builder = DistributedApplication.CreateBuilder(args);
var isDev = builder.Environment.IsDevelopment(); var isDev = builder.Environment.IsDevelopment();
// Database was configured separately in each service.
// var database = builder.AddPostgres("database");
var cache = builder.AddRedis("cache"); var cache = builder.AddRedis("cache");
var queue = builder.AddNats("queue").WithJetStream(); var queue = builder.AddNats("queue").WithJetStream();

View File

@@ -10,12 +10,9 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=localhost;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
}, },
"KnownProxies": [ "KnownProxies": ["127.0.0.1", "::1"],
"127.0.0.1",
"::1"
],
"Swagger": { "Swagger": {
"PublicBasePath": "/develop" "PublicBasePath": "/develop"
}, },

View File

@@ -44,6 +44,7 @@ public class AppDatabase(
public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!; public DbSet<SnWalletOrder> PaymentOrders { get; set; } = null!;
public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!; public DbSet<SnWalletTransaction> PaymentTransactions { get; set; } = null!;
public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!; public DbSet<SnWalletSubscription> WalletSubscriptions { get; set; } = null!;
public DbSet<SnWalletGift> WalletGifts { get; set; } = null!;
public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!; public DbSet<SnWalletCoupon> WalletCoupons { get; set; } = null!;
public DbSet<SnAccountPunishment> Punishments { get; set; } = null!; public DbSet<SnAccountPunishment> Punishments { get; set; } = null!;

View File

@@ -0,0 +1,24 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pass", "DysonNetwork.Pass.csproj", "{0E8F6522-90DE-5BDE-7127-114E02C2C10F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E8F6522-90DE-5BDE-7127-114E02C2C10F}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {8DAB9031-CC04-4A1A-A05A-4ADFEBAB90A8}
EndGlobalSection
EndGlobal

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -49,31 +49,64 @@ public class BroadcastEventHandler(
evt?.OrderId evt?.OrderId
); );
if (evt?.ProductIdentifier is null || if (evt?.ProductIdentifier is null)
!evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
continue; continue;
logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId); // Handle subscription orders
if (evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram))
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{ {
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId); logger.LogInformation("Handling stellar program order: {OrderId}", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
// Handle gift orders
else if (evt.Meta?.TryGetValue("gift_id", out var giftIdValue) == true)
{
logger.LogInformation("Handling gift order: {OrderId}", evt.OrderId);
await using var scope = serviceProvider.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var subscriptions = scope.ServiceProvider.GetRequiredService<SubscriptionService>();
var order = await db.PaymentOrders.FindAsync(
[evt.OrderId],
cancellationToken: stoppingToken
);
if (order is null)
{
logger.LogWarning("Order with ID {OrderId} not found. Redelivering.", evt.OrderId);
await msg.NakAsync(cancellationToken: stoppingToken);
continue;
}
await subscriptions.HandleGiftOrder(order);
logger.LogInformation("Gift for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
}
else
{
// Not a subscription or gift order, skip
continue; continue;
} }
await subscriptions.HandleSubscriptionOrder(order);
logger.LogInformation("Subscription for order {OrderId} handled successfully.", evt.OrderId);
await msg.AckAsync(cancellationToken: stoppingToken);
} }
catch (Exception ex) catch (Exception ex)
{ {

View File

@@ -46,6 +46,16 @@ public static class ScheduledJobsConfiguration
.WithIntervalInMinutes(30) .WithIntervalInMinutes(30)
.RepeatForever()) .RepeatForever())
); );
var giftCleanupJob = new JobKey("GiftCleanup");
q.AddJob<GiftCleanupJob>(opts => opts.WithIdentity(giftCleanupJob));
q.AddTrigger(opts => opts
.ForJob(giftCleanupJob)
.WithIdentity("GiftCleanupTrigger")
.WithSimpleSchedule(o => o
.WithIntervalInHours(1)
.RepeatForever())
);
}); });
services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true);

View File

@@ -0,0 +1,40 @@
using DysonNetwork.Shared.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
using Quartz;
namespace DysonNetwork.Pass.Wallet;
public class GiftCleanupJob(
AppDatabase db,
ILogger<GiftCleanupJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
logger.LogInformation("Starting gift cleanup job...");
var now = SystemClock.Instance.GetCurrentInstant();
// Clean up gifts that are in Created status and older than 24 hours
var cutoffTime = now.Minus(Duration.FromHours(24));
var oldCreatedGifts = await db.WalletGifts
.Where(g => g.Status == GiftStatus.Created)
.Where(g => g.CreatedAt < cutoffTime)
.ToListAsync();
if (oldCreatedGifts.Count == 0)
{
logger.LogInformation("No old created gifts to clean up");
return;
}
logger.LogInformation("Found {Count} old created gifts to clean up", oldCreatedGifts.Count);
// Remove the gifts
db.WalletGifts.RemoveRange(oldCreatedGifts);
await db.SaveChangesAsync();
logger.LogInformation("Successfully cleaned up {Count} old created gifts", oldCreatedGifts.Count);
}
}

View File

@@ -9,7 +9,10 @@ namespace DysonNetwork.Pass.Wallet;
[ApiController] [ApiController]
[Route("/api/subscriptions/gifts")] [Route("/api/subscriptions/gifts")]
public class GiftController(SubscriptionService subscriptions, AppDatabase db) : ControllerBase public class SubscriptionGiftController(
SubscriptionService subscriptions,
AppDatabase db
) : ControllerBase
{ {
/// <summary> /// <summary>
/// Lists gifts purchased by the current user. /// Lists gifts purchased by the current user.
@@ -71,9 +74,9 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized(); if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
var gift = await db.WalletGifts var gift = await db.WalletGifts
.Include(g => g.Gifter) .Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient) .Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer) .Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription) .Include(g => g.Subscription)
.Include(g => g.Coupon) .Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.Id == giftId); .FirstOrDefaultAsync(g => g.Id == giftId);
@@ -101,7 +104,7 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
var canRedeem = false; var canRedeem = false;
var error = ""; var error = "";
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent) if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
{ {
error = gift.Status switch error = gift.Status switch
{ {
@@ -137,7 +140,8 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
.ToArray() .ToArray()
: [gift.SubscriptionIdentifier]; : [gift.SubscriptionIdentifier];
var existingSubscription = await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup); var existingSubscription =
await subscriptions.GetSubscriptionAsync(currentUser.Id, subscriptionsInGroup);
if (existingSubscription is not null) if (existingSubscription is not null)
{ {
error = "You already have an active subscription of this type."; error = "You already have an active subscription of this type.";
@@ -147,7 +151,8 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id); var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == currentUser.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel) if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
{ {
error = $"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift."; error =
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.";
} }
else else
{ {
@@ -310,4 +315,26 @@ public class GiftController(SubscriptionService subscriptions, AppDatabase db) :
return BadRequest(ex.Message); return BadRequest(ex.Message);
} }
} }
/// <summary>
/// Creates an order for an unpaid gift.
/// </summary>
[HttpPost("{giftId}/order")]
[Authorize]
public async Task<ActionResult<SnWalletOrder>> CreateGiftOrder(Guid giftId)
{
if (HttpContext.Items["CurrentUser"] is not SnAccount currentUser) return Unauthorized();
try
{
var order = await subscriptions.CreateGiftOrder(currentUser.Id, giftId);
return order;
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
} }

View File

@@ -42,6 +42,7 @@ public class SubscriptionService(
: null; : null;
if (subscriptionInfo is null) if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found."); throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict ? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier) .Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -57,36 +58,42 @@ public class SubscriptionService(
if (existingSubscription is not null) if (existingSubscription is not null)
return existingSubscription; return existingSubscription;
// Batch database queries for account profile and coupon to reduce round trips
var accountProfileTask = subscriptionInfo.RequiredLevel > 0
? db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == account.Id)
: Task.FromResult((Shared.Models.SnAccountProfile?)null);
var prevFreeTrialTask = isFreeTrial
? db.WalletSubscriptions.FirstOrDefaultAsync(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
: Task.FromResult((SnWalletSubscription?)null);
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponTask = coupon != null
? db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: Task.FromResult((SnWalletCoupon?)null);
// Await batched queries
var profile = await accountProfileTask;
var prevFreeTrial = await prevFreeTrialTask;
var couponData = await couponTask;
// Validation checks
if (subscriptionInfo.RequiredLevel > 0) if (subscriptionInfo.RequiredLevel > 0)
{ {
var profile = await db.AccountProfiles if (profile is null)
.Where(p => p.AccountId == account.Id) throw new InvalidOperationException("Account profile was not found.");
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel) if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException( throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}." $"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
); );
} }
if (isFreeTrial) if (isFreeTrial && prevFreeTrial != null)
{ throw new InvalidOperationException("Free trial already exists.");
var prevFreeTrial = await db.WalletSubscriptions
.Where(s => s.AccountId == account.Id && s.Identifier == identifier && s.IsFreeTrial)
.FirstOrDefaultAsync();
if (prevFreeTrial is not null)
throw new InvalidOperationException("Free trial already exists.");
}
SnWalletCoupon? couponData = null; if (coupon != null && couponData is null)
if (coupon is not null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
{
var inputCouponId = Guid.TryParse(coupon, out var parsedCouponId) ? parsedCouponId : Guid.Empty;
couponData = await db.WalletCoupons
.Where(c => (c.Id == inputCouponId) || (c.Identifier != null && c.Identifier == coupon))
.FirstOrDefaultAsync();
if (couponData is null) throw new InvalidOperationException($"Coupon {coupon} was not found.");
}
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
var subscription = new SnWalletSubscription var subscription = new SnWalletSubscription
@@ -266,6 +273,41 @@ public class SubscriptionService(
); );
} }
/// <summary>
/// Creates a gift order for an unpaid gift.
/// </summary>
/// <param name="accountId">The account ID of the gifter.</param>
/// <param name="giftId">The unique identifier for the gift.</param>
/// <returns>A task that represents the asynchronous operation. The task result contains the created gift order.</returns>
/// <exception cref="InvalidOperationException">Thrown when the gift is not found or not in payable status.</exception>
public async Task<SnWalletOrder> CreateGiftOrder(Guid accountId, Guid giftId)
{
var gift = await db.WalletGifts
.Where(g => g.Id == giftId && g.GifterId == accountId)
.Where(g => g.Status == DysonNetwork.Shared.Models.GiftStatus.Created)
.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.");
return await payment.CreateOrderAsync(
null,
subscriptionInfo.Currency,
gift.FinalPrice,
appIdentifier: "gift",
productIdentifier: gift.SubscriptionIdentifier,
meta: new Dictionary<string, object>()
{
["gift_id"] = gift.Id.ToString()
}
);
}
public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order) public async Task<SnWalletSubscription> HandleSubscriptionOrder(SnWalletOrder order)
{ {
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson) if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["subscription_id"] is not JsonElement subscriptionIdJson)
@@ -285,14 +327,11 @@ public class SubscriptionService(
if (subscription.Status == Shared.Models.SubscriptionStatus.Expired) if (subscription.Status == Shared.Models.SubscriptionStatus.Expired)
{ {
var now = SystemClock.Instance.GetCurrentInstant(); // Calculate original cycle duration and extend from the current ended date
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now); Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle); subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
var nextEndedAt = subscription.EndedAt?.Plus(cycle); subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
} }
subscription.Status = Shared.Models.SubscriptionStatus.Active; subscription.Status = Shared.Models.SubscriptionStatus.Active;
@@ -305,6 +344,36 @@ public class SubscriptionService(
return subscription; return subscription;
} }
public async Task<SnWalletGift> HandleGiftOrder(SnWalletOrder order)
{
if (order.Status != Shared.Models.OrderStatus.Paid || order.Meta?["gift_id"] is not JsonElement giftIdJson)
throw new InvalidOperationException("Invalid order.");
var giftId = Guid.TryParse(giftIdJson.ToString(), out var parsedGiftId)
? parsedGiftId
: Guid.Empty;
if (giftId == Guid.Empty)
throw new InvalidOperationException("Invalid order.");
var gift = await db.WalletGifts
.Where(g => g.Id == giftId)
.Include(g => g.Coupon)
.FirstOrDefaultAsync();
if (gift is null)
throw new InvalidOperationException("Invalid order.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift is not in payable status.");
// Mark gift as sent after payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
db.Update(gift);
await db.SaveChangesAsync();
return gift;
}
/// <summary> /// <summary>
/// Updates the status of expired subscriptions to reflect their current state. /// Updates the status of expired subscriptions to reflect their current state.
/// This helps maintain accurate subscription records and is typically called periodically. /// This helps maintain accurate subscription records and is typically called periodically.
@@ -326,16 +395,19 @@ public class SubscriptionService(
if (expiredSubscriptions.Count == 0) if (expiredSubscriptions.Count == 0)
return 0; return 0;
// Mark as expired
foreach (var subscription in expiredSubscriptions) foreach (var subscription in expiredSubscriptions)
{ {
subscription.Status = Shared.Models.SubscriptionStatus.Expired; subscription.Status = Shared.Models.SubscriptionStatus.Expired;
// Clear the cache for this subscription
var cacheKey = $"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}";
await cache.RemoveAsync(cacheKey);
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
// Batch invalidate caches for better performance
var cacheTasks = expiredSubscriptions.Select(subscription =>
cache.RemoveAsync($"{SubscriptionCacheKeyPrefix}{subscription.AccountId}:{subscription.Identifier}"));
await Task.WhenAll(cacheTasks);
return expiredSubscriptions.Count; return expiredSubscriptions.Count;
} }
@@ -379,10 +451,11 @@ public class SubscriptionService(
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers) public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{ {
// Create a unique cache key for this subscription // Create a unique cache key for this subscription
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))); var identifierPart = identifiers.Length == 1
var hashIdentifier = Convert.ToHexStringLower(hashBytes); ? identifiers[0]
: Convert.ToHexStringLower(MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers))));
var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{hashIdentifier}"; var cacheKey = $"{SubscriptionCacheKeyPrefix}{accountId}:{identifierPart}";
// Try to get the subscription from cache first // Try to get the subscription from cache first
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey); var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
@@ -443,17 +516,24 @@ public class SubscriptionService(
var missingAccountIds = new List<Guid>(); var missingAccountIds = new List<Guid>();
// Try to get the subscription from cache first // Try to get the subscription from cache first
foreach (var accountId in accountIds) var cacheTasks = accountIds.Select(async accountId =>
{ {
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}"; var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey); var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
return (accountId, found, cachedSubscription);
});
var cacheResults = await Task.WhenAll(cacheTasks);
foreach (var (accountId, found, cachedSubscription) in cacheResults)
{
if (found && cachedSubscription != null) if (found && cachedSubscription != null)
result[accountId] = cachedSubscription; result[accountId] = cachedSubscription;
else else
missingAccountIds.Add(accountId); missingAccountIds.Add(accountId);
} }
if (missingAccountIds.Count <= 0) return result; if (missingAccountIds.Count == 0) return result;
// If not in cache, get from database // If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant(); var now = SystemClock.Instance.GetCurrentInstant();
@@ -464,18 +544,443 @@ public class SubscriptionService(
.Where(s => s.EndedAt == null || s.EndedAt > now) .Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt) .OrderByDescending(s => s.BegunAt)
.ToListAsync(); .ToListAsync();
subscriptions = subscriptions.Where(s => s.IsAvailable).ToList();
// Group the subscriptions by account id // Group by account and select latest available subscription
foreach (var subscription in subscriptions) var groupedSubscriptions = subscriptions
.Where(s => s.IsAvailable)
.GroupBy(s => s.AccountId)
.ToDictionary(g => g.Key, g => g.First());
// Update results and batch cache operations
var cacheSetTasks = new List<Task>();
foreach (var kvp in groupedSubscriptions)
{ {
result[subscription.AccountId] = subscription; result[kvp.Key] = kvp.Value;
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
// Cache the result if found (with 30 minutes expiry) cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}";
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
} }
await Task.WhenAll(cacheSetTasks);
return result; return result;
} }
/// <summary>
/// Purchases a gift subscription that can be redeemed by another user.
/// </summary>
/// <param name="gifter">The account purchasing the gift.</param>
/// <param name="recipientId">Optional specific recipient. If null, creates an open gift anyone can redeem.</param>
/// <param name="subscriptionIdentifier">The subscription type being gifted.</param>
/// <param name="paymentMethod">Payment method used by the gifter.</param>
/// <param name="paymentDetails">Payment details from the gifter.</param>
/// <param name="message">Optional personal message from the gifter.</param>
/// <param name="coupon">Optional coupon code for discount.</param>
/// <param name="giftDuration">How long the gift can be redeemed (default 30 days).</param>
/// <param name="cycleDuration">The duration of the subscription once redeemed (default 30 days).</param>
/// <returns>The created gift record.</returns>
public async Task<SnWalletGift> PurchaseGiftAsync(
SnAccount gifter,
Guid? recipientId,
string subscriptionIdentifier,
string paymentMethod,
SnPaymentDetails paymentDetails,
string? message = null,
string? coupon = null,
Duration? giftDuration = null,
Duration? cycleDuration = null)
{
// Validate subscription exists
var subscriptionInfo = SubscriptionTypeData
.SubscriptionDict.TryGetValue(subscriptionIdentifier, out var template)
? template
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
// Check if recipient account exists (if specified)
SnAccount? recipient = null;
if (recipientId.HasValue)
{
recipient = await db.Accounts
.Where(a => a.Id == recipientId.Value)
.Include(a => a.Profile)
.FirstOrDefaultAsync();
if (recipient is null)
throw new ArgumentOutOfRangeException(nameof(recipientId), "Recipient account not found.");
}
// Validate and get coupon if provided
Guid couponGuidId = Guid.TryParse(coupon ?? "", out var parsedId) ? parsedId : Guid.Empty;
var couponData = coupon != null
? await db.WalletCoupons.FirstOrDefaultAsync(c =>
c.Id == couponGuidId || (c.Identifier != null && c.Identifier == coupon))
: null;
if (coupon != null && couponData is null)
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
var now = SystemClock.Instance.GetCurrentInstant();
// Generate unique gift code
var giftCode = await GenerateUniqueGiftCodeAsync();
// Calculate final price (with potential coupon discount)
var tempSubscription = new SnWalletSubscription
{
BasePrice = subscriptionInfo.BasePrice,
CouponId = couponData?.Id,
Coupon = couponData,
BegunAt = now // Need for price calculation
};
var finalPrice = tempSubscription.CalculateFinalPriceAt(now);
var gift = new SnWalletGift
{
GifterId = gifter.Id,
RecipientId = recipientId,
GiftCode = giftCode,
Message = message,
SubscriptionIdentifier = subscriptionIdentifier,
BasePrice = subscriptionInfo.BasePrice,
FinalPrice = finalPrice,
Status = DysonNetwork.Shared.Models.GiftStatus.Created,
ExpiresAt = now.Plus(giftDuration.Value),
IsOpenGift = !recipientId.HasValue,
PaymentMethod = paymentMethod,
PaymentDetails = paymentDetails,
CouponId = couponData?.Id,
CreatedAt = now,
UpdatedAt = now
};
db.WalletGifts.Add(gift);
await db.SaveChangesAsync();
// Create order and process payment
var order = await payment.CreateOrderAsync(
null, // No specific payee wallet for gifts
subscriptionInfo.Currency,
finalPrice,
appIdentifier: "gift",
productIdentifier: subscriptionIdentifier,
meta: new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString()
}
);
// If payment method is in-app wallet, process payment immediately
if (paymentMethod == SubscriptionPaymentMethod.InAppWallet)
{
var gifterWallet = await db.Wallets.FirstOrDefaultAsync(w => w.AccountId == gifter.Id);
if (gifterWallet == null)
throw new InvalidOperationException("Gifter wallet not found.");
await payment.PayOrderAsync(order.Id, gifterWallet);
// Mark gift as sent after successful payment
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
}
return gift;
}
/// <summary>
/// Activates a gift using the redemption code, creating a subscription for the redeemer.
/// </summary>
/// <param name="redeemer">The account redeeming the gift.</param>
/// <param name="giftCode">The unique redemption code.</param>
/// <returns>A tuple containing the activated gift and the created subscription.</returns>
public async Task<(SnWalletGift Gift, SnWalletSubscription Subscription)> RedeemGiftAsync(
SnAccount redeemer,
string giftCode)
{
var now = SystemClock.Instance.GetCurrentInstant();
// Find and validate the gift
var gift = await db.WalletGifts
.Include(g => g.Coupon) // Include coupon for price calculation
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
if (gift is null)
throw new InvalidOperationException("Gift code not found.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift is not available for redemption.");
if (now > gift.ExpiresAt)
throw new InvalidOperationException("Gift has expired.");
// Validate redeemer permissions
if (!gift.IsOpenGift && gift.RecipientId != redeemer.Id)
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)
throw new InvalidOperationException("Invalid gift subscription type.");
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(redeemer.Id, subscriptionsInGroup);
if (existingSubscription is not null)
throw new InvalidOperationException("You already have an active subscription of this type.");
// Check account level requirement
if (subscriptionInfo.RequiredLevel > 0)
{
var profile = await db.AccountProfiles.FirstOrDefaultAsync(p => p.AccountId == redeemer.Id);
if (profile is null || profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to redeem this gift.");
}
// Create the subscription from the gift
var cycleDuration = Duration.FromDays(30); // Standard 30-day subscription
var subscription = new SnWalletSubscription
{
BegunAt = now,
EndedAt = now.Plus(cycleDuration),
Identifier = gift.SubscriptionIdentifier,
IsActive = true,
IsFreeTrial = false,
Status = Shared.Models.SubscriptionStatus.Active,
PaymentMethod = $"gift:{gift.Id}", // Special payment method indicating gift redemption
PaymentDetails = new Shared.Models.SnPaymentDetails
{
Currency = "gift",
OrderId = gift.Id.ToString()
},
BasePrice = gift.BasePrice,
CouponId = gift.CouponId,
Coupon = gift.Coupon,
RenewalAt = now.Plus(cycleDuration),
AccountId = redeemer.Id,
GiftId = gift.Id
};
// Update the gift status
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Redeemed;
gift.RedeemedAt = now;
gift.RedeemerId = redeemer.Id;
gift.Subscription = subscription;
gift.UpdatedAt = now;
// Save both gift and subscription
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.WalletSubscriptions.Add(subscription);
db.WalletGifts.Update(gift);
await db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
// Send notification to redeemer
await NotifyGiftRedeemed(gift, subscription, redeemer);
// Send notification to gifter if different from redeemer
if (gift.GifterId != redeemer.Id)
{
var gifter = await db.Accounts.FirstOrDefaultAsync(a => a.Id == gift.GifterId);
if (gifter != null)
{
await NotifyGiftClaimedByRecipient(gift, subscription, gifter, redeemer);
}
}
return (gift, subscription);
}
/// <summary>
/// Retrieves a gift by its code (for redemption checking).
/// </summary>
public async Task<SnWalletGift?> GetGiftByCodeAsync(string giftCode)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Coupon)
.FirstOrDefaultAsync(g => g.GiftCode == giftCode);
}
/// <summary>
/// Retrieves gifts purchased by a specific account.
/// Only returns gifts that have been sent or processed (not created/unpaid ones).
/// </summary>
public async Task<List<SnWalletGift>> GetGiftsByGifterAsync(Guid gifterId)
{
return await db.WalletGifts
.Include(g => g.Recipient).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.GifterId == gifterId && g.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
public async Task<List<SnWalletGift>> GetGiftsByRecipientAsync(Guid recipientId)
{
return await db.WalletGifts
.Include(g => g.Gifter).ThenInclude(a => a.Profile)
.Include(g => g.Redeemer).ThenInclude(a => a.Profile)
.Include(g => g.Subscription)
.Where(g => g.RecipientId == recipientId || (g.IsOpenGift && g.RedeemerId == recipientId))
.OrderByDescending(g => g.CreatedAt)
.ToListAsync();
}
/// <summary>
/// Marks a gift as sent (ready for redemption).
/// </summary>
public async Task<SnWalletGift> MarkGiftAsSentAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created)
throw new InvalidOperationException("Gift cannot be marked as sent.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Sent;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
/// <summary>
/// Cancels a gift before it's redeemed.
/// </summary>
public async Task<SnWalletGift> CancelGiftAsync(Guid giftId, Guid gifterId)
{
var gift = await db.WalletGifts.FirstOrDefaultAsync(g => g.Id == giftId && g.GifterId == gifterId);
if (gift is null)
throw new InvalidOperationException("Gift not found or access denied.");
if (gift.Status != DysonNetwork.Shared.Models.GiftStatus.Created && gift.Status != DysonNetwork.Shared.Models.GiftStatus.Sent)
throw new InvalidOperationException("Gift cannot be cancelled.");
gift.Status = DysonNetwork.Shared.Models.GiftStatus.Cancelled;
gift.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
await db.SaveChangesAsync();
return gift;
}
private async Task<string> GenerateUniqueGiftCodeAsync()
{
const int maxAttempts = 10;
const int codeLength = 12;
for (int attempt = 0; attempt < maxAttempts; attempt++)
{
// Generate a random code
var code = GenerateRandomCode(codeLength);
// Check if it already exists
var existingGift = await db.WalletGifts.FirstOrDefaultAsync(g => g.GiftCode == code);
if (existingGift is null)
return code;
}
throw new InvalidOperationException("Unable to generate unique gift code.");
}
private static string GenerateRandomCode(int length)
{
const string chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
var result = new char[length];
for (int i = 0; i < length; i++)
{
result[i] = chars[Random.Shared.Next(chars.Length)];
}
return new string(result);
}
private async Task NotifyGiftRedeemed(SnWalletGift gift, SnWalletSubscription subscription, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(redeemer);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.redeemed",
Title = localizer["GiftRedeemedTitle"],
Body = localizer["GiftRedeemedBody", humanReadableName],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = redeemer.Id.ToString(),
Notification = notification
}
);
}
private async Task NotifyGiftClaimedByRecipient(SnWalletGift gift, SnWalletSubscription subscription, SnAccount gifter, SnAccount redeemer)
{
Account.AccountService.SetCultureInfo(gifter);
var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var notification = new PushNotification
{
Topic = "gifts.claimed",
Title = localizer["GiftClaimedTitle"],
Body = localizer["GiftClaimedBody", humanReadableName, redeemer.Name ?? redeemer.Id.ToString()],
Meta = GrpcTypeHelper.ConvertObjectToByteString(new Dictionary<string, object>
{
["gift_id"] = gift.Id.ToString(),
["subscription_id"] = subscription.Id.ToString(),
["redeemer_id"] = redeemer.Id.ToString()
}),
IsSavable = true
};
await pusher.SendPushNotificationToUserAsync(
new SendPushNotificationToUserRequest
{
UserId = gifter.Id.ToString(),
Notification = notification
}
);
}
} }

View File

@@ -9,7 +9,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=localhost;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=localhost;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
}, },
"Notifications": { "Notifications": {
"Push": { "Push": {
@@ -36,10 +36,7 @@
"GeoIp": { "GeoIp": {
"DatabasePath": "./Keys/GeoLite2-City.mmdb" "DatabasePath": "./Keys/GeoLite2-City.mmdb"
}, },
"KnownProxies": [ "KnownProxies": ["127.0.0.1", "::1"],
"127.0.0.1",
"::1"
],
"Service": { "Service": {
"Name": "DysonNetwork.Ring", "Name": "DysonNetwork.Ring",
"Url": "https://localhost:7259" "Url": "https://localhost:7259"

View File

@@ -58,6 +58,185 @@ public record class SubscriptionTypeData(
}; };
} }
/// <summary>
/// Represents a gifted subscription that can be claimed by another user.
/// Support both direct gifts (to specific users) and open gifts (anyone can redeem via link/code).
/// </summary>
[Index(nameof(GiftCode))]
[Index(nameof(GifterId))]
[Index(nameof(RecipientId))]
public class SnWalletGift : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// The user who purchased/gave the gift.
/// </summary>
public Guid GifterId { get; set; }
public SnAccount Gifter { get; set; } = null!;
/// <summary>
/// The intended recipient. Null for open gifts that anyone can redeem.
/// </summary>
public Guid? RecipientId { get; set; }
public SnAccount? Recipient { get; set; }
/// <summary>
/// Unique redemption code/link identifier for the gift.
/// </summary>
[MaxLength(128)]
public string GiftCode { get; set; } = null!;
/// <summary>
/// Optional custom message from the gifter.
/// </summary>
[MaxLength(1000)]
public string? Message { get; set; }
/// <summary>
/// The subscription type being gifted.
/// </summary>
[MaxLength(4096)]
public string SubscriptionIdentifier { get; set; } = null!;
/// <summary>
/// The original price before any discounts.
/// </summary>
public decimal BasePrice { get; set; }
/// <summary>
/// The final price paid after discounts.
/// </summary>
public decimal FinalPrice { get; set; }
/// <summary>
/// Current status of the gift.
/// </summary>
public GiftStatus Status { get; set; } = GiftStatus.Created;
/// <summary>
/// When the gift was redeemed. Null if not yet redeemed.
/// </summary>
public Instant? RedeemedAt { get; set; }
/// <summary>
/// The user who redeemed the gift (if different from recipient).
/// </summary>
public Guid? RedeemerId { get; set; }
public SnAccount? Redeemer { get; set; }
/// <summary>
/// The subscription created when the gift is redeemed.
/// </summary>
public SnWalletSubscription? Subscription { get; set; }
/// <summary>
/// When the gift expires and can no longer be redeemed.
/// </summary>
public Instant ExpiresAt { get; set; }
/// <summary>
/// Whether this gift can be redeemed by anyone (open gift) or only the specified recipient.
/// </summary>
public bool IsOpenGift { get; set; }
/// <summary>
/// Payment method used by the gifter.
/// </summary>
[MaxLength(4096)]
public string PaymentMethod { get; set; } = null!;
[Column(TypeName = "jsonb")]
public SnPaymentDetails PaymentDetails { get; set; } = null!;
/// <summary>
/// Coupon used for the gift purchase.
/// </summary>
public Guid? CouponId { get; set; }
public SnWalletCoupon? Coupon { get; set; }
/// <summary>
/// Checks if the gift can still be redeemed.
/// </summary>
[NotMapped]
public bool IsRedeemable
{
get
{
if (Status != GiftStatus.Sent) return false;
var now = SystemClock.Instance.GetCurrentInstant();
return now <= ExpiresAt;
}
}
/// <summary>
/// Checks if the gift has expired.
/// </summary>
[NotMapped]
public bool IsExpired
{
get
{
if (Status == GiftStatus.Redeemed || Status == GiftStatus.Cancelled) return false;
var now = SystemClock.Instance.GetCurrentInstant();
return now > ExpiresAt;
}
}
// TODO: Uncomment once protobuf files are regenerated
/*
public Proto.Gift ToProtoValue() => new()
{
Id = Id.ToString(),
GifterId = GifterId.ToString(),
RecipientId = RecipientId?.ToString(),
GiftCode = GiftCode,
Message = Message,
SubscriptionIdentifier = SubscriptionIdentifier,
BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
Status = (Proto.GiftStatus)Status,
RedeemedAt = RedeemedAt?.ToTimestamp(),
RedeemerId = RedeemerId?.ToString(),
SubscriptionId = SubscriptionId?.ToString(),
ExpiresAt = ExpiresAt.ToTimestamp(),
IsOpenGift = IsOpenGift,
PaymentMethod = PaymentMethod,
PaymentDetails = PaymentDetails.ToProtoValue(),
CouponId = CouponId?.ToString(),
Coupon = Coupon?.ToProtoValue(),
IsRedeemable = IsRedeemable,
IsExpired = IsExpired,
CreatedAt = CreatedAt.ToTimestamp(),
UpdatedAt = UpdatedAt.ToTimestamp()
};
public static SnWalletGift FromProtoValue(Proto.Gift proto) => new()
{
Id = Guid.Parse(proto.Id),
GifterId = Guid.Parse(proto.GifterId),
RecipientId = proto.HasRecipientId ? Guid.Parse(proto.RecipientId) : null,
GiftCode = proto.GiftCode,
Message = proto.Message,
SubscriptionIdentifier = proto.SubscriptionIdentifier,
BasePrice = decimal.Parse(proto.BasePrice),
FinalPrice = decimal.Parse(proto.FinalPrice),
Status = (GiftStatus)proto.Status,
RedeemedAt = proto.RedeemedAt?.ToInstant(),
RedeemerId = proto.HasRedeemerId ? Guid.Parse(proto.RedeemerId) : null,
SubscriptionId = proto.HasSubscriptionId ? Guid.Parse(proto.SubscriptionId) : null,
ExpiresAt = proto.ExpiresAt.ToInstant(),
IsOpenGift = proto.IsOpenGift,
PaymentMethod = proto.PaymentMethod,
PaymentDetails = SnPaymentDetails.FromProtoValue(proto.PaymentDetails),
CouponId = proto.HasCouponId ? Guid.Parse(proto.CouponId) : null,
Coupon = proto.Coupon is not null ? SnWalletCoupon.FromProtoValue(proto.Coupon) : null,
CreatedAt = proto.CreatedAt.ToInstant(),
UpdatedAt = proto.UpdatedAt.ToInstant()
};
*/
}
public abstract class SubscriptionType public abstract class SubscriptionType
{ {
/// <summary> /// <summary>
@@ -99,11 +278,24 @@ public enum SubscriptionStatus
Cancelled Cancelled
} }
public enum GiftStatus
{
Created = 0,
Sent = 1,
Redeemed = 2,
Expired = 3,
Cancelled = 4
}
/// <summary> /// <summary>
/// The subscription is for the Stellar Program in most cases. /// The subscription is for the Stellar Program in most cases.
/// The paid subscription in another word. /// The paid subscription in another word.
/// </summary> /// </summary>
[Index(nameof(Identifier))] [Index(nameof(Identifier))]
[Index(nameof(AccountId))]
[Index(nameof(Status))]
[Index(nameof(AccountId), nameof(Identifier))]
[Index(nameof(AccountId), nameof(IsActive))]
public class SnWalletSubscription : ModelBase public class SnWalletSubscription : ModelBase
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -142,40 +334,51 @@ public class SnWalletSubscription : ModelBase
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
public SnAccount Account { get; set; } = null!; public SnAccount Account { get; set; } = null!;
/// <summary>
/// If this subscription was redeemed from a gift, this references the gift record.
/// </summary>
public Guid? GiftId { get; set; }
public SnWalletGift? Gift { get; set; }
[NotMapped] [NotMapped]
public bool IsAvailable public bool IsAvailable
{ {
get get => IsAvailableAt(SystemClock.Instance.GetCurrentInstant());
{
if (!IsActive) return false;
var now = SystemClock.Instance.GetCurrentInstant();
if (BegunAt > now) return false;
if (EndedAt.HasValue && now > EndedAt.Value) return false;
if (RenewalAt.HasValue && now > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Active) return false;
return true;
}
} }
[NotMapped] [NotMapped]
public decimal FinalPrice public decimal FinalPrice
{ {
get get => CalculateFinalPriceAt(SystemClock.Instance.GetCurrentInstant());
{ }
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice;
var now = SystemClock.Instance.GetCurrentInstant(); /// <summary>
if (Coupon.AffectedAt.HasValue && now < Coupon.AffectedAt.Value || /// Optimized method to check availability at a specific instant (avoids repeated SystemClock calls).
Coupon.ExpiredAt.HasValue && now > Coupon.ExpiredAt.Value) return BasePrice; /// </summary>
public bool IsAvailableAt(Instant currentInstant)
{
if (!IsActive) return false;
if (BegunAt > currentInstant) return false;
if (EndedAt.HasValue && currentInstant > EndedAt.Value) return false;
if (RenewalAt.HasValue && currentInstant > RenewalAt.Value) return false;
if (Status != SubscriptionStatus.Active) return false;
return true;
}
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value; /// <summary>
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value); /// Optimized method to calculate final price at a specific instant (avoids repeated SystemClock calls).
return BasePrice; /// </summary>
} public decimal CalculateFinalPriceAt(Instant currentInstant)
{
if (IsFreeTrial) return 0;
if (Coupon == null) return BasePrice;
if (Coupon.AffectedAt.HasValue && currentInstant < Coupon.AffectedAt.Value ||
Coupon.ExpiredAt.HasValue && currentInstant > Coupon.ExpiredAt.Value) return BasePrice;
if (Coupon.DiscountAmount.HasValue) return BasePrice - Coupon.DiscountAmount.Value;
if (Coupon.DiscountRate.HasValue) return BasePrice * (decimal)(1 - Coupon.DiscountRate.Value);
return BasePrice;
} }
/// <summary> /// <summary>
@@ -184,6 +387,9 @@ public class SnWalletSubscription : ModelBase
/// </summary> /// </summary>
public SnSubscriptionReferenceObject ToReference() public SnSubscriptionReferenceObject ToReference()
{ {
// Cache the current instant once to avoid multiple SystemClock calls
var currentInstant = SystemClock.Instance.GetCurrentInstant();
return new SnSubscriptionReferenceObject return new SnSubscriptionReferenceObject
{ {
Id = Id, Id = Id,
@@ -191,11 +397,11 @@ public class SnWalletSubscription : ModelBase
BegunAt = BegunAt, BegunAt = BegunAt,
EndedAt = EndedAt, EndedAt = EndedAt,
IsActive = IsActive, IsActive = IsActive,
IsAvailable = IsAvailable, IsAvailable = IsAvailableAt(currentInstant),
IsFreeTrial = IsFreeTrial, IsFreeTrial = IsFreeTrial,
Status = Status, Status = Status,
BasePrice = BasePrice, BasePrice = BasePrice,
FinalPrice = FinalPrice, FinalPrice = CalculateFinalPriceAt(currentInstant),
RenewalAt = RenewalAt, RenewalAt = RenewalAt,
AccountId = AccountId AccountId = AccountId
}; };
@@ -263,11 +469,13 @@ public class SnSubscriptionReferenceObject : ModelBase
public Instant? RenewalAt { get; set; } public Instant? RenewalAt { get; set; }
public Guid AccountId { get; set; } public Guid AccountId { get; set; }
private string? _displayName;
/// <summary> /// <summary>
/// Gets the human-readable name of the subscription type if available. /// Gets the human-readable name of the subscription type if available (cached for performance).
/// </summary> /// </summary>
[NotMapped] [NotMapped]
public string? DisplayName => SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name) public string? DisplayName => _displayName ??= SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(Identifier, out var name)
? name ? name
: null; : null;
@@ -281,8 +489,8 @@ public class SnSubscriptionReferenceObject : ModelBase
IsAvailable = IsAvailable, IsAvailable = IsAvailable,
IsFreeTrial = IsFreeTrial, IsFreeTrial = IsFreeTrial,
Status = (Proto.SubscriptionStatus)Status, Status = (Proto.SubscriptionStatus)Status,
BasePrice = BasePrice.ToString(CultureInfo.CurrentCulture), BasePrice = BasePrice.ToString(CultureInfo.InvariantCulture),
FinalPrice = FinalPrice.ToString(CultureInfo.CurrentCulture), FinalPrice = FinalPrice.ToString(CultureInfo.InvariantCulture),
RenewalAt = RenewalAt?.ToTimestamp(), RenewalAt = RenewalAt?.ToTimestamp(),
AccountId = AccountId.ToString(), AccountId = AccountId.ToString(),
DisplayName = DisplayName, DisplayName = DisplayName,

View File

@@ -31,6 +31,16 @@ enum SubscriptionStatus {
SUBSCRIPTION_STATUS_CANCELLED = 4; SUBSCRIPTION_STATUS_CANCELLED = 4;
} }
enum GiftStatus {
// Using proto3 enum naming convention
GIFT_STATUS_UNSPECIFIED = 0;
GIFT_STATUS_CREATED = 1;
GIFT_STATUS_SENT = 2;
GIFT_STATUS_REDEEMED = 3;
GIFT_STATUS_EXPIRED = 4;
GIFT_STATUS_CANCELLED = 5;
}
message Subscription { message Subscription {
string id = 1; string id = 1;
google.protobuf.Timestamp begun_at = 2; google.protobuf.Timestamp begun_at = 2;
@@ -93,6 +103,31 @@ message Coupon {
google.protobuf.Timestamp updated_at = 10; google.protobuf.Timestamp updated_at = 10;
} }
message Gift {
string id = 1;
string gifter_id = 2;
optional string recipient_id = 3;
string gift_code = 4;
optional string message = 5;
string subscription_identifier = 6;
string base_price = 7;
string final_price = 8;
GiftStatus status = 9;
optional google.protobuf.Timestamp redeemed_at = 10;
optional string redeemer_id = 11;
optional string subscription_id = 12;
google.protobuf.Timestamp expires_at = 13;
bool is_open_gift = 14;
string payment_method = 15;
PaymentDetails payment_details = 16;
optional string coupon_id = 17;
optional Coupon coupon = 18;
bool is_redeemable = 19;
bool is_expired = 20;
google.protobuf.Timestamp created_at = 21;
google.protobuf.Timestamp updated_at = 22;
}
service WalletService { service WalletService {
rpc GetWallet(GetWalletRequest) returns (Wallet); rpc GetWallet(GetWalletRequest) returns (Wallet);
rpc CreateWallet(CreateWalletRequest) returns (Wallet); rpc CreateWallet(CreateWalletRequest) returns (Wallet);

View File

@@ -0,0 +1,345 @@
# Gift Subscriptions API Documentation
## Overview
The Gift Subscriptions feature allows users to purchase subscription gifts that can be redeemed by other users, enabling social gifting and subscription sharing within the DysonNetwork platform.
If you use it through the gateway, the `/api` should be replaced with the `/id`
### Key Features
- **Purchase Gifts**: Users can buy subscriptions as gifts for specific recipients or as open gifts
- **Gift Codes**: Each gift has a unique redemption code
- **Flexible Redemption**: Open gifts can be redeemed by anyone, while targeted gifts are recipient-specific
- **Security**: Prevents duplicate subscriptions and enforces account level requirements
- **Integration**: Full integration with existing subscription, coupon, and pricing systems
- **Clean User Experience**: Unpaid gifts are hidden from users and automatically cleaned up
- **Automatic Maintenance**: Old unpaid gifts are removed after 24 hours
## API Endpoints
All endpoints are authenticated and require a valid user session. The base path for gift endpoints is `/api/gifts`.
### 1. List Sent Gifts
Retrieve gifts you have purchased.
```http
GET /api/gifts/sent?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 2. List Received Gifts
Retrieve gifts sent to you or redeemed by you (for open gifts).
```http
GET /api/gifts/received?offset=0&take=20
Authorization: Bearer <token>
```
**Response**: Array of `SnWalletGift` objects
### 3. Get Specific Gift
Retrieve details for a specific gift.
```http
GET /api/gifts/{giftId}
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift
**Response**: `SnWalletGift` object
### 4. Check Gift Code
Validate if a gift code can be redeemed by the current user.
```http
GET /api/gifts/check/{giftCode}
Authorization: Bearer <token>
```
**Response**:
```json
{
"gift_code": "ABCD1234EFGH",
"subscription_identifier": "basic",
"can_redeem": true,
"error": null,
"message": "Happy birthday!"
}
```
### 5. Purchase a Gift
Create and purchase a gift subscription.
```http
POST /api/gifts/purchase
Authorization: Bearer <token>
Content-Type: application/json
{
"subscription_identifier": "premium",
"recipient_id": "550e8400-e29b-41d4-a716-446655440000", // Optional: null for open gifts
"payment_method": "in_app_wallet",
"payment_details": {
"currency": "irl"
},
"message": "Enjoy your premium subscription!", // Optional
"coupon": "SAVE20", // Optional
"gift_duration_days": 30, // Optional: defaults to 30
"subscription_duration_days": 30 // Optional: defaults to 30
}
```
**Response**: `SnWalletGift` object
### 6. Redeem a Gift
Redeem a gift code to create a subscription for yourself.
```http
POST /api/gifts/redeem
Authorization: Bearer <token>
Content-Type: application/json
{
"gift_code": "ABCD1234EFGH"
}
```
**Response**:
```json
{
"gift": { ... },
"subscription": { ... }
}
```
### 7. Mark Gift as Sent
Mark a gift as sent (ready for redemption).
```http
POST /api/gifts/{giftId}/send
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to mark as sent
### 8. Cancel a Gift
Cancel a gift before it has been redeemed.
```http
POST /api/gifts/{giftId}/cancel
Authorization: Bearer <token>
```
**Parameters**:
- `giftId`: GUID of the gift to cancel
## Usage Examples
### Client Implementation
Here are examples showing how to integrate gift subscriptions into your client application.
#### Example 1: Purchase a Gift for a Specific User
```javascript
async function purchaseGiftForFriend(subscriptionId, friendId, message) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
recipient_id: friendId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: message
})
});
const gift = await response.json();
return gift.gift_code; // Share this code with the friend
}
```
#### Example 2: Create an Open Gift
```javascript
async function createOpenGift(subscriptionId) {
const response = await fetch('/api/gifts/purchase', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
subscription_identifier: subscriptionId,
payment_method: 'in_app_wallet',
payment_details: { currency: 'irl' },
message: 'Redeem this anywhere!'
// No recipient_id makes it an open gift
})
});
const gift = await response.json();
// Mark as sent to make it redeemable
await markGiftAsSent(gift.id);
return gift;
}
```
#### Example 3: Redeem a Gift Code
```javascript
async function redeemGiftCode(giftCode) {
// First, check if the gift can be redeemed
const checkResponse = await fetch(`/api/gifts/check/${giftCode}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
const checkResult = await checkResponse.json();
if (!checkResult.canRedeem) {
throw new Error(checkResult.error);
}
// If valid, redeem it
const redeemResponse = await fetch('/api/gifts/redeem', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
gift_code: giftCode
})
});
const result = await redeemResponse.json();
return result.subscription; // The newly created subscription
}
```
#### Example 4: Display User's Gift History
```javascript
async function getGiftHistory() {
// Get gifts I sent
const sentResponse = await fetch('/api/gifts/sent', {
headers: { 'Authorization': `Bearer ${token}` }
});
const sentGifts = await sentResponse.json();
// Get gifts I received
const receivedResponse = await fetch('/api/gifts/received', {
headers: { 'Authorization': `Bearer ${token}` }
});
const receivedGifts = await receivedResponse.json();
return { sent: sentGifts, received: receivedGifts };
}
```
## Gift Status Lifecycle
Gifts follow this status lifecycle:
1. **Created**: Initially purchased, can be cancelled or marked as sent
- **Note**: Gifts in "Created" status are not visible to users and are automatically cleaned up after 24 hours if unpaid
2. **Sent**: Made available for redemption, can be cancelled
3. **Redeemed**: Successfully redeemed, creates a subscription
4. **Cancelled**: Permanently cancelled, refund may be processed
5. **Expired**: Expired without redemption
## Automatic Maintenance
The system includes automatic cleanup to maintain data integrity:
- **Unpaid Gift Cleanup**: Gifts that remain in "Created" status (unpaid) for more than 24 hours are automatically removed from the database
- **User Visibility**: Only gifts that have been successfully paid and sent are visible in user gift lists
- **Background Processing**: Cleanup runs hourly via scheduled jobs
This ensures a clean user experience while preventing accumulation of abandoned gift purchases.
## Validation Rules
### Purchase Validation
- Subscription must exist and be valid
- If coupon provided, it must be valid and applicable
- Recipient account must exist (if specified)
- User must meet level requirements for the subscription
### Redemption Validation
- Gift code must exist
- Gift must be in "Sent" status
- Gift must not be expired
- User must meet level requirements
- User must not already have an active subscription of the same type
- For targeted gifts, user must be the specified recipient
## Pricing & Payments
Gifts use the same pricing system as regular subscriptions:
- Base price from subscription template
- Coupon discounts applied
- Currency conversion as needed
- Payment processing through existing payment methods
## Notification Events
The system sends push notifications for:
- **gifts.redeemed**: When someone redeems your gift
- **gifts.claimed**: When the recipient redeems your targeted gift
Notifications include gift and subscription details for rich UI updates.
## Error Handling
Common error responses:
- `400 Bad Request`: Invalid parameters, validation failures
- `401 Unauthorized`: Missing or invalid authentication
- `403 Forbidden`: Insufficient permissions
- `404 Not Found`: Gift or subscription not found
- `409 Conflict`: Business logic violations (duplicate subscriptions, etc.)
## Integration Notes
### Database Schema
The feature adds a `wallet_gifts` table with relationships to:
- `accounts` (gifter, recipient, redeemer)
- `wallet_subscriptions` (created subscription)
- `wallet_coupons` (applied discounts)
### Backwards Compatibility
- No changes to existing subscription endpoints
- New gift-related endpoints are additive
- Existing payment flows remain unchanged
### Performance Considerations
- Gift codes are indexed for fast lookups
- Status filters optimize database queries
- Caching integrated with existing subscription caching
## Support
For implementation questions or issues, refer to the DysonNetwork API documentation or contact the development team.

View File

@@ -10,7 +10,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_network_dev;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=host.docker.internal;Port=5432;Database=dyson_develop;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
}, },
"KnownProxies": ["127.0.0.1", "::1"], "KnownProxies": ["127.0.0.1", "::1"],
"Etcd": { "Etcd": {

View File

@@ -9,7 +9,7 @@
}, },
"AllowedHosts": "*", "AllowedHosts": "*",
"ConnectionStrings": { "ConnectionStrings": {
"App": "Host=host.docker.internal;Port=5432;Database=dyson_pusher;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60" "App": "Host=host.docker.internal;Port=5432;Database=dyson_ring;Username=postgres;Password=postgres;Include Error Detail=True;Maximum Pool Size=20;Connection Idle Lifetime=60"
}, },
"Notifications": { "Notifications": {
"Push": { "Push": {