♻️ Refactored order handling
This commit is contained in:
		| @@ -14,7 +14,7 @@ public class BroadcastEventHandler( | ||||
| { | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken)) | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountDeletedEvent.Type, cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|   | ||||
							
								
								
									
										2021
									
								
								DysonNetwork.Pass/Migrations/20250904144723_AddOrderProductIdentifier.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
							
						
						
									
										2021
									
								
								DysonNetwork.Pass/Migrations/20250904144723_AddOrderProductIdentifier.Designer.cs
									
									
									
										generated
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -0,0 +1,29 @@ | ||||
| using Microsoft.EntityFrameworkCore.Migrations; | ||||
|  | ||||
| #nullable disable | ||||
|  | ||||
| namespace DysonNetwork.Pass.Migrations | ||||
| { | ||||
|     /// <inheritdoc /> | ||||
|     public partial class AddOrderProductIdentifier : Migration | ||||
|     { | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Up(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.AddColumn<string>( | ||||
|                 name: "product_identifier", | ||||
|                 table: "payment_orders", | ||||
|                 type: "character varying(4096)", | ||||
|                 maxLength: 4096, | ||||
|                 nullable: true); | ||||
|         } | ||||
|  | ||||
|         /// <inheritdoc /> | ||||
|         protected override void Down(MigrationBuilder migrationBuilder) | ||||
|         { | ||||
|             migrationBuilder.DropColumn( | ||||
|                 name: "product_identifier", | ||||
|                 table: "payment_orders"); | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -1381,6 +1381,11 @@ namespace DysonNetwork.Pass.Migrations | ||||
|                         .HasColumnType("uuid") | ||||
|                         .HasColumnName("payee_wallet_id"); | ||||
|  | ||||
|                     b.Property<string>("ProductIdentifier") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|                         .HasColumnName("product_identifier"); | ||||
|  | ||||
|                     b.Property<string>("Remarks") | ||||
|                         .HasMaxLength(4096) | ||||
|                         .HasColumnType("character varying(4096)") | ||||
|   | ||||
| @@ -170,5 +170,47 @@ namespace DysonNetwork.Sphere.Resources.Localization { | ||||
|                 return ResourceManager.GetString("NewLoginBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string FriendRequestTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("FriendRequestTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string FriendRequestBody { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("FriendRequestBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string OrderReceivedTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("OrderReceivedTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string OrderReceivedBody { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("OrderReceivedBody", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewTitle { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewTitle", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewBodyPlus { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewBodyPlus", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|          | ||||
|         internal static string TransactionNewBodyMinus { | ||||
|             get { | ||||
|                 return ResourceManager.GetString("TransactionNewBodyMinus", resourceCulture); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
|   | ||||
| @@ -78,7 +78,7 @@ | ||||
|         <value>Order {0} recipent</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidBody" xml:space="preserve"> | ||||
|        <value>{0} {1} was removed from your wallet to pay {2}</value> | ||||
|         <value>Paid order {2} with {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="NewLoginTitle" xml:space="preserve"> | ||||
|         <value>New login detected</value> | ||||
| @@ -92,4 +92,19 @@ | ||||
|     <data name="FriendRequestBody" xml:space="preserve"> | ||||
|         <value>You can go to relationships page and decide accept their request or not.</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedTitle" xml:space="preserve"> | ||||
|         <value>Order {0} recipent</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedBody" xml:space="preserve"> | ||||
|         <value>Received {2} payment of {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewTitle" xml:space="preserve"> | ||||
|         <value>Transaction {0}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyPlus" xml:space="preserve"> | ||||
|         <value>{0} {1} added to your wallet</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyMinus" xml:space="preserve"> | ||||
|         <value>{0} {1} removed from your wallet</value> | ||||
|     </data> | ||||
| </root> | ||||
| @@ -67,10 +67,10 @@ | ||||
|         <value>感谢你支持 Solar Network 的开发!你的 {0} 天 {1} 订阅刚刚开始,接下来来探索新解锁的新功能吧!</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidTitle" xml:space="preserve"> | ||||
|         <value>订单回执 {0}</value> | ||||
|         <value>订单收据 {0}</value> | ||||
|     </data> | ||||
|     <data name="OrderPaidBody" xml:space="preserve"> | ||||
|         <value>{0} {1} 已从你的帐户中扣除来支付 {2}</value> | ||||
|         <value>已支付订单 {2} 的 {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="NewLoginTitle" xml:space="preserve"> | ||||
|         <value>检测到新登陆</value> | ||||
| @@ -84,4 +84,19 @@ | ||||
|     <data name="FriendRequestBody" xml:space="preserve"> | ||||
|         <value>您可以前往人际关系页面来决定时候要接受他们的邀请。</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedTitle" xml:space="preserve"> | ||||
|         <value>订单收据 {0}</value> | ||||
|     </data> | ||||
|     <data name="OrderReceivedBody" xml:space="preserve"> | ||||
|         <value>收到订单 {2} 支付的 {0} {1}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewTitle" xml:space="preserve"> | ||||
|         <value>交易 {0}</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyPlus" xml:space="preserve"> | ||||
|         <value>{0} {1} 添加到了您的钱包</value> | ||||
|     </data> | ||||
|     <data name="TransactionNewBodyMinus" xml:space="preserve"> | ||||
|         <value>{0} {1} 从您的钱包移除</value> | ||||
|     </data> | ||||
| </root> | ||||
| @@ -4,6 +4,7 @@ using DysonNetwork.Pass.Auth; | ||||
| using DysonNetwork.Pass.Credit; | ||||
| using DysonNetwork.Pass.Leveling; | ||||
| using DysonNetwork.Pass.Permission; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using Microsoft.AspNetCore.HttpOverrides; | ||||
| using Microsoft.Extensions.FileProviders; | ||||
| using Prometheus; | ||||
| @@ -81,6 +82,8 @@ public static class ApplicationConfiguration | ||||
|         app.MapGrpcService<SocialCreditServiceGrpc>(); | ||||
|         app.MapGrpcService<ExperienceServiceGrpc>(); | ||||
|         app.MapGrpcService<BotAccountReceiverGrpc>(); | ||||
|         app.MapGrpcService<WalletServiceGrpc>(); | ||||
|         app.MapGrpcService<PaymentServiceGrpc>(); | ||||
|  | ||||
|         return app; | ||||
|     } | ||||
|   | ||||
							
								
								
									
										34
									
								
								DysonNetwork.Pass/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								DysonNetwork.Pass/Startup/BroadcastEventHandler.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,34 @@ | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Pass.Wallet; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using NATS.Client.Core; | ||||
|  | ||||
| namespace DysonNetwork.Pass.Startup; | ||||
|  | ||||
| public class BroadcastEventHandler( | ||||
|     INatsConnection nats, | ||||
|     ILogger<BroadcastEventHandler> logger, | ||||
|     IServiceProvider serviceProvider | ||||
| ) : BackgroundService | ||||
| { | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>(PaymentOrderEvent.Type, cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|                 var evt = JsonSerializer.Deserialize<PaymentOrderEvent>(msg.Data); | ||||
|  | ||||
|                 if (evt?.ProductIdentifier is null || !evt.ProductIdentifier.StartsWith(SubscriptionType.StellarProgram)) | ||||
|                     continue; | ||||
|                  | ||||
|                 logger.LogInformation("Stellar program order paid: {OrderId}", evt.OrderId); | ||||
|                  | ||||
|             } | ||||
|             catch (Exception ex) | ||||
|             { | ||||
|                 logger.LogError(ex, "Error processing AccountDeleted"); | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| } | ||||
| @@ -54,10 +54,6 @@ public static class ServiceCollectionExtensions | ||||
|          | ||||
|         services.AddPusherService(); | ||||
|          | ||||
|         // Register gRPC services | ||||
|         services.AddScoped<AccountServiceGrpc>(); | ||||
|         services.AddScoped<AuthServiceGrpc>(); | ||||
|  | ||||
|         // Register OIDC services | ||||
|         services.AddScoped<OidcService, GoogleOidcService>(); | ||||
|         services.AddScoped<OidcService, AppleOidcService>(); | ||||
|   | ||||
| @@ -15,9 +15,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | ||||
|         var order = await db.PaymentOrders.FindAsync(id); | ||||
|          | ||||
|         if (order == null) | ||||
|         { | ||||
|             return NotFound(); | ||||
|         } | ||||
|          | ||||
|         return Ok(order); | ||||
|     } | ||||
| @@ -41,7 +39,7 @@ public class OrderController(PaymentService payment, AuthService auth, AppDataba | ||||
|                 return BadRequest("Wallet was not found."); | ||||
|          | ||||
|             // Pay the order | ||||
|             var paidOrder = await payment.PayOrderAsync(id, wallet.Id); | ||||
|             var paidOrder = await payment.PayOrderAsync(id, wallet); | ||||
|             return Ok(paidOrder); | ||||
|         } | ||||
|         catch (InvalidOperationException ex) | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| using System.ComponentModel.DataAnnotations; | ||||
| using System.ComponentModel.DataAnnotations.Schema; | ||||
| using System.Globalization; | ||||
| using DysonNetwork.Shared.Data; | ||||
| using NodaTime; | ||||
| using NodaTime.Serialization.Protobuf; | ||||
| @@ -23,11 +24,14 @@ public enum OrderStatus | ||||
|  | ||||
| public class Order : ModelBase | ||||
| { | ||||
|     public const string InternalAppIdentifier = "internal"; | ||||
|      | ||||
|     public Guid Id { get; set; } = Guid.NewGuid(); | ||||
|     public OrderStatus Status { get; set; } = OrderStatus.Unpaid; | ||||
|     [MaxLength(128)] public string Currency { get; set; } = null!; | ||||
|     [MaxLength(4096)] public string? Remarks { get; set; } | ||||
|     [MaxLength(4096)] public string? AppIdentifier { get; set; } | ||||
|     [MaxLength(4096)] public string? ProductIdentifier { get; set; } | ||||
|     [Column(TypeName = "jsonb")] public Dictionary<string, object>? Meta { get; set; } | ||||
|     public decimal Amount { get; set; } | ||||
|     public Instant ExpiredAt { get; set; } | ||||
| @@ -44,10 +48,11 @@ public class Order : ModelBase | ||||
|         Currency = Currency, | ||||
|         Remarks = Remarks, | ||||
|         AppIdentifier = AppIdentifier, | ||||
|         ProductIdentifier = ProductIdentifier, | ||||
|         Meta = Meta == null | ||||
|             ? null | ||||
|             : Google.Protobuf.ByteString.CopyFrom(System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(Meta)), | ||||
|         Amount = Amount.ToString(), | ||||
|         Amount = Amount.ToString(CultureInfo.InvariantCulture), | ||||
|         ExpiredAt = ExpiredAt.ToTimestamp(), | ||||
|         PayeeWalletId = PayeeWalletId?.ToString(), | ||||
|         TransactionId = TransactionId?.ToString(), | ||||
| @@ -61,6 +66,7 @@ public class Order : ModelBase | ||||
|         Currency = proto.Currency, | ||||
|         Remarks = proto.Remarks, | ||||
|         AppIdentifier = proto.AppIdentifier, | ||||
|         ProductIdentifier = proto.ProductIdentifier, | ||||
|         Meta = proto.HasMeta | ||||
|             ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(proto.Meta.ToByteArray()) | ||||
|             : null, | ||||
|   | ||||
| @@ -1,9 +1,12 @@ | ||||
| using System.Globalization; | ||||
| using System.Text.Json; | ||||
| using DysonNetwork.Pass.Localization; | ||||
| using DysonNetwork.Shared.Proto; | ||||
| using DysonNetwork.Shared.Stream; | ||||
| using Microsoft.EntityFrameworkCore; | ||||
| using Microsoft.EntityFrameworkCore.Storage; | ||||
| using Microsoft.Extensions.Localization; | ||||
| using NATS.Client.Core; | ||||
| using NodaTime; | ||||
| using AccountService = DysonNetwork.Pass.Account.AccountService; | ||||
|  | ||||
| @@ -13,7 +16,8 @@ public class PaymentService( | ||||
|     AppDatabase db, | ||||
|     WalletService wat, | ||||
|     PusherService.PusherServiceClient pusher, | ||||
|     IStringLocalizer<NotificationResource> localizer | ||||
|     IStringLocalizer<NotificationResource> localizer, | ||||
|     INatsConnection nats | ||||
| ) | ||||
| { | ||||
|     public async Task<Order> CreateOrderAsync( | ||||
| @@ -22,6 +26,7 @@ public class PaymentService( | ||||
|         decimal amount, | ||||
|         Duration? expiration = null, | ||||
|         string? appIdentifier = null, | ||||
|         string? productIdentifier = null, | ||||
|         Dictionary<string, object>? meta = null, | ||||
|         bool reuseable = true | ||||
|     ) | ||||
| @@ -29,28 +34,27 @@ public class PaymentService( | ||||
|         // Check if there's an existing unpaid order that can be reused | ||||
|         if (reuseable && appIdentifier != null) | ||||
|         { | ||||
|             var now = SystemClock.Instance.GetCurrentInstant(); | ||||
|             var existingOrder = await db.PaymentOrders | ||||
|                 .Where(o => o.Status == OrderStatus.Unpaid && | ||||
|                             o.PayeeWalletId == payeeWalletId && | ||||
|                             o.Currency == currency && | ||||
|                             o.Amount == amount && | ||||
|                             o.AppIdentifier == appIdentifier && | ||||
|                             o.ExpiredAt > SystemClock.Instance.GetCurrentInstant()) | ||||
|                             o.ProductIdentifier == productIdentifier && | ||||
|                             o.ExpiredAt > now) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|  | ||||
|             // If an existing order is found, check if meta matches | ||||
|             if (existingOrder != null && meta != null && existingOrder.Meta != null) | ||||
|             { | ||||
|                 // Compare meta dictionaries - if they are equivalent, reuse the order | ||||
|                 // Compare the meta dictionary - if they are equivalent, reuse the order | ||||
|                 var metaMatches = existingOrder.Meta.Count == meta.Count && | ||||
|                                   !existingOrder.Meta.Except(meta).Any(); | ||||
|  | ||||
|                 if (metaMatches) | ||||
|                 { | ||||
|                     return existingOrder; | ||||
|             } | ||||
|         } | ||||
|         } | ||||
|  | ||||
|         // Create a new order if no reusable order was found | ||||
|         var order = new Order | ||||
| @@ -60,6 +64,7 @@ public class PaymentService( | ||||
|             Amount = amount, | ||||
|             ExpiredAt = SystemClock.Instance.GetCurrentInstant().Plus(expiration ?? Duration.FromHours(24)), | ||||
|             AppIdentifier = appIdentifier, | ||||
|             ProductIdentifier = productIdentifier, | ||||
|             Meta = meta | ||||
|         }; | ||||
|  | ||||
| @@ -104,7 +109,8 @@ public class PaymentService( | ||||
|         string currency, | ||||
|         decimal amount, | ||||
|         string? remarks = null, | ||||
|         TransactionType type = TransactionType.System | ||||
|         TransactionType type = TransactionType.System, | ||||
|         bool silent = false | ||||
|     ) | ||||
|     { | ||||
|         if (payerWalletId == null && payeeWalletId == null) | ||||
| @@ -121,8 +127,12 @@ public class PaymentService( | ||||
|             Type = type | ||||
|         }; | ||||
|  | ||||
|         Wallet? payerWallet = null, payeeWallet = null; | ||||
|          | ||||
|         if (payerWalletId.HasValue) | ||||
|         { | ||||
|             payerWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payerWalletId.Value); | ||||
|              | ||||
|             var (payerPocket, isNewlyCreated) = | ||||
|                 await wat.GetOrCreateWalletPocketAsync(payerWalletId.Value, currency); | ||||
|  | ||||
| @@ -137,6 +147,8 @@ public class PaymentService( | ||||
|  | ||||
|         if (payeeWalletId.HasValue) | ||||
|         { | ||||
|             payeeWallet = await db.Wallets.FirstOrDefaultAsync(e => e.AccountId == payeeWalletId.Value); | ||||
|              | ||||
|             var (payeePocket, isNewlyCreated) = | ||||
|                 await wat.GetOrCreateWalletPocketAsync(payeeWalletId.Value, currency, amount); | ||||
|  | ||||
| @@ -149,13 +161,85 @@ public class PaymentService( | ||||
|  | ||||
|         db.PaymentTransactions.Add(transaction); | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         if (!silent) | ||||
|             await NotifyNewTransaction(transaction, payerWallet, payeeWallet); | ||||
|          | ||||
|         return transaction; | ||||
|     } | ||||
|      | ||||
|     public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId) | ||||
|     private async Task NotifyNewTransaction(Transaction transaction, Wallet? payerWallet, Wallet? payeeWallet) | ||||
|     { | ||||
|         if (payerWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payerWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; | ||||
|  | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.transactions", | ||||
|                         Title = transaction.Amount > 0 | ||||
|                             ? localizer["TransactionNewBodyMinus", readableTransactionRemark] | ||||
|                             : localizer["TransactionNewBodyPlus", readableTransactionRemark], | ||||
|                         Body = localizer["TransactionNewTitle", | ||||
|                             transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             transaction.Currency], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|          | ||||
|         if (payeeWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payeeWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // Due to ID is uuid, it longer than 8 words for sure | ||||
|             var readableTransactionId = transaction.Id.ToString().Replace("-", "")[..8]; | ||||
|             var readableTransactionRemark = transaction.Remarks ?? $"#{readableTransactionId}"; | ||||
|  | ||||
|             await pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.transactions", | ||||
|                         Title = transaction.Amount > 0 | ||||
|                             ? localizer["TransactionNewBodyPlus", readableTransactionRemark] | ||||
|                             : localizer["TransactionNewBodyMinus", readableTransactionRemark], | ||||
|                         Body = localizer["TransactionNewTitle", | ||||
|                             transaction.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             transaction.Currency], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Order> PayOrderAsync(Guid orderId, Wallet payerWallet) | ||||
|     { | ||||
|         var order = await db.PaymentOrders | ||||
|             .Include(o => o.Transaction) | ||||
|             .Include(o => o.PayeeWallet) | ||||
|             .FirstOrDefaultAsync(o => o.Id == orderId); | ||||
|  | ||||
|         if (order == null) | ||||
| @@ -176,7 +260,7 @@ public class PaymentService( | ||||
|         } | ||||
|  | ||||
|         var transaction = await CreateTransactionAsync( | ||||
|             payerWalletId, | ||||
|             payerWallet.Id, | ||||
|             order.PayeeWalletId, | ||||
|             order.Currency, | ||||
|             order.Amount, | ||||
| @@ -189,15 +273,29 @@ public class PaymentService( | ||||
|  | ||||
|         await db.SaveChangesAsync(); | ||||
|  | ||||
|         await NotifyOrderPaid(order); | ||||
|         await NotifyOrderPaid(order, payerWallet, order.PayeeWallet); | ||||
|  | ||||
|         await nats.PublishAsync(PaymentOrderEvent.Type, JsonSerializer.SerializeToUtf8Bytes(new PaymentOrderEvent | ||||
|         { | ||||
|             OrderId = order.Id, | ||||
|             WalletId = payerWallet.Id, | ||||
|             AccountId = payerWallet.AccountId, | ||||
|             AppIdentifier = order.AppIdentifier, | ||||
|             ProductIdentifier = order.ProductIdentifier, | ||||
|             Meta = order.Meta ?? [], | ||||
|             Status = (int)order.Status, | ||||
|         })); | ||||
|  | ||||
|         return order; | ||||
|     } | ||||
|  | ||||
|     private async Task NotifyOrderPaid(Order order) | ||||
|     private async Task NotifyOrderPaid(Order order, Wallet? payerWallet, Wallet? payeeWallet) | ||||
|     { | ||||
|         if (order.PayeeWallet is null) return; | ||||
|         var account = await db.Accounts.FirstOrDefaultAsync(a => a.Id == order.PayeeWallet.AccountId); | ||||
|         if (payerWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payerWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
| @@ -215,7 +313,8 @@ public class PaymentService( | ||||
|                     { | ||||
|                         Topic = "wallets.orders.paid", | ||||
|                         Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||
|                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, | ||||
|                         Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             order.Currency, | ||||
|                             readableOrderRemark], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
| @@ -223,6 +322,37 @@ public class PaymentService( | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (payeeWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payeeWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             if (account is null) return; | ||||
|  | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|             // 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 pusher.SendPushNotificationToUserAsync( | ||||
|                 new SendPushNotificationToUserRequest | ||||
|                 { | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.orders.received", | ||||
|                         Title = localizer["OrderReceivedTitle", $"#{readableOrderId}"], | ||||
|                         Body = localizer["OrderReceivedBody", order.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             order.Currency, | ||||
|                             readableOrderRemark], | ||||
|                         IsSavable = true | ||||
|                     } | ||||
|                 } | ||||
|             ); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     public async Task<Order> CancelOrderAsync(Guid orderId) | ||||
|     { | ||||
|         var order = await db.PaymentOrders.FindAsync(orderId); | ||||
|   | ||||
| @@ -13,10 +13,10 @@ public class PaymentServiceGrpc(PaymentService paymentService) : Shared.Proto.Pa | ||||
|             request.Currency, | ||||
|             decimal.Parse(request.Amount), | ||||
|             request.Expiration is not null ? Duration.FromSeconds(request.Expiration.Seconds) : null, | ||||
|             request.HasAppIdentifier ? request.AppIdentifier : null, | ||||
|             // Assuming meta is a JSON string | ||||
|             request.HasAppIdentifier ? request.AppIdentifier : Order.InternalAppIdentifier, | ||||
|             request.HasProductIdentifier ? request.ProductIdentifier : null, | ||||
|             request.HasMeta | ||||
|                 ? System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(request.Meta.ToStringUtf8()) | ||||
|                 ? GrpcTypeHelper.ConvertByteStringToObject<Dictionary<string, object>>(request.Meta) | ||||
|                 : null, | ||||
|             request.Reuseable | ||||
|         ); | ||||
|   | ||||
| @@ -86,7 +86,7 @@ public class SubscriptionRenewalJob( | ||||
|                         if (wallet is null) continue; | ||||
|  | ||||
|                         // Process automatic payment from wallet | ||||
|                         await paymentService.PayOrderAsync(order.Id, wallet.Id); | ||||
|                         await paymentService.PayOrderAsync(order.Id, wallet); | ||||
|  | ||||
|                         // Update subscription details | ||||
|                         subscription.BegunAt = subscription.EndedAt!.Value; | ||||
|   | ||||
| @@ -8,11 +8,7 @@ public class WalletServiceGrpc(WalletService walletService) : Shared.Proto.Walle | ||||
|     public override async Task<Shared.Proto.Wallet> GetWallet(GetWalletRequest request, ServerCallContext context) | ||||
|     { | ||||
|         var wallet = await walletService.GetWalletAsync(Guid.Parse(request.AccountId)); | ||||
|         if (wallet == null) | ||||
|         { | ||||
|             throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")); | ||||
|         } | ||||
|         return wallet.ToProtoValue(); | ||||
|         return wallet == null ? throw new RpcException(new Status(StatusCode.NotFound, "Wallet not found.")) : wallet.ToProtoValue(); | ||||
|     } | ||||
|  | ||||
|     public override async Task<Shared.Proto.Wallet> CreateWallet(CreateWalletRequest request, ServerCallContext context) | ||||
|   | ||||
| @@ -123,6 +123,7 @@ message CreateOrderRequest { | ||||
|     string amount = 3; | ||||
|     optional google.protobuf.Duration expiration = 4; | ||||
|     optional string app_identifier = 5; | ||||
|     optional string product_identifier = 8; | ||||
|     // Using bytes for meta to represent JSON. | ||||
|     optional bytes meta = 6; | ||||
|     bool reuseable = 7; | ||||
| @@ -135,6 +136,7 @@ message Order { | ||||
|     string amount = 4; | ||||
|     google.protobuf.Timestamp expired_at = 5; | ||||
|     optional string app_identifier = 6; | ||||
|     optional string product_identifier = 12; | ||||
|     // Using bytes for meta to represent JSON. | ||||
|     optional bytes meta = 7; | ||||
|     OrderStatus status = 8; | ||||
|   | ||||
							
								
								
									
										14
									
								
								DysonNetwork.Shared/Stream/PaymentEvent.cs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								DysonNetwork.Shared/Stream/PaymentEvent.cs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| namespace DysonNetwork.Shared.Stream; | ||||
|  | ||||
| public class PaymentOrderEvent | ||||
| { | ||||
|     public static string Type => "payments.orders"; | ||||
|      | ||||
|     public Guid OrderId { get; set; } | ||||
|     public Guid WalletId { get; set; } | ||||
|     public Guid AccountId { get; set; } | ||||
|     public string? AppIdentifier { get; set; } | ||||
|     public string? ProductIdentifier { get; set; } | ||||
|     public Dictionary<string, object> Meta { get; set; } = null!; | ||||
|     public int Status { get; set; } | ||||
| } | ||||
| @@ -31,6 +31,7 @@ public class AppDatabase( | ||||
|  | ||||
|     public DbSet<Post.Post> Posts { get; set; } = null!; | ||||
|     public DbSet<PostReaction> PostReactions { get; set; } = null!; | ||||
|     public DbSet<PostAward> PostAwards { get; set; } = null!; | ||||
|     public DbSet<PostTag> PostTags { get; set; } = null!; | ||||
|     public DbSet<PostCategory> PostCategories { get; set; } = null!; | ||||
|     public DbSet<PostCollection> PostCollections { get; set; } = null!; | ||||
|   | ||||
| @@ -51,6 +51,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity | ||||
|     public int ViewsTotal { get; set; } | ||||
|     public int Upvotes { get; set; } | ||||
|     public int Downvotes { get; set; } | ||||
|     public decimal AwardedScore { get; set; } | ||||
|     [NotMapped] public Dictionary<string, int> ReactionsCount { get; set; } = new(); | ||||
|     [NotMapped] public int RepliesCount { get; set; } | ||||
|     [NotMapped] public Dictionary<string, bool>? ReactionsMade { get; set; } | ||||
| @@ -73,6 +74,7 @@ public class Post : ModelBase, IIdentifiedResource, IActivity | ||||
|     public Guid PublisherId { get; set; } | ||||
|     public Publisher.Publisher Publisher { get; set; } = null!; | ||||
|  | ||||
|     public ICollection<PostAward> Awards { get; set; } = null!; | ||||
|     public ICollection<PostReaction> Reactions { get; set; } = new List<PostReaction>(); | ||||
|     public ICollection<PostTag> Tags { get; set; } = new List<PostTag>(); | ||||
|     public ICollection<PostCategory> Categories { get; set; } = new List<PostCategory>(); | ||||
| @@ -168,3 +170,15 @@ public class PostReaction : ModelBase | ||||
|     [JsonIgnore] public Post Post { get; set; } = null!; | ||||
|     public Guid AccountId { get; set; } | ||||
| } | ||||
|  | ||||
| public class PostAward : ModelBase | ||||
| { | ||||
|     public Guid Id { get; set; } | ||||
|     public decimal Amount { get; set; } | ||||
|     public PostReactionAttitude Attitude { get; set; } | ||||
|     [MaxLength(4096)] public string? Message { get; set; } | ||||
|      | ||||
|     public Guid PostId { get; set; } | ||||
|     [JsonIgnore] public Post Post { get; set; } = null!; | ||||
|     public Guid AccountId { get; set; } | ||||
| } | ||||
| @@ -579,6 +579,38 @@ public class PostController( | ||||
|         return Ok(reaction); | ||||
|     } | ||||
|  | ||||
|     public class PostAwardRequest | ||||
|     { | ||||
|         public decimal Amount { get; set; } | ||||
|         [MaxLength(4096)] public string? Message { get; set; } | ||||
|     } | ||||
|  | ||||
|     [HttpPost("{id:guid}/awards")] | ||||
|     [Authorize] | ||||
|     public async Task<ActionResult<PostAward>> AwardPost(Guid id, [FromBody] PostAwardRequest request) | ||||
|     { | ||||
|         if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized(); | ||||
|  | ||||
|         var friendsResponse = | ||||
|             await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest | ||||
|                 { AccountId = currentUser.Id.ToString() }); | ||||
|         var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); | ||||
|         var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); | ||||
|  | ||||
|         var post = await db.Posts | ||||
|             .Where(e => e.Id == id) | ||||
|             .Include(e => e.Publisher) | ||||
|             .FilterWithVisibility(currentUser, userFriends, userPublishers) | ||||
|             .FirstOrDefaultAsync(); | ||||
|         if (post is null) return NotFound(); | ||||
|  | ||||
|         var accountId = Guid.Parse(currentUser.Id); | ||||
|          | ||||
|         // TODO Make payment, add record | ||||
|  | ||||
|         return Ok(); | ||||
|     } | ||||
|  | ||||
|     public class PostPinRequest | ||||
|     { | ||||
|         [Required] public PostPinMode Mode { get; set; } | ||||
| @@ -807,7 +839,7 @@ public class PostController( | ||||
|         if (post is null) return NotFound(); | ||||
|  | ||||
|         if (!await pub.IsMemberWithRole(post.Publisher.Id, Guid.Parse(currentUser.Id), | ||||
|                 Publisher.PublisherMemberRole.Editor)) | ||||
|                 PublisherMemberRole.Editor)) | ||||
|             return StatusCode(403, "You need at least be an editor to delete the publisher's post."); | ||||
|  | ||||
|         await ps.DeletePostAsync(post); | ||||
|   | ||||
| @@ -13,7 +13,7 @@ public class BroadcastEventHandler( | ||||
| { | ||||
|     protected override async Task ExecuteAsync(CancellationToken stoppingToken) | ||||
|     { | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>("accounts.deleted", cancellationToken: stoppingToken)) | ||||
|         await foreach (var msg in nats.SubscribeAsync<byte[]>(AccountDeletedEvent.Type, cancellationToken: stoppingToken)) | ||||
|         { | ||||
|             try | ||||
|             { | ||||
|   | ||||
		Reference in New Issue
	
	Block a user