♻️ Refactored order handling
This commit is contained in:
		| @@ -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,26 +34,25 @@ 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; | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @@ -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; | ||||
|     } | ||||
|      | ||||
|     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; | ||||
|  | ||||
|     public async Task<Order> PayOrderAsync(Guid orderId, Guid payerWalletId) | ||||
|             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, | ||||
| @@ -186,41 +270,87 @@ public class PaymentService( | ||||
|         order.TransactionId = transaction.Id; | ||||
|         order.Transaction = transaction; | ||||
|         order.Status = OrderStatus.Paid; | ||||
|          | ||||
|  | ||||
|         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 (account is null) return; | ||||
|          | ||||
|         AccountService.SetCultureInfo(account); | ||||
|         if (payerWallet is not null) | ||||
|         { | ||||
|             var account = await db.Accounts | ||||
|                 .Where(a => a.Id == payerWallet.AccountId) | ||||
|                 .FirstOrDefaultAsync(); | ||||
|             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}"; | ||||
|             AccountService.SetCultureInfo(account); | ||||
|  | ||||
|          | ||||
|         await pusher.SendPushNotificationToUserAsync( | ||||
|             new SendPushNotificationToUserRequest | ||||
|             { | ||||
|                 UserId = account.Id.ToString(), | ||||
|                 Notification = new PushNotification | ||||
|             // 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 | ||||
|                 { | ||||
|                     Topic = "wallets.orders.paid", | ||||
|                     Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||
|                     Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), order.Currency, | ||||
|                         readableOrderRemark], | ||||
|                     IsSavable = true | ||||
|                     UserId = account.Id.ToString(), | ||||
|                     Notification = new PushNotification | ||||
|                     { | ||||
|                         Topic = "wallets.orders.paid", | ||||
|                         Title = localizer["OrderPaidTitle", $"#{readableOrderId}"], | ||||
|                         Body = localizer["OrderPaidBody", order.Amount.ToString(CultureInfo.InvariantCulture), | ||||
|                             order.Currency, | ||||
|                             readableOrderRemark], | ||||
|                         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 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) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
		Reference in New Issue
	
	Block a user