Payment and subscription notification

This commit is contained in:
LittleSheep 2025-06-23 01:34:53 +08:00
parent 049a5c9b6f
commit feb612afcd
6 changed files with 143 additions and 16 deletions

View File

@ -134,5 +134,29 @@ namespace DysonNetwork.Sphere.Resources.Localization {
return ResourceManager.GetString("AuthCodeBody", resourceCulture); return ResourceManager.GetString("AuthCodeBody", resourceCulture);
} }
} }
internal static string SubscriptionAppliedTitle {
get {
return ResourceManager.GetString("SubscriptionAppliedTitle", resourceCulture);
}
}
internal static string SubscriptionAppliedBody {
get {
return ResourceManager.GetString("SubscriptionAppliedBody", resourceCulture);
}
}
internal static string OrderPaidTitle {
get {
return ResourceManager.GetString("OrderPaidTitle", resourceCulture);
}
}
internal static string OrderPaidBody {
get {
return ResourceManager.GetString("OrderPaidBody", resourceCulture);
}
}
} }
} }

View File

@ -63,4 +63,17 @@
<data name="AuthCodeBody" xml:space="preserve"> <data name="AuthCodeBody" xml:space="preserve">
<value>{0} is your disposable code, it will expires in 5 minutes</value> <value>{0} is your disposable code, it will expires in 5 minutes</value>
</data> </data>
<data name="SubscriptionAppliedTitle" xml:space="preserve">
Subscription {0} just activated for your account
</data>
<data name="SubscriptionAppliedBody" xml:space="preserve">
Thank for supporting the Solar Network! Your {0} days {1} subscription just begun,
feel free to explore the newly unlocked features!
</data>
<data name="OrderPaidTitle" xml:space="preserve">
Order {0} recipent
</data>
<data name="OrderPaidBody" xml:space="preserve">
{0} {1} was removed from your wallet to pay {2}
</data>
</root> </root>

View File

@ -56,4 +56,17 @@
<data name="AuthCodeBody" xml:space="preserve"> <data name="AuthCodeBody" xml:space="preserve">
<value>{0} 是你的一次性验证码,它将会在五分钟内过期</value> <value>{0} 是你的一次性验证码,它将会在五分钟内过期</value>
</data> </data>
<data name="SubscriptionAppliedTitle" xml:space="preserve">
{0} 的订阅激活成功
</data>
<data name="SubscriptionAppliedBody" xml:space="preserve">
感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,
接下来来探索新解锁的新功能吧!
</data>
<data name="OrderPaidTitle" xml:space="preserve">
订单回执 {0}
</data>
<data name="OrderPaidBody" xml:space="preserve">
{0} {1} 已从你的帐户中扣除来支付 {2}
</data>
</root> </root>

View File

@ -1,10 +1,19 @@
using System.Globalization;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage; using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
public class PaymentService(AppDatabase db, WalletService wat) public class PaymentService(
AppDatabase db,
WalletService wat,
NotificationService nty,
IStringLocalizer<NotificationResource> localizer
)
{ {
public async Task<Order> CreateOrderAsync( public async Task<Order> CreateOrderAsync(
Guid? payeeWalletId, Guid? payeeWalletId,
@ -21,11 +30,11 @@ public class PaymentService(AppDatabase db, WalletService wat)
{ {
var existingOrder = await db.PaymentOrders var existingOrder = await db.PaymentOrders
.Where(o => o.Status == OrderStatus.Unpaid && .Where(o => o.Status == OrderStatus.Unpaid &&
o.PayeeWalletId == payeeWalletId && o.PayeeWalletId == payeeWalletId &&
o.Currency == currency && o.Currency == currency &&
o.Amount == amount && o.Amount == amount &&
o.AppIdentifier == appIdentifier && o.AppIdentifier == appIdentifier &&
o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) o.ExpiredAt > SystemClock.Instance.GetCurrentInstant())
.FirstOrDefaultAsync(); .FirstOrDefaultAsync();
// If an existing order is found, check if meta matches // If an existing order is found, check if meta matches
@ -178,9 +187,36 @@ public class PaymentService(AppDatabase db, WalletService wat)
order.Status = OrderStatus.Paid; order.Status = OrderStatus.Paid;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifyOrderPaid(order);
return order; return order;
} }
private async Task NotifyOrderPaid(Order order)
{
if (order.PayeeWallet is null) return;
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId);
if (account is null) return;
// Due to ID is uuid, it longer than 8 words for sure
var readableOrderId = order.Id.ToString().Replace("-", "")[..8];
var readableOrderRemark = order.Remarks ?? $"#{readableOrderId}";
await nty.SendNotification(
account,
"wallets.orders.paid",
localizer["OrderPaidTitle", $"#{readableOrderId}"],
null,
localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency,
readableOrderRemark],
new Dictionary<string, object>()
{
["order_id"] = order.Id.ToString()
}
);
}
public async Task<Order> CancelOrderAsync(Guid orderId) public async Task<Order> CancelOrderAsync(Guid orderId)
{ {
var order = await db.PaymentOrders.FindAsync(orderId); var order = await db.PaymentOrders.FindAsync(orderId);

View File

@ -18,6 +18,15 @@ public record class SubscriptionTypeData(
[SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20), [SubscriptionType.Nova] = new SubscriptionTypeData(SubscriptionType.Nova, 20),
[SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30) [SubscriptionType.Supernova] = new SubscriptionTypeData(SubscriptionType.Supernova, 30)
}; };
public static readonly Dictionary<string, string> SubscriptionHumanReadable =
new()
{
[SubscriptionType.Twinkle] = "Stellar Program Twinkle",
[SubscriptionType.Stellar] = "Stellar Program",
[SubscriptionType.Nova] = "Stellar Program Nova",
[SubscriptionType.Supernova] = "Stellar Program Supernova"
};
} }
public abstract class SubscriptionType public abstract class SubscriptionType

View File

@ -1,8 +1,10 @@
using System.Text.Json; using System.Text.Json;
using DysonNetwork.Sphere.Account; using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Storage; using DysonNetwork.Sphere.Storage;
using DysonNetwork.Sphere.Wallet.PaymentHandlers; using DysonNetwork.Sphere.Wallet.PaymentHandlers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization;
using NodaTime; using NodaTime;
namespace DysonNetwork.Sphere.Wallet; namespace DysonNetwork.Sphere.Wallet;
@ -11,6 +13,8 @@ public class SubscriptionService(
AppDatabase db, AppDatabase db,
PaymentService payment, PaymentService payment,
AccountService accounts, AccountService accounts,
NotificationService nty,
IStringLocalizer<NotificationResource> localizer,
IConfiguration configuration, IConfiguration configuration,
ICacheService cache ICacheService cache
) )
@ -100,8 +104,6 @@ public class SubscriptionService(
if (afdianPlan?.Key is not null) subscriptionIdentifier = afdianPlan.Value.Key; if (afdianPlan?.Key is not null) subscriptionIdentifier = afdianPlan.Value.Key;
currency = "cny"; currency = "cny";
break; break;
default:
break;
} }
var subscriptionTemplate = SubscriptionTypeData var subscriptionTemplate = SubscriptionTypeData
@ -109,7 +111,8 @@ public class SubscriptionService(
? template ? template
: null; : null;
if (subscriptionTemplate is null) if (subscriptionTemplate is null)
throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier), $@"Subscription {subscriptionIdentifier} was not found."); throw new ArgumentOutOfRangeException(nameof(subscriptionIdentifier),
$@"Subscription {subscriptionIdentifier} was not found.");
Account.Account? account = null; Account.Account? account = null;
if (!string.IsNullOrEmpty(provider)) if (!string.IsNullOrEmpty(provider))
@ -124,7 +127,8 @@ public class SubscriptionService(
var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier); var existingSubscription = await GetSubscriptionAsync(account.Id, subscriptionIdentifier);
if (existingSubscription is not null && existingSubscription.PaymentMethod != provider) if (existingSubscription is not null && existingSubscription.PaymentMethod != provider)
throw new InvalidOperationException($"Active subscription with identifier {subscriptionIdentifier} already exists."); throw new InvalidOperationException(
$"Active subscription with identifier {subscriptionIdentifier} already exists.");
if (existingSubscription?.PaymentDetails.OrderId == order.Id) if (existingSubscription?.PaymentDetails.OrderId == order.Id)
return existingSubscription; return existingSubscription;
if (existingSubscription is not null) if (existingSubscription is not null)
@ -146,7 +150,7 @@ public class SubscriptionService(
BegunAt = order.BegunAt, BegunAt = order.BegunAt,
EndedAt = order.BegunAt.Plus(cycleDuration), EndedAt = order.BegunAt.Plus(cycleDuration),
IsActive = true, IsActive = true,
Status = SubscriptionStatus.Unpaid, Status = SubscriptionStatus.Paid,
PaymentMethod = provider, PaymentMethod = provider,
PaymentDetails = new PaymentDetails PaymentDetails = new PaymentDetails
{ {
@ -161,6 +165,8 @@ public class SubscriptionService(
db.WalletSubscriptions.Add(subscription); db.WalletSubscriptions.Add(subscription);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await NotifySubscriptionBegun(subscription);
return subscription; return subscription;
} }
@ -266,6 +272,8 @@ public class SubscriptionService(
.ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference())); .ExecuteUpdateAsync(s => s.SetProperty(a => a.StellarMembership, subscription.ToReference()));
} }
await NotifySubscriptionBegun(subscription);
return subscription; return subscription;
} }
@ -303,9 +311,33 @@ public class SubscriptionService(
return expiredSubscriptions.Count; return expiredSubscriptions.Count;
} }
private const string SubscriptionCacheKeyPrefix = "subscription:"; private async Task NotifySubscriptionBegun(Subscription subscription)
{
var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == subscription.AccountId);
if (account is null) return;
public AccountService Accounts { get; } = accounts; var humanReadableName =
SubscriptionTypeData.SubscriptionHumanReadable.TryGetValue(subscription.Identifier, out var humanReadable)
? humanReadable
: subscription.Identifier;
var duration = subscription.EndedAt is not null
? subscription.EndedAt.Value.Minus(subscription.BegunAt).ToString()
: "infinite";
await nty.SendNotification(
account,
"subscriptions.begun",
localizer["SubscriptionAppliedTitle", humanReadableName],
null,
localizer["SubscriptionAppliedBody", duration, humanReadableName],
new Dictionary<string, object>()
{
["subscription_id"] = subscription.Id.ToString(),
}
);
}
private const string SubscriptionCacheKeyPrefix = "subscription:";
public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier) public async Task<Subscription?> GetSubscriptionAsync(Guid accountId, string identifier)
{ {