✨ Subscription gifts
This commit is contained in:
@@ -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();
|
||||||
|
|
||||||
|
@@ -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"
|
||||||
},
|
},
|
||||||
|
@@ -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!;
|
||||||
@@ -278,4 +279,4 @@ public static class OptionalQueryExtensions
|
|||||||
{
|
{
|
||||||
return condition ? transform(source) : source;
|
return condition ? transform(source) : source;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
24
DysonNetwork.Pass/DysonNetwork.Pass.sln
Normal file
24
DysonNetwork.Pass/DysonNetwork.Pass.sln
Normal 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
|
2207
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.Designer.cs
generated
Normal file
2207
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
1142
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.cs
Normal file
1142
DysonNetwork.Pass/Migrations/20251003061315_AddSubscriptionGift.cs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||||
{
|
{
|
||||||
|
@@ -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);
|
||||||
|
|
||||||
|
40
DysonNetwork.Pass/Wallet/GiftCleanupJob.cs
Normal file
40
DysonNetwork.Pass/Wallet/GiftCleanupJob.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -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"
|
||||||
|
@@ -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,
|
||||||
@@ -401,4 +609,4 @@ public class SnWalletCoupon : ModelBase
|
|||||||
CreatedAt = proto.CreatedAt.ToInstant(),
|
CreatedAt = proto.CreatedAt.ToInstant(),
|
||||||
UpdatedAt = proto.UpdatedAt.ToInstant()
|
UpdatedAt = proto.UpdatedAt.ToInstant()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@@ -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);
|
||||||
|
345
README_GIFT_SUBSCRIPTIONS.md
Normal file
345
README_GIFT_SUBSCRIPTIONS.md
Normal 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.
|
@@ -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": {
|
||||||
|
@@ -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": {
|
||||||
|
Reference in New Issue
Block a user