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

@@ -42,6 +42,7 @@ public class SubscriptionService(
: null;
if (subscriptionInfo is null)
throw new ArgumentOutOfRangeException(nameof(identifier), $@"Subscription {identifier} was not found.");
var subscriptionsInGroup = subscriptionInfo.GroupIdentifier is not null
? SubscriptionTypeData.SubscriptionDict
.Where(s => s.Value.GroupIdentifier == subscriptionInfo.GroupIdentifier)
@@ -57,36 +58,42 @@ public class SubscriptionService(
if (existingSubscription is not null)
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)
{
var profile = await db.AccountProfiles
.Where(p => p.AccountId == account.Id)
.FirstOrDefaultAsync();
if (profile is null) throw new InvalidOperationException("Account profile was not found.");
if (profile is null)
throw new InvalidOperationException("Account profile was not found.");
if (profile.Level < subscriptionInfo.RequiredLevel)
throw new InvalidOperationException(
$"Account level must be at least {subscriptionInfo.RequiredLevel} to subscribe to {identifier}."
);
}
if (isFreeTrial)
{
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.");
}
if (isFreeTrial && prevFreeTrial != null)
throw new InvalidOperationException("Free trial already exists.");
SnWalletCoupon? couponData = null;
if (coupon is not null)
{
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.");
}
if (coupon != null && couponData is null)
throw new InvalidOperationException($"Coupon {coupon} was not found.");
var now = SystemClock.Instance.GetCurrentInstant();
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)
{
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)
{
var now = SystemClock.Instance.GetCurrentInstant();
var cycle = subscription.BegunAt.Minus(subscription.RenewalAt ?? subscription.EndedAt ?? now);
// Calculate original cycle duration and extend from the current ended date
Duration originalCycle = subscription.EndedAt.Value - subscription.BegunAt;
var nextRenewalAt = subscription.RenewalAt?.Plus(cycle);
var nextEndedAt = subscription.EndedAt?.Plus(cycle);
subscription.RenewalAt = nextRenewalAt;
subscription.EndedAt = nextEndedAt;
subscription.RenewalAt = subscription.RenewalAt.HasValue ? subscription.RenewalAt.Value.Plus(originalCycle) : subscription.EndedAt.Value.Plus(originalCycle);
subscription.EndedAt = subscription.EndedAt.Value.Plus(originalCycle);
}
subscription.Status = Shared.Models.SubscriptionStatus.Active;
@@ -305,6 +344,36 @@ public class SubscriptionService(
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>
/// Updates the status of expired subscriptions to reflect their current state.
/// This helps maintain accurate subscription records and is typically called periodically.
@@ -326,16 +395,19 @@ public class SubscriptionService(
if (expiredSubscriptions.Count == 0)
return 0;
// Mark as expired
foreach (var subscription in expiredSubscriptions)
{
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();
// 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;
}
@@ -379,10 +451,11 @@ public class SubscriptionService(
public async Task<SnWalletSubscription?> GetSubscriptionAsync(Guid accountId, params string[] identifiers)
{
// Create a unique cache key for this subscription
var hashBytes = MD5.HashData(Encoding.UTF8.GetBytes(string.Join(",", identifiers)));
var hashIdentifier = Convert.ToHexStringLower(hashBytes);
var identifierPart = identifiers.Length == 1
? 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
var (found, cachedSubscription) = await cache.GetAsyncWithStatus<SnWalletSubscription>(cacheKey);
@@ -443,17 +516,24 @@ public class SubscriptionService(
var missingAccountIds = new List<Guid>();
// Try to get the subscription from cache first
foreach (var accountId in accountIds)
var cacheTasks = accountIds.Select(async accountId =>
{
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{accountId}";
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)
result[accountId] = cachedSubscription;
else
missingAccountIds.Add(accountId);
}
if (missingAccountIds.Count <= 0) return result;
if (missingAccountIds.Count == 0) return result;
// If not in cache, get from database
var now = SystemClock.Instance.GetCurrentInstant();
@@ -464,18 +544,443 @@ public class SubscriptionService(
.Where(s => s.EndedAt == null || s.EndedAt > now)
.OrderByDescending(s => s.BegunAt)
.ToListAsync();
subscriptions = subscriptions.Where(s => s.IsAvailable).ToList();
// Group the subscriptions by account id
foreach (var subscription in subscriptions)
// Group by account and select latest available subscription
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;
// Cache the result if found (with 30 minutes expiry)
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{subscription.AccountId}";
await cache.SetAsync(cacheKey, subscription, TimeSpan.FromMinutes(30));
result[kvp.Key] = kvp.Value;
var cacheKey = $"{SubscriptionPerkCacheKeyPrefix}{kvp.Key}";
cacheSetTasks.Add(cache.SetAsync(cacheKey, kvp.Value, TimeSpan.FromMinutes(30)));
}
await Task.WhenAll(cacheSetTasks);
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
}
);
}
}